mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-13 18:49:34 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5475df1d50 |
No files matched your search
@@ -20,8 +20,7 @@ The CLI itself finds the daemon in these orders:
|
||||
`$OPENRIG_HOME/daemon.json` when it holds a valid `pid` and `port`; else the config keys `daemon.host` and
|
||||
`daemon.port` (environment `OPENRIG_HOST` and `OPENRIG_PORT`), which default to `127.0.0.1:7433`.
|
||||
- **Status** (`rig status`, `rig daemon status`): `OPENRIG_URL` (or `RIGGED_URL`) first, probed directly; else
|
||||
the state file below. If no state is read, probe the configured `daemon.host` and `daemon.port` instead (defaulting
|
||||
to `127.0.0.1:7433`).
|
||||
the state file below.
|
||||
- **Start and stop** (`rig daemon start`, `stop`): the state file, `$OPENRIG_HOME/daemon.json`, then the legacy
|
||||
`~/.rigged/daemon.json`.
|
||||
|
||||
|
||||
@@ -208,8 +208,8 @@ curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.12/scripts/i
|
||||
|
||||
The same command without `--dry-run`,
|
||||
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.12/scripts/install.sh | sh`, installs the
|
||||
latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check if the installed checker is
|
||||
present (otherwise it reports the check skipped), then runs `rig setup --dry-run` and `rig setup`. `rig setup` checks both Claude Code and
|
||||
latest published `@openrig/cli` with `npm install -g`,
|
||||
runs the Node.js and SQLite check, then `rig setup --dry-run` and `rig setup`. `rig setup` checks both Claude Code and
|
||||
Codex and may install a missing one, as described below. A failed step prints
|
||||
`FAILED [n/4] <command or check> (exit <code>)` for steps 1–3. If step 4 is not ready, it reports
|
||||
`INSTALLED, SETUP NOT COMPLETE` and points to the details from `rig setup` above. Follow that output and any Fix, then rerun
|
||||
@@ -308,12 +308,11 @@ team. `rig daemon start --no-kernel` is for automation or an explicit request to
|
||||
omit the kernel; it leaves you without that operator.
|
||||
|
||||
On a fresh instance, explicit daemon startup chooses the kernel variant from
|
||||
the providers selected by `rig setup --providers` that pass native auth probes:
|
||||
Claude alone, Codex alone, or both. If no choice was recorded, it considers every
|
||||
signed-in provider. `OPENRIG_PROVIDER_SELECTED` in the daemon's environment
|
||||
overrides the saved choice. It starts the kernel in the background without a
|
||||
project starter. An absent unused provider is fine. The kernel choice and a
|
||||
later project's provider choice are separate.
|
||||
successful native auth probes: Claude alone, Codex alone, or both. It starts the
|
||||
kernel in the background without a project starter. An absent unused provider is
|
||||
fine; if both accounts are authenticated, the kernel uses both even when your
|
||||
later project uses just one. A request to use only one provider for everything
|
||||
is a separate choice.
|
||||
|
||||
`/api/kernel/status` retains the chosen template (`rig.yaml`,
|
||||
`rig-claude-only.yaml` or `rig-codex-only.yaml`) across daemon restarts and
|
||||
|
||||
@@ -124,12 +124,10 @@ manual replay. A recurring reminder keeps its next normal cycle under a new occu
|
||||
finishing its accounting does not type the same occurrence twice.
|
||||
|
||||
Exhausted retries end only the failed occurrence, with its cause and next action; a recurring
|
||||
reminder keeps its next normal cycle, and a permanent transport failure leaves it a terminal job:
|
||||
correct the destination and register a replacement reminder. A one-shot park timer (`--wake-after`)
|
||||
has no later cycle, so neither the retry limit nor a permanent transport failure ends it while its
|
||||
task is still set aside: each failure before input is recorded on the parked row while the timer
|
||||
stays armed, and it retries until it's delivered or the park ends. If input may have reached the
|
||||
seat, it records the unconfirmed wake on the parked row and ends. Deliberately held
|
||||
reminder keeps its next normal cycle. A permanent transport failure leaves a terminal job:
|
||||
correct the destination and register a replacement reminder. A one-shot park timer records its
|
||||
failed or unconfirmed wake on the parked row before it ends. A retryable failure is recorded
|
||||
there while the timer stays armed. Deliberately held
|
||||
input keeps its existing retained-message custody. Condition watches and parked-owner policy
|
||||
behavior are unchanged. This does not replay failed occurrences recorded by older versions.
|
||||
|
||||
@@ -291,14 +289,11 @@ Unknown: not said yet.
|
||||
stopped (the first fire comes one interval after you register it), and it stops when the agent that registered it
|
||||
is replaced. It's fine for agent-to-agent reminders. Your own watch timers also show as `periodic-reminder` in
|
||||
`rig watchdog list`: that's the one-shot timer `--wake-after` arms, not a registered repeating reminder. It's retired
|
||||
after delivery, when the occurrence ends, or when the task stops being set aside. A wake held back because you were typing (a retained
|
||||
delivery) wasn't delivered, so it doesn't use the timer up. A failure known to have happened before input reached
|
||||
the seat also keeps the same wake owed and retries on the existing cadence until delivery or the park ends. If input
|
||||
may have reached the seat, the occurrence ends without automatic replay; inspect the receipt before retrying.
|
||||
A timer another task shares through `--wake-watchdog` stays live for that task when this task is
|
||||
handed off or stopped, and a held-back wake doesn't use it up. Once this task's park has ended, though,
|
||||
a failure before input to that task is retried only up to `queue.wake_retry_cap` times (a permanent
|
||||
transport failure ends it at once), and then the timer ends. None of this is a delivery guarantee.
|
||||
when it fires or when the task stops being set aside. A wake held back because you were typing (a retained
|
||||
delivery) wasn't delivered, so it doesn't use the timer up: it's tried again at each interval until an attempt goes
|
||||
through (delivered or failed), which retires it. A timer another task shares through `--wake-watchdog` stays live
|
||||
for that task when this task is handed off or stopped, and the same rule applies to its wake. None of this is a
|
||||
delivery guarantee.
|
||||
- **Transcript bytes as "usage".** `context-usage-threshold` measures an agent's transcript, not an account's
|
||||
allowance. For an allowance, see "A usage warning" above.
|
||||
|
||||
|
||||
@@ -132,10 +132,6 @@ reaction on any other message, including a person's reply in the thread, is igno
|
||||
`reaction_added` event: add both under **OAuth & Permissions** and **Event Subscriptions**, then
|
||||
reinstall the app to the workspace.
|
||||
|
||||
Human-row posts suppress link and media previews by default. Run `rig slack setup --unfurl on` to
|
||||
allow Slack previews, or `rig slack setup --unfurl off` to suppress them again. The selection reaches
|
||||
the running connector on its next rewire or daemon restart; it does not remove already posted previews.
|
||||
|
||||
To have reactions ignored instead, run `rig slack setup --reactions off`. The agent that asked
|
||||
never hears of a reaction, including one still waiting to be retried, and a person answers an ask by
|
||||
typing a reply in its thread. Slack still sends the event and the manifest keeps it, so switching
|
||||
|
||||
@@ -14,7 +14,6 @@ import type { Command } from "commander";
|
||||
import { InvalidArgumentError } from "commander";
|
||||
import { DaemonConnectionError, DaemonResponseError, DaemonTimeoutError } from "./client.js";
|
||||
import { blockedConnectionGuidance, isBlockedConnectionCode } from "./daemon-reachability.js";
|
||||
import { resetSenderSession } from "./sender-identity.js";
|
||||
|
||||
/**
|
||||
* Commander option parser — a positive integer (>= 1). Rejects negative, zero,
|
||||
@@ -176,14 +175,9 @@ export async function runProgram(program: Command, argv: string[], io: RunProgra
|
||||
};
|
||||
suppressErr(program);
|
||||
|
||||
// Each run starts clean: an earlier in-process run's failure isn't this run's status, and its sender isn't this run's.
|
||||
process.exitCode = undefined;
|
||||
resetSenderSession();
|
||||
try {
|
||||
await program.parseAsync(argv);
|
||||
// An action's own failure sets process.exitCode. It's returned, not passed to process.exit(), which can drop output
|
||||
// still being written to a pipe; the process ends with process.exitCode.
|
||||
return typeof process.exitCode === "number" ? process.exitCode : 0;
|
||||
return 0;
|
||||
} catch (e) {
|
||||
if (isCleanCommanderExit(e)) return 0;
|
||||
// Response-integrity: render daemon transport failures honestly (3-part, both
|
||||
|
||||
@@ -6,7 +6,6 @@ import { ConfigStore } from "./config-store.js";
|
||||
import { getOpenRigHome, readOpenRigEnv, readOpenRigUrl } from "./openrig-compat.js";
|
||||
import { fetchWithTimeout, FetchTimeoutError } from "./fetch-with-timeout.js";
|
||||
import { FETCH_HEADERS_TIMEOUT_MS, nodeHttpFetch } from "./long-request-fetch.js";
|
||||
import { resolveSenderSession, type BindingCheck, type TmuxIdentityReader } from "./sender-identity.js";
|
||||
|
||||
export function terminalAuthHeaders(): Record<string, string> {
|
||||
const token = resolveTerminalToken();
|
||||
@@ -36,10 +35,8 @@ export const OCCUPANT_GENERATION_HEADER = "X-OpenRig-Occupant-Generation";
|
||||
* chokepoint: `DaemonClient.fetch` stamps this on every request; callers never set it by hand. A client built for one
|
||||
* authenticated caller (`sessionName`) carries that caller's seat instead of the process env.
|
||||
*/
|
||||
export function senderIdentityHeaders(
|
||||
originSelfHostId?: string, sessionName?: string, tmuxReader?: TmuxIdentityReader, isBound?: BindingCheck,
|
||||
): Record<string, string> {
|
||||
const session = (sessionName ?? resolveSenderSession(tmuxReader, isBound))?.trim();
|
||||
export function senderIdentityHeaders(originSelfHostId?: string, sessionName?: string): Record<string, string> {
|
||||
const session = (sessionName ?? readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME"))?.trim();
|
||||
if (!session) return {};
|
||||
// Preserve qualified senders; append only a locally derived instance id.
|
||||
const value =
|
||||
|
||||
@@ -17,7 +17,6 @@ import { omittedReadField, readView } from "../read-view.js";
|
||||
import { buildWriteReceipt, compactOutputEnabled, type ReceiptContext } from "../queue-receipt.js";
|
||||
import { TRANSITIONS_SHOWN, compactShow, compactTransitions, showEmptyNotice, transitionsNotice } from "../queue-read-compact.js";
|
||||
import { codeResultOf, planItemArtifact, type FileRead, type SharedRoot } from "../queue-artifact.js";
|
||||
import { resolveSenderSession } from "../sender-identity.js";
|
||||
|
||||
/**
|
||||
* `rig queue` — coordination primitive L3/inbox/outbox commands (PL-004 Phase A).
|
||||
@@ -695,167 +694,6 @@ async function printQueueItemResult(
|
||||
await printWriteResult(client, json, body, status, receipt && remoteHost ? { ...receipt, remoteHost } : receipt, remoteHost !== undefined);
|
||||
}
|
||||
|
||||
const UPDATE_READ_BACK_FIELDS = [
|
||||
"state", "closureReason", "closureTarget", "closureRequiredAt", "resolution", "blockedOn", "summary", "evidenceRef",
|
||||
] as const;
|
||||
|
||||
interface QueueTransitionReadBack {
|
||||
transitionId: number;
|
||||
transitionNote: string | null;
|
||||
actorSession: string;
|
||||
}
|
||||
|
||||
function queueUpdateFieldMatches(field: (typeof UPDATE_READ_BACK_FIELDS)[number], requested: unknown, actual: unknown): boolean {
|
||||
if (field === "closureRequiredAt" && typeof requested === "string" && typeof actual === "string") {
|
||||
const requestedTime = Date.parse(requested);
|
||||
const actualTime = Date.parse(actual);
|
||||
return Number.isFinite(requestedTime) && Number.isFinite(actualTime) && requestedTime === actualTime;
|
||||
}
|
||||
if (field === "resolution" && typeof requested === "string" && typeof actual === "string") {
|
||||
return actual === requested || actual.endsWith(`\n${requested}`);
|
||||
}
|
||||
return Object.is(actual, requested);
|
||||
}
|
||||
|
||||
/** Whether a recorded note is this update's note as the daemon records it (QueueRepository.update): as written, or with
|
||||
* only what the daemon adds for this request, the reopen prefix, the summary suffix, or a revised deadline's receipt
|
||||
* naming the new time and the accepting actor. Another writer's note that merely starts the same way isn't. */
|
||||
function noteRecordedAs(actual: string, note: string, requested: Record<string, unknown>, actor: string, deadline: unknown): boolean {
|
||||
const bases = [note];
|
||||
if (requested.reopen === true) bases.push(`reopen acknowledged: ${note}`);
|
||||
if (typeof requested.summary === "string" && requested.summary.trim() && !note.includes("(summary updated)")) {
|
||||
bases.push(`${note} (summary updated)`);
|
||||
}
|
||||
if (requested.closureRequiredAt === undefined) return bases.includes(actual);
|
||||
// The receipt's earlier time isn't known here: it's one line between the fixed parts.
|
||||
const tail = ` -> ${String(deadline)}; accepted by ${actor}`;
|
||||
return bases.some((base) => {
|
||||
const head = `${base}\nclosure deadline re-estimated: `;
|
||||
if (!actual.startsWith(head) || !actual.endsWith(tail) || actual.length <= head.length + tail.length) return false;
|
||||
return !actual.slice(head.length, actual.length - tail.length).includes("\n");
|
||||
});
|
||||
}
|
||||
|
||||
/** Who the daemon records as this write's actor: the sender header as sent, else the actor the body declares. Empty when
|
||||
* neither is known. A daemon drops a sender's host suffix only when it's that daemon's own id, so the bare form is
|
||||
* this write's only where the selected daemon answers with that id; nothing else (an absent --host included) shows it. */
|
||||
async function updateActors(client: Pick<DaemonClient, "senderIdentity" | "get">, bodyActor: unknown): Promise<string[]> {
|
||||
const { session } = await client.senderIdentity();
|
||||
if (session) {
|
||||
const parts = session.split("@");
|
||||
if (parts.length !== 3) return [session];
|
||||
let daemonId: unknown;
|
||||
try {
|
||||
daemonId = (await client.get<{ selfHostId?: unknown }>("/healthz", { timeoutMs: 1_000 })).data?.selfHostId;
|
||||
} catch { /* Unanswered: the suffix stays unproven, so only the full actor counts. */ }
|
||||
return daemonId === parts[2] ? [session, `${parts[0]}@${parts[1]}`] : [session];
|
||||
}
|
||||
return typeof bodyActor === "string" && bodyActor.trim() ? [bodyActor.trim()] : [];
|
||||
}
|
||||
|
||||
async function readQueueTransitionCursor(
|
||||
client: Pick<DaemonClient, "get">,
|
||||
qitemId: string,
|
||||
options: { headers?: Record<string, string>; timeoutMs?: number },
|
||||
): Promise<{ ok: true; latestTransitionId: number } | { ok: false; detail: string }> {
|
||||
let response: DaemonResponse<unknown>;
|
||||
try {
|
||||
response = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/transitions`, options);
|
||||
} catch (error) {
|
||||
return { ok: false, detail: `canonical transition preflight did not answer: ${(error as Error).message}` };
|
||||
}
|
||||
if (response.status !== 200 || !Array.isArray(response.data)) {
|
||||
return { ok: false, detail: `canonical transition preflight returned HTTP ${response.status} without a transition list` };
|
||||
}
|
||||
const transitionIds = response.data.flatMap((entry) => {
|
||||
if (!entry || typeof entry !== "object") return [];
|
||||
const transitionId = (entry as { transitionId?: unknown }).transitionId;
|
||||
return typeof transitionId === "number" && Number.isSafeInteger(transitionId) ? [transitionId] : [];
|
||||
});
|
||||
if (transitionIds.length !== response.data.length) {
|
||||
return { ok: false, detail: "canonical transition preflight returned a malformed transition list" };
|
||||
}
|
||||
return { ok: true, latestTransitionId: Math.max(0, ...transitionIds) };
|
||||
}
|
||||
|
||||
async function readBackQueueUpdate(
|
||||
client: Pick<DaemonClient, "get">,
|
||||
qitemId: string,
|
||||
requested: Record<string, unknown>,
|
||||
options: { headers?: Record<string, string>; timeoutMs?: number },
|
||||
transitionFloor?: number,
|
||||
/** Why the transition cursor wasn't taken before the write, when it wasn't. */
|
||||
floorUnknown?: string,
|
||||
/** Who the daemon records as this write's actor (updateActors). */
|
||||
actors: string[] = [],
|
||||
): Promise<{ ok: true; row: Record<string, unknown> } | { ok: false; detail: string }> {
|
||||
let response: DaemonResponse<Record<string, unknown>>;
|
||||
try {
|
||||
response = await client.get<Record<string, unknown>>(`/api/queue/${encodeURIComponent(qitemId)}`, options);
|
||||
} catch (error) {
|
||||
return { ok: false, detail: `canonical read-back did not answer: ${(error as Error).message}` };
|
||||
}
|
||||
if (response.status !== 200 || !response.data || response.data.qitemId !== qitemId) {
|
||||
return { ok: false, detail: `canonical read-back returned HTTP ${response.status} without row ${qitemId}` };
|
||||
}
|
||||
const mismatches = UPDATE_READ_BACK_FIELDS.flatMap((field) => {
|
||||
if (requested[field] === undefined) return [];
|
||||
return queueUpdateFieldMatches(field, requested[field], response.data[field])
|
||||
? []
|
||||
: [`${field}: requested ${JSON.stringify(requested[field])}, read ${JSON.stringify(response.data[field])}`];
|
||||
});
|
||||
if (mismatches.length > 0) {
|
||||
return { ok: false, detail: `canonical read-back mismatch (${mismatches.join("; ")})` };
|
||||
}
|
||||
|
||||
const requestedNote = typeof requested.transitionNote === "string" && requested.transitionNote.trim().length > 0
|
||||
? requested.transitionNote
|
||||
: null;
|
||||
if (requestedNote !== null) {
|
||||
// Without the cursor, a transition carrying the note can't be shown to be this write's: never accept an older one.
|
||||
if (transitionFloor === undefined) {
|
||||
return { ok: false, detail: `the requested note can't be shown to be new, because the transition read before the write failed (${floorUnknown ?? "no cursor"})` };
|
||||
}
|
||||
if (actors.length === 0) {
|
||||
return { ok: false, detail: "the requested note can't be tied to this write, because the actor it was sent as isn't known" };
|
||||
}
|
||||
let transitions: DaemonResponse<unknown>;
|
||||
try {
|
||||
transitions = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/transitions`, options);
|
||||
} catch (error) {
|
||||
return { ok: false, detail: `canonical transition read-back did not answer: ${(error as Error).message}` };
|
||||
}
|
||||
if (transitions.status !== 200 || !Array.isArray(transitions.data)) {
|
||||
return { ok: false, detail: `canonical transition read-back returned HTTP ${transitions.status} without a transition list` };
|
||||
}
|
||||
const matching = transitions.data.some((entry) => {
|
||||
if (!entry || typeof entry !== "object") return false;
|
||||
const transition = entry as Partial<QueueTransitionReadBack>;
|
||||
return typeof transition.transitionId === "number"
|
||||
&& transition.transitionId > transitionFloor
|
||||
&& typeof transition.transitionNote === "string"
|
||||
&& typeof transition.actorSession === "string"
|
||||
&& actors.includes(transition.actorSession)
|
||||
&& noteRecordedAs(transition.transitionNote, requestedNote, requested, transition.actorSession, response.data.closureRequiredAt);
|
||||
});
|
||||
if (!matching) {
|
||||
return {
|
||||
ok: false,
|
||||
detail: `no newer canonical transition carried requested note ${JSON.stringify(requestedNote)} from ${actors.map((actor) => JSON.stringify(actor)).join(" or ")}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { ok: true, row: response.data };
|
||||
}
|
||||
|
||||
function emitQueueUpdateReadBackFailure(qitemId: string, detail: string, json: boolean): void {
|
||||
const message = `Queue update response was not accepted as success: ${detail}. The write may have committed, but its requested state was not verified.`;
|
||||
const action = `Run rig queue show ${shellQuote(qitemId)} --full --json against the same daemon before retrying.`;
|
||||
if (json) console.error(JSON.stringify({ error: "queue_update_readback_failed", message, action }));
|
||||
else console.error(`${message}\n${action}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
// OPR.0.4.3.03 — `rig queue show` body preview.
|
||||
//
|
||||
// Default `show` renders a BOUNDED body preview instead of dumping the whole
|
||||
@@ -1036,21 +874,21 @@ function emitBodyResolveError(err: Error & { fact?: string; consequence?: string
|
||||
}
|
||||
|
||||
function resolveCurrentSession(explicit: string | undefined, optionName: string): string | undefined {
|
||||
const session = explicit ?? resolveSenderSession();
|
||||
const session = explicit ?? readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
|
||||
if (session) return session;
|
||||
|
||||
console.error(`--${optionName} is required when OPENRIG_SESSION_NAME is not set and reconciled tmux pane metadata does not resolve a session`);
|
||||
console.error(`--${optionName} is required when OPENRIG_SESSION_NAME is not set`);
|
||||
process.exitCode = 1;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** For a verb whose actor is the seat's own identity (the X-OpenRig-Session header the CLI sets from managed identity),
|
||||
/** For a verb whose actor is the seat's own identity (the X-OpenRig-Session header the CLI sets from OPENRIG_SESSION_NAME),
|
||||
* where the named flag is deprecated and ignored: the error names what actually sets the actor, never the flag. */
|
||||
function requireSeatSession(ignoredFlag: string): string | undefined {
|
||||
const session = resolveSenderSession();
|
||||
const session = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
|
||||
if (session) return session;
|
||||
|
||||
console.error(`OPENRIG_SESSION_NAME is not set, so there's no seat to act as, and reconciled tmux pane metadata does not resolve one. Run this from a managed seat, or set ` +
|
||||
console.error(`OPENRIG_SESSION_NAME is not set, so there's no seat to act as. Run this from a managed seat, or set ` +
|
||||
`OPENRIG_SESSION_NAME to the seat's session name (for example dev-impl@my-rig). --${ignoredFlag} is ignored: the ` +
|
||||
"actor is the seat's own identity.");
|
||||
process.exitCode = 1;
|
||||
@@ -1074,8 +912,8 @@ const UPDATE_STATE_WRITE_FLAGS = [
|
||||
*/
|
||||
function resolveUpdateActor(source: string | undefined, opts: Record<string, unknown>): { actorSession?: string } | false {
|
||||
const refuse = (message: string): false => { console.error(message); process.exitCode = 1; return false; };
|
||||
// The same resolver the DaemonClient header uses, so the preflight and transport can never disagree.
|
||||
const seat = resolveSenderSession();
|
||||
// Trimmed like the DaemonClient's header (senderIdentityHeaders): a blank env is no seat, so the two never disagree.
|
||||
const seat = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME")?.trim() || undefined;
|
||||
if (source !== undefined) {
|
||||
const trimmed = source.trim();
|
||||
if (parseSessionName(trimmed).kind !== "external") {
|
||||
@@ -1095,10 +933,10 @@ function resolveUpdateActor(source: string | undefined, opts: Record<string, unk
|
||||
if (seat) return {};
|
||||
const noteOnly = opts.note !== undefined && UPDATE_STATE_WRITE_FLAGS.every(([key]) => opts[key] === undefined);
|
||||
return refuse(noteOnly
|
||||
? "notes need a seat; an external routine can pass --source <name>@external. OPENRIG_SESSION_NAME is not set and reconciled " +
|
||||
"tmux pane metadata does not resolve a seat: run this from a managed seat, or add --source <name>@external to record the note as " +
|
||||
? "notes need a seat; an external routine can pass --source <name>@external. OPENRIG_SESSION_NAME is not set, so " +
|
||||
"there's no seat to act as: run this from a managed seat, or add --source <name>@external to record the note as " +
|
||||
"that external routine (claimed, never a seat). --actor is ignored: the actor is the seat's own identity."
|
||||
: "OPENRIG_SESSION_NAME is not set, so there's no seat to act as, and reconciled tmux pane metadata does not resolve one. Run this from a managed seat, or set " +
|
||||
: "OPENRIG_SESSION_NAME is not set, so there's no seat to act as. Run this from a managed seat, or set " +
|
||||
"OPENRIG_SESSION_NAME to the seat's session name (for example dev-impl@my-rig). --actor is ignored: the actor is " +
|
||||
"the seat's own identity. Changing a row needs a seat, and notes need a seat; an external routine can pass " +
|
||||
"--source <name>@external for a note only. A person answering an ask addressed to them (a host pairing approval, " +
|
||||
@@ -1328,7 +1166,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
}
|
||||
// A managed seat keeps transport-derived identity; an external caller can
|
||||
// name the existing claimed:v1 body actor without forging a transport header.
|
||||
const managedSource = resolveSenderSession();
|
||||
const managedSource = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
|
||||
const source = resolveCurrentSession(managedSource ?? opts.source, "source");
|
||||
if (!source) return;
|
||||
const deps = getDeps();
|
||||
@@ -1630,7 +1468,7 @@ For a new cross-host transfer, prefer rig queue handoff --host <registered-host>
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const requested = {
|
||||
const res = await target.client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
|
||||
state: opts.state,
|
||||
reopen: opts.reopen,
|
||||
closureReason: opts.closureReason,
|
||||
@@ -1644,29 +1482,7 @@ For a new cross-host transfer, prefer rig queue handoff --host <registered-host>
|
||||
evidenceRef: opts.evidenceRef,
|
||||
transitionNote: opts.note,
|
||||
...actor,
|
||||
};
|
||||
let transitionFloor: number | undefined;
|
||||
let floorUnknown: string | undefined;
|
||||
if (typeof requested.transitionNote === "string" && requested.transitionNote.trim().length > 0) {
|
||||
// The cursor proves the note is new afterwards. Without it the write still goes; the receipt says what's unproven.
|
||||
const cursor = await readQueueTransitionCursor(target.client, qitemId, target.options);
|
||||
if (cursor.ok) transitionFloor = cursor.latestTransitionId;
|
||||
else floorUnknown = cursor.detail;
|
||||
}
|
||||
const res = await target.client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, requested, target.options);
|
||||
if (res.status < 400) {
|
||||
const actors = await updateActors(target.client, requested.actorSession);
|
||||
const verified = await readBackQueueUpdate(target.client, qitemId, requested, target.options, transitionFloor, floorUnknown, actors);
|
||||
if (!verified.ok) {
|
||||
emitQueueUpdateReadBackFailure(qitemId, verified.detail, opts.json ?? false);
|
||||
return;
|
||||
}
|
||||
// The canonical row's fields win; the write's own advisories (a park with no wake) come only with its response.
|
||||
const advisories = (res.data as { advisories?: unknown } | null)?.advisories;
|
||||
const row = Array.isArray(advisories) ? { ...verified.row, advisories } : verified.row;
|
||||
await printQueueItemResult(target.client, qitemId, opts.json ?? false, row, res.status, writeReceipt(opts.full, { verb: "update" }), target.remote);
|
||||
return;
|
||||
}
|
||||
}, target.options);
|
||||
await printQueueItemResult(target.client, qitemId, opts.json ?? false, res.data, res.status, writeReceipt(opts.full, { verb: "update" }), target.remote);
|
||||
}, remoteHostOf(opts.host) !== undefined, remoteHostOf(opts.host));
|
||||
});
|
||||
|
||||
@@ -188,7 +188,6 @@ export function slackCommand(deps: SlackDeps = {}): Command {
|
||||
.option("--secrets-env-file <path>", "path to the 0600 env file with SLACK_BOT_TOKEN / SLACK_APP_TOKEN")
|
||||
.option("--required-scopes <csv>", "comma-separated bot scopes to require at verify time")
|
||||
.option("--reactions <mode>", "what a person's emoji reaction on an OpenRig post does: route (default) makes a row for the seat that asked; off ignores reactions, so an acknowledging emoji costs no agent turn")
|
||||
.option("--unfurl <mode>", "link and media previews on human-row posts: off (default) or on; takes effect when the connector rewires")
|
||||
.option("--reason <reason>", "Reason recorded with the configuration change", "configure human delivery")
|
||||
.option("--actor <actor>", "Named operator when outside a managed seat")
|
||||
.action(async (opts) => {
|
||||
@@ -204,7 +203,6 @@ export function slackCommand(deps: SlackDeps = {}): Command {
|
||||
secretsEnvFile: opts.secretsEnvFile ?? cur.secretsEnvFile,
|
||||
requiredScopes: opts.requiredScopes ? String(opts.requiredScopes).split(",").map((s: string) => s.trim()).filter(Boolean) : cur.requiredScopes,
|
||||
...(opts.reactions !== undefined ? { reactions: opts.reactions } : {}),
|
||||
...(opts.unfurl !== undefined ? { unfurl: opts.unfurl } : {}),
|
||||
};
|
||||
const { runChannelOperation, channelStateDigest } = await import("@openrig/daemon/gateway-slack");
|
||||
const result = await runChannelOperation({
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { join } from "node:path";
|
||||
import type Database from "better-sqlite3";
|
||||
import { ConfigStore } from "./config-store.js";
|
||||
import { getOpenRigHome } from "./openrig-compat.js";
|
||||
|
||||
/** This home's database, opened read-only without requiring the local daemon. better-sqlite3 loads only when this is
|
||||
* called, so importing this module costs a command nothing. Throws when there's no database. */
|
||||
export function openLocalDbReadonly(): Database.Database {
|
||||
const configured = new ConfigStore().resolveWithSource("db.path");
|
||||
let dbPath = configured.value as string;
|
||||
// An explicit configured DB wins. Otherwise retain the last launch's --db selection.
|
||||
if (configured.source === "default") {
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(join(getOpenRigHome(), "daemon.json"), "utf8"));
|
||||
if (typeof state.db === "string" && state.db.length > 0) dbPath = state.db;
|
||||
} catch { /* No launch record: use the configured default, never search other homes. */ }
|
||||
}
|
||||
const Sqlite = createRequire(import.meta.url)("better-sqlite3") as typeof Database;
|
||||
return new Sqlite(dbPath, { readonly: true, fileMustExist: true, timeout: 100 });
|
||||
}
|
||||
@@ -1,12 +1,24 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import Database from "better-sqlite3";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { ConfigStore } from "./config-store.js";
|
||||
import { getOpenRigHome } from "./openrig-compat.js";
|
||||
import { validateHostRegistry } from "./host-registry.js";
|
||||
import { openLocalDbReadonly } from "./local-db.js";
|
||||
|
||||
/** Read the boot-minted identity without requiring the local daemon or minting a replacement. */
|
||||
export function readLocalOrigin(): string | undefined {
|
||||
let db: Database.Database | undefined;
|
||||
try {
|
||||
db = openLocalDbReadonly();
|
||||
const configured = new ConfigStore().resolveWithSource("db.path");
|
||||
let dbPath = configured.value as string;
|
||||
// An explicit configured DB wins. Otherwise retain the last launch's --db selection.
|
||||
if (configured.source === "default") {
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(join(getOpenRigHome(), "daemon.json"), "utf8"));
|
||||
if (typeof state.db === "string" && state.db.length > 0) dbPath = state.db;
|
||||
} catch { /* No launch record: use the configured default, never search other homes. */ }
|
||||
}
|
||||
db = new Database(dbPath, { readonly: true, fileMustExist: true, timeout: 100 });
|
||||
const row = db.prepare("SELECT host_id FROM self_host_identity WHERE singleton = 1").get() as { host_id?: unknown } | undefined;
|
||||
const id = row?.host_id;
|
||||
if (typeof id !== "string" || id === "localhost") return undefined;
|
||||
|
||||
@@ -1,90 +1,20 @@
|
||||
import { readOpenRigEnv } from "./openrig-compat.js";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import type Database from "better-sqlite3";
|
||||
import { openLocalDbReadonly } from "./local-db.js";
|
||||
|
||||
export type TmuxIdentityReader = (pane: string, option: string) => string | undefined;
|
||||
|
||||
/** A busy or wedged tmux server must not hold every queue request: past this, the pane's seat is unresolved. */
|
||||
export const TMUX_IDENTITY_TIMEOUT_MS = 1_000;
|
||||
|
||||
const readTmuxIdentity: TmuxIdentityReader = (pane, option) => {
|
||||
try {
|
||||
const value = execFileSync("tmux", ["show-option", "-v", "-t", pane, option], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: TMUX_IDENTITY_TIMEOUT_MS,
|
||||
}).trim();
|
||||
return value || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** Whether the daemon still binds a node to a tmux session. Claim records the binding beside the session's metadata;
|
||||
* unclaim deletes it in its own transaction even when clearing the metadata fails. */
|
||||
export type BindingCheck = (nodeId: string, tmuxSession: string) => boolean;
|
||||
|
||||
export function bindingIsCurrent(db: Database.Database, nodeId: string, tmuxSession: string): boolean {
|
||||
return db.prepare("SELECT 1 FROM bindings WHERE node_id = ? AND tmux_session = ?").get(nodeId, tmuxSession) !== undefined;
|
||||
}
|
||||
|
||||
/** The binding as this home's database records it; no database, or no answer, isn't evidence of a binding. */
|
||||
const localBindingIsCurrent: BindingCheck = (nodeId, tmuxSession) => {
|
||||
let db: Database.Database | undefined;
|
||||
try {
|
||||
db = openLocalDbReadonly();
|
||||
return bindingIsCurrent(db, nodeId, tmuxSession);
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
};
|
||||
|
||||
// The pane's seat, read once per run (runProgram resets it), so a command's preflight and every request it sends share one
|
||||
// value and one tmux read. Keyed by what produced it, so a different pane or reader reads again.
|
||||
let paneSeat: { pane: string; tmuxReader: TmuxIdentityReader; isBound: BindingCheck; session: string | undefined } | undefined;
|
||||
|
||||
/** Forget the pane seat read for the previous run. */
|
||||
export function resetSenderSession(): void {
|
||||
paneSeat = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The seat identity a CLI dispatch is attributed to — the SAME value the
|
||||
* DaemonClient stamps as `X-OpenRig-Session` (the P18 chokepoint), from which
|
||||
* the daemon DERIVES the actor / rendered `From:`. The process seat env wins;
|
||||
* a reconciled managed pane's `@rigged_session_name` repairs a live process
|
||||
* that predates its env binding, but only while the daemon still binds that
|
||||
* session's node to it: metadata left behind on a released session isn't a
|
||||
* current seat. Caller-supplied body claims never become a seat. Returns
|
||||
* `undefined` when neither managed source resolves.
|
||||
* the daemon DERIVES the actor / rendered `From:`. `--from` is deprecated and
|
||||
* ignored: origin is the seat env, never a caller-supplied body claim. Returns
|
||||
* `undefined` when the seat is unresolvable — the caller then delivers-and-labels
|
||||
* with `SENDER_FALLBACK` (never a refusal, never a forged actor).
|
||||
*/
|
||||
export function resolveSenderSession(
|
||||
tmuxReader: TmuxIdentityReader = readTmuxIdentity,
|
||||
isBound: BindingCheck = localBindingIsCurrent,
|
||||
): string | undefined {
|
||||
const env = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME")?.trim();
|
||||
if (env) return env;
|
||||
const pane = process.env.TMUX_PANE?.trim();
|
||||
if (!pane) return undefined;
|
||||
if (paneSeat?.pane === pane && paneSeat.tmuxReader === tmuxReader && paneSeat.isBound === isBound) return paneSeat.session;
|
||||
const session = boundPaneSeat(pane, tmuxReader, isBound);
|
||||
paneSeat = { pane, tmuxReader, isBound, session };
|
||||
return session;
|
||||
}
|
||||
|
||||
function boundPaneSeat(pane: string, tmuxReader: TmuxIdentityReader, isBound: BindingCheck): string | undefined {
|
||||
const session = tmuxReader(pane, "@rigged_session_name")?.trim();
|
||||
if (!session) return undefined;
|
||||
const nodeId = tmuxReader(pane, "@rigged_node_id")?.trim();
|
||||
return nodeId && isBound(nodeId, session) ? session : undefined;
|
||||
export function resolveSenderSession(): string | undefined {
|
||||
return readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
|
||||
}
|
||||
|
||||
/**
|
||||
* The session-less fall-open marker for an unattributable CLI dispatch. P18 (deletion atom) REVERSES A1's
|
||||
* seat-boundary refusal: an unresolved `rig send`/`broadcast` now DELIVERS carrying this HONEST label rather
|
||||
* seat-boundary refusal: an env-less `rig send`/`broadcast` now DELIVERS carrying this HONEST label rather
|
||||
* than refusing, because the daemon half delivers-and-labels the header-absent write (no downstream 401) —
|
||||
* unverified is labelled unknown, never laundered to verified.
|
||||
*
|
||||
|
||||
@@ -513,17 +513,6 @@ describe("PUBLIC bin ownership (guard finding 1 — the wrapper is the real fron
|
||||
|
||||
describe("script-safety integration (the compiled front door)", () => {
|
||||
const cliEntry = join(__dirname, "..", "dist", "index.js");
|
||||
it.skipIf(!existsSync(cliEntry))("an unresolved queue actor stays nonzero through a pipe with pipefail", () => {
|
||||
const result = spawnSync("/bin/bash", ["--noprofile", "--norc", "-o", "pipefail", "-c",
|
||||
'env -u OPENRIG_SESSION_NAME -u RIGGED_SESSION_NAME -u TMUX_PANE "$NODE" "$CLI" queue update qitem-pipe-proof --state in-progress --json 2>&1 | cat'], {
|
||||
timeout: 5000,
|
||||
encoding: "utf-8",
|
||||
env: { ...process.env, NODE: process.execPath, CLI: cliEntry },
|
||||
});
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toContain("OPENRIG_SESSION_NAME is not set");
|
||||
});
|
||||
|
||||
it.skipIf(!existsSync(cliEntry))("`echo x | rig` returns usage fast — no hang (exit code preserves today's clean-help baseline)", () => {
|
||||
const started = Date.now();
|
||||
const result = spawnSync(process.execPath, [cliEntry], {
|
||||
|
||||
@@ -92,17 +92,12 @@ describe("cross-host queue writes wait for the daemon's answer", () => {
|
||||
}
|
||||
|
||||
it("a create that gets no answer in time reads as unknown, never as a failure, with the ID to reconcile by", async () => {
|
||||
let thrown: unknown;
|
||||
await run(["create", "--destination", "dev@rig-b@vps-b", "--body", "b", "--summary", "s"],
|
||||
[new DaemonTimeoutError("Request to http://localhost:7433/api/queue/create timed out after 15000ms")]).catch((e: unknown) => { thrown = e; });
|
||||
await expect(run(["create", "--destination", "dev@rig-b@vps-b", "--body", "b", "--summary", "s"],
|
||||
[new DaemonTimeoutError("Request to http://localhost:7433/api/queue/create timed out after 15000ms")])).rejects.toThrow();
|
||||
const all = stderr.join("\n");
|
||||
expect(all).toContain("queue request got no answer in time (routing to host 'vps-b')");
|
||||
expect(all).not.toContain("queue transport failure");
|
||||
expect(all).toContain("The write outcome is INDETERMINATE");
|
||||
// The final timeout error carries the same-ID instruction itself, not only the request line printed before the write.
|
||||
const id = /Queue create request ID: (qitem-\d{14}-[0-9a-f]{16})/.exec(all)?.[1];
|
||||
expect(id).toBeDefined();
|
||||
expect(thrown).toBeInstanceOf(DaemonTimeoutError);
|
||||
expect((thrown as Error).message).toContain(`repeat the same create with --id '${id}' and unchanged source, destination, body and options`);
|
||||
expect(all).toMatch(/repeat the same create with --id 'qitem-\d{14}-[0-9a-f]{16}'/);
|
||||
});
|
||||
});
|
||||
@@ -52,7 +52,6 @@ describe("queue verbs on an existing row with --host (#2637)", () => {
|
||||
let local: Awaited<ReturnType<typeof serve>>;
|
||||
let remote: Awaited<ReturnType<typeof serve>>;
|
||||
let remoteAnswer: Answer;
|
||||
let remoteRow: Record<string, unknown>;
|
||||
let home: string;
|
||||
let out: string[];
|
||||
let err: string[];
|
||||
@@ -80,22 +79,11 @@ describe("queue verbs on an existing row with --host (#2637)", () => {
|
||||
? { status: 200, data: { healthy: true, selfHostId: "vm-a" } }
|
||||
: { status: 500, data: { error: "the selected daemon must not be asked for the row" } });
|
||||
localPort = Number(new URL(local.url).port);
|
||||
remoteRow = { ...ROW };
|
||||
const remoteTransitions: Array<{ transitionId: number; state: string; transitionNote: string | null }> = [
|
||||
{ transitionId: 1, state: "pending", transitionNote: null },
|
||||
];
|
||||
remoteAnswer = (req) => {
|
||||
if (req.path === "/healthz") return { status: 200, data: { healthy: true, selfHostId: "devbox-id" } };
|
||||
if (req.path.endsWith("/transitions")) return { status: 200, data: remoteTransitions };
|
||||
if (req.path.endsWith("/transitions")) return { status: 200, data: [{ transitionId: 1, state: "pending" }] };
|
||||
if (req.path.startsWith("/api/queue/list")) return { status: 200, data: [ROW] };
|
||||
if (req.method === "POST" && req.path.endsWith("/update")) {
|
||||
remoteRow = { ...remoteRow, ...(req.body as Record<string, unknown>) };
|
||||
const transitionNote = (req.body as { transitionNote?: unknown }).transitionNote;
|
||||
if (typeof transitionNote === "string") {
|
||||
remoteTransitions.push({ transitionId: remoteTransitions.length + 1, state: String(remoteRow.state), transitionNote });
|
||||
}
|
||||
}
|
||||
return { status: 200, data: remoteRow };
|
||||
return { status: 200, data: ROW };
|
||||
};
|
||||
remote = await serve((req) => remoteAnswer(req));
|
||||
hosts = [
|
||||
@@ -133,7 +121,7 @@ describe("queue verbs on an existing row with --host (#2637)", () => {
|
||||
it("transitions --host reads the remote log", async () => {
|
||||
await run("transitions", "qitem-remote-1", "--host", "devbox", "--json", "--full");
|
||||
expect(remoteQueueCalls().map((c) => c.path)).toEqual(["/api/queue/qitem-remote-1/transitions"]);
|
||||
expect(JSON.parse(out.join("\n"))).toEqual([{ transitionId: 1, state: "pending", transitionNote: null }]);
|
||||
expect(JSON.parse(out.join("\n"))).toEqual([{ transitionId: 1, state: "pending" }]);
|
||||
});
|
||||
|
||||
it("list --host lists the remote rows, its filters kept and no rig derived from this host's seat", async () => {
|
||||
@@ -155,7 +143,7 @@ describe("queue verbs on an existing row with --host (#2637)", () => {
|
||||
|
||||
it("update --host writes on the remote daemon, the actor stamped with this host's id", async () => {
|
||||
await run("update", "qitem-remote-1", "--host", "devbox", "--state", "done", "--closure-reason", "no-follow-on", "--note", "settled from vm-a", "--json");
|
||||
const call = remoteQueueCalls().find((entry) => entry.method === "POST");
|
||||
const [call] = remoteQueueCalls();
|
||||
expect(call).toMatchObject({ method: "POST", path: "/api/queue/qitem-remote-1/update" });
|
||||
expect(call!.headers["x-openrig-session"]).toBe("dev@fabrica@vm-a");
|
||||
expect(call!.headers.authorization).toBe("Bearer s3cret");
|
||||
@@ -220,7 +208,7 @@ describe("queue verbs on an existing row with --host (#2637)", () => {
|
||||
it("a self-id learned at the entry's current URL routes to that entry", async () => {
|
||||
bind({ observedUrl: remote.url });
|
||||
await run("update", "qitem-remote-1", "--host", "devbox-self-id", "--state", "done", "--json");
|
||||
expect(remoteQueueCalls().map((c) => c.path)).toEqual(["/api/queue/qitem-remote-1/update", "/api/queue/qitem-remote-1"]);
|
||||
expect(remoteQueueCalls().map((c) => c.path)).toEqual(["/api/queue/qitem-remote-1/update"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -275,9 +263,7 @@ describe("queue verbs on an existing row with --host (#2637)", () => {
|
||||
const { compactOutputEnabled } = await import("../src/queue-receipt.js");
|
||||
expect(compactOutputEnabled()).toBe(true);
|
||||
// The remote daemon's own id equals the alias: the receipt must still call the row remote.
|
||||
remoteAnswer = (req) => req.path === "/healthz"
|
||||
? { status: 200, data: { selfHostId: "devbox" } }
|
||||
: { status: 200, data: { ...ROW, state: "in-progress" } };
|
||||
remoteAnswer = (req) => req.path === "/healthz" ? { status: 200, data: { selfHostId: "devbox" } } : { status: 200, data: ROW };
|
||||
await run("update", "qitem-remote-1", "--host", "devbox", "--state", "in-progress", "--json");
|
||||
const receipt = JSON.parse(out.join("\n"));
|
||||
expect(receipt.host).toBe("devbox");
|
||||
@@ -288,15 +274,13 @@ describe("queue verbs on an existing row with --host (#2637)", () => {
|
||||
local.close();
|
||||
local = await serve((req) => req.path === "/healthz"
|
||||
? { status: 200, data: { selfHostId: "vm-a" } }
|
||||
: { status: 200, data: req.path.startsWith("/api/queue/list") ? [] : { ...ROW, state: "done" } });
|
||||
: { status: 200, data: req.path.startsWith("/api/queue/list") ? [] : ROW });
|
||||
localPort = Number(new URL(local.url).port);
|
||||
await run("show", "qitem-remote-1", "--json");
|
||||
await run("list", "--json");
|
||||
await run("update", "qitem-remote-1", "--state", "done", "--json");
|
||||
expect(remote.seen).toEqual([]);
|
||||
expect(localQueueCalls().map((c) => c.path.split("?")[0])).toEqual([
|
||||
"/api/queue/qitem-remote-1", "/api/queue/list", "/api/queue/qitem-remote-1/update", "/api/queue/qitem-remote-1",
|
||||
]);
|
||||
expect(localQueueCalls().map((c) => c.path.split("?")[0])).toEqual(["/api/queue/qitem-remote-1", "/api/queue/list", "/api/queue/qitem-remote-1/update"]);
|
||||
// The local list still derives the rig from the seat, and no sender is stamped with a host id.
|
||||
expect(new URL(localQueueCalls()[1]!.path, "http://x").searchParams.get("rig")).toBe("fabrica");
|
||||
expect(localQueueCalls()[2]!.headers["x-openrig-session"]).toBe("dev@fabrica");
|
||||
|
||||
@@ -68,11 +68,8 @@ for (const json of [false, true]) {
|
||||
expect(body.hint).toBe(`Queue item "${id}" was not found on daemon "selected-host".\n`
|
||||
+ `If it came from another host, run rig host list; replace <daemon-url> with its registered daemon URL and run: OPENRIG_URL='<daemon-url>' rig queue show ${shellQuote(id)} --full --json\n`
|
||||
+ `Or name that host: rig queue show ${shellQuote(id)} --host <host-id> --full --json`);
|
||||
// A note's update first reads the row's transitions (read-only), so its read-back can prove the note is new.
|
||||
const preflight = verb === "update" ? 1 : 0;
|
||||
expect(calls).toHaveLength(2 + preflight);
|
||||
if (preflight) expect(calls[0]!.path).toBe(`/api/queue/${id}/transitions`);
|
||||
expect(calls[1 + preflight]).toEqual({ path: "/healthz", options: { timeoutMs: 1_000 } });
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1]).toEqual({ path: "/healthz", options: { timeoutMs: 1_000 } });
|
||||
expect(calls.some(c => c.path.includes("/hosts"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -109,8 +106,6 @@ it("quotes the supplied ID without guessing a host or suggesting a write replay"
|
||||
const body = JSON.parse(output.join("\n"));
|
||||
expect(body.hint).toContain(`rig queue show ${shellQuote(supplied)} --full --json`);
|
||||
expect(body.hint).not.toContain("queue update");
|
||||
expect(calls.slice(0, 2).map((c) => c.path)).toEqual([
|
||||
`/api/queue/${encodeURIComponent(supplied)}/transitions`, `/api/queue/${encodeURIComponent(supplied)}/update`,
|
||||
]);
|
||||
expect(calls[0]?.path).toBe(`/api/queue/${encodeURIComponent(supplied)}/update`);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
@@ -42,8 +42,6 @@ describe("an agent's pane task: woken, claimed and closed (#165)", () => {
|
||||
return { status: response.status, data: await response.json() };
|
||||
};
|
||||
const client = {
|
||||
// The sender it stamps, as the real client reports it: the update's read-back checks the recorded actor against it.
|
||||
senderIdentity: async () => ({ session: process.env.OPENRIG_SESSION_NAME, originUnknown: false }),
|
||||
get: async (url: string) => request(url, { method: "GET" }),
|
||||
post: async (url: string, body: unknown) => request(url, { method: "POST", body: JSON.stringify(body) }),
|
||||
};
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../../daemon/src/db/connection.js";
|
||||
import { migrate } from "../../daemon/src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../../daemon/src/db/all-migrations.js";
|
||||
import { EventBus } from "../../daemon/src/domain/event-bus.js";
|
||||
import { OutboxHandler } from "../../daemon/src/domain/outbox-handler.js";
|
||||
import { QueueRepository } from "../../daemon/src/domain/queue-repository.js";
|
||||
import { runProgram } from "../src/cli-error.js";
|
||||
import { queueCommand } from "../src/commands/queue.js";
|
||||
import { STATE_FILE } from "../src/daemon-lifecycle.js";
|
||||
|
||||
type UpdateTransform = (body: Record<string, unknown>) => Record<string, unknown> | null;
|
||||
/** The nth read of the row's transitions (0 is the cursor read before the write): a response, or a throw for no answer. */
|
||||
type TransitionsRead = (call: number) => { status: number; data: unknown } | undefined;
|
||||
|
||||
let database: Database.Database;
|
||||
let repo: QueueRepository;
|
||||
|
||||
/** The selected daemon as the CLI sees it: the sender header it stamps, and the id the daemon's /healthz answers with. */
|
||||
interface Endpoint { sender?: string; selfHostId?: string }
|
||||
|
||||
async function runUpdate(qitemId: string, args: string[], transform: UpdateTransform = (body) => body, transitionsRead?: TransitionsRead,
|
||||
endpoint: Endpoint = {}) {
|
||||
let transitionReads = 0;
|
||||
let posts = 0;
|
||||
let exits = 0;
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const program = new Command();
|
||||
program.addCommand(queueCommand({
|
||||
lifecycleDeps: {
|
||||
spawn: () => ({ pid: 1, unref() {} }),
|
||||
fetch: async () => ({ ok: true }),
|
||||
kill: () => true,
|
||||
readFile: (file: string) => file === STATE_FILE
|
||||
? JSON.stringify({ pid: 1, port: 7433, host: "127.0.0.1", db: "test", startedAt: "test" })
|
||||
: null,
|
||||
writeFile() {},
|
||||
removeFile() {},
|
||||
exists: (file: string) => file === STATE_FILE,
|
||||
mkdirp() {},
|
||||
openForAppend: () => 3,
|
||||
isProcessAlive: () => true,
|
||||
},
|
||||
clientFactory: () => ({
|
||||
senderIdentity: async () => ({ session: endpoint.sender ?? "reviewer@fixture", originUnknown: false }),
|
||||
get: async (route: string) => route === "/healthz"
|
||||
? { status: 200, data: { selfHostId: endpoint.selfHostId } }
|
||||
: route.endsWith("/transitions")
|
||||
? transitionsRead?.(transitionReads++) ?? { status: 200, data: repo.listTransitions(qitemId) }
|
||||
: { status: 200, data: repo.getById(qitemId) },
|
||||
post: async (_route: string, rawBody: unknown) => {
|
||||
posts += 1;
|
||||
const body = transform(structuredClone(rawBody as Record<string, unknown>));
|
||||
return {
|
||||
status: 200,
|
||||
data: body === null
|
||||
? repo.getById(qitemId)
|
||||
: repo.update({ ...body, qitemId, actorSession: "reviewer@fixture" }),
|
||||
};
|
||||
},
|
||||
}) as never,
|
||||
}));
|
||||
// No exit status is reset here: each run's status is runProgram's to start clean.
|
||||
const originalLog = console.log;
|
||||
const originalError = console.error;
|
||||
console.log = (...values: unknown[]) => output.push(values.join(" "));
|
||||
console.error = (...values: unknown[]) => errors.push(values.join(" "));
|
||||
try {
|
||||
const code = await runProgram(program, ["node", "rig", "queue", "update", qitemId, ...args, "--json"], {
|
||||
out: (value) => output.push(value),
|
||||
err: (value) => errors.push(value),
|
||||
exit: () => { exits += 1; },
|
||||
});
|
||||
return { code, output, errors, row: repo.getByIdOrThrow(qitemId), transitions: repo.listTransitions(qitemId), posts, exits };
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
console.error = originalError;
|
||||
}
|
||||
}
|
||||
|
||||
async function claimedRow(): Promise<string> {
|
||||
const row = await repo.create({
|
||||
sourceSession: "reviewer@fixture",
|
||||
destinationSession: "reviewer@fixture",
|
||||
body: "work",
|
||||
nudge: false,
|
||||
});
|
||||
repo.claim({ qitemId: row.qitemId, destinationSession: "reviewer@fixture" });
|
||||
return row.qitemId;
|
||||
}
|
||||
|
||||
describe("queue update canonical read-back against daemon domain semantics", () => {
|
||||
beforeAll(() => {
|
||||
database = createDb();
|
||||
migrate(database, ALL_MIGRATIONS);
|
||||
const bus = new EventBus(database);
|
||||
repo = new QueueRepository(database, bus, {
|
||||
validateRig: () => true,
|
||||
loadHumanRegistry: () => ({ ok: true, entities: [] }),
|
||||
});
|
||||
repo.attachOutbox(new OutboxHandler(database));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("OPENRIG_SESSION_NAME", "reviewer@fixture");
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
afterAll(() => database.close());
|
||||
|
||||
it("accepts a closing resolution appended after an existing fallback resolution", async () => {
|
||||
const row = await repo.create({
|
||||
sourceSession: "reviewer@fixture",
|
||||
destinationSession: "worker@fixture",
|
||||
body: "work",
|
||||
nudge: false,
|
||||
});
|
||||
repo.routeToFallback(row.qitemId, "reviewer@fixture", "worker unavailable");
|
||||
repo.claim({ qitemId: row.qitemId, destinationSession: "reviewer@fixture" });
|
||||
|
||||
const result = await runUpdate(row.qitemId, [
|
||||
"--state", "done", "--closure-reason", "no-follow-on", "--resolution", "Handled by reviewer",
|
||||
]);
|
||||
|
||||
expect(result.code, [...result.errors, ...result.output].join("\n")).toBe(0);
|
||||
expect(result.row.resolution).toBe("fallback: worker unavailable\nHandled by reviewer");
|
||||
});
|
||||
|
||||
it("accepts close, reopen, and reclose while preserving each resolution line", async () => {
|
||||
const qitemId = await claimedRow();
|
||||
const first = await runUpdate(qitemId, [
|
||||
"--state", "done", "--closure-reason", "no-follow-on", "--resolution", "first outcome",
|
||||
]);
|
||||
const reopened = await runUpdate(qitemId, ["--state", "pending", "--reopen", "--note", "repair requested"]);
|
||||
const second = await runUpdate(qitemId, [
|
||||
"--state", "done", "--closure-reason", "no-follow-on", "--resolution", "second outcome",
|
||||
]);
|
||||
|
||||
expect([first.code, reopened.code, second.code], [first.errors, reopened.errors, second.errors].flat().join("\n"))
|
||||
.toEqual([0, 0, 0]);
|
||||
expect(second.row.resolution).toBe("first outcome\nsecond outcome");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"2026-11-14T10:00:00Z",
|
||||
"2026-11-14T12:00:00+02:00",
|
||||
])("accepts equivalent deadline spelling %s after daemon normalization", async (deadline) => {
|
||||
const qitemId = await claimedRow();
|
||||
const result = await runUpdate(qitemId, [
|
||||
"--closure-required-at", deadline, "--note", "Accepted revised deadline",
|
||||
]);
|
||||
|
||||
expect(result.code, result.errors.join("\n")).toBe(0);
|
||||
expect(result.row.closureRequiredAt).toBe("2026-11-14T10:00:00.000Z");
|
||||
});
|
||||
|
||||
it("rejects a genuinely different stored deadline", async () => {
|
||||
const qitemId = await claimedRow();
|
||||
repo.update({
|
||||
qitemId,
|
||||
actorSession: "reviewer@fixture",
|
||||
closureRequiredAt: "2026-11-14T09:00:00Z",
|
||||
transitionNote: "initial deadline",
|
||||
});
|
||||
const result = await runUpdate(qitemId, [
|
||||
"--closure-required-at", "2026-11-14T10:00:00Z", "--note", "Accepted revised deadline",
|
||||
], () => null);
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.errors.join("\n")).toContain("closureRequiredAt");
|
||||
});
|
||||
|
||||
it("rejects a close whose requested resolution was dropped", async () => {
|
||||
const qitemId = await claimedRow();
|
||||
const result = await runUpdate(qitemId, [
|
||||
"--state", "done", "--closure-reason", "no-follow-on", "--resolution", "must persist",
|
||||
], (body) => {
|
||||
delete body.resolution;
|
||||
return body;
|
||||
});
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.row.state).toBe("done");
|
||||
expect(result.row.resolution).toBeNull();
|
||||
expect(result.errors.join("\n")).toContain("resolution");
|
||||
});
|
||||
|
||||
it("requires a newer canonical transition carrying the requested note", async () => {
|
||||
const qitemId = await claimedRow();
|
||||
const before = repo.listTransitions(qitemId).length;
|
||||
const result = await runUpdate(qitemId, ["--note", "must persist"], () => null);
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.transitions).toHaveLength(before);
|
||||
expect(result.errors.join("\n")).toContain("no newer canonical transition carried requested note");
|
||||
});
|
||||
|
||||
// The cursor read before the write only proves a note is new afterwards. When it fails, the write is still sent and
|
||||
// persists; the receipt says the note can't be shown to be new and exits non-zero, even with an older transition
|
||||
// already carrying the same note.
|
||||
it.each([
|
||||
["doesn't answer", () => { throw new Error("socket hang up (fixture)"); }],
|
||||
["answers HTTP 503", () => ({ status: 503, data: { error: "busy" } })],
|
||||
["returns a malformed list", () => ({ status: 200, data: [{ transitionId: "seven" }] })],
|
||||
] as const)("sends a note-bearing update when the cursor read %s, and says the note is unproven", async (_label, fail) => {
|
||||
const qitemId = await claimedRow();
|
||||
repo.update({ qitemId, actorSession: "reviewer@fixture", transitionNote: "must persist" }); // an older matching note
|
||||
const before = repo.listTransitions(qitemId).length;
|
||||
const result = await runUpdate(qitemId, ["--note", "must persist", "--summary", "kept summary"], undefined,
|
||||
(call) => call === 0 ? fail() : undefined);
|
||||
|
||||
expect(result.posts).toBe(1);
|
||||
expect(result.transitions).toHaveLength(before + 1);
|
||||
expect(result.transitions.at(-1)?.transitionNote).toMatch(/^must persist/);
|
||||
expect(result.row.summary).toBe("kept summary");
|
||||
expect(result.code).toBe(1);
|
||||
const said = result.errors.join("\n");
|
||||
expect(said).toContain("queue_update_readback_failed");
|
||||
expect(said).toContain("can't be shown to be new");
|
||||
expect(said).not.toContain("queue_update_preflight_failed");
|
||||
});
|
||||
|
||||
it("gives each run its own exit status: a failed update, then a successful one in the same process", async () => {
|
||||
const qitemId = await claimedRow();
|
||||
const failed = await runUpdate(qitemId, ["--note", "dropped"], () => null);
|
||||
const succeeded = await runUpdate(qitemId, ["--note", "kept"]);
|
||||
|
||||
expect(failed.code).toBe(1);
|
||||
expect(succeeded.code, succeeded.errors.join("\n")).toBe(0);
|
||||
// The action's failure is returned, never passed to process.exit(), which can cut off piped output.
|
||||
expect([failed.exits, succeeded.exits]).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
// A note is this write's only when this write's actor recorded it, as written or with what the daemon adds for it.
|
||||
it.each([
|
||||
["another writer's identical note", "other@fixture", "must persist"],
|
||||
["a lookalike note that starts the same way", "reviewer@fixture", "must persist (correction: still blocked)"],
|
||||
["a lookalike note on a new line", "reviewer@fixture", "must persist\nbut still blocked"],
|
||||
] as const)("doesn't take %s for a dropped note", async (_label, actor, note) => {
|
||||
const qitemId = await claimedRow();
|
||||
const result = await runUpdate(qitemId, ["--note", "must persist"], () => {
|
||||
repo.update({ qitemId, actorSession: actor, transitionNote: note });
|
||||
return null;
|
||||
});
|
||||
|
||||
expect(result.transitions.at(-1)?.transitionNote).toBe(note);
|
||||
expect(result.code).toBe(1);
|
||||
const said = JSON.parse(result.errors.join("\n")) as { error: string; message: string };
|
||||
expect(said.error).toBe("queue_update_readback_failed");
|
||||
expect(said.message).toContain('no newer canonical transition carried requested note "must persist" from "reviewer@fixture"');
|
||||
});
|
||||
|
||||
// A seat on host-a writing to a daemon at a direct endpoint sends reviewer@fixture@host-a. Only a daemon whose own id is
|
||||
// host-a drops that suffix; any other daemon keeps it, and its own reviewer@fixture is a different seat.
|
||||
it("doesn't take the selected daemon's own same-named seat for a dropped note's writer", async () => {
|
||||
const qitemId = await claimedRow();
|
||||
const result = await runUpdate(qitemId, ["--note", "must persist"], () => {
|
||||
repo.update({ qitemId, actorSession: "reviewer@fixture", transitionNote: "must persist" });
|
||||
return null;
|
||||
}, undefined, { sender: "reviewer@fixture@host-a", selfHostId: "host-b" });
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
const said = JSON.parse(result.errors.join("\n")) as { error: string; message: string };
|
||||
expect(said.message).toContain('from "reviewer@fixture@host-a"');
|
||||
expect(said.message).not.toContain('or "reviewer@fixture"');
|
||||
});
|
||||
|
||||
it("accepts the bare actor when the selected daemon answers with the sender's own host id", async () => {
|
||||
const qitemId = await claimedRow();
|
||||
const result = await runUpdate(qitemId, ["--note", "must persist"], undefined, undefined,
|
||||
{ sender: "reviewer@fixture@host-a", selfHostId: "host-a" });
|
||||
|
||||
expect(result.code, result.errors.join("\n")).toBe(0);
|
||||
expect(result.transitions.at(-1)).toMatchObject({ actorSession: "reviewer@fixture", transitionNote: "must persist" });
|
||||
});
|
||||
|
||||
it("accepts the summary suffix the daemon adds to a note", async () => {
|
||||
const qitemId = await claimedRow();
|
||||
const result = await runUpdate(qitemId, ["--note", "must persist", "--summary", "new subject"]);
|
||||
|
||||
expect(result.code, result.errors.join("\n")).toBe(0);
|
||||
expect(result.transitions.at(-1)?.transitionNote).toBe("must persist (summary updated)");
|
||||
});
|
||||
|
||||
it("accepts a note only after its canonical transition appears", async () => {
|
||||
const qitemId = await claimedRow();
|
||||
const result = await runUpdate(qitemId, ["--note", "must persist"]);
|
||||
|
||||
expect(result.code, result.errors.join("\n")).toBe(0);
|
||||
expect(result.transitions.at(-1)?.transitionNote).toBe("must persist");
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
// A verified `rig queue update` prints the canonical row it read back, and keeps what only the write's response carries:
|
||||
// the park advisory (#2739). A real `queueCommand` talks over loopback HTTP to the real queueRoutes and QueueRepository,
|
||||
// so the POST and the GET that reads it back are the daemon's own.
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { Hono } from "hono";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../../daemon/src/db/connection.js";
|
||||
import { migrate } from "../../daemon/src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../../daemon/src/db/all-migrations.js";
|
||||
import { EventBus } from "../../daemon/src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../../daemon/src/domain/queue-repository.js";
|
||||
import { queueRoutes } from "../../daemon/src/routes/queue.js";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { queueCommand } from "../src/commands/queue.js";
|
||||
import { STATE_FILE } from "../src/daemon-lifecycle.js";
|
||||
|
||||
let db: Database.Database;
|
||||
let repo: QueueRepository;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let server: any;
|
||||
let port = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = createDb();
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
const bus = new EventBus(db);
|
||||
repo = new QueueRepository(db, bus, { validateRig: () => true });
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => { c.set("eventBus" as never, bus); c.set("queueRepo" as never, repo); await next(); });
|
||||
app.route("/api/queue", queueRoutes());
|
||||
await new Promise<void>((resolve) => {
|
||||
server = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: 0 }, (info: { port: number }) => { port = info.port; resolve(); });
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (server) await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
db?.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// A seat, on this host's own daemon: the CLI stamps it as the sender. Ambient endpoints would win over the state file.
|
||||
vi.stubEnv("OPENRIG_SESSION_NAME", "dev-x@rig");
|
||||
vi.stubEnv("TMUX_PANE", "");
|
||||
for (const key of ["OPENRIG_URL", "RIGGED_URL", "OPENRIG_PORT"]) vi.stubEnv(key, "");
|
||||
});
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
async function runCli(args: string[]): Promise<{ out: string; err: string; exit: number | undefined }> {
|
||||
const out: string[] = [];
|
||||
const err: string[] = [];
|
||||
const ol = console.log, oe = console.error;
|
||||
console.log = (...a: unknown[]) => { out.push(a.join(" ")); };
|
||||
console.error = (...a: unknown[]) => { err.push(a.join(" ")); };
|
||||
process.exitCode = undefined;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await queueCommand({
|
||||
lifecycleDeps: {
|
||||
spawn: () => ({ pid: 1, unref() {} }), fetch: async () => ({ ok: true }), kill: () => true,
|
||||
readFile: (p: string) => (p === STATE_FILE ? JSON.stringify({ pid: 1, port, host: "127.0.0.1", db: "x", startedAt: "x" }) : null),
|
||||
writeFile() {}, removeFile() {}, exists: (p: string) => p === STATE_FILE, mkdirp() {}, openForAppend: () => 3, isProcessAlive: () => true,
|
||||
},
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
} as any).parseAsync(["node", "rig", ...args]);
|
||||
} finally {
|
||||
console.log = ol;
|
||||
console.error = oe;
|
||||
}
|
||||
const exit = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
return { out: out.join("\n"), err: err.join("\n"), exit };
|
||||
}
|
||||
|
||||
async function claimedRow(): Promise<string> {
|
||||
const row = await repo.create({ sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "work", nudge: false });
|
||||
repo.claim({ qitemId: row.qitemId, destinationSession: "dev-x@rig" });
|
||||
return row.qitemId;
|
||||
}
|
||||
|
||||
const park = (id: string) => ["update", id, "--state", "blocked", "--blocked-on", "external:vendor-fix", "--note", "waiting on the vendor"];
|
||||
|
||||
describe("a verified queue update keeps the write's park advisory", () => {
|
||||
it.each([
|
||||
["the compact JSON receipt", "true", ["--json"]],
|
||||
["the full JSON row", "false", ["--json"]],
|
||||
["the full row with --full", "true", ["--full", "--json"]],
|
||||
] as const)("in %s", async (_label, compact, flags) => {
|
||||
vi.stubEnv("OPENRIG_OUTPUT_COMPACT", compact);
|
||||
const id = await claimedRow();
|
||||
const { out, err, exit } = await runCli([...park(id), ...flags]);
|
||||
|
||||
expect(exit, err).toBeFalsy();
|
||||
const printed = JSON.parse(out) as { state?: string; advisories?: Array<{ code?: string }> };
|
||||
expect(printed.advisories).toEqual([expect.objectContaining({ code: "park_without_wake" })]);
|
||||
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "blocked", blockedOn: "external:vendor-fix" });
|
||||
});
|
||||
|
||||
it("in the compact text receipt", async () => {
|
||||
vi.stubEnv("OPENRIG_OUTPUT_COMPACT", "true");
|
||||
const id = await claimedRow();
|
||||
const { out, err, exit } = await runCli(park(id));
|
||||
|
||||
expect(exit, err).toBeFalsy();
|
||||
expect(out).toMatch(/advisory: No wake is set for this park/);
|
||||
});
|
||||
|
||||
it("and a park with a wake carries none", async () => {
|
||||
vi.stubEnv("OPENRIG_OUTPUT_COMPACT", "true");
|
||||
const id = await claimedRow();
|
||||
const { out, err, exit } = await runCli([...park(id), "--wake-after", "10m", "--json"]);
|
||||
|
||||
expect(exit, err).toBeFalsy();
|
||||
expect((JSON.parse(out) as { advisories?: unknown }).advisories).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -49,8 +49,6 @@ function row(over: Row = {}, waiting: Row = {}): Row {
|
||||
}
|
||||
|
||||
function makeDeps(routes: Record<string, { status: number; data: unknown } | Array<{ status: number; data: unknown }>>): QueueDeps {
|
||||
const transitions: Array<{ transitionId: number; transitionNote: string | null; actorSession: string }> =
|
||||
[{ transitionId: 1, transitionNote: null, actorSession: "stub@rig" }];
|
||||
const pick = (key: string, fallback: { status: number; data: unknown }) => {
|
||||
const r = routes[key];
|
||||
return (Array.isArray(r) ? r.shift() : r) ?? fallback;
|
||||
@@ -58,27 +56,8 @@ function makeDeps(routes: Record<string, { status: number; data: unknown } | Arr
|
||||
return {
|
||||
lifecycleDeps: {} as QueueDeps["lifecycleDeps"],
|
||||
clientFactory: () => ({
|
||||
// The sender this stub stamps; the transitions it records carry it as their actor, as the daemon does.
|
||||
senderIdentity: async () => ({ session: "stub@rig", originUnknown: false }),
|
||||
get: vi.fn(async (p: string) => {
|
||||
const explicit = routes[`GET ${p}`];
|
||||
if (explicit) return Array.isArray(explicit) ? explicit.shift()! : explicit;
|
||||
if (p.endsWith("/transitions")) return { status: 200, data: transitions };
|
||||
const update = routes[`POST ${p}/update`];
|
||||
if (update) {
|
||||
const response = Array.isArray(update) ? update[0] : update;
|
||||
return { status: 200, data: response?.data ?? {} };
|
||||
}
|
||||
return { status: 200, data: {} };
|
||||
}),
|
||||
post: vi.fn(async (p: string, body: unknown) => {
|
||||
const response = pick(`POST ${p}`, { status: 500, data: { error: "unrouted" } });
|
||||
const transitionNote = body && typeof body === "object" ? (body as { transitionNote?: unknown }).transitionNote : undefined;
|
||||
if (response.status < 400 && typeof transitionNote === "string") {
|
||||
transitions.push({ transitionId: transitions.length + 1, transitionNote, actorSession: "stub@rig" });
|
||||
}
|
||||
return response;
|
||||
}),
|
||||
get: vi.fn(async (p: string) => pick(`GET ${p}`, { status: 200, data: {} })),
|
||||
post: vi.fn(async (p: string) => pick(`POST ${p}`, { status: 500, data: { error: "unrouted" } })),
|
||||
}) as unknown as ReturnType<QueueDeps["clientFactory"]>,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,8 +31,6 @@ interface StubResponse {
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
const STUB_SENDER = "stub@rig";
|
||||
|
||||
function makeDeps(opts?: {
|
||||
routes?: Record<string, StubResponse | StubResponse[]>;
|
||||
}): { deps: QueueDeps; calls: Array<{ method: string; path: string; body?: unknown }> } {
|
||||
@@ -43,37 +41,10 @@ function makeDeps(opts?: {
|
||||
deps: {
|
||||
lifecycleDeps: {} as QueueDeps["lifecycleDeps"],
|
||||
clientFactory: () => ({
|
||||
// The sender this stub stamps; the transitions it synthesizes record it as their actor, as the daemon does.
|
||||
senderIdentity: async () => ({ session: STUB_SENDER, originUnknown: false }),
|
||||
get: vi.fn(async (path: string) => {
|
||||
calls.push({ method: "GET", path });
|
||||
const route = routes[`GET ${path}`];
|
||||
if (route) return Array.isArray(route) ? route.shift() : route;
|
||||
const transitionMatch = /^\/api\/queue\/([^/]+)\/transitions$/.exec(path);
|
||||
if (transitionMatch) {
|
||||
const qitemId = decodeURIComponent(transitionMatch[1]!);
|
||||
const update = [...calls].reverse().find((call) => call.method === "POST"
|
||||
&& call.path === `/api/queue/${encodeURIComponent(qitemId)}/update`);
|
||||
const transitionNote = update?.body && typeof update.body === "object"
|
||||
? (update.body as { transitionNote?: unknown }).transitionNote
|
||||
: undefined;
|
||||
return { status: 200, data: [
|
||||
{ transitionId: 1, transitionNote: null, actorSession: STUB_SENDER },
|
||||
...(typeof transitionNote === "string" ? [{ transitionId: 2, transitionNote, actorSession: STUB_SENDER }] : []),
|
||||
] };
|
||||
}
|
||||
const match = /^\/api\/queue\/([^/]+)$/.exec(path);
|
||||
if (match) {
|
||||
const qitemId = decodeURIComponent(match[1]!);
|
||||
const update = [...calls].reverse().find((call) => call.method === "POST"
|
||||
&& call.path === `/api/queue/${encodeURIComponent(qitemId)}/update`);
|
||||
if (update?.body && typeof update.body === "object") {
|
||||
const body = Object.fromEntries(Object.entries(update.body as Record<string, unknown>)
|
||||
.filter(([key, value]) => value !== undefined && !["transitionNote", "actorSession", "reopen"].includes(key)));
|
||||
return { status: 200, data: { qitemId, ...body } };
|
||||
}
|
||||
}
|
||||
return { status: 200, data: {} };
|
||||
return (Array.isArray(route) ? route.shift() : route) ?? { status: 200, data: {} };
|
||||
}),
|
||||
getText: vi.fn(async (path: string) => {
|
||||
calls.push({ method: "GET", path });
|
||||
@@ -112,7 +83,6 @@ describe("rig queue CLI", () => {
|
||||
// AMBIENT OPENRIG_SESSION_NAME (which masked the env-less break in a managed runner). Per-test stubs
|
||||
// that need a specific seat override this.
|
||||
vi.stubEnv("OPENRIG_SESSION_NAME", "seat@rig");
|
||||
vi.stubEnv("TMUX_PANE", "");
|
||||
logs = [];
|
||||
errors = [];
|
||||
vi.spyOn(console, "log").mockImplementation((...args) => logs.push(args.join(" ")));
|
||||
@@ -1233,37 +1203,6 @@ describe("rig queue CLI", () => {
|
||||
evidenceRef: undefined,
|
||||
transitionNote: "audit bytes",
|
||||
});
|
||||
expect(calls.some((c) => c.method === "GET" && c.path === "/api/queue/qitem-note")).toBe(true);
|
||||
});
|
||||
|
||||
it("update reports success only after canonical read-back matches the requested state", async () => {
|
||||
const row = { qitemId: "qitem-verified", state: "in-progress", summary: "working" };
|
||||
const { deps, calls } = makeDeps({ routes: {
|
||||
"POST /api/queue/qitem-verified/update": { status: 200, data: { qitemId: "qitem-verified", state: "in-progress" } },
|
||||
"GET /api/queue/qitem-verified": { status: 200, data: row },
|
||||
} });
|
||||
await createProgram({ queueDeps: deps }).parseAsync([
|
||||
"node", "rig", "queue", "update", "qitem-verified", "--state", "in-progress", "--summary", "working", "--json",
|
||||
]);
|
||||
expect(calls.map((call) => `${call.method} ${call.path}`)).toEqual([
|
||||
"POST /api/queue/qitem-verified/update",
|
||||
"GET /api/queue/qitem-verified",
|
||||
]);
|
||||
expect(JSON.parse(logs.at(-1)!)).toMatchObject(row);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("update fails closed when canonical read-back does not contain the requested state", async () => {
|
||||
const { deps } = makeDeps({ routes: {
|
||||
"POST /api/queue/qitem-mismatch/update": { status: 200, data: { qitemId: "qitem-mismatch", state: "in-progress" } },
|
||||
"GET /api/queue/qitem-mismatch": { status: 200, data: { qitemId: "qitem-mismatch", state: "pending" } },
|
||||
} });
|
||||
await createProgram({ queueDeps: deps }).parseAsync([
|
||||
"node", "rig", "queue", "update", "qitem-mismatch", "--state", "in-progress", "--json",
|
||||
]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(JSON.parse(errors.at(-1)!)).toMatchObject({ error: "queue_update_readback_failed" });
|
||||
expect(logs).toEqual([]);
|
||||
});
|
||||
|
||||
it("update forwards an explicitly accepted closure deadline without a state change", async () => {
|
||||
|
||||
@@ -62,12 +62,12 @@ function depsThrowing(err: Error): { queueDeps: QueueDeps } {
|
||||
async function runCli(args: string[], deps?: Parameters<typeof createProgram>[0]) {
|
||||
const out: string[] = [];
|
||||
const err: string[] = [];
|
||||
let exitCode = 0;
|
||||
const program = createProgram(deps);
|
||||
// The run's status is what runProgram returns; it calls exit only on its own error paths, with the same code.
|
||||
const exitCode = await runProgram(program, ["node", "rig", ...args], {
|
||||
await runProgram(program, ["node", "rig", ...args], {
|
||||
out: (l) => out.push(l),
|
||||
err: (l) => err.push(l),
|
||||
exit: () => undefined,
|
||||
exit: (c) => { exitCode = c; },
|
||||
});
|
||||
return { out: out.join("\n"), err: err.join("\n"), exitCode };
|
||||
}
|
||||
@@ -122,17 +122,6 @@ describe("render response integrity — 3-part error + honest exit, never daemon
|
||||
beforeEach(() => { vi.stubEnv("OPENRIG_SESSION_NAME", "harness@rig"); process.exitCode = undefined; });
|
||||
afterEach(() => { vi.unstubAllEnvs(); });
|
||||
|
||||
it("an unresolved queue actor stays nonzero when a wrapper pipes stdout", async () => {
|
||||
vi.stubEnv("OPENRIG_SESSION_NAME", "");
|
||||
vi.stubEnv("RIGGED_SESSION_NAME", "");
|
||||
vi.stubEnv("TMUX_PANE", "");
|
||||
const { exitCode } = await runCli(
|
||||
["queue", "update", "qitem-x", "--state", "in-progress", "--json"],
|
||||
depsThrowing(new Error("daemon must not be contacted")),
|
||||
);
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("bad-response renders fact/consequence/action JSON with nonzero exit (json)", async () => {
|
||||
const err = new DaemonResponseError(502, "<html>502 Bad Gateway</html>");
|
||||
const { out, exitCode } = await runCli(
|
||||
|
||||
@@ -241,8 +241,6 @@ describe("Send CLI", () => {
|
||||
// (Hermetic-gate default is env-UNSET, so without this env-less delivery would render the unknown
|
||||
// marker.) Nested describes that stub their own envs re-run after this; the block afterEach restores.
|
||||
vi.stubEnv("OPENRIG_SESSION_NAME", "sender@my-rig");
|
||||
// A runner inside tmux would otherwise let the managed-pane fallback recover the runner's own seat.
|
||||
vi.stubEnv("TMUX_PANE", "");
|
||||
vi.stubEnv("RIGGED_SESSION_NAME", "");
|
||||
});
|
||||
afterEach(() => { vi.unstubAllEnvs(); });
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import { createDb } from "../../daemon/src/db/connection.js";
|
||||
import { migrate } from "../../daemon/src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../../daemon/src/db/all-migrations.js";
|
||||
import { createTestApp } from "../../daemon/test/helpers/test-app.js";
|
||||
import { runProgram } from "../src/cli-error.js";
|
||||
import { senderIdentityHeaders } from "../src/client.js";
|
||||
import { bindingIsCurrent, resetSenderSession, resolveSenderSession, TMUX_IDENTITY_TIMEOUT_MS } from "../src/sender-identity.js";
|
||||
|
||||
const execFileSync = vi.hoisted(() => vi.fn());
|
||||
vi.mock("node:child_process", async (original) => ({ ...(await original<typeof import("node:child_process")>()), execFileSync }));
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
execFileSync.mockReset();
|
||||
resetSenderSession();
|
||||
});
|
||||
|
||||
function outsideASeatEnv(pane: string): void {
|
||||
vi.stubEnv("OPENRIG_SESSION_NAME", "");
|
||||
vi.stubEnv("RIGGED_SESSION_NAME", "");
|
||||
vi.stubEnv("TMUX_PANE", pane);
|
||||
}
|
||||
|
||||
describe("managed sender identity recovery", () => {
|
||||
it("uses reconciled pane metadata when the live process missed the seat env", () => {
|
||||
outsideASeatEnv("%149");
|
||||
const metadata: Record<string, string> = { "@rigged_session_name": "producer-lead@studio", "@rigged_node_id": "node-1" };
|
||||
const read = (pane: string, option: string) => {
|
||||
expect(pane).toBe("%149");
|
||||
return metadata[option];
|
||||
};
|
||||
const bound = (nodeId: string, session: string) => nodeId === "node-1" && session === "producer-lead@studio";
|
||||
expect(resolveSenderSession(read, bound)).toBe("producer-lead@studio");
|
||||
expect(senderIdentityHeaders(undefined, undefined, read, bound)).toEqual({
|
||||
"X-OpenRig-Session": "producer-lead@studio",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the process seat env authoritative over pane metadata", () => {
|
||||
vi.stubEnv("OPENRIG_SESSION_NAME", "dev-coder@studio");
|
||||
vi.stubEnv("TMUX_PANE", "%149");
|
||||
expect(resolveSenderSession(() => "producer-lead@studio", () => true)).toBe("dev-coder@studio");
|
||||
});
|
||||
|
||||
it("bounds the tmux read, and a read that times out leaves the seat unresolved", () => {
|
||||
outsideASeatEnv("%149");
|
||||
// The reader catches what the call throws, so the options are checked here, not inside the mock.
|
||||
execFileSync.mockImplementationOnce(() => {
|
||||
throw Object.assign(new Error("spawnSync tmux ETIMEDOUT"), { code: "ETIMEDOUT" });
|
||||
});
|
||||
expect(resolveSenderSession()).toBeUndefined();
|
||||
expect(execFileSync).toHaveBeenCalledTimes(1);
|
||||
expect(execFileSync.mock.calls[0]![2]).toMatchObject({ timeout: TMUX_IDENTITY_TIMEOUT_MS });
|
||||
expect(TMUX_IDENTITY_TIMEOUT_MS).toBeLessThanOrEqual(2_000);
|
||||
});
|
||||
|
||||
it("stays unresolved when neither env nor managed pane metadata exists", () => {
|
||||
outsideASeatEnv("%404");
|
||||
expect(resolveSenderSession(() => undefined, () => true)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads the pane once per run: a command's preflight and its requests share it, and the next run reads again", async () => {
|
||||
outsideASeatEnv("%149");
|
||||
execFileSync.mockImplementation((_tmux: string, args: string[]) => args.at(-1) === "@rigged_session_name" ? "producer-lead@studio\n" : "node-1\n");
|
||||
const bound = () => true;
|
||||
const run = () => runProgram(new Command().action(() => {
|
||||
expect(resolveSenderSession(undefined, bound)).toBe("producer-lead@studio"); // the preflight
|
||||
expect(senderIdentityHeaders(undefined, undefined, undefined, bound)).toEqual({ "X-OpenRig-Session": "producer-lead@studio" });
|
||||
expect(senderIdentityHeaders("origin-a", undefined, undefined, bound)).toEqual({ "X-OpenRig-Session": "producer-lead@studio@origin-a" });
|
||||
}), ["node", "rig"], { exit: () => undefined });
|
||||
expect(await run()).toBe(0);
|
||||
expect(execFileSync).toHaveBeenCalledTimes(2); // the seat and its node, once
|
||||
expect(await run()).toBe(0);
|
||||
expect(execFileSync).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a pane's seat holds only while the daemon binds it", () => {
|
||||
// A real claim sets the session's metadata and the daemon's binding. A real unclaim deletes the binding in its own
|
||||
// transaction even when clearing the metadata fails, which leaves the pane naming its old seat.
|
||||
async function claimedPane() {
|
||||
const db = createDb();
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
const setup = createTestApp(db);
|
||||
const metadata = new Map<string, string>();
|
||||
vi.mocked(setup.tmuxAdapter.setSessionOption).mockImplementation(async (_session: string, key: string, value: string) => {
|
||||
metadata.set(key, value);
|
||||
return { ok: true as const };
|
||||
});
|
||||
const rig = setup.rigRepo.createRig("adoption-fixture");
|
||||
const node = setup.rigRepo.addNode(rig.id, "external.helper", { runtime: "terminal" });
|
||||
const discovered = setup.discoveryRepo.upsertDiscoveredSession({
|
||||
tmuxSession: "manual-adoption", tmuxPane: "%1", cwd: "/tmp",
|
||||
activeCommand: "bash", runtimeHint: "terminal", confidence: "high",
|
||||
});
|
||||
const claim = await setup.claimService.bind({ discoveredId: discovered.id, rigId: rig.id, logicalId: node.logicalId });
|
||||
if (!claim.ok) throw new Error(claim.error);
|
||||
const read = (_pane: string, option: string) => metadata.get(option) || undefined;
|
||||
const bound = (nodeId: string, session: string) => bindingIsCurrent(db, nodeId, session);
|
||||
return { db, setup, claim, read, bound };
|
||||
}
|
||||
|
||||
it("reads a claimed session the daemon binds as its seat", async () => {
|
||||
outsideASeatEnv("%1");
|
||||
const { db, read, bound } = await claimedPane();
|
||||
expect(resolveSenderSession(read, bound)).toBe("manual-adoption");
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("reads a released session as unknown when clearing its metadata failed", async () => {
|
||||
outsideASeatEnv("%1");
|
||||
const { db, setup, claim, read, bound } = await claimedPane();
|
||||
vi.mocked(setup.tmuxAdapter.setSessionOption).mockImplementation(async () => { throw new Error("tmux: no server"); });
|
||||
expect((await setup.rigLifecycleService.unclaimSession(claim.sessionId)).ok).toBe(true);
|
||||
expect(read("%1", "@rigged_session_name")).toBe("manual-adoption");
|
||||
expect(resolveSenderSession(read, bound)).toBeUndefined();
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -121,19 +121,6 @@ describe("S10 CLI cutover — admin verbs route to the daemon", () => {
|
||||
});
|
||||
|
||||
describe("S10 CLI cutover — config surfaces survive on the daemon-homed modules", () => {
|
||||
it("saves and reports the human-row preview choice", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "slack-preview-"));
|
||||
homes.push(home);
|
||||
const logs: string[] = [];
|
||||
const deps: SlackDeps = { home, log: message => logs.push(message), clientFactory: () => ({ get: async () => { throw new Error("offline"); } }) };
|
||||
await run(slackCommand(deps), ["setup", "--unfurl", "on"]);
|
||||
expect(JSON.parse(readFileSync(join(home, "slack-connector.json"), "utf8")).unfurl).toBe("on");
|
||||
logs.length = 0;
|
||||
await run(slackCommand(deps), ["status"]);
|
||||
expect(logs.join("\n")).toContain("human-row previews");
|
||||
expect(logs.join("\n")).toContain("on");
|
||||
});
|
||||
|
||||
it("retires the legacy alert-tag setup knob", () => {
|
||||
const { deps } = makeDeps();
|
||||
const setup = slackCommand(deps).commands.find((command) => command.name() === "setup");
|
||||
|
||||
@@ -95,17 +95,6 @@ rig queue handoff <qitem-id> \
|
||||
A queue write (`create`, `claim`, `update`, `handoff`, `handoff-and-complete`) answers with the row it wrote; a
|
||||
handoff answers with the closed row and its successor. When that answer shows what you need to establish, use it.
|
||||
|
||||
`queue update` is stricter: before it reports success, the CLI reads the row back from the canonical queue and checks
|
||||
every requested row field using daemon semantics (including normalized deadlines and appended closing resolutions).
|
||||
When `--note` is present, it first records the transition cursor and then requires a newer canonical transition,
|
||||
recorded under the actor the write was sent as, that carries those note bytes as written or with only what the daemon
|
||||
adds for that request (the reopen prefix, the summary suffix, a revised deadline's receipt). Another writer's note, or
|
||||
a note that only starts the same way, doesn't count. If that cursor can't be read, the write is still sent, and the
|
||||
note can't be shown to be new: the command says so and exits non-zero. A failed post-write check exits non-zero and
|
||||
says the write may have committed. Reconcile with `rig queue show <id> --full --json` before retrying. Preserve that
|
||||
exit status in wrappers, and use `set -o pipefail` when piping its output so a downstream formatter cannot turn
|
||||
failure into success.
|
||||
|
||||
`create` also prints `Queue create request ID: <id> (not proof of persistence)` on stderr *before* it sends the write,
|
||||
so an interrupted create can be reconciled. That ID names a request, not a row. The row is the `qitemId` on stdout
|
||||
when the command exits 0. A non-zero exit means no row was confirmed, even when stdout carries a `qitemId`: an
|
||||
|
||||
@@ -465,8 +465,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
// Codex's "continue without trusting" choice writes no state, so an existing install with no hooks.state entry for any
|
||||
// of OpenRig's hooks may be an unrecorded skip, or an older install that never granted them. OpenRig trusts its own
|
||||
// exact shipped hooks there as on a first install (#17). Any recorded choice for one of them, or a layer it can't read,
|
||||
// leaves native review in place. A proven owned grant may renew its content approval, and its declarations when a new
|
||||
// shipped bundle changes them and every approval still matches OpenRig's own record.
|
||||
// leaves native review in place. A proven owned grant may renew its content approval.
|
||||
const firstInstall = !this.fs.exists(cacheRoot) && !layers.some(layer => layer.state === "read"
|
||||
&& Object.hasOwn((parseToml(layer.content).plugins ?? {}) as object, CODEX_CORE_PLUGIN_KEY));
|
||||
const noOwnHookChoice = (read: CodexConfigLayerRead[]) => {
|
||||
@@ -483,20 +482,14 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
let content = before;
|
||||
const planned = planOwnedCodexContentApproval({ configPath, content, layers: currentLayers, shipped, installed: cachedDir, freshGrant });
|
||||
if (!planned) return;
|
||||
for (const [key, hash] of Object.entries(planned.trustedHashes)) content = upsertCodexHookTrust(content, key, hash);
|
||||
// The declaration writer ends the file with one newline; keep the owner's own ending.
|
||||
if (content !== before) content = content.replace(/\n*$/, /\n*$/.exec(before)?.[0] ?? "");
|
||||
for (const [key, hash] of Object.entries(planned.hashes)) content = upsertCodexHookTrust(content, key, hash, "trusted_content_hash");
|
||||
// The line writer must not alter examples in strings or duplicate quoted keys.
|
||||
try {
|
||||
const expected = parseToml(before) as { hooks: { state: Record<string, Record<string, unknown>> } };
|
||||
for (const [key, hash] of Object.entries(planned.trustedHashes)) expected.hooks.state[key]!.trusted_hash = hash;
|
||||
for (const [key, hash] of Object.entries(planned.hashes)) expected.hooks.state[key]!.trusted_content_hash = hash;
|
||||
if (!isDeepStrictEqual(parseToml(content), expected)) return;
|
||||
} catch { return; } // Leave native review and owner bytes intact for unsupported TOML spellings.
|
||||
// A concurrent native choice wins: in this file, or in any layer the plan read (a layer appearing, changing,
|
||||
// disappearing or becoming unreadable counts).
|
||||
if (this.fs.readFile(configPath) !== before || !isDeepStrictEqual(readLayers(), currentLayers)) return;
|
||||
if (this.fs.readFile(configPath) !== before) return; // A concurrent native choice wins.
|
||||
if (content !== before) this.fs.writeFile(configPath, content);
|
||||
recordOwnedCodexApproval(configPath, planned.approval);
|
||||
};
|
||||
|
||||
@@ -79,14 +79,10 @@ export function codexLegacyPluginTrustPath(configPath: string): string {
|
||||
interface OwnedApproval { schema: 1; plugin: string; contentHash: string; declarations: Record<string, string> }
|
||||
export const ownedCodexApprovalPath = (configPath: string) => path.join(path.dirname(configPath), ".openrig-core-hook-trust.json");
|
||||
|
||||
/**
|
||||
* No native baseline is changed. Content approval advances for approved declarations. A declaration changed by a new
|
||||
* shipped bundle is renewed only from OpenRig's own record (#17): every approval must still equal the recorded
|
||||
* declaration and content. `trustedHashes` names the declarations to rewrite; `hashes` the content approvals.
|
||||
*/
|
||||
/** No native baseline is changed. Existing declarations must already be approved; only content approval may advance. */
|
||||
export function planOwnedCodexContentApproval(input: {
|
||||
configPath: string; content: string; layers: CodexConfigLayerRead[]; shipped: string; installed: string; freshGrant?: boolean;
|
||||
}): { hashes: Record<string, string>; trustedHashes: Record<string, string>; approval: OwnedApproval } | undefined {
|
||||
}): { hashes: Record<string, string>; approval: OwnedApproval } | undefined {
|
||||
try {
|
||||
if (codexPluginContentHash(input.shipped, true) !== codexPluginContentHash(input.installed, true)) return;
|
||||
// The trees just matched byte for byte, so approve the shipped bytes rather than a second read of the cache.
|
||||
@@ -115,41 +111,26 @@ export function planOwnedCodexContentApproval(input: {
|
||||
const states = doc.hooks?.state ?? {};
|
||||
for (const [key, hash] of Object.entries(declarations)) {
|
||||
const state = states[key];
|
||||
if (!state || (state.enabled !== undefined && state.enabled !== true) || typeof state.trusted_hash !== "string"
|
||||
if (!state || (state.enabled !== undefined && state.enabled !== true) || state.trusted_hash !== hash
|
||||
|| Object.keys(state).some(field => !["enabled", "trusted_hash", "trusted_content_hash"].includes(field))
|
||||
|| findTomlTable(input.content.split("\n"), ["hooks", "state", key]) < 0
|
||||
|| docs.some(layer => otherChoice(layer.doc, key, layer.path !== input.configPath))) return;
|
||||
}
|
||||
const approval: OwnedApproval = { schema: 1, plugin: CODEX_CORE_PLUGIN_KEY, contentHash, declarations };
|
||||
const hashes = Object.fromEntries(Object.keys(declarations).map(key => [key, contentHash]));
|
||||
const changed = Object.entries(declarations).filter(([key, hash]) => states[key].trusted_hash !== hash);
|
||||
const parseRecord = (saved: string): OwnedApproval | undefined => {
|
||||
const previous: OwnedApproval = JSON.parse(saved);
|
||||
return previous.schema === 1 && previous.plugin === CODEX_CORE_PLUGIN_KEY && /^sha256:[a-f0-9]{64}$/.test(previous.contentHash)
|
||||
&& Object.keys(previous.declarations).length === 6 ? previous : undefined;
|
||||
};
|
||||
if (changed.length > 0) {
|
||||
// Only OpenRig's own saved record proves these approvals are its earlier grant; the legacy fallback below doesn't.
|
||||
const saved = readRecord(ownedCodexApprovalPath(input.configPath));
|
||||
const previous = saved === undefined ? undefined : parseRecord(saved);
|
||||
if (input.freshGrant || !previous) return;
|
||||
for (const key of Object.keys(declarations)) {
|
||||
if (states[key].trusted_hash !== previous.declarations[key] || states[key].trusted_content_hash !== previous.contentHash) return;
|
||||
}
|
||||
return { hashes, trustedHashes: Object.fromEntries(changed), approval };
|
||||
}
|
||||
if (input.freshGrant) return { hashes, trustedHashes: {}, approval };
|
||||
if (Object.keys(declarations).every(key => states[key].trusted_content_hash === contentHash)) return { hashes: {}, trustedHashes: {}, approval };
|
||||
if (input.freshGrant) return { hashes, approval };
|
||||
if (Object.keys(declarations).every(key => states[key].trusted_content_hash === contentHash)) return { hashes: {}, approval };
|
||||
const baseline = readRecord(codexLegacyPluginTrustPath(input.configPath));
|
||||
if (Object.keys(declarations).every(key => (states[key].trusted_content_hash ?? baseline) === contentHash)) return { hashes, trustedHashes: {}, approval };
|
||||
if (Object.keys(declarations).every(key => (states[key].trusted_content_hash ?? baseline) === contentHash)) return { hashes, approval };
|
||||
const saved = readRecord(ownedCodexApprovalPath(input.configPath));
|
||||
const previous = saved === undefined ? { schema: 1 as const, plugin: CODEX_CORE_PLUGIN_KEY,
|
||||
contentHash: LEGACY_CONTENT, declarations: legacyDeclarations } : parseRecord(saved);
|
||||
if (!previous) return;
|
||||
const previous: OwnedApproval = saved === undefined ? { schema: 1, plugin: CODEX_CORE_PLUGIN_KEY,
|
||||
contentHash: LEGACY_CONTENT, declarations: legacyDeclarations } : JSON.parse(saved);
|
||||
if (previous.schema !== 1 || previous.plugin !== CODEX_CORE_PLUGIN_KEY || !/^sha256:[a-f0-9]{64}$/.test(previous.contentHash)
|
||||
|| Object.keys(previous.declarations).length !== 6) return;
|
||||
for (const [key, hash] of Object.entries(declarations)) {
|
||||
if (previous.declarations[key] !== hash || (states[key].trusted_content_hash ?? baseline) !== previous.contentHash) return;
|
||||
}
|
||||
return { hashes, trustedHashes: {}, approval };
|
||||
return { hashes, approval };
|
||||
} catch { return undefined; } // Unknown ownership or config leaves native review in place.
|
||||
}
|
||||
|
||||
|
||||
@@ -61,8 +61,6 @@ export interface SlackConnectorConfig {
|
||||
* "off" = reactions are ignored, so an acknowledging emoji costs no agent turn. Not in DEFAULT_CONFIG, like `channelMap`.
|
||||
*/
|
||||
reactions?: SlackReactionsMode;
|
||||
/** Link and media previews on human-row posts; absent defaults to off. */
|
||||
unfurl?: "on" | "off";
|
||||
}
|
||||
|
||||
export const SLACK_REACTIONS_MODES = ["route", "off"] as const;
|
||||
@@ -121,9 +119,6 @@ function validateConfig(cfg: SlackConnectorConfig, opts: { reading?: boolean } =
|
||||
if (!opts.reading && cfg.reactions !== undefined && !SLACK_REACTIONS_MODES.includes(cfg.reactions)) {
|
||||
throw new Error(`reactions must be one of ${SLACK_REACTIONS_MODES.join(", ")} (got ${String(cfg.reactions)})`);
|
||||
}
|
||||
if (!opts.reading && cfg.unfurl !== undefined && cfg.unfurl !== "on" && cfg.unfurl !== "off") {
|
||||
throw new Error(`unfurl must be on or off (got ${String(cfg.unfurl)})`);
|
||||
}
|
||||
}
|
||||
|
||||
export function configPathFor(home?: string): string {
|
||||
@@ -181,7 +176,6 @@ export function staticReadiness(cfg: SlackConnectorConfig, hasBotToken: boolean,
|
||||
{ ok: Boolean(cfg.inboundDestination), label: "inbound-destination",
|
||||
detail: cfg.inboundDestination === INHERIT_INBOUND_DESTINATION ? `${INHERIT_INBOUND_DESTINATION} (the host's default receiving agent)` : cfg.inboundDestination },
|
||||
{ ok: cfg.enabled, label: "enabled", detail: cfg.enabled ? "yes" : "no (run `rig slack enable`)" },
|
||||
{ ok: true, label: "human-row previews", detail: cfg.unfurl === "on" ? "on" : "off" },
|
||||
...channelMapReadiness(cfg),
|
||||
...botInboxReadiness(cfg),
|
||||
...forwardReadiness(cfg),
|
||||
|
||||
@@ -474,8 +474,6 @@ export interface PostChatMessageInput {
|
||||
blocks?: unknown[];
|
||||
/** Thread reply: the PARENT message's ts (never a reply's ts — the affordance discriminator). */
|
||||
thread_ts?: string;
|
||||
unfurl_links?: boolean;
|
||||
unfurl_media?: boolean;
|
||||
}
|
||||
|
||||
export type PostChatMessageResult =
|
||||
@@ -507,8 +505,6 @@ export async function postChatMessage(
|
||||
const body: Record<string, unknown> = { channel: input.channel, text: input.text };
|
||||
if (input.blocks?.length) body.blocks = input.blocks;
|
||||
if (input.thread_ts) body.thread_ts = input.thread_ts;
|
||||
if (input.unfurl_links !== undefined) body.unfurl_links = input.unfurl_links;
|
||||
if (input.unfurl_media !== undefined) body.unfurl_media = input.unfurl_media;
|
||||
let r = await callWebApi("chat.postMessage", token, body, fetchImpl, timeoutMs);
|
||||
if (!r.ok && r.status === 429) {
|
||||
const waitMs = (r.retryAfterSeconds ?? 0) * 1000;
|
||||
|
||||
@@ -41,8 +41,6 @@ export interface SubsystemSlackDeliveryOpts {
|
||||
* (no map) nothing is recorded and the channel is resolved as before. */
|
||||
pinChannel?: boolean;
|
||||
sourceLabel: string; // host/box/rig — from config, never hardcoded (item 7)
|
||||
/** Human-row previews; absent defaults off. */
|
||||
unfurl?: "on" | "off";
|
||||
/** This daemon's machine name, read per post, shown after a seat sender (message.ts `machine`). */
|
||||
machine?: () => string | null;
|
||||
bodyExcerpt?: number;
|
||||
@@ -334,8 +332,7 @@ function deliverSinglePart(opts: SubsystemSlackDeliveryOpts, markEpisode = true)
|
||||
};
|
||||
const postInput = (possibleRepeat = false) => {
|
||||
const shown = render(possibleRepeat, q.stateUnconfirmed === true, q.quietUnreadable === true);
|
||||
const unfurl = opts.unfurl === "on";
|
||||
return { channel, text: shown.text, blocks: shown.blocks, thread_ts: threadTs, unfurl_links: unfurl, unfurl_media: unfurl };
|
||||
return { channel, text: shown.text, blocks: shown.blocks, thread_ts: threadTs };
|
||||
};
|
||||
const payloadHash = () => createHash("sha256").update(JSON.stringify({ channel, text: render().text, blocks: render().blocks, thread_ts: threadTs })).digest("hex");
|
||||
const failurePrefix = `${decision.decisionId}${TRANSPORT_FAILURE_RECEIPT_PREFIX}`;
|
||||
|
||||
@@ -638,7 +638,6 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
|
||||
},
|
||||
pinChannel: Boolean(cfg.channelMap?.length),
|
||||
sourceLabel: cfg.sourceLabel,
|
||||
unfurl: cfg.unfurl === "on" ? "on" : "off",
|
||||
// P6: each post names the machine it comes from, the daemon's own host id.
|
||||
machine: () => getSelfHostId(),
|
||||
fetchImpl: opts.fetchImpl,
|
||||
|
||||
@@ -176,7 +176,6 @@ export interface RestoreCheckOpts {
|
||||
// --- Deps (framework-free per ADR-0001; reads from existing projections per ADR-0002) ---
|
||||
|
||||
export interface NodeInventoryEntry {
|
||||
codexConfigProfile?: string | null;
|
||||
nodeId?: string | null;
|
||||
rigId: string;
|
||||
rigName: string;
|
||||
@@ -456,8 +455,6 @@ export class RestoreCheckService {
|
||||
fsOps: { exists: this.deps.exists },
|
||||
servicesRecord: input.servicesRecord,
|
||||
currentRuntimes: Object.fromEntries(inventory.map((current) => [current.logicalId, current.runtime])),
|
||||
currentCodexProfiles: Object.fromEntries(inventory.filter((current) => current.codexConfigProfile !== undefined)
|
||||
.map((current) => [current.logicalId, current.codexConfigProfile!])),
|
||||
});
|
||||
const { blockers, warnings } = validation;
|
||||
return {
|
||||
|
||||
@@ -440,9 +440,6 @@ export class RestoreOrchestrator {
|
||||
servicesRecord: this.rigRepo.getServicesRecord(rigId),
|
||||
freshLogicalIds: opts?.freshLogicalIds,
|
||||
currentRuntimes: Object.fromEntries((this.rigRepo.getRig(rigId)?.nodes ?? []).map((current) => [current.logicalId, current.runtime])),
|
||||
currentCodexProfiles: Object.fromEntries((this.rigRepo.getRig(rigId)?.nodes ?? [])
|
||||
.filter((current) => current.codexConfigProfile !== undefined)
|
||||
.map((current) => [current.logicalId, current.codexConfigProfile!])),
|
||||
});
|
||||
const topology = resolveSnapshotRestoreTopology(snapshot.data);
|
||||
// A required startup file that is missing holds only its own seat. That seat isn't launched, because it would start
|
||||
|
||||
@@ -13,8 +13,6 @@ export function validatePreRestore(
|
||||
freshLogicalIds?: string[];
|
||||
/** The runtime each seat has now, by logical id. A seat not listed is read as its saved runtime. */
|
||||
currentRuntimes?: Record<string, string | null | undefined>;
|
||||
/** Current explicit seat selections; a missing key is unknown, null is an explicit absence. */
|
||||
currentCodexProfiles?: Record<string, string | null>;
|
||||
},
|
||||
): { blockers: RestoreValidationBlocker[]; warnings: string[] } {
|
||||
const blockers: RestoreValidationBlocker[] = [];
|
||||
@@ -90,12 +88,6 @@ export function validatePreRestore(
|
||||
}
|
||||
|
||||
for (const node of topology.intendedNodes) {
|
||||
if (node.runtime === "codex" && node.codexConfigProfile
|
||||
&& opts.currentRuntimes?.[node.logicalId] === "codex"
|
||||
&& opts.currentCodexProfiles && Object.hasOwn(opts.currentCodexProfiles, node.logicalId)
|
||||
&& opts.currentCodexProfiles[node.logicalId] === null) {
|
||||
warnings.push(`Restore pre-validation: ${node.logicalId} had Codex profile ${node.codexConfigProfile} in this snapshot, but its current seat selection has no named profile. Restore uses the current selection; an ordinary floor launch uses workspace-write instead. Review rig seat set-permissions before restoring if this change is unintended. The snapshot does not prove what a prior process enforced.`);
|
||||
}
|
||||
const checkpoint = checkpoints[node.id] ?? null;
|
||||
if (checkpoint && !node.cwd) {
|
||||
add({
|
||||
|
||||
@@ -23,7 +23,6 @@ import { validateResumeToken } from "./resume-token-validation.js";
|
||||
import { readProcessTable } from "./process-table.js";
|
||||
import type { HomeRuntime } from "./seat-runtime-home.js";
|
||||
import { readSeatClaudeMcp, type ClaudeMcp } from "./claude-mcp.js";
|
||||
import { ObservationPass } from "./process-observation-pass.js";
|
||||
|
||||
export interface ResumeRefreshSession {
|
||||
/** The seat, when known, so its own runtime home is read. */
|
||||
@@ -59,8 +58,6 @@ interface ResumeMetadataRefresherDeps {
|
||||
};
|
||||
/** #421 — start time of the pane's current Claude process; a sidecar sampled earlier is not used. */
|
||||
claudeProcessStartedAt?: ResumeTokenCaptureDeps["claudeProcessStartedAt"];
|
||||
/** One fresh identity lister per seat, shared only within a snapshot refresh. */
|
||||
claudeStartedAtForPass?: (pass: ObservationPass) => NonNullable<ResumeTokenCaptureDeps["claudeProcessStartedAt"]>;
|
||||
/** Provider root used by the daemon's Claude launches; no scan of other homes. */
|
||||
claudeConfigDir?: string;
|
||||
listClaudeProcesses?: NativeProcessLister;
|
||||
@@ -80,7 +77,6 @@ export class ResumeMetadataRefresher {
|
||||
private homeDir: string;
|
||||
private contextUsageStore: ResumeMetadataRefresherDeps["contextUsageStore"] | null;
|
||||
private claudeProcessStartedAt: ResumeMetadataRefresherDeps["claudeProcessStartedAt"] | null;
|
||||
private claudeStartedAtForPass: ResumeMetadataRefresherDeps["claudeStartedAtForPass"];
|
||||
private claudeConfigDir: string;
|
||||
private seatHome?: (nodeId: string, runtime: HomeRuntime) => string | undefined;
|
||||
private listClaudeProcesses: NativeProcessLister | undefined;
|
||||
@@ -116,7 +112,6 @@ export class ResumeMetadataRefresher {
|
||||
this.homeDir = deps.homeDir ?? os.homedir();
|
||||
this.contextUsageStore = deps.contextUsageStore ?? null;
|
||||
this.claudeProcessStartedAt = deps.claudeProcessStartedAt ?? null;
|
||||
this.claudeStartedAtForPass = deps.claudeStartedAtForPass;
|
||||
this.claudeConfigDir = deps.claudeConfigDir ?? process.env.CLAUDE_CONFIG_DIR ?? nodePath.join(this.homeDir, ".claude");
|
||||
this.seatHome = deps.seatHome;
|
||||
this.listClaudeProcesses = deps.listClaudeProcesses;
|
||||
@@ -156,8 +151,6 @@ export class ResumeMetadataRefresher {
|
||||
},
|
||||
): Promise<void> {
|
||||
const fillNullOnly = opts?.fillNullOnly === true;
|
||||
const claudeStartedAt = fillNullOnly && this.claudeStartedAtForPass
|
||||
? this.claudeStartedAtForPass(new ObservationPass()) : this.claudeProcessStartedAt;
|
||||
// Snapshot-time discovery is SINGLE-ATTEMPT (mini-req 2): the 8-attempt
|
||||
// 250ms-sleep loop exists for the adoption boundary racing a booting codex;
|
||||
// on a recurring tick it multiplied `ps` spawns per seat, forever.
|
||||
@@ -214,7 +207,7 @@ export class ResumeMetadataRefresher {
|
||||
if (sidecar?.ok) {
|
||||
const token = sidecar.data.session_id;
|
||||
if (typeof token === "string" && token.trim().length > 0
|
||||
&& !(await isClaudeSidecarFromEarlierProcess(sidecar.data.sampled_at, session.sessionName, claudeStartedAt))) {
|
||||
&& !(await isClaudeSidecarFromEarlierProcess(sidecar.data.sampled_at, session.sessionName, this.claudeProcessStartedAt))) {
|
||||
this.sessionRegistry.updateResumeToken(session.sessionId, "claude_id", token.trim(), "scrape");
|
||||
}
|
||||
}
|
||||
@@ -238,7 +231,7 @@ export class ResumeMetadataRefresher {
|
||||
if (sidecar?.ok) {
|
||||
const derived = sidecar.data.session_id;
|
||||
if (typeof derived === "string" && derived.trim().length > 0 && derived.trim() === session.resumeToken
|
||||
&& !(await isClaudeSidecarFromEarlierProcess(sidecar.data.sampled_at, session.sessionName, claudeStartedAt))) {
|
||||
&& !(await isClaudeSidecarFromEarlierProcess(sidecar.data.sampled_at, session.sessionName, this.claudeProcessStartedAt))) {
|
||||
this.sessionRegistry.markResumeProbeResult(session.sessionId, "resumable");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,8 +93,7 @@ export function parseSkillFrontmatter(content: string): ParseResult {
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
// The delimiter's preceding LF is outside the capture; on CRLF files its CR is not.
|
||||
parsed = parseYaml(fmText!.replace(/\r$/, ""));
|
||||
parsed = parseYaml(fmText!);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, reason: `frontmatter YAML parse error: ${msg}` };
|
||||
|
||||
@@ -116,7 +116,7 @@ import { setSelfHostId, setSelfHostIdSource } from "./domain/hosts/fanout-contra
|
||||
import { UpCommandRouter } from "./domain/up-command-router.js";
|
||||
import { RigTeardownOrchestrator } from "./domain/rig-teardown.js";
|
||||
import { ResumeMetadataRefresher } from "./domain/resume-metadata-refresher.js";
|
||||
import { observeClaudePaneStartedAt, listNativeProcessesOrThrow, listNativeProcesses } from "./domain/native-process-lineage.js";
|
||||
import { observeClaudePaneStartedAt } from "./domain/native-process-lineage.js";
|
||||
import { TranscriptStore } from "./domain/transcript-store.js";
|
||||
import { resumeRunningTranscriptCaptures, wireTranscriptCaptureStops } from "./domain/transcript-capture.js";
|
||||
import { probeSessionActivity, SessionTransport, inspectComposerInput, draftDeliveryAllows, readDraftDeliverySetting } from "./domain/session-transport.js";
|
||||
@@ -1417,9 +1417,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// #421 — the pane's current Claude process start time; a sidecar sampled earlier is not this
|
||||
// process's, so capture and null-fill skip it.
|
||||
const claudeProcessStartedAt = (sessionName: string) => observeClaudePaneStartedAt({ target: sessionName, tmux: tmuxAdapter });
|
||||
const resumeMetadataRefresher = new ResumeMetadataRefresher({ sessionRegistry, tmuxAdapter, contextUsageStore, claudeProcessStartedAt,
|
||||
claudeStartedAtForPass: (pass) => (sessionName) => observeClaudePaneStartedAt({ target: sessionName, tmux: tmuxAdapter,
|
||||
listProcesses: pass.lister(listNativeProcessesOrThrow, listNativeProcesses) }), codexHome: configuredCodexHome,
|
||||
const resumeMetadataRefresher = new ResumeMetadataRefresher({ sessionRegistry, tmuxAdapter, contextUsageStore, claudeProcessStartedAt, codexHome: configuredCodexHome,
|
||||
seatHome: (nodeId, runtime) => seatRuntimeHome(db, nodeId, runtime).home });
|
||||
const claimService = new ClaimService({
|
||||
db, rigRepo, sessionRegistry, discoveryRepo, eventBus, tmuxAdapter, transcriptStore,
|
||||
|
||||
@@ -59,7 +59,7 @@ describe("issue824 existing strict adapter idle frame (behavioral red-first)", (
|
||||
const streaming = codexFrame.replace(/^ {2}Worked for[^\n]+$/m, "• Streaming an unfinished answer");
|
||||
expect((await adapterFold(streaming, { sourceRuntime: "codex" })).activity.state).toBe("running");
|
||||
});
|
||||
it.each(["0.167.0", "99.0.0"])("an unrecorded Codex %s frame stays unknown under redraw motion", async (version) => {
|
||||
it.each(["0.168.0", "99.0.0"])("an unrecorded Codex %s frame stays unknown under redraw motion", async (version) => {
|
||||
// Synthetic version edits of the recorded 0.160 fixture, not native newer-version captures.
|
||||
const frame = codexFrame.replaceAll("0.160.0", version);
|
||||
for (const pane of [frame, frame.replace(/^ {2}Worked for[^\n]+$/m, "• Streaming an unfinished answer")]) {
|
||||
@@ -116,6 +116,31 @@ describe("issue824 existing strict adapter idle frame (behavioral red-first)", (
|
||||
expect(result.observation).toMatchObject({ state: "agent_active", reason: "mid_work_pattern" });
|
||||
expect(result.activity).toMatchObject({ state: "running", evidenceSource: "pane_heuristic", reason: "mid_work_pattern" });
|
||||
});
|
||||
// Synthetic resumed frames: Codex's session header (drawn as 0.160 draws it) or its update notice over the recorded
|
||||
// 0.167 layouts. Codex 0.167.1 and 0.167.2 changed no TUI code from 0.167.0.
|
||||
const codexHeader = (version: string) => `\n >_ OpenAI Codex (v${version})\n ~/example/work\n permissions: YOLO mode\n\n`;
|
||||
const codexNotice = (version: string) => `│ ✨ Update available! ${version} -> 0.169.0 │\n\n`;
|
||||
it.each(["0.167.0", "0.167.1", "0.167.2"])("a resumed Codex %s seat that shows its version reads the recorded 0.167 layout", async (version) => {
|
||||
for (const shown of [codexHeader(version), codexNotice(version)]) {
|
||||
const completed = `${shown}${codexCompleted}`;
|
||||
expect(readSeatBeforePaste(completed, "codex")).toBe("idle");
|
||||
const idle = await adapterFold(completed, { sourceRuntime: "codex", hook: null });
|
||||
expect(idle.observation?.reason).not.toBe("unrecognized_client_version");
|
||||
expect(idle.activity).toMatchObject({ state: "idle", evidenceSource: "pane_heuristic" });
|
||||
const working = `${shown}${codexWorking}`;
|
||||
expect(readSeatBeforePaste(working, "codex")).toBe("not-idle");
|
||||
expect((await adapterFold(working, { sourceRuntime: "codex", hook: null, motion: false })).activity.state).toBe("running");
|
||||
}
|
||||
});
|
||||
it.each(["0.168.0", "0.169.0", "0.167.0-alpha.4.3", "0.167", "0.160.1"])("a Codex %s version over the same layout stays unknown", async (version) => {
|
||||
for (const shown of [codexHeader(version), codexNotice(version)]) {
|
||||
const frame = `${shown}${codexCompleted}`;
|
||||
expect(readSeatBeforePaste(frame, "codex")).toBe("unknown");
|
||||
const result = await adapterFold(frame, { sourceRuntime: "codex", hook: null });
|
||||
expect(result.observation).toMatchObject({ state: "unknown", reason: "unrecognized_client_version" });
|
||||
expect(result.activity.state).toBe("unknown");
|
||||
}
|
||||
});
|
||||
it("a newer redraw after an old idle capture keeps D2 running", async () => {
|
||||
expect((await adapterFold(cframe, { age: 2000 })).activity.state).toBe("running");
|
||||
// The write and capture are in the same second: ordering is unknown,
|
||||
|
||||
@@ -9,12 +9,12 @@ import { createHash } from "node:crypto";
|
||||
import { parse } from "smol-toml";
|
||||
import { CodexRuntimeAdapter } from "../src/adapters/codex-runtime-adapter.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import { codexPluginContentHash, codexLegacyPluginTrustPath, planOwnedCodexContentApproval, recordOwnedCodexApproval } from "../src/domain/codex-owned-plugin-trust.js";
|
||||
import { codexPluginContentHash, codexLegacyPluginTrustPath, planOwnedCodexContentApproval } from "../src/domain/codex-owned-plugin-trust.js";
|
||||
|
||||
const shipped = path.resolve(import.meta.dirname, "../assets/plugins/openrig-core");
|
||||
const roots: string[] = [];
|
||||
afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); });
|
||||
function fixture(config = "", corruptCache = false, duringPluginAdd?: () => void, onConfigRead?: () => void) {
|
||||
function fixture(config = "", corruptCache = false, duringPluginAdd?: () => void) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "owned-codex-hooks-")); roots.push(root);
|
||||
const plugin = path.join(root, "plugins/openrig-core"), home = path.join(root, "codex");
|
||||
fs.cpSync(shipped, plugin, { recursive: true }); fs.mkdirSync(home);
|
||||
@@ -23,7 +23,7 @@ function fixture(config = "", corruptCache = false, duringPluginAdd?: () => void
|
||||
const cache = path.join(home, "plugins/cache/openrig/openrig-core", version);
|
||||
const calls: string[][] = [];
|
||||
const adapter = () => new CodexRuntimeAdapter({ tmux: {} as TmuxAdapter, codexHome: home,
|
||||
fsOps: { readFile: p => { if (p === file) onConfigRead?.(); return fs.readFileSync(p, "utf8"); }, writeFile: (p,c) => fs.writeFileSync(p,c), exists: fs.existsSync, mkdirp: p => fs.mkdirSync(p,{recursive:true}) },
|
||||
fsOps: { readFile: p => fs.readFileSync(p, "utf8"), writeFile: (p,c) => fs.writeFileSync(p,c), exists: fs.existsSync, mkdirp: p => fs.mkdirSync(p,{recursive:true}) },
|
||||
resolveCorePluginDir: () => plugin, codexSystemConfigDir: path.join(root, "etc"),
|
||||
runCodexCli: async args => {
|
||||
calls.push(args);
|
||||
@@ -361,126 +361,3 @@ it("R2 oa fresh nonmatching bundle gains no declaration-only legacy approval", a
|
||||
expect(fs.existsSync(path.join(f.home, ".openrig-core-hook-trust.json"))).toBe(false);
|
||||
expect(Object.keys(f.read().hooks?.state ?? {})).toHaveLength(0);
|
||||
});
|
||||
|
||||
// #17: a home OpenRig granted for an earlier release whose Stop hook declaration differed (its timeout), recorded by
|
||||
// OpenRig's own planner and writer, then upgraded to the current shipped bundle.
|
||||
const stopKey = "openrig-core@openrig:hooks/codex.json:stop:0:0";
|
||||
const recordPath = (f: ReturnType<typeof fixture>) => path.join(f.home, ".openrig-core-hook-trust.json");
|
||||
function earlierReleaseGrant(f: ReturnType<typeof fixture>, extra = "") {
|
||||
const earlier = path.join(path.dirname(f.home), "earlier-shipped");
|
||||
fs.cpSync(shipped, earlier, { recursive: true });
|
||||
const manifestPath = path.join(earlier, "hooks/codex.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
manifest.hooks.Stop[0].hooks[0].timeout += 5;
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
const earlierCache = path.join(path.dirname(f.cache), "earlier");
|
||||
fs.cpSync(earlier, earlierCache, { recursive: true });
|
||||
const { computeCodexHookTrust } = createRequire(import.meta.url)(path.join(shipped, "hooks/scripts/codex-hook-trust.cjs"));
|
||||
const content = codexPluginContentHash(earlier);
|
||||
let config = `[marketplaces.openrig]\nsource = ${JSON.stringify(path.dirname(f.plugin))}\n\n[plugins."openrig-core@openrig"]\nenabled = true\n`;
|
||||
for (const [event, groups] of Object.entries(manifest.hooks) as Array<[string, Array<{ matcher?: string; hooks: Array<{ command: string; timeout: number }> }>]>) {
|
||||
groups.forEach((group, groupIndex) => group.hooks.forEach((handler, handlerIndex) => {
|
||||
const { key, hash } = computeCodexHookTrust(event, { ...handler, matcher: group.matcher, timeoutSec: handler.timeout,
|
||||
keySource: "openrig-core@openrig:hooks/codex.json", groupIndex, handlerIndex });
|
||||
config += `\n[hooks.state.${JSON.stringify(key)}]\ntrusted_hash = ${JSON.stringify(hash)}\ntrusted_content_hash = ${JSON.stringify(content)}\n`;
|
||||
}));
|
||||
}
|
||||
config += extra;
|
||||
fs.writeFileSync(f.file, config);
|
||||
const plan = planOwnedCodexContentApproval({ configPath: f.file, content: config,
|
||||
layers: [{ name: "user config", path: f.file, state: "read", content: config }], shipped: earlier, installed: earlierCache, freshGrant: true });
|
||||
expect(plan).toBeDefined();
|
||||
recordOwnedCodexApproval(f.file, plan!.approval);
|
||||
const stop = (parse(config) as any).hooks.state[stopKey];
|
||||
return { config, record: fs.readFileSync(recordPath(f), "utf8"), earlierStop: stop.trusted_hash as string, earlierContent: content };
|
||||
}
|
||||
|
||||
const outside = '\n[hooks.state."other-plugin@market:hooks/codex.json:stop:0:0"]\ntrusted_hash = "sha256:outside"\ntrusted_content_hash = "sha256:outside-content"\n\n\n';
|
||||
it("the first launch after an upgrade that changed a declaration renews OpenRig's own recorded approvals and its record", async () => {
|
||||
const f = fixture(), earlier = earlierReleaseGrant(f, outside);
|
||||
expect(earlier.earlierStop).not.toBe((await freshInstallStates())[stopKey].trusted_hash);
|
||||
expect(await f.adapter().ensureCodexCorePlugin()).toMatchObject({ outcome: "installed" });
|
||||
const states = f.read().hooks.state;
|
||||
const fresh = await freshInstallStates();
|
||||
expect(Object.fromEntries(Object.entries(states).filter(([key]) => key.startsWith("openrig-core@openrig:")))).toEqual(fresh);
|
||||
expect(states["other-plugin@market:hooks/codex.json:stop:0:0"]).toEqual({ trusted_hash: "sha256:outside", trusted_content_hash: "sha256:outside-content" });
|
||||
const text = fs.readFileSync(f.file, "utf8");
|
||||
expect(text.endsWith(outside)).toBe(true);
|
||||
expect(text.replace(/^trusted(_content)?_hash = .*\n/gm, "")).toBe(earlier.config.replace(/^trusted(_content)?_hash = .*\n/gm, ""));
|
||||
const record = JSON.parse(fs.readFileSync(recordPath(f), "utf8"));
|
||||
expect(record).toEqual({ schema: 1, plugin: "openrig-core@openrig", contentHash: codexPluginContentHash(shipped),
|
||||
declarations: Object.fromEntries(Object.entries(fresh).map(([key, state]: [string, any]) => [key, state.trusted_hash])) });
|
||||
const renewed = fs.readFileSync(f.file, "utf8");
|
||||
await f.adapter().ensureCodexCorePlugin();
|
||||
expect(fs.readFileSync(f.file, "utf8")).toBe(renewed);
|
||||
});
|
||||
|
||||
it("a restart after the new bundle was installed but before renewal renews the same way, without reinstalling", async () => {
|
||||
const f = fixture(); earlierReleaseGrant(f);
|
||||
fs.cpSync(f.plugin, f.cache, { recursive: true });
|
||||
expect(await f.adapter().ensureCodexCorePlugin()).toMatchObject({ outcome: "current" });
|
||||
expect(f.calls).toEqual([]);
|
||||
expect(f.read().hooks.state).toEqual(await freshInstallStates());
|
||||
});
|
||||
|
||||
it.each(["mismatched declaration", "mismatched content", "disabled", "other layer", "profile choice", "nested plugin entry", "record removed", "record of another plugin"])(
|
||||
"a changed declaration keeps Codex's review for %s", async condition => {
|
||||
const f = fixture(), earlier = earlierReleaseGrant(f);
|
||||
const header = `[hooks.state.${JSON.stringify(stopKey)}]`;
|
||||
const edit = (change: (text: string) => string) => fs.writeFileSync(f.file, change(fs.readFileSync(f.file, "utf8")));
|
||||
if (condition === "mismatched declaration") edit(text => text.replace(earlier.earlierStop, `sha256:${"0".repeat(64)}`));
|
||||
if (condition === "mismatched content") edit(text => text.replace(`${header}\ntrusted_hash = ${JSON.stringify(earlier.earlierStop)}\ntrusted_content_hash = "${earlier.earlierContent}"`,
|
||||
`${header}\ntrusted_hash = ${JSON.stringify(earlier.earlierStop)}\ntrusted_content_hash = "sha256:${"0".repeat(64)}"`));
|
||||
if (condition === "disabled") edit(text => text.replace(header, `${header}\nenabled = false`));
|
||||
if (condition === "other layer") {
|
||||
fs.mkdirSync(path.join(path.dirname(f.home), "etc"));
|
||||
fs.writeFileSync(path.join(path.dirname(f.home), "etc/config.toml"), `${header}\ntrusted_hash = ${JSON.stringify(earlier.earlierStop)}\n`);
|
||||
}
|
||||
if (condition === "profile choice") edit(text => `${text}\n[profiles.special.hooks.state.${JSON.stringify(stopKey)}]\nenabled = false\n`);
|
||||
if (condition === "nested plugin entry") edit(text => `${text}\n[profiles.special.plugins."openrig-core@openrig"]\nenabled = true\n`);
|
||||
if (condition === "record removed") fs.rmSync(recordPath(f));
|
||||
if (condition === "record of another plugin") fs.writeFileSync(recordPath(f), earlier.record.replace('"openrig-core@openrig"', '"other-plugin@market"'));
|
||||
const before = fs.readFileSync(f.file, "utf8"), record = fs.existsSync(recordPath(f)) ? fs.readFileSync(recordPath(f), "utf8") : undefined;
|
||||
expect(f.read().hooks.state[stopKey]).toBeDefined();
|
||||
await f.adapter().ensureCodexCorePlugin();
|
||||
expect(fs.readFileSync(f.file, "utf8")).toBe(before);
|
||||
expect(fs.existsSync(recordPath(f)) ? fs.readFileSync(recordPath(f), "utf8") : undefined).toBe(record);
|
||||
});
|
||||
|
||||
it("the planner names exactly the changed declaration to rewrite, from the earlier grant's record", async () => {
|
||||
const f = fixture(), earlier = earlierReleaseGrant(f);
|
||||
fs.cpSync(f.plugin, f.cache, { recursive: true });
|
||||
const plan = planOwnedCodexContentApproval({ configPath: f.file, content: earlier.config,
|
||||
layers: [{ name: "user config", path: f.file, state: "read", content: earlier.config }], shipped, installed: f.cache });
|
||||
expect(plan?.trustedHashes).toEqual({ [stopKey]: (await freshInstallStates())[stopKey].trusted_hash });
|
||||
expect(Object.values(plan!.hashes)).toEqual(Array(6).fill(codexPluginContentHash(shipped)));
|
||||
});
|
||||
|
||||
// Codex records a person's decline of another OpenRig hook after the planner fingerprinted the installed bundle and
|
||||
// before OpenRig writes: on the first read of config.toml that follows, in the user config or in the system layer.
|
||||
it.each(["user config", "other layer"])("a choice recorded in the %s while the renewal is being prepared wins: no renewal and no new record", async where => {
|
||||
const header = '[hooks.state."openrig-core@openrig:hooks/codex.json:session_start:0:0"]';
|
||||
const decline = (text: string) => text.replace(header, `${header}\nenabled = false`);
|
||||
let planned = false, injected = false;
|
||||
const f: ReturnType<typeof fixture> = fixture("", false, undefined, () => {
|
||||
if (!planned || injected) return;
|
||||
injected = true;
|
||||
if (where === "user config") fs.writeFileSync(f.file, decline(fs.readFileSync(f.file, "utf8")));
|
||||
else {
|
||||
fs.mkdirSync(path.join(path.dirname(f.home), "etc"), { recursive: true });
|
||||
fs.writeFileSync(path.join(path.dirname(f.home), "etc/config.toml"), `${header}\nenabled = false\n`);
|
||||
}
|
||||
});
|
||||
const earlier = earlierReleaseGrant(f);
|
||||
const readdir = fs.readdirSync;
|
||||
const spy = vi.spyOn(fs, "readdirSync").mockImplementation(((dir: fs.PathLike, ...rest: unknown[]) => {
|
||||
if (String(dir).startsWith(f.cache)) planned = true;
|
||||
return (readdir as (...args: unknown[]) => unknown)(dir, ...rest);
|
||||
}) as typeof fs.readdirSync);
|
||||
try {
|
||||
await f.adapter().ensureCodexCorePlugin();
|
||||
} finally { spy.mockRestore(); }
|
||||
expect(injected).toBe(true);
|
||||
expect(fs.readFileSync(f.file, "utf8")).toBe(where === "user config" ? decline(earlier.config) : earlier.config);
|
||||
expect(fs.readFileSync(recordPath(f), "utf8")).toBe(earlier.record);
|
||||
});
|
||||
@@ -48,24 +48,6 @@ const cases = [
|
||||
] as const;
|
||||
|
||||
describe("shared restore preconditions", () => {
|
||||
it("reports a saved profile cleared on the current Codex seat through restore-check", () => {
|
||||
const snapshot = data();
|
||||
snapshot.nodes[0]!.runtime = "codex";
|
||||
snapshot.nodes[0]!.codexConfigProfile = "limited";
|
||||
const current = { ...node(), runtime: "codex", codexConfigProfile: null };
|
||||
const service = new RestoreCheckService(deps({
|
||||
getNodeInventory: () => [current],
|
||||
getRestoreInputs: () => ({ snapshot: { id: "snapshot-1", kind: "manual", data: snapshot }, servicesRecord: null }),
|
||||
}));
|
||||
const check = service.check({ noHooks: true, noQueue: true }).checks.find((entry) => entry.check === "rig.test-rig.restore-preconditions");
|
||||
expect(check?.status).toBe("yellow");
|
||||
expect(check?.evidence).toContain("had Codex profile limited in this snapshot");
|
||||
expect(check?.evidence).toContain("does not prove what a prior process enforced");
|
||||
for (const profiles of [{ seat: "limited" }, {}, { seat: "other" }]) {
|
||||
expect(validatePreRestore(snapshot, { currentRuntimes: { seat: "codex" }, currentCodexProfiles: profiles }).warnings).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(cases)("preserves the exact refusal for missing %s", (missing, code, message, remediation) => {
|
||||
const result = validatePreRestore(data(), { fsOps: { exists: (path) => path !== missing }, servicesRecord: services() });
|
||||
expect(result).toEqual({
|
||||
|
||||
@@ -5,7 +5,6 @@ import Database from "better-sqlite3";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { ResumeMetadataRefresher } from "../src/domain/resume-metadata-refresher.js";
|
||||
import { deriveResumeToken } from "../src/domain/resume-token-capture.js";
|
||||
import { observeClaudePaneStartedAt } from "../src/domain/native-process-lineage.js";
|
||||
import { seedCodexThreads } from "./helpers/codex-state.js";
|
||||
import type { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
@@ -56,30 +55,6 @@ function createCodexLogsDb(homeDir: string, pid: number, threadId: string, dbNam
|
||||
}
|
||||
|
||||
describe("ResumeMetadataRefresher", () => {
|
||||
it("shares snapshot observations across Claude seats while retaining two samples per proof", async () => {
|
||||
const stamp = vi.fn();
|
||||
const sessionRegistry = { markResumeProbeResult: stamp } as unknown as SessionRegistry;
|
||||
const start = "Sun Oct 11 12:00:00 2026";
|
||||
const later = "Sun Oct 11 12:01:00 2026";
|
||||
const tmux = mockTmux({ getPanePid: vi.fn(async (name: string) => Number(name)) });
|
||||
const rows = [10, 20, 30].flatMap(pid => [
|
||||
{ pid, ppid: 1, pgid: pid, tpgid: pid + 1, executableName: "bash", command: "bash", startedAt: start },
|
||||
{ pid: pid + 1, ppid: pid, pgid: pid + 1, tpgid: pid + 1, executableName: "claude", command: "claude", startedAt: pid === 30 ? later : start },
|
||||
]);
|
||||
const list = vi.fn(async () => rows);
|
||||
const refresher = new ResumeMetadataRefresher({ sessionRegistry, tmuxAdapter: tmux,
|
||||
contextUsageStore: { readSidecar: () => ({ ok: true as const, data: { session_id: "token", sampled_at: new Date(Date.parse(start) + 1000).toISOString() } }) },
|
||||
claudeStartedAtForPass: pass => name => observeClaudePaneStartedAt({ target: name, tmux, listProcesses: pass.lister(list) }),
|
||||
});
|
||||
const seats = [10, 20, 30].map(pid => ({ sessionId: String(pid), sessionName: String(pid), runtime: "claude-code", resumeType: "claude_id", resumeToken: "token" }));
|
||||
await refresher.refresh(seats, { fillNullOnly: true });
|
||||
expect(stamp).toHaveBeenCalledTimes(2);
|
||||
expect(stamp).not.toHaveBeenCalledWith("30", "resumable");
|
||||
expect(list).toHaveBeenCalledTimes(4);
|
||||
await refresher.refresh(seats, { fillNullOnly: true });
|
||||
expect(list).toHaveBeenCalledTimes(8);
|
||||
});
|
||||
|
||||
it("PERF FIX 2 — the periodic snapshot refresh is SINGLE-ATTEMPT per codex seat: no x8 retry burst under failing discovery", async () => {
|
||||
const sessionRegistry = {
|
||||
isForkClaimingHeldToken: vi.fn(() => false),
|
||||
|
||||
@@ -914,24 +914,6 @@ describe("plugin skills in the managed loadout", () => {
|
||||
expect(resolvePluginSkills({ pluginId: "core", pluginRoot: plugin, runtime: "claude-code", pluginType: "codex" }).entries).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["claude-code", "codex"] as const)("loads a CRLF plugin skill with realistic multiline frontmatter for %s", (runtime) => {
|
||||
const f = fixture([]);
|
||||
const plugin = writePlugin(f.root, [".claude-plugin", ".codex-plugin"], ["applying-a-permission-policy"]);
|
||||
const file = join(plugin, "skills", "applying-a-permission-policy", "SKILL.md");
|
||||
const original = readFileSync(new URL("../assets/plugins/openrig-core/skills/applying-a-permission-policy/SKILL.md", import.meta.url), "utf8").replace(/\r\n/g, "\n");
|
||||
const resolve = () => resolvePluginSkills({ pluginId: "core", pluginRoot: plugin, runtime });
|
||||
writeFileSync(file, original, "utf8");
|
||||
expect(resolve().entries.map((entry) => entry.id)).toEqual(["applying-a-permission-policy"]);
|
||||
writeFileSync(file, original.replace(/\n/g, "\r\n"), "utf8");
|
||||
const crlf = resolve();
|
||||
expect(crlf.warnings).toEqual([]);
|
||||
expect(crlf.entries.map((entry) => entry.id)).toEqual(["applying-a-permission-policy"]);
|
||||
const result = reconcileSkillLoadout({ loadout: loadoutOf(crlf.entries, f.root), runtime, cwd: f.project, topologyOwner: "seat-a", apply: true });
|
||||
expect(result).toMatchObject({ ok: true, applied: true });
|
||||
const harness = runtime === "codex" ? ".agents" : ".claude";
|
||||
expect(readFileSync(join(f.project, harness, "skills", "applying-a-permission-policy", "SKILL.md"), "utf8")).toBe(original.replace(/\n/g, "\r\n"));
|
||||
});
|
||||
|
||||
it("skips a plugin skill it cannot copy exactly and says why", () => {
|
||||
const f = fixture([]);
|
||||
const plugin = writePlugin(f.root, [".claude-plugin"], ["good", "linked"]);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
postWebhook,
|
||||
postChatMessage,
|
||||
callWebApi,
|
||||
downloadPrivateFile,
|
||||
verifyScopes,
|
||||
@@ -10,19 +9,6 @@ import {
|
||||
type FetchImpl,
|
||||
} from "../src/domain/gateway/slack/slack-api.js";
|
||||
|
||||
it("preserves preview controls and thread on a rate-limit retry", async () => {
|
||||
const bodies: unknown[] = [];
|
||||
const fetchImpl: FetchImpl = async (_url, init) => {
|
||||
bodies.push(JSON.parse(String(init?.body)));
|
||||
return bodies.length === 1
|
||||
? new Response("rate limited", { status: 429, headers: { "Retry-After": "1" } })
|
||||
: new Response(JSON.stringify({ ok: true, ts: "2.0" }), { status: 200 });
|
||||
};
|
||||
const result = await postChatMessage("synthetic", { channel: "C", text: "link", thread_ts: "1.0", unfurl_links: false, unfurl_media: false }, fetchImpl, 1000, async () => {});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(bodies).toEqual([expect.objectContaining({ thread_ts: "1.0", unfurl_links: false, unfurl_media: false }), bodies[0]]);
|
||||
});
|
||||
|
||||
// Fake Slack: routes by URL, returns real Response objects (headers + json).
|
||||
function fakeSlack(spec: {
|
||||
webhookStatus?: number;
|
||||
|
||||
@@ -14,15 +14,6 @@ describe("Slice-11 config — first-class + honest unconfigured (item 5)", () =>
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("keeps human-row previews off by default and accepts an explicit choice", () => {
|
||||
expect(staticReadiness(loadConfig(home), true, true).find(r => r.label === "human-row previews")?.detail).toBe("off");
|
||||
saveConfig({ ...DEFAULT_CONFIG, unfurl: "on" }, home);
|
||||
expect(loadConfig(home).unfurl).toBe("on");
|
||||
expect(staticReadiness(loadConfig(home), true, true).find(r => r.label === "human-row previews")?.detail).toBe("on");
|
||||
expect(() => saveConfig({ ...DEFAULT_CONFIG, unfurl: "bad" } as never, home)).toThrow(/unfurl must be on or off/);
|
||||
expect(loadConfig(home).unfurl).toBe("on");
|
||||
});
|
||||
|
||||
it("loadConfig returns defaults when absent (no throw); inbound-dest first-class default", () => {
|
||||
expect(configFileExists(home)).toBe(false);
|
||||
const cfg = loadConfig(home);
|
||||
|
||||
@@ -71,7 +71,7 @@ function capturingFetch(status = 200): { fetchImpl: FetchImpl; calls: { url: str
|
||||
}
|
||||
|
||||
/** Compose the REAL subsystem outbound path over a temp home: driver → wire → delivery. */
|
||||
function composeOutbound(home: string, port: OutboundQueuePort, fetchImpl: FetchImpl, fsx = memFs(), unfurl?: "on" | "off", thread?: string) {
|
||||
function composeOutbound(home: string, port: OutboundQueuePort, fetchImpl: FetchImpl, fsx = memFs()) {
|
||||
const outboundSeen = new SeenStore("/s/outbound-seen.jsonl", fsx, clock);
|
||||
const delivered = new SeenStore("/s/delivered.jsonl", fsx, clock);
|
||||
const attempted = new SeenStore("/s/attempted.jsonl", fsx, clock);
|
||||
@@ -80,8 +80,6 @@ function composeOutbound(home: string, port: OutboundQueuePort, fetchImpl: Fetch
|
||||
botToken: "xoxb-EXAMPLE-fake",
|
||||
channel: "C-TEST",
|
||||
sourceLabel: "vm",
|
||||
unfurl,
|
||||
resolveThreadTs: () => thread,
|
||||
fetchImpl,
|
||||
delivered,
|
||||
attempted,
|
||||
@@ -117,15 +115,6 @@ describe("S10 cutover — outbound classes ride the SUBSYSTEM path (slice-11 ite
|
||||
expect(calls[0]!.url).toBe("https://slack.com/api/chat.postMessage");
|
||||
expect(calls.some((c) => c.url.includes("hooks.slack.com"))).toBe(false);
|
||||
expect(calls[0]!.body.channel).toBe("C-TEST");
|
||||
expect(calls[0]!.body).toMatchObject({ unfurl_links: false, unfurl_media: false });
|
||||
});
|
||||
|
||||
it("applies enabled previews to a threaded human row", async () => {
|
||||
const { fetchImpl, calls } = capturingFetch(200);
|
||||
const { driver } = composeOutbound(home, fakePort([ALERT]), fetchImpl, memFs(), "on", "100.1");
|
||||
await driver.sweepOnce();
|
||||
await flush();
|
||||
expect(calls[0]!.body).toMatchObject({ thread_ts: "100.1", unfurl_links: true, unfurl_media: true });
|
||||
});
|
||||
|
||||
it("item 2 idempotent: a second sweep posts nothing", async () => {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
# Delivery conformance suite
|
||||
|
||||
This kit proves OpenRig's typed delivery against the real Claude Code and Codex terminal programs. The delivery measurement case list (kept outside this repository) plans 25 cases, from idle and busy seats to drafts, dialogs, copy mode, ordering, `/clear`, compaction and restart. Quality owns the expected result for each case. This directory holds the code that drives the cases.
|
||||
This kit proves OpenRig's typed delivery against the real Claude Code and Codex terminal programs. It covers 25 cases, from idle and busy seats to drafts, dialogs, copy mode, ordering, `/clear`, compaction and restart. Quality owns the expected result for each case. This directory holds the code that drives the cases.
|
||||
|
||||
**Registered and runnable today:** only the cases `cases/index.mjs` lists, on both programs. Those are D01 idle, D02 busy, D03 and D04 drafts, D05 permission prompt, D06 other dialog (the model picker), D08 copy mode, D09 order, D15 multi-line paste, and issue #3076's two late-gap cells (`R3076-notice`, `R3076-send`). `run.mjs` runs nothing else, and `--cases` refuses an id that isn't registered (`no case D12 in this suite yet`).
|
||||
|
||||
**Planned, not fixtures yet:** `cases/expected.yaml` already states the expected result for D07, D14, D18 and D19, and its `later` list names D10, D12 (a managed `/compact` with a typed draft), D16 and D25. Compaction and restart have no fixture yet.
|
||||
**Built so far:** the stand-in model servers, the case harness, the release-run entry point, and the first cases (D01 idle, D02 busy, D03 and D04 drafts, D08 copy mode, D09 order, D15 multi-line paste) on both programs. Dialogs and guards, lifecycle, and fault injection come next.
|
||||
|
||||
## Running a release
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
"openrig-operating-model/templates/SOP.md": "1eaf0daf4f9bbf0715e6a5aaf0f29f030af5fe8b8a10232488ee97c507afa6dc",
|
||||
"openrig-operating-model/templates/SPEC.md": "f126536daa59cea3f1cfb0898d83c7cdf99f6392f6f155ea64fe55187dd0f5f1",
|
||||
"openrig-skills/SKILL.md": "6d7bf933ac40531f11757a31a93d26dcbda617f1b85f48a21865b3a5c5e2ddaa",
|
||||
"openrig-user/SKILL.md": "ef22c218348bdf06d0f56d9de86a3ac8a5f954eae161a212d1e1010a19e464b9",
|
||||
"openrig-user/SKILL.md": "6176761014c9ecd61675cb47d4de991de6dd126a92be7e06c3ef31aa0573f82e",
|
||||
"orienting-to-an-inherited-seat/CHANGELOG.md": "a91f117e88034d8bae9b723916235e858fbc151983cc7c32784f5d4ba186274a",
|
||||
"orienting-to-an-inherited-seat/SKILL.md": "278c554b3bb4c481f2928499f02442d947ffd4751d60f830610b652e24cb0468",
|
||||
"queue-handoff/SKILL.md": "96251f6830006bb9d8ede2e5826ab936df4a9fb91090187a23ccaf860ab76336",
|
||||
|
||||
Reference in new issue
Block a user