shell-redesign V1 pre-release CLI/daemon Item 1: capture-pane transcript rotation

Replaces the legacy `tmux pipe-pane` transcript-capture mechanism
(infinite-growth file per session) with a periodic
`tmux capture-pane -t <session> -p -S -<lines>` shellout that
atomically overwrites the transcript file each tick. File size stays
bounded by trailing line count + line-byte ceiling, not by session
duration.

New module packages/daemon/src/domain/transcript-rotation.ts:
- startTranscriptRotation(adapter, session, outputPath, opts)
  registers a per-session timer that ticks immediately + every
  pollIntervalMs.
- stopTranscriptRotation(session) clears a timer (idempotent).
- getTranscriptRotationOptionsFromEnv() reads the new tunables
  (env > defaults; file config flows through the daemon settings-
  store at startup).
- Each tick reads any pre-existing SESSION BOUNDARY lines from the
  output file and prepends them to the captured content so the
  restore-orchestrator's pre-launch boundary marker survives across
  rotation overwrites.
- Timers .unref() so they don't keep the daemon process alive on
  their own.

Two ConfigStore allowlist additions (lockstep daemon settings-store
+ CLI config-store):
- transcripts.lines (number; default 1000)
- transcripts.poll_interval_seconds (number; default 2)
SC-29 EXCEPTION #4 declared in pre-release ACK §5; same allowlist
shape as Phase 4 + Phase 5 prior exceptions.

Call-site swaps:
- node-launcher.ts: inline pipe-pane invocation replaced with
  startTranscriptRotation + getTranscriptRotationOptionsFromEnv.
  The launch-warning path narrows to "transcript directory creation
  failed"; per-tick failures are silent (next tick retries).
- transcript-capture.ts: startTmuxTranscriptCapture now starts the
  rotation timer instead of pipe-pane.
- rig-teardown.ts: stopTranscriptRotation called for each live
  session before killSession so capture-pane stops poking dead
  targets.

transcript-store.writeBoundaryMarker now mkdir's its parent on
demand so a fresh rig directory is no longer a fail case (the
restore orchestrator writes the marker before the launcher's
ensureTranscriptDir runs).

Tests:
- New packages/daemon/test/transcript-rotation.test.ts: 11 tests
  (capture invocation contract / atomic file write / overwrite-not-
  append / timer lifecycle + idempotent replace / env-override +
  defaults including legacy RIGGED_* fallback).
- node-launcher.test.ts: transcript-integration cases now assert on
  getActiveRotationCount + dir-creation-failure warning instead of
  pipe-pane.
- transcript-routes.test.ts: lazy-start cases assert rotation timer
  registered.
- self-attach-service.test.ts / claim-service.test.ts: transcript
  capture assertions point at rotation count.
- restore-orchestrator.test.ts: removed the pipe-pane-failure
  warning expectation (rotation failures are best-effort silent);
  boundary marker assertion still applies.
- transcript-store.test.ts: writeBoundaryMarker filesystem-error
  case rewritten to use a parent-is-a-regular-file collision now
  that the deeply-nested-nonexistent case auto-mkdirs.
- user-settings-routes.test.ts + user-settings-store.test.ts:
  expected key-count and key-list updated to include the 2 new
  transcripts.* keys plus the previously-stale Phase 4/5 keys.

Verification:
- npx tsc --noEmit on all 3 package configs: clean
- npx vitest run on the daemon suite: 3110 pass, 1 pre-existing
  failure unrelated to this slice (rsi-v2-starter-spec-content
  workflow terminal-exits assertion)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-07 09:21:08 -07:00
1 parent ec43d48af9
commit a7a34ca1ed
16 files changed
+489 -52

No files matched your search

+20 -2
View File
@@ -14,7 +14,15 @@ import {
export interface RiggedConfig {
daemon: { port: number; host: string };
db: { path: string };
transcripts: { enabled: boolean; path: string };
transcripts: {
enabled: boolean;
path: string;
// V1 pre-release CLI/daemon Item 1 — capture-pane rotation tunables.
// SC-29 EXCEPTION #4 declared in pre-release ACK §5: same allowlist
// shape as the Phase 4 / Phase 5 prior exceptions.
lines: number;
pollIntervalSeconds: number;
};
// User Settings v0 — workspace paths.
workspace: {
root: string;
@@ -74,7 +82,7 @@ const DEFAULT_WORKSPACE_ROOT = getDefaultOpenRigPath("workspace");
const DEFAULTS = {
daemon: { port: 7433, host: "127.0.0.1" },
db: { path: getDefaultOpenRigPath("openrig.sqlite") },
transcripts: { enabled: true, path: getDefaultOpenRigPath("transcripts") },
transcripts: { enabled: true, path: getDefaultOpenRigPath("transcripts"), lines: 1000, pollIntervalSeconds: 2 },
workspace: {
root: DEFAULT_WORKSPACE_ROOT,
slicesRoot: "",
@@ -123,6 +131,10 @@ export const VALID_KEYS = [
"db.path",
"transcripts.enabled",
"transcripts.path",
// V1 pre-release CLI/daemon Item 1 — SC-29 EXCEPTION #4 allowlist
// sub-piece: transcript rotation tunables (line count + poll interval).
"transcripts.lines",
"transcripts.poll_interval_seconds",
"workspace.root",
"workspace.slices_root",
"workspace.steering_path",
@@ -154,6 +166,8 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy: string }> = {
"db.path": { primary: "OPENRIG_DB", legacy: "RIGGED_DB" },
"transcripts.enabled": { primary: "OPENRIG_TRANSCRIPTS_ENABLED", legacy: "RIGGED_TRANSCRIPTS_ENABLED" },
"transcripts.path": { primary: "OPENRIG_TRANSCRIPTS_PATH", legacy: "RIGGED_TRANSCRIPTS_PATH" },
"transcripts.lines": { primary: "OPENRIG_TRANSCRIPTS_LINES", legacy: "RIGGED_TRANSCRIPTS_LINES" },
"transcripts.poll_interval_seconds": { primary: "OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS", legacy: "RIGGED_TRANSCRIPTS_POLL_INTERVAL_SECONDS" },
"workspace.root": { primary: "OPENRIG_WORKSPACE_ROOT", legacy: "RIGGED_WORKSPACE_ROOT" },
"workspace.slices_root": { primary: "OPENRIG_WORKSPACE_SLICES_ROOT", legacy: "RIGGED_WORKSPACE_SLICES_ROOT" },
"workspace.steering_path": { primary: "OPENRIG_WORKSPACE_STEERING_PATH", legacy: "RIGGED_WORKSPACE_STEERING_PATH" },
@@ -188,6 +202,8 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"db.path": ["db", "path"],
"transcripts.enabled": ["transcripts", "enabled"],
"transcripts.path": ["transcripts", "path"],
"transcripts.lines": ["transcripts", "lines"],
"transcripts.poll_interval_seconds": ["transcripts", "pollIntervalSeconds"],
"workspace.root": ["workspace", "root"],
"workspace.slices_root": ["workspace", "slicesRoot"],
"workspace.steering_path": ["workspace", "steeringPath"],
@@ -318,6 +334,8 @@ export class ConfigStore {
transcripts: {
enabled: v("transcripts.enabled") as boolean,
path: v("transcripts.path") as string,
lines: v("transcripts.lines") as number,
pollIntervalSeconds: v("transcripts.poll_interval_seconds") as number,
},
workspace: {
root: workspaceRoot,
+15 -5
View File
@@ -6,6 +6,10 @@ import type { TmuxAdapter } from "../adapters/tmux.js";
import type { TranscriptStore } from "./transcript-store.js";
import type { PersistedEvent } from "./types.js";
import { validateSessionName, deriveSessionName } from "./session-name.js";
import {
startTranscriptRotation,
getTranscriptRotationOptionsFromEnv,
} from "./transcript-rotation.js";
import type { Session, Binding } from "./types.js";
@@ -106,16 +110,22 @@ export class NodeLauncher {
return { ok: false, code: tmuxResult.code, message: tmuxResult.message };
}
// 3b. Start transcript piping (best-effort — failure warns, doesn't block launch)
// 3b. Start transcript rotation (V1 pre-release CLI/daemon Item 1:
// bounded capture-pane overwrite replaces the unbounded pipe-pane
// mechanism). Failures inside individual rotation ticks are silent
// (best-effort); only the transcript directory not being writable
// surfaces a launch warning.
const launchWarnings: string[] = [];
if (this.transcriptStore?.enabled) {
const dirOk = this.transcriptStore.ensureTranscriptDir(rig.rig.name);
if (dirOk) {
const transcriptPath = this.transcriptStore.getTranscriptPath(rig.rig.name, sessionName);
const pipeResult = await this.tmuxAdapter.startPipePane(sessionName, transcriptPath);
if (!pipeResult.ok) {
launchWarnings.push(`Transcript capture failed for ${sessionName}: ${pipeResult.message}`);
}
startTranscriptRotation(
this.tmuxAdapter,
sessionName,
transcriptPath,
getTranscriptRotationOptionsFromEnv(),
);
} else {
launchWarnings.push(`Transcript directory creation failed for rig ${rig.rig.name}`);
}
@@ -9,6 +9,7 @@ import type { ResumeMetadataRefresher } from "./resume-metadata-refresher.js";
import fs from "node:fs";
import nodePath from "node:path";
import { removeManagedBlocksFromFile } from "./managed-blocks.js";
import { stopTranscriptRotation } from "./transcript-rotation.js";
export interface TeardownResult {
rigId: string;
@@ -114,6 +115,10 @@ export class RigTeardownOrchestrator {
// 5. Kill each live session
let killFailures = 0;
for (const session of liveSessions) {
// V1 pre-release CLI/daemon Item 1: stop the rotation timer
// before killing the tmux session so capture-pane stops poking
// a dead target. Idempotent: silent no-op if no timer registered.
stopTranscriptRotation(session.sessionName);
const killResult = await this.deps.tmuxAdapter.killSession(session.sessionName);
if (killResult.ok || (killResult as { code?: string }).code === "session_not_found") {
@@ -1,5 +1,9 @@
import type { TmuxAdapter } from "../adapters/tmux.js";
import type { TranscriptStore } from "./transcript-store.js";
import {
startTranscriptRotation,
getTranscriptRotationOptionsFromEnv,
} from "./transcript-rotation.js";
export async function startTmuxTranscriptCapture(
tmuxAdapter: TmuxAdapter | null | undefined,
@@ -16,10 +20,16 @@ export async function startTmuxTranscriptCapture(
}
const transcriptPath = transcriptStore.getTranscriptPath(rigName, sessionName);
const pipeResult = await tmuxAdapter.startPipePane(sessionName, transcriptPath);
if (!pipeResult.ok) {
return { started: false, reason: pipeResult.message };
}
// V1 pre-release CLI/daemon Item 1: bounded-trail rotation replaces
// the pipe-pane infinite-growth file. Defaults from env vars + 1000
// line / 2s baseline; failure inside rotation is best-effort silent
// and never blocks launch (the prior pipe-pane site treated failure
// as launch-warning, not launch-blocker, so behavior stays).
startTranscriptRotation(
tmuxAdapter,
sessionName,
transcriptPath,
getTranscriptRotationOptionsFromEnv(),
);
return { started: true };
}
@@ -0,0 +1,127 @@
// V1 pre-release CLI/daemon Item 1 — bounded-trail transcript rotation.
//
// Replaces the legacy `tmux pipe-pane` mechanism (infinite-growth file)
// with a periodic `tmux capture-pane -t <session> -p -S -<lines>` shellout
// that atomically overwrites the transcript file. File size stays bounded
// by trailing line count + line-byte ceiling, not by session duration.
//
// Tunables (env: OPENRIG_TRANSCRIPTS_LINES / OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS;
// legacy: RIGGED_*; allowlist keys transcripts.lines / transcripts.poll_interval_seconds
// declared in SC-29 EXCEPTION #4):
// - lines: number of trailing lines to capture per tick (default 1000)
// - pollIntervalMs: millisecond cadence between ticks (default 2000)
//
// SC-29 EXCEPTION #4 declared in pre-release CLI/daemon ACK §5.
import * as fs from "node:fs";
import * as path from "node:path";
import { readOpenRigEnv } from "../openrig-compat.js";
import type { TmuxAdapter } from "../adapters/tmux.js";
export interface TranscriptRotationOptions {
/** Trailing line count to capture each tick. */
lines: number;
/** Poll interval in milliseconds. */
pollIntervalMs: number;
}
export const DEFAULT_TRANSCRIPT_LINES = 1000;
export const DEFAULT_TRANSCRIPT_POLL_INTERVAL_MS = 2000;
/** Resolve rotation options from env vars, falling back to defaults.
* File-stored config (rig config set transcripts.lines …) is loaded
* via the daemon settings-store at startup; consumers that need the
* file-stored value can pass an explicit options object instead. */
export function getTranscriptRotationOptionsFromEnv(): TranscriptRotationOptions {
const linesRaw = readOpenRigEnv("OPENRIG_TRANSCRIPTS_LINES", "RIGGED_TRANSCRIPTS_LINES");
const pollRaw = readOpenRigEnv("OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS", "RIGGED_TRANSCRIPTS_POLL_INTERVAL_SECONDS");
const lines = parsePositiveInt(linesRaw, DEFAULT_TRANSCRIPT_LINES);
const pollSeconds = parsePositiveInt(pollRaw, DEFAULT_TRANSCRIPT_POLL_INTERVAL_MS / 1000);
return { lines, pollIntervalMs: pollSeconds * 1000 };
}
function parsePositiveInt(raw: string | undefined, fallback: number): number {
if (raw === undefined) return fallback;
const n = parseInt(raw, 10);
if (!Number.isFinite(n) || n <= 0) return fallback;
return n;
}
const activeTimers = new Map<string, NodeJS.Timeout>();
/** Start a per-session capture-pane rotation timer. Idempotent: a
* second start for the same session replaces the first timer. The
* first tick fires immediately so the transcript file is populated
* before the first poll interval elapses. */
export function startTranscriptRotation(
tmuxAdapter: TmuxAdapter,
sessionName: string,
outputPath: string,
opts: TranscriptRotationOptions,
): void {
stopTranscriptRotation(sessionName);
const tick = async (): Promise<void> => {
try {
const content = await tmuxAdapter.capturePaneContent(sessionName, opts.lines);
if (content === null) return;
// Preserve SESSION BOUNDARY lines that the restore orchestrator
// writes to the transcript file before launch. The capture-pane
// overwrite would otherwise wipe them on the first tick. The
// marker is the only structural header the transcript file is
// expected to carry across rotations; everything else is
// terminal-scrollback content from capture-pane.
let header = "";
try {
if (fs.existsSync(outputPath)) {
const prev = fs.readFileSync(outputPath, "utf8");
const boundaryLines = prev
.split("\n")
.filter((line) => line.startsWith("--- SESSION BOUNDARY:"));
if (boundaryLines.length > 0) header = boundaryLines.join("\n") + "\n";
}
} catch {
// Best-effort header read; missing file or read error means no header.
}
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
const tmpPath = `${outputPath}.tmp.${process.pid}`;
fs.writeFileSync(tmpPath, header + content);
fs.renameSync(tmpPath, outputPath);
} catch {
// Best-effort capture: target session may have died, output path
// may be unwritable, etc. The next tick retries; failure here
// does not bubble up to the daemon's launch / lifecycle paths.
}
};
void tick();
const timer = setInterval(tick, opts.pollIntervalMs);
// Don't keep the daemon process alive solely on transcript timers.
if (typeof timer.unref === "function") timer.unref();
activeTimers.set(sessionName, timer);
}
/** Clear the rotation timer for a session. Safe to call when no timer
* is registered. */
export function stopTranscriptRotation(sessionName: string): void {
const timer = activeTimers.get(sessionName);
if (timer) {
clearInterval(timer);
activeTimers.delete(sessionName);
}
}
/** Test-only: count of active rotators. Production code should not
* depend on this. */
export function getActiveRotationCount(): number {
return activeTimers.size;
}
/** Test-only: clear all active rotators. Production code should not
* call this; use stopTranscriptRotation for individual sessions. */
export function clearAllTranscriptRotationsForTest(): void {
for (const timer of activeTimers.values()) clearInterval(timer);
activeTimers.clear();
}
@@ -1,5 +1,5 @@
import { mkdirSync, appendFileSync, existsSync, openSync, readSync, closeSync, statSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { join, dirname } from "node:path";
import { StringDecoder } from "node:string_decoder";
import { getCompatibleOpenRigPath } from "../openrig-compat.js";
@@ -208,6 +208,12 @@ export class TranscriptStore {
if (!this._enabled) return false;
try {
const filePath = this.getTranscriptPath(rigName, sessionName);
// Ensure the rig directory exists so the marker write succeeds
// even when called before the launcher's ensureTranscriptDir.
// Restore orchestration writes the marker before launch; the
// launcher creates the dir later, which used to lose markers
// for the first restore on a fresh rig.
mkdirSync(dirname(filePath), { recursive: true });
const marker = `--- SESSION BOUNDARY: ${reason} at ${new Date().toISOString()} ---\n`;
appendFileSync(filePath, marker, "utf-8");
return true;
@@ -35,6 +35,11 @@ export const SETTINGS_VALID_KEYS = [
"db.path",
"transcripts.enabled",
"transcripts.path",
// V1 pre-release CLI/daemon Item 1 — capture-pane rotation tunables.
// SC-29 EXCEPTION #4 allowlist sub-piece (lockstep with
// cli/src/config-store.ts).
"transcripts.lines",
"transcripts.poll_interval_seconds",
"workspace.root",
"workspace.slices_root",
"workspace.steering_path",
@@ -74,6 +79,8 @@ const ENV_MAP: Record<SettingsValidKey, { primary: string; legacy: string }> = {
"db.path": { primary: "OPENRIG_DB", legacy: "RIGGED_DB" },
"transcripts.enabled": { primary: "OPENRIG_TRANSCRIPTS_ENABLED", legacy: "RIGGED_TRANSCRIPTS_ENABLED" },
"transcripts.path": { primary: "OPENRIG_TRANSCRIPTS_PATH", legacy: "RIGGED_TRANSCRIPTS_PATH" },
"transcripts.lines": { primary: "OPENRIG_TRANSCRIPTS_LINES", legacy: "RIGGED_TRANSCRIPTS_LINES" },
"transcripts.poll_interval_seconds": { primary: "OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS", legacy: "RIGGED_TRANSCRIPTS_POLL_INTERVAL_SECONDS" },
"workspace.root": { primary: "OPENRIG_WORKSPACE_ROOT", legacy: "RIGGED_WORKSPACE_ROOT" },
"workspace.slices_root": { primary: "OPENRIG_WORKSPACE_SLICES_ROOT", legacy: "RIGGED_WORKSPACE_SLICES_ROOT" },
"workspace.steering_path": { primary: "OPENRIG_WORKSPACE_STEERING_PATH", legacy: "RIGGED_WORKSPACE_STEERING_PATH" },
@@ -101,6 +108,8 @@ const KEY_TO_PATH: Record<SettingsValidKey, string[]> = {
"db.path": ["db", "path"],
"transcripts.enabled": ["transcripts", "enabled"],
"transcripts.path": ["transcripts", "path"],
"transcripts.lines": ["transcripts", "lines"],
"transcripts.poll_interval_seconds": ["transcripts", "pollIntervalSeconds"],
"workspace.root": ["workspace", "root"],
"workspace.slices_root": ["workspace", "slicesRoot"],
"workspace.steering_path": ["workspace", "steeringPath"],
@@ -194,6 +203,9 @@ function getDefaultValue(key: SettingsValidKey, workspaceRoot: string): string |
case "db.path": return path.join(path.dirname(DEFAULT_CONFIG_PATH), "openrig.sqlite");
case "transcripts.enabled": return true;
case "transcripts.path": return path.join(path.dirname(DEFAULT_CONFIG_PATH), "transcripts");
// V1 pre-release CLI/daemon Item 1 — capture-pane rotation defaults.
case "transcripts.lines": return 1000;
case "transcripts.poll_interval_seconds": return 2;
case "workspace.root": return DEFAULT_WORKSPACE_ROOT;
// Preview Terminal v0 (PL-018) defaults — match cli/src/config-store.ts.
case "ui.preview.refresh_interval_seconds": return 3;
+7 -4
View File
@@ -275,10 +275,13 @@ describe("ClaimService", () => {
const result = await claimService.bind({ discoveredId: discovered.id, rigId: rig.id, logicalId: "orch.lead" });
expect(result.ok).toBe(true);
expect(startPipePaneSpy).toHaveBeenCalledWith(
"orch-lead@host",
transcriptStore.getTranscriptPath("test-rig", "orch-lead@host"),
);
// V1 pre-release Item 1: transcript capture path now starts a
// rotation timer instead of pipe-pane. Confirm via the rotation
// module's active-count rather than the legacy pipe-pane spy.
const { getActiveRotationCount, clearAllTranscriptRotationsForTest } =
await import("../src/domain/transcript-rotation.js");
expect(getActiveRotationCount()).toBeGreaterThan(0);
clearAllTranscriptRotationsForTest();
});
it("bind provisions Claude context collection for adopted tmux sessions", async () => {
+12 -12
View File
@@ -409,14 +409,16 @@ describe("NodeLauncher", () => {
});
describe("transcript integration", () => {
it("calls startPipePane on successful launch when TranscriptStore is enabled", async () => {
it("starts the transcript rotation timer on successful launch when TranscriptStore is enabled", async () => {
const {
getActiveRotationCount,
clearAllTranscriptRotationsForTest,
} = await import("../src/domain/transcript-rotation.js");
clearAllTranscriptRotationsForTest();
const { rig } = seedRigWithNode();
const pipePaneSpy = vi.fn<(name: string, path: string) => Promise<TmuxResult>>()
.mockResolvedValue({ ok: true });
const tmux = mockTmuxAdapter({
createSession: async () => ({ ok: true as const }),
});
(tmux as unknown as Record<string, unknown>).startPipePane = pipePaneSpy;
const { TranscriptStore } = await import("../src/domain/transcript-store.js");
const transcriptStore = new TranscriptStore({ transcriptsRoot: "/tmp/test-transcripts", enabled: true });
@@ -428,25 +430,23 @@ describe("NodeLauncher", () => {
const result = await launcher.launchNode(rig.id, "dev1-impl");
expect(result.ok).toBe(true);
expect(pipePaneSpy).toHaveBeenCalledOnce();
expect(pipePaneSpy.mock.calls[0]![1]).toContain("dev1-impl");
// Rotation timer registered for the launched session.
expect(getActiveRotationCount()).toBeGreaterThan(0);
if (result.ok) {
expect(result.warnings).toBeUndefined();
}
clearAllTranscriptRotationsForTest();
});
it("adds warning to LaunchResult.warnings on pipe-pane failure but launch still succeeds", async () => {
it("warns and still succeeds when the transcript directory cannot be created", async () => {
const { rig } = seedRigWithNode();
const pipePaneSpy = vi.fn<(name: string, path: string) => Promise<TmuxResult>>()
.mockResolvedValue({ ok: false, code: "unknown", message: "pipe-pane failed" });
const tmux = mockTmuxAdapter({
createSession: async () => ({ ok: true as const }),
});
(tmux as unknown as Record<string, unknown>).startPipePane = pipePaneSpy;
const { TranscriptStore } = await import("../src/domain/transcript-store.js");
const transcriptStore = new TranscriptStore({ transcriptsRoot: "/tmp/test-transcripts", enabled: true });
vi.spyOn(transcriptStore, "ensureTranscriptDir").mockReturnValue(true);
vi.spyOn(transcriptStore, "ensureTranscriptDir").mockReturnValue(false);
const launcher = new NodeLauncher({
db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux, transcriptStore,
@@ -457,7 +457,7 @@ describe("NodeLauncher", () => {
if (result.ok) {
expect(result.warnings).toBeDefined();
expect(result.warnings!.length).toBe(1);
expect(result.warnings![0]).toContain("Transcript capture failed");
expect(result.warnings![0]).toContain("Transcript directory creation failed");
}
});
});
@@ -1588,8 +1588,13 @@ describe("RestoreOrchestrator", () => {
const transcriptStore = new TranscriptStore({ transcriptsRoot: tmpDir, enabled: true });
const tmux = mockTmux();
// startPipePane fails → warning propagates, but boundary marker still written
(tmux as unknown as Record<string, unknown>).startPipePane = vi.fn(async () => ({ ok: false, code: "unknown", message: "pipe-pane failed" }));
// V1 pre-release Item 1: capture is via the rotation module, whose
// failures are best-effort silent (next tick retries). The legacy
// "Transcript capture failed" launch-warning path no longer fires;
// the only structural transcript warning surfaces if the
// transcript directory creation itself fails. The boundary marker
// is still written prior to launch and asserted below regardless
// of capture-tick outcomes.
const nodeLauncher = new NodeLauncher({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux, transcriptStore });
const orch = new RestoreOrchestrator({
@@ -1603,9 +1608,6 @@ describe("RestoreOrchestrator", () => {
const result = await orch.restore(snap.id);
expect(result.ok).toBe(true);
if (result.ok) {
// Launch warnings propagate (transcript capture failed)
expect(result.result.warnings.some((w: string) => w.includes("Transcript capture failed"))).toBe(true);
// Boundary markers were actually written to transcript files
const rigDir = path.join(tmpDir, "r99");
expect(fs.existsSync(rigDir)).toBe(true);
@@ -215,10 +215,16 @@ describe("SelfAttachService", () => {
});
expect(result.ok).toBe(true);
expect(startPipePaneSpy).toHaveBeenCalledWith(
"dev1-impl2@rigged-buildout",
transcriptStore.getTranscriptPath("rigged-buildout", "dev1-impl2@rigged-buildout"),
);
// V1 pre-release Item 1: transcript capture path now starts a
// rotation timer (capture-pane overwrite) instead of pipe-pane.
// The rotation module's first tick calls capturePaneContent on the
// adapter; with capturePaneContent unstubbed in the smoke harness,
// the call is best-effort silent. Test the integration boundary
// via getActiveRotationCount.
const { getActiveRotationCount, clearAllTranscriptRotationsForTest } =
await import("../src/domain/transcript-rotation.js");
expect(getActiveRotationCount()).toBeGreaterThan(0);
clearAllTranscriptRotationsForTest();
});
it("attachToNode provisions Claude context collection when self-attaching from tmux", async () => {
@@ -0,0 +1,206 @@
// V1 pre-release CLI/daemon Item 1 — transcript rotation contract.
//
// Covers the new capture-pane periodic-overwrite mechanism that replaced
// the legacy pipe-pane infinite-growth file pattern.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
startTranscriptRotation,
stopTranscriptRotation,
getActiveRotationCount,
getTranscriptRotationOptionsFromEnv,
clearAllTranscriptRotationsForTest,
DEFAULT_TRANSCRIPT_LINES,
DEFAULT_TRANSCRIPT_POLL_INTERVAL_MS,
} from "../src/domain/transcript-rotation.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
interface FakeAdapter {
capturePaneContent: ReturnType<typeof vi.fn>;
/** rest of TmuxAdapter is unused by rotation; cast at call site. */
}
function makeFakeAdapter(captureValue: string | null = "captured-content"): FakeAdapter {
return {
capturePaneContent: vi.fn(async () => captureValue),
};
}
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "transcript-rotation-"));
});
afterEach(() => {
clearAllTranscriptRotationsForTest();
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
// Clear env var overrides set by individual tests.
delete process.env.OPENRIG_TRANSCRIPTS_LINES;
delete process.env.OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS;
delete process.env.RIGGED_TRANSCRIPTS_LINES;
delete process.env.RIGGED_TRANSCRIPTS_POLL_INTERVAL_SECONDS;
});
describe("startTranscriptRotation — capture-pane invocation contract", () => {
it("calls tmuxAdapter.capturePaneContent with sessionName + lines on first tick", async () => {
const adapter = makeFakeAdapter("hello\nworld\n");
const outputPath = path.join(tmpDir, "rig", "session.log");
startTranscriptRotation(
adapter as unknown as TmuxAdapter,
"session@rig",
outputPath,
{ lines: 500, pollIntervalMs: 60_000 },
);
// First tick is async; allow microtasks to flush.
await new Promise((r) => setImmediate(r));
expect(adapter.capturePaneContent).toHaveBeenCalledWith("session@rig", 500);
stopTranscriptRotation("session@rig");
});
it("writes the captured content to the output path atomically", async () => {
const adapter = makeFakeAdapter("line1\nline2\nline3\n");
const outputPath = path.join(tmpDir, "rig", "session.log");
startTranscriptRotation(
adapter as unknown as TmuxAdapter,
"session@rig",
outputPath,
{ lines: 1000, pollIntervalMs: 60_000 },
);
await new Promise((r) => setImmediate(r));
expect(fs.existsSync(outputPath)).toBe(true);
expect(fs.readFileSync(outputPath, "utf8")).toBe("line1\nline2\nline3\n");
// Partial-write tmp file must NOT remain after rename.
const dirEntries = fs.readdirSync(path.dirname(outputPath));
expect(dirEntries.filter((e) => e.includes(".tmp."))).toEqual([]);
stopTranscriptRotation("session@rig");
});
it("overwrites the file each tick rather than appending (bounded size)", async () => {
const adapter = makeFakeAdapter("first-tick");
const outputPath = path.join(tmpDir, "rig", "session.log");
startTranscriptRotation(
adapter as unknown as TmuxAdapter,
"session@rig",
outputPath,
{ lines: 1000, pollIntervalMs: 60_000 },
);
await new Promise((r) => setImmediate(r));
// Swap the adapter return value and trigger a fresh start (idempotent
// replace). The rewrite path must replace, not append.
adapter.capturePaneContent.mockResolvedValueOnce("second-tick");
startTranscriptRotation(
adapter as unknown as TmuxAdapter,
"session@rig",
outputPath,
{ lines: 1000, pollIntervalMs: 60_000 },
);
await new Promise((r) => setImmediate(r));
// File holds the second tick's content only — no concatenation of
// first + second.
expect(fs.readFileSync(outputPath, "utf8")).toBe("second-tick");
stopTranscriptRotation("session@rig");
});
it("silently skips the write when capturePaneContent returns null", async () => {
const adapter = makeFakeAdapter(null);
const outputPath = path.join(tmpDir, "rig", "session.log");
startTranscriptRotation(
adapter as unknown as TmuxAdapter,
"session@rig",
outputPath,
{ lines: 1000, pollIntervalMs: 60_000 },
);
await new Promise((r) => setImmediate(r));
expect(fs.existsSync(outputPath)).toBe(false);
stopTranscriptRotation("session@rig");
});
});
describe("startTranscriptRotation — timer lifecycle", () => {
it("registers exactly one active timer per session and replaces on second start", () => {
const adapter = makeFakeAdapter();
const outputPath = path.join(tmpDir, "rig", "session.log");
expect(getActiveRotationCount()).toBe(0);
startTranscriptRotation(
adapter as unknown as TmuxAdapter,
"session@rig",
outputPath,
{ lines: 1000, pollIntervalMs: 60_000 },
);
expect(getActiveRotationCount()).toBe(1);
startTranscriptRotation(
adapter as unknown as TmuxAdapter,
"session@rig",
outputPath,
{ lines: 1000, pollIntervalMs: 60_000 },
);
expect(getActiveRotationCount()).toBe(1);
stopTranscriptRotation("session@rig");
expect(getActiveRotationCount()).toBe(0);
});
it("stopTranscriptRotation is a safe no-op when no timer is registered", () => {
expect(getActiveRotationCount()).toBe(0);
stopTranscriptRotation("never-started@rig");
expect(getActiveRotationCount()).toBe(0);
});
it("tracks separate timers for separate sessions", () => {
const adapter = makeFakeAdapter();
startTranscriptRotation(
adapter as unknown as TmuxAdapter,
"a@rig",
path.join(tmpDir, "a.log"),
{ lines: 1000, pollIntervalMs: 60_000 },
);
startTranscriptRotation(
adapter as unknown as TmuxAdapter,
"b@rig",
path.join(tmpDir, "b.log"),
{ lines: 1000, pollIntervalMs: 60_000 },
);
expect(getActiveRotationCount()).toBe(2);
stopTranscriptRotation("a@rig");
expect(getActiveRotationCount()).toBe(1);
stopTranscriptRotation("b@rig");
expect(getActiveRotationCount()).toBe(0);
});
});
describe("getTranscriptRotationOptionsFromEnv — env override + defaults", () => {
it("returns the documented defaults when no env vars are set", () => {
const opts = getTranscriptRotationOptionsFromEnv();
expect(opts.lines).toBe(DEFAULT_TRANSCRIPT_LINES);
expect(opts.pollIntervalMs).toBe(DEFAULT_TRANSCRIPT_POLL_INTERVAL_MS);
expect(opts.lines).toBe(1000);
expect(opts.pollIntervalMs).toBe(2000);
});
it("honors OPENRIG_TRANSCRIPTS_LINES + OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS overrides", () => {
process.env.OPENRIG_TRANSCRIPTS_LINES = "500";
process.env.OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS = "5";
const opts = getTranscriptRotationOptionsFromEnv();
expect(opts.lines).toBe(500);
expect(opts.pollIntervalMs).toBe(5000);
});
it("falls back to legacy RIGGED_* env vars when OPENRIG_* unset", () => {
process.env.RIGGED_TRANSCRIPTS_LINES = "250";
process.env.RIGGED_TRANSCRIPTS_POLL_INTERVAL_SECONDS = "10";
const opts = getTranscriptRotationOptionsFromEnv();
expect(opts.lines).toBe(250);
expect(opts.pollIntervalMs).toBe(10_000);
});
it("rejects non-positive / non-numeric values and uses defaults", () => {
process.env.OPENRIG_TRANSCRIPTS_LINES = "0";
process.env.OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS = "not-a-number";
const opts = getTranscriptRotationOptionsFromEnv();
expect(opts.lines).toBe(DEFAULT_TRANSCRIPT_LINES);
expect(opts.pollIntervalMs).toBe(DEFAULT_TRANSCRIPT_POLL_INTERVAL_MS);
});
});
+24 -10
View File
@@ -119,7 +119,12 @@ describe("transcript routes", () => {
expect(body.error).toContain("rig up");
});
it("GET /tail starts transcript capture for a tmux-bound session with no transcript file", async () => {
it("GET /tail registers a transcript rotation timer for a tmux-bound session with no transcript file", async () => {
const {
getActiveRotationCount,
clearAllTranscriptRotationsForTest,
} = await import("../src/domain/transcript-rotation.js");
clearAllTranscriptRotationsForTest();
const rig = rigRepo.createRig("my-rig");
const node = rigRepo.addNode(rig.id, "dev-impl", { role: "worker", runtime: "claude-code" });
sessionRegistry.registerSession(node.id, "dev-impl@my-rig");
@@ -127,26 +132,34 @@ describe("transcript routes", () => {
const store = new TranscriptStore({ transcriptsRoot: tmpDir, enabled: true });
vi.spyOn(store, "ensureTranscriptDir").mockReturnValue(true);
const startPipePaneSpy = vi.fn(async () => ({ ok: true as const }));
// V1 pre-release Item 1: capture-pane returns null (no terminal
// content yet) so the rotation tick is a no-op write — the
// transcript file stays empty and readTail returns null, which
// surfaces as the legacy "started now" 404 to clients polling
// before the first real capture.
const capturePaneSpy = vi.fn(async () => null);
const app = createApp({
db,
rigRepo,
sessionRegistry,
transcriptStore: store,
tmuxAdapter: { startPipePane: startPipePaneSpy } as unknown as TmuxAdapter,
tmuxAdapter: { capturePaneContent: capturePaneSpy } as unknown as TmuxAdapter,
});
const res = await app.request("/api/transcripts/dev-impl@my-rig/tail");
expect(res.status).toBe(404);
const body = await res.json();
expect(body.error).toContain("started now");
expect(startPipePaneSpy).toHaveBeenCalledWith(
"dev-impl@my-rig",
store.getTranscriptPath("my-rig", "dev-impl@my-rig"),
);
expect(getActiveRotationCount()).toBeGreaterThan(0);
clearAllTranscriptRotationsForTest();
});
it("GET /tail returns warmed content after lazy-start capture when output appears quickly", async () => {
const {
getActiveRotationCount,
clearAllTranscriptRotationsForTest,
} = await import("../src/domain/transcript-rotation.js");
clearAllTranscriptRotationsForTest();
const rig = rigRepo.createRig("my-rig");
const node = rigRepo.addNode(rig.id, "dev-impl", { role: "worker", runtime: "claude-code" });
sessionRegistry.registerSession(node.id, "dev-impl@my-rig");
@@ -157,21 +170,22 @@ describe("transcript routes", () => {
.mockReturnValueOnce(null)
.mockReturnValueOnce("READY\n");
vi.spyOn(store, "ensureTranscriptDir").mockReturnValue(true);
const startPipePaneSpy = vi.fn(async () => ({ ok: true as const }));
const capturePaneSpy = vi.fn(async () => "READY\n");
const app = createApp({
db,
rigRepo,
sessionRegistry,
transcriptStore: store,
tmuxAdapter: { startPipePane: startPipePaneSpy } as unknown as TmuxAdapter,
tmuxAdapter: { capturePaneContent: capturePaneSpy } as unknown as TmuxAdapter,
});
const res = await app.request("/api/transcripts/dev-impl@my-rig/tail");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.content).toBe("READY\n");
expect(startPipePaneSpy).toHaveBeenCalledOnce();
expect(getActiveRotationCount()).toBeGreaterThan(0);
expect(readTailSpy).toHaveBeenCalledTimes(2);
clearAllTranscriptRotationsForTest();
});
it("GET /tail with non-positive lines normalizes to default", async () => {
@@ -61,9 +61,15 @@ describe("TranscriptStore", () => {
});
it("returns false on filesystem error without throwing", () => {
const store = new TranscriptStore({ transcriptsRoot: join(tmpDir, "nonexistent", "deep") });
// Directory doesn't exist, write will fail
const result = store.writeBoundaryMarker("my-rig", "dev-impl@my-rig", "test");
// V1 pre-release Item 1: writeBoundaryMarker now mkdir's its
// parent on demand so a fresh rig directory is no longer a fail
// case. The remaining error path is mkdir running into a regular
// file where it expected a directory — reproducible cross-
// platform without permission-quirk tricks.
const collisionPath = join(tmpDir, "rig-name-collision");
writeFileSync(collisionPath, "regular file in the way");
const store = new TranscriptStore({ transcriptsRoot: tmpDir });
const result = store.writeBoundaryMarker("rig-name-collision", "dev-impl@my-rig", "test");
expect(result).toBe(false);
});
});
@@ -67,7 +67,9 @@ describe("config routes (User Settings v0)", () => {
const res = await app.request("/api/config");
expect(res.status).toBe(200);
const body = await res.json() as { settings: Record<string, { value: unknown; source: string }> };
expect(Object.keys(body.settings).length).toBe(17);
// 17 v0 keys + 2 Phase 4 (advisor/operator) + 5 Phase 5 (feed.subscriptions.*)
// + 2 V1 pre-release Item 1 (transcripts.lines / transcripts.poll_interval_seconds).
expect(Object.keys(body.settings).length).toBe(26);
expect(body.settings["daemon.port"]?.source).toBe("default");
expect(body.settings["ui.preview.refresh_interval_seconds"]?.value).toBe(3);
expect(body.settings["ui.preview.max_pins"]?.value).toBe(4);
@@ -62,12 +62,22 @@ describe("SettingsStore (User Settings v0)", () => {
expect([...SETTINGS_VALID_KEYS]).toEqual([
"daemon.port", "daemon.host", "db.path",
"transcripts.enabled", "transcripts.path",
// V1 pre-release CLI/daemon Item 1 — capture-pane rotation tunables.
"transcripts.lines", "transcripts.poll_interval_seconds",
"workspace.root", "workspace.slices_root", "workspace.steering_path",
"workspace.field_notes_root", "workspace.specs_root",
"files.allowlist", "progress.scan_roots",
"ui.preview.refresh_interval_seconds", "ui.preview.max_pins", "ui.preview.default_lines",
"recovery.auto_drive_provider_prompts",
"recovery.provider_auth_env_allowlist",
// V1 attempt-3 Phase 4 — Advisor/Operator placeholders.
"agents.advisor_session", "agents.operator_session",
// V1 attempt-3 Phase 5 P5-3 — For You feed subscription toggles.
"feed.subscriptions.action_required",
"feed.subscriptions.approvals",
"feed.subscriptions.shipped",
"feed.subscriptions.progress",
"feed.subscriptions.audit_log",
]);
});