diff --git a/.github/workflows/rust-lite-ci.yml b/.github/workflows/rust-lite-ci.yml index 8d9683e..b008bd6 100644 --- a/.github/workflows/rust-lite-ci.yml +++ b/.github/workflows/rust-lite-ci.yml @@ -5,6 +5,9 @@ on: paths: - "rust/**" - "lite-rs/**" + - "src/**" + - "lite/**" + - "herdr-plugin.toml" - "docs/rust-lite-parity-proof.md" - "scripts/parity-lite.py" - "scripts/parity-lite.sh" diff --git a/README.md b/README.md index 884f027..fb58a70 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,12 @@ type = "plugin_action" command = "annotate.copy-context" description = "copy annotations as context" +[[keys.command]] +key = "prefix+ctrl+a" +type = "plugin_action" +command = "annotate.copy-archive" +description = "copy annotations as context and archive them" + [[keys.command]] key = "prefix+m" type = "plugin_action" @@ -106,6 +112,12 @@ type = "plugin_action" command = "annotate.copy-context" description = "copy annotations as context" +[[keys.command]] +key = "prefix+ctrl+a" +type = "plugin_action" +command = "annotate.copy-archive" +description = "copy annotations as context and archive them" + [[keys.command]] key = "prefix+m" type = "plugin_action" @@ -130,6 +142,7 @@ herdr server reload-config |---|---| | `Ctrl+B A` | comment on the selected text · `Ctrl+S` saves | | `Ctrl+B Shift+A` | copy all annotations as Markdown | +| `Ctrl+B Ctrl+A` | copy all annotations as Markdown, then archive them | | `Ctrl+B M` | manage · `y` copy one · `c` copy all · `Shift+C` copy and archive · `Tab` archives (`y` copy · `u` restore · `d d` delete) | ### Review documents and agent replies diff --git a/docs/rust-lite-parity-proof.md b/docs/rust-lite-parity-proof.md index 18ee937..e7d8e48 100644 --- a/docs/rust-lite-parity-proof.md +++ b/docs/rust-lite-parity-proof.md @@ -5,7 +5,7 @@ TypeScript Lite surface to the Rust call path at function granularity and names case that compares the result. The current local result is: ```text -Parity Lite: 401 observables compared, 92 screens diffed, zero divergences / 1 deliberate +Parity Lite: 459 observables compared, 92 screens diffed, zero divergences / 1 deliberate ``` Run it from the repository root with `bash scripts/parity-lite.sh`. The shell wrapper stages a fresh @@ -17,21 +17,24 @@ diffs and prints their path. A green run removes its temporary workspace. - Process cases compare exit code, stdout, stderr, every fake `herdr`/clipboard command and argument, notification arguments, clipboard bytes, pending bytes and mode, and resulting filesystem trees. +- Manifest cases parse `lite/herdr-plugin.toml`, `lite-rs/herdr-plugin.toml`, and the root Full + manifest and compare the declared entrypoints field by field, so a new action cannot reach one + runtime only. - Screen cases use real PTYs at 86×22 (editor) and 98×28 (manager). The ANSI parser at - `scripts/parity-lite.py:146` ignores style escapes but retains the terminal cell grid, including + `scripts/parity-lite.py:156` ignores style escapes but retains the terminal cell grid, including wide-character continuation cells. It snapshots the initial frame and the frame after every input. - Store cases byte-compare JSONL, modes, and leftover lock/temp files after scripted editor and manager mutations. `store.cross-read` makes Bun export the Rust editor's store and Rust export the Bun editor's store, then compares the Markdown and subprocess traces. - The only normalized values are each case's deliberately different temporary root, generated UUIDs, generated ISO timestamps, and pid/time components in pending and temporary filenames - (`scripts/parity-lite.py:446`). Seed timestamps are allow-listed and remain literal. Product files + (`scripts/parity-lite.py:459`). Seed timestamps are allow-listed and remain literal. Product files are never rewritten or filtered. Screen cells and clipboard bytes are never normalized. - The harness runs on the host's real adapter branch. macOS therefore proves `pbpaste`/`pbcopy` and Ubuntu proves the Wayland → xclip → xsel chain. Windows is intentionally outside this PTY harness and remains in the separate build/promotion track. -The parity surface is the five commands wired by the Lite manifest. Rust's single-binary dispatch +The parity surface is the six commands wired by the Lite manifest. Rust's single-binary dispatch usage error and `--version` output are packaging controls outside that surface; no valid manifest invocation reaches them. Conversely, TypeScript dynamic-import loader failures have no native counterpart because those modules are linked into the binary. Their underlying clipboard/store @@ -43,13 +46,13 @@ failures do have mapped counterparts below. | Observable decision or effect | TypeScript call path | Rust call path | Mechanical evidence | |---|---|---|---| -| Manifest invocation | `lite/herdr-plugin.toml:17` starts `bun ../src/capture.ts`. | `lite-rs/herdr-plugin.toml:27` starts `herdr-annotate capture`; `rust/src/main.rs:3` passes argv to `cli::run` at `rust/src/cli.rs:24`, then `capture` at `rust/src/cli.rs:55`. | Every `process.capture.*` case. | -| Context decode | Top-level `src/capture.ts:12` parses `HERDR_PLUGIN_CONTEXT_JSON`; invalid JSON falls back to an empty object. `parseInvocationContext` and `selectedTextFromInvocation` are `src/types.ts:44` and `src/types.ts:58`. | `invocation_context` at `rust/src/cli.rs:48`, then `parse_invocation_context` and `selected_text_from_invocation` at `rust/src/types.rs:85` and `rust/src/types.rs:101`. | `process.capture.context`, `process.capture.invalid-context`. | -| Required paths | `stateDir`/`pluginRoot` at `src/paths.ts:27` and `src/paths.ts:32`, checked at `src/capture.ts:21`. | `state_dir`/`plugin_root` at `rust/src/paths.rs:29` and `rust/src/paths.rs:36`, checked at `rust/src/cli.rs:59`. | `process.capture.missing-state`, `process.capture.missing-root`. | -| Selection precedence | Invocation selection at `src/capture.ts:15`; if absent, `takeHandoff` at `src/handoff.ts:24`; if absent/blank/stale, `readClipboard` at `src/clipboard.ts:48`. | The same branches at `rust/src/cli.rs:61`, through `take_default_handoff`/`take_handoff` at `rust/src/handoff.rs:60`/`:32`, then `read_clipboard` at `rust/src/clipboard.rs:93`. | `process.capture.context` seeds all three sources and leaves the skipped handoff untouched; `process.capture.handoff` seeds a competing clipboard; stale, blank, invalid-UTF-8, and clipboard cases exercise the remaining decisions. | -| Empty selection | `src/capture.ts:38` sends `Nothing to annotate`, creates no pending file, exits 0. | `rust/src/cli.rs:68` sends the same notification and returns success. | `process.capture.empty`. | -| Pending record | `src/capture.ts:42` creates the state directory; `:44` constructs the record; `:49` names it; `:52` writes it. | `rust/src/cli.rs:75` constructs it; `:82` names it; `write_pending` at `:166` writes it. | All successful capture cases compare literal JSON after generated time/name normalization and assert mode 0600. | -| Editor pane | `runHerdr` at `src/herdr.ts:11` receives the argv built at `src/capture.ts:54`. Failure removes pending at `:74`, then the catch at `:78` notifies, prints, and exits 1. | `run_herdr` at `rust/src/herdr.rs:22` receives the argv at `rust/src/cli.rs:87`. Failure removes pending at `:107`; `cli::run` notifies at `:26`; `main` prints/exits 1 at `rust/src/main.rs:3`. | `process.capture.context` compares success argv; `process.capture.open-failure` compares cleanup, notification, stderr, and exit. | +| Manifest invocation | `lite/herdr-plugin.toml:17` starts `bun ../src/capture.ts`. | `lite-rs/herdr-plugin.toml:27` starts `herdr-annotate capture`; `rust/src/main.rs:3` passes argv to `cli::run` at `rust/src/cli.rs:33`, then `capture` at `rust/src/cli.rs:65`. | Every `process.capture.*` case. | +| Context decode | Top-level `src/capture.ts:12` parses `HERDR_PLUGIN_CONTEXT_JSON`; invalid JSON falls back to an empty object. `parseInvocationContext` and `selectedTextFromInvocation` are `src/types.ts:44` and `src/types.ts:58`. | `invocation_context` at `rust/src/cli.rs:58`, then `parse_invocation_context` and `selected_text_from_invocation` at `rust/src/types.rs:85` and `rust/src/types.rs:101`. | `process.capture.context`, `process.capture.invalid-context`. | +| Required paths | `stateDir`/`pluginRoot` at `src/paths.ts:27` and `src/paths.ts:32`, checked at `src/capture.ts:21`. | `state_dir`/`plugin_root` at `rust/src/paths.rs:29` and `rust/src/paths.rs:36`, checked at `rust/src/cli.rs:69`. | `process.capture.missing-state`, `process.capture.missing-root`. | +| Selection precedence | Invocation selection at `src/capture.ts:15`; if absent, `takeHandoff` at `src/handoff.ts:24`; if absent/blank/stale, `readClipboard` at `src/clipboard.ts:48`. | The same branches at `rust/src/cli.rs:71`, through `take_default_handoff`/`take_handoff` at `rust/src/handoff.rs:60`/`:32`, then `read_clipboard` at `rust/src/clipboard.rs:93`. | `process.capture.context` seeds all three sources and leaves the skipped handoff untouched; `process.capture.handoff` seeds a competing clipboard; stale, blank, invalid-UTF-8, and clipboard cases exercise the remaining decisions. | +| Empty selection | `src/capture.ts:38` sends `Nothing to annotate`, creates no pending file, exits 0. | `rust/src/cli.rs:78` sends the same notification and returns success. | `process.capture.empty`. | +| Pending record | `src/capture.ts:42` creates the state directory; `:44` constructs the record; `:49` names it; `:52` writes it. | `rust/src/cli.rs:85` constructs it; `:92` names it; `write_pending` at `:247` writes it. | All successful capture cases compare literal JSON after generated time/name normalization and assert mode 0600. | +| Editor pane | `runHerdr` at `src/herdr.ts:11` receives the argv built at `src/capture.ts:54`. Failure removes pending at `:74`, then the catch at `:78` notifies, prints, and exits 1. | `run_herdr` at `rust/src/herdr.rs:22` receives the argv at `rust/src/cli.rs:97`. Failure removes pending at `:117`; `cli::run` notifies at `:35`; `main` prints/exits 1 at `rust/src/main.rs:3`. | `process.capture.context` compares success argv; `process.capture.open-failure` compares cleanup, notification, stderr, and exit. | The structural difference is exception flow versus `Result`. Both converge on the same process contract: success and blank input exit 0; a defined failure produces one stderr line, one best-effort @@ -59,24 +62,35 @@ contract: success and blank input exit 0; a defined failure produces one stderr | Observable decision or effect | TypeScript call path | Rust call path | Mechanical evidence | |---|---|---|---| -| Manifest and state | `lite/herdr-plugin.toml:24` → top-level `src/export.ts:7`; state is required at `:8`. | `lite-rs/herdr-plugin.toml:34` → `rust/src/main.rs:3` → `cli::run` at `rust/src/cli.rs:24` → `copy_context` at `:114`. | Every `process.copy.*` case. | -| Load and ordering | `loadAnnotations` at `src/store.ts:37` locks/parses; `newestFirstAnnotations` at `:32` reverses a copy; `src/export.ts:10` propagates load failure. | `load_annotations` at `rust/src/store.rs:70` locks/parses; `newest_first_annotations` at `:65` reverses clones; `rust/src/cli.rs:116` propagates failure. | Empty, populated, invalid-store, busy-lock, and stale-lock cases. | -| Empty store | `src/export.ts:14` notifies `No annotations` / `There is nothing to copy yet.` and exits 0. | `rust/src/cli.rs:117` sends the same notification and returns success. | `process.copy.empty`, including the newly created state-directory mode. | +| Manifest and state | `lite/herdr-plugin.toml:24` → top-level `src/export.ts:7`; state is required at `:8`. | `lite-rs/herdr-plugin.toml:34` → `rust/src/main.rs:3` → `cli::run` at `rust/src/cli.rs:33` → `copy_context` at `:124`. | Every `process.copy.*` case. | +| Load and ordering | `loadAnnotations` at `src/store.ts:37` locks/parses; `newestFirstAnnotations` at `:32` reverses a copy; `src/export.ts:10` propagates load failure. | `load_annotations` at `rust/src/store.rs:70` locks/parses; `newest_first_annotations` at `:65` reverses clones; `rust/src/cli.rs:126` propagates failure. | Empty, populated, invalid-store, busy-lock, and stale-lock cases. | +| Empty store | `src/export.ts:14` notifies `No annotations` / `There is nothing to copy yet.` and exits 0. | `rust/src/cli.rs:127` sends the same notification and returns success. | `process.copy.empty`, including the newly created state-directory mode. | | Markdown and clipboard | `formatAnnotations` at `src/format.ts:44`, then `writeClipboard` at `src/clipboard.ts:63`. | `format_annotations` at `rust/src/format.rs:67`, then `write_clipboard` at `rust/src/clipboard.rs:110`. | `process.copy.single`, `process.copy.populated`, `process.copy.no-clipboard`, and `store.cross-read`; clipboard bytes are unnormalized. | -| Success/failure reporting | `src/export.ts:21` sends singular/plural `Annotations copied`; catch at `:25` sends `Copy failed`, prints, exits 1. | `rust/src/cli.rs:122` sends the same success notification; `cli::run` at `:29` sends `Copy failed`; `main` prints/exits 1. | Single and populated cases prove grammar; no-clipboard, invalid-store, busy-lock, and missing-state prove failure outputs and exits. | +| Success/failure reporting | `src/export.ts:21` sends singular/plural `Annotations copied`; catch at `:25` sends `Copy failed`, prints, exits 1. | `rust/src/cli.rs:132` sends the same success notification; `cli::run` at `:38` sends `Copy failed`; `main` prints/exits 1. | Single and populated cases prove grammar; no-clipboard, invalid-store, busy-lock, and missing-state prove failure outputs and exits. | + +### `copy-archive` + +| Observable decision or effect | TypeScript call path | Rust call path | Mechanical evidence | +|---|---|---|---| +| Manifest and state | `lite/herdr-plugin.toml:31` → top-level `src/export-archive.ts:76` → `main` at `:45`; state is required at `:47`. | `lite-rs/herdr-plugin.toml:41` → `rust/src/main.rs:3` → `cli::run` at `rust/src/cli.rs:33` → `copy_archive` at `:183`; state is required at `:184`. | Every `process.copy-archive.*` case. | +| Shared workflow | `copyAndArchiveAnnotations` at `src/archive-workflow.ts:29` receives the same dependencies `src/manager.ts:226` injects, wired at `src/export-archive.ts:55`. | `copy_and_archive_annotations` at `rust/src/archive_workflow.rs:27` receives the dependencies `rust/src/manager.rs:555` injects, wired at `rust/src/cli.rs:191`. | `test/archive-workflow.test.ts` and `archive_workflow::tests` cover the workflow; `store.manager.copy-archive` covers the manager key; the process cases cover the action. | +| Operation order | Load, format newest first, clipboard write, `appendArchivedSet`, then `removeAnnotationsById` — `src/archive-workflow.ts:32`-`:52`. | The same order at `rust/src/archive_workflow.rs:38`-`:65`. | `process.copy-archive.populated` byte-compares both JSONL stores, modes, and leftover lock/temp files afterwards, plus the unnormalized clipboard bytes. | +| Empty store | The injected loader records the empty read at `src/export-archive.ts:58`; `copyArchiveReport` at `:39` notifies `No annotations` / `There is nothing to copy yet.` and exits 0, matching `export.ts`. | `Cell` flag at `rust/src/cli.rs:194`; `copy_archive_report` at `:170` sends the same notification and returns success. | `process.copy-archive.empty` compares the notification, exit 0, and the untouched state tree. | +| Success reporting | `copyArchiveReport` at `src/export-archive.ts:25` sends singular/plural `Annotations copied and archived`. | `copy_archive_report` at `rust/src/cli.rs:157` builds the same title and body; `copy_archive` notifies at `:207`. | `process.copy-archive.populated`; `test/export-archive.test.ts` and `cli::tests::copy_archive_maps_every_outcome_to_its_notification_and_exit_status` pin both grammars. | +| Failure reporting | A `stay_open` failure maps to `Copy and archive failed` at `src/export-archive.ts:42`; the retained-active partial maps to `Copy and archive incomplete` at `:32`. Both print the body and exit 1 at `:70`. | The same two arms at `rust/src/cli.rs:175` and `:165`; `copy_archive` returns the body at `:209` and `main` prints/exits 1. | `process.copy-archive.no-clipboard` and `process.copy-archive.missing-state` compare notification argv, stderr, exit code, and the unchanged stores; the retained-active arm is pinned by both unit tests. | ### `manage` | Observable decision or effect | TypeScript call path | Rust call path | Mechanical evidence | |---|---|---|---| -| Manifest and root | `lite/herdr-plugin.toml:31` → `src/open-manager.ts:5` → `pluginRoot` at `src/paths.ts:32`. | `lite-rs/herdr-plugin.toml:41` → `rust/src/main.rs:3` → `cli::run` at `rust/src/cli.rs:24` → `manage` at `:133` → `plugin_root` at `rust/src/paths.rs:36`. | `process.manage.success`, `process.manage.missing-root`. | -| Manager pane | The argv literal is `src/open-manager.ts:13`; `runHerdr` is `src/herdr.ts:11`. Errors notify/print/exit at `src/open-manager.ts:32`. | The argv literal is `rust/src/cli.rs:135`; `run_herdr` is `rust/src/herdr.rs:22`; `cli::run`/`main` handle notify, stderr, and exit. | Success, child-stderr failure, empty-child-stderr fallback, and missing-root cases. | +| Manifest and root | `lite/herdr-plugin.toml:38` → `src/open-manager.ts:5` → `pluginRoot` at `src/paths.ts:32`. | `lite-rs/herdr-plugin.toml:48` → `rust/src/main.rs:3` → `cli::run` at `rust/src/cli.rs:33` → `manage` at `:214` → `plugin_root` at `rust/src/paths.rs:36`. | `process.manage.success`, `process.manage.missing-root`. | +| Manager pane | The argv literal is `src/open-manager.ts:13`; `runHerdr` is `src/herdr.ts:11`. Errors notify/print/exit at `src/open-manager.ts:32`. | The argv literal is `rust/src/cli.rs:216`; `run_herdr` is `rust/src/herdr.rs:22`; `cli::run`/`main` handle notify, stderr, and exit. | Success, child-stderr failure, empty-child-stderr fallback, and missing-root cases. | ### `editor` | Observable decision or effect | TypeScript call path | Rust call path | Mechanical evidence | |---|---|---|---| -| Manifest and pending selection | `lite/herdr-plugin.toml:39` → top-level `src/editor.ts:17`. `invocationContext` at `:20`; pending-file parse at `:34`; fallback `pendingAnnotationFromInvocation` at `src/types.ts:65`; parsed-file canonicalization at `src/types.ts:79`. | `lite-rs/herdr-plugin.toml:49` → dispatcher → `editor::run` at `rust/src/editor.rs:346`; `pending_from_env` at `:320`; invocation fallback and pending parsing at `rust/src/types.rs:107` and `:119`. | Missing/invalid process cases; `screen.editor.pending-file-save`; the other editor PTY cases use invocation fallback. | +| Manifest and pending selection | `lite/herdr-plugin.toml:46` → top-level `src/editor.ts:17`. `invocationContext` at `:20`; pending-file parse at `:34`; fallback `pendingAnnotationFromInvocation` at `src/types.ts:65`; parsed-file canonicalization at `src/types.ts:79`. | `lite-rs/herdr-plugin.toml:56` → dispatcher → `editor::run` at `rust/src/editor.rs:346`; `pending_from_env` at `:320`; invocation fallback and pending parsing at `rust/src/types.rs:107` and `:119`. | Missing/invalid process cases; `screen.editor.pending-file-save`; the other editor PTY cases use invocation fallback. | | Terminal and screen | `render` at `src/editor.ts:77` uses `sanitizeTerminalText`, `wrapText`, `layoutComment`, and width helpers; alternate-screen setup is `:207`. | `EditorApp::draw` at `rust/src/editor.rs:67` calls the corresponding helpers at `rust/src/format.rs:7`/`:26`, `rust/src/layout.rs:14`, and `rust/src/width.rs:35`; `editor::run` owns the Ratatui terminal. | Initial frame and every frame in `screen.editor.*`; fixed 86×22 cells include wide Hangul. | | Input and save | Key dispatch is `src/editor.ts:172`; vertical movement is `:52`; `save` is `:122`; successful save renders, waits 250 ms, and exits. | `EditorApp::handle_key` at `rust/src/editor.rs:168`; vertical movement at `:227`; `save` at `:253`; `run` at `:346` renders the saved state, waits 250 ms, and exits. | `screen.editor.edit-save`, empty-save, missing-state, Esc, and Ctrl+C cases. Store bytes are compared after save. | | Cleanup and signals | `cleanup`/`exit` at `src/editor.ts:110`/`:117`; SIGTERM/SIGHUP handlers at `:160`. | `Termination::install`/`requested` at `rust/src/termination.rs:17`/`:33`; the polling loop at `rust/src/editor.rs:346` reaches `ratatui::restore`. | `screen.editor.sigterm` compares exit 0 and the restored terminal grid. | @@ -85,7 +99,7 @@ contract: success and blank input exit 0; a defined failure produces one stderr | Observable decision or effect | TypeScript call path | Rust call path | Mechanical evidence | |---|---|---|---| -| Manifest, state, initial load | `lite/herdr-plugin.toml:47` → `requireStateDir` at `src/manager.ts:29`; `reloadActive`/`reloadArchives` at `:53`/`:64`. | `lite-rs/herdr-plugin.toml:57` → dispatcher → `manager::run` at `rust/src/manager.rs:718`; `ManagerApp::load` at `:62`; reload methods at `:79`/`:94`. | `process.manager.missing-state`; initial screens in all manager PTY cases. | +| Manifest, state, initial load | `lite/herdr-plugin.toml:54` → `requireStateDir` at `src/manager.ts:29`; `reloadActive`/`reloadArchives` at `:53`/`:64`. | `lite-rs/herdr-plugin.toml:64` → dispatcher → `manager::run` at `rust/src/manager.rs:718`; `ManagerApp::load` at `:62`; reload methods at `:79`/`:94`. | `process.manager.missing-state`; initial screens in all manager PTY cases. | | Active screen | `render` at `src/manager.ts:196` → `renderActive` at `:93`, with `clipped` at `:75`, formatting helpers, newest-first state, source and timestamp metadata. | `ManagerApp::draw` at `rust/src/manager.rs:109` → `draw_active` at `:161`, with `clipped` at `:680` and `format_timestamp` at `:690`. | Every active-view snapshot, including the detail-width regression fixture in `screen.manager.all-views`. | | Archive screen | `render` → `renderArchives` at `src/manager.ts:136`; archive annotations are previewed newest first. | `ManagerApp::draw` → `draw_archives` at `rust/src/manager.rs:299`; same preview ordering and clipping. | Every archive-view snapshot and scripted archive mutation. | | Input and mutations | Top-level key dispatch is `src/manager.ts:402`; active/archive handlers are `:318`/`:344`; action functions are `:216`–`:316`. | `ManagerApp::handle_key` is `rust/src/manager.rs:433`; view handlers are `:465`/`:502`; action methods are `:546`–`:650`. | `screen.manager.all-views`, empty-actions, success-copy sessions, and the exit/signal sessions. Resulting JSONL and clipboard bytes are compared. | @@ -146,8 +160,8 @@ also fed by `screen.manager.all-views`. | Observable | TypeScript call path | Rust call path | Exact product and evidence | |---|---|---|---| -| State directory creation | `fs.mkdirSync(..., {recursive:true})` at `src/capture.ts:42` and `withStoreLock` at `src/store.ts:196`. | `create_dir_all` at `rust/src/cli.rs:74` and `create_private_dir_all` at `rust/src/store.rs:392`. | Process-default directory mode (0755 under harness umask 022), not forced 0700. `process.copy.empty` compares the directory mode. | -| Pending file | `src/capture.ts:44`/`:49`/`:52`. | `rust/src/cli.rs:75`/`:82` and `write_pending` at `:166`. | Name `pending--.json`; mode 0600 on creation; bytes are one JSON object plus `\n`; property order `selectedText`, `context`, `capturedAt`. Capture cases byte/mode-diff it. | +| State directory creation | `fs.mkdirSync(..., {recursive:true})` at `src/capture.ts:42` and `withStoreLock` at `src/store.ts:196`. | `create_dir_all` at `rust/src/cli.rs:84` and `create_private_dir_all` at `rust/src/store.rs:392`. | Process-default directory mode (0755 under harness umask 022), not forced 0700. `process.copy.empty` compares the directory mode. | +| Pending file | `src/capture.ts:44`/`:49`/`:52`. | `rust/src/cli.rs:85`/`:92` and `write_pending` at `:247`. | Name `pending--.json`; mode 0600 on creation; bytes are one JSON object plus `\n`; property order `selectedText`, `context`, `capturedAt`. Capture cases byte/mode-diff it. | | Pending consumption | `src/editor.ts:34` reads/parses, then `fs.rmSync(...,{force:true})` at `:39`. | `pending_from_env` at `rust/src/editor.rs:320`, then `remove_pending_file` at `:337`. | Delete only after successful read and semantic parse; missing-at-delete is ignored; other deletion errors fail startup. `screen.editor.pending-file-save` compares the consumed tree and saved JSONL; Rust regression `pending_removal_is_forceful_like_typescript` pins the delete race. | | Handoff take | `handoffPath`/`takeHandoff` at `src/handoff.ts:17`/`:24`. | `handoff_path`/`take_handoff` at `rust/src/handoff.rs:12`/`:32`. | `$XDG_RUNTIME_DIR` else temp + `herdr-annotate-/selection`; missing/stat failure means absent; regular files ≤15 s old are decoded as UTF-8 with replacement; stale and blank values are rejected; every found node is removed; non-NotFound removal failure propagates. Context-skipped handoff remains. Capture handoff cases diff pending bytes and the runtime tree. | | Active append | `appendAnnotation` at `src/store.ts:42`. | `append_annotation` / `append_annotation_context_first` at `rust/src/store.rs:77`/`:82`, sharing `append_annotation_record` at `:97`. | Append one compact JSON object plus `\n`; mode 0600 on creation. Capture-file editor order is `selectedText,capturedAt,context,id,comment,createdAt`; direct invocation fallback preserves TypeScript's distinct `selectedText,context,capturedAt,id,comment,createdAt`. `screen.editor.pending-file-save.state` and `screen.editor.edit-save.state` byte-diff both orders; two Rust regressions pin them. | @@ -168,8 +182,8 @@ herdr plugin pane open --cwd --plugin annotate --entrypoint --env HERDR_ANNOTATE_PENDING= --focus ``` -It is constructed at `src/capture.ts:54` and `rust/src/cli.rs:87` and compared by every successful -capture case. The manager argv is constructed at `src/open-manager.ts:13` and `rust/src/cli.rs:135`: +It is constructed at `src/capture.ts:54` and `rust/src/cli.rs:97` and compared by every successful +capture case. The manager argv is constructed at `src/open-manager.ts:13` and `rust/src/cli.rs:216`: ```text herdr plugin pane open --cwd --plugin annotate --entrypoint manager \ @@ -188,7 +202,8 @@ herdr notification show [--body <body>] ``` The compared title/body pairs are `Nothing to annotate`, `Annotate failed`, `No annotations`, -`Annotations copied`, `Copy failed`, and `Unable to open annotations`; their bodies are listed in the +`Annotations copied`, `Copy failed`, `Annotations copied and archived`, `Copy and archive failed`, +`Copy and archive incomplete`, and `Unable to open annotations`; their bodies are listed in the error/reporting table below or generated from the exact annotation count. Clipboard candidates and arguments are defined at `src/clipboard.ts:13`/`:30` and @@ -209,7 +224,7 @@ branches. Windows arguments are source-mapped and build-checked, not run by this | Path | Exit | |---|---| -| Successful capture, empty capture after notification, successful/empty copy, successful manage pane open | 0 | +| Successful capture, empty capture after notification, successful/empty copy, successful/empty copy-archive, successful manage pane open | 0 | | Missing required env, no clipboard adapter, store parse/lock/access failure, or pane-open failure | 1 after one stderr line; action commands also attempt their failure notification | | Editor/manager initialization error | 1 with stderr, no action-level notification | | Editor Esc/Ctrl+C, manager Esc/q/Ctrl+C, editor SIGTERM, manager SIGHUP | 0 after terminal restoration | @@ -224,8 +239,8 @@ the prefix exactly as shown. | Exact string or template | TypeScript emitter | Rust emitter | Proof | |---|---|---|---| -| `HERDR_PLUGIN_STATE_DIR is not set` | `src/capture.ts:22`, `src/export.ts:9`, `src/manager.ts:32` | `rust/src/cli.rs:59`/`:115`, `rust/src/manager.rs:719` | Missing-state process cases. | -| `HERDR_PLUGIN_ROOT is not set` | `src/capture.ts:24`, `src/open-manager.ts:7` | `rust/src/cli.rs:60`/`:134` | Missing-root cases. | +| `HERDR_PLUGIN_STATE_DIR is not set` | `src/capture.ts:22`, `src/export.ts:9`, `src/export-archive.ts:48`, `src/manager.ts:32` | `rust/src/cli.rs:69`/`:125`/`:185`, `rust/src/manager.rs:719` | Missing-state process cases. | +| `HERDR_PLUGIN_ROOT is not set` | `src/capture.ts:24`, `src/open-manager.ts:7` | `rust/src/cli.rs:70`/`:215` | Missing-root cases. | | `No supported clipboard reader is available` | `src/clipboard.ts:59` | `rust/src/clipboard.rs:106` | `process.capture.no-clipboard`. | | `Missing pending annotation` | `src/editor.ts:32` | `rust/src/editor.rs:326` | `process.editor.missing-pending`. | | `Pending annotation is invalid` | `src/editor.ts:37` | `rust/src/editor.rs:332` | `process.editor.invalid-pending`. | @@ -241,7 +256,7 @@ the prefix exactly as shown. | `Nothing to copy.` | `src/manager-copy.ts:18` | `rust/src/manager_copy.rs:20` | `screen.manager.empty-actions`. | | `Nothing to copy and archive.` | `src/archive-workflow.ts:35` | `rust/src/archive_workflow.rs:44` | `screen.manager.empty-actions`. | | `No archive selected.` | `src/manager.ts:272`/`:349` | `rust/src/manager.rs:504`/`:608` | `screen.manager.empty-actions`. | -| `Copied and archived, but active annotations remain: <store error>` | `src/manager.ts:237` | `rust/src/manager.rs:569` | Paired workflow partial-failure tests plus static catalog. | +| `Copied and archived, but active annotations remain: <store error>` | `src/manager.ts:237`, `src/export-archive.ts:35` | `rust/src/manager.rs:569`, `rust/src/cli.rs:167` | Paired workflow partial-failure tests plus static catalog. | | `Annotations restored, but the archive remains: <store error>` | `src/manager.ts:287` | `rust/src/manager.rs:627` | Paired workflow partial-failure tests plus static catalog. | | Child `herdr` stderr, or `herdr <argv> failed` | `src/herdr.ts:20` | `rust/src/herdr.rs:32`–`:39` | Manage child-stderr and empty-stderr cases; capture pane failure. | @@ -265,6 +280,11 @@ Notable differential groups: - `process.capture.*`: context > handoff > clipboard precedence; stale/blank/lossy-UTF-8 handoff; invalid context; empty; missing adapter/env; pane success/failure and pending cleanup. - `process.copy.*`: missing/empty/single/plural/invalid stores; writer failure; fresh/stale locks. +- `process.copy-archive.*`: missing/empty stores; a complete archive byte-compared afterwards; writer + failure leaving both stores untouched. +- `manifest.*`: both Lite manifests declare the same action and pane ids, titles, descriptions, + contexts, placements, geometry, platform gates, and per-runtime argv, and the harness drives every + declared entrypoint. - `process.manage.*`, `process.editor.*`, `process.manager.*`: argv/error fallback and initialization. - `screen.editor.*`: both pending sources, every requested edit key, save branches, cancel keys, and SIGTERM. - `screen.manager.*`: both views, every view-valid key, ignored cross-view keys by handler mapping, diff --git a/docs/rust-lite-parity.md b/docs/rust-lite-parity.md index cf931bd..ad0ffdb 100644 --- a/docs/rust-lite-parity.md +++ b/docs/rust-lite-parity.md @@ -11,6 +11,9 @@ separately so test coverage is not confused with behavior observed inside Herdr. JSON is mode 0600; pane-open failure removes it. - [x] `export.ts` → `herdr-annotate copy-context`: loads newest first, formats Markdown, copies, and sends the same singular/plural notifications and errors. +- [x] `export-archive.ts` → `herdr-annotate copy-archive`: injects the dependencies `manager.ts` passes + to `copyAndArchiveAnnotations`, maps the three outcomes to the same notifications, and exits 0 on an + empty store and 1 on a real failure. - [x] `open-manager.ts` → `herdr-annotate manage`: requires `HERDR_PLUGIN_ROOT` and opens the same focused 100×30 popup. - [x] `editor.ts` → `herdr-annotate editor`: pending-file and invocation-context fallback, delete-on- @@ -21,6 +24,8 @@ separately so test coverage is not confused with behavior observed inside Herdr. permanent archive deletion, reload, status messages, Esc/Tab/q/Ctrl+C behavior. - [x] `lite-rs/herdr-plugin.toml` preserves plugin id `annotate`, action ids, pane ids, placements, dimensions, contexts, and supported platform declarations. All commands are the one native binary. + `scripts/parity-lite.py` compares those declarations against `lite/herdr-plugin.toml` and the root + Full manifest field by field. ## Modules and compatibility boundaries @@ -57,6 +62,7 @@ document-anchor/API wire shape, not Lite's existing terminal-selection JSONL sha | `test/layout.test.ts` | `layout::tests` (3 grouped tests covering every assertion) | | `test/store.test.ts` | `store::tests` (6 tests) | | `test/archive-workflow.test.ts` | `archive_workflow::tests` (8 tests) | +| `test/export-archive.test.ts` | `cli::tests::copy_archive_maps_every_outcome_to_its_notification_and_exit_status` | | `test/manager-copy.test.ts` | `manager_copy::tests` (3 tests) | Additional Rust-only coverage: diff --git a/herdr-plugin.toml b/herdr-plugin.toml index 22f604a..9148924 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -19,6 +19,13 @@ description = "Copy all saved annotations to the clipboard as Markdown." contexts = ["global"] command = ["bun", "src/export.ts"] +[[actions]] +id = "copy-archive" +title = "Copy annotations as context and archive them" +description = "Copy all saved annotations to the clipboard as Markdown, then archive them." +contexts = ["global"] +command = ["bun", "src/export-archive.ts"] + [[actions]] id = "manage" title = "Manage annotations" diff --git a/lite-rs/herdr-plugin.toml b/lite-rs/herdr-plugin.toml index f9e2e95..bf3f1c7 100644 --- a/lite-rs/herdr-plugin.toml +++ b/lite-rs/herdr-plugin.toml @@ -33,6 +33,13 @@ description = "Copy all saved annotations to the clipboard as Markdown." contexts = ["global"] command = ["./bin/herdr-annotate.exe", "copy-context"] +[[actions]] +id = "copy-archive" +title = "Copy annotations as context and archive them" +description = "Copy all saved annotations to the clipboard as Markdown, then archive them." +contexts = ["global"] +command = ["./bin/herdr-annotate.exe", "copy-archive"] + [[actions]] id = "manage" title = "Manage annotations" diff --git a/lite/herdr-plugin.toml b/lite/herdr-plugin.toml index d88028b..e6d5374 100644 --- a/lite/herdr-plugin.toml +++ b/lite/herdr-plugin.toml @@ -23,6 +23,13 @@ description = "Copy all saved annotations to the clipboard as Markdown." contexts = ["global"] command = ["bun", "../src/export.ts"] +[[actions]] +id = "copy-archive" +title = "Copy annotations as context and archive them" +description = "Copy all saved annotations to the clipboard as Markdown, then archive them." +contexts = ["global"] +command = ["bun", "../src/export-archive.ts"] + [[actions]] id = "manage" title = "Manage annotations" diff --git a/rust/src/cli.rs b/rust/src/cli.rs index 4d950fa..3d9c729 100644 --- a/rust/src/cli.rs +++ b/rust/src/cli.rs @@ -1,5 +1,6 @@ -//! One native command boundary for the five Herdr entrypoints. +//! One native command boundary for the six Herdr entrypoints. +use std::cell::Cell; use std::fs::OpenOptions; use std::io::Write; use std::path::Path; @@ -7,18 +8,26 @@ use std::time::{SystemTime, UNIX_EPOCH}; use chrono::{SecondsFormat, Utc}; use serde_json::Value; +use uuid::Uuid; +use crate::archive_workflow::{ + CopyAndArchiveDependencies, CopyAndArchiveOutcome, copy_and_archive_annotations, +}; use crate::clipboard::{read_clipboard, write_clipboard}; use crate::format::format_annotations; use crate::handoff::take_default_handoff; use crate::herdr::{notify, run_herdr}; use crate::paths::{normalize_windows_path, plugin_root, state_dir}; -use crate::store::{load_annotations, newest_first_annotations}; +use crate::store::{ + append_archived_set, load_annotations, newest_first_annotations, remove_annotations_by_id, +}; use crate::types::{ - PendingAnnotation, javascript_trim, parse_invocation_context, selected_text_from_invocation, + ArchivedAnnotationSet, PendingAnnotation, javascript_trim, parse_invocation_context, + selected_text_from_invocation, }; -const USAGE: &str = "Usage: herdr-annotate <capture|copy-context|editor|manage|manager>"; +const USAGE: &str = + "Usage: herdr-annotate <capture|copy-context|copy-archive|editor|manage|manager>"; /// Dispatch one native binary subcommand. pub fn run(args: &[String]) -> Result<(), String> { @@ -29,6 +38,7 @@ pub fn run(args: &[String]) -> Result<(), String> { Some("copy-context") if args.len() == 1 => copy_context().inspect_err(|message| { notify("Copy failed", Some(message)); }), + Some("copy-archive") if args.len() == 1 => copy_archive(), Some("manage") if args.len() == 1 => manage().inspect_err(|message| { notify("Unable to open annotations", Some(message)); }), @@ -130,6 +140,77 @@ fn copy_context() -> Result<(), String> { Ok(()) } +/// The notification and exit status one copy-and-archive action reports. +#[derive(Debug, Clone, PartialEq, Eq)] +struct CopyArchiveReport { + title: String, + body: String, + failure: bool, +} + +/// Map one copy-and-archive outcome to the action's notification and exit status. +/// +/// `loaded_empty` separates the nothing-to-do case from a real failure: both are `StayOpen`, +/// but an empty store is reported like `copy-context` and returns success. +fn copy_archive_report(outcome: CopyAndArchiveOutcome, loaded_empty: bool) -> CopyArchiveReport { + match outcome { + CopyAndArchiveOutcome::Close { archived_count } => CopyArchiveReport { + title: "Annotations copied and archived".to_owned(), + body: format!( + "{archived_count} annotation{} copied as Markdown and archived.", + if archived_count == 1 { "" } else { "s" } + ), + failure: false, + }, + CopyAndArchiveOutcome::ArchivedActiveRetained { message } => CopyArchiveReport { + title: "Copy and archive incomplete".to_owned(), + body: format!("Copied and archived, but active annotations remain: {message}"), + failure: true, + }, + CopyAndArchiveOutcome::StayOpen { .. } if loaded_empty => CopyArchiveReport { + title: "No annotations".to_owned(), + body: "There is nothing to copy yet.".to_owned(), + failure: false, + }, + CopyAndArchiveOutcome::StayOpen { message } => CopyArchiveReport { + title: "Copy and archive failed".to_owned(), + body: message, + failure: true, + }, + } +} + +fn copy_archive() -> Result<(), String> { + let Some(dir) = state_dir() else { + let message = "HERDR_PLUGIN_STATE_DIR is not set".to_owned(); + notify("Copy and archive failed", Some(&message)); + return Err(message); + }; + + let loaded_empty = Cell::new(false); + let outcome = copy_and_archive_annotations(CopyAndArchiveDependencies { + load_active: || { + let loaded = load_annotations(&dir); + if matches!(&loaded, Ok(active) if active.is_empty()) { + loaded_empty.set(true); + } + loaded + }, + write_clipboard: |text: String| write_clipboard(&text), + save_archive: |archive: ArchivedAnnotationSet| append_archived_set(&dir, &archive), + remove_active: |ids: Vec<String>| remove_annotations_by_id(&dir, &ids), + create_archive_id: || Uuid::new_v4().to_string(), + now: now_iso, + }); + + let report = copy_archive_report(outcome, loaded_empty.get()); + notify(&report.title, Some(&report.body)); + if report.failure { + return Err(report.body); + } + Ok(()) +} + fn manage() -> Result<(), String> { let root = plugin_root().ok_or_else(|| "HERDR_PLUGIN_ROOT is not set".to_owned())?; run_herdr(&[ @@ -188,5 +269,69 @@ mod tests { run(&["capture".to_owned(), "extra".to_owned()]), Err(USAGE.to_owned()) ); + assert_eq!( + run(&["copy-archive".to_owned(), "extra".to_owned()]), + Err(USAGE.to_owned()) + ); + } + + #[test] + fn copy_archive_maps_every_outcome_to_its_notification_and_exit_status() { + assert_eq!( + copy_archive_report(CopyAndArchiveOutcome::Close { archived_count: 1 }, false), + CopyArchiveReport { + title: "Annotations copied and archived".to_owned(), + body: "1 annotation copied as Markdown and archived.".to_owned(), + failure: false, + } + ); + assert_eq!( + copy_archive_report(CopyAndArchiveOutcome::Close { archived_count: 3 }, false), + CopyArchiveReport { + title: "Annotations copied and archived".to_owned(), + body: "3 annotations copied as Markdown and archived.".to_owned(), + failure: false, + } + ); + assert_eq!( + copy_archive_report( + CopyAndArchiveOutcome::StayOpen { + message: "Nothing to copy and archive.".to_owned(), + }, + true, + ), + CopyArchiveReport { + title: "No annotations".to_owned(), + body: "There is nothing to copy yet.".to_owned(), + failure: false, + } + ); + assert_eq!( + copy_archive_report( + CopyAndArchiveOutcome::StayOpen { + message: "clipboard write failed".to_owned(), + }, + false, + ), + CopyArchiveReport { + title: "Copy and archive failed".to_owned(), + body: "clipboard write failed".to_owned(), + failure: true, + } + ); + assert_eq!( + copy_archive_report( + CopyAndArchiveOutcome::ArchivedActiveRetained { + message: "store is busy".to_owned(), + }, + false, + ), + CopyArchiveReport { + title: "Copy and archive incomplete".to_owned(), + body: "Copied and archived, but active annotations remain: store is busy" + .to_owned(), + failure: true, + } + ); } } diff --git a/scripts/parity-lite.py b/scripts/parity-lite.py index ead1aee..4927455 100755 --- a/scripts/parity-lite.py +++ b/scripts/parity-lite.py @@ -21,6 +21,7 @@ import sys import tempfile import termios +import tomllib import time from dataclasses import dataclass from pathlib import Path @@ -41,6 +42,15 @@ UUID_PATTERN = re.compile( r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b" ) +TYPESCRIPT_SCRIPTS = { + "capture": "capture.ts", + "copy-context": "export.ts", + "copy-archive": "export-archive.ts", + "manage": "open-manager.ts", + "editor": "editor.ts", + "manager": "manager.ts", +} +NATIVE_PROGRAM = "./bin/herdr-annotate.exe" PENDING_PATTERN = re.compile(r"pending-\d+-\d+\.json") TEMP_PATTERN = re.compile(r"\.(annotations|archives)-\d+-\d+\.tmp") DELIBERATE_DIVERGENCES = ("manager timestamp locale outside en-US",) @@ -542,14 +552,7 @@ def __init__(self, root: Path, rust_binary: Path, workspace: Path, proof: Proof) def command(self, implementation: str, entrypoint: str) -> list[str]: if implementation == "rust": return [str(self.rust_binary), entrypoint] - scripts = { - "capture": "capture.ts", - "copy-context": "export.ts", - "manage": "open-manager.ts", - "editor": "editor.ts", - "manager": "manager.ts", - } - return ["bun", str(self.root / "src" / scripts[entrypoint])] + return ["bun", str(self.root / "src" / TYPESCRIPT_SCRIPTS[entrypoint])] def environment( self, @@ -733,6 +736,16 @@ def clipboard_artifact(_implementation: str, state: Path, runtime: Path, log: Pa return sink.read_bytes() if sink.exists() else b"" +def clipboard_and_state_artifact( + _implementation: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path +) -> object: + del runtime, log, source + return { + "clipboard": (sink.read_bytes() if sink.exists() else b"").decode("utf-8", "replace"), + "state": state_snapshot(state, [state]).decode("utf-8", "replace"), + } + + def no_pending_artifact(_implementation: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> object: del runtime, log, source, sink return sorted(path.name for path in state.glob("pending-*.json")) if state.exists() else [] @@ -965,6 +978,49 @@ def stale_lock(_impl: str, state: Path, runtime: Path, log: Path, source: Path, lambda impl, state, runtime, log, source, sink: state_snapshot(state, [state]), ) + def copy_archive_empty(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None: + del state, runtime, log, source, sink + + harness.process_pair( + "process.copy-archive.empty", + "copy-archive", + copy_archive_empty, + clipboard_and_state_artifact, + ) + + def copy_archive_populated(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None: + del runtime, log, source, sink + seed_stores(state) + + harness.process_pair( + "process.copy-archive.populated", + "copy-archive", + copy_archive_populated, + clipboard_and_state_artifact, + ) + + def copy_archive_no_clipboard( + _impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path + ) -> Mapping[str, str]: + del runtime, log, source, sink + seed_stores(state) + return {"PARITY_CLIPBOARD_FAIL": "write"} + + harness.process_pair( + "process.copy-archive.no-clipboard", + "copy-archive", + copy_archive_no_clipboard, + clipboard_and_state_artifact, + ) + + def copy_archive_missing_state( + _impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path + ) -> Mapping[str, str]: + del state, runtime, log, source, sink + return {"HERDR_PLUGIN_STATE_DIR": ""} + + harness.process_pair("process.copy-archive.missing-state", "copy-archive", copy_archive_missing_state) + def manage_success(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None: del state, runtime, log, source, sink @@ -1376,6 +1432,10 @@ def verify_error_catalog(root: Path, proof: Proof) -> None: "src/manager.ts", "rust/src/manager.rs", ), + "Annotations copied and archived": ("src/export-archive.ts", "rust/src/cli.rs"), + "Copy and archive failed": ("src/export-archive.ts", "rust/src/cli.rs"), + "Copy and archive incomplete": ("src/export-archive.ts", "rust/src/cli.rs"), + "copied as Markdown and archived.": ("src/export-archive.ts", "rust/src/cli.rs"), "Annotations restored, but the archive remains:": ( "src/manager.ts", "rust/src/manager.rs", @@ -1393,6 +1453,72 @@ def verify_error_catalog(root: Path, proof: Proof) -> None: ) +def manifest_entries(manifest: Mapping[str, object], table: str) -> dict[str, dict[str, object]]: + entries = manifest.get(table, []) + if not isinstance(entries, list): + return {} + return { + str(item.get("id")): item + for item in entries + if isinstance(item, dict) and isinstance(item.get("id"), str) + } + + +def verify_manifests(root: Path, proof: Proof) -> None: + """Both Lite manifests must declare the same entrypoints, and the harness must drive them all.""" + + def load(path: Path) -> dict[str, object]: + with path.open("rb") as handle: + return tomllib.load(handle) + + lite = load(root / "lite" / "herdr-plugin.toml") + native = load(root / "lite-rs" / "herdr-plugin.toml") + full = load(root / "herdr-plugin.toml") + + lite_actions = manifest_entries(lite, "actions") + native_actions = manifest_entries(native, "actions") + lite_panes = manifest_entries(lite, "panes") + native_panes = manifest_entries(native, "panes") + full_actions = manifest_entries(full, "actions") + full_panes = manifest_entries(full, "panes") + + proof.compare("manifest.action-ids", sorted(lite_actions), sorted(native_actions)) + proof.compare("manifest.pane-ids", sorted(lite_panes), sorted(native_panes)) + proof.compare( + "manifest.harness-entrypoints", + sorted(TYPESCRIPT_SCRIPTS), + sorted(set(lite_actions) | set(lite_panes)), + ) + + for table, lite_entries, native_entries, full_entries, fields in ( + ("action", lite_actions, native_actions, full_actions, ("title", "description", "contexts")), + ("pane", lite_panes, native_panes, full_panes, ("title", "placement", "width", "height")), + ): + for identifier, entry in lite_entries.items(): + native_entry = native_entries.get(identifier, {}) + full_entry = full_entries.get(identifier, {}) + for field in fields: + proof.compare( + f"manifest.{table}.{identifier}.{field}", + (entry.get(field), entry.get(field)), + (native_entry.get(field), full_entry.get(field)), + ) + lite_command = entry.get("command", []) + proof.compare( + f"manifest.{table}.{identifier}.command", + ( + [NATIVE_PROGRAM, identifier], + [part.replace("../src/", "src/") for part in lite_command], + ), + (native_entry.get("command"), full_entry.get("command")), + ) + proof.compare( + f"manifest.{table}.{identifier}.platforms", + (None, None, None), + (entry.get("platforms"), native_entry.get("platforms"), full_entry.get("platforms")), + ) + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument("--root", type=Path, required=True) @@ -1411,9 +1537,10 @@ def main() -> int: run_process_layer(harness) print("== screen and store layers") typescript_state, rust_state = run_screen_and_store_layer(harness) - print("== cross-read and error catalog") + print("== cross-read, error catalog, and manifests") cross_read(harness, typescript_state, rust_state) verify_error_catalog(args.root.resolve(), proof) + verify_manifests(args.root.resolve(), proof) if proof.failures: print( f"Parity Lite: {proof.observables} observables compared, {proof.screens} screens diffed, " diff --git a/scripts/smoke-rust-lite.sh b/scripts/smoke-rust-lite.sh index ac0f34a..eb4aaf5 100755 --- a/scripts/smoke-rust-lite.sh +++ b/scripts/smoke-rust-lite.sh @@ -63,12 +63,12 @@ check "plugin root" "$(printf '%s' "$installed" | field "p['plugin_root']")" "$r actions="$(herdr plugin action list --plugin annotate | python3 -c ' import json,sys print(",".join(sorted(a["action_id"] for a in json.load(sys.stdin)["result"]["actions"])))')" -check "actions" "$actions" "capture,copy-context,manage" +check "actions" "$actions" "capture,copy-archive,copy-context,manage" commands="$(printf '%s' "$installed" | python3 -c ' import json,sys p=json.load(sys.stdin) print(",".join(sorted(a["command"][0] for a in p["actions"])))')" -check "native action commands" "$commands" "./bin/herdr-annotate.exe,./bin/herdr-annotate.exe,./bin/herdr-annotate.exe" +check "native action commands" "$commands" "./bin/herdr-annotate.exe,./bin/herdr-annotate.exe,./bin/herdr-annotate.exe,./bin/herdr-annotate.exe" check "bundled binary" "$("$root/lite-rs/bin/herdr-annotate.exe" --version)" "herdr-annotate $(tr -d '[:space:]' < "$root/lite-rs/herdr-annotate.version")" echo "== manager pane renders in $HERDR_SESSION" diff --git a/scripts/smoke.sh b/scripts/smoke.sh index ed4daee..5c3fc6f 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -54,7 +54,7 @@ trap restore EXIT echo "== fresh install: full" install "$spec" -check "actions" "$(actions)" "capture,copy-context,last,manage,open,open-link" +check "actions" "$(actions)" "capture,copy-archive,copy-context,last,manage,open,open-link" check "binary matches pin" "$(bin_version)" "$(pin)" echo "== review pane opens with --cwd (the #7 regression)" @@ -81,12 +81,12 @@ check "binary replaced on upgrade" "$(bin_version)" "$(pin)" echo "== fresh install: lite" install "$spec/lite" -check "actions" "$(actions)" "capture,copy-context,manage" +check "actions" "$(actions)" "capture,copy-archive,copy-context,manage" check "no binary" "$(bin_version)" "none" echo "== swap: lite -> full" install "$spec" -check "actions" "$(actions)" "capture,copy-context,last,manage,open,open-link" +check "actions" "$(actions)" "capture,copy-archive,copy-context,last,manage,open,open-link" check "binary" "$(bin_version)" "$(pin)" echo "== result: $failures failure(s)" diff --git a/src/export-archive.ts b/src/export-archive.ts new file mode 100644 index 0000000..37d4a2c --- /dev/null +++ b/src/export-archive.ts @@ -0,0 +1,76 @@ +#!/usr/bin/env bun +import crypto from "node:crypto"; +import { copyAndArchiveAnnotations, type CopyAndArchiveOutcome } from "./archive-workflow"; +import { writeClipboard } from "./clipboard"; +import { notify } from "./herdr"; +import { appendArchivedSet, loadAnnotations, removeAnnotationsById } from "./store"; + +/** The notification and exit status one copy-and-archive action reports. */ +export interface CopyArchiveReport { + readonly title: string; + readonly body: string; + readonly failure: boolean; +} + +/** + * Map one copy-and-archive outcome to the action's notification and exit status. + * + * `loadedEmpty` separates the nothing-to-do case from a real failure: both are `stay_open`, + * but an empty store is reported like `export.ts` and exits successfully. + */ +export function copyArchiveReport( + outcome: CopyAndArchiveOutcome, + loadedEmpty: boolean, +): CopyArchiveReport { + if (outcome._tag === "close") { + return { + title: "Annotations copied and archived", + body: `${outcome.archivedCount} annotation${outcome.archivedCount === 1 ? "" : "s"} copied as Markdown and archived.`, + failure: false, + }; + } + if (outcome._tag === "archived_active_retained") { + return { + title: "Copy and archive incomplete", + body: `Copied and archived, but active annotations remain: ${outcome.message}`, + failure: true, + }; + } + if (loadedEmpty) { + return { title: "No annotations", body: "There is nothing to copy yet.", failure: false }; + } + return { title: "Copy and archive failed", body: outcome.message, failure: true }; +} + +function main(): void { + const dir = process.env.HERDR_PLUGIN_STATE_DIR; + if (!dir) { + const message = "HERDR_PLUGIN_STATE_DIR is not set"; + notify("Copy and archive failed", message); + console.error(message); + process.exit(1); + } + + let loadedEmpty = false; + const outcome = copyAndArchiveAnnotations({ + loadActive: () => { + const loaded = loadAnnotations(dir); + if (loaded.ok && loaded.value.length === 0) loadedEmpty = true; + return loaded; + }, + writeClipboard, + saveArchive: (archive) => appendArchivedSet(dir, archive), + removeActive: (annotationIds) => removeAnnotationsById(dir, annotationIds), + createArchiveId: () => crypto.randomUUID(), + now: () => new Date().toISOString(), + }); + + const report = copyArchiveReport(outcome, loadedEmpty); + notify(report.title, report.body); + if (report.failure) { + console.error(report.body); + process.exit(1); + } +} + +if (import.meta.main) main(); diff --git a/test/export-archive.test.ts b/test/export-archive.test.ts new file mode 100644 index 0000000..b9c1f20 --- /dev/null +++ b/test/export-archive.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test"; +import { copyAndArchiveAnnotations } from "../src/archive-workflow"; +import { copyArchiveReport } from "../src/export-archive"; +import type { Annotation, ArchivedAnnotationSet } from "../src/types"; + +function annotation(id: string): Annotation { + return { + id, + selectedText: `selection ${id}`, + comment: `comment ${id}`, + capturedAt: "2026-08-08T00:00:00Z", + createdAt: "2026-08-08T00:00:01Z", + context: {}, + }; +} + +interface Dependencies { + readonly active?: readonly Annotation[]; + readonly loadMessage?: string; + readonly clipboardMessage?: string; + readonly saveMessage?: string; + readonly removeMessage?: string; +} + +/** Drive the workflow with mocked dependencies, then report it like `src/export-archive.ts` does. */ +function report(dependencies: Dependencies) { + const active = dependencies.active ?? []; + let loadedEmpty = false; + const outcome = copyAndArchiveAnnotations({ + loadActive: () => { + if (dependencies.loadMessage) return { ok: false, message: dependencies.loadMessage }; + if (active.length === 0) loadedEmpty = true; + return { ok: true, value: [...active] }; + }, + writeClipboard: () => + dependencies.clipboardMessage + ? { ok: false, message: dependencies.clipboardMessage } + : { ok: true, value: undefined }, + saveArchive: (_archive: ArchivedAnnotationSet) => + dependencies.saveMessage + ? { ok: false, message: dependencies.saveMessage } + : { ok: true, value: undefined }, + removeActive: () => + dependencies.removeMessage + ? { ok: false, message: dependencies.removeMessage } + : { ok: true, value: undefined }, + createArchiveId: () => "archive-one", + now: () => "2026-08-26T23:32:00.000Z", + }); + return copyArchiveReport(outcome, loadedEmpty); +} + +describe("copy-archive action reporting", () => { + test("an empty store reports nothing to copy and succeeds", () => { + expect(report({ active: [] })).toEqual({ + title: "No annotations", + body: "There is nothing to copy yet.", + failure: false, + }); + }); + + test("a complete archive reports the singular and plural counts", () => { + expect(report({ active: [annotation("one")] })).toEqual({ + title: "Annotations copied and archived", + body: "1 annotation copied as Markdown and archived.", + failure: false, + }); + expect(report({ active: [annotation("one"), annotation("two")] })).toEqual({ + title: "Annotations copied and archived", + body: "2 annotations copied as Markdown and archived.", + failure: false, + }); + }); + + test("a failed clipboard write fails with the clipboard message", () => { + expect( + report({ active: [annotation("one")], clipboardMessage: "clipboard write failed" }), + ).toEqual({ + title: "Copy and archive failed", + body: "clipboard write failed", + failure: true, + }); + }); + + test("a failed load or save fails without the empty-store wording", () => { + expect(report({ loadMessage: "annotations.jsonl is unreadable" })).toEqual({ + title: "Copy and archive failed", + body: "annotations.jsonl is unreadable", + failure: true, + }); + expect(report({ active: [annotation("one")], saveMessage: "archives.jsonl is busy" })).toEqual({ + title: "Copy and archive failed", + body: "archives.jsonl is busy", + failure: true, + }); + }); + + test("retained active annotations report the partial archive and fail", () => { + expect( + report({ active: [annotation("one")], removeMessage: "store is busy" }), + ).toEqual({ + title: "Copy and archive incomplete", + body: "Copied and archived, but active annotations remain: store is busy", + failure: true, + }); + }); +});