mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-12 18:48:31 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
384413e830 |
No files matched your search
+2
-2
@@ -6,9 +6,9 @@ This isn't a schedule or a promise: we release often, and an item ships when a g
|
||||
To see what's being worked on, by release and theme, open the [OpenRig roadmap board](https://github.com/users/mvschwarz/projects/1).
|
||||
Issues labelled `good first issue` are also listed on the [contribute page](https://github.com/mvschwarz/openrig/contribute).
|
||||
|
||||
## Released in 0.7.13
|
||||
## Released in 0.7.12
|
||||
|
||||
[0.7.13 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.13): OpenRig 0.7.13 is a patch for people running their own agent teams. When you answer a runner's permission request, you now get a note back saying who decides it, and the runner wakes on the decision. See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
|
||||
[0.7.12 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.12): OpenRig 0.7.12 is a patch for people running their own agent teams. When you answer your team, by typing, clicking or qualifying an answer, the answer now goes to an agent that reads it with the question and decides. When no receiving agent is available, your answer waits. See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
|
||||
|
||||
## How to help
|
||||
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface NodeEntry {
|
||||
lifecycleState?: "running" | "detached" | "recoverable" | "attention_required";
|
||||
tmuxAttachCommand: string | null;
|
||||
resumeCommand: string | null;
|
||||
/** What the resume command couldn't include, when something the seat chose couldn't be read. */
|
||||
resumeCommandNote?: string;
|
||||
latestError: string | null;
|
||||
/** Slice 15 — `terminal-active` primitive. true=producing output,
|
||||
* false=silent past threshold, null=no signal. NEVER derived from
|
||||
@@ -231,6 +233,7 @@ const ALLOWED_NODE_FIELDS = new Set([
|
||||
"lifecycleState",
|
||||
"tmuxAttachCommand",
|
||||
"resumeCommand",
|
||||
"resumeCommandNote",
|
||||
"latestError",
|
||||
"terminalActive",
|
||||
"hasAssignedWork",
|
||||
|
||||
@@ -1278,8 +1278,7 @@ reaching them is ordinary work rather than an escalation.
|
||||
- **The `relay` gateway connector** carries a registered person's questions, updates and notices to a
|
||||
bridge process you run, over loopback HTTP, and takes their replies back with the sender resolved
|
||||
against their bindings. It's off until a person has a relay binding (`rig gateway human add <id> …
|
||||
--binding "relay:<connectorRef>:file:<absolute env file>:primary:handle=<alias>"`). The handle is how
|
||||
their replies are matched to them: a binding without one is outbound-only. The bridge, with its reach to
|
||||
--binding "relay:<connectorRef>:file:<absolute env file>:primary"`), and the bridge, with its reach to
|
||||
any messaging platform, is yours (`docs/reference/gateway-relay.md`).
|
||||
- **`rig gateway channel add <id> --display-name <name> --binding
|
||||
slack:<connectorRef>:<secretsRef>:<channelId>`** — a Slack shared channel as one participant,
|
||||
@@ -1598,11 +1597,8 @@ your circumstances is configuration, and the ones that are not, another agent ca
|
||||
without that storage), so don't copy them by hand; read the `plugin_skill_*` warnings if one
|
||||
is missing. TDD is no longer a default skill.
|
||||
- **`on_restore: follow_source`** on a spec's `resources.skills[]` entry refreshes that skill from its source when
|
||||
a Claude or Codex seat is restored, keeping its conversation, while the seat's copy is still what OpenRig wrote: an
|
||||
edited, unrecorded or linked copy is kept and named, and a source that can't be read leaves the saved copy.
|
||||
`keep_saved`, the default, keeps the saved copy, but a missing file comes back only from a source that still has the
|
||||
bytes OpenRig wrote: a file whose source changed after `rig down` stays missing, named in the restore's warnings,
|
||||
even when the rest of the skill (its `SKILL.md`, say) is unchanged.
|
||||
a Claude or Codex seat is restored, keeping its conversation; `keep_saved`, the default, keeps the saved copy, but a
|
||||
skill whose source changed after `rig down` comes back missing, named in the restore's warnings.
|
||||
- **`rig startup-proof submit`** — answer an explicitly selected authenticated startup challenge.
|
||||
Startup adds no proof exercise by default. Declare `startup_proof` with `authenticated` or
|
||||
`none` in the applicable startup layers; see `docs/reference/rig-spec.md#startup-block/startup-proof-selection`.
|
||||
|
||||
@@ -12,7 +12,6 @@ import { networkReceiptsSchema } from "./migrations/161_network_receipts.js";
|
||||
import { sessionAddressReadIndexesSchema } from "./migrations/162_session_address_read_indexes.js";
|
||||
import { networkInstallationsSchema } from "./migrations/163_network_installations.js";
|
||||
import { queueItemsReplyToIndexSchema } from "./migrations/164_queue_items_reply_to_index.js";
|
||||
import { queueListReadIndexesSchema } from "./migrations/166_queue_list_read_indexes.js";
|
||||
import { activityGrantsSchema } from "./migrations/143_activity_grants.js";
|
||||
import { environmentBindingsSchema } from "./migrations/144_environment_bindings.js";
|
||||
import { nodePermissionsToSchema } from "./migrations/145_node_permissions_to.js";
|
||||
@@ -332,5 +331,4 @@ export const ALL_MIGRATIONS: Migration[] = [
|
||||
sessionAddressReadIndexesSchema,
|
||||
networkInstallationsSchema,
|
||||
queueItemsReplyToIndexSchema,
|
||||
queueListReadIndexesSchema,
|
||||
];
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/** Bound queue selection by its existing order, and notification reads by their eligible history. */
|
||||
export const queueListReadIndexesSchema: Migration = {
|
||||
name: "166_queue_list_read_indexes.sql",
|
||||
sql: `
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_items_created
|
||||
ON queue_items(ts_created DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_items_active_created
|
||||
ON queue_items(CASE WHEN state IN ('pending', 'in-progress', 'blocked') THEN 0 ELSE 1 END, ts_created DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_items_active_updated
|
||||
ON queue_items(CASE WHEN state IN ('pending', 'in-progress', 'blocked') THEN 0 ELSE 1 END, ts_updated DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_transitions_owner_notification_qitem
|
||||
ON queue_transitions(qitem_id, transition_id DESC) WHERE owner_notification_level IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_transitions_archive_owner_notification_qitem
|
||||
ON queue_transitions_archive(qitem_id, transition_id DESC) WHERE owner_notification_level IS NOT NULL;
|
||||
`,
|
||||
};
|
||||
@@ -2,6 +2,8 @@ import { currentClaudeMcpMenu } from "./claude-mcp-menu.js";
|
||||
import { shellQuote } from "../adapters/shell-quote.js";
|
||||
import { claudePostureFlag, codexPostureArg } from "../adapters/yolo-mode.js";
|
||||
import { claudeMcpArgs, type ClaudeMcp } from "./claude-mcp.js";
|
||||
import { codexMemberEnvelope, type CodexMemberPermissions } from "./codex-member-permissions.js";
|
||||
import { codexEnvelopeArgs } from "./codex-rebuild-permissions.js";
|
||||
|
||||
// L3 adds `attention_required` for the Claude resume-selection prompt proxy.
|
||||
// Distinct from `inconclusive` (we don't know yet) and `failed` (terminal
|
||||
@@ -52,6 +54,8 @@ export function buildNativeResumeCommand(
|
||||
/** Codex: the seat's recorded daemon choice; only a positive true adds --no-daemon (absent and false print as before). */
|
||||
codexDaemonOptOut?: boolean,
|
||||
claudeMcp?: ClaudeMcp | null,
|
||||
/** Codex: the seat's own saved permission choice; it replaces the posture decision, as on the seat's managed resume. */
|
||||
codexPermissions?: CodexMemberPermissions,
|
||||
): string | null {
|
||||
if (!resumeToken) return null;
|
||||
if (runtime === "claude-code") {
|
||||
@@ -60,11 +64,19 @@ export function buildNativeResumeCommand(
|
||||
return `claude ${claudePostureFlag(process.env)} --resume ${shellQuote(resumeToken)}${nameSuffix}${mcpSuffix}`;
|
||||
}
|
||||
if (runtime === "codex") {
|
||||
return buildCodexResumeCore(resumeToken, codexConfigProfile, undefined, undefined, undefined, undefined, undefined, codexDaemonOptOut === true);
|
||||
return buildCodexResumeCore(resumeToken, codexConfigProfile, undefined, undefined, undefined, undefined,
|
||||
codexSeatPostureArg(codexConfigProfile, codexPermissions), codexDaemonOptOut === true);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A Codex seat's own permission choice as the managed resume emits it: the profile, then the choice's flags, in place
|
||||
* of the posture decision. Undefined without a choice, so the posture decision stands. */
|
||||
export function codexSeatPostureArg(codexConfigProfile: string | null | undefined, permissions: CodexMemberPermissions | undefined): string | undefined {
|
||||
if (!permissions) return undefined;
|
||||
return `${codexConfigProfile ? ` -p ${shellQuote(codexConfigProfile)}` : ""}${codexEnvelopeArgs(codexMemberEnvelope(permissions))}`;
|
||||
}
|
||||
|
||||
/** Managed Codex seats run tool commands in a non-login shell, so they keep the seat's own environment, including the
|
||||
* OpenRig command folder at the front of PATH. Codex otherwise gives each command the user's login-shell environment
|
||||
* and puts back only its own directories, so a login file that sets PATH outright drops that folder. */
|
||||
|
||||
@@ -24,7 +24,8 @@ import { RigRepository, findLatestUsableSnapshot, findLatestUsableSnapshotsForAl
|
||||
import { hasCompleteGuidanceBlock, seatGuidanceFile } from "./restore-guidance.js";
|
||||
import { resolveNodeWorkspace } from "./workspace/workspace-resolver.js";
|
||||
import { deriveCanonicalSessionName } from "./session-name.js";
|
||||
import { buildNativeResumeCommand, buildCodexResumeCore } from "./native-resume-probe.js";
|
||||
import { buildNativeResumeCommand, buildCodexResumeCore, codexSeatPostureArg } from "./native-resume-probe.js";
|
||||
import { validateCodexMemberPermissions, type CodexMemberPermissions } from "./codex-member-permissions.js";
|
||||
import { AppliedLaunchObservationStore } from "./applied-launch-observation-store.js";
|
||||
import { readReadyFreshLaunchFences } from "./fresh-occupant-relation.js";
|
||||
import { daemonTmuxServerArgs, daemonTmuxServerInfo, tmuxAttachCommand } from "../adapters/tmux-server.js";
|
||||
@@ -114,13 +115,38 @@ interface BindingRow {
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
function computeResumeCommand(runtime: string | null, resumeToken: string | null, codexConfigProfile?: string | null, codexDaemonOptOut?: boolean, claudeMcp?: ClaudeMcp | null): string | null {
|
||||
return buildNativeResumeCommand(runtime, resumeToken, null, codexConfigProfile, codexDaemonOptOut, claudeMcp);
|
||||
function computeResumeCommand(runtime: string | null, resumeToken: string | null, codexConfigProfile?: string | null, codexDaemonOptOut?: boolean, claudeMcp?: ClaudeMcp | null, codexChoice?: CodexSeatChoice): string | null {
|
||||
return buildNativeResumeCommand(runtime, resumeToken, null, codexConfigProfile, codexDaemonOptOut, claudeMcp,
|
||||
codexChoice && "permissions" in codexChoice ? codexChoice.permissions : undefined);
|
||||
}
|
||||
|
||||
const CODEX_DAEMON_CHOICE_NOT_RECORDED = "Not recorded whether this seat's last managed launch used --no-daemon. If `codex --help` lists --no-daemon, "
|
||||
+ "add it right after `codex`, as the managed launch does.";
|
||||
|
||||
/** Shown beside a Codex resume command when the seat's saved permission choice couldn't be read: the command still shows. */
|
||||
const CODEX_PERMISSIONS_NOT_INCLUDED = "This seat's saved Codex permission choice couldn't be read, so the resume command doesn't include it and "
|
||||
+ "carries the default posture instead. Repair it with rig seat set-permissions <seat> --codex-permissions <file> --reason <reason>.";
|
||||
|
||||
/** A Codex seat's saved permission choice: the choice, or unreadable. A seat with none has no entry. */
|
||||
type CodexSeatChoice = { permissions: CodexMemberPermissions } | { unreadable: true };
|
||||
|
||||
/** Every seat's saved Codex permission choice, in one read. A schema without the column (a minimal fixture) has none. */
|
||||
function readCodexSeatChoices(db: Database.Database): Map<string, CodexSeatChoice> {
|
||||
const choices = new Map<string, CodexSeatChoice>();
|
||||
let rows: Array<{ id: string; codex_permissions_json: string }>;
|
||||
try {
|
||||
rows = db.prepare("SELECT id, codex_permissions_json FROM nodes WHERE codex_permissions_json IS NOT NULL").all() as typeof rows;
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code === "SQLITE_ERROR" && /no such column: codex_permissions_json\b/.test((error as Error).message)) return choices;
|
||||
throw error;
|
||||
}
|
||||
for (const row of rows) {
|
||||
try { choices.set(row.id, { permissions: validateCodexMemberPermissions(JSON.parse(row.codex_permissions_json)) }); }
|
||||
catch { choices.set(row.id, { unreadable: true }); }
|
||||
}
|
||||
return choices;
|
||||
}
|
||||
|
||||
function computeRecoveryGuidance(input: {
|
||||
runtime: string | null;
|
||||
resumeToken: string | null;
|
||||
@@ -130,8 +156,10 @@ function computeRecoveryGuidance(input: {
|
||||
/** Codex: the current generation's recorded daemon choice; undefined when none was recorded. */
|
||||
codexDaemonOptOut?: boolean;
|
||||
claudeMcp?: ClaudeMcp;
|
||||
/** Codex: the seat's saved permission choice; undefined when it has none. */
|
||||
codexChoice?: CodexSeatChoice;
|
||||
}): NodeRecoveryGuidance | null {
|
||||
const { runtime, resumeToken, cwd, sessionName, codexConfigProfile, codexDaemonOptOut, claudeMcp } = input;
|
||||
const { runtime, resumeToken, cwd, sessionName, codexConfigProfile, codexDaemonOptOut, claudeMcp, codexChoice } = input;
|
||||
|
||||
if (runtime === "claude-code") {
|
||||
const commands: string[] = [];
|
||||
@@ -165,14 +193,20 @@ function computeRecoveryGuidance(input: {
|
||||
const notes: string[] = [];
|
||||
|
||||
const daemonOptOut = codexDaemonOptOut === true;
|
||||
// The seat's own permission choice replaces the posture decision, as on its managed resume.
|
||||
const seatPosture = codexSeatPostureArg(codexConfigProfile, codexChoice && "permissions" in codexChoice ? codexChoice.permissions : undefined);
|
||||
const lastCommand = buildCodexResumeCore("", codexConfigProfile, true, undefined, undefined, undefined, seatPosture, daemonOptOut);
|
||||
if (resumeToken) {
|
||||
commands.push(buildCodexResumeCore(resumeToken, codexConfigProfile, undefined, undefined, undefined, undefined, undefined, daemonOptOut));
|
||||
commands.push(buildCodexResumeCore(resumeToken, codexConfigProfile, undefined, undefined, undefined, undefined, seatPosture, daemonOptOut));
|
||||
}
|
||||
if (cwd) {
|
||||
commands.push(`cd ${cwd}`);
|
||||
}
|
||||
if (!resumeToken) {
|
||||
commands.push(buildCodexResumeCore("", codexConfigProfile, true, undefined, undefined, undefined, undefined, daemonOptOut));
|
||||
commands.push(lastCommand);
|
||||
}
|
||||
if (codexChoice && "unreadable" in codexChoice) {
|
||||
notes.push(CODEX_PERMISSIONS_NOT_INCLUDED);
|
||||
}
|
||||
|
||||
notes.push("Use workspace and recent prompt text to identify the right conversation.");
|
||||
@@ -186,8 +220,11 @@ function computeRecoveryGuidance(input: {
|
||||
notes.push(CODEX_DAEMON_CHOICE_NOT_RECORDED);
|
||||
}
|
||||
|
||||
const ownChoice = "it carries this seat's own permission choice, as its managed launch does.";
|
||||
return {
|
||||
summary: resumeToken
|
||||
summary: seatPosture
|
||||
? (resumeToken ? `Try native Codex resume first; ${ownChoice}` : `No stored Codex resume token. Try ${lastCommand}; ${ownChoice}`)
|
||||
: resumeToken
|
||||
? "Try native Codex resume first; the managed launch sets the explicit -s workspace-write floor flag unless a named config profile is present."
|
||||
: `No stored Codex resume token. Try codex${daemonOptOut ? " --no-daemon" : ""} -s workspace-write resume --last; the managed launch sets the explicit -s workspace-write floor flag unless a named config profile is present.`,
|
||||
commands,
|
||||
@@ -612,11 +649,12 @@ export function getNodeInventory(db: Database.Database, rigId: string): NodeInve
|
||||
const restoreOutcomes = buildRestoreOutcomeMap(db, rigId);
|
||||
const orienteds = buildOrientedMap(db, rigId);
|
||||
const codexDaemonOptOuts = new AppliedLaunchObservationStore(db).readCurrentDaemonOptOuts();
|
||||
const codexSeatChoices = readCodexSeatChoices(db);
|
||||
|
||||
const rows = queryInventoryRows(db, rigId);
|
||||
|
||||
return rows.map((row) =>
|
||||
buildInventoryEntry(db, row, { usableSnapshot, workspaceSpec, identityVerdicts, restoreOutcomes, orienteds, codexDaemonOptOuts }),
|
||||
buildInventoryEntry(db, row, { usableSnapshot, workspaceSpec, identityVerdicts, restoreOutcomes, orienteds, codexDaemonOptOuts, codexSeatChoices }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -731,6 +769,8 @@ interface InventoryBuildContext {
|
||||
orienteds: Map<string, NodeOriented>;
|
||||
/** Each node's current recorded Codex daemon choice (one read per projection); absent = not recorded. */
|
||||
codexDaemonOptOuts: Map<string, boolean>;
|
||||
/** Each seat's saved Codex permission choice (one read per projection); absent = none. */
|
||||
codexSeatChoices: Map<string, CodexSeatChoice>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -743,7 +783,8 @@ function buildInventoryEntry(
|
||||
row: InventoryRow,
|
||||
ctx: InventoryBuildContext,
|
||||
): NodeInventoryEntry {
|
||||
const { usableSnapshot, workspaceSpec, identityVerdicts, restoreOutcomes, orienteds, codexDaemonOptOuts } = ctx;
|
||||
const { usableSnapshot, workspaceSpec, identityVerdicts, restoreOutcomes, orienteds, codexDaemonOptOuts, codexSeatChoices } = ctx;
|
||||
const codexChoice = row.runtime === "codex" ? codexSeatChoices.get(row.node_id) : undefined;
|
||||
const launchWrapper = parseStoredLaunchWrapper(row.launch_wrapper, `Seat ${row.node_id} launch_wrapper`);
|
||||
if (launchWrapper && !isLaunchWrapperRuntime(row.runtime)) {
|
||||
throw new Error(`Seat ${row.node_id} launch_wrapper: only valid when runtime is "pi", "codex", "claude-code" or a supported community harness`);
|
||||
@@ -823,7 +864,8 @@ function buildInventoryEntry(
|
||||
? { tmuxServerArgs: [...daemonTmuxServerArgs()] } : {}),
|
||||
...(row.binding_attachment_type === "tmux" && row.session_name && daemonTmuxServerInfo()
|
||||
? { tmuxServerMode: daemonTmuxServerInfo()!.mode } : {}),
|
||||
resumeCommand: computeResumeCommand(row.runtime, row.resume_token, row.codex_config_profile, codexDaemonOptOuts.get(row.node_id), row.claude_mcp),
|
||||
resumeCommand: computeResumeCommand(row.runtime, row.resume_token, row.codex_config_profile, codexDaemonOptOuts.get(row.node_id), row.claude_mcp, codexChoice),
|
||||
...(row.resume_token && codexChoice && "unreadable" in codexChoice ? { resumeCommandNote: CODEX_PERMISSIONS_NOT_INCLUDED } : {}),
|
||||
// OPR.0.4.0.26: recoveryGuidance is NOT inlined per node in the LIST
|
||||
// payload. It duplicated ~47KB of templated prose across all nodes and
|
||||
// no node-list consumer reads it. The full guidance is recomputed on
|
||||
@@ -921,6 +963,7 @@ function buildInventoryMapFromRows(db: Database.Database, rows: InventoryRow[]):
|
||||
const restoreOutcomes = buildRestoreOutcomeMap(db); // one fleet restore-outcome scan
|
||||
const orienteds = buildOrientedMap(db); // one fleet startup-orientation scan
|
||||
const codexDaemonOptOuts = new AppliedLaunchObservationStore(db).readCurrentDaemonOptOuts(); // one launch-fact read
|
||||
const codexSeatChoices = readCodexSeatChoices(db); // one permission-choice read
|
||||
const out = new Map<string, NodeInventoryEntry[]>();
|
||||
for (const row of rows) {
|
||||
const entry = buildInventoryEntry(db, row, {
|
||||
@@ -930,6 +973,7 @@ function buildInventoryMapFromRows(db: Database.Database, rows: InventoryRow[]):
|
||||
restoreOutcomes,
|
||||
orienteds,
|
||||
codexDaemonOptOuts,
|
||||
codexSeatChoices,
|
||||
});
|
||||
let list = out.get(row.rig_id);
|
||||
if (!list) { list = []; out.set(row.rig_id, list); }
|
||||
@@ -1137,6 +1181,7 @@ export function getNodeDetail(
|
||||
codexConfigProfile: entry.codexConfigProfile,
|
||||
codexDaemonOptOut: entry.runtime === "codex" ? new AppliedLaunchObservationStore(db).readCurrent(nodeId)?.daemonOptOut : undefined,
|
||||
claudeMcp: entry.runtime === "claude-code" ? readSeatClaudeMcp(db, nodeId) : undefined,
|
||||
codexChoice: entry.runtime === "codex" ? readCodexSeatChoices(db).get(nodeId) : undefined,
|
||||
}),
|
||||
binding,
|
||||
startupFiles,
|
||||
|
||||
@@ -42,9 +42,7 @@ export function freshRecoveryId(db: Database.Database, qitemId: string, overdueD
|
||||
* idx_queue_items_recovery_tags' WHERE clause (migration 099) byte for byte, which is what lets the
|
||||
* planner read that small index instead of scanning and parsing every row. */
|
||||
const RECOVERY_TAG_ROWS = `(tags LIKE '%"recovery-for:%' OR tags LIKE '%\\%')`;
|
||||
// Unary + preserves the numeric CASE order but prevents a prefix match on the full-history listing
|
||||
// index. Both standalone lookups and the batch need the small recovery-tag index instead.
|
||||
const RECOVERY_ORDER = "ORDER BY +CASE WHEN state IN ('pending','in-progress','blocked') THEN 0 ELSE 1 END, ts_updated DESC, qitem_id DESC LIMIT 1";
|
||||
const RECOVERY_ORDER = "ORDER BY CASE WHEN state IN ('pending','in-progress','blocked') THEN 0 ELSE 1 END, ts_updated DESC, qitem_id DESC LIMIT 1";
|
||||
/** A listing's recovery index, built by SQLite's own json_each (the same values, and the same truthy excludeTag
|
||||
* test, as the per-row query): for each string tag value, the rows that carry it in RECOVERY_ORDER, and each row's
|
||||
* string values. Built lazily, on the first lookup inside `withRecoveryIndex`, so a listing that never asks (a
|
||||
|
||||
@@ -814,6 +814,9 @@ export interface NodeInventoryEntry {
|
||||
/** Effective mode; inherited non-default sockets also have server arguments. */
|
||||
tmuxServerMode?: "inherit" | "isolated";
|
||||
resumeCommand: string | null;
|
||||
/** Present when the resume command couldn't include something the seat chose (its saved Codex permission choice
|
||||
* couldn't be read): the command still shows, and this says what it lacks. */
|
||||
resumeCommandNote?: string;
|
||||
recoveryGuidance: NodeRecoveryGuidance | null;
|
||||
latestError: string | null;
|
||||
// Extended fields
|
||||
|
||||
@@ -361,22 +361,6 @@ export class WorkflowValidator {
|
||||
});
|
||||
}
|
||||
|
||||
// #3087: validate never resolves seats, so a harness pin is only
|
||||
// reconciled when a run resolves the step's owner. Say so, so a
|
||||
// clean result is not read as "every pin is satisfiable".
|
||||
spec.steps.forEach((step, idx) => {
|
||||
if (!step.harness) return;
|
||||
const binding = spec.target?.rig
|
||||
? `rig "${spec.target.rig}"`
|
||||
: "no rig is bound";
|
||||
issues.push({
|
||||
code: "harness_pin_unchecked",
|
||||
message: `step "${step.id}" pins harness "${step.harness}", but validate does not check harness pins against any seats (${binding}). The pin is checked when a run resolves the step's owner, and the run fails with harness_pin_unsatisfied if no preferred_target of role "${step.actor_role}" runs ${step.harness}.`,
|
||||
field: `workflow.steps[${idx}].harness`,
|
||||
severity: "warning",
|
||||
});
|
||||
});
|
||||
|
||||
// OPR.0.4.6.WF2 FR-5: gate target semantics — a HUMAN-seat target
|
||||
// requires summary + evidence_ref (the shipped human-route write
|
||||
// path enforces them at create; failing HERE is the fail-at-author-
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// The Codex resume command OpenRig shows a person for a seat (`rig ps --nodes` resumeCommand and the node detail's
|
||||
// recovery guidance) carries the seat's own saved permission choice, as its managed resume does. A choice that can't be
|
||||
// read still shows the command, with a note that it doesn't include it.
|
||||
import { mockShellCommand } from "./helpers/shell-command-mock.js";
|
||||
import type Database from "better-sqlite3";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { AppliedLaunchObservationStore } from "../src/domain/applied-launch-observation-store.js";
|
||||
import { observeCodexSandbox, type AppliedLaunchObservation } from "../src/domain/permission-drift.js";
|
||||
import { getNodeDetail, getNodeInventory } from "../src/domain/node-inventory.js";
|
||||
import { CodexResumeAdapter } from "../src/adapters/codex-resume.js";
|
||||
import type { CodexMemberPermissions } from "../src/domain/codex-member-permissions.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
|
||||
// No real Codex, shell or tmux runs here.
|
||||
const processMocks = vi.hoisted(() => ({
|
||||
execFile: vi.fn(),
|
||||
execSync: vi.fn<() => string>(() => { throw new Error("Unexpected synchronous process execution"); }),
|
||||
}));
|
||||
vi.mock("node:child_process", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("node:child_process")>()), execFile: processMocks.execFile, execSync: processMocks.execSync,
|
||||
}));
|
||||
|
||||
const workspace: CodexMemberPermissions = { sandbox: "workspace-write", approval: "on-request", network_access: true, writable_roots: ["/data"] };
|
||||
const WORKSPACE_ARGS = " -s workspace-write -a on-request -c 'sandbox_workspace_write.writable_roots=[\"/data\"]'"
|
||||
+ " -c 'sandbox_workspace_write.network_access=true' -c 'sandbox_workspace_write.exclude_tmpdir_env_var=false'"
|
||||
+ " -c 'sandbox_workspace_write.exclude_slash_tmp=false'";
|
||||
const NOT_INCLUDED = "This seat's saved Codex permission choice couldn't be read, so the resume command doesn't include it and "
|
||||
+ "carries the default posture instead. Repair it with rig seat set-permissions <seat> --codex-permissions <file> --reason <reason>.";
|
||||
|
||||
describe("the Codex resume command a person is shown carries the seat's own permission choice", () => {
|
||||
let db: Database.Database;
|
||||
let registry: SessionRegistry;
|
||||
let sessionId: string;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createFullTestDb();
|
||||
db.prepare("INSERT INTO rigs (id, name) VALUES (?, ?)").run("rig-2", "test-rig");
|
||||
db.prepare("INSERT INTO pods (id, rig_id, namespace, label) VALUES (?, ?, ?, ?)").run("pod-2", "rig-2", "dev", "Dev");
|
||||
db.prepare(
|
||||
"INSERT INTO nodes (id, rig_id, logical_id, runtime, cwd, pod_id, agent_ref, profile, resolved_spec_name, resolved_spec_version, resolved_spec_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
).run("node-codex", "rig-2", "dev.qa", "codex", "/workspace/app", "pod-2", "local:agents/qa", "default", "qa", "1.0.0", "hash");
|
||||
registry = new SessionRegistry(db);
|
||||
sessionId = registry.registerSession("node-codex", "dev-qa@test-rig").id;
|
||||
});
|
||||
afterEach(() => db.close());
|
||||
|
||||
const choose = (stored: string) => db.prepare("UPDATE nodes SET codex_permissions_json = ? WHERE id = ?").run(stored, "node-codex");
|
||||
const shown = () => {
|
||||
const entry = getNodeInventory(db, "rig-2").find((e) => e.logicalId === "dev.qa")!;
|
||||
const detail = getNodeDetail(db, "rig-2", "dev.qa")!;
|
||||
return { entry, guidance: detail.recoveryGuidance! };
|
||||
};
|
||||
|
||||
describe("with a stored conversation", () => {
|
||||
beforeEach(() => registry.updateResumeToken(sessionId, "codex_id", "sess-456", "hook"));
|
||||
|
||||
it("a workspace-write choice replaces the default posture, in the command and the guidance", () => {
|
||||
choose(JSON.stringify(workspace));
|
||||
const { entry, guidance } = shown();
|
||||
expect(entry.resumeCommand).toBe(`codex${WORKSPACE_ARGS} resume 'sess-456'`);
|
||||
expect(entry).not.toHaveProperty("resumeCommandNote");
|
||||
expect(guidance.commands).toEqual([`codex${WORKSPACE_ARGS} resume 'sess-456'`, "cd /workspace/app"]);
|
||||
expect(guidance.summary).toBe("Try native Codex resume first; it carries this seat's own permission choice, as its managed launch does.");
|
||||
expect(guidance.notes).not.toContain(NOT_INCLUDED);
|
||||
});
|
||||
|
||||
it("a read-only choice keeps the seat's profile and a recorded --no-daemon", () => {
|
||||
choose(JSON.stringify({ sandbox: "read-only", approval: "never" }));
|
||||
db.prepare("UPDATE nodes SET codex_config_profile = ? WHERE id = ?").run("team", "node-codex");
|
||||
const generation = registry.currentOccupantTenure("node-codex")!.generationUuid;
|
||||
new AppliedLaunchObservationStore(db).recordGeneration(generation, { ...observeCodexSandbox("-s workspace-write"), daemonOptOut: true } as AppliedLaunchObservation);
|
||||
expect(shown().entry.resumeCommand).toBe("codex --no-daemon -p 'team' -s read-only -a never resume 'sess-456'");
|
||||
});
|
||||
|
||||
it("prints the same permission arguments the seat's managed resume sends", async () => {
|
||||
choose(JSON.stringify(workspace));
|
||||
const sendText = vi.fn(async () => ({ ok: true as const }));
|
||||
const tmux = mockShellCommand({
|
||||
sendText, hasSession: vi.fn(async () => true), getPaneCommand: vi.fn(async () => "codex"),
|
||||
capturePaneContent: vi.fn(async () => "OpenAI Codex (v0.0.0)\n› Ask Codex to do anything"),
|
||||
sendKeys: vi.fn(async () => ({ ok: true as const })), getPanePid: vi.fn(async () => null),
|
||||
listSessions: vi.fn(async () => []), listWindows: vi.fn(async () => []), listPanes: vi.fn(async () => []),
|
||||
} as unknown as TmuxAdapter);
|
||||
const adapter = new CodexResumeAdapter(tmux, { sleep: async () => {}, maxWaitMs: 0 });
|
||||
const result = await adapter.resume("dev-qa@test-rig", "codex_id", "sess-456", "/workspace/app", null, undefined,
|
||||
undefined, undefined, undefined, undefined, undefined, undefined, workspace);
|
||||
expect(result.ok).toBe(true);
|
||||
const managed = (sendText.mock.calls as unknown[][]).map((call) => String(call[1])).find((text) => text.includes(" resume "))!;
|
||||
expect(managed).toContain(`codex${WORKSPACE_ARGS}`);
|
||||
expect(shown().entry.resumeCommand).toBe(`codex${WORKSPACE_ARGS} resume 'sess-456'`);
|
||||
});
|
||||
|
||||
it("a choice that can't be read still shows the command, with a note that it doesn't include it", () => {
|
||||
choose(JSON.stringify({ sandbox: "danger-full-access", approval: "never" }));
|
||||
const { entry, guidance } = shown();
|
||||
expect(entry.resumeCommand).toBe("codex -s workspace-write resume 'sess-456'");
|
||||
expect(entry.resumeCommandNote).toBe(NOT_INCLUDED);
|
||||
expect(guidance.commands).toEqual(["codex -s workspace-write resume 'sess-456'", "cd /workspace/app"]);
|
||||
expect(guidance.notes).toContain(NOT_INCLUDED);
|
||||
});
|
||||
|
||||
it("a seat with no choice shows the command as before, with no note", () => {
|
||||
const { entry, guidance } = shown();
|
||||
expect(entry.resumeCommand).toBe("codex -s workspace-write resume 'sess-456'");
|
||||
expect(entry).not.toHaveProperty("resumeCommandNote");
|
||||
expect(guidance.notes).not.toContain(NOT_INCLUDED);
|
||||
});
|
||||
});
|
||||
|
||||
it("without a stored conversation, the picker command and the summary carry the choice", () => {
|
||||
choose(JSON.stringify(workspace));
|
||||
const { entry, guidance } = shown();
|
||||
expect(entry.resumeCommand).toBeNull();
|
||||
expect(entry).not.toHaveProperty("resumeCommandNote");
|
||||
expect(guidance.commands).toEqual(["cd /workspace/app", `codex${WORKSPACE_ARGS} resume --last`]);
|
||||
expect(guidance.summary).toBe(`No stored Codex resume token. Try codex${WORKSPACE_ARGS} resume --last; it carries this seat's own permission choice, as its managed launch does.`);
|
||||
});
|
||||
});
|
||||
@@ -188,7 +188,6 @@ export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, even
|
||||
* its exclusion); the guard's redundant-exclusion check then keeps the two in sync.
|
||||
*/
|
||||
export const migrationsForFullTestDbExclusions: Record<string, string> = {
|
||||
"166_queue_list_read_indexes.sql": "read indexes depend on owner-notification columns/archive omitted here (054/076); startup and queue-list-order-indexes.test.ts exercise ALL_MIGRATIONS.",
|
||||
"156_queue_erasure_link_indexes.sql": "indexes outbox_entries, inbox_entries and the stream tables (all excluded from the core edge) for queue erasure's lookups; erasure tests migrate the canonical full schema.",
|
||||
"082_archive_identity_provenance.sql": "archive extension; 054 is excluded from this core fixture. Archive identity regressions use ALL_MIGRATIONS.",
|
||||
"026_inbox_entries.sql": "inbox subsystem table — not on the shared core edge (inbox suites migrate it inline).",
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository, type QueueListOptions } from "../src/domain/queue-repository.js";
|
||||
import { RECOVERY_INDEX_SQL, findQueueRecovery } from "../src/domain/queue-recovery.js";
|
||||
import { QueueTransitionLog } from "../src/domain/queue-transition-log.js";
|
||||
import { runStuckSweep } from "../src/domain/queue-stuck-sweep.js";
|
||||
import { OutboxHandler } from "../src/domain/outbox-handler.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
|
||||
describe("bounded queue lists use their ordering indexes", () => {
|
||||
let db: Database.Database;
|
||||
let repo: QueueRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
repo = new QueueRepository(db, new EventBus(db), { loadHumanRegistry: () => ({ ok: true, entities: [] }) });
|
||||
const insert = db.prepare(`INSERT INTO queue_items
|
||||
(qitem_id, ts_created, ts_updated, source_session, destination_session, state, body, tags)
|
||||
VALUES (?, ?, ?, 'sender@r', 'worker@r', ?, ?, ?)`);
|
||||
db.transaction(() => {
|
||||
for (let i = 0; i < 240; i++) {
|
||||
// Equal timestamps across states exercise the existing scan's tie order too.
|
||||
const ts = new Date(Date.UTC(2026, 10, 12, 0, 0, Math.floor(i / 3))).toISOString();
|
||||
const tags = i % 7 === 0 ? ["first", "second"] : i % 2 ? ["first"] : ["second"];
|
||||
insert.run(`q-${i}`, ts, ts, ["pending", "done", "blocked"][i % 3], "body".repeat(256), JSON.stringify(tags));
|
||||
}
|
||||
})();
|
||||
});
|
||||
afterEach(() => { vi.restoreAllMocks(); db.close(); });
|
||||
|
||||
function selection(options: QueueListOptions) {
|
||||
const prepare = db.prepare.bind(db);
|
||||
let selected: { sql: string; args: unknown[] } | undefined;
|
||||
const spy = vi.spyOn(db, "prepare").mockImplementation((sql: string) => {
|
||||
const statement = prepare(sql);
|
||||
if (/^SELECT (?:\*|qitem_id,).* FROM queue_items\s+(?:WHERE\b|ORDER BY\b)/s.test(sql)) {
|
||||
const all = statement.all.bind(statement);
|
||||
statement.all = (...args: unknown[]) => { selected = { sql, args }; return all(...args); };
|
||||
}
|
||||
return statement;
|
||||
});
|
||||
let ids: string[];
|
||||
try { ids = repo.list(options).map((item) => item.qitemId); }
|
||||
finally { spy.mockRestore(); }
|
||||
expect(selected).toBeDefined();
|
||||
const { sql, args } = selected!;
|
||||
const legacy = prepare(sql.replace("FROM queue_items", "FROM queue_items NOT INDEXED"))
|
||||
.all(...args) as Array<{ qitem_id: string }>;
|
||||
expect(ids).toEqual(legacy.map((row) => row.qitem_id));
|
||||
const plan = prepare(`EXPLAIN QUERY PLAN ${sql}`).all(...args) as Array<{ detail: string }>;
|
||||
return { ids, details: plan.map((row) => row.detail).join("\n") };
|
||||
}
|
||||
|
||||
it.each([false, true])("does not scan and sort all rows for a bounded list (compact=%s)", (compact) => {
|
||||
const { ids, details } = selection({ compact, limit: 6 });
|
||||
expect(ids).toHaveLength(6);
|
||||
expect(details).toContain("USING INDEX");
|
||||
expect(details).not.toContain("TEMP B-TREE");
|
||||
});
|
||||
|
||||
it.each([false, true])("keeps active rows first without sorting all rig history (compact=%s)", (compact) => {
|
||||
const { ids, details } = selection({ rig: "r", compact, limit: 6 });
|
||||
expect(ids).toHaveLength(6);
|
||||
expect(details).toContain("USING INDEX");
|
||||
expect(details).not.toContain("TEMP B-TREE");
|
||||
});
|
||||
|
||||
it.each([false, true])("keeps scoped update-time ordering indexed (compact=%s)", (compact) => {
|
||||
const { ids, details } = selection({ rig: "r", sortByUpdated: true, compact, limit: 6 });
|
||||
expect(ids).toHaveLength(6);
|
||||
expect(details).toContain("USING INDEX");
|
||||
expect(details).not.toContain("TEMP B-TREE");
|
||||
});
|
||||
|
||||
it("looks for owner notifications without reading ordinary live or archived transitions", () => {
|
||||
const log = new QueueTransitionLog(db);
|
||||
const prepare = db.prepare.bind(db);
|
||||
let query = "";
|
||||
const spy = vi.spyOn(db, "prepare").mockImplementation((sql: string) => {
|
||||
if (sql.includes("ROW_NUMBER()") && sql.includes("owner_notification_level IS NOT NULL")) query = sql;
|
||||
return prepare(sql);
|
||||
});
|
||||
try { expect(log.ownerNotificationsForQitems(["q-0"])).toEqual(new Map()); }
|
||||
finally { spy.mockRestore(); }
|
||||
expect(query).not.toBe("");
|
||||
const plan = prepare(`EXPLAIN QUERY PLAN ${query}`).all(JSON.stringify(["q-0"])) as Array<{ detail: string }>;
|
||||
const details = plan.map((row) => row.detail).join("\n");
|
||||
expect(details).toContain("idx_queue_transitions_owner_notification_qitem");
|
||||
expect(details).toContain("idx_queue_transitions_archive_owner_notification_qitem");
|
||||
});
|
||||
|
||||
it("still applies both exact tags before the bound, including zero and unbounded limits", () => {
|
||||
for (const limit of [0, 1, 6, -1]) {
|
||||
const { ids } = selection({ rig: "r", tag: ["first", "second"], limit });
|
||||
expect(ids.every((id) => Number(id.slice(2)) % 7 === 0)).toBe(true);
|
||||
expect(ids).toHaveLength(limit < 0 ? 35 : Math.min(limit, 35));
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps recovery lookup on its small partial index when listing indexes exist", () => {
|
||||
const plan = db.prepare(`EXPLAIN QUERY PLAN ${RECOVERY_INDEX_SQL}`).all() as Array<{ detail: string }>;
|
||||
expect(plan.some((row) => row.detail.includes("idx_queue_items_recovery_tags"))).toBe(true);
|
||||
});
|
||||
|
||||
async function pointPlan(prefix: string, run: () => unknown | Promise<unknown>) {
|
||||
const prepare = db.prepare.bind(db);
|
||||
let selected: { sql: string; args: unknown[] } | undefined;
|
||||
const spy = vi.spyOn(db, "prepare").mockImplementation((sql: string) => {
|
||||
const statement = prepare(sql);
|
||||
if (sql.startsWith(prefix)) {
|
||||
const get = statement.get.bind(statement);
|
||||
statement.get = (...args: unknown[]) => { selected = { sql, args }; return get(...args); };
|
||||
}
|
||||
return statement;
|
||||
});
|
||||
try { await run(); } finally { spy.mockRestore(); }
|
||||
expect(selected).toBeDefined();
|
||||
const plan = prepare(`EXPLAIN QUERY PLAN ${selected!.sql}`).all(...selected!.args) as Array<{ detail: string }>;
|
||||
expect(plan.some((row) => row.detail.includes("idx_queue_items_recovery_tags"))).toBe(true);
|
||||
}
|
||||
|
||||
it("keeps standalone recovery hits and misses on the recovery partial index", async () => {
|
||||
db.prepare("UPDATE queue_items SET tags = ? WHERE qitem_id = 'q-0'").run(JSON.stringify(["recovery-for:subject"]));
|
||||
for (const subject of ["subject", "no-match"]) {
|
||||
for (const exclude of [undefined, "excluded"]) {
|
||||
await pointPlan("SELECT qitem_id, state, ts_updated FROM queue_items", () => {
|
||||
expect(findQueueRecovery(db, subject, exclude)?.qitemId ?? null).toBe(subject === "subject" ? "q-0" : null);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function sweepForPlan(state: "pending" | "in-progress") {
|
||||
db.exec("DELETE FROM queue_items");
|
||||
repo.attachOutbox(new OutboxHandler(db));
|
||||
if (state === "pending") {
|
||||
const rigs = new RigRepository(db);
|
||||
const rig = rigs.createRig("r");
|
||||
const node = rigs.addNode(rig.id, "worker", { runtime: "claude-code" });
|
||||
db.prepare("INSERT INTO sessions (id,node_id,session_name,status) VALUES ('plan-session',?,'worker@r','running')").run(node.id);
|
||||
}
|
||||
const past = new Date(Date.now() - 120 * 60_000).toISOString();
|
||||
const insert = db.prepare(`INSERT INTO queue_items
|
||||
(qitem_id, ts_created, ts_updated, source_session, destination_session, state, body, claimed_at, closure_required_at)
|
||||
VALUES (?, ?, ?, 'sender@r', 'worker@r', ?, 'work', ?, ?)`);
|
||||
for (let i = 0; i < (state === "pending" ? 3 : 1); i++) {
|
||||
insert.run(`candidate-${i}`, past, past, state, state === "in-progress" ? past : null, state === "in-progress" ? past : null);
|
||||
}
|
||||
return runStuckSweep({ db, queueRepo: repo, resolveOrchestrator: () => null, resolveRecoveryAgentSeat: () => null,
|
||||
resolveOperatorSeat: () => null, isRegisteredHost: () => false, unclaimedAgeMinutes: 60, log: () => {} });
|
||||
}
|
||||
|
||||
it("keeps the per-row finding lookup on the recovery partial index", async () => {
|
||||
await pointPlan("SELECT qitem_id, source_session, state, ts_updated FROM queue_items", () => sweepForPlan("in-progress"));
|
||||
});
|
||||
|
||||
it("keeps the latest backlog finding lookup on the recovery partial index", async () => {
|
||||
await pointPlan("SELECT qitem_id, state, ts_updated, summary, body, destination_session, tags FROM queue_items", () => sweepForPlan("pending"));
|
||||
});
|
||||
});
|
||||
@@ -96,8 +96,6 @@ describe("Slack sweep selection", () => {
|
||||
it("keeps a legacy schema without owner classification quiet", async () => {
|
||||
row("legacy");
|
||||
for (const table of ["queue_transitions", "queue_transitions_archive"]) {
|
||||
// The simulated pre-classification schema also predates its partial index.
|
||||
db.exec(`DROP INDEX idx_${table}_owner_notification_qitem`);
|
||||
db.exec(`ALTER TABLE ${table} DROP COLUMN owner_notification_level; ALTER TABLE ${table} DROP COLUMN owner_notification_kind`);
|
||||
}
|
||||
repo = new QueueRepository(db, new EventBus(db), { loadHumanRegistry: () => registry });
|
||||
|
||||
@@ -577,23 +577,6 @@ describe("OPR.0.4.6.WF2 — spec language", () => {
|
||||
).rejects.toMatchObject({ code: "harness_pin_unsatisfied" });
|
||||
});
|
||||
|
||||
it("validate warns that a harness pin is unchecked instead of reporting a clean result (#3087)", () => {
|
||||
const spec = parseWorkflowSpec(HARNESS_SPEC, "harness.yaml");
|
||||
const result = new WorkflowValidator().validate(spec);
|
||||
expect(result.ok).toBe(true);
|
||||
const unchecked = result.issues.filter((i) => i.code === "harness_pin_unchecked");
|
||||
expect(unchecked).toHaveLength(1);
|
||||
expect(unchecked[0]).toMatchObject({ severity: "warning", field: "workflow.steps[0].harness" });
|
||||
expect(unchecked[0]!.message).toContain('"codex"');
|
||||
expect(unchecked[0]!.message).toContain("no rig is bound");
|
||||
});
|
||||
|
||||
it("validate adds no pin warning to a spec without harness pins", () => {
|
||||
const spec = parseWorkflowSpec(LINEAR_SPEC, "linear.yaml");
|
||||
const result = new WorkflowValidator().validate(spec);
|
||||
expect(result.issues.some((i) => i.code === "harness_pin_unchecked")).toBe(false);
|
||||
});
|
||||
|
||||
it("steps with no pin resolve preferred_targets[0] unchanged (zero regression)", async () => {
|
||||
const specPath = writeSpec(tmp, "linear.yaml", LINEAR_SPEC);
|
||||
const result = await runtime.instantiate({
|
||||
|
||||
Reference in new issue
Block a user