Files
openrig/packages/daemon/src/domain/rig-repository.ts
T
Seán McNamara b1065870f6 feat(pi): switch accounts and preserve selection through the seat lifecycle (#2630)
rig provider switch --account-home and --clear-account-home select or clear an existing Pi seat's account home through a confirmed successor with a fresh conversation and handover; an unconfirmed or failed switch leaves the old account and state intact, and the selection survives restart, resume, restore and daemon restart (migration 151 keeps switch history and creates or copies no credentials). The chosen directory must already hold a readable auth.json, which the runner links into the seat's managed agent directory; env-only accounts and wrappers that create credentials later are outside this path, and an existing seat-local login or unmanaged auth link is preserved through the confirmed switch. Selected accounts load a Node preload that points only their auth-lock operations at the chosen account's auth.json.lock pathname, even when that auth.json is a symlink, and removes its own NODE_OPTIONS entry before Pi starts tools; outside the managed target it does nothing. A Node-hosted launch wrapper that execs Pi can consume the preload, so that Pi gets no shared-auth lock and keeps its native per-seat lock; compiled Pi and wrappers that discard NODE_OPTIONS are likewise not covered. Contributed by seanmcn; requested in #2629.
2026-11-06 23:52:50 -08:00

1349 lines
60 KiB
TypeScript

import { isEscalationTargetRef } from "./escalation-target-ref.js";
import { validateClaudePermissionRules } from "./claude-permission-rules.js";
import { validateClaudeMcp, type ClaudeMcp } from "./claude-mcp.js";
import { isLaunchWrapperRuntime, parseStoredLaunchWrapper, validateLaunchWrapper } from "./launch-wrapper.js";
import { validateCodexMemberPermissions } from "./codex-member-permissions.js";
import type Database from "better-sqlite3";
import { resolveActiveOccupantRow } from "./active-occupant.js";
import { decodeStoredSnapshotData } from "./snapshot-repository.js";
import { posix, resolve } from "node:path";
import { ulid } from "ulid";
import { deriveComposeProjectName } from "./compose-project-name.js";
import type { ClaudeManagedBlockFile } from "./managed-blocks.js";
import { mergeSeatEnv, parseStoredSeatEnv, type SeatEnv } from "./seat-env.js";
import { seatAccountHome, RUNTIME_HOME_VAR, withoutIgnoredRuntimeHomes } from "./seat-runtime-home.js";
import { claudeSeatTokenRefProblem, parseClaudeSeatTokenRef, type ClaudeSeatTokenRef } from "./claude-seat-token.js";
import type {
Rig,
Node,
Edge,
Binding,
NodeWithBinding,
RigWithRelations,
RigServicesRecord,
RigServicesRecordInput,
Snapshot,
SnapshotData,
SessionSourceSpec,
NodeLaunchRecipe,
StartupBlock,
StartupFile,
} from "./types.js";
function isValidStoredRecipeLaunchWrapper(value: unknown, runtime: unknown): boolean {
if (!isLaunchWrapperRuntime(runtime)) return false;
try {
validateLaunchWrapper(value);
return true;
} catch {
return false;
}
}
function isStringRecord(value: unknown): value is Record<string, string> {
return value !== null && typeof value === "object" && !Array.isArray(value)
&& Object.values(value).every((entry) => typeof entry === "string");
}
function isStoredSessionSource(value: unknown): boolean {
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
const source = value as Record<string, unknown>;
const ref = source.ref;
if (ref === null || typeof ref !== "object" || Array.isArray(ref)) return false;
const refRecord = ref as Record<string, unknown>;
if (source.mode === "fork") {
return ["native_id", "artifact_path", "name", "last"].includes(String(refRecord.kind))
&& (refRecord.value === undefined || typeof refRecord.value === "string");
}
if (source.mode === "rebuild") {
return refRecord.kind === "artifact_set"
&& Array.isArray(refRecord.value)
&& refRecord.value.length > 0
&& refRecord.value.every((entry) => typeof entry === "string" && entry.trim().length > 0);
}
if (source.mode === "agent_image") {
return refRecord.kind === "image_name"
&& typeof refRecord.value === "string"
&& refRecord.value.trim().length > 0
&& (refRecord.version === undefined || typeof refRecord.version === "string");
}
return false;
}
function isStoredStartupBlock(value: unknown): boolean {
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
const startup = value as Record<string, unknown>;
if (!Array.isArray(startup.files) || !Array.isArray(startup.actions)) return false;
const fileHints = ["auto", "guidance_merge", "skill_install", "send_text"];
const phases = ["fresh_start", "restore"];
const actionTypes = ["slash_command", "send_text", "startup_proof"];
const actionPhases = ["after_files", "after_ready"];
return startup.files.every((file) => {
if (file === null || typeof file !== "object" || Array.isArray(file)) return false;
const entry = file as Record<string, unknown>;
return typeof entry.path === "string"
&& fileHints.includes(String(entry.deliveryHint))
&& typeof entry.required === "boolean"
&& Array.isArray(entry.appliesOn)
&& entry.appliesOn.every((phase) => typeof phase === "string" && phases.includes(phase))
&& (entry.orientation === undefined || entry.orientation === "role")
&& (entry.kind === undefined || entry.kind === "file");
}) && startup.actions.every((action) => {
if (action === null || typeof action !== "object" || Array.isArray(action)) return false;
const entry = action as Record<string, unknown>;
return actionTypes.includes(String(entry.type))
&& typeof entry.value === "string"
&& actionPhases.includes(String(entry.phase))
&& Array.isArray(entry.appliesOn)
&& entry.appliesOn.every((phase) => typeof phase === "string" && phases.includes(phase))
&& typeof entry.idempotent === "boolean"
&& (entry.builtin === undefined || entry.builtin === "session_identity");
});
}
function isStoredContinuityPolicy(value: unknown): boolean {
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
const policy = value as Record<string, unknown>;
if (typeof policy.enabled !== "boolean") return false;
if (policy.syncTriggers !== undefined
&& (!Array.isArray(policy.syncTriggers) || !policy.syncTriggers.every((entry) => typeof entry === "string"))) return false;
for (const field of ["artifacts", "restoreProtocol"] as const) {
const nested = policy[field];
if (nested !== undefined) {
if (nested === null || typeof nested !== "object" || Array.isArray(nested)) return false;
if (!Object.values(nested as Record<string, unknown>).every((entry) => typeof entry === "boolean")) return false;
}
}
return true;
}
function isStoredWorkspace(value: unknown): boolean {
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
const workspace = value as Record<string, unknown>;
if (typeof workspace.workspaceRoot !== "string" || !Array.isArray(workspace.repos)) return false;
if (workspace.defaultRepo !== undefined && typeof workspace.defaultRepo !== "string") return false;
if (workspace.knowledgeRoot !== undefined && typeof workspace.knowledgeRoot !== "string") return false;
return workspace.repos.every((repo) => {
if (repo === null || typeof repo !== "object" || Array.isArray(repo)) return false;
const entry = repo as Record<string, unknown>;
return typeof entry.name === "string" && typeof entry.path === "string" && typeof entry.kind === "string";
});
}
function isStoredServices(value: unknown): boolean {
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
const services = value as Record<string, unknown>;
if (services.kind !== "compose" || typeof services.composeFile !== "string") return false;
if (services.projectName !== undefined && typeof services.projectName !== "string") return false;
if (services.profiles !== undefined
&& (!Array.isArray(services.profiles) || !services.profiles.every((entry) => typeof entry === "string"))) return false;
if (services.downPolicy !== undefined
&& !["leave_running", "down", "down_and_volumes"].includes(String(services.downPolicy))) return false;
if (services.waitFor !== undefined && !Array.isArray(services.waitFor)) return false;
if (services.surfaces !== undefined
&& (services.surfaces === null || typeof services.surfaces !== "object" || Array.isArray(services.surfaces))) return false;
if (services.checkpoints !== undefined && !Array.isArray(services.checkpoints)) return false;
return true;
}
function isStoredRigSpec(value: unknown): value is NodeLaunchRecipe["rigSpec"] {
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
const spec = value as Record<string, unknown>;
if (typeof spec.version !== "string" || typeof spec.name !== "string" || !Array.isArray(spec.pods)) return false;
if (spec.cultureFile !== undefined && typeof spec.cultureFile !== "string") return false;
if (spec.nonInterruptive !== undefined && typeof spec.nonInterruptive !== "boolean") return false;
if (spec.permissionPolicy !== undefined && typeof spec.permissionPolicy !== "string") return false;
if (spec.env !== undefined && !isStringRecord(spec.env)) return false;
if (spec.managedBlocks !== undefined) {
if (spec.managedBlocks === null || typeof spec.managedBlocks !== "object" || Array.isArray(spec.managedBlocks)) return false;
const managedBlocks = spec.managedBlocks as Record<string, unknown>;
if (managedBlocks["claude-code"] !== undefined
&& !["CLAUDE.md", "CLAUDE.local.md"].includes(String(managedBlocks["claude-code"]))) return false;
}
if (spec.startup !== undefined && !isStoredStartupBlock(spec.startup)) return false;
if (spec.services !== undefined && !isStoredServices(spec.services)) return false;
if (spec.workspace !== undefined && !isStoredWorkspace(spec.workspace)) return false;
return spec.pods.every((pod) => {
if (pod === null || typeof pod !== "object" || Array.isArray(pod)) return false;
const candidate = pod as Record<string, unknown>;
if (typeof candidate.id !== "string" || typeof candidate.label !== "string"
|| !Array.isArray(candidate.members) || candidate.members.length === 0) return false;
if (candidate.continuityPolicy !== undefined && !isStoredContinuityPolicy(candidate.continuityPolicy)) return false;
if (candidate.startup !== undefined && !isStoredStartupBlock(candidate.startup)) return false;
return candidate.members.every((member) => {
if (member === null || typeof member !== "object" || Array.isArray(member)) return false;
const entry = member as Record<string, unknown>;
return typeof entry.id === "string"
&& typeof entry.agentRef === "string"
&& typeof entry.profile === "string"
&& typeof entry.runtime === "string"
&& typeof entry.cwd === "string"
&& (entry.label === undefined || typeof entry.label === "string")
&& (entry.codexConfigProfile === undefined || typeof entry.codexConfigProfile === "string")
&& (entry.model === undefined || typeof entry.model === "string")
&& (entry.effort === undefined || typeof entry.effort === "string")
&& (entry.role === undefined || typeof entry.role === "string")
&& (entry.advisor === undefined || typeof entry.advisor === "string")
&& (entry.claudeMcp === undefined || (entry.runtime === "claude-code" && entry.claudeMcp === "none"))
&& (entry.launchWrapper === undefined || isValidStoredRecipeLaunchWrapper(entry.launchWrapper, entry.runtime))
&& (entry.agentFile === undefined || typeof entry.agentFile === "string")
&& (entry.appendSystemPromptFile === undefined || typeof entry.appendSystemPromptFile === "string")
&& (entry.pluginDirs === undefined
|| (Array.isArray(entry.pluginDirs) && entry.pluginDirs.every((dir) => typeof dir === "string")))
&& (entry.pluginChannels === undefined
|| (Array.isArray(entry.pluginChannels) && entry.pluginChannels.every((channel) => typeof channel === "string")))
&& (entry.permissionPolicy === undefined || typeof entry.permissionPolicy === "string")
&& (entry.env === undefined || isStringRecord(entry.env))
&& (entry.restorePolicy === undefined || typeof entry.restorePolicy === "string")
&& (entry.compactionStrategy === undefined || typeof entry.compactionStrategy === "string")
&& (entry.mechanic === undefined || typeof entry.mechanic === "string")
&& (entry.startup === undefined || isStoredStartupBlock(entry.startup))
&& (entry.sessionSource === undefined || isStoredSessionSource(entry.sessionSource))
&& (entry.starterRef === undefined
|| (entry.starterRef !== null && typeof entry.starterRef === "object" && !Array.isArray(entry.starterRef)
&& typeof (entry.starterRef as Record<string, unknown>).name === "string"));
});
});
}
/** A rig's own startup layers: its `culture_file` and rig `startup` block as declared, and the absolute root they resolve against.
* `podStartupFiles` holds each pod's declared startup files by pod id, resolved against the same root; a seat added to an
* existing pod inherits them. Files only: pod startup actions are not inherited. */
export interface RigStartupLayers {
root: string;
cultureFile?: string;
startup?: StartupBlock;
podStartupFiles?: Record<string, StartupFile[]>;
/** Export-only authored actions, never inherited by seats added later. */
podStartupActions?: Record<string, StartupBlock["actions"]>;
/** Validated declarations for seats created without a first launch. One source per materialization. */
materializedSources?: Array<{ root: string; spec: import("./types.js").RigSpec; nodeIds: string[]; cwdOverride?: string }>;
}
/**
* Free helper: returns the latest snapshot for a rig whose data carries non-null
* `resume_token` for at least one persisted session, or null otherwise.
*
* Used by node-inventory and ps-projection to derive `lifecycleState=recoverable`
* post-L1 cold-start. Distinct from `findLatestAutoPreDown`, which filters by kind.
*/
interface SnapshotRow { id: string; rig_id: string; kind: string; status: string; data: string; created_at: string }
/** Shared row → usable-Snapshot mapping (single source of truth for the parse +
* the ≥1-resume-token usability rule), used by both the single-rig and the
* FS-1 W1.2 all-rigs batched reads so their semantics are byte-identical. */
function snapshotFromRowIfUsable(row: SnapshotRow): Snapshot | null {
let data: SnapshotData;
try {
data = decodeStoredSnapshotData(row.data);
} catch {
return null;
}
// OPR.0.5.7.1 — usability is OCCUPANT truth, not any-historical-row truth:
// a snapshot is usable iff at least one node RESOLVES to an active occupant
// carrying a token (the same four-way ladder execution consumes). A
// present-null/missing-key/dangling relation never counts — a historical
// token on a non-occupant row must not read as recoverable.
const sessions = data.sessions ?? [];
const nodeIds = [...new Set(sessions.map((s) => s.nodeId))];
const hasResolvedOccupantToken = nodeIds.some((nodeId) => {
const resolution = resolveActiveOccupantRow(sessions, data.activeSessionIdByNode, nodeId);
return resolution.kind === "resolved"
&& typeof resolution.session.resumeToken === "string"
&& resolution.session.resumeToken.length > 0;
});
if (!hasResolvedOccupantToken) return null;
return {
id: row.id,
rigId: row.rig_id,
kind: row.kind,
status: row.status,
data,
createdAt: row.created_at,
};
}
export function findLatestUsableSnapshot(db: Database.Database, rigId: string): Snapshot | null {
const row = db.prepare(
"SELECT * FROM snapshots WHERE rig_id = ? ORDER BY created_at DESC, id DESC LIMIT 1"
).get(rigId) as SnapshotRow | undefined;
if (!row) return null;
return snapshotFromRowIfUsable(row);
}
/**
* FS-1 W1.2 — the all-rigs batched form of `findLatestUsableSnapshot`: the
* latest snapshot per rig in ONE query (same `created_at DESC, id DESC` order),
* each mapped through the shared `snapshotFromRowIfUsable`. Rigs with no usable
* snapshot are simply absent from the map (callers default to null). Rides the
* arch-D1.1 sibling-audit `snapshots(rig_id, created_at)` index candidate.
*/
export function findLatestUsableSnapshotsForAllRigs(db: Database.Database): Map<string, Snapshot> {
// One latest-snapshot lookup per rig with snapshots (from the index), not one per snapshot row.
const rows = db.prepare(`
SELECT s.* FROM (SELECT DISTINCT rig_id FROM snapshots) r
JOIN snapshots s ON s.id = (
SELECT s2.id FROM snapshots s2 WHERE s2.rig_id = r.rig_id
ORDER BY s2.created_at DESC, s2.id DESC LIMIT 1
)
`).all() as SnapshotRow[];
const out = new Map<string, Snapshot>();
for (const row of rows) {
const snap = snapshotFromRowIfUsable(row);
if (snap) out.set(row.rig_id, snap);
}
return out;
}
/**
* OPR.0.3.3.19 - rig archive read filter. Default daemon reads EXCLUDE archived
* rigs; explicit modes opt in. Mirrors the stream-items `--include-archived`
* precedent (stream-store.list).
*/
export interface RigArchiveFilter {
/** include both active and archived rigs */
includeArchived?: boolean;
/** return ONLY archived rigs */
archivedOnly?: boolean;
}
/**
* SQL condition fragment for the archive filter on a column (e.g. "archived_at"
* or the aliased "r.archived_at"). Returns null when no filter applies
* (includeArchived = include everything). Default (no filter) excludes archived.
*/
export function archiveWhereClause(col: string, filter?: RigArchiveFilter): string | null {
if (filter?.archivedOnly) return `${col} IS NOT NULL`;
if (filter?.includeArchived) return null;
return `${col} IS NULL`;
}
interface NodeOptions {
role?: string;
runtime?: string;
model?: string;
effort?: string;
advisor?: string;
codexConfigProfile?: string;
/** OPR.0.4.8.3 Seam B: per-seat permission_policy REF (builtin:<name> or spec-relative path). */
permissionPolicy?: string;
claudePermissions?: import("./claude-permission-rules.js").ClaudePermissionRules;
/** A Claude seat's own subscription token file, by reference (spec `claude_token`). */
claudeTokenRef?: ClaudeSeatTokenRef;
claudeMcp?: ClaudeMcp;
launchWrapper?: readonly string[] | null;
codexPermissions?: import("./codex-member-permissions.js").CodexMemberPermissions;
/** The member's declared launch environment (spec `env:`); not merged with the rig's. */
env?: SeatEnv;
cwd?: string;
surfaceHint?: string;
workspace?: string;
restorePolicy?: string;
packageRefs?: string[];
podId?: string;
agentRef?: string;
profile?: string;
label?: string;
sessionSource?: SessionSourceSpec;
resolvedSpecName?: string;
resolvedSpecVersion?: string;
resolvedSpecHash?: string;
/** Validated pod-aware source used to rebuild a fresh launch after restart. */
launchRecipe?: NodeLaunchRecipe;
}
export class RigRepository {
readonly db: Database.Database;
/** OPR.0.5.6.24 — born-armed (advisor-ruled birth-property principle):
* protection arms in the same act that creates the rig. Wired at startup;
* optional so repo construction stays dependency-free. */
onRigCreated?: (rig: Rig) => void;
constructor(db: Database.Database) {
this.db = db;
}
createRig(name: string, installRoot?: string): Rig {
const id = ulid();
if (installRoot !== undefined) {
this.db.prepare("INSERT INTO rigs (id, name, install_root) VALUES (?, ?, ?)")
.run(id, name, installRoot);
} else {
this.db.prepare("INSERT INTO rigs (id, name) VALUES (?, ?)").run(id, name);
}
const rig = this.rowToRig(
this.db.prepare("SELECT * FROM rigs WHERE id = ?").get(id) as RigRow
);
this.onRigCreated?.(rig);
return rig;
}
/** Original built-in kernel template from the rig's latest rig-spec bootstrap, including partial launches.
* Match the verified specs root supplied by daemon startup, not an arbitrary matching path suffix.
* A custom spec or absent history is unknown; do not infer a selection from today's runtime mix. */
getKernelVariantFromBootstrap(rigId: string, builtInSpecsDir: string): string | null {
const row = this.db.prepare(
"SELECT source_ref FROM bootstrap_runs WHERE rig_id = ? AND source_kind = 'rig_spec' ORDER BY created_at DESC, rowid DESC LIMIT 1"
).get(rigId) as { source_ref: string } | undefined;
if (!row) return null;
const source = posix.normalize(row.source_ref.replace(/\\/g, "/"));
const root = posix.normalize(builtInSpecsDir.replace(/\\/g, "/"));
if (!posix.isAbsolute(root) && !/^[A-Za-z]:\//.test(root)) return null;
const directory = root.replace(/\/+$/, "") + "/rigs/launch/kernel/";
if (!source.startsWith(directory)) return null;
const variant = source.slice(directory.length);
return /^rig(?:-claude-only|-codex-only)?\.yaml$/.test(variant) ? variant : null;
}
/** Canonical bundle materialization folder, independent of seats' working directories. */
getRigInstallRoot(rigId: string): string | null {
const row = this.db.prepare("SELECT * FROM rigs WHERE id = ?").get(rigId) as
{ install_root?: string | null } | undefined;
return row?.install_root ?? null;
}
/**
* PL-007 Workspace Primitive — persist the typed workspace block on the
* rigs row. Stored as JSON in `workspace_json` (migration 038). Pass
* null to clear. Older test fixtures that bypass the canonical migration
* list don't have the column; setter is a no-op in that case (the
* caller's contract is "best-effort persistence"). Whoami / node-inventory
* read this column to surface the rig's workspace block alongside cwd.
*/
setRigWorkspace(rigId: string, workspace: import("./types.js").WorkspaceSpec | null): void {
if (!this.hasRigColumn("workspace_json")) return;
const json = workspace ? JSON.stringify(workspace) : null;
this.db.prepare("UPDATE rigs SET workspace_json = ?, updated_at = ? WHERE id = ?")
.run(json, new Date().toISOString(), rigId);
}
/** PL-007 — read the persisted workspace block for a rig. */
getRigWorkspace(rigId: string): import("./types.js").WorkspaceSpec | null {
if (!this.hasRigColumn("workspace_json")) return null;
const row = this.db.prepare("SELECT workspace_json FROM rigs WHERE id = ?")
.get(rigId) as { workspace_json: string | null } | undefined;
if (!row || !row.workspace_json) return null;
try {
return JSON.parse(row.workspace_json) as import("./types.js").WorkspaceSpec;
} catch {
return null;
}
}
setRigNonInterruptive(rigId: string, enabled: boolean): void {
if (!this.hasRigColumn("non_interruptive")) return;
this.db.prepare("UPDATE rigs SET non_interruptive = ?, updated_at = ? WHERE id = ?")
.run(enabled ? 1 : 0, new Date().toISOString(), rigId);
}
getRigNonInterruptive(rigId: string): boolean {
if (!this.hasRigColumn("non_interruptive")) return false;
const row = this.db.prepare("SELECT non_interruptive FROM rigs WHERE id = ?")
.get(rigId) as { non_interruptive: number } | undefined;
return row?.non_interruptive === 1;
}
/** Persist the rig-level `env:` block from the spec, or null to clear. */
setRigEnv(rigId: string, env: SeatEnv | null): void {
if (!this.hasRigColumn("env_json")) return;
this.db.prepare("UPDATE rigs SET env_json = ?, updated_at = ? WHERE id = ?")
.run(env ? JSON.stringify(env) : null, new Date().toISOString(), rigId);
}
getRigEnv(rigId: string): SeatEnv | null {
if (!this.hasRigColumn("env_json")) return null;
const row = this.db.prepare("SELECT env_json FROM rigs WHERE id = ?")
.get(rigId) as { env_json: string | null } | undefined;
return parseStoredSeatEnv(row?.env_json);
}
/** What a seat's process gets: its rig's `env:` with the member's own `env:` over it. A launch,
* a restore and a handover successor all read this, so they cannot disagree. A CLAUDE_CONFIG_DIR or
* CODEX_HOME that isn't an absolute path is left out: OpenRig reports it as ignored (seat-runtime-home.ts),
* so the runtime resolves as if the seat declared none. */
getEffectiveSeatEnv(nodeId: string, ignoreAccountHome = false): SeatEnv {
if (!this.hasNodeColumn("env_json")) return {};
const row = this.db.prepare("SELECT rig_id, env_json FROM nodes WHERE id = ?")
.get(nodeId) as { rig_id: string; env_json: string | null } | undefined;
if (!row) return {};
const env = withoutIgnoredRuntimeHomes(mergeSeatEnv(this.getRigEnv(row.rig_id), parseStoredSeatEnv(row.env_json)));
for (const runtime of ignoreAccountHome ? [] : ["codex", "claude-code", "pi"] as const) {
const accountHome = seatAccountHome(this.db, nodeId, runtime);
if (accountHome) env[RUNTIME_HOME_VAR[runtime]] = accountHome;
}
return env;
}
/** OPR.0.4.8.3 Seam B — persist a rig's attached permission_policy REF (builtin:<name> or a
* spec-relative custom path), or null to clear. Mirrors setRigWorkspace (migration 056). */
setRigPermissionPolicy(rigId: string, permissionPolicy: string | null): void {
if (!this.hasRigColumn("permission_policy")) return;
this.db.prepare("UPDATE rigs SET permission_policy = ?, updated_at = ? WHERE id = ?")
.run(permissionPolicy ?? null, new Date().toISOString(), rigId);
}
/** OPR.0.4.8.3 Seam B — read the persisted rig-level permission_policy REF (null when none). */
getRigPermissionPolicy(rigId: string): string | null {
if (!this.hasRigColumn("permission_policy")) return null;
const row = this.db.prepare("SELECT permission_policy FROM rigs WHERE id = ?")
.get(rigId) as { permission_policy: string | null } | undefined;
return row?.permission_policy ?? null;
}
/** #25 — persist the rig's selected Claude managed-block file (migration 085), or null for
* the CLAUDE.md default. Mirrors setRigPermissionPolicy. */
setRigClaudeManagedBlockFile(rigId: string, file: ClaudeManagedBlockFile | null): void {
if (!this.hasRigColumn("claude_managed_block_file")) return;
this.db.prepare("UPDATE rigs SET claude_managed_block_file = ?, updated_at = ? WHERE id = ?")
.run(file ?? null, new Date().toISOString(), rigId);
}
/** The rig's `culture_file` and rig `startup` block as its spec declared them, and the absolute root their paths
* resolve against (migration 121). A seat added to the rig later reads them back. Null when neither was declared. */
setRigStartupLayers(rigId: string, layers: RigStartupLayers | null): void {
if (!this.hasRigColumn("startup_layers_json")) return;
this.db.prepare("UPDATE rigs SET startup_layers_json = ?, updated_at = ? WHERE id = ?")
.run(layers ? JSON.stringify(layers) : null, new Date().toISOString(), rigId);
}
getRigStartupLayers(rigId: string): RigStartupLayers | null {
if (!this.hasRigColumn("startup_layers_json")) return null;
const row = this.db.prepare("SELECT startup_layers_json FROM rigs WHERE id = ?")
.get(rigId) as { startup_layers_json: string | null } | undefined;
if (!row?.startup_layers_json) return null;
try {
const parsed = JSON.parse(row.startup_layers_json) as Partial<RigStartupLayers>;
return typeof parsed.root === "string" ? parsed as RigStartupLayers : null;
} catch {
return null;
}
}
/** #25 — the rig's selected Claude managed-block file, or null when it uses the default. */
getRigClaudeManagedBlockFile(rigId: string): ClaudeManagedBlockFile | null {
if (!this.hasRigColumn("claude_managed_block_file")) return null;
const row = this.db.prepare("SELECT claude_managed_block_file FROM rigs WHERE id = ?")
.get(rigId) as { claude_managed_block_file: ClaudeManagedBlockFile | null } | undefined;
return row?.claude_managed_block_file ?? null;
}
/** Seam B Guard-F1 — persist the RIG-level resolved attachment provenance (migration 058).
* declaringDir = the ORIGINAL declaring RigSpec dir; consumers must never re-resolve the
* raw relative ref against an unrelated operation root. No-op on pre-058 fixture DBs. */
setRigPolicyProvenance(
rigId: string,
provenance: {
origin: "builtin" | "custom" | "deliberate_none";
resolvedTarget: string | null;
declaringDir: string | null;
launchPosture: "floor" | "full_bypass" | "auto";
},
): void {
if (!this.hasRigColumn("rig_policy_launch_posture")) return;
this.db.prepare(
"UPDATE rigs SET rig_policy_origin = ?, rig_policy_resolved_target = ?, rig_policy_declaring_dir = ?, rig_policy_launch_posture = ?, updated_at = ? WHERE id = ?",
).run(provenance.origin, provenance.resolvedTarget, provenance.declaringDir, provenance.launchPosture, new Date().toISOString(), rigId);
}
/** Seam B Guard-F1 — read the rig-level resolved attachment provenance (null when none). */
getRigPolicyProvenance(rigId: string): {
origin: "builtin" | "custom" | "deliberate_none";
resolvedTarget: string | null;
declaringDir: string | null;
launchPosture: "floor" | "full_bypass" | "auto";
/** the raw rig ref (056) alongside, for re-validation */
rigRef: string | null;
} | null {
if (!this.hasRigColumn("rig_policy_launch_posture")) return null;
const row = this.db.prepare(
"SELECT permission_policy, rig_policy_origin, rig_policy_resolved_target, rig_policy_declaring_dir, rig_policy_launch_posture FROM rigs WHERE id = ?",
).get(rigId) as {
permission_policy: string | null;
rig_policy_origin: string | null;
rig_policy_resolved_target: string | null;
rig_policy_declaring_dir: string | null;
rig_policy_launch_posture: string | null;
} | undefined;
if (!row || row.rig_policy_origin == null || row.rig_policy_launch_posture == null) return null;
return {
origin: row.rig_policy_origin as "builtin" | "custom" | "deliberate_none",
resolvedTarget: row.rig_policy_resolved_target,
declaringDir: row.rig_policy_declaring_dir,
launchPosture: row.rig_policy_launch_posture as "floor" | "full_bypass" | "auto",
rigRef: row.permission_policy,
};
}
/** OPR.0.4.8.3 Seam B (R2, dev-guard ruling) — persist a node's RESOLVED policy attachment
* provenance (restart-stable: origin + resolved target + declaring dir + launch posture).
* Builtins carry resolvedTarget=null until the packaging leg's canonical path is ruled
* (PM lane c76c7153) — never a `builtin:<name>` echo. No-op on pre-057 fixture DBs. */
setNodePolicyProvenance(
nodeId: string,
provenance: {
origin: "builtin" | "custom" | "deliberate_none";
resolvedTarget: string | null;
declaringDir: string | null;
launchPosture: "floor" | "full_bypass" | "auto";
},
): void {
if (!this.hasNodeColumn("policy_launch_posture")) return;
this.db.prepare(
"UPDATE nodes SET policy_origin = ?, policy_resolved_target = ?, policy_declaring_dir = ?, policy_launch_posture = ? WHERE id = ?",
).run(provenance.origin, provenance.resolvedTarget, provenance.declaringDir, provenance.launchPosture, nodeId);
}
/** Re-point an EXISTING seat's own permission_policy REF (`rig policy apply --seat`); the caller
* writes its resolved provenance through setNodePolicyProvenance in the same transaction. Restore,
* resume and launches that read the stored seat use that provenance. A later launch from a spec that
* declares a policy rewrites the provenance but not this ref, so the two can then differ: this ref
* is what `rig export` emits, not necessarily the policy the seat launched with. */
setNodePermissionPolicy(nodeId: string, permissionPolicy: string): void {
if (!this.hasNodeColumn("permission_policy")) return;
this.db.prepare("UPDATE nodes SET permission_policy = ? WHERE id = ?").run(permissionPolicy, nodeId);
}
/** Seam B (R2) — read a node's persisted policy provenance; null when none attached
* (or pre-057 DB). Restore consumers re-derive posture from this without the spec. */
getNodePolicyProvenance(nodeId: string): {
origin: "builtin" | "custom" | "deliberate_none";
resolvedTarget: string | null;
declaringDir: string | null;
launchPosture: "floor" | "full_bypass" | "auto";
/** The node's own raw ref (member-level; null when the attachment came from the rig). */
nodeRef: string | null;
} | null {
if (!this.hasNodeColumn("policy_launch_posture")) return null;
const row = this.db.prepare(
"SELECT permission_policy, policy_origin, policy_resolved_target, policy_declaring_dir, policy_launch_posture FROM nodes WHERE id = ?",
).get(nodeId) as {
permission_policy: string | null;
policy_origin: string | null;
policy_resolved_target: string | null;
policy_declaring_dir: string | null;
policy_launch_posture: string | null;
} | undefined;
if (!row || row.policy_origin == null || row.policy_launch_posture == null) return null;
return {
origin: row.policy_origin as "builtin" | "custom" | "deliberate_none",
resolvedTarget: row.policy_resolved_target,
declaringDir: row.policy_declaring_dir,
launchPosture: row.policy_launch_posture as "floor" | "full_bypass" | "auto",
nodeRef: row.permission_policy,
};
}
/** PL-007 — defensive column probe on rigs (migration 038's
* workspace_json is absent in legacy test fixtures). */
private hasRigColumn(columnName: string): boolean {
try {
return this.db.prepare("PRAGMA table_info(rigs)").all()
.some((row) => (row as { name?: string }).name === columnName);
} catch {
return false;
}
}
addNode(rigId: string, logicalId: string, opts?: NodeOptions): Node {
if (opts?.claudeMcp !== undefined) {
if (opts.runtime !== "claude-code") throw new Error("claude_mcp requires a claude-code seat");
validateClaudeMcp(opts.claudeMcp);
if (!this.db.inTransaction) return this.db.transaction(() => this.addNode(rigId, logicalId, opts))();
}
if (opts?.launchWrapper !== undefined && opts.launchWrapper !== null) {
if (!isLaunchWrapperRuntime(opts.runtime)) throw new Error("launch_wrapper requires a pi or codex seat");
validateLaunchWrapper(opts.launchWrapper);
if (!this.db.inTransaction) return this.db.transaction(() => this.addNode(rigId, logicalId, opts))();
}
if (opts?.claudePermissions !== undefined) {
if (opts.runtime !== "claude-code") throw new Error("claude_permissions requires a claude-code seat");
validateClaudePermissionRules(opts.claudePermissions);
// Required rule persistence is atomic with creation, including direct repository callers.
if (!this.db.inTransaction) return this.db.transaction(() => this.addNode(rigId, logicalId, opts))();
}
if (opts?.codexPermissions !== undefined) {
if (opts.runtime !== "codex") throw new Error("codex_permissions requires a codex seat");
validateCodexMemberPermissions(opts.codexPermissions);
// The envelope is written atomically with creation, including direct repository callers.
if (!this.db.inTransaction) return this.db.transaction(() => this.addNode(rigId, logicalId, opts))();
}
if (opts?.claudeTokenRef !== undefined) {
if (opts.runtime !== "claude-code") throw new Error("claude_token requires a claude-code seat");
const problem = claudeSeatTokenRefProblem(opts.claudeTokenRef);
if (problem) throw new Error(`claude_token: ${problem}`);
// The reference is written atomically with creation, including direct repository callers.
if (!this.db.inTransaction) return this.db.transaction(() => this.addNode(rigId, logicalId, opts))();
}
// Same-rig guard for podId
if (opts?.podId) {
const pod = this.db.prepare("SELECT rig_id FROM pods WHERE id = ?").get(opts.podId) as { rig_id: string } | undefined;
if (!pod) throw new Error(`Pod not found: ${opts.podId}`);
if (pod.rig_id !== rigId) throw new Error("Pod belongs to a different rig");
}
const id = ulid();
if (this.hasNodeColumn("codex_config_profile") && this.hasNodeColumn("permission_policy")) {
// OPR.0.4.8.3 Seam B: both ALTER-added optional columns present (migrations 022 + 055; 055
// runs after 022, so a permission_policy column implies a codex_config_profile column).
this.db
.prepare(
`INSERT INTO nodes (id, rig_id, logical_id, role, runtime, model, codex_config_profile, permission_policy, cwd, surface_hint, workspace, restore_policy, package_refs,
pod_id, agent_ref, profile, label, resolved_spec_name, resolved_spec_version, resolved_spec_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
rigId,
logicalId,
opts?.role ?? null,
opts?.runtime ?? null,
opts?.model ?? null,
opts?.codexConfigProfile ?? null,
opts?.permissionPolicy ?? null,
opts?.cwd ?? null,
opts?.surfaceHint ?? null,
opts?.workspace ?? null,
opts?.restorePolicy ?? null,
opts?.packageRefs ? JSON.stringify(opts.packageRefs) : null,
opts?.podId ?? null,
opts?.agentRef ?? null,
opts?.profile ?? null,
opts?.label ?? null,
opts?.resolvedSpecName ?? null,
opts?.resolvedSpecVersion ?? null,
opts?.resolvedSpecHash ?? null,
);
} else if (this.hasNodeColumn("codex_config_profile")) {
this.db
.prepare(
`INSERT INTO nodes (id, rig_id, logical_id, role, runtime, model, codex_config_profile, cwd, surface_hint, workspace, restore_policy, package_refs,
pod_id, agent_ref, profile, label, resolved_spec_name, resolved_spec_version, resolved_spec_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
rigId,
logicalId,
opts?.role ?? null,
opts?.runtime ?? null,
opts?.model ?? null,
opts?.codexConfigProfile ?? null,
opts?.cwd ?? null,
opts?.surfaceHint ?? null,
opts?.workspace ?? null,
opts?.restorePolicy ?? null,
opts?.packageRefs ? JSON.stringify(opts.packageRefs) : null,
opts?.podId ?? null,
opts?.agentRef ?? null,
opts?.profile ?? null,
opts?.label ?? null,
opts?.resolvedSpecName ?? null,
opts?.resolvedSpecVersion ?? null,
opts?.resolvedSpecHash ?? null,
);
} else {
this.db
.prepare(
`INSERT INTO nodes (id, rig_id, logical_id, role, runtime, model, cwd, surface_hint, workspace, restore_policy, package_refs,
pod_id, agent_ref, profile, label, resolved_spec_name, resolved_spec_version, resolved_spec_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
rigId,
logicalId,
opts?.role ?? null,
opts?.runtime ?? null,
opts?.model ?? null,
opts?.cwd ?? null,
opts?.surfaceHint ?? null,
opts?.workspace ?? null,
opts?.restorePolicy ?? null,
opts?.packageRefs ? JSON.stringify(opts.packageRefs) : null,
opts?.podId ?? null,
opts?.agentRef ?? null,
opts?.profile ?? null,
opts?.label ?? null,
opts?.resolvedSpecName ?? null,
opts?.resolvedSpecVersion ?? null,
opts?.resolvedSpecHash ?? null,
);
}
if (opts?.claudeTokenRef !== undefined && !this.setNodeClaudeTokenRef(id, opts.claudeTokenRef)) {
throw new Error("claude_token needs the current database schema; the seat wasn't created");
}
if (opts?.claudePermissions !== undefined) {
if (opts.runtime !== "claude-code") throw new Error("claude_permissions requires a claude-code seat");
this.db.prepare("UPDATE nodes SET claude_permissions_json = ? WHERE id = ?")
.run(JSON.stringify(validateClaudePermissionRules(opts.claudePermissions)), id);
}
if (opts?.codexPermissions !== undefined) {
if (opts.runtime !== "codex") throw new Error("codex_permissions requires a codex seat");
this.db.prepare("UPDATE nodes SET codex_permissions_json = ? WHERE id = ?")
.run(JSON.stringify(validateCodexMemberPermissions(opts.codexPermissions)), id);
}
if (opts?.sessionSource && this.hasNodeColumn("session_source_json")) {
this.db.prepare("UPDATE nodes SET session_source_json = ? WHERE id = ?")
.run(JSON.stringify(opts.sessionSource), id);
}
if (opts?.effort && this.hasNodeColumn("effort")) {
this.db.prepare("UPDATE nodes SET effort = ? WHERE id = ?")
.run(opts.effort, id);
}
if (opts?.advisor) this.setNodeAdvisor(id, opts.advisor);
if (opts?.claudeMcp !== undefined) this.setNodeClaudeMcp(id, opts.claudeMcp);
if (opts?.launchWrapper !== undefined && opts.launchWrapper !== null && opts.launchWrapper.length > 0) {
this.setNodeLaunchWrapper(id, opts.launchWrapper);
}
if (opts?.env && this.hasNodeColumn("env_json")) {
this.db.prepare("UPDATE nodes SET env_json = ? WHERE id = ?")
.run(JSON.stringify(opts.env), id);
}
if (opts?.launchRecipe && this.hasNodeColumn("launch_recipe_json")) {
this.db.prepare("UPDATE nodes SET launch_recipe_json = ? WHERE id = ?")
.run(JSON.stringify(opts.launchRecipe), id);
}
return this.rowToNode(
this.db.prepare("SELECT * FROM nodes WHERE id = ?").get(id) as NodeRow
);
}
/** Return the durable source for rebuilding a pod-aware seat, if available. */
getNodeLaunchRecipe(nodeId: string): NodeLaunchRecipe | null {
if (!this.hasNodeColumn("launch_recipe_json")) return null;
const row = this.db.prepare("SELECT launch_recipe_json FROM nodes WHERE id = ?").get(nodeId) as
{ launch_recipe_json?: string | null } | undefined;
if (!row?.launch_recipe_json) return null;
try {
const recipe = JSON.parse(row.launch_recipe_json) as Partial<NodeLaunchRecipe>;
if (recipe.version !== 1 || typeof recipe.rigRoot !== "string" || !recipe.rigSpec) return null;
if (!isStoredRigSpec(recipe.rigSpec)) return null;
return recipe as NodeLaunchRecipe;
} catch {
return null;
}
}
/** S5 (OPR.0.5.4.7) — the first supported nodes.model write (the inventory was insert-only;
* KI-5.3-9). Every managed resume/successor launch reads node.model at call time, so this
* UPDATE is the complete persistence half of set-model. Returns whether a row changed. */
setNodeModel(nodeId: string, model: string): boolean {
const result = this.db
.prepare("UPDATE nodes SET model = ? WHERE id = ?")
.run(model, nodeId);
return result.changes > 0;
}
/** Saves a seat's launch directory only if it's still `expectedCwd`. The lifecycle service checks the new
* directory, read-only, before calling this; it writes no startup files. */
setNodeCwd(nodeId: string, cwd: string, expectedCwd: string | null): boolean {
return this.db.prepare("UPDATE nodes SET cwd = ? WHERE id = ? AND cwd IS ?")
.run(cwd, nodeId, expectedCwd).changes > 0;
}
setNodeEffort(nodeId: string, effort: string): boolean {
if (!this.hasNodeColumn("effort")) return false;
const result = this.db
.prepare("UPDATE nodes SET effort = ? WHERE id = ?")
.run(effort, nodeId);
return result.changes > 0;
}
/** OPR.0.7.0.7: write or clear (null) a seat's explicit effort choice. Returns false when
* the column isn't available or no row matched, so a caller never reports a change from it. */
setNodeEffortChoice(nodeId: string, choice: string | null): boolean {
if (!this.hasNodeColumn("effort_choice")) return false;
return this.db.prepare("UPDATE nodes SET effort_choice = ? WHERE id = ?")
.run(choice, nodeId).changes > 0;
}
/** Write or clear (null) the registered person who answers this seat's runner permission requests. Returns false when
* the column isn't available or no row matched, so a caller never reports a change from it. */
setNodePermissionsTo(nodeId: string, owner: string | null): boolean {
if (!this.hasNodeColumn("permissions_to")) return false;
return this.db.prepare("UPDATE nodes SET permissions_to = ? WHERE id = ?")
.run(owner, nodeId).changes > 0;
}
/** The seat's selected permission answer owner, or null when none is selected or the column isn't available. */
getNodePermissionsTo(nodeId: string): string | null {
if (!this.hasNodeColumn("permissions_to")) return null;
const row = this.db.prepare("SELECT permissions_to FROM nodes WHERE id = ?").get(nodeId) as { permissions_to: string | null } | undefined;
return row?.permissions_to ?? null;
}
clearNodeEffort(nodeId: string): boolean {
if (!this.hasNodeColumn("effort")) return false;
const result = this.db
.prepare("UPDATE nodes SET effort = NULL WHERE id = ?")
.run(nodeId);
return result.changes > 0;
}
/** A Claude seat's own subscription credential, by reference only; null clears it. */
setNodeClaudeTokenRef(nodeId: string, ref: ClaudeSeatTokenRef | null): boolean {
if (!this.hasNodeColumn("claude_token_ref")) return false;
return this.db.prepare("UPDATE nodes SET claude_token_ref = ? WHERE id = ?")
.run(ref ? JSON.stringify({ label: ref.label, tokenFile: ref.tokenFile, ...(ref.kind ? { kind: ref.kind } : {}) }) : null, nodeId).changes > 0;
}
/** Persist the resolved Advisor; null removes a previous spec's selection. */
setNodeClaudeMcp(nodeId: string, value: ClaudeMcp | null): boolean {
if (value !== null) validateClaudeMcp(value);
if (!this.hasNodeColumn("claude_mcp")) {
if (value !== null) throw new Error("Cannot persist claude_mcp: database migration is required");
return false;
}
return this.db.prepare("UPDATE nodes SET claude_mcp = ? WHERE id = ?").run(value, nodeId).changes > 0;
}
/** Persist or clear (null or []) a seat's launch_wrapper argv prefix. */
setNodeLaunchWrapper(nodeId: string, value: readonly string[] | null): boolean {
const validated = value === null ? null : validateLaunchWrapper(value);
const stored = validated && validated.length > 0 ? JSON.stringify(validated) : null;
if (!this.hasNodeColumn("launch_wrapper")) {
if (stored !== null) throw new Error("Cannot persist launch_wrapper: database migration is required");
return false;
}
if (value !== null) {
const existing = this.db.prepare("SELECT runtime FROM nodes WHERE id = ?").get(nodeId) as { runtime: string | null } | undefined;
if (existing && !isLaunchWrapperRuntime(existing.runtime)) {
throw new Error("launch_wrapper requires a pi or codex seat");
}
}
return this.db.prepare("UPDATE nodes SET launch_wrapper = ? WHERE id = ?").run(stored, nodeId).changes > 0;
}
setNodeAdvisor(nodeId: string, advisor: string | null): boolean {
if (!this.hasNodeColumn("advisor")) return false;
return this.db.prepare("UPDATE nodes SET advisor = ? WHERE id = ?")
.run(advisor, nodeId).changes > 0;
}
addEdge(rigId: string, sourceId: string, targetId: string, kind: string): Edge {
const id = ulid();
const targetRef = kind === "escalates_to" && isEscalationTargetRef(targetId) ? targetId : null;
if (targetRef) {
this.db.prepare("INSERT INTO edges (id, rig_id, source_id, target_ref, kind) VALUES (?, ?, ?, ?, ?)")
.run(id, rigId, sourceId, targetRef, kind);
} else {
this.db.prepare("INSERT INTO edges (id, rig_id, source_id, target_id, kind) VALUES (?, ?, ?, ?, ?)")
.run(id, rigId, sourceId, targetId, kind);
}
return this.rowToEdge(
this.db.prepare("SELECT * FROM edges WHERE id = ?").get(id) as EdgeRow
);
}
/** Removes exactly one edge row within the target rig; true when it existed. */
removeEdge(rigId: string, edgeId: string): boolean {
return this.db.prepare("DELETE FROM edges WHERE rig_id = ? AND id = ?").run(rigId, edgeId).changes > 0;
}
getRig(rigId: string): RigWithRelations | null {
const rigRow = this.db
.prepare("SELECT * FROM rigs WHERE id = ?")
.get(rigId) as RigRow | undefined;
if (!rigRow) return null;
const nodeRows = this.db
.prepare("SELECT * FROM nodes WHERE rig_id = ? ORDER BY created_at")
.all(rigId) as NodeRow[];
const edgeRows = this.db
.prepare("SELECT * FROM edges WHERE rig_id = ?")
.all(rigId) as EdgeRow[];
const nodes: NodeWithBinding[] = nodeRows.map((row) => {
const bindingRow = this.db
.prepare("SELECT * FROM bindings WHERE node_id = ?")
.get(row.id) as BindingRow | undefined;
return {
...this.rowToNode(row),
binding: bindingRow ? this.rowToBinding(bindingRow) : null,
};
});
return {
rig: this.rowToRig(rigRow),
nodes,
edges: edgeRows.map((r) => this.rowToEdge(r)),
};
}
listRigs(filter?: RigArchiveFilter, observe?: import("./request-phase-observer.js").QueryObservation): Rig[] {
const cond = archiveWhereClause("archived_at", filter);
const where = cond ? `WHERE ${cond}` : "";
let rows: RigRow[];
try { observe?.("begin"); } catch { /* Best-effort measurement only. */ }
try {
rows = this.db.prepare(`SELECT * FROM rigs ${where} ORDER BY created_at`).all() as RigRow[];
} catch (error) {
try { observe?.("end", true); } catch { /* Preserve the actual query error. */ }
throw error;
}
try { observe?.("end", false); } catch { /* Preserve the query result. */ }
return rows.map((r) => this.rowToRig(r));
}
/**
* Returns the latest snapshot for this rig with at least one non-null resume token,
* or null if no usable snapshot exists. Used by the lifecycle projection to derive
* `recoverable` state.
*/
findLatestUsableSnapshot(rigId: string): Snapshot | null {
return findLatestUsableSnapshot(this.db, rigId);
}
findRigsByName(name: string): Rig[] {
const rows = this.db
.prepare("SELECT * FROM rigs WHERE name = ? ORDER BY created_at")
.all(name) as RigRow[];
return rows.map((r) => this.rowToRig(r));
}
/** #174: seat-ref resolution sees only unarchived rigs, as default reads do since migration 042. */
findUnarchivedRigsByName(name: string): Rig[] {
const archived = this.hasRigColumn("archived_at") ? " AND archived_at IS NULL" : "";
const rows = this.db
.prepare(`SELECT * FROM rigs WHERE name = ?${archived} ORDER BY created_at`)
.all(name) as RigRow[];
return rows.map((r) => this.rowToRig(r));
}
getRigSummaries(filter?: RigArchiveFilter): Array<{ id: string; name: string; nodeCount: number; latestSnapshotAt: string | null; latestSnapshotId: string | null; hasServices: boolean; archivedAt: string | null }> {
const cond = archiveWhereClause("r.archived_at", filter);
const where = cond ? `WHERE ${cond}` : "";
const rows = this.db.prepare(`
SELECT
r.id,
r.name,
r.archived_at AS archived_at,
(SELECT COUNT(*) FROM nodes n WHERE n.rig_id = r.id) AS node_count,
EXISTS(SELECT 1 FROM rig_services rs WHERE rs.rig_id = r.id) AS has_services,
ls.id AS latest_snapshot_id,
ls.created_at AS latest_snapshot_at
FROM rigs r
LEFT JOIN snapshots ls ON ls.id = (
SELECT s2.id FROM snapshots s2
WHERE s2.rig_id = r.id
ORDER BY s2.created_at DESC, s2.id DESC
LIMIT 1
)
${where}
ORDER BY r.created_at
`).all() as Array<{ id: string; name: string; archived_at: string | null; node_count: number; has_services: number; latest_snapshot_id: string | null; latest_snapshot_at: string | null }>;
return rows.map((r) => ({
id: r.id,
name: r.name,
nodeCount: r.node_count,
hasServices: r.has_services === 1,
latestSnapshotAt: r.latest_snapshot_at,
latestSnapshotId: r.latest_snapshot_id,
archivedAt: r.archived_at,
}));
}
/**
* OPR.0.3.3.19 - soft-archive a rig: sets `archived_at`. The rigs row,
* topology rows, and snapshots are all RETAINED (this is NOT the delete
* path - contrast deleteRig). Returns false if the rig was already archived.
*/
archiveRig(rigId: string): boolean {
const result = this.db
.prepare("UPDATE rigs SET archived_at = datetime('now'), updated_at = datetime('now') WHERE id = ? AND archived_at IS NULL")
.run(rigId);
return result.changes > 0;
}
/** OPR.0.3.3.19 - reverse archive: clears `archived_at`. Returns false if not archived. */
unarchiveRig(rigId: string): boolean {
const result = this.db
.prepare("UPDATE rigs SET archived_at = NULL, updated_at = datetime('now') WHERE id = ? AND archived_at IS NOT NULL")
.run(rigId);
return result.changes > 0;
}
deleteRig(rigId: string): void {
this.db.prepare("DELETE FROM rigs WHERE id = ?").run(rigId);
}
deleteNode(nodeId: string): void {
this.db.prepare("DELETE FROM nodes WHERE id = ?").run(nodeId);
}
setServicesRecord(rigId: string, record: RigServicesRecordInput): RigServicesRecord {
const now = new Date().toISOString();
const composeFile = resolve(record.rigRoot, record.composeFile);
const rig = this.db.prepare("SELECT 1 AS present FROM rigs WHERE id = ?").get(rigId);
if (!rig) throw new Error(`Rig not found: ${rigId}`);
// Keep the fallback aligned with bootstrap's unique, stable rig-ID default.
const projectName = record.projectName ?? this.getServicesRecord(rigId)?.projectName ?? deriveComposeProjectName(rigId);
this.db.prepare(`
INSERT INTO rig_services (
rig_id,
kind,
spec_json,
rig_root,
compose_file,
project_name,
latest_receipt_json,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(rig_id) DO UPDATE SET
kind = excluded.kind,
spec_json = excluded.spec_json,
rig_root = excluded.rig_root,
compose_file = excluded.compose_file,
project_name = excluded.project_name,
latest_receipt_json = excluded.latest_receipt_json,
updated_at = excluded.updated_at
`).run(
rigId,
record.kind,
record.specJson,
record.rigRoot,
composeFile,
projectName,
record.latestReceiptJson ?? null,
now,
now,
);
const stored = this.db.prepare("SELECT * FROM rig_services WHERE rig_id = ?").get(rigId) as RigServicesRow | undefined;
if (!stored) throw new Error(`Failed to persist services record for rig ${rigId}`);
return this.rowToServicesRecord(stored);
}
/** A retained generation must not tear down a live generation's project. */
getLiveServicesSuccessor(rigId: string, projectName: string): { id: string; name: string } | null {
if (!this.hasRigColumn("archived_at")) return null;
return this.db.prepare(`
SELECT r.id, r.name FROM rig_services s JOIN rigs r ON r.id = s.rig_id
JOIN rigs predecessor ON predecessor.id = ?
WHERE s.project_name = ? AND r.id != predecessor.id
AND predecessor.archived_at IS NOT NULL AND r.archived_at IS NULL
AND r.name = predecessor.name
ORDER BY r.created_at DESC LIMIT 1
`).get(rigId, projectName) as { id: string; name: string } | undefined ?? null;
}
getServicesRecord(rigId: string): RigServicesRecord | null {
const row = this.db.prepare("SELECT * FROM rig_services WHERE rig_id = ?").get(rigId) as RigServicesRow | undefined;
return row ? this.rowToServicesRecord(row) : null;
}
updateServicesReceipt(rigId: string, latestReceiptJson: string | null): RigServicesRecord | null {
const now = new Date().toISOString();
const result = this.db.prepare(`
UPDATE rig_services
SET latest_receipt_json = ?, updated_at = ?
WHERE rig_id = ?
`).run(latestReceiptJson, now, rigId);
if (result.changes === 0) return null;
return this.getServicesRecord(rigId);
}
// -- Row-to-domain mappers --
private rowToRig(row: RigRow): Rig {
return {
id: row.id,
name: row.name,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
private rowToNode(row: NodeRow): Node {
const launchWrapper = parseStoredLaunchWrapper(row.launch_wrapper, `Seat ${row.id} launch_wrapper`);
if (launchWrapper && !isLaunchWrapperRuntime(row.runtime)) {
throw new Error(`Seat ${row.id} launch_wrapper: only valid when runtime is "pi" or "codex"`);
}
return {
id: row.id,
rigId: row.rig_id,
logicalId: row.logical_id,
role: row.role,
runtime: row.runtime,
model: row.model,
effort: row.effort ?? null,
advisor: row.advisor ?? null,
claudeMcp: row.claude_mcp ?? null,
...(launchWrapper ? { launchWrapper } : {}),
effortChoice: row.effort_choice ?? null,
codexConfigProfile: row.codex_config_profile ?? null,
claudeTokenRef: parseClaudeSeatTokenRef(row.claude_token_ref),
...(row.account_home ? { accountHome: row.account_home } : {}),
permissionPolicy: row.permission_policy ?? null,
env: parseStoredSeatEnv(row.env_json),
cwd: row.cwd,
surfaceHint: row.surface_hint ?? null,
workspace: row.workspace ?? null,
restorePolicy: row.restore_policy ?? null,
packageRefs: row.package_refs ? JSON.parse(row.package_refs) as string[] : [],
podId: row.pod_id ?? null,
agentRef: row.agent_ref ?? null,
profile: row.profile ?? null,
label: row.label ?? null,
sessionSource: row.session_source_json
? JSON.parse(row.session_source_json) as SessionSourceSpec
: null,
resolvedSpecName: row.resolved_spec_name ?? null,
resolvedSpecVersion: row.resolved_spec_version ?? null,
resolvedSpecHash: row.resolved_spec_hash ?? null,
occupantLifecycle: row.occupant_lifecycle as Node["occupantLifecycle"] ?? null,
continuityOutcome: row.continuity_outcome as Node["continuityOutcome"] ?? null,
handoverResult: row.handover_result as Node["handoverResult"] ?? null,
previousOccupant: row.previous_occupant ?? null,
handoverAt: row.handover_at ?? null,
createdAt: row.created_at,
};
}
private rowToEdge(row: EdgeRow): Edge {
return {
id: row.id,
rigId: row.rig_id,
sourceId: row.source_id,
targetId: row.target_ref ?? row.target_id!,
...(row.target_ref ? { targetRef: row.target_ref } : {}),
kind: row.kind,
createdAt: row.created_at,
};
}
private rowToBinding(row: BindingRow): Binding {
return {
id: row.id,
nodeId: row.node_id,
attachmentType: (row.attachment_type as Binding["attachmentType"]) ?? "tmux",
tmuxSession: row.tmux_session,
tmuxWindow: row.tmux_window,
tmuxPane: row.tmux_pane,
externalSessionName: row.external_session_name ?? null,
cmuxWorkspace: row.cmux_workspace,
cmuxSurface: row.cmux_surface,
updatedAt: row.updated_at,
};
}
private rowToServicesRecord(row: RigServicesRow): RigServicesRecord {
return {
rigId: row.rig_id,
kind: row.kind as "compose",
specJson: row.spec_json,
rigRoot: row.rig_root,
composeFile: row.compose_file,
projectName: row.project_name,
latestReceiptJson: row.latest_receipt_json ?? null,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
private hasNodeColumn(columnName: string): boolean {
return this.db.prepare("PRAGMA table_info(nodes)").all()
.some((row) => (row as { name?: string }).name === columnName);
}
}
// -- Raw DB row types (snake_case) --
interface RigRow {
id: string;
name: string;
created_at: string;
updated_at: string;
}
interface NodeRow {
id: string;
rig_id: string;
logical_id: string;
role: string | null;
runtime: string | null;
model: string | null;
effort?: string | null;
advisor?: string | null;
claude_mcp?: ClaudeMcp | null;
launch_wrapper?: string | null;
effort_choice?: string | null;
codex_config_profile?: string | null;
claude_token_ref?: string | null;
permission_policy?: string | null;
env_json?: string | null;
account_home?: string | null;
cwd: string | null;
surface_hint: string | null;
workspace: string | null;
restore_policy: string | null;
package_refs: string | null;
pod_id: string | null;
agent_ref: string | null;
profile: string | null;
label: string | null;
session_source_json?: string | null;
launch_recipe_json?: string | null;
resolved_spec_name: string | null;
resolved_spec_version: string | null;
resolved_spec_hash: string | null;
occupant_lifecycle: string | null;
continuity_outcome: string | null;
handover_result: string | null;
previous_occupant: string | null;
handover_at: string | null;
created_at: string;
}
interface EdgeRow {
id: string;
rig_id: string;
source_id: string;
target_id: string | null;
target_ref?: string | null;
kind: string;
created_at: string;
}
interface BindingRow {
id: string;
node_id: string;
attachment_type: string | null;
tmux_session: string | null;
tmux_window: string | null;
tmux_pane: string | null;
external_session_name: string | null;
cmux_workspace: string | null;
cmux_surface: string | null;
updated_at: string;
}
interface RigServicesRow {
rig_id: string;
kind: string;
spec_json: string;
rig_root: string;
compose_file: string;
project_name: string;
latest_receipt_json: string | null;
created_at: string;
updated_at: string;
}