mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-12 01:41:39 -10:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4414ca2fc8 | ||
|
|
25b8f05991 | ||
|
|
a093534871 | ||
|
|
65b466235e | ||
|
|
27d5d6d206 | ||
|
|
aa71304a7d | ||
|
|
492170b1b5 |
No files matched your search
@@ -13,7 +13,9 @@ provider usage stop updating, and Claude resume-token capture at adoption or
|
||||
handover loses its current session evidence. An earlier sidecar can remain and
|
||||
contain an older session ID; without one, context and capture report
|
||||
`missing_sidecar`, and provider usage reports `no_statusline_cache_yet` unless an
|
||||
earlier cache remains.
|
||||
earlier cache remains. A cache that exists but cannot be read reports
|
||||
`claude_statusline_cache_unreadable`; a readable cache that carried no rate
|
||||
limits reports `claude_statusline_cache_had_no_windows`.
|
||||
|
||||
## Feed both commands the same input
|
||||
|
||||
|
||||
@@ -82,6 +82,13 @@ All of them are server-sent events (`text/event-stream`):
|
||||
`?replay=all`, no `replay`, or any other value sends the whole log first. A `Last-Event-ID` header (what
|
||||
`EventSource` sends when it reconnects) sends the events after that id instead, whatever `replay` says.
|
||||
Live events that arrive during the replay follow it, without duplicates.
|
||||
When a person clicks an answer on an ask with questions (in Steering, the Feed, Slack or an MCP client), the stream
|
||||
carries `queue.answer_recorded`: `{ qitemId, actorSession, questionId, answered, questions, complete, summary }`.
|
||||
`complete: false` means the set is still partial: the ask stays open, and its answers haven't gone back yet. The
|
||||
event carries ids and counts, not the choice; read the ask for its `humanAnswers`. A click that changes nothing (the
|
||||
same option again) has no event, and neither does a typed reply, which settles the ask through its own
|
||||
`queue.updated`. The event is recorded after the answer: if it can't be recorded, the answer still stands, so a
|
||||
client that re-reads the ask after a gap still sees it.
|
||||
- **`GET /api/activity/events`**: live only, no replay or ids. Each message is named after its event type:
|
||||
`seat.activity_changed`, `seat.rung_health`, `proof.judged` or `proof.sources_changed`. It tells a view to read
|
||||
again; it carries no derived state.
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
OPENRIG_DIR,
|
||||
STATE_FILE,
|
||||
resolveBindIntent,
|
||||
statusGuardMessage,
|
||||
} from "../daemon-lifecycle.js";
|
||||
|
||||
interface ProcessAliveDeps {
|
||||
@@ -37,14 +38,16 @@ export function createIsProcessAlive(deps: ProcessAliveDeps): (pid: number) => b
|
||||
|
||||
export type SignalOutcome = "sent" | "missing" | "not-permitted";
|
||||
|
||||
/** kill(pid, 0) as an outcome. POSIX: only ESRCH means no such process; EPERM means the process
|
||||
* EXISTS but this shell may not signal it (another user's process, or a sandbox such as Codex's). */
|
||||
/** kill(pid, 0) as an outcome. POSIX: kill(pid, 0) fails only with ESRCH (no such process) or EPERM
|
||||
* (the process EXISTS but this shell may not signal it: another user's process, or a sandbox such as
|
||||
* Codex's). Only ESRCH is "missing"; any other error is not evidence of death, so it reads as
|
||||
* "not-permitted" (uninspectable), never as a dead process. */
|
||||
export function signalProbe(pid: number, kill: (pid: number, signal: 0) => unknown = process.kill): SignalOutcome {
|
||||
try {
|
||||
kill(pid, 0);
|
||||
return "sent";
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code === "EPERM" ? "not-permitted" : "missing";
|
||||
return (error as NodeJS.ErrnoException).code === "ESRCH" ? "missing" : "not-permitted";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +162,11 @@ export function realDeps(): LifecycleDeps {
|
||||
isProcessAlive,
|
||||
// RULING 1ae863d2 — sibling-home scan (home-resolution honesty).
|
||||
listDir: (p) => { try { return fs.readdirSync(p); } catch { return []; } },
|
||||
// #2860: where the OS has pid namespaces (Linux), a missing pid is only proof from the daemon's own.
|
||||
pidNamespace: () => {
|
||||
if (process.platform !== "linux") return "host";
|
||||
try { return fs.readlinkSync("/proc/self/ns/pid"); } catch { return undefined; }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -369,6 +377,12 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
|
||||
console.log(` resolved: ${status.siblingHint.resolvedHome}`);
|
||||
console.log(` sibling: ${status.siblingHint.siblingHome}`);
|
||||
console.log(" Fix: point OPENRIG_HOME at the right home (or check your shell env) — this CLI is likely resolving the wrong home.");
|
||||
} else if (status.pidUnseen) {
|
||||
// #2860: the same words as the command guard.
|
||||
const unseen = statusGuardMessage(status);
|
||||
console.log(unseen.fact);
|
||||
console.log(` ${unseen.consequence}`);
|
||||
console.log(` ${unseen.action}`);
|
||||
} else if (!status.startPending) {
|
||||
// A pending start's probe was refused, not inconclusive; its line above says what is known.
|
||||
console.log("Daemon state UNVERIFIED — the probe timed out or was inconclusive (this is NOT evidence the daemon is down).");
|
||||
|
||||
@@ -1395,6 +1395,13 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.option("--summary <text>", "Summary persisted onto the item (human-seat parks, or standalone summary update without --state)")
|
||||
.option("--evidence-ref <path>", "Pointer to the durable artifact, persisted onto the item on a human-seat park or a done close (--state done). For a code result, a JSON file of kind openrig.code-result/v1 (repo, base and revision as full commit ids, paths) lets a reader diff it exactly")
|
||||
.option("--note <text>", "Transition note for the audit log")
|
||||
.addHelpText("after", `
|
||||
After reading an existing remote successor, record its exact closure target, including the host:
|
||||
rig queue update <source-id> --note "custody-verified: receiver@team@remote-host. Registered-host read confirmed the successor"
|
||||
The note can accompany the closing update or be appended later; a separate note is not required.
|
||||
A trailing period, comma, semicolon, colon or closing parenthesis is accepted; a longer or different target is not.
|
||||
For a new cross-host transfer, prefer rig queue handoff --host <registered-host>.
|
||||
`)
|
||||
.option("--source <name@external>", "Outside a managed seat: the external routine that appends the --note, recorded as an external actor (claimed:v1), never a seat; note only, on this host's daemon (a --host that resolves to another host is refused). A managed seat stays the actor. Like any note, it resets the item's stall and notification timing (and its recovery and operator episodes) and advances a claimed item's last activity, but never resets how long an unclaimed item has waited")
|
||||
.option("--host <id>", QUEUE_HOST_ROW_OPTION_HELP)
|
||||
.option("--full", "Print the whole response row(s) as today, even when output.compact is on (the receipt's waiting keeps only blocker and nextBackstop)")
|
||||
|
||||
@@ -22,8 +22,15 @@ export interface DaemonState {
|
||||
host?: string;
|
||||
db: string;
|
||||
startedAt: string;
|
||||
/** The pid namespace (Linux `/proc/self/ns/pid`) of the shell that started the daemon. A shell
|
||||
* in another namespace cannot tell a missing pid from an invisible one, nor map the number.
|
||||
* Absent on older records. */
|
||||
pidNamespace?: string;
|
||||
}
|
||||
|
||||
/** Linux' initial pid namespace: where an older record, which names no namespace, is read. */
|
||||
const INITIAL_PID_NAMESPACE = "pid:[4026531836]";
|
||||
|
||||
/**
|
||||
* OPR.0.4.3.21 — event-loop wedge evidence read from the enriched `/healthz`
|
||||
* body. Present only when the daemon actually answered healthz with a monitor
|
||||
@@ -64,6 +71,10 @@ export interface DaemonStatus {
|
||||
eventLoop?: DaemonEventLoopEvidence;
|
||||
/** #275 — the OS code behind a failed health probe (e.g. EPERM when a sandbox blocked it). */
|
||||
probeErrorCode?: string;
|
||||
/** The recorded pid looks missing and the connection was refused, but this shell is not in the
|
||||
* daemon's pid namespace and no clean-shutdown receipt matches: nothing here shows the daemon is
|
||||
* down (#2860). */
|
||||
pidUnseen?: true;
|
||||
/** T2 — a refused listener under a live start reservation (daemon-start.lock): a launch is in
|
||||
* progress and not listening yet. `pid` is the live launcher (else child) PID; `since` its start. */
|
||||
startPending?: { pid: number; since?: string };
|
||||
@@ -152,6 +163,15 @@ export function statusGuardMessage(status: DaemonStatus): DaemonNotRunningError
|
||||
...causeCode,
|
||||
};
|
||||
}
|
||||
// #2860: a refused probe and a missing pid, seen from outside the daemon's pid namespace.
|
||||
if (status.pidUnseen) {
|
||||
return {
|
||||
fact: "Couldn't connect to the daemon from this shell; it may still be running.",
|
||||
consequence: "This shell can't see the daemon's process (a sandbox or another namespace) and no clean shutdown was recorded, so a refused connection here doesn't show it is down.",
|
||||
action: "Don't start another daemon from here. Check from a normal terminal with 'rig daemon status'.",
|
||||
...causeCode,
|
||||
};
|
||||
}
|
||||
// unverified, or running-but-unhealthy: we do NOT know it is down.
|
||||
return {
|
||||
fact: "Daemon did not respond — it may be busy or stopped (state not confirmed).",
|
||||
@@ -248,6 +268,9 @@ export interface LifecycleDeps {
|
||||
/** #275 — three-state liveness for status reads (production: realDeps). Optional so existing
|
||||
* mocks keep the boolean rule; absent = derived from isProcessAlive. */
|
||||
processLiveness?: (pid: number) => ProcessLiveness;
|
||||
/** This shell's pid namespace ("host" where the OS has none, undefined when unreadable).
|
||||
* Production: realDeps. Absent = no namespace evidence, a missing pid counts as dead. */
|
||||
pidNamespace?: () => string | undefined;
|
||||
// OPR.0.4.2.1 — optional injectable delay for the status-probe bounded settle/retry.
|
||||
// Defaults to a real setTimeout in production; tests pass a no-op to stay fast. Optional so
|
||||
// existing deps / mocks / callers are untouched.
|
||||
@@ -889,7 +912,8 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D
|
||||
}
|
||||
assertChild();
|
||||
if (!deps.isProcessAlive(pid!)) throw new Error(`Daemon child ${pid} liveness could not be confirmed; state not published`);
|
||||
const state: DaemonState = { pid: pid!, port, host: probeHost, db, startedAt: new Date().toISOString() };
|
||||
const namespace = deps.pidNamespace?.();
|
||||
const state: DaemonState = { pid: pid!, port, host: probeHost, db, startedAt: new Date().toISOString(), ...(namespace && namespace !== "host" ? { pidNamespace: namespace } : {}) };
|
||||
try {
|
||||
deps.writeFile(STATE_FILE, JSON.stringify(state, null, 2));
|
||||
assertChild();
|
||||
@@ -950,6 +974,17 @@ function isCleanShutdown(receipt: DaemonShutdownReceipt | undefined): boolean {
|
||||
return receipt?.outcome === "clean" && receipt.phase === "complete" && receipt.failures.length === 0;
|
||||
}
|
||||
|
||||
/** Whether the recorded pid number means the same process in this shell. A recorded namespace must
|
||||
* be this shell's own: another namespace (even the host's) numbers processes differently, so its
|
||||
* missing number says nothing. Only an older record, which names none, is read as the host's. */
|
||||
function sharesPidNamespace(state: DaemonState, deps: LifecycleDeps): boolean {
|
||||
if (!deps.pidNamespace) return true;
|
||||
const own = deps.pidNamespace();
|
||||
if (own === undefined) return false;
|
||||
if (state.pidNamespace) return own === state.pidNamespace;
|
||||
return own === "host" || own === INITIAL_PID_NAMESPACE;
|
||||
}
|
||||
|
||||
function removeMatchingState(deps: LifecycleDeps, stateFile: string, state: DaemonState): void {
|
||||
// A concurrent start/rebind must not have its state removed by an old stop/status.
|
||||
const current = readState(deps);
|
||||
@@ -1153,7 +1188,23 @@ export async function getDaemonStatus(
|
||||
return { state: "running", port: Number(url.port) || DEFAULT_PORT, host: url.hostname || DEFAULT_HOST, healthy: ev.healthy, reason: ev.reason, eventLoop: ev.eventLoop, ...(ev.databaseWarnings ? { databaseWarnings: ev.databaseWarnings } : {}) };
|
||||
} catch (err) {
|
||||
// 1ae863d2: refusal = positive down; anything else (timeout/wedged/blocked) = unverified.
|
||||
return isRefusedError(err) ? { state: "stopped" } : { state: "unverified", ...endpoint, ...probeErrorCodeOf(err) };
|
||||
if (!isRefusedError(err)) return { state: "unverified", ...endpoint, ...probeErrorCodeOf(err) };
|
||||
// #2860: a loopback URL that is this home's recorded daemon is judged like the record: a refusal
|
||||
// alone proves nothing from a shell that can't see the pid. No record for it keeps the plain rule.
|
||||
const recorded = "explicitRemote" in endpoint ? null : readState(deps);
|
||||
if (!recorded || recorded.port !== ("port" in endpoint ? endpoint.port : undefined)) return { state: "stopped" };
|
||||
const recordedHost = recorded.host ?? DEFAULT_HOST;
|
||||
const recordedLiveness: ProcessLiveness = deps.processLiveness
|
||||
? deps.processLiveness(recorded.pid)
|
||||
: deps.isProcessAlive(recorded.pid) ? "alive" : "dead";
|
||||
const { receipt } = readShutdownReceipt(deps, resolveLifecycleFile(deps, "daemon.json"));
|
||||
if ((receiptMatchesState(receipt, recorded) && isCleanShutdown(receipt))
|
||||
|| (recordedLiveness === "dead" && sharesPidNamespace(recorded, deps))) return { state: "stopped" };
|
||||
const startPending = liveStartReservation(deps);
|
||||
return {
|
||||
state: "unverified", pid: recorded.pid, port: recorded.port, host: recordedHost, ...(startPending ? { startPending } : {}),
|
||||
...(recordedLiveness !== "alive" && !startPending ? { pidUnseen: true as const } : {}), ...probeErrorCodeOf(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1187,17 +1238,21 @@ export async function getDaemonStatus(
|
||||
}
|
||||
}
|
||||
|
||||
const liveness: ProcessLiveness = deps.processLiveness
|
||||
let liveness: ProcessLiveness = deps.processLiveness
|
||||
? deps.processLiveness(state.pid)
|
||||
: deps.isProcessAlive(state.pid) ? "alive" : "dead";
|
||||
// #2860: down needs positive evidence: the daemon's clean-shutdown receipt, or a missing pid seen
|
||||
// from the daemon's own pid namespace. Elsewhere a missing pid may just be invisible.
|
||||
const stateFile = resolveLifecycleFile(deps, "daemon.json");
|
||||
const { receipt } = readShutdownReceipt(deps, stateFile);
|
||||
const cleanShutdown = receiptMatchesState(receipt, state) && isCleanShutdown(receipt);
|
||||
if (liveness === "dead") {
|
||||
// A status read must not erase the identity needed to judge a failed stop.
|
||||
const stateFile = resolveLifecycleFile(deps, "daemon.json");
|
||||
const { receipt } = readShutdownReceipt(deps, stateFile);
|
||||
if (options.cleanupStaleState !== false && receiptMatchesState(receipt, state) && isCleanShutdown(receipt)) {
|
||||
if (cleanShutdown && options.cleanupStaleState !== false) {
|
||||
// A status read must not erase the identity needed to judge a failed stop.
|
||||
removeMatchingState(deps, stateFile, state);
|
||||
}
|
||||
return { state: "stale" };
|
||||
if (cleanShutdown || sharesPidNamespace(state, deps)) return { state: "stale" };
|
||||
liveness = "unknown";
|
||||
}
|
||||
|
||||
const host = state.host ?? DEFAULT_HOST;
|
||||
@@ -1213,7 +1268,9 @@ export async function getDaemonStatus(
|
||||
// T2: unless a live start reservation says a launch is in progress.
|
||||
if (isRefusedError(err)) {
|
||||
const startPending = liveStartReservation(deps);
|
||||
return startPending ? { state: "unverified", pid: state.pid, port: state.port, host, startPending, ...probeErrorCodeOf(err) } : { state: "stopped" };
|
||||
if (startPending) return { state: "unverified", pid: state.pid, port: state.port, host, startPending, ...probeErrorCodeOf(err) };
|
||||
// #2860: a refusal from a shell that can't see the pid proves nothing without a clean shutdown.
|
||||
return cleanShutdown ? { state: "stopped" } : { state: "unverified", pid: state.pid, port: state.port, host, pidUnseen: true, ...probeErrorCodeOf(err) };
|
||||
}
|
||||
return { state: "unverified", pid: state.pid, port: state.port, host, ...probeErrorCodeOf(err) };
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export function getOfflineHelpPath(): string | undefined {
|
||||
* reporting a starved loop) gets the request, under its normal timeout. */
|
||||
export function daemonMayAnswer(status: DaemonStatus): boolean {
|
||||
if (status.state === "stopped" || status.state === "stale") return false;
|
||||
if (status.probeErrorCode === "ECONNREFUSED" || status.startPending) return false;
|
||||
if (status.probeErrorCode === "ECONNREFUSED" || status.startPending || status.pidUnseen) return false;
|
||||
if (status.siblingHint || isBlockedConnectionCode(status.probeErrorCode)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -289,6 +289,12 @@ describe("sandboxed liveness: EPERM on kill(pid, 0) is not a dead daemon", () =>
|
||||
expect(signalProbe(1, throwing("ESRCH"))).toBe("missing");
|
||||
});
|
||||
|
||||
it("an error other than ESRCH is never evidence that the process is gone", () => {
|
||||
const throwing = (code: string | undefined) => () => { throw Object.assign(new Error(String(code)), { code }); };
|
||||
expect(signalProbe(1, throwing("EINVAL"))).toBe("not-permitted");
|
||||
expect(signalProbe(1, throwing(undefined))).toBe("not-permitted");
|
||||
});
|
||||
|
||||
it("unknown liveness plus a blocked probe is unverified with EPERM, never stale, and the guard says blocked", async () => {
|
||||
const status = await getDaemonStatus(withState({
|
||||
isProcessAlive: () => false,
|
||||
@@ -304,14 +310,16 @@ describe("sandboxed liveness: EPERM on kill(pid, 0) is not a dead daemon", () =>
|
||||
expect(guard.fact).not.toMatch(/not running/i);
|
||||
});
|
||||
|
||||
it("unknown liveness plus a refused probe is still a stopped daemon with down advice", async () => {
|
||||
// #2860: a refusal from a shell that can't inspect the pid is "stopped" only with the daemon's clean-shutdown receipt.
|
||||
it("unknown liveness plus a refused probe and no clean shutdown is unverified, not stopped", async () => {
|
||||
const status = await getDaemonStatus(withState({
|
||||
isProcessAlive: () => false,
|
||||
processLiveness: sandboxLiveness,
|
||||
fetch: probeFailing("ECONNREFUSED"),
|
||||
}));
|
||||
expect(status.state).toBe("stopped");
|
||||
expect(statusGuardMessage(status).fact).toMatch(/not running/i);
|
||||
expect(status.state).toBe("unverified");
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
expect(statusGuardMessage(status).fact).toMatch(/couldn't connect.*from this shell/i);
|
||||
});
|
||||
|
||||
it("unknown liveness plus a healthy probe is running", async () => {
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
// #2860 — a sandbox that cuts loopback often also hides the daemon's pid. A refused probe plus a
|
||||
// missing pid then looked exactly like a stopped daemon, and the guard told the agent to run `rig up`.
|
||||
//
|
||||
// Contract pinned here: "stopped" needs positive evidence, either the daemon's own clean-shutdown
|
||||
// receipt or a missing pid seen from the daemon's pid namespace. Otherwise status says the CLI
|
||||
// couldn't connect from this shell and the daemon may still be running, and never advises starting
|
||||
// another daemon.
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
daemonStatusGuard,
|
||||
getDaemonStatus,
|
||||
statusGuardMessage,
|
||||
STATE_FILE,
|
||||
type DaemonState,
|
||||
type LifecycleDeps,
|
||||
} from "../src/daemon-lifecycle.js";
|
||||
import { daemonCommand } from "../src/commands/daemon.js";
|
||||
import { daemonMayAnswer, shouldAutoStartDaemon } from "../src/daemon-reachability.js";
|
||||
|
||||
const INITIAL_NAMESPACE = "pid:[4026531836]";
|
||||
const DAEMON_NAMESPACE = "pid:[4026532001]";
|
||||
const SANDBOX_NAMESPACE = "pid:[4026532777]";
|
||||
const START = "2026-01-01T00:00:00Z";
|
||||
|
||||
const REFUSED: LifecycleDeps["fetch"] = async () => {
|
||||
const err = new Error("fetch failed") as Error & { cause?: { code: string } };
|
||||
err.cause = { code: "ECONNREFUSED" };
|
||||
throw err;
|
||||
};
|
||||
|
||||
function cleanReceipt(pid: number): string {
|
||||
const now = new Date().toISOString();
|
||||
return JSON.stringify({ schema: "openrig.daemon-shutdown/v1", pid, startedAt: START, completedAt: now, outcome: "clean", phase: "complete", failures: [] });
|
||||
}
|
||||
|
||||
function deps(state: DaemonState, over: Partial<LifecycleDeps> = {}, receipt?: string): LifecycleDeps {
|
||||
return {
|
||||
spawn: vi.fn() as unknown as LifecycleDeps["spawn"],
|
||||
fetch: vi.fn(REFUSED),
|
||||
kill: vi.fn(() => true),
|
||||
readFile: vi.fn((p: string) => (p === STATE_FILE ? JSON.stringify(state) : p.endsWith("daemon-shutdown.json") ? receipt ?? null : null)),
|
||||
writeFile: vi.fn(),
|
||||
removeFile: vi.fn(),
|
||||
exists: vi.fn((p: string) => p === STATE_FILE || (receipt !== undefined && p.endsWith("daemon-shutdown.json"))),
|
||||
mkdirp: vi.fn(),
|
||||
openForAppend: vi.fn(() => 0),
|
||||
isProcessAlive: vi.fn(() => false),
|
||||
processLiveness: vi.fn(() => "dead" as const),
|
||||
pidNamespace: () => SANDBOX_NAMESPACE,
|
||||
sleep: async () => {},
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const recorded = (extra: Partial<DaemonState> = {}): DaemonState =>
|
||||
({ pid: 4242, port: 7433, db: "x.db", startedAt: START, pidNamespace: DAEMON_NAMESPACE, ...extra });
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("#2860: a refused probe with an invisible pid is not a stopped daemon", () => {
|
||||
it("reads 'couldn't connect from this shell' when the pid is missing, the probe is refused and there is no clean shutdown", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded()));
|
||||
expect(status.state).toBe("unverified");
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
const message = statusGuardMessage(status);
|
||||
expect(message.fact).toMatch(/couldn't connect to the daemon from this shell.*may still be running/i);
|
||||
expect(`${message.fact} ${message.consequence} ${message.action}`).not.toMatch(/rig up|rig daemon start/i);
|
||||
expect(message.action).toMatch(/don't start another daemon/i);
|
||||
expect(shouldAutoStartDaemon(status)).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks commands through the guard with the same message", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded()));
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "error").mockImplementation((line: string) => { lines.push(line); });
|
||||
const savedExit = process.exitCode;
|
||||
try {
|
||||
expect(daemonStatusGuard(status)).toBe(false);
|
||||
} finally { process.exitCode = savedExit; }
|
||||
const message = statusGuardMessage(status);
|
||||
expect(lines).toEqual([`Error: ${message.fact}`, ` ${message.consequence}`, ` ${message.action}`]);
|
||||
});
|
||||
|
||||
it("is the same on an older record that names no namespace", async () => {
|
||||
const status = await getDaemonStatus(deps({ pid: 4242, port: 7433, db: "x.db", startedAt: START }));
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
});
|
||||
|
||||
it("is the same when this shell cannot read its own namespace", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), { pidNamespace: () => undefined }));
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
});
|
||||
|
||||
it("is the same when the pid exists but cannot be inspected (unknown liveness) and the probe is refused", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), { processLiveness: vi.fn(() => "unknown" as const) }));
|
||||
expect(status.state).toBe("unverified");
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
});
|
||||
|
||||
it("still answers 'running' when the probe gets through, whatever the pid looks like", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), { fetch: vi.fn(async () => ({ ok: true })) }));
|
||||
expect(status.state).toBe("running");
|
||||
});
|
||||
|
||||
it("does not remove state it cannot prove stale", async () => {
|
||||
const d = deps(recorded());
|
||||
await getDaemonStatus(d);
|
||||
expect(d.removeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("#2860: the real stopped cases keep reading as down", () => {
|
||||
it("a clean shutdown for the recorded pid reads stopped from any shell", async () => {
|
||||
const d = deps(recorded(), {}, cleanReceipt(4242));
|
||||
const status = await getDaemonStatus(d);
|
||||
expect(status.state).toBe("stale");
|
||||
expect(statusGuardMessage(status).fact).toBe("Daemon not running.");
|
||||
expect(shouldAutoStartDaemon(status)).toBe(true);
|
||||
expect(d.removeFile).toHaveBeenCalledWith(STATE_FILE);
|
||||
});
|
||||
|
||||
it("a clean shutdown plus a refused probe reads stopped even when the pid state is unknown", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), { processLiveness: vi.fn(() => "unknown" as const) }, cleanReceipt(4242)));
|
||||
expect(status.state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("a receipt for another pid is no evidence", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), {}, cleanReceipt(1111)));
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the daemon's own namespace", recorded(), () => DAEMON_NAMESPACE],
|
||||
["the initial namespace it was started in", recorded({ pidNamespace: INITIAL_NAMESPACE }), () => INITIAL_NAMESPACE],
|
||||
["an older record, from the initial namespace", recorded({ pidNamespace: undefined }), () => INITIAL_NAMESPACE],
|
||||
["an older record, on a platform without pid namespaces", recorded({ pidNamespace: undefined }), () => "host"],
|
||||
])("a dead pid in a normal shell (%s) still reads down so 'rig up' can start it", async (_label, state, namespace) => {
|
||||
const status = await getDaemonStatus(deps(state, { pidNamespace: namespace }));
|
||||
expect(status.state).toBe("stale");
|
||||
expect(statusGuardMessage(status).fact).toBe("Daemon not running.");
|
||||
expect(shouldAutoStartDaemon(status)).toBe(true);
|
||||
});
|
||||
|
||||
it("a number recorded in a nested namespace says nothing from the initial one, whatever it finds there", async () => {
|
||||
const fetch = vi.fn(REFUSED);
|
||||
const status = await getDaemonStatus(deps(recorded(), { pidNamespace: () => INITIAL_NAMESPACE, fetch }));
|
||||
expect(status.state).toBe("unverified");
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
expect(fetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("...and a healthy endpoint reads running, not stale", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), { pidNamespace: () => INITIAL_NAMESPACE, fetch: vi.fn(async () => ({ ok: true })) }));
|
||||
expect(status.state).toBe("running");
|
||||
});
|
||||
|
||||
it("deps without namespace evidence keep the old rule", async () => {
|
||||
const d = deps(recorded());
|
||||
delete d.pidNamespace;
|
||||
expect((await getDaemonStatus(d)).state).toBe("stale");
|
||||
});
|
||||
});
|
||||
|
||||
describe("#2860: a selected loopback OPENRIG_URL is judged like the record", () => {
|
||||
const withUrl = async (url: string, d: LifecycleDeps) => {
|
||||
process.env.OPENRIG_URL = url;
|
||||
try { return await getDaemonStatus(d); } finally { delete process.env.OPENRIG_URL; }
|
||||
};
|
||||
const URL_OF_DAEMON = "http://127.0.0.1:7433";
|
||||
|
||||
it("a refusal with an invisible pid and no clean shutdown is not stopped, and 'rig up' is not advised", async () => {
|
||||
const status = await withUrl(URL_OF_DAEMON, deps(recorded()));
|
||||
expect(status.state).toBe("unverified");
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
expect(shouldAutoStartDaemon(status)).toBe(false);
|
||||
expect(daemonMayAnswer(status)).toBe(false);
|
||||
});
|
||||
|
||||
it("a missing pid in the daemon's own namespace reads stopped", async () => {
|
||||
expect((await withUrl(URL_OF_DAEMON, deps(recorded(), { pidNamespace: () => DAEMON_NAMESPACE }))).state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("a matching clean shutdown reads stopped from any shell", async () => {
|
||||
expect((await withUrl(URL_OF_DAEMON, deps(recorded(), {}, cleanReceipt(4242)))).state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("with no record for that endpoint a refusal keeps reading stopped", async () => {
|
||||
expect((await withUrl("http://127.0.0.1:9999", deps(recorded()))).state).toBe("stopped");
|
||||
expect((await withUrl(URL_OF_DAEMON, deps(recorded(), { exists: vi.fn(() => false), readFile: vi.fn(() => null) }))).state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("a remote URL keeps reading stopped on a refusal", async () => {
|
||||
expect((await withUrl("http://daemon.example:7433", deps(recorded()))).state).toBe("stopped");
|
||||
});
|
||||
});
|
||||
|
||||
describe("#2860: `rig daemon status` says what the guard says", () => {
|
||||
it("prints the guard's three lines", async () => {
|
||||
const d = deps(recorded());
|
||||
const status = await getDaemonStatus(d);
|
||||
const message = statusGuardMessage(status);
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((line: string) => { lines.push(line); });
|
||||
await daemonCommand(d).parseAsync(["status"], { from: "user" });
|
||||
expect(lines).toEqual([message.fact, ` ${message.consequence}`, ` ${message.action}`]);
|
||||
});
|
||||
});
|
||||
@@ -78,7 +78,8 @@ describe("rig edge", () => {
|
||||
expect(result.out).toHaveLength(1);
|
||||
const error = JSON.parse(result.out[0]!).error;
|
||||
expect(error).toMatchObject({ fact: expect.any(String), consequence: expect.any(String), action: expect.any(String) });
|
||||
if (causeCode === "ECONNREFUSED") expect(error.fact).toBe("Daemon not running.");
|
||||
// #2860: a refused connection alone isn't proof the daemon is stopped, so rig edge says so only on positive evidence.
|
||||
if (causeCode === "ECONNREFUSED") expect(error.fact).toBe("Daemon did not respond — it may be busy or stopped (state not confirmed).");
|
||||
else expect(error).toMatchObject({ causeCode: "EPERM", fact: expect.stringContaining("blocked") });
|
||||
expect(client.get).not.toHaveBeenCalled();
|
||||
expect(client.post).not.toHaveBeenCalled();
|
||||
|
||||
@@ -61,15 +61,16 @@ export interface ClaudeUsageReaderDeps {
|
||||
|
||||
/**
|
||||
* Read each Claude seat's provider_usage cache → provider_statusline rows via
|
||||
* claudeStatuslineSignals. Explicit unknown rows for absent cache (no_statusline_cache_yet) and
|
||||
* cache-present-but-no/-malformed windows (empty_reading).
|
||||
* Never throws — a malformed cache is treated as present-with-no-windows, not a crash.
|
||||
* claudeStatuslineSignals. Explicit unknown rows for absent cache (no_statusline_cache_yet),
|
||||
* an unreadable cache (unreadable_cache), and a valid cache with no windows (empty_reading).
|
||||
* Never throws — a malformed cache becomes an unknown row, not a crash.
|
||||
*/
|
||||
export function collectClaudeStatuslineSignals(deps: ClaudeUsageReaderDeps): ProviderSignal[] {
|
||||
const now = deps.now();
|
||||
const out: ProviderSignal[] = [];
|
||||
for (const seat of deps.listClaudeSeats()) {
|
||||
let cachePresent = false;
|
||||
let cacheUnreadable = false;
|
||||
let reading: ClaudeStatuslineReading | undefined;
|
||||
let staleAfter: string | undefined;
|
||||
let capturedAsOf: string | undefined;
|
||||
@@ -84,7 +85,8 @@ export function collectClaudeStatuslineSignals(deps: ClaudeUsageReaderDeps): Pro
|
||||
staleAfter = parsed.staleAfter;
|
||||
capturedAsOf = typeof parsed.asOf === "string" ? parsed.asOf : undefined;
|
||||
} catch {
|
||||
// Malformed cache → present-with-no-windows (empty_reading unknown), never a throw.
|
||||
// Malformed cache → present-but-unreadable (its own unknown), never a throw.
|
||||
cacheUnreadable = true;
|
||||
reading = undefined;
|
||||
}
|
||||
}
|
||||
@@ -92,6 +94,7 @@ export function collectClaudeStatuslineSignals(deps: ClaudeUsageReaderDeps): Pro
|
||||
...claudeStatuslineSignals({
|
||||
seatSession: seat.seatSession,
|
||||
cachePresent,
|
||||
cacheUnreadable,
|
||||
reading,
|
||||
// Signal asOf = when the reading was captured (the cache's asOf) when present+valid; else now.
|
||||
asOf: cachePresent && capturedAsOf ? capturedAsOf : now,
|
||||
@@ -109,6 +112,7 @@ export function collectClaudeSignalsFromProviderUsageDirectory(
|
||||
legacyDirectory?: string,
|
||||
): ProviderSignal[] {
|
||||
const seats = new Map<string, ClaudeSeatRef>();
|
||||
const unreadableFiles = new Set<string>();
|
||||
for (const candidateDirectory of [directory, legacyDirectory]) {
|
||||
if (!candidateDirectory) continue;
|
||||
try {
|
||||
@@ -119,10 +123,16 @@ export function collectClaudeSignalsFromProviderUsageDirectory(
|
||||
if (typeof parsed.seatSession === "string") {
|
||||
seats.set(parsed.seatSession, { seatSession: parsed.seatSession });
|
||||
}
|
||||
} catch { /* malformed cache cannot create a usable cache signal */ }
|
||||
} catch {
|
||||
// A cache file that exists but cannot be read or parsed still names its seat in the
|
||||
// filename (telemetrySidecarFilename sanitizes the same seat name). Keep it in the
|
||||
// seat list so the reader reports an unreadable cache instead of the file vanishing.
|
||||
unreadableFiles.add(file.slice(0, -".json".length));
|
||||
}
|
||||
}
|
||||
} catch { /* absent cache directory */ }
|
||||
}
|
||||
for (const seatSession of unreadableFiles) seats.set(seatSession, { seatSession });
|
||||
|
||||
const readFrom = (candidateDirectory: string, seatSession: string): string | null => {
|
||||
try {
|
||||
|
||||
@@ -131,6 +131,8 @@ export interface ClaudeSignalInput {
|
||||
seatSession: string;
|
||||
/** false = the statusline cache has not been written yet (before the first API response). */
|
||||
cachePresent: boolean;
|
||||
/** True when a cache file exists but cannot be read or parsed. Distinct from an empty reading. */
|
||||
cacheUnreadable?: boolean;
|
||||
/** Present only when the cache exists and carried a rate_limits object. */
|
||||
reading?: ClaudeStatuslineReading;
|
||||
asOf: string;
|
||||
@@ -140,6 +142,7 @@ export interface ClaudeSignalInput {
|
||||
export const CLAUDE_UNKNOWN_REASON = {
|
||||
no_statusline_cache_yet: "claude_no_statusline_cache_yet",
|
||||
empty_reading: "claude_statusline_cache_had_no_windows",
|
||||
unreadable_cache: "claude_statusline_cache_unreadable",
|
||||
} as const;
|
||||
|
||||
function claudeUnknownSignal(
|
||||
@@ -165,17 +168,22 @@ function claudeUnknownSignal(
|
||||
/**
|
||||
* Normalize a Claude statusline `rate_limits` reading into `signals[]` rows.
|
||||
* - seat before first response (no cache) → explicit unknown row.
|
||||
* - a cache that exists but cannot be read/parsed → its own unknown reason (broken, not empty).
|
||||
* - seat with subscription windows → one `provider_statusline` row per window (five_hour, and
|
||||
* seven_day normalized to "weekly"); a genuine 0 survives as 0.
|
||||
* - subscription with cache but no windows → explicit unknown row (never fabricated zeros).
|
||||
*/
|
||||
export function claudeStatuslineSignals(input: ClaudeSignalInput): ProviderSignal[] {
|
||||
const { seatSession, cachePresent, reading, asOf, staleAfter } = input;
|
||||
const { seatSession, cachePresent, cacheUnreadable, reading, asOf, staleAfter } = input;
|
||||
|
||||
if (!cachePresent) {
|
||||
return [claudeUnknownSignal(seatSession, asOf, CLAUDE_UNKNOWN_REASON.no_statusline_cache_yet, staleAfter)];
|
||||
}
|
||||
|
||||
if (cacheUnreadable) {
|
||||
return [claudeUnknownSignal(seatSession, asOf, CLAUDE_UNKNOWN_REASON.unreadable_cache, staleAfter)];
|
||||
}
|
||||
|
||||
const windows: Array<["five_hour" | "weekly", ClaudeStatuslineWindow | undefined]> = [
|
||||
["five_hour", reading?.five_hour],
|
||||
["weekly", reading?.seven_day],
|
||||
|
||||
@@ -4400,6 +4400,8 @@ export class QueueRepository {
|
||||
clickedTs?: string }): RecordHumanAnswerResult {
|
||||
if (!this.hasHumanQuestionsColumn) return { status: "not-applicable", reason: "schema" };
|
||||
if (this.closeStaleRunnerPermission(input.qitemId)) return { status: "not-applicable", reason: "state-canceled" };
|
||||
// Set inside the transaction when this click changed the answers (a declared type, so the closure's write is seen).
|
||||
let changed = null as { summary: string | null; answered: number } | null;
|
||||
const result = this.db.transaction((): RecordHumanAnswerResult => {
|
||||
const item = this.getById(input.qitemId);
|
||||
if (!item?.humanQuestions?.length) return { status: "not-applicable" as const, reason: "no-questions" };
|
||||
@@ -4430,8 +4432,21 @@ export class QueueRepository {
|
||||
const answers: HumanAnswers = { ...(item.humanAnswers ?? {}), [input.questionId]: input.optionId };
|
||||
this.db.prepare("UPDATE queue_items SET human_answers = ? WHERE qitem_id = ?").run(JSON.stringify(answers), input.qitemId);
|
||||
const complete = unansweredQuestions(item.humanQuestions, answers).length === 0;
|
||||
if (item.humanAnswers?.[input.questionId] !== input.optionId) {
|
||||
changed = { summary: item.summary ?? null, answered: item.humanQuestions.length - unansweredQuestions(item.humanQuestions, answers).length };
|
||||
}
|
||||
return { status: "recorded" as const, answers, complete, questions: item.humanQuestions };
|
||||
})();
|
||||
// The click is on the ask before anyone hears of it; a failed event never takes it back or holds up its return.
|
||||
if (changed && result.status === "recorded") {
|
||||
try {
|
||||
this.eventBus.emit({ type: "queue.answer_recorded", qitemId: input.qitemId, actorSession: input.actorSession,
|
||||
questionId: input.questionId, answered: changed.answered, questions: result.questions.length,
|
||||
complete: result.complete, summary: changed.summary });
|
||||
} catch (error) {
|
||||
console.warn(`[queue] ${input.qitemId}'s recorded answer has no event`, error);
|
||||
}
|
||||
}
|
||||
// A pending ask's interrupted close finishes here, as theirs; a park resumes with it through the reply resolver.
|
||||
if (result.status === "not-applicable" && result.answeredBy) {
|
||||
this.resolveDirectHumanReply({ qitemId: input.qitemId, actorSession: input.actorSession, decision: "", primaryUser: input.primaryUser });
|
||||
|
||||
@@ -116,6 +116,7 @@ export const PRUNABLE_EVENT_TYPES = [
|
||||
"queue.claimed",
|
||||
"queue.unclaimed",
|
||||
"queue.handed_off",
|
||||
"queue.answer_recorded",
|
||||
// One delivery-evidence record per send, and one prompt observation per prompt hook; kept only as long as the other
|
||||
// high-volume event types.
|
||||
"transport.delivery_evidence",
|
||||
|
||||
@@ -291,7 +291,9 @@ function custodyVerifiedTargets(db: Database.Database, qitemId: string, targets:
|
||||
for (const { transition_note: note } of notes) {
|
||||
if (!note) continue;
|
||||
for (const token of note.slice(CUSTODY_VERIFIED_PREFIX.length).trim().split(/\s+/)) {
|
||||
const named = [token, ...token.split(",").map((t) => t.trim())].filter(Boolean);
|
||||
const named = [token, ...token.split(",").map((t) => t.trim())]
|
||||
.filter(Boolean)
|
||||
.map((t) => targets.includes(t) ? t : t.replace(/[.,;:)]$/, ""));
|
||||
if (!named.some((t) => targets.includes(t))) continue;
|
||||
for (const t of named) verified.add(t);
|
||||
break;
|
||||
|
||||
@@ -92,7 +92,7 @@ export class Reconciler {
|
||||
// A session this server doesn't show isn't gone while its seat's recorded process still runs: the daemon is
|
||||
// looking at a different tmux server. Detaching it would have the kernel relaunch duplicates and supersede the
|
||||
// live seats. Without that evidence (after a reboot nothing recorded is running), it detaches as before.
|
||||
const elsewhere = await this.recordedProcessStillRunning(session, processes);
|
||||
const elsewhere = await recordedProcessStillRunning(this.db, session, processes);
|
||||
if (elsewhere) {
|
||||
unseen.push({ sessionId: session.id, sessionName: session.sessionName, ...elsewhere });
|
||||
continue;
|
||||
@@ -123,31 +123,32 @@ export class Reconciler {
|
||||
|
||||
return { checked, detached, detachedSessionIds, unseen, errors };
|
||||
}
|
||||
}
|
||||
|
||||
/** The seat's last observed process, as a conservative hint that it still runs, not proof of identity: the last pid
|
||||
* evidence recorded for this very session (kept with its own time, so a later poll that couldn't see the seat doesn't
|
||||
* erase it), and a process with that pid whose start isn't observably later than that observation. Start times are
|
||||
* whole seconds, so a pid reused within the observation's second still passes. An unreadable table or record is no
|
||||
* evidence. */
|
||||
private async recordedProcessStillRunning(
|
||||
session: { nodeId: string; sessionName: string },
|
||||
processes: () => Promise<ProcessTableRow[] | null>,
|
||||
): Promise<{ pid: number; parent: { pid: number; command: string } | null } | null> {
|
||||
const evidence = new SeatIdentityStore(this.db).getProcessEvidence(session.nodeId);
|
||||
if (!evidence || evidence.sessionName !== session.sessionName) return null;
|
||||
const observedAt = Date.parse(evidence.observedAt) / 1000;
|
||||
const pids = [evidence.panePid, evidence.pid]
|
||||
.filter((pid): pid is number => typeof pid === "number" && pid > 0);
|
||||
if (!pids.length || Number.isNaN(observedAt)) return null;
|
||||
const rows = await processes();
|
||||
if (!rows) return null;
|
||||
for (const pid of pids) {
|
||||
const row = rows.find((r) => r.pid === pid);
|
||||
const started = row ? lstartToMinTs(row.startedAt) : undefined;
|
||||
if (!row || started === undefined || started > observedAt) continue;
|
||||
const parent = rows.find((r) => r.pid === row.ppid);
|
||||
return { pid, parent: parent ? { pid: parent.pid, command: parent.command } : null };
|
||||
}
|
||||
return null;
|
||||
/** The seat's last observed process, as a conservative hint that it still runs, not proof of identity: the last pid
|
||||
* evidence recorded for this very session (kept with its own time, so a later poll that couldn't see the seat doesn't
|
||||
* erase it), and a process with that pid whose start isn't observably later than that observation. Start times are
|
||||
* whole seconds, so a pid reused within the observation's second still passes. An unreadable table or record is no
|
||||
* evidence. */
|
||||
export async function recordedProcessStillRunning(
|
||||
db: Database.Database,
|
||||
session: { nodeId: string; sessionName: string },
|
||||
processes: () => Promise<ProcessTableRow[] | null>,
|
||||
): Promise<{ pid: number; parent: { pid: number; command: string } | null } | null> {
|
||||
const evidence = new SeatIdentityStore(db).getProcessEvidence(session.nodeId);
|
||||
if (!evidence || evidence.sessionName !== session.sessionName) return null;
|
||||
const observedAt = Date.parse(evidence.observedAt) / 1000;
|
||||
const pids = [evidence.panePid, evidence.pid]
|
||||
.filter((pid): pid is number => typeof pid === "number" && pid > 0);
|
||||
if (!pids.length || Number.isNaN(observedAt)) return null;
|
||||
const rows = await processes();
|
||||
if (!rows) return null;
|
||||
for (const pid of pids) {
|
||||
const row = rows.find((r) => r.pid === pid);
|
||||
const started = row ? lstartToMinTs(row.startedAt) : undefined;
|
||||
if (!row || started === undefined || started > observedAt) continue;
|
||||
const parent = rows.find((r) => r.pid === row.ppid);
|
||||
return { pid, parent: parent ? { pid: parent.pid, command: parent.command } : null };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -141,6 +141,22 @@ export class SeatIdentityReconciler {
|
||||
`).all() as RunningSeatRow[];
|
||||
}
|
||||
|
||||
/** Nodes whose latest session `rig unclaim` released (detached, origin claimed). Unclaim leaves the process running,
|
||||
* so the node's last recorded process stays on record: a fresh launch reads it before reopening a tmux server, and
|
||||
* must not start a second occupant beside a process that still runs. An older schema without `origin` keeps none. */
|
||||
private releasedSeats(): string[] {
|
||||
try {
|
||||
return (this.db.prepare(`
|
||||
SELECT n.id as node_id FROM nodes n
|
||||
JOIN sessions s ON s.node_id = n.id
|
||||
AND s.id = (SELECT s2.id FROM sessions s2 WHERE s2.node_id = n.id ORDER BY s2.id DESC LIMIT 1)
|
||||
WHERE s.status = 'detached' AND s.origin = 'claimed'
|
||||
`).all() as Array<{ node_id: string }>).map((r) => r.node_id);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconcile every running tmux-bound seat once and persist the verdicts. */
|
||||
async reconcileAll(): Promise<void> {
|
||||
// Skip ticks while actual reads are pending; never release on a deadline
|
||||
@@ -156,8 +172,8 @@ export class SeatIdentityReconciler {
|
||||
|
||||
private async reconcileSweep(generation: number): Promise<void> {
|
||||
const seats = this.runningSeats();
|
||||
// Prune verdicts for nodes no longer running (keep the table bounded).
|
||||
this.store.pruneExcept(seats.map((s) => s.node_id));
|
||||
// Prune verdicts for nodes no longer running (keep the table bounded), except a released seat's.
|
||||
this.store.pruneExcept([...seats.map((s) => s.node_id), ...this.releasedSeats()]);
|
||||
if (seats.length === 0) return;
|
||||
|
||||
const observedAt = this.now().toISOString();
|
||||
|
||||
@@ -55,6 +55,8 @@ import { sessionCommunityHarnessSelect } from "./session-registry.js";
|
||||
import { seatLaunchAdditionsReport } from "./seat-launch-additions.js";
|
||||
import { seatSkillPaths } from "./seat-skill-storage.js";
|
||||
import { resolveSeatKind, seatProcessOwner } from "./seat-kinds.js";
|
||||
import { recordedProcessStillRunning } from "./reconciler.js";
|
||||
import { readProcessTable, type ProcessTableRow } from "./process-table.js";
|
||||
|
||||
/**
|
||||
* S5 (OPR.0.5.4.7) — the seat-lifecycle verb surface: set-model, single-seat stop,
|
||||
@@ -110,6 +112,8 @@ export interface SeatLifecycleDeps {
|
||||
eventBus: EventBus;
|
||||
tmuxAdapter: TmuxAdapter;
|
||||
listProcesses?: NativeProcessLister;
|
||||
/** The OS process table, read before reopening a released seat's tmux server; injectable for tests. */
|
||||
readProcesses?: () => Promise<ProcessTableRow[]>;
|
||||
nodeLauncher?: NodeLauncher;
|
||||
startupOrchestrator?: StartupOrchestrator;
|
||||
runtimeAdapters?: Record<string, RuntimeAdapter>;
|
||||
@@ -338,6 +342,7 @@ export class SeatLifecycleService {
|
||||
private readonly eventBus: EventBus;
|
||||
private readonly tmuxAdapter: TmuxAdapter;
|
||||
private readonly listProcesses?: NativeProcessLister;
|
||||
private readonly readProcesses: () => Promise<ProcessTableRow[]>;
|
||||
private readonly nodeLauncher: NodeLauncher | null;
|
||||
private readonly startupOrchestrator: StartupOrchestrator | null;
|
||||
private readonly runtimeAdapters: Record<string, RuntimeAdapter>;
|
||||
@@ -360,6 +365,7 @@ export class SeatLifecycleService {
|
||||
this.eventBus = deps.eventBus;
|
||||
this.tmuxAdapter = deps.tmuxAdapter;
|
||||
this.listProcesses = deps.listProcesses;
|
||||
this.readProcesses = deps.readProcesses ?? readProcessTable;
|
||||
this.nodeLauncher = deps.nodeLauncher ?? null;
|
||||
this.startupOrchestrator = deps.startupOrchestrator ?? null;
|
||||
this.runtimeAdapters = deps.runtimeAdapters ?? {};
|
||||
@@ -1575,17 +1581,34 @@ export class SeatLifecycleService {
|
||||
// probe sees unavailable transport instead of positive absence. Reopen an
|
||||
// empty server only when the persisted state proves this seat's managed
|
||||
// occupant was deliberately stopped and no other managed row remains live.
|
||||
// `rig unclaim` is as deliberate for an adopted session: it marks the row
|
||||
// detached and clears the binding, and the person ends the process and its
|
||||
// tmux session, which can be the server's last session too.
|
||||
// The probe below still decides absence; a recreated session or failed
|
||||
// transport remains a refusal.
|
||||
if ("code" in canonicalProbe) {
|
||||
const latest = this.latestSession(node.id);
|
||||
const hasCurrentBinding = this.sessionRegistry.getBindingForNode(node.id) !== null;
|
||||
const released = latest?.status === "detached" && latest.origin === "claimed";
|
||||
const stoppedManagedOccupant = latest !== null
|
||||
&& latest.session_name === canonicalSessionName
|
||||
&& latest.status === "exited"
|
||||
&& latest.origin !== "claimed"
|
||||
&& ((latest.status === "exited" && latest.origin !== "claimed") || released)
|
||||
&& !hasCurrentBinding
|
||||
&& this.nonTerminalSessions(node.id).length === 0;
|
||||
// Unclaim doesn't end the process. While the released session's recorded process still runs, its server may
|
||||
// only have lost its socket: a server started there would read the session absent and launch a second occupant
|
||||
// beside it. So keep the refusal, as the reconciler keeps such a session (the same evidence).
|
||||
const stillRuns = stoppedManagedOccupant && released
|
||||
? await recordedProcessStillRunning(this.db, { nodeId: node.id, sessionName: canonicalSessionName },
|
||||
() => this.readProcesses().catch(() => null))
|
||||
: null;
|
||||
if (stillRuns) {
|
||||
return {
|
||||
...canonicalProbe,
|
||||
guidance: `This seat's released session last ran as process ${stillRuns.pid}, which is still running, so it may be `
|
||||
+ "on a tmux server this daemon can't reach. End that process, then launch again.",
|
||||
};
|
||||
}
|
||||
if (stoppedManagedOccupant) {
|
||||
try {
|
||||
const restored = await this.tmuxAdapter.startServer();
|
||||
|
||||
@@ -330,6 +330,10 @@ export type RigEvent =
|
||||
// consumers on /api/views/:name/sse when ANY queue state mutation
|
||||
// changes a view result-set, not just create/handoff/claim/unclaim.
|
||||
| { type: "queue.updated"; qitemId: string; fromState: string; toState: string; closureReason: string | null; closureTarget: string | null; actorSession: string; summary: string | null }
|
||||
// A person's clicked answer recorded on an ask with questions: `answered` of `questions` now have an answer, and
|
||||
// `complete` says whether that's all of them. A partial set is a change to the ask, not its answer: the ask stays open
|
||||
// until the set is complete and returned.
|
||||
| { type: "queue.answer_recorded"; qitemId: string; actorSession: string; questionId: string; answered: number; questions: number; complete: boolean; summary: string | null }
|
||||
// Coordination primitive (PL-004 Phase B) — project (classifier) / view.
|
||||
// project.classified: emitted when a stream item is successfully projected.
|
||||
// classifier.lease_*: lifecycle of the daemon-enforced single-writer lease.
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// Route-level: the real Mission Control route and write contract, the real /api/events stream and a migrated SQLite.
|
||||
// A click on an ask with questions, from Steering or from Slack, is one queue.answer_recorded event that says how much
|
||||
// of the set is answered; a typed reply, which settles the ask, is not one.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { Hono } from "hono";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { MissionControlWriteContract } from "../src/domain/mission-control/mission-control-write-contract.js";
|
||||
import { MissionControlActionLog } from "../src/domain/mission-control/mission-control-action-log.js";
|
||||
import { missionControlRoutes } from "../src/routes/mission-control.js";
|
||||
import { eventsRoute } from "../src/routes/events.js";
|
||||
|
||||
const PERSON = "human-test@external", ASKER = "requester@test-rig";
|
||||
const registry = { ok: true as const, entities: [{ entityId: "human-test", class: "human" as const, displayName: "Test person", address: PERSON,
|
||||
connectorBindings: [{ kind: "slack" as const, connectorRef: "primary", secretsRef: "unused", role: "primary" as const, handle: "UTEST" }],
|
||||
prefs: { deliveryClass: "A" as const } }] };
|
||||
const questions = [
|
||||
{ id: "ship", question: "Ship it?", options: [{ id: "yes", label: "Yes" }, { id: "no", label: "No" }] },
|
||||
{ id: "when", question: "When?", options: [{ id: "today", label: "Today" }, { id: "monday", label: "Monday" }] },
|
||||
];
|
||||
|
||||
const closes: Array<() => void> = [];
|
||||
afterEach(() => {
|
||||
for (const close of closes.splice(0).reverse()) close();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function fixture() {
|
||||
const db = new Database(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
const bus = new EventBus(db);
|
||||
const repo = new QueueRepository(db, bus, { validateRig: () => true, loadHumanRegistry: () => registry,
|
||||
transport: { send: async () => ({ ok: true, verified: true, sent: true }) } });
|
||||
const contract = new MissionControlWriteContract({ db, eventBus: bus, queueRepo: repo, actionLog: new MissionControlActionLog(db) });
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("queueRepo" as never, repo as never); c.set("eventBus" as never, bus as never);
|
||||
c.set("missionControlWriteContract" as never, contract as never);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/mission-control", missionControlRoutes());
|
||||
app.route("/api/events", eventsRoute);
|
||||
closes.push(() => db.close());
|
||||
const ask = async () => (await repo.create({ sourceSession: ASKER, destinationSession: PERSON, body: "Two questions", summary: "Ship and when",
|
||||
humanIntent: "decision", humanQuestions: questions, evidenceRef: "/fixture/decision", nudge: false })).qitemId;
|
||||
// The person at the terminal, as the TUI sends it: no seat header, the primary user's authority.
|
||||
const steering = async (body: Record<string, string>) => {
|
||||
const response = await app.request("/api/mission-control/action", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ verb: "answer", actorSession: PERSON, authority: "primary-user", ...body }) });
|
||||
return { status: response.status, body: await response.json() as Record<string, unknown> };
|
||||
};
|
||||
// What an app following /api/events from its watermark reads: the stored events after it.
|
||||
const eventsAfter = async (watermark: number) => {
|
||||
const response = await app.request("/api/events", { headers: { "Last-Event-ID": String(watermark) } });
|
||||
const reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let text = "";
|
||||
const deadline = Date.now() + 300;
|
||||
while (Date.now() < deadline) {
|
||||
const chunk = await Promise.race([reader.read(), new Promise<null>((resolve) => setTimeout(() => resolve(null), deadline - Date.now()))]);
|
||||
if (!chunk || chunk.done) break;
|
||||
text += decoder.decode(chunk.value, { stream: true });
|
||||
}
|
||||
await reader.cancel().catch(() => {});
|
||||
return text.split("\n").filter((line) => line.startsWith("data:")).map((line) => JSON.parse(line.slice(5)) as Record<string, unknown>);
|
||||
};
|
||||
const recorded = async (watermark: number) => (await eventsAfter(watermark)).filter((event) => event.type === "queue.answer_recorded");
|
||||
return { bus, repo, ask, steering, recorded, eventsAfter };
|
||||
}
|
||||
|
||||
describe("queue.answer_recorded: a person's click on an ask with questions", () => {
|
||||
it("one event per click that changes the answers, partial until the set is complete, from Steering and from Slack", async () => {
|
||||
const f = await fixture();
|
||||
const qitemId = await f.ask();
|
||||
|
||||
let watermark = f.bus.maxSeq();
|
||||
const partial = await f.steering({ qitemId, questionId: "ship", optionId: "yes" });
|
||||
expect(partial.status).toBe(200);
|
||||
expect(f.repo.getById(qitemId)).toMatchObject({ state: "pending", humanAnswers: { ship: "yes" } });
|
||||
const [first, ...noMore] = await f.recorded(watermark);
|
||||
expect(noMore).toEqual([]);
|
||||
// Partial: it says so, and counts the set. Ids and counts only: the person's choice stays on the row.
|
||||
// The person at the terminal OpenRig can't name is recorded as such, as on the answer itself.
|
||||
expect(first).toMatchObject({ type: "queue.answer_recorded", qitemId, actorSession: "human@host", questionId: "ship",
|
||||
answered: 1, questions: 2, complete: false, summary: "Ship and when" });
|
||||
expect(JSON.stringify(first)).not.toMatch(/"yes"|Yes|optionId/);
|
||||
|
||||
// The same click again changes nothing, so there's nothing to hear.
|
||||
watermark = f.bus.maxSeq();
|
||||
expect(f.repo.recordHumanAnswer({ qitemId, actorSession: PERSON, questionId: "ship", optionId: "yes" })).toMatchObject({ status: "recorded", complete: false });
|
||||
expect(await f.recorded(watermark)).toEqual([]);
|
||||
|
||||
// A Slack-style click (the inbound router's call) completes the set.
|
||||
watermark = f.bus.maxSeq();
|
||||
expect(f.repo.recordHumanAnswer({ qitemId, actorSession: PERSON, questionId: "when", optionId: "monday" }))
|
||||
.toMatchObject({ status: "recorded", complete: true });
|
||||
expect(await f.recorded(watermark)).toEqual([expect.objectContaining({ qitemId, questionId: "when", answered: 2, questions: 2, complete: true })]);
|
||||
});
|
||||
|
||||
it("a typed reply settles the ask through its own transitions and records no click event (the control)", async () => {
|
||||
const f = await fixture();
|
||||
const qitemId = await f.ask();
|
||||
|
||||
const watermark = f.bus.maxSeq();
|
||||
const typed = await f.steering({ qitemId, decision: "Ship it on Monday" });
|
||||
expect(typed.status).toBe(200);
|
||||
expect(f.repo.getById(qitemId)?.state).toBe("done");
|
||||
const events = await f.eventsAfter(watermark);
|
||||
expect(events.some((event) => event.type === "queue.updated" && event.qitemId === qitemId)).toBe(true);
|
||||
expect(events.filter((event) => event.type === "queue.answer_recorded")).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails open: a click whose event can't be recorded is still on the ask, and its answer goes back", async () => {
|
||||
const f = await fixture();
|
||||
const qitemId = await f.ask();
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const emit = f.bus.emit.bind(f.bus);
|
||||
vi.spyOn(f.bus, "emit").mockImplementation((event) => {
|
||||
if (event.type === "queue.answer_recorded") throw new Error("events table unavailable");
|
||||
return emit(event);
|
||||
});
|
||||
|
||||
const partial = await f.steering({ qitemId, questionId: "ship", optionId: "no" });
|
||||
expect(partial.status).toBe(200);
|
||||
const complete = await f.steering({ qitemId, questionId: "when", optionId: "today" });
|
||||
expect(complete.status).toBe(200);
|
||||
expect(f.repo.getById(qitemId)).toMatchObject({ state: "done", humanAnswers: { ship: "no", when: "today" } });
|
||||
expect(f.repo.list({ limit: 50 }).filter((item) => item.replyTo === qitemId)).toHaveLength(1);
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining(`${qitemId}'s recorded answer has no event`), expect.any(Error));
|
||||
});
|
||||
});
|
||||
@@ -112,6 +112,40 @@ describe("Slice-04 C3 — seat-keyed Claude provider_usage discovery (production
|
||||
}
|
||||
});
|
||||
|
||||
it("a live seat whose only cache is malformed reports unreadable_cache through real getReadModel", async () => {
|
||||
const root = fs.mkdtempSync(nodePath.join(os.tmpdir(), "provider-c3-unreadable-"));
|
||||
try {
|
||||
const usageDir = nodePath.join(root, "provider-usage");
|
||||
fs.mkdirSync(usageDir, { recursive: true });
|
||||
const seatSession = "dev-impl@test-rig";
|
||||
fs.writeFileSync(nodePath.join(usageDir, `${seatSession}.json`), "{ not json");
|
||||
|
||||
const db = createFullTestDb();
|
||||
seedClaudeSeat(db, seatSession);
|
||||
const svc = new ProviderServiceImpl({
|
||||
db,
|
||||
listRigs: () => [{ id: "rig-1" }],
|
||||
env: emptyCodexHomeEnv(),
|
||||
now: () => ASOF,
|
||||
collectClaudeSignals: () => collectClaudeSignalsFromProviderUsageDirectory(usageDir, () => ASOF),
|
||||
});
|
||||
const model = await svc.getReadModel();
|
||||
const claude = model.signals.filter((signal) => signal.provider === "claude");
|
||||
|
||||
expect(claude).toHaveLength(1);
|
||||
expect(claude[0]).toMatchObject({
|
||||
seatSession,
|
||||
sourceClass: "unknown",
|
||||
authority: "unknown",
|
||||
unknownReason: "claude_statusline_cache_unreadable",
|
||||
automationUse: "do_not_automate",
|
||||
});
|
||||
expect(claude[0]!.usedPercent).toBeUndefined();
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("an exited latest Claude session plus matching stale cache emits zero Claude signals", async () => {
|
||||
const root = fs.mkdtempSync(nodePath.join(os.tmpdir(), "provider-c3-exited-"));
|
||||
try {
|
||||
|
||||
@@ -76,12 +76,20 @@ describe("collectClaudeStatuslineSignals — reader → provider_statusline / ex
|
||||
expect(sigs[0]!.unknownReason).toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
|
||||
});
|
||||
|
||||
it("malformed cache JSON → unknown(empty_reading), never throws", () => {
|
||||
it("malformed cache JSON → unknown(unreadable_cache), never throws", () => {
|
||||
const sigs = collectClaudeStatuslineSignals(readerDeps({
|
||||
listClaudeSeats: () => [{ seatSession: "dev-impl@rig" }],
|
||||
readCacheRaw: () => "{ not json",
|
||||
}));
|
||||
expect(sigs[0]!.sourceClass).toBe("unknown");
|
||||
expect(sigs[0]!.unknownReason).toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
|
||||
});
|
||||
|
||||
it("a valid cache with no windows keeps empty_reading (distinct from an unreadable cache)", () => {
|
||||
const sigs = collectClaudeStatuslineSignals(readerDeps({
|
||||
listClaudeSeats: () => [{ seatSession: "dev-impl@rig" }],
|
||||
readCacheRaw: () => JSON.stringify({ seatSession: "dev-impl@rig", asOf: CACHE_ASOF }),
|
||||
}));
|
||||
expect(sigs[0]!.unknownReason).toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
|
||||
});
|
||||
});
|
||||
@@ -111,14 +119,33 @@ describe("0.5.8 provider-usage compatibility bridge", () => {
|
||||
const malformedCanonical = collectClaudeSignalsFromProviderUsageDirectory(canonical, () => ASOF, legacy);
|
||||
expect(malformedCanonical.find((signal) => signal.seatSession === "legacy@rig")?.usedPercent).toBeUndefined();
|
||||
expect(malformedCanonical.find((signal) => signal.seatSession === "legacy@rig")?.unknownReason)
|
||||
.toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
|
||||
.toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
|
||||
|
||||
fs.rmSync(path.join(canonical, "[email protected]"));
|
||||
fs.mkdirSync(path.join(canonical, "[email protected]"));
|
||||
const wrongTypeCanonical = collectClaudeSignalsFromProviderUsageDirectory(canonical, () => ASOF, legacy);
|
||||
expect(wrongTypeCanonical.find((signal) => signal.seatSession === "legacy@rig")?.usedPercent).toBeUndefined();
|
||||
expect(wrongTypeCanonical.find((signal) => signal.seatSession === "legacy@rig")?.unknownReason)
|
||||
.toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
|
||||
.toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectClaudeSignalsFromProviderUsageDirectory — a malformed-only cache stays visible", () => {
|
||||
it("reports unreadable_cache for a seat known only from a malformed file's name", () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "provider-unreadable-"));
|
||||
const directory = path.join(home, "state", "provider-usage");
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
fs.writeFileSync(path.join(directory, "[email protected]"), "{ not json");
|
||||
try {
|
||||
const signals = collectClaudeSignalsFromProviderUsageDirectory(directory, () => ASOF);
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0]!.seatSession).toBe("broken@rig");
|
||||
expect(signals[0]!.sourceClass).toBe("unknown");
|
||||
expect(signals[0]!.unknownReason).toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
|
||||
expect(signals[0]!.usedPercent).toBeUndefined();
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { claudeStatuslineSignals } from "../src/domain/provider/provider-signals.js";
|
||||
import { CLAUDE_UNKNOWN_REASON, claudeStatuslineSignals } from "../src/domain/provider/provider-signals.js";
|
||||
|
||||
// Slice-04 (OPR.0.5.0.4) — Claude statusline lane normalization (§2 Claude lane + the
|
||||
// 2026-07-31 RESEARCH verdict). Proof item 6: the statusline sidecar's rate_limits object
|
||||
@@ -68,5 +68,21 @@ describe("claudeStatuslineSignals — Claude lane honesty", () => {
|
||||
expect(sigs[0].sourceClass).toBe("unknown");
|
||||
expect(sigs[0].usedPercent).toBeUndefined();
|
||||
expect(sigs[0].automationUse).toBe("do_not_automate");
|
||||
expect(sigs[0].unknownReason).toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
|
||||
});
|
||||
|
||||
it("an unreadable cache gets its own reason, not the empty-reading one", () => {
|
||||
const sigs = claudeStatuslineSignals({
|
||||
seatSession: "dev-impl@rig",
|
||||
cachePresent: true,
|
||||
cacheUnreadable: true,
|
||||
asOf: ASOF,
|
||||
});
|
||||
expect(sigs).toHaveLength(1);
|
||||
expect(sigs[0].sourceClass).toBe("unknown");
|
||||
expect(sigs[0].unknownReason).toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
|
||||
expect(sigs[0].unknownReason).not.toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
|
||||
expect(sigs[0].usedPercent).toBeUndefined();
|
||||
expect(sigs[0].automationUse).toBe("do_not_automate");
|
||||
});
|
||||
});
|
||||
@@ -1767,6 +1767,36 @@ describe("S02 standing stuck sweep — both halves, routed findings, quiet-but-o
|
||||
expect(await findingsFor(row.qitemId)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each(["", ".", ",", ";", ":", ")"])("CUSTODY PUNCTUATION: a closing note accepts the exact target followed by %j", async (punctuation) => {
|
||||
const row = await mkRow();
|
||||
const target = "qitem-20990101000000-punctuation@remote-host";
|
||||
repo.update({ qitemId: row.qitemId, actorSession: "worker@r", state: "done",
|
||||
closureReason: "handed_off_to", closureTarget: target,
|
||||
transitionNote: `custody-verified: ${target}${punctuation} registered-host read confirmed the successor` });
|
||||
await runSweep();
|
||||
expect(await findingsFor(row.qitemId)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each(["x.", ".other.", ":123)"])("CUSTODY PUNCTUATION: extra target characters %j do not verify a prefix", async (suffix) => {
|
||||
const row = await mkRow();
|
||||
const target = "qitem-20990101000000-punctuation@remote-host";
|
||||
repo.update({ qitemId: row.qitemId, actorSession: "worker@r", state: "done",
|
||||
closureReason: "handed_off_to", closureTarget: target,
|
||||
transitionNote: `custody-verified: ${target}${suffix} registered-host read confirmed another successor` });
|
||||
await runSweep();
|
||||
expect(await findingsFor(row.qitemId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("CUSTODY PUNCTUATION: a shortened target is not an exact target", async () => {
|
||||
const row = await mkRow();
|
||||
const target = "qitem-20990101000000-punctuation@remote-host";
|
||||
repo.update({ qitemId: row.qitemId, actorSession: "worker@r", state: "done",
|
||||
closureReason: "handed_off_to", closureTarget: target,
|
||||
transitionNote: `custody-verified: ${target.slice(0, -1)}. checked elsewhere` });
|
||||
await runSweep();
|
||||
expect(await findingsFor(row.qitemId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("DISPOSITION CLOSES THE OPEN FINDING: verification landing after the finding minted auto-closes it on the next sweep, and the finding taught the recipe", async () => {
|
||||
const row = await mkRow();
|
||||
const missing = "qitem-20990101000000-precon02";
|
||||
|
||||
@@ -16,6 +16,7 @@ import { SeatLifecycleService } from "../src/domain/seat-lifecycle-service.js";
|
||||
import { getNodeInventory } from "../src/domain/node-inventory.js";
|
||||
import { PsProjectionService } from "../src/domain/ps-projection.js";
|
||||
import { SeatIdentityStore } from "../src/domain/seat-identity-store.js";
|
||||
import { SeatIdentityReconciler } from "../src/domain/seat-identity-reconciler.js";
|
||||
import type { RuntimeAdapter } from "../src/domain/runtime-adapter.js";
|
||||
import type { ProjectionPlan } from "../src/domain/projection-planner.js";
|
||||
import type { TmuxAdapter, TmuxResult } from "../src/adapters/tmux.js";
|
||||
@@ -24,6 +25,7 @@ import { deriveOriented } from "../src/domain/startup-proof.js";
|
||||
import { deriveRehydrateSessionIdByNode } from "../src/domain/active-occupant.js";
|
||||
import { readFreshOccupantRelations } from "../src/domain/fresh-occupant-relation.js";
|
||||
import type { NativeProcessLister, NativeProcessRow } from "../src/domain/native-process-lineage.js";
|
||||
import type { ProcessTableRow } from "../src/domain/process-table.js";
|
||||
|
||||
import { ClaudeCodeAdapter } from "../src/adapters/claude-code-adapter.js";
|
||||
import { CLAUDE_BYPASS_CONSENT } from "./fixtures/claude-bypass-consent.js";
|
||||
@@ -59,6 +61,7 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
let activitySwaps: Array<{ nodeId: string; generation: string }>;
|
||||
let service: SeatLifecycleService;
|
||||
let listProcesses: NativeProcessLister;
|
||||
let readProcesses: () => Promise<ProcessTableRow[]>;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createFullTestDb();
|
||||
@@ -131,6 +134,7 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
listProcesses = async () => [{ pid: 4242, ppid: 1, pgid: 4242, tpgid: 4242,
|
||||
executableName: "codex", command: "/opt/native/codex -m model", argv: ["/opt/native/codex", "-m", "model"],
|
||||
startedAt: "Sat Jan 1 12:00:00 2000" }];
|
||||
readProcesses = async () => [];
|
||||
runtimeAdapters = { "claude-code": adapter, codex: { ...adapter, runtime: "codex" } };
|
||||
service = new SeatLifecycleService({
|
||||
db,
|
||||
@@ -139,6 +143,7 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
eventBus,
|
||||
tmuxAdapter: tmux,
|
||||
listProcesses: () => listProcesses(),
|
||||
readProcesses: () => readProcesses(),
|
||||
nodeLauncher,
|
||||
startupOrchestrator,
|
||||
runtimeAdapters,
|
||||
@@ -843,6 +848,105 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
expect(alive.has(seat.sessionName)).toBe(true);
|
||||
});
|
||||
|
||||
// `rig unclaim` releases an adopted session (its row detached, its binding cleared) and leaves its process running;
|
||||
// the person then ends it and its tmux session. When that was the server's last session, tmux exits with it.
|
||||
const releaseAdopted = (seat: ReturnType<typeof seedSeat>) => {
|
||||
sessionRegistry.markDetached(seat.session!.id);
|
||||
sessionRegistry.clearBinding(seat.node.id);
|
||||
};
|
||||
|
||||
it("reopens an empty tmux server after rig unclaim released the seat's last session", async () => {
|
||||
const seat = seedSeat({ adopted: true });
|
||||
const server = modelServerLifetime();
|
||||
releaseAdopted(seat);
|
||||
alive.delete(seat.sessionName);
|
||||
livePanes.delete(seat.sessionName);
|
||||
server.up = false;
|
||||
|
||||
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, reason: "start a new occupant" });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, status: "ready", sessionName: seat.sessionName });
|
||||
expect(server).toEqual({ up: true, starts: 1 });
|
||||
expect(tmux.createSession).toHaveBeenCalledTimes(1);
|
||||
expect(killed).toEqual([]);
|
||||
});
|
||||
|
||||
it("still refuses a released seat's fresh launch when its session is live behind a transport blip", async () => {
|
||||
const seat = seedSeat({ adopted: true });
|
||||
const server = modelServerLifetime();
|
||||
releaseAdopted(seat);
|
||||
// The released process is still running in its session; only the first probe fails.
|
||||
vi.mocked(tmux.probeSession).mockResolvedValueOnce({ state: "transport_unavailable", cause: "probe blip" });
|
||||
|
||||
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, reason: "start a new occupant" });
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: "unmanaged_session_collision" });
|
||||
expect(server.starts).toBe(0);
|
||||
expect(tmux.createSession).not.toHaveBeenCalled();
|
||||
expect(killed).toEqual([]);
|
||||
expect(alive.has(seat.sessionName)).toBe(true);
|
||||
});
|
||||
|
||||
// Unclaim doesn't end the process, and tmux can lose its socket while its server keeps running: the probe then
|
||||
// can't reach the server, and a server started at that path is a new, empty one. So the model keeps the two apart:
|
||||
// `alive` and `server.up` are what the socket answers, and the process table is what still runs.
|
||||
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
const lstart = (d: Date) => `${DAYS[d.getDay()]} ${MONTHS[d.getMonth()]} ${String(d.getDate()).padStart(2, " ")} `
|
||||
+ `${[d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, "0")).join(":")} ${d.getFullYear()}`;
|
||||
const processRow = (pid: number, ppid: number, startedAt: Date, command: string): ProcessTableRow =>
|
||||
({ pid, ppid, pgid: pid, tpgid: -1, executableName: command.split(" ")[0]!, startedAt: lstart(startedAt), command });
|
||||
const observed = new Date(Date.now() - 60_000);
|
||||
const releasedWithLostSocket = async () => {
|
||||
const seat = seedSeat({ adopted: true });
|
||||
const server = modelServerLifetime();
|
||||
// Recorded while the adopted seat ran; then released, and the daemon's identity sweep (every 5 s) runs.
|
||||
new SeatIdentityStore(db).upsert({
|
||||
nodeId: seat.node.id, verdict: "verified", evidenceSource: null, reason: null,
|
||||
evidence: { registeredPane: "%old", observedPid: 5151, observedPanePid: 5150, observedCommand: "claude", matchedLayer: 1 },
|
||||
sessionName: seat.sessionName, observedAt: observed.toISOString(),
|
||||
});
|
||||
releaseAdopted(seat);
|
||||
const blindPoll = { listSessions: async () => [] } as unknown as ConstructorParameters<typeof SeatIdentityReconciler>[0]["tmux"];
|
||||
await new SeatIdentityReconciler({ db, tmux: blindPoll }).reconcileAll();
|
||||
alive.delete(seat.sessionName);
|
||||
livePanes.delete(seat.sessionName);
|
||||
server.up = false;
|
||||
return { seat, server };
|
||||
};
|
||||
|
||||
it("keeps refusing a released seat's fresh launch while its recorded process still runs behind a lost socket", async () => {
|
||||
const { seat } = await releasedWithLostSocket();
|
||||
const before = new Date(observed.getTime() - 10 * 60_000);
|
||||
readProcesses = async () => [processRow(5150, 900, before, "zsh"), processRow(900, 1, before, "tmux new-session")];
|
||||
|
||||
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, reason: "start a new occupant" });
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: "tmux_probe_failed",
|
||||
guidance: expect.stringContaining("last ran as process 5150, which is still running") });
|
||||
expect(tmux.startServer).not.toHaveBeenCalled();
|
||||
expect(tmux.createSession).not.toHaveBeenCalled();
|
||||
expect(killed).toEqual([]);
|
||||
expect(sessionRegistry.getSessionsForRig(seat.rig.id).map((s) => s.status)).toEqual(["detached"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "its recorded process has ended", rows: async () => [] as ProcessTableRow[] },
|
||||
{ name: "its pid now belongs to a later process",
|
||||
rows: async () => [processRow(5150, 1, new Date(observed.getTime() + 30_000), "vim")] },
|
||||
{ name: "the process table can't be read", rows: async (): Promise<ProcessTableRow[]> => { throw new Error("ps failed"); } },
|
||||
])("reopens the server for a released seat when $name", async ({ rows }) => {
|
||||
const { seat, server } = await releasedWithLostSocket();
|
||||
readProcesses = rows;
|
||||
|
||||
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, reason: "start a new occupant" });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, status: "ready", sessionName: seat.sessionName });
|
||||
expect(server).toEqual({ up: true, starts: 1 });
|
||||
expect(tmux.createSession).toHaveBeenCalledTimes(1);
|
||||
expect(killed).toEqual([]);
|
||||
});
|
||||
|
||||
it("proves older non-terminal rows absent on the restored server before superseding them", async () => {
|
||||
const seat = seedSeat({ clean: true });
|
||||
const older = sessionRegistry.registerSession(seat.node.id, "r00-dev-impl@fresh-rig");
|
||||
|
||||
@@ -440,6 +440,28 @@ describe("SeatIdentityReconciler.reconcileAll", () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("keeps a released seat's recorded process through the prune, and still prunes a stopped one", async () => {
|
||||
// rig unclaim leaves the latest row detached with origin claimed, and the process running. A fresh launch reads
|
||||
// that process's record before reopening a tmux server, so the 5 s sweep mustn't drop it.
|
||||
const db = createFullTestDb();
|
||||
seedSeat(db, { nodeId: "n1", sessionName: "s1@rig", pane: "%1", status: "detached" });
|
||||
db.prepare("UPDATE sessions SET origin = 'claimed' WHERE node_id = 'n1'").run();
|
||||
seedSeat(db, { nodeId: "n2", sessionName: "s2@rig", pane: "%2", status: "exited" });
|
||||
const store = new SeatIdentityStore(db);
|
||||
for (const [nodeId, sessionName, pid] of [["n1", "s1@rig", 5151], ["n2", "s2@rig", 6161]] as const) {
|
||||
store.upsert({ nodeId, verdict: "verified", evidenceSource: null, reason: null,
|
||||
evidence: { registeredPane: "%1", observedPid: pid, observedPanePid: pid - 1, observedCommand: "claude", matchedLayer: 1 },
|
||||
sessionName, observedAt: NOW().toISOString() });
|
||||
}
|
||||
const rec = new SeatIdentityReconciler({ db, tmux: makeTmux({ sessions: [] }), now: NOW });
|
||||
|
||||
await rec.reconcileAll();
|
||||
|
||||
expect(store.getProcessEvidence("n1")).toEqual({ sessionName: "s1@rig", pid: 5151, panePid: 5150, observedAt: NOW().toISOString() });
|
||||
expect(store.getForNode("n2")).toBeNull();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("liveness is NOT derived from heartbeats — a dead pane stays non-green regardless of any heartbeat", async () => {
|
||||
// The reconciler never reads queue/classifier/hook state; a seat whose pane
|
||||
// is gone yields pane_missing even if (hypothetically) work/heartbeats exist.
|
||||
|
||||
@@ -287,8 +287,10 @@ function toAgentRow(node: NodeInventoryRead): AgentRow {
|
||||
: node.activityState?.display === "idle"
|
||||
? "idle"
|
||||
: (node.terminalActive === true || (node.terminalActive == null && node.agentActivity?.state === "running") ? "active"
|
||||
// A community harness gives no idle signal: a quiet screen is unknown, not idle.
|
||||
: node.communityHarness ? "unknown" : "idle"))
|
||||
// The daemon's activity word decides when it's served (as in `rig ps`): it said unknown, so a quiet
|
||||
// screen stays unknown. A community harness gives no idle signal either. Only an older daemon that
|
||||
// serves no activity word keeps the screen fallback.
|
||||
: node.communityHarness || node.activityState ? "unknown" : "idle"))
|
||||
: (node.sessionStatus ?? "unknown"),
|
||||
live: node.lifecycleState === "running",
|
||||
canRun: node.lifecycleState !== "running"
|
||||
|
||||
@@ -710,7 +710,7 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
|
||||
expect(output.lines.find((line) => /\bmismatch\s/.test(line))).not.toContain("run ▸");
|
||||
expect(output.lines.find((line) => /\bmissing\s/.test(line))).not.toContain("run ▸");
|
||||
});
|
||||
it("shows a quiet community harness as unknown, never idle, and leaves built-in rows as they were", async () => {
|
||||
it("shows a quiet seat the daemon reads as unknown as unknown, never idle, for a community harness and a built-in runtime alike", async () => {
|
||||
const base = (FIXTURES["/api/rigs/01JRIG/nodes"] as Array<Record<string, unknown>>)[0]!;
|
||||
const quiet = { startupStatus: "ready", sessionStatus: "running", terminalActive: false, agentActivity: { state: "unknown" },
|
||||
activityState: { activity: "unknown", display: "unknown", needsInput: { count: 0, reason: null }, decidedBy: null } };
|
||||
@@ -727,8 +727,28 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
|
||||
"dev.app-quiet": "unknown",
|
||||
"dev.app-moving": "active",
|
||||
"dev.app-prompt": "needs_input",
|
||||
"dev.pi-quiet": "idle",
|
||||
"dev.claude-quiet": "idle",
|
||||
"dev.pi-quiet": "unknown",
|
||||
"dev.claude-quiet": "unknown",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the daemon's idle and working words, and the screen fallback only for a daemon that serves no activity word", async () => {
|
||||
const base = (FIXTURES["/api/rigs/01JRIG/nodes"] as Array<Record<string, unknown>>)[0]!;
|
||||
const running = { startupStatus: "ready", sessionStatus: "running", runtime: "claude-code", agentActivity: { state: "unknown" } };
|
||||
const served = (display: string) => ({ activity: display === "idle" ? "idle-at-prompt" : display, display, needsInput: { count: 0, reason: null }, decidedBy: null });
|
||||
const nodes = [
|
||||
{ ...base, ...running, logicalId: "dev.served-idle", terminalActive: false, activityState: served("idle") },
|
||||
{ ...base, ...running, logicalId: "dev.served-working", terminalActive: false, activityState: served("working") },
|
||||
{ ...base, ...running, logicalId: "dev.unknown-moving", terminalActive: true, activityState: served("unknown") },
|
||||
{ ...base, ...running, logicalId: "dev.legacy-quiet", terminalActive: false, activityState: undefined },
|
||||
];
|
||||
const snap = await hydrateSnapshot(fixtureClient({}, { "/api/rigs/01JRIG/nodes": nodes }));
|
||||
const statuses = Object.fromEntries(myrig(snap).pods[0]!.agents.map((agent) => [agent.name, agent.status]));
|
||||
expect(statuses).toEqual({
|
||||
"dev.served-idle": "idle",
|
||||
"dev.served-working": "active",
|
||||
"dev.unknown-moving": "active",
|
||||
"dev.legacy-quiet": "idle",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import fs from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { packEntry } from "./npm-pack-json.mjs";
|
||||
|
||||
const script = fs.readFileSync(new URL("./build-package.sh", import.meta.url), "utf8");
|
||||
const assetsBranch = script.match(/^if \[ -d "\$DAEMON_DIR\/assets" \]; then\n[\s\S]*?^fi$/m)?.[0];
|
||||
@@ -129,7 +130,7 @@ test("offline npm pack carries staged asset bytes and executability, with npm's
|
||||
assertStaged(f);
|
||||
const staged = snapshot(f.staged);
|
||||
fs.writeFileSync(join(f.cli, "package.json"), JSON.stringify({ name: "openrig-assets-fixture", version: "1.0.0", private: true, files: ["daemon"] }));
|
||||
const packed = JSON.parse(execFileSync("bash", ["-c", 'umask 0022; exec npm pack --offline --ignore-scripts --json --pack-destination "$1"', "asset-pack", f.root], { cwd: f.cli, encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"] }))[0];
|
||||
const packed = packEntry(execFileSync("bash", ["-c", 'umask 0022; exec npm pack --offline --ignore-scripts --json --pack-destination "$1"', "asset-pack", f.root], { cwd: f.cli, encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"] }));
|
||||
const files = new Map(packed.files.map(file => [file.path, file]));
|
||||
for (const [name, bytes] of Object.entries(contents)) {
|
||||
const entry = `daemon/assets/${name}`;
|
||||
|
||||
@@ -2,14 +2,14 @@ import { execSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { packEntry } from "./npm-pack-json.mjs";
|
||||
|
||||
test("npm pack of @openrig/cli includes scripts/check-abi.mjs in tarball", () => {
|
||||
const output = execSync("npm pack --dry-run --json 2>/dev/null", {
|
||||
cwd: "packages/cli",
|
||||
encoding: "utf-8",
|
||||
});
|
||||
const entries = JSON.parse(output);
|
||||
const files = entries[0]?.files?.map((f) => f.path) ?? [];
|
||||
const files = packEntry(output).files?.map((f) => f.path) ?? [];
|
||||
|
||||
assert.ok(
|
||||
files.some((f) => f.includes("scripts/check-abi.mjs")),
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
scanInternalLeaks,
|
||||
} from "./internal-leak-scanner.mjs";
|
||||
import { LEAK_GUARD_RULES_ENV, leakGuardRulesPath } from "./leak-guard-rules.mjs";
|
||||
import { packEntries } from "./npm-pack-json.mjs";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const FIX = "Genericize the content, cite its public home, or re-home it to the internal pack root.";
|
||||
@@ -155,13 +156,22 @@ function derivePackageArtifacts(packageRoot) {
|
||||
if (packed.status !== 0) {
|
||||
throw new Error(`npm artifact enumeration failed:\n${packed.stderr || packed.stdout || `exit ${packed.status}`}`);
|
||||
}
|
||||
let parsed;
|
||||
let entries = [];
|
||||
try {
|
||||
parsed = JSON.parse(packed.stdout);
|
||||
// One parse, in the shared reader: a JSON string (even one holding a serialized list) is not a shape npm prints.
|
||||
entries = packEntries(packed.stdout);
|
||||
} catch (error) {
|
||||
throw new Error(`npm artifact enumeration did not return JSON: ${error.message}`);
|
||||
if (error instanceof SyntaxError) throw new Error(`npm artifact enumeration did not return JSON: ${error.message}`);
|
||||
// not a shape npm prints: reported below as no files array
|
||||
}
|
||||
const rawFiles = parsed?.[0]?.files;
|
||||
// One package is packed here; scanning only the first of several would issue a receipt for part of the artifact.
|
||||
if (entries.length > 1) {
|
||||
throw new Error(
|
||||
`npm artifact enumeration listed ${entries.length} packages where one was packed; `
|
||||
+ "run the gate for one package (check --package-root and any npm workspaces setting)",
|
||||
);
|
||||
}
|
||||
const rawFiles = entries[0]?.files;
|
||||
if (!Array.isArray(rawFiles)) throw new Error("npm artifact enumeration returned no files array");
|
||||
const files = rawFiles.map((entry) => entry?.path).filter((path) => typeof path === "string");
|
||||
if (files.length !== rawFiles.length || files.length === 0) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { execFileSync, spawnSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
chmodSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
@@ -405,6 +406,44 @@ test("exact-line allowances reach content and derived scans and are recorded in
|
||||
});
|
||||
});
|
||||
|
||||
test("npm output that is a JSON string is refused, even when the string holds a serialized list", () => {
|
||||
withFixture(({ root, repo, rules, review, receipt }) => {
|
||||
write(join(repo, "public/guide.md"), "Generic product guidance.\n");
|
||||
commitAll(repo, "string output fixture");
|
||||
writeFileSync(review, JSON.stringify({ surfaces: [] }));
|
||||
const bin = join(root, "bin");
|
||||
const serialized = JSON.stringify([{ files: [{ path: "package.json" }, { path: "public/guide.md" }] }]);
|
||||
write(join(bin, "npm"), `#!${process.execPath}\nprocess.stdout.write(${JSON.stringify(JSON.stringify(serialized))});\n`);
|
||||
chmodSync(join(bin, "npm"), 0o755);
|
||||
|
||||
const result = runGate({ repo, rules, review, receipt, cutSha: gitHead(repo),
|
||||
env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } });
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /npm artifact enumeration returned no files array/);
|
||||
});
|
||||
});
|
||||
|
||||
test("npm output that lists more than one package is refused before any scan or receipt", () => {
|
||||
const first = { name: "first", files: [{ path: "package.json" }, { path: "public/guide.md" }] };
|
||||
const second = { name: "second", files: [{ path: "package.json" }, { path: "public/other.md" }] };
|
||||
for (const printed of [[first, second], { first, second }]) {
|
||||
withFixture(({ root, repo, rules, review, receipt }) => {
|
||||
write(join(repo, "public/guide.md"), "Generic product guidance.\n");
|
||||
commitAll(repo, "two package output fixture");
|
||||
writeFileSync(review, JSON.stringify({ surfaces: [] }));
|
||||
const bin = join(root, "bin");
|
||||
write(join(bin, "npm"), `#!${process.execPath}\nprocess.stdout.write(${JSON.stringify(JSON.stringify(printed))});\n`);
|
||||
chmodSync(join(bin, "npm"), 0o755);
|
||||
|
||||
const result = runGate({ repo, rules, review, receipt, cutSha: gitHead(repo),
|
||||
env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } });
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /npm artifact enumeration listed 2 packages where one was packed; run the gate for one package \(check --package-root and any npm workspaces setting\)/);
|
||||
assert.equal(existsSync(receipt), false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("the release ceremony names the gate and its durable receipt", () => {
|
||||
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
|
||||
assert.equal(pkg.scripts["gate:substance"], "node scripts/check-substance-gate.mjs");
|
||||
@@ -420,7 +459,7 @@ function writeGateHarness(root, source, invocationPath) {
|
||||
mkdirSync(root, { recursive: true });
|
||||
const gate = join(root, "check-substance-gate.mjs");
|
||||
writeFileSync(gate, source);
|
||||
for (const module of ["internal-leak-scanner.mjs", "leak-guard-rules.mjs"]) {
|
||||
for (const module of ["internal-leak-scanner.mjs", "leak-guard-rules.mjs", "npm-pack-json.mjs"]) {
|
||||
writeFileSync(join(root, module), readFileSync(resolve("scripts", module)));
|
||||
}
|
||||
writeFileSync(join(root, "check-internal-leak-guard.mjs"), [
|
||||
@@ -474,7 +513,7 @@ function withFixture(run) {
|
||||
}
|
||||
}
|
||||
|
||||
function runGate({ repo, rules, review, receipt, cutSha, gate = GATE }) {
|
||||
function runGate({ repo, rules, review, receipt, cutSha, gate = GATE, env = process.env }) {
|
||||
return spawnSync(process.execPath, [
|
||||
gate,
|
||||
"--repo", repo,
|
||||
@@ -485,7 +524,7 @@ function runGate({ repo, rules, review, receipt, cutSha, gate = GATE }) {
|
||||
"--receipt", receipt,
|
||||
"--judge", "dev-qa@fixture",
|
||||
"--cut-sha", cutSha,
|
||||
], { encoding: "utf8" });
|
||||
], { encoding: "utf8", env });
|
||||
}
|
||||
|
||||
function write(path, content) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { packEntry } from "./npm-pack-json.mjs";
|
||||
|
||||
test("the CLI build carries the unchanged repository LICENSE and README into npm pack", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "cli-license-"));
|
||||
@@ -26,14 +27,14 @@ test("the CLI build carries the unchanged repository LICENSE and README into npm
|
||||
execFileSync("npm", ["run", "build"], { cwd: cli, env, stdio: "pipe" });
|
||||
const source = readFileSync(join(root, "LICENSE"));
|
||||
assert.deepEqual(readFileSync(join(cli, "LICENSE")), source, "build must stage the repository license verbatim");
|
||||
const packed = JSON.parse(execFileSync("npm", ["pack", "--ignore-scripts", "--json"], { cwd: cli, env, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }));
|
||||
assert.ok(packed[0].files.some((file) => file.path === "LICENSE"), "npm archive must include LICENSE");
|
||||
const bytes = execFileSync("tar", ["-xOf", join(cli, packed[0].filename), "package/LICENSE"]);
|
||||
const packed = packEntry(execFileSync("npm", ["pack", "--ignore-scripts", "--json"], { cwd: cli, env, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }));
|
||||
assert.ok(packed.files.some((file) => file.path === "LICENSE"), "npm archive must include LICENSE");
|
||||
const bytes = execFileSync("tar", ["-xOf", join(cli, packed.filename), "package/LICENSE"]);
|
||||
assert.deepEqual(bytes, source, "packed license must equal the existing root bytes");
|
||||
// npm shows the package-root README on the registry page; without it the page reads "No README data found".
|
||||
const readme = readFileSync(join(root, "README.md"));
|
||||
assert.ok(packed[0].files.some((file) => file.path === "README.md"), "npm archive must include README.md");
|
||||
const readmeBytes = execFileSync("tar", ["-xOf", join(cli, packed[0].filename), "package/README.md"]);
|
||||
assert.ok(packed.files.some((file) => file.path === "README.md"), "npm archive must include README.md");
|
||||
const readmeBytes = execFileSync("tar", ["-xOf", join(cli, packed.filename), "package/README.md"]);
|
||||
assert.deepEqual(readmeBytes, readme, "packed README must equal the existing root bytes");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// `npm pack --json` prints an array of entries up to npm 11 and, from npm 12, an object keyed by package name
|
||||
// ("the --json output of `npm pack` and `npm publish` have changed",
|
||||
// https://github.com/npm/cli/releases/tag/v12.0.0). Every reader of that output goes through here, so both shapes
|
||||
// give the same entries.
|
||||
|
||||
/** The entries `npm pack --json` printed, in order, from its output text or its parsed value. */
|
||||
export function packEntries(output) {
|
||||
const parsed = typeof output === "string" ? JSON.parse(output) : output;
|
||||
const entries = Array.isArray(parsed)
|
||||
? parsed
|
||||
: parsed !== null && typeof parsed === "object" ? Object.values(parsed) : null;
|
||||
if (!entries || !entries.every((entry) => entry !== null && typeof entry === "object" && !Array.isArray(entry))) {
|
||||
throw new Error("npm pack --json printed neither an array of entries nor an object of per-package entries");
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** The one entry of a `npm pack --json` run that packed one package; any other count throws. */
|
||||
export function packEntry(output) {
|
||||
const entries = packEntries(output);
|
||||
if (entries.length !== 1) {
|
||||
throw new Error(`npm pack --json printed ${entries.length} entries where one package was packed`);
|
||||
}
|
||||
return entries[0];
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { packEntries, packEntry } from "./npm-pack-json.mjs";
|
||||
|
||||
const entry = { id: "@openrig/[email protected]", name: "@openrig/cli", version: "1.2.3", filename: "openrig-cli-1.2.3.tgz",
|
||||
files: [{ path: "LICENSE" }, { path: "package.json" }] };
|
||||
|
||||
test("npm 10 and 11: an array of entries", () => {
|
||||
assert.deepEqual(packEntries(JSON.stringify([entry])), [entry]);
|
||||
assert.deepEqual(packEntry(JSON.stringify([entry])), entry);
|
||||
});
|
||||
|
||||
test("npm 12: an object of entries keyed by package name", () => {
|
||||
assert.deepEqual(packEntries(JSON.stringify({ "@openrig/cli": entry })), [entry]);
|
||||
assert.deepEqual(packEntry(JSON.stringify({ "@openrig/cli": entry })), entry);
|
||||
});
|
||||
|
||||
test("both shapes keep every entry in order, and a parsed value reads like its text", () => {
|
||||
const other = { ...entry, name: "@openrig/acp", filename: "openrig-acp-1.2.3.tgz" };
|
||||
assert.deepEqual(packEntries([entry, other]), [entry, other]);
|
||||
assert.deepEqual(packEntries({ "@openrig/cli": entry, "@openrig/acp": other }), [entry, other]);
|
||||
});
|
||||
|
||||
test("packEntry refuses any count but one", () => {
|
||||
assert.throws(() => packEntry("[]"), /0 entries/);
|
||||
assert.throws(() => packEntry("{}"), /0 entries/);
|
||||
assert.throws(() => packEntry(JSON.stringify([entry, entry])), /2 entries/);
|
||||
});
|
||||
|
||||
test("output that is neither shape throws", () => {
|
||||
for (const output of ["null", "\"text\"", "3", JSON.stringify(entry), JSON.stringify([[entry]]), JSON.stringify({ a: null })]) {
|
||||
assert.throws(() => packEntries(output), /neither an array of entries nor an object of per-package entries/, output);
|
||||
}
|
||||
assert.throws(() => packEntries("not json"), SyntaxError);
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { packEntry } from './npm-pack-json.mjs';
|
||||
|
||||
const LISTING = ['packages/acp/registry/agent.json', 'packages/acp/registry/icon.svg'];
|
||||
const readJson = (root, rel) => JSON.parse(readFileSync(join(root, rel), 'utf8'));
|
||||
@@ -35,8 +36,8 @@ export function packRelease(root, dest) {
|
||||
mkdirSync(dest, { recursive: true });
|
||||
// The CLI is packed as the release producer has packed it; the launcher isn't a workspace, so it's packed by path.
|
||||
// --ignore-scripts skips pack's prepack and postpack, but npm 10 still runs a `prepare`; neither package has one.
|
||||
const pack = target => JSON.parse(execFileSync('npm', ['pack', ...target, '--ignore-scripts', '--json', '--pack-destination', dest],
|
||||
{ cwd: root, encoding: 'utf8' }))[0];
|
||||
const pack = target => packEntry(execFileSync('npm', ['pack', ...target, '--ignore-scripts', '--json', '--pack-destination', dest],
|
||||
{ cwd: root, encoding: 'utf8' }));
|
||||
const artifacts = [pack(['--workspace', 'packages/cli']), pack(['./packages/acp'])].map(packed => {
|
||||
const path = join(dest, packed.filename);
|
||||
return { name: packed.name, version: packed.version, path, size: packed.size, sha256: sha256(path), integrity: packed.integrity };
|
||||
|
||||
@@ -4,6 +4,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { packEntry } from "./npm-pack-json.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const cli = path.join(root, "packages", "cli");
|
||||
@@ -47,8 +48,8 @@ async function assertDelayedSplits(entry, pkgDir) {
|
||||
|
||||
try {
|
||||
execFileSync("npm", ["run", "build:package"], { cwd: root, stdio: "inherit" });
|
||||
const packed = JSON.parse(execFileSync("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", temp], { cwd: cli, encoding: "utf8" }));
|
||||
const tarball = path.join(temp, packed[0].filename);
|
||||
const packed = packEntry(execFileSync("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", temp], { cwd: cli, encoding: "utf8" }));
|
||||
const tarball = path.join(temp, packed.filename);
|
||||
execFileSync("tar", ["-xzf", tarball, "-C", temp]);
|
||||
|
||||
const pkgDir = path.join(temp, "package");
|
||||
@@ -68,7 +69,7 @@ try {
|
||||
});
|
||||
assert.equal(launched.status, 0, launched.stderr || `packed TUI exited ${launched.status}`);
|
||||
await assertDelayedSplits(entry, pkgDir);
|
||||
console.log(`packed TUI launch PASS: ${packed[0].filename}`);
|
||||
console.log(`packed TUI launch PASS: ${packed.filename}`);
|
||||
} finally {
|
||||
fs.rmSync(temp, { recursive: true, force: true });
|
||||
}
|
||||
Reference in new issue
Block a user