mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
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:
1 parent
ec43d48af9
commit
a7a34ca1ed
16 files changed
+489
-52
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user