mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-13 18:49:34 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
104a7bd11b | ||
|
|
d362cdb09a | ||
|
|
abe652d7aa |
No files matched your search
@@ -22,7 +22,7 @@ type LaunchResponse = {
|
||||
held?: Array<{ nodeId: string; logicalId: string; reason: string }>;
|
||||
alreadyRunning?: Array<{ nodeId: string; logicalId: string }>;
|
||||
failedTargets?: Array<{ nodeId: string; logicalId: string; reason: string }>;
|
||||
targetNodes?: Array<{ nodeId: string; logicalId: string }>;
|
||||
targetNodes?: Array<{ nodeId: string; logicalId: string; permission?: { summary?: string } }>;
|
||||
snapshotSelection?: { snapshotId: string; kind: string; createdAt: string; ageMs: number; mode: "explicit" | "automatic"; rationale: string; newerUsableAlternative: unknown };
|
||||
nonTargetEffects?: { mode: "unchanged" | "detach_and_hold"; reason: string | null; affected: unknown[] };
|
||||
planOnly?: boolean;
|
||||
@@ -317,6 +317,11 @@ export function launchCommand(depsOverride?: StatusDeps): Command {
|
||||
if (opts.plan && res.data.planOnly) {
|
||||
console.log("Plan only; no changes made.");
|
||||
printSnapshotSelection(res.data.snapshotSelection);
|
||||
// Each target and the permission this launch will start it with (--json has the whole account).
|
||||
if (res.data.targetNodes?.length) console.log("Targets:");
|
||||
for (const target of res.data.targetNodes ?? []) {
|
||||
console.log(` ${target.logicalId}${target.permission?.summary ? `: ${target.permission.summary}` : ""}`);
|
||||
}
|
||||
console.log(`Non-target effect: ${res.data.nonTargetEffects?.mode ?? "unavailable"}`);
|
||||
for (const node of res.data.nonTargetEffects?.affected ?? []) {
|
||||
const affected = node as { logicalId?: string; reason?: string };
|
||||
|
||||
@@ -685,7 +685,7 @@ as no rig by that name or a failed boot).
|
||||
} else if (res.data["wouldCaptureCurrentState"] === true) {
|
||||
console.log(`Snapshot: none usable — apply would first capture current DB state as an auto-rehydrate snapshot.`);
|
||||
}
|
||||
const planNodes = (res.data["nodes"] as Array<{ logicalId: string; intendedAction: string; reason?: string; tokenState?: string; provenance?: string | null; lastVerified?: string | null; freshRequired?: boolean; runtimePrompt?: string }>) ?? [];
|
||||
const planNodes = (res.data["nodes"] as Array<{ logicalId: string; intendedAction: string; reason?: string; tokenState?: string; provenance?: string | null; lastVerified?: string | null; freshRequired?: boolean; runtimePrompt?: string; permission?: { summary?: string } }>) ?? [];
|
||||
for (const n of planNodes) {
|
||||
console.log(` ${n.logicalId}: ${n.intendedAction}${n.reason ? ` — ${n.reason}` : ""}`);
|
||||
// OPR.0.4.3.20 FR-6 — per-seat token truth: present/missing/stale/unverified
|
||||
@@ -698,6 +698,8 @@ as no rig by that name or a failed boot).
|
||||
const prompt = n.runtimePrompt ? `; ${n.runtimePrompt}` : "";
|
||||
console.log(` token: ${n.tokenState}${reverify}${prov}${verified}${fresh}${prompt}`);
|
||||
}
|
||||
// The permission restore will start the seat with, and where it comes from (--json has the whole account).
|
||||
if (n.permission?.summary) console.log(` permission: ${n.permission.summary}`);
|
||||
}
|
||||
console.log("No changes made.");
|
||||
return;
|
||||
|
||||
@@ -114,6 +114,25 @@ describe("rig launch --seats", () => {
|
||||
expect(logs.join("\n")).not.toContain("Launched");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["no saved snapshot names this seat yet, so which launch path restore takes for it isn't known."],
|
||||
["restore takes this seat's runtime from its resume token, which this read doesn't know."],
|
||||
["this seat has no saved startup context, so restore starts it fresh and re-reads its policy then."],
|
||||
])("--plan prints an unsettled target's permission as unknown with the reason: %s", async (reason) => {
|
||||
const deps = makeDeps({
|
||||
"launch-subset": {
|
||||
status: 200,
|
||||
data: { ok: true, planOnly: true, snapshotSelection: { snapshotId: "snap-1", mode: "automatic" },
|
||||
targetNodes: [{ nodeId: "n-1", logicalId: "dev.driver", permission: { basis: "unknown", runtime: "claude-code", summary: `Unknown: ${reason}` } }],
|
||||
nonTargetEffects: { mode: "detach_and_hold", reason: "operator hold", affected: [{ logicalId: "dev.guard", reason: "operator hold" }] } },
|
||||
},
|
||||
});
|
||||
deps._client.get.mockResolvedValue({ status: 200, data: { version: "0.6.8" } });
|
||||
await launchCommand(deps).parseAsync(["node", "rig", "rig-1", "--seats", "dev.driver", "--hold-reason", "operator hold", "--plan"]);
|
||||
expect(logs.join("\n")).toContain(`Targets:\n dev.driver: Unknown: ${reason}`);
|
||||
expect(logs.join("\n")).not.toContain("On restore");
|
||||
});
|
||||
|
||||
it("passes --plan through and renders non-target effects without claiming launch", async () => {
|
||||
const deps = makeDeps({
|
||||
"launch-subset": {
|
||||
@@ -122,6 +141,8 @@ describe("rig launch --seats", () => {
|
||||
ok: true,
|
||||
planOnly: true,
|
||||
snapshotSelection: { snapshotId: "snap-1", mode: "automatic" },
|
||||
targetNodes: [{ nodeId: "n-1", logicalId: "dev.driver",
|
||||
permission: { basis: "planned", summary: "On restore: --permission-mode acceptEdits, from OpenRig's team default (no policy or seat choice is recorded)." } }],
|
||||
nonTargetEffects: {
|
||||
mode: "detach_and_hold",
|
||||
reason: "operator hold",
|
||||
@@ -144,6 +165,8 @@ describe("rig launch --seats", () => {
|
||||
undefined,
|
||||
);
|
||||
expect(logs.join("\n")).toContain("Plan only; no changes made.");
|
||||
// Each target and the permission this launch will start it with.
|
||||
expect(logs.join("\n")).toContain("Targets:\n dev.driver: On restore: --permission-mode acceptEdits, from OpenRig's team default");
|
||||
expect(logs.join("\n")).toContain("dev.guard: operator hold");
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -241,6 +241,22 @@ describe("rig up --existing — resume-original default (OPR.0.3.4.2)", () => {
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["no saved snapshot names this seat yet, so which launch path restore takes for it isn't known."],
|
||||
["restore takes this seat's runtime from its resume token, which this read doesn't know."],
|
||||
["this seat has no saved startup context, so restore starts it fresh and re-reads its policy then."],
|
||||
])("--plan prints an unsettled seat's permission as unknown with the reason: %s", async (reason) => {
|
||||
respond = () => ({ status: "plan", mode: "restore", rigId: "rig-1", rigName: "myrig", snapshot: null, wouldCaptureCurrentState: true,
|
||||
nodes: [{ logicalId: "dev.impl", intendedAction: "fresh-primed", permission: { basis: "unknown", runtime: "claude-code", summary: `Unknown: ${reason}` } }],
|
||||
mutated: false });
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "up", "myrig", "--existing", "--plan"]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain(` permission: Unknown: ${reason}`);
|
||||
expect(output).not.toContain("On restore");
|
||||
});
|
||||
|
||||
// OPR.0.3.4.4 — plan-restore preview render + honest-async timeout.
|
||||
it("--plan restore preview renders per-seat intended actions and 'No changes made.'", async () => {
|
||||
respond = () => ({
|
||||
@@ -251,7 +267,8 @@ describe("rig up --existing — resume-original default (OPR.0.3.4.2)", () => {
|
||||
snapshot: { id: "snap-9", kind: "auto-pre-down", createdAt: "2026-06-11T00:00:00Z" },
|
||||
wouldCaptureCurrentState: false,
|
||||
nodes: [
|
||||
{ logicalId: "dev.impl", intendedAction: "resume-original" },
|
||||
{ logicalId: "dev.impl", intendedAction: "resume-original",
|
||||
permission: { basis: "planned", summary: "On restore: --permission-mode auto, from this seat's own choice (person@host: after the reboot)." } },
|
||||
{ logicalId: "dev.qa", intendedAction: "awaiting-decision", reason: "resume source 'claude_name' recorded but no token available — apply would stop and ask (zero session)" },
|
||||
],
|
||||
mutated: false,
|
||||
@@ -261,6 +278,9 @@ describe("rig up --existing — resume-original default (OPR.0.3.4.2)", () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "up", "myrig", "--existing", "--plan"]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
// Each seat's permission on restore, and where it comes from, before anything launches.
|
||||
expect(output).toContain(" permission: On restore: --permission-mode auto, from this seat's own choice (person@host: after the reboot).");
|
||||
expect(output.match(/permission: /g)).toHaveLength(1);
|
||||
expect(output).toContain('Plan: restore rig "myrig"');
|
||||
expect(output).toContain("snap-9");
|
||||
expect(output).toContain("dev.impl: resume-original");
|
||||
|
||||
@@ -51,8 +51,10 @@ function policyRefs(db: Database.Database, nodeId: string): { member: string | n
|
||||
return { ...refs, nonInterruptive };
|
||||
}
|
||||
|
||||
/** `restore`: plan the seat's restore instead of its next fresh launch. Restore starts from the policy posture it re-reads
|
||||
* from the seat's saved provenance (RestoreOrchestrator.resolveRestorePosture), not the cached one a fresh launch uses. */
|
||||
export function planPermissionAccount(db: Database.Database, nodeId: string, seat: string, runtime: string | null,
|
||||
env: NodeJS.ProcessEnv = process.env): PermissionAccount {
|
||||
env: NodeJS.ProcessEnv = process.env, restore?: { launchPosture: NonNullable<NodeBinding["launchPosture"]> }): PermissionAccount {
|
||||
const controls = [
|
||||
`rig seat set-permissions ${seat} --mode <mode> --reason "<why>"`,
|
||||
`rig seat set-permissions ${seat} --mode inherit --reason "<why>" (clears the seat's choice)`,
|
||||
@@ -79,7 +81,7 @@ export function planPermissionAccount(db: Database.Database, nodeId: string, sea
|
||||
// choice and the team or kernel default through NativePermissionStore.apply.
|
||||
const repo = new RigRepository(db);
|
||||
const rigId = (db.prepare("SELECT rig_id FROM nodes WHERE id = ?").get(nodeId) as { rig_id: string } | undefined)?.rig_id;
|
||||
const bindingPosture = repo.getNodePolicyProvenance(nodeId)?.launchPosture
|
||||
const bindingPosture = restore?.launchPosture ?? repo.getNodePolicyProvenance(nodeId)?.launchPosture
|
||||
?? (rigId ? repo.getRigPolicyProvenance(rigId)?.launchPosture : undefined) ?? "floor";
|
||||
const planned = store.apply({ nodeId, launchPosture: bindingPosture } as NodeBinding, runtime);
|
||||
const posture = planned.launchPosture;
|
||||
@@ -98,7 +100,9 @@ export function planPermissionAccount(db: Database.Database, nodeId: string, sea
|
||||
: codexPostureArg(profile ? ` -p ${profile}` : "", env, posture).trim();
|
||||
const openRigAdds: PermissionAccount["openRigAdds"] = { claudeAllow: [], claudeAsk: [], notes: [] };
|
||||
const unknown = [NOT_READ, UNVERIFIED,
|
||||
runtime === "codex" && envelope
|
||||
restore
|
||||
? "This is the plan for restoring this seat, from the policy posture restore re-reads from its saved provenance, then the seat's own choice and the team or kernel default."
|
||||
: runtime === "codex" && envelope
|
||||
? "This is the saved plan for a fresh launch. Resumes, restores and handovers emit the same envelope."
|
||||
: runtime === "codex"
|
||||
? "This is the saved plan for a fresh launch. A restore re-reads a custom policy file, and a rebuild handover can carry the previous occupant's observed Codex sandbox and approvals instead, without the extra roots or network setting."
|
||||
@@ -178,6 +182,36 @@ export function planPermissionAccount(db: Database.Database, nodeId: string, sea
|
||||
return {
|
||||
basis: "planned", runtime, source, nativeArgument, overrides, openRigAdds, unknown, controls, advisories: planNote ? [planNote] : [],
|
||||
...(explicit ? { explicit: { mode: explicit.mode, actor: explicit.actor, reason: explicit.reason, updatedAt: explicit.updatedAt } } : {}),
|
||||
summary: `Next launch: ${nativeArgument}, from ${where[source]}${notes}${adds}.`,
|
||||
summary: `${restore ? "On restore" : "Next launch"}: ${nativeArgument}, from ${where[source]}${notes}${adds}.`,
|
||||
};
|
||||
}
|
||||
|
||||
/** What a restore preview shows for one seat: its account as restore will decide it, or why it can't be read. */
|
||||
export type RestorePermissionPreview = PermissionAccount | { basis: "unknown"; runtime: string | null; summary: string };
|
||||
|
||||
/** One seat's permission as a restore will start it, for the plan previews (`rig up --existing --plan`, `rig launch
|
||||
* --plan`): restore's own posture through the same NativePermissionStore.apply its launch calls. It's planned only for
|
||||
* the path that posture is exact for, a pod seat with saved startup context; `podAware` and `startupContext` come from
|
||||
* the snapshot restore reads (undefined when no saved node names the seat). Every other path, a runtime restore stops
|
||||
* for before launch, and a permission fact restore would refuse on read unknown with the reason, never a definite mode. */
|
||||
export function planRestorePermission(db: Database.Database, input: {
|
||||
nodeId: string; seat: string; runtime: string | null;
|
||||
restorePosture: () => NonNullable<NodeBinding["launchPosture"]>;
|
||||
podAware?: boolean; startupContext?: boolean; runtimeError?: string;
|
||||
}, env: NodeJS.ProcessEnv = process.env): RestorePermissionPreview {
|
||||
const unknown = (reason: string): RestorePermissionPreview => ({ basis: "unknown", runtime: input.runtime, summary: `Unknown: ${reason}` });
|
||||
if (input.runtimeError) return unknown(`restore stops for a runtime decision before it starts this seat. ${input.runtimeError}`);
|
||||
if (input.podAware === undefined) return unknown("no saved snapshot names this seat yet, so which launch path restore takes for it isn't known.");
|
||||
if (!input.podAware) return unknown("restore takes this seat's runtime from its resume token, which this read doesn't know.");
|
||||
if (input.startupContext === false) return unknown("this seat has no saved startup context, so restore starts it fresh and re-reads its policy then.");
|
||||
if (input.runtime === null) return unknown("this seat's runtime isn't recorded.");
|
||||
try {
|
||||
const launchPosture = input.restorePosture();
|
||||
// Startup makes this decision for every runtime (StartupOrchestrator's apply) and fails the seat on its error, so a
|
||||
// choice it would refuse, even on a runtime OpenRig sets no native argument for, reads unknown here too.
|
||||
new NativePermissionStore(db).apply({ nodeId: input.nodeId, launchPosture } as NodeBinding, input.runtime);
|
||||
return planPermissionAccount(db, input.nodeId, input.seat, input.runtime, env, { launchPosture });
|
||||
} catch (error) {
|
||||
return unknown((error as Error).message);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import { captureClaudeContinuation, type ClaudeContinuationLaunch } from "./clau
|
||||
import { observeSolePane } from "./pane-binding-observation.js";
|
||||
import { automaticRestoreOverrides, type AutomaticSeatReplacement } from "./existing-rig-restore.js";
|
||||
import { restoreRuntimeCompatibilityError } from "./restore-runtime-compatibility.js";
|
||||
import { leftAttachedReason } from "./restore-plan-preview.js";
|
||||
import { leftAttachedReason, restorePermissionSeat } from "./restore-plan-preview.js";
|
||||
import { planRestorePermission, type RestorePermissionPreview } from "./permission-account.js";
|
||||
import { assessSnapshotCurrentOccupants, stoppedSnapshotReason } from "./rehydrate-eligibility.js";
|
||||
import { nonInterruptiveNotice, nonInterruptiveSummary } from "../adapters/non-interruptive.js";
|
||||
import { planModelAliasNotice } from "../adapters/yolo-mode.js";
|
||||
@@ -215,7 +216,8 @@ export interface NarrowLaunchResult {
|
||||
held?: Array<{ nodeId: string; logicalId: string; reason: string }>;
|
||||
alreadyRunning?: Array<{ nodeId: string; logicalId: string }>;
|
||||
failedTargets?: Array<{ nodeId: string; logicalId: string; reason: string }>;
|
||||
targetNodes?: Array<{ nodeId: string; logicalId: string }>;
|
||||
/** In a plan, each target's `permission`: what this launch will start it with (absent for a seat it leaves attached). */
|
||||
targetNodes?: Array<{ nodeId: string; logicalId: string; permission?: RestorePermissionPreview }>;
|
||||
unmatchedIds?: string[];
|
||||
warnings?: string[];
|
||||
snapshotSelection?: RestoreSnapshotSelection;
|
||||
@@ -623,7 +625,17 @@ export class RestoreOrchestrator {
|
||||
snapshotSelection: selected.selection,
|
||||
targetNodes: intendedNodes
|
||||
.filter((node) => targetIds.has(node.logicalId))
|
||||
.map((node) => ({ nodeId: node.id, logicalId: node.logicalId })),
|
||||
.map((node) => {
|
||||
// The permission this launch will start the seat with: restore's own posture rule through the launch's resolver.
|
||||
const live = rig.nodes.find((candidate) => candidate.logicalId === node.logicalId);
|
||||
if (live && seatProcessOwner(resolveSeatKind(live.binding?.attachmentType)) === "external") {
|
||||
return { nodeId: node.id, logicalId: node.logicalId };
|
||||
}
|
||||
const seat = restorePermissionSeat(rig.rig.name, live ?? { id: node.id, logicalId: node.logicalId, runtime: node.runtime ?? null },
|
||||
selected.snapshot, node.id);
|
||||
return { nodeId: node.id, logicalId: node.logicalId,
|
||||
permission: planRestorePermission(this.db, { ...seat, restorePosture: () => this.resolveRestorePosture(seat.nodeId, rigId) }) };
|
||||
}),
|
||||
unmatchedIds: logicalIds.filter((id) => !targetIds.has(id) && !targetNodeIds.has(id)),
|
||||
nonTargetEffects,
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import { restoreRuntimeCompatibilityError } from "./restore-runtime-compatibilit
|
||||
import type Database from "better-sqlite3";
|
||||
import { resolveActiveSnapshotSession, deriveRehydrateOccupantsByNode, deriveRehydrateSessionIdByNode, activeOccupantAmbiguityError, absentSeatResumeHistory, absentSeatResumeHistoryError, type ActiveOccupantResolution } from "./active-occupant.js";
|
||||
import type { RigWithRelations, Snapshot } from "./types.js";
|
||||
import type { RestorePermissionPreview } from "./permission-account.js";
|
||||
import { readFreshOccupantRelations } from "./fresh-occupant-relation.js";
|
||||
import { parseSqliteUtcMs } from "./sqlite-time.js";
|
||||
import { communityLaunchOnlyRestoreReason, effectiveRestorePolicy, isOrdinaryTerminalSeat, type RestoreSeatFacts } from "./restore-policy.js";
|
||||
@@ -56,6 +57,9 @@ export interface RestorePlanPreviewNode {
|
||||
/** A runtime prompt the operator should expect at restore (Claude session
|
||||
* picker, Codex auth/update) — a forecast, not an incidental surprise. */
|
||||
runtimePrompt?: string;
|
||||
/** The permission restore will start this seat with, and where it comes from, when the caller can read it (planned,
|
||||
* not observed). Absent for a seat restore leaves attached. */
|
||||
permission?: RestorePermissionPreview;
|
||||
}
|
||||
|
||||
export interface RestorePlanPreview {
|
||||
@@ -283,6 +287,35 @@ export function capturedPreviewSessionRows(snapshot: Snapshot): PreviewSessionRo
|
||||
* Forecast per-seat restore actions from current inventory and saved occupancy.
|
||||
* Report token state and guidance without launching or changing any sessions.
|
||||
*/
|
||||
/** One seat's facts for a restore permission plan, from the snapshot restore reads: whether restore takes its pod-aware
|
||||
* path (the saved node's pod) and finds saved startup context (keyed by the saved node, as restore keys it), and the
|
||||
* runtime that context names. Undefined when there's no saved node to read them from. */
|
||||
export interface RestorePermissionSeat {
|
||||
nodeId: string;
|
||||
logicalId: string;
|
||||
seat: string;
|
||||
runtime: string | null;
|
||||
podAware?: boolean;
|
||||
startupContext?: boolean;
|
||||
/** Restore's own runtime check (restoreRuntimeCompatibilityError): set, restore stops for a decision before launch. */
|
||||
runtimeError?: string;
|
||||
}
|
||||
|
||||
export function restorePermissionSeat(rigName: string, node: { id: string; logicalId: string; runtime: string | null; binding?: { tmuxSession: string | null } | null },
|
||||
snapshot: Snapshot | null, savedNodeId: string): RestorePermissionSeat {
|
||||
const saved = snapshot?.data.nodes?.find((entry) => entry.logicalId === node.logicalId);
|
||||
const startup = saved ? snapshot?.data.nodeStartupContext?.[savedNodeId] : undefined;
|
||||
const runtimeError = restoreRuntimeCompatibilityError(node.runtime, startup?.runtime);
|
||||
return {
|
||||
nodeId: node.id,
|
||||
logicalId: node.logicalId,
|
||||
seat: node.binding?.tmuxSession ?? `${node.logicalId.replace(".", "-")}@${rigName}`,
|
||||
runtime: startup?.runtime ?? node.runtime,
|
||||
...(saved ? { podAware: !!saved.podId, startupContext: !!startup } : {}),
|
||||
...(runtimeError ? { runtimeError } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRestorePlanPreview(
|
||||
rig: RigWithRelations,
|
||||
snapshot: Snapshot | null,
|
||||
@@ -291,6 +324,8 @@ export function buildRestorePlanPreview(
|
||||
nowMs: number = Date.now(),
|
||||
recorded: Record<string, string | null> = {},
|
||||
selectedHistory?: { nodeId: string; historySessionId: string },
|
||||
/** Plans a seat's restore permission (planRestorePermission); omitted, the preview carries none. */
|
||||
permissionOf?: (seat: RestorePermissionSeat) => RestorePermissionPreview,
|
||||
): RestorePlanPreview {
|
||||
// OPR.0.5.7.1 — the relation the resolution consumes: the snapshot's own
|
||||
// when previewing a snapshot; for the live no-snapshot case, the SAME
|
||||
@@ -350,6 +385,7 @@ export function buildRestorePlanPreview(
|
||||
// A terminal has no token to find and relaunches fresh on its own.
|
||||
freshRequired: attached || stoppedCurrent || runtimeError || resolution.kind === "ambiguous" ? false : tokenState === "missing" && !isOrdinaryTerminalSeat(seat),
|
||||
...(runtimePrompt ? { runtimePrompt } : {}),
|
||||
...(permissionOf && !attached ? { permission: permissionOf(restorePermissionSeat(rig.rig.name, node, snapshot, savedNodeId)) } : {}),
|
||||
};
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -11,7 +11,8 @@ import type { SnapshotRepository } from "../domain/snapshot-repository.js";
|
||||
import type { SnapshotCapture } from "../domain/snapshot-capture.js";
|
||||
import type { RestoreOrchestrator } from "../domain/restore-orchestrator.js";
|
||||
import { chooseRestoreSnapshot, joinAutomaticRestore, JOINED_AUTOMATIC_RESTORE_WARNING, runExistingRigRestore, type ExistingRigRestoreOutcome } from "../domain/existing-rig-restore.js";
|
||||
import { buildRestorePlanPreview, collectPreviewSessionRows } from "../domain/restore-plan-preview.js";
|
||||
import { buildRestorePlanPreview, collectPreviewSessionRows, type RestorePermissionSeat } from "../domain/restore-plan-preview.js";
|
||||
import { planRestorePermission } from "../domain/permission-account.js";
|
||||
import { readFreshOccupantRelations } from "../domain/fresh-occupant-relation.js";
|
||||
import { loadTopologyManifest } from "../domain/topology/topology-manifest.js";
|
||||
import { MultiRigLauncher } from "../domain/topology/multi-rig-launcher.js";
|
||||
@@ -120,7 +121,11 @@ async function restoreByRigId(rigId: string, rigName: string | null, deps: Retur
|
||||
// snapshot capture (itself a mutation) is reported as would-happen, never
|
||||
// performed.
|
||||
if (plan) {
|
||||
return c.json(buildRestorePlanPreview(rig, choice.snapshot ?? null, collectPreviewSessionRows(snapshotRepo.db, rig, choice.snapshot ?? null, true), freshLogicalIds, Date.now(), readFreshOccupantRelations(snapshotRepo.db, rig.rig.id)), 200);
|
||||
// Each seat's permission as this restore will start it: restore's own posture rule through the launch's resolver.
|
||||
const restorer = deps.restoreOrchestrator;
|
||||
const permissionOf = restorer ? (seat: RestorePermissionSeat) =>
|
||||
planRestorePermission(snapshotRepo.db, { ...seat, restorePosture: () => restorer.resolveRestorePosture(seat.nodeId, rig.rig.id) }) : undefined;
|
||||
return c.json(buildRestorePlanPreview(rig, choice.snapshot ?? null, collectPreviewSessionRows(snapshotRepo.db, rig, choice.snapshot ?? null, true), freshLogicalIds, Date.now(), readFreshOccupantRelations(snapshotRepo.db, rig.rig.id), undefined, permissionOf), 200);
|
||||
}
|
||||
|
||||
const fs = await import("node:fs");
|
||||
|
||||
@@ -145,6 +145,20 @@ describe("RestoreOrchestrator.launchNodeSubset", () => {
|
||||
expect(db.prepare("SELECT * FROM sessions ORDER BY id").all()).toEqual(before);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["codex", "claude-code"],
|
||||
["claude-code", "codex"],
|
||||
])("plans saved startup %s on live %s as unknown with restore's reason, never a definite mode", (saved, live) => {
|
||||
const { rigId, nodeIds } = seedPodAwareRig();
|
||||
const snapshotId = seedSnapshot(rigId, nodeIds, { [nodeIds[0]!]: emptyStartupContext(saved) });
|
||||
db.prepare("UPDATE nodes SET runtime = ? WHERE id = ?").run(live, nodeIds[0]);
|
||||
const result = orchestrator.planNodeSubset(rigId, ["dev.driver"], { snapshotId });
|
||||
const permission = result.targetNodes?.[0]?.permission;
|
||||
expect(permission).toMatchObject({ basis: "unknown" });
|
||||
expect(permission?.summary).toContain("restore stops for a runtime decision before it starts this seat. No session was started for this seat.");
|
||||
expect(permission?.summary).not.toContain("On restore");
|
||||
});
|
||||
|
||||
it("returns rig_not_found for unknown rig", async () => {
|
||||
const result = await orchestrator.launchNodeSubset("nonexistent", ["dev.driver"]);
|
||||
expect(result.ok).toBe(false);
|
||||
@@ -309,6 +323,8 @@ describe("RestoreOrchestrator.launchNodeSubset", () => {
|
||||
});
|
||||
expect(db.prepare("SELECT COUNT(*) AS n FROM sessions").get()).toEqual(before.sessions);
|
||||
expect(db.prepare("SELECT COUNT(*) AS n FROM events").get()).toEqual(before.events);
|
||||
// Each target carries the permission this launch will start it with, from restore's own posture rule.
|
||||
expect(result.targetNodes?.[0]?.permission?.summary).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it("plans single-node subset by node id with nonTargetMode unchanged (#887)", () => {
|
||||
|
||||
@@ -2,7 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { NativePermissionStore } from "../src/domain/native-permission-store.js";
|
||||
import { planPermissionAccount } from "../src/domain/permission-account.js";
|
||||
import { planPermissionAccount, planRestorePermission } from "../src/domain/permission-account.js";
|
||||
import { claudePostureFlag } from "../src/adapters/yolo-mode.js";
|
||||
import { SeatStatusService } from "../src/domain/seat-status-service.js";
|
||||
import { KERNEL_CLAUDE_ALLOW, TEAM_CLAUDE_ALLOW, TEAM_CLAUDE_ASK } from "../src/adapters/kernel-authority.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
@@ -149,3 +150,76 @@ describe("the planned permission account", () => {
|
||||
expect(result.status.permissions).toMatchObject({ selectionState: "inherit", nativeEffect: "unverified", account: { basis: "planned", runtime: "claude-code" } });
|
||||
});
|
||||
});
|
||||
|
||||
// After a reboot a seat came back in a mode the person didn't expect. The plan previews now show, per seat, the permission
|
||||
// restore will start it with: restore's own posture through the launch's resolver, with no precedence change.
|
||||
describe("a seat's planned permission on restore", () => {
|
||||
const inputs = (nodeId: string, restorePosture: () => "floor" | "full_bypass" | "auto") =>
|
||||
({ nodeId, seat: "dev-builder@workshop", runtime: "claude-code", restorePosture, podAware: true, startupContext: true });
|
||||
|
||||
it("starts from the posture restore re-reads, not the cached one a fresh launch uses", () => {
|
||||
const { db, rigRepo, node } = seat("claude-code");
|
||||
attach(rigRepo, db, node.id, "builtin:yolo", "full_bypass");
|
||||
const restored = planRestorePermission(db, inputs(node.id, () => "floor"), env);
|
||||
const fresh = planPermissionAccount(db, node.id, "dev-builder@workshop", "claude-code", env);
|
||||
expect(fresh.nativeArgument).toBe(claudePostureFlag(env, "full_bypass", undefined));
|
||||
expect(restored).toMatchObject({ basis: "planned", source: "member_policy", nativeArgument: claudePostureFlag(env, "floor", undefined) });
|
||||
expect(restored.summary.startsWith("On restore: ")).toBe(true);
|
||||
expect(fresh.summary.startsWith("Next launch: ")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the seat's own choice ahead of restore's posture, as the launch does", () => {
|
||||
const { db, rigRepo, node } = seat("claude-code");
|
||||
attach(rigRepo, db, node.id, "builtin:yolo", "full_bypass");
|
||||
new NativePermissionStore(db).write(node.id, { runtime: "claude-code", mode: "auto" }, "person@host", "after the reboot");
|
||||
const restored = planRestorePermission(db, inputs(node.id, () => "full_bypass"), env);
|
||||
expect(restored).toMatchObject({ basis: "planned", source: "explicit", nativeArgument: "--permission-mode auto",
|
||||
explicit: { mode: "auto", actor: "person@host", reason: "after the reboot" }, overrides: ["member policy builtin:yolo"] });
|
||||
});
|
||||
|
||||
it("reads unknown, with restore's own reason, when restore would refuse the seat's permission facts", () => {
|
||||
const { db, node } = seat("claude-code");
|
||||
new NativePermissionStore(db).write(node.id, { runtime: "claude-code", mode: "plan" }, "person@host", "review");
|
||||
const restored = planRestorePermission(db, { ...inputs(node.id, () => "floor"), runtime: "codex" }, env);
|
||||
expect(restored.basis).toBe("unknown");
|
||||
expect(restored.summary).toContain("Seat runtime changed since permission selection");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ podAware: false }, "runtime from its resume token"],
|
||||
[{ podAware: true, startupContext: false }, "no saved startup context"],
|
||||
[{ podAware: undefined, startupContext: undefined }, "no saved snapshot names this seat"],
|
||||
[{ podAware: true, startupContext: true, runtimeError: "No session was started for this seat." }, "restore stops for a runtime decision"],
|
||||
])("reads a restore path it can't settle as unknown with the reason, never a definite mode (%o)", (path, words) => {
|
||||
const { db, rigRepo, node } = seat("claude-code");
|
||||
attach(rigRepo, db, node.id, "builtin:yolo", "full_bypass");
|
||||
const restored = planRestorePermission(db, { ...inputs(node.id, () => "floor"), ...path }, env);
|
||||
expect(restored).toEqual({ basis: "unknown", runtime: "claude-code", summary: expect.stringContaining(words) });
|
||||
expect(restored.summary).not.toContain("On restore");
|
||||
});
|
||||
|
||||
it("plans only the path restore's posture is exact for: a pod seat with saved startup context", () => {
|
||||
const { db, node } = seat("claude-code");
|
||||
const restored = planRestorePermission(db, inputs(node.id, () => "floor"), env);
|
||||
expect(restored.basis).toBe("planned");
|
||||
expect(restored.summary.startsWith("On restore: ")).toBe(true);
|
||||
expect(restored.basis === "planned" ? restored.unknown.join(" ") : "").toContain("This is the plan for restoring this seat");
|
||||
});
|
||||
|
||||
// Startup makes the permission decision for every runtime, Pi included, and fails the seat on its error.
|
||||
it("reads unknown when startup would refuse a Pi seat's choice, made for another runtime or unreadable", () => {
|
||||
const { db, node } = seat("pi");
|
||||
expect(planRestorePermission(db, { ...inputs(node.id, () => "floor"), runtime: "pi" }, env))
|
||||
.toMatchObject({ basis: "planned", summary: "Pi has no native permission policy; its resource trust is set at launch." });
|
||||
new NativePermissionStore(db).write(node.id, { runtime: "claude-code", mode: "plan" }, "person@host", "before the switch");
|
||||
const changed = planRestorePermission(db, { ...inputs(node.id, () => "floor"), runtime: "pi" }, env);
|
||||
expect(changed).toMatchObject({ basis: "unknown", runtime: "pi" });
|
||||
expect(changed.summary).toContain("Seat runtime changed since permission selection");
|
||||
// A stored choice is only ever Claude's or Codex's (the table checks it); an unreadable mode fails startup's read first.
|
||||
db.prepare("UPDATE node_permission_selections SET mode = 'not-a-mode' WHERE node_id = ?").run(node.id);
|
||||
const unreadable = planRestorePermission(db, { ...inputs(node.id, () => "floor"), runtime: "pi" }, env);
|
||||
expect(unreadable).toMatchObject({ basis: "unknown", runtime: "pi" });
|
||||
expect(unreadable.summary).toContain("Invalid persisted native permission selection");
|
||||
expect(unreadable.summary).not.toContain("Pi has no native permission policy");
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildRestorePlanPreview, collectPreviewSessionRows, type PreviewSessionRow } from "../src/domain/restore-plan-preview.js";
|
||||
import { buildRestorePlanPreview, collectPreviewSessionRows, type PreviewSessionRow, type RestorePermissionSeat } from "../src/domain/restore-plan-preview.js";
|
||||
import type { RigWithRelations, Snapshot } from "../src/domain/types.js";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { planRestorePermission } from "../src/domain/permission-account.js";
|
||||
|
||||
// OPR.0.4.3.20 FR-6 — restore-plan per-seat token state (read-only forecast).
|
||||
|
||||
@@ -254,3 +255,60 @@ describe("FR-6 live path — DB → collectPreviewSessionRows → plan state", (
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("restore plan permission per seat", () => {
|
||||
const snapshotOf = (nodes: Array<{ id: string; logicalId: string; runtime: string; podId: string | null }>, startup: Record<string, { runtime: string }>) =>
|
||||
({ id: "snap", kind: "manual", createdAt: "now", data: {
|
||||
nodes, sessions: [row("n1")], activeSessionIdByNode: { n1: "s-n1" }, nodeStartupContext: startup,
|
||||
} } as unknown as Snapshot);
|
||||
|
||||
it("asks for each seat restore launches, with the facts restore reads from the snapshot, and none for an attached seat", () => {
|
||||
const rig = rigWith([{ id: "n1", logicalId: "dev.impl", runtime: "claude-code" }, { id: "n2", logicalId: "dev.qa", runtime: "codex" },
|
||||
{ id: "n3", logicalId: "dev.old", runtime: "claude-code" }]);
|
||||
(rig.nodes[1] as unknown as { binding: unknown }).binding = { attachmentType: "external_cli", externalSessionName: "qa-cli", tmuxSession: null };
|
||||
const snapshot = snapshotOf([{ id: "n1", logicalId: "dev.impl", runtime: "claude-code", podId: "pod-1" },
|
||||
{ id: "n2", logicalId: "dev.qa", runtime: "codex", podId: "pod-1" }, { id: "n3", logicalId: "dev.old", runtime: "claude-code", podId: null }],
|
||||
{ n1: { runtime: "codex" } });
|
||||
const seen: RestorePermissionSeat[] = [];
|
||||
const preview = buildRestorePlanPreview(rig, snapshot, [row("n1")], undefined, NOW, {}, undefined, (seat) => {
|
||||
seen.push(seat);
|
||||
return { basis: "unknown", runtime: seat.runtime, summary: `planned ${seat.logicalId}` };
|
||||
});
|
||||
// n1's saved startup context names Codex while it runs Claude now: restore stops for that decision before launch
|
||||
// (restoreRuntimeCompatibilityError), so its seat carries restore's own reason, and the plan awaits the decision.
|
||||
expect(preview.nodes[0]).toMatchObject({ intendedAction: "awaiting-decision" });
|
||||
expect(seen).toEqual([
|
||||
{ nodeId: "n1", logicalId: "dev.impl", seat: "dev-impl@test-rig", runtime: "codex", podAware: true, startupContext: true,
|
||||
runtimeError: "No session was started for this seat. Restore a snapshot whose saved startup runtime matches the seat's current runtime." },
|
||||
{ nodeId: "n3", logicalId: "dev.old", seat: "dev-old@test-rig", runtime: "claude-code", podAware: false, startupContext: false },
|
||||
]);
|
||||
expect(preview.nodes.map((n) => n.permission?.summary)).toEqual(["planned dev.impl", undefined, "planned dev.old"]);
|
||||
});
|
||||
|
||||
it("plans a live Claude seat whose saved startup runtime is Codex as unknown with restore's reason, beside its awaiting decision", () => {
|
||||
const rig = rigWith([{ id: "n1", logicalId: "dev.impl", runtime: "claude-code" }]);
|
||||
const snapshot = snapshotOf([{ id: "n1", logicalId: "dev.impl", runtime: "codex", podId: "pod-1" }], { n1: { runtime: "codex" } });
|
||||
const db = createFullTestDb();
|
||||
try {
|
||||
// The same planner `rig up --existing --plan` passes; no explicit choice is stored for the seat.
|
||||
const preview = buildRestorePlanPreview(rig, snapshot, [row("n1")], undefined, NOW, {}, undefined,
|
||||
(seat) => planRestorePermission(db, { ...seat, restorePosture: () => "floor" }));
|
||||
expect(preview.nodes[0]).toMatchObject({ intendedAction: "awaiting-decision", permission: { basis: "unknown" } });
|
||||
expect(preview.nodes[0]!.permission!.summary).toContain("restore stops for a runtime decision before it starts this seat.");
|
||||
expect(preview.nodes[0]!.permission!.summary).not.toContain("On restore");
|
||||
} finally { db.close(); }
|
||||
});
|
||||
|
||||
it("carries no permission when the caller doesn't plan one, so the other plan callers are unchanged", () => {
|
||||
const rig = rigWith([{ id: "n1", logicalId: "dev.impl", runtime: "claude-code" }]);
|
||||
const preview = buildRestorePlanPreview(rig, snapshotOf([{ id: "n1", logicalId: "dev.impl", runtime: "claude-code", podId: "pod-1" }], {}), [row("n1")], undefined, NOW);
|
||||
expect(preview.nodes[0]).not.toHaveProperty("permission");
|
||||
});
|
||||
|
||||
it("leaves the path facts unset for a seat no saved snapshot names", () => {
|
||||
const rig = rigWith([{ id: "n1", logicalId: "dev.impl", runtime: "claude-code" }]);
|
||||
const seen: RestorePermissionSeat[] = [];
|
||||
buildRestorePlanPreview(rig, null, [row("n1")], undefined, NOW, {}, undefined, (seat) => { seen.push(seat); return { basis: "unknown", runtime: null, summary: "x" }; });
|
||||
expect(seen).toEqual([{ nodeId: "n1", logicalId: "dev.impl", seat: "dev-impl@test-rig", runtime: "claude-code" }]);
|
||||
});
|
||||
});
|
||||
@@ -245,6 +245,8 @@ describe("up --existing keeps proved current stops", () => {
|
||||
expect(preview.status).toBe(200); expect(applied.status).toBe(200);
|
||||
expect(plan.nodes[0].intendedAction).toBe(fresh ? "fresh-primed" : "awaiting-decision");
|
||||
expect(result.nodes[0].status).toBe(plan.nodes[0].intendedAction);
|
||||
// The plan shows the permission restore will start the seat with, and stays read-only (total_changes above).
|
||||
expect(plan.nodes[0].permission?.summary).toEqual(expect.any(String));
|
||||
if (!fresh) {
|
||||
const command = "rig seat launch 'dev.worker@stopped-review' --fresh --reason";
|
||||
expect(plan.nodes[0].reason).toContain(command);
|
||||
|
||||
Reference in new issue
Block a user