Files
openrig/packages/daemon/src/domain/seat-status-service.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

255 lines
13 KiB
TypeScript

import type { ClaudeCompactionEnforcer, ObservedCompaction } from "./claude-compaction-enforcer.js";
import path from "node:path";
import { TerminalPolicyStore } from "./terminal-policy.js";
import type { RigRepository } from "./rig-repository.js";
import { deriveCanonicalFromEntry, getNodeInventory } from "./node-inventory.js";
import { parseSessionName } from "./session-name.js";
import { sessionCommunityRunnerSelect } from "./session-registry.js";
import type { NodeInventoryEntry, SeatIdentityVerdict } from "./types.js";
import { NativePermissionStore, type StoredNativePermissionSelection, type ResolvedSeatPermission } from "./native-permission-store.js";
import { AppliedLaunchObservationStore, type StoredAppliedLaunchObservation } from "./applied-launch-observation-store.js";
import { planPermissionAccount, type PermissionAccount } from "./permission-account.js";
import { seatAccountHome, RUNTIME_HOME_VAR, seatRuntimeHome, type HomeRuntime } from "./seat-runtime-home.js";
import { claudeSeatTokenFallbackNotice, claudeSeatTokenFileProblem, readClaudeSeatTokenRef } from "./claude-seat-token.js";
import { unavailablePermissionStop, type PermissionStopStatus } from "./permission-stop-observation.js";
import type { AgentActivityStore } from "./agent-activity-store.js";
const SEAT_LOOKUP_GUIDANCE = "List seats with: rig ps --nodes";
export interface SeatStatus {
/** Latest bounded observation across actors, not aggregate pending state or proof a guard ran.
* Completion applies only to the displayed actor; other actors' pending state is unknown. */
permission_stop?: PermissionStopStatus;
compaction?: ObservedCompaction & { owedRestore: ReturnType<ClaudeCompactionEnforcer["getOwedRestore"]> };
seat_ref: string;
rig_id: string;
rig_name: string;
logical_id: string;
pod_id: string | null;
pod_namespace: string | null;
runtime: string | null;
/** Configured cwd for future launches; an existing process can still use its old cwd. */
launch_cwd: string | null;
/** The registered person who answers this seat's runner permission requests; null when no one is chosen. */
permissions_to: string | null;
/** Whether the seat's newest session was launched as a community runner, the kind that submits permission requests. */
runner: boolean;
current_occupant: string | null;
session_status: string | null;
session_reason: SeatIdentityVerdict["reason"];
startup_status: NodeInventoryEntry["startupStatus"];
occupant_lifecycle: NodeInventoryEntry["occupantLifecycle"];
continuity_outcome: NodeInventoryEntry["continuityOutcome"];
handover_result: NodeInventoryEntry["handoverResult"];
previous_occupant: string | null;
handover_at: string | null;
restore_outcome: NodeInventoryEntry["restoreOutcome"];
/** Claude, Codex and Pi seats: the home (and with it the login) the seat's launches use. Configured, not proof of
* which login a running process holds. `ignored` is a declared value that isn't an absolute path. */
runtime_home?: {
variable: "CLAUDE_CONFIG_DIR" | "CODEX_HOME" | "PI_CODING_AGENT_DIR";
home: string | null;
source: "member" | "rig" | "account-switch" | "daemon" | "native_default";
ignored?: { source: "member" | "rig" | "account-switch"; value: string };
};
/** A Claude seat's own subscription token: its reference (never the value), and whether the file would give Claude the
* token at the next launch. When it wouldn't, Claude starts on the config dir's stored login, and `notice` says so. */
claude_token?: { kind?: "api-key"; label: string; token_file: string; file: "readable" | "unusable"; problem?: string; notice?: string };
terminalPolicy: ReturnType<TerminalPolicyStore["account"]> | { state: "unknown"; error: string };
permissions: {
selectionState: "explicit" | "inherit" | "unknown";
desired: StoredNativePermissionSelection | null;
lastLaunchArguments: StoredAppliedLaunchObservation | null;
nativeEffect: "unverified";
error?: string;
effective?: ResolvedSeatPermission;
/** The planned account for the next fresh managed launch (permission-account.ts); absent when it can't be read. */
account?: PermissionAccount;
};
}
export type SeatStatusResult =
| { ok: true; status: SeatStatus }
| { ok: false; code: "seat_ref_required" | "seat_not_found"; message: string; guidance: string }
| { ok: false; code: "seat_ambiguous"; message: string; guidance: string; matches: Array<{ rig_name: string; logical_id: string; current_occupant: string | null }> };
interface SeatMatch {
entry: NodeInventoryEntry;
}
export class SeatStatusService {
private rigRepo: RigRepository;
private env: NodeJS.ProcessEnv;
private compactionEnforcer?: Pick<ClaudeCompactionEnforcer, "recoverSeat" | "observedCompaction" | "getOwedRestore">;
private activityStore?: Pick<AgentActivityStore, "getPermissionStopForNode">;
constructor(deps: { rigRepo: RigRepository; env?: NodeJS.ProcessEnv; compactionEnforcer?: SeatStatusService["compactionEnforcer"];
activityStore?: SeatStatusService["activityStore"] }) {
this.activityStore = deps.activityStore;
this.compactionEnforcer = deps.compactionEnforcer;
this.rigRepo = deps.rigRepo;
this.env = deps.env ?? process.env;
}
getStatus(seatRef: string): SeatStatusResult {
const ref = seatRef.trim();
if (!ref) {
return { ok: false, code: "seat_ref_required", message: "seat reference is required", guidance: SEAT_LOOKUP_GUIDANCE };
}
const matches = this.findMatches(ref);
if (matches.length === 0) {
return { ok: false, code: "seat_not_found", message: `Seat "${ref}" not found`, guidance: SEAT_LOOKUP_GUIDANCE };
}
if (matches.length > 1) {
return {
ok: false,
code: "seat_ambiguous",
message: `Seat "${ref}" matched multiple nodes`,
guidance: SEAT_LOOKUP_GUIDANCE,
matches: matches.map(({ entry }) => ({
rig_name: entry.rigName,
logical_id: entry.logicalId,
current_occupant: entry.canonicalSessionName,
})),
};
}
return { ok: true, status: this.projectStatus(ref, matches[0]!.entry) };
}
getStatusByNodeId(nodeId: string): SeatStatusResult {
const id = nodeId.trim();
if (!id) return { ok: false, code: "seat_ref_required", message: "node ID is required", guidance: SEAT_LOOKUP_GUIDANCE };
const row = this.rigRepo.db.prepare(`SELECT n.rig_id FROM nodes n JOIN rigs r ON r.id = n.rig_id
WHERE n.id = ? AND r.archived_at IS NULL`).get(id) as { rig_id: string } | undefined;
const entry = row && this.entriesForRig(row.rig_id).find(candidate => candidate.nodeId === id);
if (!entry) return { ok: false, code: "seat_not_found", message: `Node "${id}" not found in an active rig.`, guidance: SEAT_LOOKUP_GUIDANCE };
return { ok: true, status: this.projectStatus(entry.canonicalSessionName ?? deriveCanonicalFromEntry(entry) ?? entry.logicalId, entry) };
}
private findMatches(ref: string): SeatMatch[] {
// OPR.0.4.6.MH1 FR-8: the shared parse contract (greedy first-@ rig).
const parsed = parseSessionName(ref);
if (parsed.kind === "canonical") {
const localRef = parsed.member;
const rigName = parsed.rig;
const rigs = this.rigRepo.findUnarchivedRigsByName(rigName);
return rigs.flatMap((rig) => this.entriesForRig(rig.id).filter((entry) =>
entry.canonicalSessionName === ref
|| deriveCanonicalFromEntry(entry) === ref
|| entry.logicalId === localRef
).map((entry) => ({ entry })));
}
return this.rigRepo.listRigs().flatMap((rig) =>
this.entriesForRig(rig.id).filter((entry) =>
entry.canonicalSessionName === ref || entry.logicalId === ref
).map((entry) => ({ entry }))
);
}
private entriesForRig(rigId: string): NodeInventoryEntry[] {
return getNodeInventory(this.rigRepo.db, rigId);
}
private projectStatus(seatRef: string, entry: NodeInventoryEntry): SeatStatus {
return {
permission_stop: this.permissionStopStatus(entry),
...this.compactionStatus(entry),
seat_ref: seatRef,
rig_id: entry.rigId,
rig_name: entry.rigName,
logical_id: entry.logicalId,
pod_id: entry.podId,
pod_namespace: entry.podNamespace ?? null,
runtime: entry.runtime,
launch_cwd: entry.cwd,
permissions_to: this.rigRepo.getNodePermissionsTo(entry.nodeId),
runner: this.launchedAsRunner(entry.nodeId),
current_occupant: entry.canonicalSessionName,
session_status: entry.sessionStatus,
session_reason: entry.identityVerdict?.reason ?? null,
startup_status: entry.startupStatus,
occupant_lifecycle: entry.occupantLifecycle,
continuity_outcome: entry.continuityOutcome,
handover_result: entry.handoverResult,
previous_occupant: entry.previousOccupant,
handover_at: entry.handoverAt,
restore_outcome: entry.restoreOutcome,
...this.runtimeHomeStatus(entry.nodeId, entry.runtime),
...this.claudeTokenStatus(entry.nodeId, entry.runtime),
permissions: this.permissionStatus(entry.nodeId, entry.runtime, entry.canonicalSessionName ?? deriveCanonicalFromEntry(entry) ?? seatRef),
terminalPolicy: this.terminalPolicyStatus(entry.nodeId, entry.runtime),
};
}
private permissionStopStatus(entry: NodeInventoryEntry): PermissionStopStatus {
try {
const observed = entry.canonicalSessionName ? this.activityStore?.getPermissionStopForNode(entry.nodeId, entry.canonicalSessionName) : undefined;
return entry.occupantLifecycle === "active" && entry.sessionStatus === "running" ? observed ?? unavailablePermissionStop()
: unavailablePermissionStop();
}
catch { return unavailablePermissionStop(); }
}
private compactionStatus(entry: NodeInventoryEntry): Pick<SeatStatus, "compaction"> {
const enforcer = this.compactionEnforcer, session = entry.canonicalSessionName;
if (!enforcer || entry.runtime !== "claude-code" || !session) return {};
enforcer.recoverSeat({ sessionName: session, cwd: entry.cwd, runtime: entry.runtime });
const observed = enforcer.observedCompaction(session);
return observed ? { compaction: { ...observed, owedRestore: enforcer.getOwedRestore(session) } } : {};
}
private launchedAsRunner(nodeId: string): boolean {
const db = this.rigRepo.db;
const row = db.prepare(`SELECT ${sessionCommunityRunnerSelect(db)} AS community_runner FROM sessions s WHERE s.node_id = ? ORDER BY s.id DESC LIMIT 1`)
.get(nodeId) as { community_runner: number | null } | undefined;
return row?.community_runner === 1;
}
private runtimeHomeStatus(nodeId: string, runtime: string | null): Pick<SeatStatus, "runtime_home"> {
if (!runtime || !Object.hasOwn(RUNTIME_HOME_VAR, runtime)) return {};
const variable = RUNTIME_HOME_VAR[runtime as HomeRuntime];
const own = seatRuntimeHome(this.rigRepo.db, nodeId, runtime as HomeRuntime);
const ignored = own.ignored ? { ignored: own.ignored } : {};
if (own.home && own.source) return { runtime_home: { variable, home: own.home, source: own.source } };
// The daemon's explicit value, as its launches pass it on; else the runtime's native default under HOME.
const daemon = runtime === "pi" ? undefined : this.env[variable];
if (daemon) return { runtime_home: { variable, home: daemon, source: "daemon", ...ignored } };
const home = this.env.HOME;
// Unconfigured Pi uses its managed seat directory, not the operator's ~/.pi/agent login.
const native = home && runtime !== "pi" ? path.join(home, runtime === "codex" ? ".codex" : ".claude") : null;
return { runtime_home: { variable, home: native, source: "native_default", ...ignored } };
}
/** Report a Claude token's stored reference and file availability without reading its value or verifying the login. */
private claudeTokenStatus(nodeId: string, runtime: string | null): Pick<SeatStatus, "claude_token"> {
if (runtime !== "claude-code") return {};
const ref = readClaudeSeatTokenRef(this.rigRepo.db, nodeId);
if (!ref) return {};
const problem = claudeSeatTokenFileProblem(ref);
return { claude_token: { ...(ref.kind ? { kind: ref.kind } : {}), label: ref.label, token_file: ref.tokenFile, file: problem ? "unusable" : "readable",
...(problem ? { problem, notice: seatAccountHome(this.rigRepo.db, nodeId, "claude-code")
? "The selected account token is unusable; no agent will start." : claudeSeatTokenFallbackNotice(ref, problem) } : {}) } };
}
private terminalPolicyStatus(nodeId: string, runtime: string | null): SeatStatus["terminalPolicy"] {
try { return new TerminalPolicyStore(this.rigRepo.db).account(nodeId, runtime); }
catch (error) { return { state: "unknown", error: (error as Error).message }; }
}
private permissionStatus(nodeId: string, runtime: string | null | undefined, seat: string): SeatStatus["permissions"] {
const lastLaunchArguments = new AppliedLaunchObservationStore(this.rigRepo.db).readCurrent(nodeId);
try {
const store = new NativePermissionStore(this.rigRepo.db);
const desired = store.read(nodeId);
const effective = runtime ? store.resolve(nodeId, runtime) : undefined;
const account = planPermissionAccount(this.rigRepo.db, nodeId, seat, runtime ?? null);
return { selectionState: desired ? "explicit" : "inherit", desired, lastLaunchArguments, nativeEffect: "unverified", ...(effective ? { effective } : {}), account };
} catch (error) {
return { selectionState: "unknown", desired: null, lastLaunchArguments, nativeEffect: "unverified", error: (error as Error).message };
}
}
}