From 482ac9883806bf83719ddccb9217b2d286a55b61 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:50:29 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(daemon):=20stamp=20lastOutputAt=20on?= =?UTF-8?q?=20session=20metadata,=20debounced=20=E2=89=A41/s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon already parses every PTY output byte (that is how the terminal buffer exists), so recording WHEN output last happened is O(1) in the existing onData path — no new observation machinery. The stamp persists to the session metadata the daemon already maintains, through the locked metadata mutation, debounced to at most one write per second per busy session. Consumers (st2's observed harness state) derive session activity from the persisted field instead of observing output streams themselves: one field, one writer that already sees everything, one reader. Activity is an activity signal only — deliberately not liveness or delivery. --- docs/disk-layout.md | 1 + src/server.ts | 37 ++++++++ src/sessions.ts | 8 ++ tests/output-activity.test.ts | 167 ++++++++++++++++++++++++++++++++++ 4 files changed, 213 insertions(+) create mode 100644 tests/output-activity.test.ts diff --git a/docs/disk-layout.md b/docs/disk-layout.md index 9d759b0..70704a4 100644 --- a/docs/disk-layout.md +++ b/docs/disk-layout.md @@ -66,6 +66,7 @@ Pretty-printed JSON. Source of truth: `SessionMetadata` in `src/sessions.ts`. tags?: { [k: string]: string }; displayName?: string; lastAttachAt?: string; // ISO 8601 — set by the daemon on every non-readonly ATTACH + lastOutputAt?: string; // ISO 8601 — set by the daemon, debounced ≤1/s, on the last PTY output chunk } ``` diff --git a/src/server.ts b/src/server.ts index a0471d0..a9198ca 100644 --- a/src/server.ts +++ b/src/server.ts @@ -276,6 +276,14 @@ export class PtyServer { private clients = new Map(); private exited = false; private exitCode = 0; + /** Epoch ms of the last PTY output chunk this daemon processed. Stamped in + * the onData path (O(1) — the chunk is already being parsed), persisted to + * session metadata through the debounced `scheduleActivityPersist` so a + * chatty session costs at most one metadata write per second. Consumers + * (st2 observed harness state) read the persisted value to derive session + * activity; this field is the in-memory source of truth between persists. */ + private lastOutputAtMs = 0; + private activityPersistScheduled = false; private name: string; private options: ServerOptions; private attachCounter = 0; @@ -554,6 +562,8 @@ export class PtyServer { // handlers above and must NOT be forwarded to clients — otherwise the // client's terminal responds and its response appears as garbage input. this.ptyProcess.onData((data: string) => { + this.lastOutputAtMs = Date.now(); + this.scheduleActivityPersist(); this.terminal.write(data); const cleaned = stripTerminalQueries(data); if (cleaned.length > 0) { @@ -1301,11 +1311,38 @@ export class PtyServer { return lines.slice(-SESSION_EXIT_LAST_LINES_LIMIT); } + /** Trailing-edge debounce: the first output chunk after an idle period + * schedules one persist ~1s out; bursts inside the window coalesce into + * that single write carrying the newest stamp. Skipped once exited — the + * exit path persists the final stamp via `saveExitMetadata`. */ + private scheduleActivityPersist(): void { + if (this.activityPersistScheduled) return; + this.activityPersistScheduled = true; + setTimeout(() => { + this.activityPersistScheduled = false; + if (this.exited || this.lastOutputAtMs === 0) return; + const iso = new Date(this.lastOutputAtMs).toISOString(); + try { + mutateMetadataUnderLock(this.name, (metadata) => { + if (metadata.lastOutputAt === iso) return false; + metadata.lastOutputAt = iso; + return true; + }); + } catch { + // Best-effort: a lost activity stamp reads as a slightly staler + // activity sample; it must never take the daemon down. + } + }, 1000); + } + private saveExitMetadata(exitCode: number): MetadataMutationResult["status"] { const result = mutateMetadataUnderLock(this.name, (metadata) => { metadata.exitCode = exitCode; metadata.exitedAt = new Date().toISOString(); metadata.lastLines = this.getLastLines(); + if (this.lastOutputAtMs > 0) { + metadata.lastOutputAt = new Date(this.lastOutputAtMs).toISOString(); + } return true; }, { expectedGeneration: this.generation }); return result.status; diff --git a/src/sessions.ts b/src/sessions.ts index a6f93b4..b7efa6e 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -178,6 +178,14 @@ export interface SessionMetadata { * client attach — those are excluded from idle-reap (a session that * was just spawned but not yet attached to isn't "idle"). */ lastAttachAt?: string; + /** ISO 8601 timestamp of the last PTY output chunk the daemon processed. + * Written by the daemon, debounced to at most one persist per second while + * output flows (the daemon already parses every byte, so stamping is O(1) + * and adds no observation machinery). Absent on sessions that have produced + * no output yet. Consumers — e.g. st2's observed harness state — derive + * session activity from this; it is an activity signal, not a delivery or + * liveness signal. */ + lastOutputAt?: string; } export interface SessionInfo { diff --git a/tests/output-activity.test.ts b/tests/output-activity.test.ts new file mode 100644 index 0000000..8e5d67b --- /dev/null +++ b/tests/output-activity.test.ts @@ -0,0 +1,167 @@ +// Tests for the daemon-stamped `lastOutputAt` session metadata field: the +// daemon stamps every PTY output chunk in-memory and persists it debounced +// (≤1 write/second) so downstream consumers (st2 observed harness state) can +// derive session activity without observing the output stream themselves. +// +// These are integration tests against a real daemon process: the debounce +// timer lives in the daemon, not in this process, so fake timers cannot drive +// it — the real platform clock is the system under test. + +import { describe, it, expect, afterEach, afterAll } from "vitest"; +import { terminateAndWait } from "./setup/processes.ts"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn, spawnSync } from "node:child_process"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const nodeBin = process.execPath; +const cliPath = path.join(__dirname, "..", "dist", "cli.js"); +const serverModule = path.join(__dirname, "..", "dist", "server.js"); + +const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-activity-")); + +const bgPids: number[] = []; +const sessionDirs: string[] = []; +const runningDaemons: { sessionDir: string; name: string }[] = []; + +function makeSessionDir(): string { + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); + sessionDirs.push(dir); + return dir; +} + +let nameCounter = 0; +function uniqueName(): string { + return `act${++nameCounter}-${Math.random().toString(36).slice(2, 6)}`; +} + +function sleep(ms: number): Promise { + // Executor form: the repo's tsconfig lib predates Promise.withResolvers. + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function startDaemon(sessionDir: string, name: string): Promise { + const config = JSON.stringify({ + name, command: "cat", args: [], displayCommand: "cat", + cwd: os.tmpdir(), rows: 24, cols: 80, + }); + const child = spawn(nodeBin, [serverModule], { + detached: true, + stdio: ["ignore", "ignore", "pipe"], + env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir }, + }); + let stderr = ""; + child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); + let exitCode: number | null = null; + child.on("exit", (code) => { exitCode = code; }); + child.unref(); + + const socketPath = path.join(sessionDir, `${name}.sock`); + const start = Date.now(); + while (Date.now() - start < 5000) { + if (exitCode !== null) throw new Error(`Daemon exited: ${stderr}`); + if (fs.existsSync(socketPath)) { + await sleep(100); + bgPids.push(child.pid!); + return; + } + await sleep(50); + } + throw new Error("Timeout waiting for daemon"); +} + +function runCli(sessionDir: string, ...args: string[]) { + return spawnSync(nodeBin, [cliPath, ...args], { + env: { ...process.env, PTY_SESSION_DIR: sessionDir }, + encoding: "utf-8", + timeout: 10_000, + }); +} + +function readLastOutputAt(sessionDir: string, name: string): string | undefined { + const raw: unknown = JSON.parse(fs.readFileSync(path.join(sessionDir, `${name}.json`), "utf8")); + if (typeof raw !== "object" || raw === null || !("lastOutputAt" in raw)) return undefined; + const value = (raw as { lastOutputAt: unknown }).lastOutputAt; + return typeof value === "string" ? value : undefined; +} + +async function waitFor( + poll: () => boolean, + timeoutMs = 5000, + stepMs = 100, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (poll()) return; + await sleep(stepMs); + } + throw new Error("Condition not met within timeout"); +} + +afterEach(async () => { + const pids: number[] = []; + while (runningDaemons.length > 0) { + const { sessionDir, name } = runningDaemons.pop()!; + try { + pids.push(parseInt(fs.readFileSync(path.join(sessionDir, `${name}.pid`), "utf8"), 10)); + } catch {} + } + // Await before the afterAll rmtree: a daemon that is still writing would + // race the directory removal with ENOTEMPTY. + if (pids.length > 0) await terminateAndWait(pids); +}); + +afterAll(() => { + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); +}); + +describe("lastOutputAt session activity stamp", () => { + it("is absent before the session produces any output", async () => { + const sessionDir = makeSessionDir(); + const name = uniqueName(); + await startDaemon(sessionDir, name); + runningDaemons.push({ sessionDir, name }); + + expect(readLastOutputAt(sessionDir, name)).toBeUndefined(); + }); + + it("appears after output and carries a recent ISO timestamp", async () => { + const sessionDir = makeSessionDir(); + const name = uniqueName(); + await startDaemon(sessionDir, name); + runningDaemons.push({ sessionDir, name }); + + const before = Date.now(); + // cat echoes stdin back — one line in, one chunk of PTY output out. + const sent = runCli(sessionDir, "send", name, "--seq", "activity-probe", "--seq", "key:return"); + expect(sent.status).toBe(0); + + await waitFor(() => readLastOutputAt(sessionDir, name) !== undefined); + + const stampedAt = new Date(readLastOutputAt(sessionDir, name)!).getTime(); + expect(stampedAt).toBeGreaterThanOrEqual(before - 1000); + expect(stampedAt).toBeLessThanOrEqual(Date.now() + 1000); + }); + + it("updates the stamp on subsequent output bursts", async () => { + const sessionDir = makeSessionDir(); + const name = uniqueName(); + await startDaemon(sessionDir, name); + runningDaemons.push({ sessionDir, name }); + + runCli(sessionDir, "send", name, "--seq", "first", "--seq", "key:return"); + await waitFor(() => readLastOutputAt(sessionDir, name) !== undefined); + const first = new Date(readLastOutputAt(sessionDir, name)!).getTime(); + + // Wait out the 1s debounce window so the second burst cannot coalesce + // into the first persist, then require the stamp to move forward. + await sleep(1600); + runCli(sessionDir, "send", name, "--seq", "second", "--seq", "key:return"); + await waitFor(() => { + const stamp = readLastOutputAt(sessionDir, name); + return stamp !== undefined && new Date(stamp).getTime() > first; + }, 5000); + }); +}); From be0759f2ba41f66172204b197941baa65ba81d40 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:42:10 +0200 Subject: [PATCH 2/3] refactor(activity): expose unix-millisecond stamp Use lastOutputAtMs rather than RFC3339 text: st2's freshness and wire contracts already use unix milliseconds, so the numeric scalar avoids a time-parser dependency and is cheaper for every cross-language reader. --- docs/disk-layout.md | 2 +- src/server.ts | 8 ++++---- src/sessions.ts | 4 ++-- tests/output-activity.test.ts | 28 ++++++++++++++-------------- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/disk-layout.md b/docs/disk-layout.md index 70704a4..d080d55 100644 --- a/docs/disk-layout.md +++ b/docs/disk-layout.md @@ -66,7 +66,7 @@ Pretty-printed JSON. Source of truth: `SessionMetadata` in `src/sessions.ts`. tags?: { [k: string]: string }; displayName?: string; lastAttachAt?: string; // ISO 8601 — set by the daemon on every non-readonly ATTACH - lastOutputAt?: string; // ISO 8601 — set by the daemon, debounced ≤1/s, on the last PTY output chunk + lastOutputAtMs?: number; // unix ms — set by the daemon, debounced ≤1/s, on the last PTY output chunk } ``` diff --git a/src/server.ts b/src/server.ts index a9198ca..ab02c19 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1321,11 +1321,11 @@ export class PtyServer { setTimeout(() => { this.activityPersistScheduled = false; if (this.exited || this.lastOutputAtMs === 0) return; - const iso = new Date(this.lastOutputAtMs).toISOString(); + const stampedAtMs = this.lastOutputAtMs; try { mutateMetadataUnderLock(this.name, (metadata) => { - if (metadata.lastOutputAt === iso) return false; - metadata.lastOutputAt = iso; + if (metadata.lastOutputAtMs === stampedAtMs) return false; + metadata.lastOutputAtMs = stampedAtMs; return true; }); } catch { @@ -1341,7 +1341,7 @@ export class PtyServer { metadata.exitedAt = new Date().toISOString(); metadata.lastLines = this.getLastLines(); if (this.lastOutputAtMs > 0) { - metadata.lastOutputAt = new Date(this.lastOutputAtMs).toISOString(); + metadata.lastOutputAtMs = this.lastOutputAtMs; } return true; }, { expectedGeneration: this.generation }); diff --git a/src/sessions.ts b/src/sessions.ts index b7efa6e..35e45ed 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -178,14 +178,14 @@ export interface SessionMetadata { * client attach — those are excluded from idle-reap (a session that * was just spawned but not yet attached to isn't "idle"). */ lastAttachAt?: string; - /** ISO 8601 timestamp of the last PTY output chunk the daemon processed. + /** Unix-millisecond timestamp of the last PTY output chunk the daemon processed. * Written by the daemon, debounced to at most one persist per second while * output flows (the daemon already parses every byte, so stamping is O(1) * and adds no observation machinery). Absent on sessions that have produced * no output yet. Consumers — e.g. st2's observed harness state — derive * session activity from this; it is an activity signal, not a delivery or * liveness signal. */ - lastOutputAt?: string; + lastOutputAtMs?: number; } export interface SessionInfo { diff --git a/tests/output-activity.test.ts b/tests/output-activity.test.ts index 8e5d67b..b66fecc 100644 --- a/tests/output-activity.test.ts +++ b/tests/output-activity.test.ts @@ -1,4 +1,4 @@ -// Tests for the daemon-stamped `lastOutputAt` session metadata field: the +// Tests for the daemon-stamped `lastOutputAtMs` session metadata field: the // daemon stamps every PTY output chunk in-memory and persists it debounced // (≤1 write/second) so downstream consumers (st2 observed harness state) can // derive session activity without observing the output stream themselves. @@ -80,11 +80,11 @@ function runCli(sessionDir: string, ...args: string[]) { }); } -function readLastOutputAt(sessionDir: string, name: string): string | undefined { +function readLastOutputAtMs(sessionDir: string, name: string): number | undefined { const raw: unknown = JSON.parse(fs.readFileSync(path.join(sessionDir, `${name}.json`), "utf8")); - if (typeof raw !== "object" || raw === null || !("lastOutputAt" in raw)) return undefined; - const value = (raw as { lastOutputAt: unknown }).lastOutputAt; - return typeof value === "string" ? value : undefined; + if (typeof raw !== "object" || raw === null || !("lastOutputAtMs" in raw)) return undefined; + const value = (raw as { lastOutputAtMs: unknown }).lastOutputAtMs; + return typeof value === "number" ? value : undefined; } async function waitFor( @@ -117,17 +117,17 @@ afterAll(() => { fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); }); -describe("lastOutputAt session activity stamp", () => { +describe("lastOutputAtMs session activity stamp", () => { it("is absent before the session produces any output", async () => { const sessionDir = makeSessionDir(); const name = uniqueName(); await startDaemon(sessionDir, name); runningDaemons.push({ sessionDir, name }); - expect(readLastOutputAt(sessionDir, name)).toBeUndefined(); + expect(readLastOutputAtMs(sessionDir, name)).toBeUndefined(); }); - it("appears after output and carries a recent ISO timestamp", async () => { + it("appears after output and carries a recent unix-millisecond timestamp", async () => { const sessionDir = makeSessionDir(); const name = uniqueName(); await startDaemon(sessionDir, name); @@ -138,9 +138,9 @@ describe("lastOutputAt session activity stamp", () => { const sent = runCli(sessionDir, "send", name, "--seq", "activity-probe", "--seq", "key:return"); expect(sent.status).toBe(0); - await waitFor(() => readLastOutputAt(sessionDir, name) !== undefined); + await waitFor(() => readLastOutputAtMs(sessionDir, name) !== undefined); - const stampedAt = new Date(readLastOutputAt(sessionDir, name)!).getTime(); + const stampedAt = readLastOutputAtMs(sessionDir, name)!; expect(stampedAt).toBeGreaterThanOrEqual(before - 1000); expect(stampedAt).toBeLessThanOrEqual(Date.now() + 1000); }); @@ -152,16 +152,16 @@ describe("lastOutputAt session activity stamp", () => { runningDaemons.push({ sessionDir, name }); runCli(sessionDir, "send", name, "--seq", "first", "--seq", "key:return"); - await waitFor(() => readLastOutputAt(sessionDir, name) !== undefined); - const first = new Date(readLastOutputAt(sessionDir, name)!).getTime(); + await waitFor(() => readLastOutputAtMs(sessionDir, name) !== undefined); + const first = readLastOutputAtMs(sessionDir, name)!; // Wait out the 1s debounce window so the second burst cannot coalesce // into the first persist, then require the stamp to move forward. await sleep(1600); runCli(sessionDir, "send", name, "--seq", "second", "--seq", "key:return"); await waitFor(() => { - const stamp = readLastOutputAt(sessionDir, name); - return stamp !== undefined && new Date(stamp).getTime() > first; + const stamp = readLastOutputAtMs(sessionDir, name); + return stamp !== undefined && stamp > first; }, 5000); }); }); From 1bd7f9bfc53e155738621ec71e7ed88a8a2af7ec Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:06:51 +0200 Subject: [PATCH 3/3] docs(vrs): define durable PTY output activity evidence Add the user-confirmed R14 contract, exact daemon/debounce/exit-flush mechanism, and validation-matrix ownership. Extend the integration proof with immediate-output-then-exit durability. --- docs/disk-layout.md | 7 ++++++- docs/vrs/requirements.md | 11 +++++++++++ docs/vrs/spec.md | 24 ++++++++++++++++++++++++ tests/output-activity.test.ts | 33 ++++++++++++++++++++++++++++----- 4 files changed, 69 insertions(+), 6 deletions(-) diff --git a/docs/disk-layout.md b/docs/disk-layout.md index d080d55..4dd236c 100644 --- a/docs/disk-layout.md +++ b/docs/disk-layout.md @@ -66,10 +66,15 @@ Pretty-printed JSON. Source of truth: `SessionMetadata` in `src/sessions.ts`. tags?: { [k: string]: string }; displayName?: string; lastAttachAt?: string; // ISO 8601 — set by the daemon on every non-readonly ATTACH - lastOutputAtMs?: number; // unix ms — set by the daemon, debounced ≤1/s, on the last PTY output chunk + lastOutputAtMs?: number; // unix ms — newest PTY output observed by the daemon } ``` +`lastOutputAtMs` is absent until the daemon observes output. While output +continues, a trailing-edge debounce persists the newest stamp at most once per +second; exit finalization carries the final in-memory value even when a debounce +is pending. + `unsetEnv` and `extraEnv` form the persisted inherited-environment policy. Removals are applied first and explicit assignments second, so an assignment wins when both mention the same key. Older metadata without `unsetEnv` keeps diff --git a/docs/vrs/requirements.md b/docs/vrs/requirements.md index 77aa285..4aeba3d 100644 --- a/docs/vrs/requirements.md +++ b/docs/vrs/requirements.md @@ -16,6 +16,9 @@ implementation contract and validation map live in [spec.md](./spec.md). not authorization. - **A03 Terminal semantics:** Child output is an ordered terminal byte stream. Reconstructing it requires a terminal emulator rather than line-oriented logs. +- **A04 Output observation:** The per-session daemon necessarily observes every + PTY output chunk to maintain terminal state. Recording when output last + occurred adds no second observer and carries no launcher or harness semantics. ## Acceptable tradeoffs @@ -113,3 +116,11 @@ implementation contract and validation map live in [spec.md](./spec.md). `-`, or `_` separators, including compact `C-` control notation. Invalid, incomplete, or ambiguous key specs fail before any sequence bytes are sent; their diagnostics state the accepted modifiers, notation, and key names. +- **R14 Durable output-activity evidence:** Session metadata exposes an optional + unix-millisecond `lastOutputAtMs` timestamp. The daemon stamps it in the + existing output path and persists the newest value through the same locked, + generation-aware metadata mutation, debounced to at most one write per second + per busy session. Exit finalization carries the final in-memory stamp. The + field reports evidence only: PTY does not classify active/idle, infer liveness, + or authorize lifecycle/delivery behavior. Older records without the field + remain valid. diff --git a/docs/vrs/spec.md b/docs/vrs/spec.md index 90e0764..40aac00 100644 --- a/docs/vrs/spec.md +++ b/docs/vrs/spec.md @@ -170,6 +170,29 @@ Metadata and events form two compatibility tiers (R10): | event JSONL | externally readable observation stream; serialized append and bounded retention | | socket packets | internal bounded protocol with documented legacy decoding fallbacks | +### Output-activity evidence + +Session metadata may carry: + +```ts +lastOutputAtMs?: number +``` + +The value is unix milliseconds for the newest PTY output chunk the daemon has +processed. The `onData` path stamps the value in memory before feeding the same +chunk to the headless terminal and clients. A trailing-edge one-second debounce +persists the newest stamp through the locked metadata mutation; further chunks +inside the window coalesce into that write. Child exit persists the final +in-memory stamp with exit metadata, so a pending debounce cannot lose the last +output observation (R14). + +The timestamp is deliberately numeric: consumers performing freshness +arithmetic need no RFC3339 parser, and other runtime/state contracts already use +unix milliseconds. It is evidence rather than interpretation — PTY does not +define an activity threshold or label a session active/idle. Missing +`lastOutputAtMs` means no durable output observation (a new silent session or a +record from an older daemon), never zero or idle. + 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). @@ -363,6 +386,7 @@ invocation from being delivered. | 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) | | R13 | [keys](../../src/keys.ts), [CLI](../../src/cli.ts) | [keys](../../tests/keys.test.ts), [send CLI](../../tests/send-paste.test.ts), [help](../../tests/help.test.ts) | +| R14 | [server](../../src/server.ts), [sessions](../../src/sessions.ts) | [output activity](../../tests/output-activity.test.ts), [disk layout](../../tests/disk-layout-docs.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/tests/output-activity.test.ts b/tests/output-activity.test.ts index b66fecc..e6aee32 100644 --- a/tests/output-activity.test.ts +++ b/tests/output-activity.test.ts @@ -42,9 +42,14 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -async function startDaemon(sessionDir: string, name: string): Promise { +async function startDaemon( + sessionDir: string, + name: string, + command = "cat", + args: string[] = [], +): Promise { const config = JSON.stringify({ - name, command: "cat", args: [], displayCommand: "cat", + name, command, args, displayCommand: [command, ...args].join(" "), cwd: os.tmpdir(), rows: 24, cols: 80, }); const child = spawn(nodeBin, [serverModule], { @@ -80,10 +85,16 @@ function runCli(sessionDir: string, ...args: string[]) { }); } -function readLastOutputAtMs(sessionDir: string, name: string): number | undefined { +function readMetadata(sessionDir: string, name: string): Record { const raw: unknown = JSON.parse(fs.readFileSync(path.join(sessionDir, `${name}.json`), "utf8")); - if (typeof raw !== "object" || raw === null || !("lastOutputAtMs" in raw)) return undefined; - const value = (raw as { lastOutputAtMs: unknown }).lastOutputAtMs; + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + throw new Error("Session metadata must be a JSON object"); + } + return raw as Record; +} + +function readLastOutputAtMs(sessionDir: string, name: string): number | undefined { + const value = readMetadata(sessionDir, name).lastOutputAtMs; return typeof value === "number" ? value : undefined; } @@ -164,4 +175,16 @@ describe("lastOutputAtMs session activity stamp", () => { return stamp !== undefined && stamp > first; }, 5000); }); + + it("carries the final output stamp into exit metadata before debounce", async () => { + const sessionDir = makeSessionDir(); + const name = uniqueName(); + await startDaemon(sessionDir, name, "sh", ["-c", "printf final-output"]); + runningDaemons.push({ sessionDir, name }); + + // The pending activity timer skips once exited, so observing both fields + // after exit proves saveExitMetadata carried the in-memory final stamp. + await waitFor(() => typeof readMetadata(sessionDir, name).exitCode === "number"); + expect(readLastOutputAtMs(sessionDir, name)).toEqual(expect.any(Number)); + }); });