diff --git a/README.md b/README.md index 4205e36..093b1a6 100644 --- a/README.md +++ b/README.md @@ -419,6 +419,7 @@ Like `git`, `pty` supports extensions: if you run `pty foo` and there's a `pty-f ```typescript import { spawnDaemon, listSessions, getSession, + getSessionExitEvidence, removeSessionGeneration, SessionConnection, sendData, peekScreen, queryStats, EventFollower, readRecentEvents, extractFilterTags, matchesAllTags, diff --git a/completions/pty.bash b/completions/pty.bash index f2b3809..537a698 100644 --- a/completions/pty.bash +++ b/completions/pty.bash @@ -5,7 +5,7 @@ _pty() { COMPREPLY=() cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" - commands="run attach a exec peek send events list ls stats restart kill recover rm remove gc tag tag-multi emit rename metadata up down test remote-serve" + commands="run attach a exec peek send events list ls stats restart kill recover rm remove gc tag tag-multi emit rename metadata evidence up down test remote-serve" if [[ ${COMP_CWORD} -eq 1 ]]; then if [[ "${cur}" == -* ]]; then @@ -128,6 +128,33 @@ _pty() { metadata) COMPREPLY=($(compgen -W "--id" -- "${cur}")) ;; + evidence) + if [[ ${COMP_CWORD} -eq 2 ]]; then + COMPREPLY=($(compgen -W "snapshot remove" -- "${cur}")) + return + fi + case "${COMP_WORDS[2]}" in + snapshot) + if [[ "${prev}" == "--id" ]]; then + return + fi + if [[ "${cur}" == -* ]]; then + COMPREPLY=($(compgen -W "--id" -- "${cur}")) + fi + ;; + remove) + if [[ "${prev}" == "--id" ]]; then + return + fi + if [[ "${prev}" == "--expected-generation" ]]; then + return + fi + if [[ "${cur}" == -* ]]; then + COMPREPLY=($(compgen -W "--id --expected-generation" -- "${cur}")) + fi + ;; + esac + ;; up) COMPREPLY=($(compgen -o dirnames -- "${cur}")) ;; diff --git a/completions/pty.fish b/completions/pty.fish index 2060c4d..a44414c 100644 --- a/completions/pty.fish +++ b/completions/pty.fish @@ -33,6 +33,16 @@ function __pty_using_command test (count $cmd) -ge 2; and test "$cmd[2]" = "$argv[1]" end +function __pty_needs_subcommand + set -l cmd (commandline -opc) + test (count $cmd) -eq 2; and test "$cmd[2]" = "$argv[1]" +end + +function __pty_using_subcommand + set -l cmd (commandline -opc) + test (count $cmd) -ge 3; and test "$cmd[2]" = "$argv[1]"; and test "$cmd[3]" = "$argv[2]" +end + complete -c pty -f # ── Global flags ─────────────────────────────────────────────────────── @@ -62,6 +72,7 @@ complete -c pty -n __pty_needs_command -a tag-multi -d 'Bulk tag ops across sess complete -c pty -n __pty_needs_command -a emit -d 'Publish a user.* event' complete -c pty -n __pty_needs_command -a rename -d 'Set / show / clear displayName' complete -c pty -n __pty_needs_command -a metadata -d 'Atomically patch presentation metadata by stable id' +complete -c pty -n __pty_needs_command -a evidence -d 'Read or remove exact-generation retained exit evidence' complete -c pty -n __pty_needs_command -a up -d 'Start sessions from pty.toml' complete -c pty -n __pty_needs_command -a down -d 'Stop sessions from pty.toml' complete -c pty -n __pty_needs_command -a test -d 'Run the pty test suite (vitest)' @@ -142,6 +153,11 @@ complete -c pty -n '__pty_using_command rename' -l clear -d 'Remove displayName' complete -c pty -n '__pty_using_command rename' -a '(__pty_sessions)' -d 'Session' complete -c pty -n '__pty_using_command metadata' -l id -d 'Exact stable session id' complete -c pty -n '__pty_using_command metadata' -x -a 'patch' -d 'Value' +complete -c pty -n '__pty_needs_subcommand evidence' -a snapshot -d 'Read retained evidence for one exact generation' +complete -c pty -n '__pty_using_subcommand evidence snapshot' -l id -x -d 'Exact stable session id' +complete -c pty -n '__pty_needs_subcommand evidence' -a remove -d 'Remove only the matching terminal generation' +complete -c pty -n '__pty_using_subcommand evidence remove' -l id -x -d 'Exact stable session id' +complete -c pty -n '__pty_using_subcommand evidence remove' -l expected-generation -x -d 'Opaque generation returned by evidence snapshot' complete -c pty -n '__pty_using_command up' -F complete -c pty -n '__pty_using_command down' -F complete -c pty -n '__pty_using_command test' -l t -d 'Run matching tests' diff --git a/completions/pty.zsh b/completions/pty.zsh index c377d9f..9b014e9 100644 --- a/completions/pty.zsh +++ b/completions/pty.zsh @@ -35,6 +35,7 @@ _pty() { 'emit:Publish a user.* event' 'rename:Set / show / clear displayName' 'metadata:Atomically patch presentation metadata by stable id' + 'evidence:Read or remove exact-generation retained exit evidence' 'up:Start sessions from pty.toml' 'down:Stop sessions from pty.toml' 'test:Run the pty test suite (vitest)' @@ -184,6 +185,23 @@ _pty() { '--id[Exact stable session id]' \ '1:mode:(patch)' ;; + evidence) + if (( CURRENT == 3 )); then + _values 'operation' snapshot remove + else + case ${words[2]} in + snapshot) + _arguments \ + '--id[Exact stable session id]:id:' + ;; + remove) + _arguments \ + '--id[Exact stable session id]:id:' \ + '--expected-generation[Opaque generation returned by evidence snapshot]:generation:' + ;; + esac + fi + ;; up) _arguments \ '1:directory:_directories' diff --git a/docs/client.md b/docs/client.md index beacc56..f686d85 100644 --- a/docs/client.md +++ b/docs/client.md @@ -23,9 +23,45 @@ display name resolves only when it has exactly one match; multiple matches throw an error that lists the candidate stable ids. Returns `null` when no session matches. Resolve once, then pass `session.name` to socket-oriented APIs. +### `getSessionExitEvidence(name: string): Promise` + +Read the retained terminal evidence for one dead daemon generation. A snapshot +contains the stable session id, opaque generation, `exited` or `vanished` +status, nullable exit code, `stream: "combined"`, and the exact persisted +bounded `lastLines`. An absent persisted tail is tagged `unavailable`; it is +not reported as an empty tail. + +The operation fails closed with a tagged `unavailable` result when the session +is missing, running, locked, lacks a generation, or has invalid metadata. The +evidence reader rejects malformed, oversized, symlink, non-regular, type-invalid, +and over-200-line metadata as `invalid-metadata`. + +### `removeSessionGeneration(name: string, expectedGeneration: string): Promise` + +Remove all PTY artifacts only when the retained metadata still belongs to the +given opaque generation and its daemon is gone. Results distinguish `removed`, +`missing`, `generation-mismatch`, `not-terminal`, `invalid-metadata`, and +`busy`. A replacement generation is never removed. Cleanup errors other than +absence are thrown, and metadata is removed last so failed cleanup retains the +evidence for retry. + +Rust and other non-TypeScript consumers can use the equivalent machine-only +CLI boundary. Both operations address only an immutable stable id, emit exactly +one tagged JSON document on stdout, and exit 0 for semantic outcomes: + +```sh +pty evidence snapshot --id a1b2c3d4 +pty evidence remove --id a1b2c3d4 --expected-generation 7f44b35e +``` + +Invalid arguments and operational failures exit nonzero with a diagnostic on +stderr. A reconciler should durably consume the snapshot before passing its +opaque generation to `remove`; a mismatch must leave the replacement intact. + ### `validateName(name: string): void` -Throws if the name is invalid. Names must match `[a-zA-Z0-9._-]` and be at most 255 characters. +Throws if the name is invalid. Names must match `[a-zA-Z0-9._-]`, cannot be +`.` or `..`, and are at most 255 characters. ### `patchMetadataById(id: string, patch: MetadataPatch): Promise` diff --git a/docs/vrs/requirements.md b/docs/vrs/requirements.md index 73b0988..e61ef13 100644 --- a/docs/vrs/requirements.md +++ b/docs/vrs/requirements.md @@ -100,3 +100,11 @@ implementation contract and validation map live in [spec.md](./spec.md). remote routing preserve the applicable runtime, stream, geometry, registry, and lifecycle contracts. A surface rejects unsupported capabilities instead of silently weakening them; tests use real PTYs and processes. +- **R12 Exact-generation retained exit evidence:** Supported client and CLI + surfaces expose one bounded, tagged snapshot of retained terminal evidence + for an exact stable id and opaque generation, distinguishing exited, + vanished, live, missing, busy, unavailable, and invalid state. Conditional + cleanup removes artifacts only while that same terminal generation remains; + it never removes a live or replacement generation. Semantic outcomes and + operational failures are machine-distinguishable, and validation covers the + snapshot-to-cleanup race with real processes. diff --git a/docs/vrs/spec.md b/docs/vrs/spec.md index 491eb6b..fe51985 100644 --- a/docs/vrs/spec.md +++ b/docs/vrs/spec.md @@ -174,6 +174,119 @@ Explicit lifecycle commands and `gc` own mutation. Cleanup is authorized by the observed generation; removal wins over late daemon finalization, and permanent respawn cannot overwrite a replacement (R03, R10). +### Retained exit evidence + +An evidence consumer separates observation from cleanup (R12): + +```text +exact stable id + | + v +bounded snapshot -- opaque generation --> durable consumer action + | + v + conditional same-generation remove + | + +-------------------+--------------------+ + | | | + removed semantic refusal cleanup I/O failure + | | | + artifacts absent artifacts untouched metadata retained; + cleanup may be partial +``` + +The exported client API and machine CLI share these tagged result schemas: + +```typescript +type ExitEvidenceTail = + | { _tag: "present"; lastLines: string[] } + | { _tag: "unavailable" } + +type ExitEvidenceResult = + | { + _tag: "snapshot" + snapshot: { + name: string + generation: string + status: "exited" | "vanished" + exitCode: number | null + stream: "combined" + tail: ExitEvidenceTail + } + } + | { + _tag: "unavailable" + reason: + | "missing" + | "running" + | "busy" + | "generation-unavailable" + | "invalid-metadata" + } + +type RemoveGenerationResult = + | { _tag: "removed" } + | { _tag: "missing" } + | { _tag: "generation-mismatch" } + | { _tag: "not-terminal" } + | { _tag: "invalid-metadata" } + | { _tag: "busy" } +``` + +Snapshot and remove accept an exact filename-safe stable id, never a mutable +display-name reference. The generation is an opaque, nonempty token returned by +the snapshot; callers compare or return it without interpreting its format. +`exited` carries the recorded integer exit code, while `vanished` has a null +exit code because no terminal exit record exists. `tail.present` is the exact +persisted combined-stream line array, including an empty array; +`tail.unavailable` means no retained tail was persisted. + +The evidence metadata reader opens the metadata path read-only, nonblocking, +and without following symlinks. It requires a regular file no larger than 1 +MiB and a JSON object with these structural constraints: + +| Field | Constraint | +| --- | --- | +| `generation` | required nonempty string | +| `daemonPid` | optional positive integer | +| `exitedAt`, `exitCode` | both absent, or nonempty string plus integer | +| `lastLines` | optional array of at most 200 strings | + +Unknown compatible fields are ignored. Malformed JSON, oversized or nonregular +artifacts, symlinks, invalid field types, mixed terminal fields, and over-bound +tails produce `invalid-metadata`; they are neither truncated nor treated as +missing. A legacy record without `generation` is explicitly +`generation-unavailable` for snapshot and `generation-mismatch` for removal. + +Snapshot holds the stable-id creation lock across metadata validation and the +daemon-generation liveness check. Conditional removal serializes event and +creation mutation, then applies this order: + +1. read and structurally validate metadata; +2. compare the retained opaque generation with the expected generation; +3. refuse a live matching generation; +4. re-read, revalidate, and compare the generation; +5. remove socket, pid, event, and recovery-revision artifacts; +6. remove metadata last, preserving evidence if an earlier cleanup step fails; +7. release both locks. + +Steps 5 and 6 are not transactional. If step 5 fails, auxiliary artifacts +removed earlier in the sequence remain absent, while metadata remains as +retryable exact-generation evidence. No rollback is attempted. Missing +artifacts are idempotent. Contention and semantic refusal happen before cleanup +mutation and return tagged results. Argument, metadata-cleanup I/O, and +output-transport failures remain operational errors rather than success-shaped +results. The CLI output law is: + +| CLI outcome | stdout | stderr | exit status | +| --- | --- | --- | --- | +| semantic snapshot/remove result | exactly one tagged JSON document | empty | 0 | +| argument or operational failure | no success JSON | diagnostic | nonzero | + +`evidence snapshot --id` and `evidence remove --id --expected-generation` +provide leaf-specific help and completion schemas; snapshot never advertises +the remove-only generation option (R11, R12). + ### Live registry recovery A supporting daemon may publish an opaque recovery capability only when it can @@ -220,6 +333,7 @@ input, resize, and multi-client geometry without mocks. | R09 | [sessions](../../src/sessions.ts), [server](../../src/server.ts), [recovery](../../src/recovery.ts), [CLI](../../src/cli.ts) | [root](../../tests/pty-root.test.ts), [display name](../../tests/display-name.test.ts), [status](../../tests/stats-cli.test.ts), [list purity](../../tests/list-purity.test.ts), [recovery](../../tests/recovery.test.ts) | | R10 | [sessions](../../src/sessions.ts), [events](../../src/events.ts), [recovery](../../src/recovery.ts), [protocol](../../src/protocol.ts) | [atomic writes](../../tests/atomic-writes.test.ts), [metadata events](../../tests/metadata-events.test.ts), [events](../../tests/events.test.ts), [recovery](../../tests/recovery.test.ts), [disk layout](../../tests/disk-layout-docs.test.ts) | | R11 | [CLI](../../src/cli.ts), [client API](../../src/client-api.ts), [remote](../../src/remote.ts), [testing API](../../src/testing/index.ts) | [help](../../tests/help.test.ts), [completions](../../tests/completions.test.ts), [remote](../../tests/remote-fabric.test.ts), [screenshots](../../tests/screenshot.test.ts), [keys](../../tests/keys.test.ts) | +| R12 | [sessions](../../src/sessions.ts), [server](../../src/server.ts), [client API](../../src/client-api.ts), [CLI](../../src/cli.ts), [completions](../../src/completions.ts) | [exit evidence](../../tests/exit-reap.test.ts), [generation guard](../../tests/gc-generation-guard.test.ts), [immediate reuse](../../tests/rm-immediate-reuse.test.ts), [help](../../tests/help.test.ts), [completions](../../tests/completions.test.ts), [security](../../tests/security-fixes.test.ts) | `node scripts/verify-docs.ts --vrs-only` validates this two-document shape, sequential requirement IDs, links, and complete requirement references. diff --git a/scripts/verify-docs.ts b/scripts/verify-docs.ts index 7bc0213..e495863 100644 --- a/scripts/verify-docs.ts +++ b/scripts/verify-docs.ts @@ -20,6 +20,7 @@ function verifyVrs(): void { const requirementsPath = path.join(vrsRoot, "requirements.md"); const specPath = path.join(vrsRoot, "spec.md"); const errors: string[] = []; + let requirementCount = 0; if (actual.join("\n") !== expected.join("\n")) { errors.push(`docs/vrs must contain only ${expected.join(" and ")}`); @@ -32,6 +33,7 @@ function verifyVrs(): void { const ids = [...requirements.matchAll(/^- \*\*(R\d{2}) [^*]+:\*\*/gm)].map( (match) => match[1], ); + requirementCount = ids.length; if (ids.length === 0) errors.push("requirements.md defines no requirement IDs"); if (!ids.every((id, index) => id === `R${String(index + 1).padStart(2, "0")}`)) { @@ -67,7 +69,7 @@ function verifyVrs(): void { console.error(`VRS verification failed:\n${errors.map((error) => `- ${error}`).join("\n")}`); process.exit(1); } - console.log("Verified 2 VRS documents and 11 requirement IDs"); + console.log(`Verified 2 VRS documents and ${requirementCount} requirement IDs`); } verifyVrs(); diff --git a/src/cli.ts b/src/cli.ts index bf87c9b..46a187f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -29,6 +29,8 @@ import { updateTags, setDisplayName, patchMetadataById, + getSessionExitEvidence, + removeSessionGeneration, mutateMetadataUnderLock, allSessionNames, readMetadata, @@ -408,6 +410,18 @@ Examples: printf '%s' '{"displayName":"Worker","tags":{"role":"worker"}}' | pty metadata patch --id a1b2c3d4 printf '%s' '{"displayName":null,"tags":{"temporary":null}}' | pty metadata patch --id a1b2c3d4`, + evidence: `Usage: pty evidence snapshot --id + pty evidence remove --id --expected-generation + +Read retained terminal evidence for one exact stable session generation, or +remove that generation after the caller has durably consumed the evidence. +Both operations emit exactly one tagged JSON document on stdout. Semantic +outcomes exit 0; invalid arguments and operational failures exit nonzero. + +Examples: + pty evidence snapshot --id a1b2c3d4 + pty evidence remove --id a1b2c3d4 --expected-generation 7f44b35e`, + up: `Usage: pty up [] [...] Start sessions declared in a pty.toml. With no args, reads ./pty.toml and starts all. @@ -434,6 +448,23 @@ Examples: pty test -t "peek"`, }; +const EVIDENCE_LEAF_HELP = { + snapshot: `Usage: pty evidence snapshot --id + +Emit one tagged JSON snapshot of retained terminal evidence for the exact +stable session id. + +Example: + pty evidence snapshot --id a1b2c3d4`, + remove: `Usage: pty evidence remove --id --expected-generation + +Remove one terminal session only when it still carries the opaque generation +returned by an earlier snapshot. Emits one tagged JSON result. + +Example: + pty evidence remove --id a1b2c3d4 --expected-generation 7f44b35e`, +} as const; + /** Print a subcommand's focused help. Resolves aliases; returns false for an * unknown command so the caller can fall through. */ function printCommandHelp(cmd: string): boolean { @@ -506,6 +537,7 @@ Observe: Modify: pty metadata patch --id Atomically merge displayName/tags from JSON stdin + pty evidence snapshot --id Read exact-generation retained exit evidence as JSON pty rename