From 274dad6a6b7b0c4df246dbb710c2d9e582e4219c Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:10:33 +0200 Subject: [PATCH 1/6] feat: expose exact-generation exit evidence --- README.md | 1 + docs/client.md | 23 +++- src/client-api.ts | 3 + src/sessions.ts | 153 +++++++++++++++++++++++++ tests/exit-reap.test.ts | 227 ++++++++++++++++++++++++++++++++++++++ tests/integration.test.ts | 2 + 6 files changed, 408 insertions(+), 1 deletion(-) 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/docs/client.md b/docs/client.md index beacc56..52b9bc7 100644 --- a/docs/client.md +++ b/docs/client.md @@ -23,9 +23,30 @@ 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 an exit marker without +the required exit code. + +### `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`, 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. + ### `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/src/client-api.ts b/src/client-api.ts index b20118a..680f32a 100644 --- a/src/client-api.ts +++ b/src/client-api.ts @@ -5,6 +5,7 @@ export { listSessions, getSession, gc, pruneOrphanLayoutTags, isGone, validateName, updateTags, setDisplayName, patchMetadataById, + getSessionExitEvidence, removeSessionGeneration, getSessionDir, getSocketPath, cleanupSocket, cleanupAll, // Exposed for the same reason as `isReservedTagKey`: downstream tools @@ -12,6 +13,8 @@ export { // from reaping?" without re-deriving which tag values count as set. KEEP_TAG, isKeepRequested, shouldReapAtExit, type SessionInfo, type SessionMetadata, type MetadataPatch, type MetadataPatchResult, + type SessionExitEvidence, type SessionExitEvidenceTail, + type SessionExitEvidenceResult, type RemoveSessionGenerationResult, type PrunedTagResult, type GcResult, } from "./sessions.ts"; diff --git a/src/sessions.ts b/src/sessions.ts index b303bbe..fc55a94 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -35,6 +35,9 @@ export function validateName(name: string): void { if (!name || name.length === 0) { throw new Error("Session name cannot be empty."); } + if (name === "." || name === "..") { + throw new Error(`Invalid session name "${name}". Names cannot be "." or "..".`); + } if (name.length > 255) { throw new Error("Session name too long (max 255 characters)."); } @@ -194,6 +197,38 @@ export interface SessionInfo { metadata: SessionMetadata | null; } +export type SessionExitEvidenceTail = + | { _tag: "present"; lastLines: string[] } + | { _tag: "unavailable" }; + +export interface SessionExitEvidence { + name: string; + generation: string; + status: "exited" | "vanished"; + exitCode: number | null; + stream: "combined"; + tail: SessionExitEvidenceTail; +} + +export type SessionExitEvidenceResult = + | { _tag: "snapshot"; snapshot: SessionExitEvidence } + | { + _tag: "unavailable"; + reason: + | "missing" + | "running" + | "busy" + | "generation-unavailable" + | "exit-code-unavailable"; + }; + +export type RemoveSessionGenerationResult = + | { _tag: "removed" } + | { _tag: "missing" } + | { _tag: "generation-mismatch" } + | { _tag: "not-terminal" } + | { _tag: "busy" }; + /** Semantic helper: session has metadata but no live daemon (either `exited` * or `vanished`). Use this wherever the branch is "there's a record and we * might want to re-use cwd/tags/displayName"; reserve `=== "exited"` for @@ -1062,6 +1097,124 @@ export async function getSessionByName(name: string): Promise s.name === name) ?? null; } +async function isSessionGenerationAlive( + name: string, + metadata: SessionMetadata, +): Promise { + const pids = new Set(); + const sidecarPid = readSessionPid(name); + if (sidecarPid !== null) pids.add(sidecarPid); + if (metadata.daemonPid !== undefined) pids.add(metadata.daemonPid); + if ([...pids].some(isProcessAlive)) return true; + + const socketPath = getSocketPath(name); + return fs.existsSync(socketPath) && await isSocketReachable(socketPath); +} + +/** Read the bounded retained terminal evidence for one exact daemon generation. + * + * The per-name creation lock keeps a replacement from publishing between the + * generation read and the returned snapshot. `lastLines` is copied exactly as + * persisted; absence remains explicit instead of being synthesized as an empty + * combined stream. */ +export async function getSessionExitEvidence( + name: string, +): Promise { + validateName(name); + if (!acquireLock(name)) return { _tag: "unavailable", reason: "busy" }; + + try { + const metadata = readMetadata(name); + if (!metadata) return { _tag: "unavailable", reason: "missing" }; + if (!metadata.generation) { + return { _tag: "unavailable", reason: "generation-unavailable" }; + } + if (await isSessionGenerationAlive(name, metadata)) { + return { _tag: "unavailable", reason: "running" }; + } + + const current = readMetadata(name); + if (!current) return { _tag: "unavailable", reason: "missing" }; + if (current.generation !== metadata.generation) { + return { _tag: "unavailable", reason: "busy" }; + } + + const exited = typeof current.exitCode === "number"; + if (!exited && current.exitedAt !== undefined) { + return { _tag: "unavailable", reason: "exit-code-unavailable" }; + } + + return { + _tag: "snapshot", + snapshot: { + name, + generation: current.generation, + status: exited ? "exited" : "vanished", + exitCode: exited ? current.exitCode! : null, + stream: "combined", + tail: Array.isArray(current.lastLines) + ? { _tag: "present", lastLines: [...current.lastLines] } + : { _tag: "unavailable" }, + }, + }; + } finally { + releaseLock(name); + } +} + +function unlinkIfPresent(target: string): void { + try { + fs.unlinkSync(target); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } +} + +/** Remove one terminal session only if the retained record still carries the + * caller's opaque generation. I/O failures propagate, and metadata is removed + * last so a failed cleanup retains the evidence needed for a retry. */ +export async function removeSessionGeneration( + name: string, + expectedGeneration: string, +): Promise { + validateName(name); + if (expectedGeneration.length === 0) { + throw new Error("Expected session generation cannot be empty."); + } + if (!acquireEventLock(name)) return { _tag: "busy" }; + if (!acquireLock(name)) { + releaseEventLock(name); + return { _tag: "busy" }; + } + + try { + let metadata = readMetadata(name); + if (!metadata) return { _tag: "missing" }; + if (metadata.generation !== expectedGeneration) { + return { _tag: "generation-mismatch" }; + } + if (await isSessionGenerationAlive(name, metadata)) { + return { _tag: "not-terminal" }; + } + + metadata = readMetadata(name); + if (!metadata) return { _tag: "missing" }; + if (metadata.generation !== expectedGeneration) { + return { _tag: "generation-mismatch" }; + } + + unlinkIfPresent(getSocketPath(name)); + unlinkIfPresent(getPidPath(name)); + unlinkIfPresent(getEventsPath(name)); + unlinkIfPresent(recoveryRevisionPath(path.resolve(getSessionDir()), name)); + unlinkIfPresent(getMetadataPath(name)); + return { _tag: "removed" }; + } finally { + releaseLock(name); + releaseEventLock(name); + } +} + /** Look up a session by either its stable `name` (immutable id) or its mutable * `displayName`. An exact stable-id match always wins. A display name resolves * only when it identifies exactly one session; ambiguous labels fail closed diff --git a/tests/exit-reap.test.ts b/tests/exit-reap.test.ts index c1933c6..dafaf13 100644 --- a/tests/exit-reap.test.ts +++ b/tests/exit-reap.test.ts @@ -20,6 +20,10 @@ import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { spawn, execFileSync } from "node:child_process"; +import { + getSessionExitEvidence, + removeSessionGeneration, +} from "../src/client-api.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const nodeBin = process.execPath; @@ -132,6 +136,38 @@ async function waitForDaemonExit(pid: number, budgetMs = 6000): Promise { } } +async function waitForRuntimeTeardown( + dir: string, + name: string, + budgetMs = 6000, +): Promise { + const deadline = Date.now() + budgetMs; + while ( + Date.now() < deadline && + (fs.existsSync(path.join(dir, `${name}.sock`)) || + fs.existsSync(path.join(dir, `${name}.pid`))) + ) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + expect(fs.existsSync(path.join(dir, `${name}.sock`))).toBe(false); + expect(fs.existsSync(path.join(dir, `${name}.pid`))).toBe(false); +} + +async function withApiRoot(dir: string, run: () => Promise): Promise { + const previousRoot = process.env.PTY_ROOT; + const previousLegacyRoot = process.env.PTY_SESSION_DIR; + process.env.PTY_ROOT = dir; + delete process.env.PTY_SESSION_DIR; + try { + return await run(); + } finally { + if (previousRoot === undefined) delete process.env.PTY_ROOT; + else process.env.PTY_ROOT = previousRoot; + if (previousLegacyRoot === undefined) delete process.env.PTY_SESSION_DIR; + else process.env.PTY_SESSION_DIR = previousLegacyRoot; + } +} + afterEach(async () => { // Only pids this file spawned are ever signalled. await terminateAndWait(bgPids); @@ -146,6 +182,197 @@ afterEach(async () => { sessionDirs = []; }); +describe("exact-generation exit evidence", () => { + it("captures the retained combined tail before conditionally removing only that generation", async () => { + const dir = makeSessionDir(); + const name = uniqueName(); + const stdoutSentinel = `stdout-${name}`; + const stderrSentinel = `stderr-${name}`; + const oldPid = await startDaemon( + dir, + name, + "sh", + ["-c", `printf '${stdoutSentinel}\\n'; printf '${stderrSentinel}\\n' >&2; exit 23`], + { tags: { keep: "true" } }, + ); + + await waitForDaemonExit(oldPid); + await waitForRuntimeTeardown(dir, name); + + await withApiRoot(dir, async () => { + const captured = await getSessionExitEvidence(name); + expect(captured._tag).toBe("snapshot"); + if (captured._tag !== "snapshot") return; + + expect(captured.snapshot).toMatchObject({ + name, + status: "exited", + exitCode: 23, + stream: "combined", + tail: { _tag: "present" }, + }); + expect(captured.snapshot.generation).toEqual(expect.any(String)); + if (captured.snapshot.tail._tag !== "present") return; + const persisted = JSON.parse( + fs.readFileSync(path.join(dir, `${name}.json`), "utf8"), + ); + expect(captured.snapshot.tail.lastLines).toEqual(persisted.lastLines); + expect(captured.snapshot.tail.lastLines.join("\n")).toContain(stdoutSentinel); + expect(captured.snapshot.tail.lastLines.join("\n")).toContain(stderrSentinel); + + expect(await removeSessionGeneration(name, captured.snapshot.generation)) + .toEqual({ _tag: "removed" }); + expect(await getSessionExitEvidence(name)).toEqual({ + _tag: "unavailable", + reason: "missing", + }); + + const replacementPid = await startDaemon( + dir, + name, + "cat", + [], + { tags: { keep: "true" } }, + ); + const replacement = JSON.parse( + fs.readFileSync(path.join(dir, `${name}.json`), "utf8"), + ); + expect(await removeSessionGeneration(name, replacement.generation)) + .toEqual({ _tag: "not-terminal" }); + expect(await removeSessionGeneration(name, captured.snapshot.generation)) + .toEqual({ _tag: "generation-mismatch" }); + expect(isAlive(replacementPid)).toBe(true); + expect(fs.existsSync(path.join(dir, `${name}.json`))).toBe(true); + }); + }, 20_000); + + it("keeps an absent persisted tail explicit for a vanished generation", async () => { + const dir = makeSessionDir(); + const name = uniqueName(); + fs.writeFileSync(path.join(dir, `${name}.json`), JSON.stringify({ + generation: "vanished-generation", + daemonPid: 2147483647, + command: "cat", + args: [], + displayCommand: "cat", + cwd: dir, + createdAt: "2026-08-02T00:00:00.000Z", + })); + + await withApiRoot(dir, async () => { + expect(await getSessionExitEvidence(name)).toEqual({ + _tag: "snapshot", + snapshot: { + name, + generation: "vanished-generation", + status: "vanished", + exitCode: null, + stream: "combined", + tail: { _tag: "unavailable" }, + }, + }); + }); + }); + + it("preserves a persisted empty tail instead of calling it unavailable", async () => { + const dir = makeSessionDir(); + const name = uniqueName(); + fs.writeFileSync(path.join(dir, `${name}.json`), JSON.stringify({ + generation: "empty-tail-generation", + daemonPid: 2147483647, + command: "true", + args: [], + displayCommand: "true", + cwd: dir, + createdAt: "2026-08-02T00:00:00.000Z", + exitedAt: "2026-08-02T00:00:01.000Z", + exitCode: 0, + lastLines: [], + })); + + await withApiRoot(dir, async () => { + const captured = await getSessionExitEvidence(name); + expect(captured).toMatchObject({ + _tag: "snapshot", + snapshot: { + status: "exited", + exitCode: 0, + tail: { _tag: "present", lastLines: [] }, + }, + }); + }); + }); + + it("refuses cleanup when exact generation ownership is unavailable", async () => { + const dir = makeSessionDir(); + const name = uniqueName(); + fs.writeFileSync(path.join(dir, `${name}.json`), JSON.stringify({ + command: "true", + args: [], + displayCommand: "true", + cwd: dir, + createdAt: "2026-08-02T00:00:00.000Z", + exitedAt: "2026-08-02T00:00:01.000Z", + exitCode: 0, + lastLines: ["legacy evidence"], + })); + + await withApiRoot(dir, async () => { + expect(await getSessionExitEvidence(name)).toEqual({ + _tag: "unavailable", + reason: "generation-unavailable", + }); + expect(await removeSessionGeneration(name, "expected-generation")) + .toEqual({ _tag: "generation-mismatch" }); + expect(fs.existsSync(path.join(dir, `${name}.json`))).toBe(true); + }); + }); + + it("preserves terminal metadata when conditional cleanup fails", async () => { + const dir = makeSessionDir(); + const name = uniqueName(); + const metadataPath = path.join(dir, `${name}.json`); + fs.writeFileSync(metadataPath, JSON.stringify({ + generation: "cleanup-failure-generation", + daemonPid: 2147483647, + command: "true", + args: [], + displayCommand: "true", + cwd: dir, + createdAt: "2026-08-02T00:00:00.000Z", + exitedAt: "2026-08-02T00:00:01.000Z", + exitCode: 17, + lastLines: ["still available"], + })); + fs.mkdirSync(path.join(dir, `${name}.events.jsonl`)); + + await withApiRoot(dir, async () => { + await expect(removeSessionGeneration(name, "cleanup-failure-generation")) + .rejects.toThrow(); + expect(fs.existsSync(metadataPath)).toBe(true); + expect(await getSessionExitEvidence(name)).toMatchObject({ + _tag: "snapshot", + snapshot: { + generation: "cleanup-failure-generation", + exitCode: 17, + tail: { _tag: "present", lastLines: ["still available"] }, + }, + }); + }); + }); + + it("rejects unsafe identities before deriving evidence paths", async () => { + for (const name of ["/absolute", "../traversal", "nested/path", ".", ".."] as const) { + await expect(getSessionExitEvidence(name)).rejects.toThrow(); + await expect(removeSessionGeneration(name, "generation")).rejects.toThrow(); + } + await expect(getSessionExitEvidence("normal.dotted-task-id")).resolves.toEqual({ + _tag: "unavailable", + reason: "missing", + }); + }); +}); + describe("exit-time reap: sessions that clean themselves up", () => { it("removes a non-permanent session that exits cleanly", async () => { const dir = makeSessionDir(); diff --git a/tests/integration.test.ts b/tests/integration.test.ts index cd731a9..a466da7 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -1294,6 +1294,8 @@ describe("integration", () => { expect(() => validateName("")).toThrow(/empty/); expect(() => validateName("bad/name")).toThrow(/Invalid session name/); expect(() => validateName("../traversal")).toThrow(/Invalid session name/); + expect(() => validateName(".")).toThrow(/Invalid session name/); + expect(() => validateName("..")).toThrow(/Invalid session name/); expect(() => validateName("has spaces")).toThrow(/Invalid session name/); expect(() => validateName("a".repeat(256))).toThrow(/too long/); }); From b8bb2dfc9586d89325aef7222326df45827aae86 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:26:28 +0200 Subject: [PATCH 2/6] fix: validate retained exit evidence --- docs/client.md | 12 +-- src/server.ts | 5 +- src/sessions.ts | 174 ++++++++++++++++++++++++++++++++++------ tests/exit-reap.test.ts | 113 ++++++++++++++++++++++++++ 4 files changed, 273 insertions(+), 31 deletions(-) diff --git a/docs/client.md b/docs/client.md index 52b9bc7..34d2c0d 100644 --- a/docs/client.md +++ b/docs/client.md @@ -32,16 +32,18 @@ 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 an exit marker without -the required exit code. +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`, 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. +`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. ### `validateName(name: string): void` diff --git a/src/server.ts b/src/server.ts index 9449ae3..a0471d0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -33,6 +33,7 @@ import { mutateMetadataUnderLock, shouldReapAtExit, reapOnExitDefault, + SESSION_EXIT_LAST_LINES_LIMIT, type SessionMetadata, type MetadataMutationResult, } from "./sessions.ts"; @@ -201,8 +202,6 @@ function buildChildEnv(options: ServerOptions): Record { return env; } -const LAST_LINES_COUNT = 200; - export interface ProcessResources { rssKb: number; // Resident set size in KB cpuPercent: number; // CPU usage percentage @@ -1299,7 +1298,7 @@ export class PtyServer { while (lines.length > 0 && lines[lines.length - 1] === "") { lines.pop(); } - return lines.slice(-LAST_LINES_COUNT); + return lines.slice(-SESSION_EXIT_LAST_LINES_LIMIT); } private saveExitMetadata(exitCode: number): MetadataMutationResult["status"] { diff --git a/src/sessions.ts b/src/sessions.ts index fc55a94..53e1f62 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -219,7 +219,7 @@ export type SessionExitEvidenceResult = | "running" | "busy" | "generation-unavailable" - | "exit-code-unavailable"; + | "invalid-metadata"; }; export type RemoveSessionGenerationResult = @@ -227,8 +227,12 @@ export type RemoveSessionGenerationResult = | { _tag: "missing" } | { _tag: "generation-mismatch" } | { _tag: "not-terminal" } + | { _tag: "invalid-metadata" } | { _tag: "busy" }; +export const SESSION_EXIT_LAST_LINES_LIMIT = 200; +const SESSION_EXIT_EVIDENCE_METADATA_MAX_BYTES = 1024 * 1024; + /** Semantic helper: session has metadata but no live daemon (either `exited` * or `vanished`). Use this wherever the branch is "there's a record and we * might want to re-use cwd/tags/displayName"; reserve `=== "exited"` for @@ -1099,7 +1103,7 @@ export async function getSessionByName(name: string): Promise { const pids = new Set(); const sidecarPid = readSessionPid(name); @@ -1111,6 +1115,123 @@ async function isSessionGenerationAlive( return fs.existsSync(socketPath) && await isSocketReachable(socketPath); } +interface ExitEvidenceMetadata { + generation: string; + daemonPid?: number; + exitedAt?: string; + exitCode?: number; + lastLines?: string[]; +} + +type ExitEvidenceMetadataRead = + | { _tag: "valid"; metadata: ExitEvidenceMetadata } + | { _tag: "missing" } + | { _tag: "generation-unavailable" } + | { _tag: "invalid" }; + +function readExitEvidenceMetadata(name: string): ExitEvidenceMetadataRead { + const metadataPath = getMetadataPath(name); + let fd: number; + try { + fd = fs.openSync( + metadataPath, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK, + ); + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ENOENT" + ? { _tag: "missing" } + : { _tag: "invalid" }; + } + + try { + const stat = fs.fstatSync(fd); + if (!stat.isFile() || stat.size > SESSION_EXIT_EVIDENCE_METADATA_MAX_BYTES) { + return { _tag: "invalid" }; + } + + const content = Buffer.alloc(SESSION_EXIT_EVIDENCE_METADATA_MAX_BYTES + 1); + let bytesRead = 0; + while (bytesRead < content.length) { + const read = fs.readSync( + fd, + content, + bytesRead, + content.length - bytesRead, + null, + ); + if (read === 0) break; + bytesRead += read; + } + if (bytesRead > SESSION_EXIT_EVIDENCE_METADATA_MAX_BYTES) { + return { _tag: "invalid" }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(content.subarray(0, bytesRead).toString("utf8")); + } catch { + return { _tag: "invalid" }; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return { _tag: "invalid" }; + } + + const record = parsed as Record; + if (!("generation" in record)) return { _tag: "generation-unavailable" }; + if (typeof record.generation !== "string" || record.generation.length === 0) { + return { _tag: "invalid" }; + } + if ( + record.daemonPid !== undefined && + (!Number.isInteger(record.daemonPid) || (record.daemonPid as number) <= 0) + ) { + return { _tag: "invalid" }; + } + + const hasExitedAt = record.exitedAt !== undefined; + const hasExitCode = record.exitCode !== undefined; + if (hasExitedAt !== hasExitCode) return { _tag: "invalid" }; + if ( + hasExitedAt && + (typeof record.exitedAt !== "string" || record.exitedAt.length === 0 || + !Number.isInteger(record.exitCode)) + ) { + return { _tag: "invalid" }; + } + if ( + record.lastLines !== undefined && + (!Array.isArray(record.lastLines) || + record.lastLines.length > SESSION_EXIT_LAST_LINES_LIMIT || + !record.lastLines.every((line) => typeof line === "string")) + ) { + return { _tag: "invalid" }; + } + + return { + _tag: "valid", + metadata: { + generation: record.generation, + ...(record.daemonPid !== undefined + ? { daemonPid: record.daemonPid as number } + : {}), + ...(hasExitedAt + ? { + exitedAt: record.exitedAt as string, + exitCode: record.exitCode as number, + } + : {}), + ...(record.lastLines !== undefined + ? { lastLines: record.lastLines as string[] } + : {}), + }, + }; + } catch { + return { _tag: "invalid" }; + } finally { + fs.closeSync(fd); + } +} + /** Read the bounded retained terminal evidence for one exact daemon generation. * * The per-name creation lock keeps a replacement from publishing between the @@ -1124,36 +1245,33 @@ export async function getSessionExitEvidence( if (!acquireLock(name)) return { _tag: "unavailable", reason: "busy" }; try { - const metadata = readMetadata(name); - if (!metadata) return { _tag: "unavailable", reason: "missing" }; - if (!metadata.generation) { + const read = readExitEvidenceMetadata(name); + if (read._tag === "missing") { + return { _tag: "unavailable", reason: "missing" }; + } + if (read._tag === "generation-unavailable") { return { _tag: "unavailable", reason: "generation-unavailable" }; } + if (read._tag === "invalid") { + return { _tag: "unavailable", reason: "invalid-metadata" }; + } + const metadata = read.metadata; if (await isSessionGenerationAlive(name, metadata)) { return { _tag: "unavailable", reason: "running" }; } - const current = readMetadata(name); - if (!current) return { _tag: "unavailable", reason: "missing" }; - if (current.generation !== metadata.generation) { - return { _tag: "unavailable", reason: "busy" }; - } - - const exited = typeof current.exitCode === "number"; - if (!exited && current.exitedAt !== undefined) { - return { _tag: "unavailable", reason: "exit-code-unavailable" }; - } + const exited = metadata.exitCode !== undefined; return { _tag: "snapshot", snapshot: { name, - generation: current.generation, + generation: metadata.generation, status: exited ? "exited" : "vanished", - exitCode: exited ? current.exitCode! : null, + exitCode: exited ? metadata.exitCode! : null, stream: "combined", - tail: Array.isArray(current.lastLines) - ? { _tag: "present", lastLines: [...current.lastLines] } + tail: metadata.lastLines !== undefined + ? { _tag: "present", lastLines: [...metadata.lastLines] } : { _tag: "unavailable" }, }, }; @@ -1188,8 +1306,13 @@ export async function removeSessionGeneration( } try { - let metadata = readMetadata(name); - if (!metadata) return { _tag: "missing" }; + let read = readExitEvidenceMetadata(name); + if (read._tag === "missing") return { _tag: "missing" }; + if (read._tag === "generation-unavailable") { + return { _tag: "generation-mismatch" }; + } + if (read._tag !== "valid") return { _tag: "invalid-metadata" }; + let metadata = read.metadata; if (metadata.generation !== expectedGeneration) { return { _tag: "generation-mismatch" }; } @@ -1197,8 +1320,13 @@ export async function removeSessionGeneration( return { _tag: "not-terminal" }; } - metadata = readMetadata(name); - if (!metadata) return { _tag: "missing" }; + read = readExitEvidenceMetadata(name); + if (read._tag === "missing") return { _tag: "missing" }; + if (read._tag === "generation-unavailable") { + return { _tag: "generation-mismatch" }; + } + if (read._tag !== "valid") return { _tag: "invalid-metadata" }; + metadata = read.metadata; if (metadata.generation !== expectedGeneration) { return { _tag: "generation-mismatch" }; } diff --git a/tests/exit-reap.test.ts b/tests/exit-reap.test.ts index dafaf13..3dced64 100644 --- a/tests/exit-reap.test.ts +++ b/tests/exit-reap.test.ts @@ -361,6 +361,119 @@ describe("exact-generation exit evidence", () => { }); }); + it.each([ + ["numeric generation", { generation: 42 }], + ["empty generation", { generation: "" }], + ["string exit code", { exitCode: "17" }], + ["missing exit timestamp", { exitedAt: undefined }], + ["non-string tail entries", { lastLines: [7, { injected: true }] }], + ["tail above the persisted bound", { + lastLines: Array.from({ length: 201 }, (_, i) => `line-${i}`), + }], + ])("rejects %s in persisted evidence metadata", async (_case, override) => { + const dir = makeSessionDir(); + const name = uniqueName(); + const value = { + generation: "valid-generation", + daemonPid: 2147483647, + command: "true", + args: [], + displayCommand: "true", + cwd: dir, + createdAt: "2026-08-02T00:00:00.000Z", + exitedAt: "2026-08-02T00:00:01.000Z", + exitCode: 17, + lastLines: ["valid evidence"], + tags: { keep: "true" }, + futureCompatibleField: { preserved: true }, + ...override, + }; + fs.writeFileSync( + path.join(dir, `${name}.json`), + JSON.stringify(value), + ); + + await withApiRoot(dir, async () => { + expect(await getSessionExitEvidence(name)).toEqual({ + _tag: "unavailable", + reason: "invalid-metadata", + }); + expect(await removeSessionGeneration(name, "valid-generation")) + .toEqual({ _tag: "invalid-metadata" }); + expect(fs.existsSync(path.join(dir, `${name}.json`))).toBe(true); + }); + }); + + it.each(["malformed", "oversized", "directory", "symlink"])( + "fails closed on a %s metadata artifact", + async (artifact) => { + const dir = makeSessionDir(); + const name = uniqueName(); + const metadataPath = path.join(dir, `${name}.json`); + if (artifact === "malformed") { + fs.writeFileSync(metadataPath, "{not-json"); + } else if (artifact === "oversized") { + fs.writeFileSync(metadataPath, JSON.stringify({ + generation: "oversized-generation", + exitedAt: "2026-08-02T00:00:01.000Z", + exitCode: 17, + lastLines: ["valid evidence"], + padding: "x".repeat(2 * 1024 * 1024), + })); + } else if (artifact === "directory") { + fs.mkdirSync(metadataPath); + } else { + const target = path.join(dir, "symlink-target.json"); + fs.writeFileSync(target, JSON.stringify({ + generation: "symlink-generation", + exitedAt: "2026-08-02T00:00:01.000Z", + exitCode: 17, + lastLines: ["must not escape"], + })); + fs.symlinkSync(target, metadataPath); + } + + await withApiRoot(dir, async () => { + expect(await getSessionExitEvidence(name)).toEqual({ + _tag: "unavailable", + reason: "invalid-metadata", + }); + expect(await removeSessionGeneration(name, "any-generation")) + .toEqual({ _tag: "invalid-metadata" }); + }); + }, + ); + + it("accepts compatible unknown fields and nominal tags", async () => { + const dir = makeSessionDir(); + const name = uniqueName(); + fs.writeFileSync(path.join(dir, `${name}.json`), JSON.stringify({ + generation: "compatible-generation", + daemonPid: 2147483647, + command: "true", + args: [], + displayCommand: "true", + cwd: dir, + createdAt: "2026-08-02T00:00:00.000Z", + exitedAt: "2026-08-02T00:00:01.000Z", + exitCode: 0, + lastLines: ["compatible evidence"], + tags: { keep: "true", owner: "supervisor" }, + futureCompatibleField: { preserved: true }, + })); + + await withApiRoot(dir, async () => { + expect(await getSessionExitEvidence(name)).toMatchObject({ + _tag: "snapshot", + snapshot: { + generation: "compatible-generation", + exitCode: 0, + tail: { _tag: "present", lastLines: ["compatible evidence"] }, + }, + }); + }); + }); + it("rejects unsafe identities before deriving evidence paths", async () => { for (const name of ["/absolute", "../traversal", "nested/path", ".", ".."] as const) { await expect(getSessionExitEvidence(name)).rejects.toThrow(); From 77825d264d2649b6e2f021b340cdb370c0b6be76 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:48:12 +0200 Subject: [PATCH 3/6] feat: expose exit evidence through CLI --- completions/pty.bash | 5 +- completions/pty.fish | 4 + completions/pty.zsh | 7 ++ docs/client.md | 13 +++ src/cli.ts | 66 +++++++++++++++ src/completions.ts | 17 ++++ tests/exit-reap.test.ts | 174 +++++++++++++++++++++++++++++++++++++++- tests/help.test.ts | 2 +- 8 files changed, 285 insertions(+), 3 deletions(-) diff --git a/completions/pty.bash b/completions/pty.bash index f2b3809..aaeee36 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,9 @@ _pty() { metadata) COMPREPLY=($(compgen -W "--id" -- "${cur}")) ;; + evidence) + COMPREPLY=($(compgen -W "--id --expected-generation" -- "${cur}")) + ;; up) COMPREPLY=($(compgen -o dirnames -- "${cur}")) ;; diff --git a/completions/pty.fish b/completions/pty.fish index 2060c4d..f579544 100644 --- a/completions/pty.fish +++ b/completions/pty.fish @@ -62,6 +62,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 +143,9 @@ 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_using_command evidence' -l id -x -d 'Exact stable session id' +complete -c pty -n '__pty_using_command evidence' -l expected-generation -x -d 'Opaque generation returned by evidence snapshot' +complete -c pty -n '__pty_using_command evidence' -x -a 'snapshot remove' -d 'Value' 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..17ca295 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,12 @@ _pty() { '--id[Exact stable session id]' \ '1:mode:(patch)' ;; + evidence) + _arguments \ + '--id[Exact stable session id]:id:' \ + '--expected-generation[Opaque generation returned by evidence snapshot]:generation:' \ + '1:mode:(snapshot remove)' + ;; up) _arguments \ '1:directory:_directories' diff --git a/docs/client.md b/docs/client.md index 34d2c0d..f686d85 100644 --- a/docs/client.md +++ b/docs/client.md @@ -45,6 +45,19 @@ given opaque generation and its daemon is gone. Results distinguish `removed`, 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._-]`, cannot be diff --git a/src/cli.ts b/src/cli.ts index bf87c9b..c9c6ac6 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. @@ -506,6 +520,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