Fix review regressions and honesty gaps

This commit is contained in:
Mike Schwarz committed 2026-04-08 11:55:11 -07:00
1 parent 0f7876ea38
commit e9d1b4e856
29 files changed
+337 -148

No files matched your search

+2 -1
View File
@@ -24,7 +24,8 @@ Examples:
rig broadcast "System maintenance in 5 minutes."
rig broadcast --rig my-rig "message" --json
Without --rig or --pod, broadcasts to ALL running sessions across all rigs.`)
Without --rig or --pod, broadcasts to ALL running tmux sessions plus any attached external_cli nodes across all rigs.
Unsupported external_cli targets are returned as explicit per-target failures in the result.`)
.action(async (text: string, opts: { rig?: string; pod?: string; force?: boolean; json?: boolean }) => {
const deps = getDeps();
const status = await getDaemonStatus(deps.lifecycleDeps);
+5 -1
View File
@@ -23,7 +23,11 @@ Examples:
rig capture dev-impl@my-rig --lines 50
rig capture --rig my-rig
rig capture --pod dev --rig my-rig
rig capture --rig my-rig --json`)
rig capture --rig my-rig --json
Supported notes:
- Multi-target capture reports unsupported external_cli nodes as explicit per-target failures.
- For outbound-only external_cli nodes, use rig whoami/rig ps instead of rig capture.`)
.action(async (session: string | undefined, opts: { rig?: string; pod?: string; lines?: string; json?: boolean }) => {
const deps = getDeps();
const status = await getDaemonStatus(deps.lifecycleDeps);
+4
View File
@@ -21,6 +21,10 @@ export function releaseCommand(depsOverride?: StatusDeps): Command {
.argument("<rigId>", "Rig identifier")
.option("--delete", "Delete the rig record after a clean release")
.option("--json", "JSON output")
.addHelpText("after", `
Notes:
- Use rig unclaim <sessionRef> to release a single claimed session.
- Release only covers claimed/adopted sessions; OpenRig-launched nodes still require rig down.`)
.action(async (rigId: string, opts: { delete?: boolean; json?: boolean }) => {
const deps = getDeps();
const client = await getClient(deps);
+4
View File
@@ -20,6 +20,10 @@ export function unclaimCommand(depsOverride?: StatusDeps): Command {
cmd
.argument("<sessionRef>", "Claimed session ID or session name")
.option("--json", "JSON output")
.addHelpText("after", `
Notes:
- Use rig release <rigId> to release all claimed sessions from one rig.
- Unclaim preserves the live session; it only removes OpenRig management for that node.`)
.action(async (sessionRef: string, opts: { json?: boolean }) => {
const deps = getDeps();
const client = await getClient(deps);
+6 -6
View File
@@ -13,21 +13,21 @@ interface WhoamiIdentity {
podId: string | null;
podNamespace?: string | null;
memberId: string;
sessionName: string;
sessionName: string | null;
runtime: string;
}
interface WhoamiPeer {
logicalId: string;
sessionName: string;
sessionName: string | null;
runtime: string;
podNamespace?: string | null;
}
interface WhoamiEdge {
kind: string;
to?: { logicalId: string; sessionName: string };
from?: { logicalId: string; sessionName: string };
to?: { logicalId: string; sessionName: string | null };
from?: { logicalId: string; sessionName: string | null };
}
interface WhoamiResult {
@@ -208,7 +208,7 @@ export function whoamiCommand(depsOverride?: StatusDeps): Command {
console.log(`Rig: ${id.rigName}`);
console.log(`Logical ID: ${id.logicalId}`);
console.log(`Pod: ${(id.podNamespace ?? id.podId) ?? "—"} / ${id.memberId}`);
console.log(`Session: ${id.sessionName}`);
console.log(`Session: ${id.sessionName ?? "—"}`);
console.log(`Runtime: ${id.runtime}`);
console.log(`Transport: ${id.attachmentType === "external_cli" ? "external_cli (outbound only)" : id.attachmentType}`);
console.log(`Resolved: via ${data.resolvedBy.replace(/_/g, " ")}`);
@@ -217,7 +217,7 @@ export function whoamiCommand(depsOverride?: StatusDeps): Command {
console.log("");
console.log("Peers:");
for (const peer of data.peers) {
console.log(` ${peer.logicalId.padEnd(20)} ${peer.sessionName.padEnd(30)} ${peer.runtime}`);
console.log(` ${peer.logicalId.padEnd(20)} ${(peer.sessionName ?? "—").padEnd(30)} ${peer.runtime}`);
}
}
+22
View File
@@ -48,6 +48,16 @@ const WHOAMI_RESPONSE = {
commands: { sendExamples: ["rig send dev-qa@my-rig 'message' --verify"], captureExamples: ["rig capture dev-qa@my-rig"] },
};
const UNBOUND_WHOAMI_RESPONSE = {
...WHOAMI_RESPONSE,
identity: {
...WHOAMI_RESPONSE.identity,
sessionName: null,
},
transcript: { enabled: false, path: null, tailCommand: null, grepCommand: null },
commands: { sendExamples: [], captureExamples: [] },
};
describe("Whoami CLI", () => {
let server: http.Server;
let port: number;
@@ -59,6 +69,9 @@ describe("Whoami CLI", () => {
if (url.includes("/api/whoami") && url.includes("nodeId=node-1")) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(WHOAMI_RESPONSE));
} else if (url.includes("/api/whoami") && url.includes("nodeId=node-2")) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(UNBOUND_WHOAMI_RESPONSE));
} else if (url.includes("/api/whoami") && url.includes("sessionName=dev-impl")) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ...WHOAMI_RESPONSE, resolvedBy: "session_name" }));
@@ -272,6 +285,15 @@ describe("Whoami CLI", () => {
expect(output).toContain("Transcript:");
});
it("human output shows missing session honestly and omits transcript section for unbound nodes", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "whoami", "--node-id", "node-2"]);
});
const output = logs.join("\n");
expect(output).toContain("Session: —");
expect(output).not.toContain("Transcript:");
});
it("daemon down with OPENRIG_NODE_ID env returns partial JSON instead of hard-failing", async () => {
process.env["OPENRIG_NODE_ID"] = "node-1";
const program = new Command();
@@ -13,6 +13,11 @@ if (!outputTarget) {
process.exit(0); // No output path — silently exit
}
function logFailure(message, error) {
const suffix = error && error.message ? `: ${error.message}` : "";
console.error(`[openrig][collector] ${message}${suffix}`);
}
let input = "";
process.stdin.setEncoding("utf-8");
process.stdin.on("data", (chunk) => { input += chunk; });
@@ -21,7 +26,8 @@ process.stdin.on("end", () => {
const raw = JSON.parse(input);
const contextWindow = raw.context_window;
if (!contextWindow) {
process.exit(0); // No context_window in payload — skip
logFailure("missing context_window in Claude status line payload");
process.exit(0);
}
const sample = {
@@ -41,6 +47,7 @@ process.stdin.on("end", () => {
const outputPath = resolveOutputPath(outputTarget, raw);
if (!outputPath) {
logFailure("could not resolve output path from Claude status line payload");
process.exit(0);
}
@@ -52,8 +59,8 @@ process.stdin.on("end", () => {
}
fs.writeFileSync(tmpPath, JSON.stringify(sample), "utf-8");
fs.renameSync(tmpPath, outputPath);
} catch {
// Silently exit on any error — best-effort collector
} catch (error) {
logFailure("failed to collect Claude context status line", error);
process.exit(0);
}
});
+21 -14
View File
@@ -37,6 +37,12 @@ export interface AskResult {
guidance?: string;
}
interface StructuredAnswer {
excerpts: string[];
insufficient: boolean;
guidance?: string;
}
export class AskService {
private readonly deps: AskDeps;
@@ -82,9 +88,10 @@ export class AskService {
rig: rigInfo,
evidence: {
backend: "structured",
excerpts: structured,
excerpts: structured.excerpts,
},
insufficient: false,
insufficient: structured.insufficient,
guidance: structured.guidance,
};
}
@@ -147,23 +154,23 @@ export class AskService {
rigName: string,
question: string,
context?: { nodeId?: string; sessionName?: string },
): string[] | null {
): StructuredAnswer | null {
const normalized = question.trim().toLowerCase();
if (!normalized) return null;
if (this.looksLikePeerQuestion(normalized)) {
const identity = this.resolveIdentity(context);
if (identity && identity.identity.rigId === rigId) {
return identity.peers.map((peer) => this.formatPeerLine(peer.logicalId, peer.sessionName, peer.runtime, peer.podNamespace));
return {
excerpts: identity.peers.map((peer) => this.formatPeerLine(peer.logicalId, peer.sessionName, peer.runtime, peer.podNamespace)),
insufficient: false,
};
}
const rig = this.deps.rigRepo.getRig(rigId);
if (!rig) return null;
return rig.nodes.map((node) => {
const parts = node.logicalId.split(".");
const podNamespace = parts.length > 1 ? parts[0]! : null;
const sessionName = node.binding?.tmuxSession ?? node.binding?.externalSessionName ?? "—";
return this.formatPeerLine(node.logicalId, sessionName, node.runtime ?? "unknown", podNamespace);
});
return {
excerpts: [],
insufficient: true,
guidance: "Cannot determine the current node identity for a peer-relative question. Run rig whoami --json from the target session or retry from an attached managed node.",
};
}
return null;
@@ -180,10 +187,10 @@ export class AskService {
private formatPeerLine(
logicalId: string,
sessionName: string,
sessionName: string | null,
runtime: string,
podNamespace: string | null,
): string {
return `${logicalId} session=${sessionName} runtime=${runtime} pod=${podNamespace ?? "—"}`;
return `${logicalId} session=${sessionName ?? "unbound"} runtime=${runtime} pod=${podNamespace ?? "—"}`;
}
}
@@ -23,6 +23,7 @@ type NodeLifecycleRow = {
latest_session_id: string | null;
latest_session_name: string | null;
latest_session_status: string | null;
latest_session_origin: string | null;
};
type RigReleaseRow = {
@@ -349,8 +350,10 @@ export class RigLifecycleService {
return { ok: false, code: "node_not_found", error: `Node '${nodeRef}' not found in rig '${rigId}'.` };
}
const preserveDetachedClaimedSession = node.latest_session_origin === "claimed" && node.latest_session_status === "detached";
let sessionsKilled = 0;
if (node.latest_session_name) {
if (node.latest_session_name && !preserveDetachedClaimedSession) {
const kill = await this.tmuxAdapter?.killSession(node.latest_session_name);
if (kill && !kill.ok && kill.code !== "session_not_found") {
return {
@@ -364,7 +367,7 @@ export class RigLifecycleService {
const persisted: Array<{ type: "session.detached" | "node.removed"; seq: number; createdAt: string }> = [];
const tx = this.db.transaction(() => {
if (node.latest_session_name) {
if (node.latest_session_name && !preserveDetachedClaimedSession) {
const detached = this.eventBus.persistWithinTransaction({
type: "session.detached",
rigId,
@@ -537,7 +540,8 @@ export class RigLifecycleService {
SELECT n.id AS node_id, n.rig_id, n.logical_id, n.pod_id, b.tmux_session,
s.id AS latest_session_id,
s.session_name AS latest_session_name,
s.status AS latest_session_status
s.status AS latest_session_status,
s.origin AS latest_session_origin
FROM nodes n
LEFT JOIN bindings b ON b.node_id = n.id
LEFT JOIN sessions s ON s.id = (
@@ -552,7 +556,8 @@ export class RigLifecycleService {
SELECT n.id AS node_id, n.rig_id, n.logical_id, n.pod_id, b.tmux_session,
s.id AS latest_session_id,
s.session_name AS latest_session_name,
s.status AS latest_session_status
s.status AS latest_session_status,
s.origin AS latest_session_origin
FROM nodes n
LEFT JOIN bindings b ON b.node_id = n.id
LEFT JOIN sessions s ON s.id = (
+48 -59
View File
@@ -86,6 +86,7 @@ interface SessionTransportDeps {
interface SessionRow { node_id: string; session_name: string; }
interface NodeRow { rig_id: string; logical_id: string; }
interface SessionMetaRow { runtime: string | null; attachment_type: string | null; }
interface ResolvedTarget { sessionName: string; rigName: string; nodeLogicalId: string; }
export class SessionTransport {
readonly db: Database.Database;
@@ -137,26 +138,12 @@ export class SessionTransport {
if (allRigs.length === 0) {
return { ok: false, code: "not_found", error: "No rigs found. Check status with: rig ps" };
}
const sessions: Array<{ sessionName: string; rigName: string; nodeLogicalId: string }> = [];
const sessions: ResolvedTarget[] = [];
const seenRigIds = new Set<string>();
for (const rig of allRigs) {
if (seenRigIds.has(rig.id)) continue;
seenRigIds.add(rig.id);
const rigSessions = this.sessionRegistry.getSessionsForRig(rig.id);
const latestByNode = new Map<string, typeof rigSessions[0]>();
for (const s of rigSessions) {
const existing = latestByNode.get(s.nodeId);
if (!existing || s.id > existing.id) latestByNode.set(s.nodeId, s);
}
for (const s of latestByNode.values()) {
if (s.status !== "running") continue;
const binding = this.sessionRegistry.getBindingForNode(s.nodeId);
if (!binding?.tmuxSession) continue;
const nodeRow = this.db
.prepare("SELECT logical_id FROM nodes WHERE id = ?")
.get(s.nodeId) as { logical_id: string } | undefined;
sessions.push({ sessionName: s.sessionName, rigName: rig.name, nodeLogicalId: nodeRow?.logical_id ?? s.nodeId });
}
sessions.push(...this.collectTransportTargetsForRig(rig.id, rig.name));
}
if (sessions.length === 0) {
return { ok: false, code: "not_found", error: "No running sessions found. Check status with: rig ps" };
@@ -225,32 +212,9 @@ export class SessionTransport {
};
}
const sessions: Array<{ sessionName: string; rigName: string; nodeLogicalId: string }> = [];
const sessions: ResolvedTarget[] = [];
for (const rig of rigs) {
const rigSessions = this.sessionRegistry.getSessionsForRig(rig.id);
// Group by nodeId, take latest per node
const latestByNode = new Map<string, typeof rigSessions[0]>();
for (const s of rigSessions) {
const existing = latestByNode.get(s.nodeId);
if (!existing || s.id > existing.id) {
latestByNode.set(s.nodeId, s);
}
}
// Filter to running with tmux binding
for (const s of latestByNode.values()) {
if (s.status !== "running") continue;
const binding = this.sessionRegistry.getBindingForNode(s.nodeId);
if (!binding?.tmuxSession) continue;
// Look up logical ID
const nodeRow = this.db
.prepare("SELECT logical_id FROM nodes WHERE id = ?")
.get(s.nodeId) as { logical_id: string } | undefined;
sessions.push({
sessionName: s.sessionName,
rigName: rig.name,
nodeLogicalId: nodeRow?.logical_id ?? s.nodeId,
});
}
sessions.push(...this.collectTransportTargetsForRig(rig.id, rig.name));
}
if (sessions.length === 0) {
@@ -281,30 +245,16 @@ export class SessionTransport {
}
// Collect running sessions across all matching rigs, deduplicated by rig ID
const sessions: Array<{ sessionName: string; rigName: string; nodeLogicalId: string }> = [];
const sessions: ResolvedTarget[] = [];
const seenRigIds = new Set<string>();
for (const rig of rigs) {
if (seenRigIds.has(rig.id)) continue;
seenRigIds.add(rig.id);
const rigSessions = this.sessionRegistry.getSessionsForRig(rig.id);
const latestByNode = new Map<string, typeof rigSessions[0]>();
for (const s of rigSessions) {
const existing = latestByNode.get(s.nodeId);
if (!existing || s.id > existing.id) latestByNode.set(s.nodeId, s);
}
for (const s of latestByNode.values()) {
if (s.status !== "running") continue;
const binding = this.sessionRegistry.getBindingForNode(s.nodeId);
if (!binding?.tmuxSession) continue;
const nodeRow = this.db
.prepare("SELECT logical_id FROM nodes WHERE id = ?")
.get(s.nodeId) as { logical_id: string } | undefined;
const logicalId = nodeRow?.logical_id ?? s.nodeId;
// Filter by pod name from logicalId
const podPart = logicalId.split(".")[0];
for (const target of this.collectTransportTargetsForRig(rig.id, rig.name)) {
const podPart = target.nodeLogicalId.split(".")[0];
if (podPart === podName) {
sessions.push({ sessionName: s.sessionName, rigName: rig.name, nodeLogicalId: logicalId });
sessions.push(target);
}
}
}
@@ -320,6 +270,45 @@ export class SessionTransport {
return { ok: true, sessions };
}
private collectTransportTargetsForRig(rigId: string, rigName: string): ResolvedTarget[] {
const rigSessions = this.sessionRegistry.getSessionsForRig(rigId);
const latestByNode = new Map<string, typeof rigSessions[0]>();
for (const session of rigSessions) {
const existing = latestByNode.get(session.nodeId);
if (!existing || session.id > existing.id) {
latestByNode.set(session.nodeId, session);
}
}
const rig = this.rigRepo.getRig(rigId);
if (!rig) return [];
const targets: ResolvedTarget[] = [];
for (const node of rig.nodes) {
const binding = this.sessionRegistry.getBindingForNode(node.id);
const latestSession = latestByNode.get(node.id);
if (binding?.attachmentType === "external_cli" && binding.externalSessionName) {
targets.push({
sessionName: binding.externalSessionName,
rigName,
nodeLogicalId: node.logicalId,
});
continue;
}
if (latestSession?.status === "running" && binding?.tmuxSession) {
targets.push({
sessionName: binding.tmuxSession,
rigName,
nodeLogicalId: node.logicalId,
});
}
}
return targets;
}
async send(sessionName: string, text: string, opts?: SendOpts): Promise<SendResult> {
let preVerifyContent: string | null = null;
const sessionMeta = this.getSessionMeta(sessionName);
+19 -18
View File
@@ -16,7 +16,7 @@ export interface WhoamiResult {
podLabel: string | null;
memberId: string;
memberLabel: string | null;
sessionName: string;
sessionName: string | null;
runtime: string;
cwd: string | null;
agentRef: string | null;
@@ -26,15 +26,15 @@ export interface WhoamiResult {
};
peers: Array<{
logicalId: string;
sessionName: string;
sessionName: string | null;
runtime: string;
podId: string | null;
podNamespace: string | null;
memberId: string;
}>;
edges: {
outgoing: Array<{ kind: string; to: { logicalId: string; sessionName: string } }>;
incoming: Array<{ kind: string; from: { logicalId: string; sessionName: string } }>;
outgoing: Array<{ kind: string; to: { logicalId: string; sessionName: string | null } }>;
incoming: Array<{ kind: string; from: { logicalId: string; sessionName: string | null } }>;
};
transcript: {
enabled: boolean;
@@ -122,7 +122,7 @@ export class WhoamiService {
resolve(query: { nodeId?: string; sessionName?: string }): WhoamiResult | null {
let nodeRow: NodeRow | undefined;
let resolvedBy: "node_id" | "session_name";
let currentSessionName: string;
let currentSessionName: string | null;
if (query.nodeId) {
nodeRow = this.db.prepare("SELECT * FROM nodes WHERE id = ?").get(query.nodeId) as NodeRow | undefined;
@@ -197,7 +197,7 @@ export class WhoamiService {
podLabel,
memberId,
memberLabel: nodeRow.label,
sessionName: currentSessionName!,
sessionName: currentSessionName,
runtime: nodeRow.runtime ?? "unknown",
cwd: nodeRow.cwd,
agentRef: nodeRow.agent_ref,
@@ -238,7 +238,7 @@ export class WhoamiService {
.all(nodeRow.rig_id) as EdgeRow[];
// Build node ID → logicalId + sessionName map
const nodeMap = new Map<string, { logicalId: string; sessionName: string }>();
const nodeMap = new Map<string, { logicalId: string; sessionName: string | null }>();
for (const n of rig.nodes) {
nodeMap.set(n.id, {
logicalId: n.logicalId,
@@ -261,25 +261,26 @@ export class WhoamiService {
}
// Build transcript info
const transcriptEnabled = this.transcriptStore.enabled;
const transcriptPath = transcriptEnabled
? this.transcriptStore.getTranscriptPath(rig.rig.name, currentSessionName!)
const transcriptEnabled = this.transcriptStore.enabled && currentSessionName !== null;
const transcriptPath = currentSessionName && transcriptEnabled
? this.transcriptStore.getTranscriptPath(rig.rig.name, currentSessionName)
: null;
const transcript: WhoamiResult["transcript"] = {
enabled: transcriptEnabled,
path: transcriptPath,
tailCommand: transcriptEnabled ? `rig transcript ${currentSessionName!} --tail 100` : null,
grepCommand: transcriptEnabled ? `rig transcript ${currentSessionName!} --grep <pattern>` : null,
tailCommand: transcriptEnabled ? `rig transcript ${currentSessionName} --tail 100` : null,
grepCommand: transcriptEnabled ? `rig transcript ${currentSessionName} --grep <pattern>` : null,
};
// Build command examples from peers
const sendExamples = peers.slice(0, 3).map((p) => `rig send ${p.sessionName} 'message' --verify`);
const captureExamples = peers.slice(0, 3).map((p) => `rig capture ${p.sessionName}`);
const reachablePeers = peers.filter((p) => p.sessionName !== null);
const sendExamples = reachablePeers.slice(0, 3).map((p) => `rig send ${p.sessionName} 'message' --verify`);
const captureExamples = reachablePeers.slice(0, 3).map((p) => `rig capture ${p.sessionName}`);
// Context usage
const contextUsage = this.contextUsageStore
? this.contextUsageStore.getForNode(nodeRow.id, currentSessionName!)
const contextUsage = this.contextUsageStore && currentSessionName
? this.contextUsageStore.getForNode(nodeRow.id, currentSessionName)
: undefined;
return {
@@ -293,7 +294,7 @@ export class WhoamiService {
};
}
private getCurrentSessionName(nodeId: string, rigId: string): string {
private getCurrentSessionName(nodeId: string, rigId: string): string | null {
// Prefer binding's current transport/session anchor
const binding = this.db
.prepare("SELECT tmux_session, external_session_name FROM bindings WHERE node_id = ?")
@@ -306,6 +307,6 @@ export class WhoamiService {
.prepare("SELECT session_name FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1")
.get(nodeId) as { session_name: string } | undefined;
return sess?.session_name ?? `unknown-${nodeId.slice(-6)}`;
return sess?.session_name ?? null;
}
}
+39
View File
@@ -220,7 +220,46 @@ describe("AskService", () => {
expect(result.evidence.backend).toBe("structured");
expect(result.evidence.excerpts[0]).toContain("dev.qa");
expect(result.evidence.excerpts[0]).toContain("dev-qa@my-rig");
expect(result.evidence.excerpts.join("\n")).not.toContain("dev.impl");
expect(result.insufficient).toBe(false);
expect(searchSpy).not.toHaveBeenCalled();
});
it("returns insufficient guidance for peer questions when current identity is unknown", async () => {
const searchSpy = vi.fn(async (): Promise<SearchResult> => ({
backend: "rg",
excerpts: [],
insufficient: true,
}));
const deps = makeDeps({
historyQuery: {
search: searchSpy,
searchChat: vi.fn(() => []),
},
rigRepo: {
findRigsByName: vi.fn((_name: string): Rig[] => [
{ id: "rig-1", name: "my-rig", createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
]),
getRig: vi.fn(() => ({
rig: { id: "rig-1", name: "my-rig", createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
nodes: [
{ id: "node-1", logicalId: "dev.impl", runtime: "claude-code", podId: "pod-dev", binding: { tmuxSession: "dev-impl@my-rig" } },
{ id: "node-2", logicalId: "dev.qa", runtime: "codex", podId: "pod-dev", binding: { tmuxSession: "dev-qa@my-rig" } },
],
edges: [],
}) as never),
},
whoamiService: {
resolve: vi.fn(() => null),
},
});
const svc = new AskService(deps);
const result = await svc.ask("my-rig", "who are my peers?");
expect(result.insufficient).toBe(true);
expect(result.evidence.excerpts).toEqual([]);
expect(result.guidance).toContain("identity");
expect(searchSpy).not.toHaveBeenCalled();
});
});
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import { execSync, spawnSync } from "node:child_process";
import { ClaudeCodeAdapter } from "../src/adapters/claude-code-adapter.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
@@ -61,8 +61,13 @@ describe("Claude Status Line Collector Script", () => {
// T3: Malformed stdin — graceful exit, no output
it("handles malformed stdin gracefully — no output file created", () => {
const outputPath = join(tmpDir, "context", "bad.json");
execSync(`echo 'not json {{' | node ${collectorPath} ${outputPath}`, { encoding: "utf-8" });
const result = spawnSync("node", [collectorPath, outputPath], {
encoding: "utf-8",
input: "not json {{",
});
expect(result.status).toBe(0);
expect(result.stderr).toContain("[openrig][collector]");
expect(existsSync(outputPath)).toBe(false);
});
@@ -82,6 +87,17 @@ describe("Claude Status Line Collector Script", () => {
// Should not throw
execSync(`echo '${VALID_STATUS_LINE}' | node ${collectorPath}`, { encoding: "utf-8" });
});
it("logs a shape error when a directory target cannot be resolved to a session sidecar", () => {
const outputDir = join(tmpDir, "context");
const result = spawnSync("node", [collectorPath, outputDir], {
encoding: "utf-8",
input: JSON.stringify({ context_window: { used_percentage: 10 } }),
});
expect(result.status).toBe(0);
expect(result.stderr).toContain("could not resolve output path");
});
});
describe("ClaudeCodeAdapter Context Collector Provisioning", () => {
+2 -2
View File
@@ -6,10 +6,10 @@ import { coreSchema } from "../src/db/migrations/001_core_schema.js";
import { eventsSchema } from "../src/db/migrations/003_events.js";
import { EventBus } from "../src/domain/event-bus.js";
import type { RigEvent, PersistedEvent } from "../src/domain/types.js";
import { createFullTestDb } from "./helpers/test-app.js";
function setupDb(): Database.Database {
const db = createDb();
migrate(db, [coreSchema, eventsSchema]);
const db = createFullTestDb();
db.prepare("INSERT INTO rigs (id, name) VALUES (?, ?)").run(
"rig-1",
"test-rig"
+2 -1
View File
@@ -17,6 +17,7 @@ import { discoverySchema } from "../../src/db/migrations/012_discovery.js";
import { discoveryFkFix } from "../../src/db/migrations/013_discovery_fk_fix.js";
import { agentspecRebootSchema } from "../../src/db/migrations/014_agentspec_reboot.js";
import { startupContextSchema } from "../../src/db/migrations/015_startup_context.js";
import { chatMessagesSchema } from "../../src/db/migrations/016_chat_messages.js";
import { podNamespaceSchema } from "../../src/db/migrations/017_pod_namespace.js";
import { contextUsageSchema } from "../../src/db/migrations/018_context_usage.js";
import { externalCliAttachmentSchema } from "../../src/db/migrations/019_external_cli_attachment.js";
@@ -72,7 +73,7 @@ import fs from "node:fs";
export function createFullTestDb(): Database.Database {
const db = createDb();
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema]);
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema]);
return db;
}
+2 -3
View File
@@ -14,11 +14,10 @@ import { EventBus } from "../src/domain/event-bus.js";
import { NodeLauncher } from "../src/domain/node-launcher.js";
import type { TmuxAdapter, TmuxResult } from "../src/adapters/tmux.js";
import type { PersistedEvent } from "../src/domain/types.js";
import { createFullTestDb } from "./helpers/test-app.js";
function setupDb(): Database.Database {
const db = createDb();
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, nodeSpecFieldsSchema, checkpointsSchema, agentspecRebootSchema]);
return db;
return createFullTestDb();
}
function mockTmuxAdapter(overrides?: {
+2 -3
View File
@@ -14,11 +14,10 @@ import { EventBus } from "../src/domain/event-bus.js";
import { Reconciler } from "../src/domain/reconciler.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
import type { PersistedEvent } from "../src/domain/types.js";
import { createFullTestDb } from "./helpers/test-app.js";
function setupDb(): Database.Database {
const db = createDb();
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, nodeSpecFieldsSchema, checkpointsSchema, agentspecRebootSchema]);
return db;
return createFullTestDb();
}
function mockTmuxAdapter(
@@ -27,11 +27,10 @@ import type { TmuxAdapter, TmuxResult } from "../src/adapters/tmux.js";
import type { CodexResumeAdapter } from "../src/adapters/codex-resume.js";
import type { ResumeResult } from "../src/adapters/claude-resume.js";
import type { PersistedEvent, Snapshot } from "../src/domain/types.js";
import { createFullTestDb } from "./helpers/test-app.js";
function setupDb(): Database.Database {
const db = createDb();
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, agentspecRebootSchema, startupContextSchema]);
return db;
return createFullTestDb();
}
function mockTmux(): TmuxAdapter {
@@ -159,7 +159,7 @@ describe("Rig lifecycle routes", () => {
expect(events).toHaveLength(1);
});
it("DELETE /api/rigs/:rigId/nodes/:nodeRef kills a detached claimed session before removing the node", async () => {
it("DELETE /api/rigs/:rigId/nodes/:nodeRef preserves a previously detached claimed session while removing the node", async () => {
const rig = setup.rigRepo.createRig("remove-detached-rig");
const discovered = setup.discoveryRepo.upsertDiscoveredSession({
tmuxSession: "phase4-detached-remove",
@@ -191,10 +191,10 @@ describe("Rig lifecycle routes", () => {
expect(res.status).toBe(200);
const body = await res.json();
expect(body.ok).toBe(true);
expect(body.sessionsKilled).toBe(1);
expect(body.sessionsKilled).toBe(0);
const killSession = setup.tmuxAdapter.killSession as ReturnType<typeof import("vitest").vi.fn>;
expect(killSession).toHaveBeenCalledWith("phase4-detached-remove");
expect(killSession).not.toHaveBeenCalled();
expect(setup.rigRepo.getRig(rig.id)?.nodes.find((candidate) => candidate.logicalId === "external.helper")).toBeUndefined();
});
@@ -16,6 +16,11 @@ import { bootstrapSchema } from "../src/db/migrations/011_bootstrap.js";
import { discoverySchema } from "../src/db/migrations/012_discovery.js";
import { discoveryFkFix } from "../src/db/migrations/013_discovery_fk_fix.js";
import { agentspecRebootSchema } from "../src/db/migrations/014_agentspec_reboot.js";
import { startupContextSchema } from "../src/db/migrations/015_startup_context.js";
import { chatMessagesSchema } from "../src/db/migrations/016_chat_messages.js";
import { podNamespaceSchema } from "../src/db/migrations/017_pod_namespace.js";
import { contextUsageSchema } from "../src/db/migrations/018_context_usage.js";
import { externalCliAttachmentSchema } from "../src/db/migrations/019_external_cli_attachment.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { EventBus } from "../src/domain/event-bus.js";
@@ -28,6 +33,7 @@ const ALL_MIGRATIONS = [
checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema,
packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema,
discoverySchema, discoveryFkFix, agentspecRebootSchema,
startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema,
];
function mockTmux(killResult?: { ok: boolean; code?: string; message?: string }): TmuxAdapter {
@@ -19,11 +19,10 @@ import { RigSpecSchema as PodRigSpecSchema } from "../src/domain/rigspec-schema.
import { RigSpecCodec as PodRigSpecCodec } from "../src/domain/rigspec-codec.js";
import { PodRepository } from "../src/domain/pod-repository.js";
import { RigNotFoundError } from "../src/domain/errors.js";
import { createFullTestDb } from "./helpers/test-app.js";
function setupDb(): Database.Database {
const db = createDb();
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, resumeMetadataSchema, checkpointsSchema, nodeSpecFieldsSchema, agentspecRebootSchema, podNamespaceSchema]);
return db;
return createFullTestDb();
}
describe("RigSpecExporter", () => {
@@ -18,11 +18,10 @@ import { RigInstantiator } from "../src/domain/rigspec-instantiator.js";
import type { TmuxAdapter, TmuxResult } from "../src/adapters/tmux.js";
import type { ExecFn } from "../src/adapters/tmux.js";
import type { LegacyRigSpec as RigSpec, PersistedEvent } from "../src/domain/types.js"; // TODO: AS-T08b — migrate to pod-aware RigSpec
import { createFullTestDb } from "./helpers/test-app.js";
function setupDb(): Database.Database {
const db = createDb();
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, resumeMetadataSchema, checkpointsSchema, nodeSpecFieldsSchema, agentspecRebootSchema]);
return db;
return createFullTestDb();
}
function mockTmux(): TmuxAdapter {
@@ -8,11 +8,10 @@ import { resumeMetadataSchema } from "../src/db/migrations/006_resume_metadata.j
import { nodeSpecFieldsSchema } from "../src/db/migrations/007_node_spec_fields.js";
import { externalCliAttachmentSchema } from "../src/db/migrations/019_external_cli_attachment.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { createFullTestDb } from "./helpers/test-app.js";
function setupDb(): Database.Database {
const db = createDb();
migrate(db, [coreSchema, bindingsSessionsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, externalCliAttachmentSchema]);
return db;
return createFullTestDb();
}
function seedRig(db: Database.Database) {
+26 -4
View File
@@ -17,11 +17,10 @@ import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { SessionTransport } from "../src/domain/session-transport.js";
import type { TmuxAdapter, TmuxResult } from "../src/adapters/tmux.js";
import { createFullTestDb } from "./helpers/test-app.js";
function setupDb(): Database.Database {
const db = createDb();
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, externalCliAttachmentSchema]);
return db;
return createFullTestDb();
}
function mockTmux(overrides?: Partial<{
@@ -326,15 +325,17 @@ describe("SessionTransport", () => {
it("resolveSessions global returns all running sessions across all rigs", async () => {
seedCanonicalRig(); // rig "my-rig" with dev-impl@my-rig
seedLegacyRig(); // rig "r00-legacy" with r00-legacy-worker-a
seedExternalCliRig();
const transport = createTransport();
const result = await transport.resolveSessions({ global: true });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.sessions.length).toBe(2);
expect(result.sessions.length).toBe(3);
const names = result.sessions.map((s) => s.sessionName).sort();
expect(names).toContain("dev-impl@my-rig");
expect(names).toContain("r00-legacy-worker-a");
expect(names).toContain("orch1-lead@rigged-buildout");
}
});
@@ -360,4 +361,25 @@ describe("SessionTransport", () => {
expect(result.sessions[0]!.sessionName).toBe("dev-impl@multi-rig");
}
});
it("broadcast includes external_cli targets as explicit transport_unavailable failures", async () => {
seedCanonicalRig();
seedExternalCliRig();
const transport = createTransport();
const result = await transport.broadcast({ global: true }, "hello", { force: true });
expect(result.total).toBe(2);
expect(result.sent).toBe(1);
expect(result.failed).toBe(1);
expect(result.results).toEqual(
expect.arrayContaining([
expect.objectContaining({
sessionName: "orch1-lead@rigged-buildout",
ok: false,
reason: "transport_unavailable",
}),
]),
);
});
});
@@ -18,11 +18,10 @@ import { SnapshotRepository } from "../src/domain/snapshot-repository.js";
import { CheckpointStore } from "../src/domain/checkpoint-store.js";
import { SnapshotCapture } from "../src/domain/snapshot-capture.js";
import type { PersistedEvent } from "../src/domain/types.js";
import { createFullTestDb } from "./helpers/test-app.js";
function setupDb(): Database.Database {
const db = createDb();
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, agentspecRebootSchema, startupContextSchema]);
return db;
return createFullTestDb();
}
describe("SnapshotCapture", () => {
@@ -179,6 +179,20 @@ describe("TranscriptStore", () => {
expect(result).toBe("echo OPS_SHELL_READY\necho RIG_BROADCAST_OK\nRIG_BROADCAST_OK\n");
});
it("preserves terse legitimate output lines during tail cleanup", () => {
const store = new TranscriptStore({ transcriptsRoot: tmpDir });
store.ensureTranscriptDir("my-rig");
const filePath = store.getTranscriptPath("my-rig", "dev@my-rig");
writeFileSync(
filePath,
"mschwarz@mike-air rigged % echo ACK\nACK\nmschwarz@mike-air rigged % \n",
);
const result = store.readTail("my-rig", "dev@my-rig", 5);
expect(result).toBe("echo ACK\nACK\n");
});
it("normalizes carriage-return prompt redraws before filtering shell prompt lines", () => {
const store = new TranscriptStore({ transcriptsRoot: tmpDir });
store.ensureTranscriptDir("my-rig");
+40 -3
View File
@@ -19,11 +19,10 @@ import { SessionRegistry } from "../src/domain/session-registry.js";
import { SessionTransport } from "../src/domain/session-transport.js";
import type { TmuxAdapter, TmuxResult } from "../src/adapters/tmux.js";
import { transportRoutes } from "../src/routes/transport.js";
import { createFullTestDb } from "./helpers/test-app.js";
function setupDb(): Database.Database {
const db = createDb();
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, externalCliAttachmentSchema]);
return db;
return createFullTestDb();
}
function mockTmux(): TmuxAdapter {
@@ -190,6 +189,25 @@ describe("transport routes", () => {
expect(body.results.every((r: { ok: boolean }) => r.ok)).toBe(true);
});
it("POST /capture with rig targeting includes external_cli targets as explicit failures", async () => {
seedRig();
seedExternalCliRig();
const tmux = mockTmux();
const transport = new SessionTransport({ db, rigRepo, sessionRegistry, tmuxAdapter: tmux });
const app = createApp({ sessionTransport: transport });
const res = await app.request("/api/transport/capture", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rig: "rigged-buildout" }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.results).toHaveLength(1);
expect(body.results[0]!.ok).toBe(false);
expect(body.results[0]!.reason).toBe("transport_unavailable");
});
it("POST /capture for external_cli target returns 409 with honest transport guidance", async () => {
seedExternalCliRig();
const tmux = mockTmux();
@@ -252,4 +270,23 @@ describe("transport routes", () => {
expect(body.sent).toBe(1);
expect(body.failed).toBe(1);
});
it("POST /broadcast includes external_cli targets as explicit transport_unavailable failures", async () => {
seedExternalCliRig();
const tmux = mockTmux();
const transport = new SessionTransport({ db, rigRepo, sessionRegistry, tmuxAdapter: tmux });
const app = createApp({ sessionTransport: transport });
const res = await app.request("/api/transport/broadcast", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rig: "rigged-buildout", text: "broadcast message", force: true }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.total).toBe(1);
expect(body.sent).toBe(0);
expect(body.failed).toBe(1);
expect(body.results[0]!.reason).toBe("transport_unavailable");
});
});
+2 -3
View File
@@ -17,11 +17,10 @@ import { SessionRegistry } from "../src/domain/session-registry.js";
import { TranscriptStore } from "../src/domain/transcript-store.js";
import { WhoamiService } from "../src/domain/whoami-service.js";
import { whoamiRoutes } from "../src/routes/whoami.js";
import { createFullTestDb } from "./helpers/test-app.js";
function setupDb(): Database.Database {
const db = createDb();
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, agentspecRebootSchema, podNamespaceSchema]);
return db;
return createFullTestDb();
}
describe("whoami routes", () => {
+21 -3
View File
@@ -18,11 +18,10 @@ import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { TranscriptStore } from "../src/domain/transcript-store.js";
import { WhoamiService } from "../src/domain/whoami-service.js";
import { createFullTestDb } from "./helpers/test-app.js";
function setupDb(): Database.Database {
const db = createDb();
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, podNamespaceSchema, externalCliAttachmentSchema]);
return db;
return createFullTestDb();
}
describe("WhoamiService", () => {
@@ -141,4 +140,23 @@ describe("WhoamiService", () => {
expect(result!.identity.attachmentType).toBe("external_cli");
expect(result!.identity.sessionName).toBe("orch1-lead@rigged-buildout");
});
it("resolve returns null session and no transcript affordances for an unbound node", () => {
const rig = rigRepo.createRig("my-rig");
const node = rigRepo.addNode(rig.id, "dev.impl", {
role: "worker",
runtime: "claude-code",
});
const result = svc.resolve({ nodeId: node.id });
expect(result).not.toBeNull();
expect(result!.identity.sessionName).toBeNull();
expect(result!.transcript.enabled).toBe(false);
expect(result!.transcript.path).toBeNull();
expect(result!.transcript.tailCommand).toBeNull();
expect(result!.transcript.grepCommand).toBeNull();
expect(result!.commands.sendExamples).toEqual([]);
expect(result!.commands.captureExamples).toEqual([]);
});
});