diff --git a/docs/disk-layout.md b/docs/disk-layout.md index 9d759b0..4dd236c 100644 --- a/docs/disk-layout.md +++ b/docs/disk-layout.md @@ -66,9 +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 — 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/src/server.ts b/src/server.ts index a0471d0..ab02c19 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 stampedAtMs = this.lastOutputAtMs; + try { + mutateMetadataUnderLock(this.name, (metadata) => { + if (metadata.lastOutputAtMs === stampedAtMs) return false; + metadata.lastOutputAtMs = stampedAtMs; + 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.lastOutputAtMs = this.lastOutputAtMs; + } return true; }, { expectedGeneration: this.generation }); return result.status; diff --git a/src/sessions.ts b/src/sessions.ts index a6f93b4..35e45ed 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; + /** 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. */ + lastOutputAtMs?: number; } export interface SessionInfo { diff --git a/tests/output-activity.test.ts b/tests/output-activity.test.ts new file mode 100644 index 0000000..e6aee32 --- /dev/null +++ b/tests/output-activity.test.ts @@ -0,0 +1,190 @@ +// 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. +// +// 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, + command = "cat", + args: string[] = [], +): Promise { + const config = JSON.stringify({ + name, command, args, displayCommand: [command, ...args].join(" "), + 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 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 || 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; +} + +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("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(readLastOutputAtMs(sessionDir, name)).toBeUndefined(); + }); + + it("appears after output and carries a recent unix-millisecond 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(() => readLastOutputAtMs(sessionDir, name) !== undefined); + + const stampedAt = readLastOutputAtMs(sessionDir, name)!; + 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(() => 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 = readLastOutputAtMs(sessionDir, name); + 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)); + }); +});