mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 11:51:37 -10:00
feat(daemon,cli): kernel auto-boot path + --no-kernel flag + listDestinations operator-seat fallback
0.3.1 slice 05 kernel-rig-as-default — Phase 05d. Adds the daemon
startup hook that auto-boots the kernel rig on `rig daemon start`,
per IMPL-PRD §6.1 + amended §16.2. Forensic finding from the 05d
opener confirmed current daemon-restart already does NOT auto-restore
any rig (reconciler marks-stale-as-detached only; restoreOrchestrator
.restore() invoked only from explicit operator paths). HG-20
kernel-only-auto-restart is satisfied by-construction once the
kernel-boot path lands — no existing auto-restore-all to remove.
Founder OQ decisions captured per HG-18/HG-19:
- OQ-A = Option B (migration ceremony at 0.3.1 upgrade)
- OQ-B = L2 (rig daemon start owns kernel boot; rig setup invokes it)
Behavior:
- New module packages/daemon/src/domain/kernel-boot.ts exports
bootKernelIfNeeded(deps): KernelBootResult with 6 outcomes
(booted / skipped_no_kernel_flag / skipped_already_managed /
auth_blocked / spec_missing / bootstrap_failed). Composed in
startup.ts after BootstrapOrchestrator construction.
- Short-circuits:
- OPENRIG_NO_KERNEL=1 → skipped_no_kernel_flag (CLI --no-kernel
flag projected via daemon-lifecycle.ts buildDaemonEnv).
- Existing rig named `kernel` in rigs table →
skipped_already_managed (covers the OQ-A=B migration path where
operator copied the substrate kernel into ~/.openrig/specs/rigs/
before the V0.3.1 upgrade).
- Variant selection: probeRuntimes() (default shells out to
`claude auth status` + `codex login status` with 5s timeout) →
selectVariant() returns rig.yaml / rig-claude-only.yaml /
rig-codex-only.yaml. Both-unavailable → auth_blocked with the
3-part-error message (Error: / Reason: / Fix:) per the
building-agent-software skill discipline (HG-3).
- Cold boot: BootstrapOrchestrator.bootstrap({mode:"apply",
sourceRef: <variant-path>, sourceKind:"rig_spec",
autoApprove:true}). Errors during bootstrap are logged but do NOT
crash the daemon — the operator can retry manually.
- 3 rig variants now declare `runtime: terminal` on the
operator.human builtin:terminal member (validation requires it).
CLI surface:
- `rig daemon start --no-kernel` skips kernel auto-boot. Commander's
--no-kernel inverts to opts.kernel === false; thread through to
StartOptions.skipKernelBoot → buildDaemonEnv emits
OPENRIG_NO_KERNEL=1 in the daemon process env.
Mission-control fallback (HG-10):
- packages/daemon/src/routes/mission-control.ts listDestinations now
accepts an optional operatorSeatFallback parameter. The GET
/destinations route handler reads the configured operator seat
via MissionControlReadLayer.getDefaultOperatorSession() (NEW
public getter) and includes it in the destinations list even when
the kernel hasn't booted yet — picker always offers the configured
seat. Deduplicated: when the seat is already in topology or queue
history, no duplicate emitted.
Tests (16 new):
- packages/daemon/test/kernel-boot.test.ts covers selectVariant
(3 cases), kernelAlreadyManaged (3 cases), authBlockMessage
3-part-error contract, bootKernelIfNeeded short-circuit branches
(no-kernel-flag, already-managed, auth-blocked, spec-missing),
bootKernelIfNeeded happy path with all 3 variants + bootstrap
errors (orchestrator-reported + thrown).
Verification:
- npx tsc --noEmit on daemon + cli: CLEAN
- npx vitest run packages/daemon full suite: 3152 PASS / 1 skipped
/ 0 failures. No regressions in existing startup, mission-control,
user-settings, or read-layer test surfaces.
SC-29 scope: no SQL migrations / new endpoints / new event types.
The new daemon-side code uses existing tables + existing route
handlers + existing event sinks. Workspace.operator_seat_name setting
extension lives in Phase 05b's existing allowlist plumbing. SC-29
EXCEPTION ledger unchanged.
Next phase per IMPL-PRD §11 + §16.5:
- 05e/05f — end-to-end disposable Tart VM lifecycle dogfood per
HG-23 (consolidated v0 success criterion). Founder-flagged VM
rehearsal of the OQ-A=B migration ceremony per the earlier
reinforcement note. velocity-qa owns the sustained VM loop;
driver hands off after this commit per established cadence.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
575f2b8ca0
commit
4bf1fce9d0
10 files changed
+488
-3
No files matched your search
@@ -77,7 +77,13 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
|
||||
.option("--port <port>", "Port to listen on")
|
||||
.option("--host <host>", "Host to bind on")
|
||||
.option("--db <path>", "Database path")
|
||||
.action(async (opts: { port?: string; host?: string; db?: string }) => {
|
||||
// V0.3.1 slice 05 kernel-rig-as-default — skip the kernel auto-boot
|
||||
// path. Used by test fixtures, headless CI, and operators who want
|
||||
// a no-kernel daemon for ad-hoc topology work. The daemon proceeds
|
||||
// and serves its HTTP API normally; just doesn't materialize the
|
||||
// kernel rig.
|
||||
.option("--no-kernel", "Skip kernel auto-boot (daemon serves without the kernel rig)")
|
||||
.action(async (opts: { port?: string; host?: string; db?: string; kernel?: boolean }) => {
|
||||
try {
|
||||
const { ConfigStore } = await import("../config-store.js");
|
||||
const { SystemPreflight } = await import("../system-preflight.js");
|
||||
@@ -105,6 +111,8 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
|
||||
return;
|
||||
}
|
||||
|
||||
// V0.3.1 slice 05 — Commander's --no-kernel inverts to opts.kernel === false.
|
||||
const skipKernel = opts.kernel === false;
|
||||
const state = await startDaemon(
|
||||
{
|
||||
port: effectivePort,
|
||||
@@ -120,6 +128,10 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
|
||||
// reach the rotation hook.
|
||||
transcriptsLines: config.transcripts.lines,
|
||||
transcriptsPollIntervalSeconds: config.transcripts.pollIntervalSeconds,
|
||||
// V0.3.1 slice 05 kernel-rig-as-default — propagated via
|
||||
// OPENRIG_NO_KERNEL env var so the daemon's kernel-boot
|
||||
// check in startup.ts honors the flag.
|
||||
skipKernelBoot: skipKernel,
|
||||
},
|
||||
getDeps(),
|
||||
);
|
||||
|
||||
@@ -39,6 +39,11 @@ export interface StartOptions {
|
||||
// picks up file-stored ConfigStore values, not just shell env.
|
||||
transcriptsLines?: number;
|
||||
transcriptsPollIntervalSeconds?: number;
|
||||
// V0.3.1 slice 05 kernel-rig-as-default — when true, daemon startup
|
||||
// skips the kernel auto-boot path (kernel rig is not materialized).
|
||||
// Exposed at the CLI as `rig daemon start --no-kernel`; projected
|
||||
// into the daemon process env as OPENRIG_NO_KERNEL.
|
||||
skipKernelBoot?: boolean;
|
||||
}
|
||||
|
||||
export interface LifecycleDeps {
|
||||
@@ -251,6 +256,9 @@ export function buildDaemonEnv(
|
||||
// shell env.
|
||||
transcriptsLines?: number;
|
||||
transcriptsPollIntervalSeconds?: number;
|
||||
// V0.3.1 slice 05 — projected via OPENRIG_NO_KERNEL env var so
|
||||
// the daemon's startup.ts kernel-boot path honors the flag.
|
||||
skipKernelBoot?: boolean;
|
||||
},
|
||||
): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
@@ -277,6 +285,9 @@ export function buildDaemonEnv(
|
||||
if (opts.transcriptsPollIntervalSeconds !== undefined) {
|
||||
env["OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS"] = String(opts.transcriptsPollIntervalSeconds);
|
||||
}
|
||||
if (opts.skipKernelBoot) {
|
||||
env["OPENRIG_NO_KERNEL"] = "1";
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
@@ -330,6 +341,7 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
|
||||
transcriptsPath: opts.transcriptsPath,
|
||||
transcriptsLines: opts.transcriptsLines,
|
||||
transcriptsPollIntervalSeconds: opts.transcriptsPollIntervalSeconds,
|
||||
skipKernelBoot: opts.skipKernelBoot,
|
||||
}),
|
||||
stdio: ["ignore", logFd, logFd],
|
||||
detached: true,
|
||||
|
||||
@@ -22,6 +22,7 @@ pods:
|
||||
members:
|
||||
- id: human
|
||||
agent_ref: "builtin:terminal"
|
||||
runtime: terminal
|
||||
profile: default
|
||||
cwd: "."
|
||||
- id: agent
|
||||
|
||||
@@ -22,6 +22,7 @@ pods:
|
||||
members:
|
||||
- id: human
|
||||
agent_ref: "builtin:terminal"
|
||||
runtime: terminal
|
||||
profile: default
|
||||
cwd: "."
|
||||
- id: agent
|
||||
|
||||
@@ -28,6 +28,7 @@ pods:
|
||||
# startup actions. Real human interaction routes through the
|
||||
# operator agent + the host shell.
|
||||
agent_ref: "builtin:terminal"
|
||||
runtime: terminal
|
||||
profile: default
|
||||
cwd: "."
|
||||
- id: agent
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
// V0.3.1 slice 05 kernel-rig-as-default — kernel auto-boot path.
|
||||
//
|
||||
// Composes into rig daemon start per IMPL-PRD §6.1: probes runtime
|
||||
// auth state, selects a variant (dual / claude-only / codex-only),
|
||||
// and bootstraps the kernel rig via the in-process bootstrap pipeline.
|
||||
// Short-circuits cleanly when:
|
||||
// - OPENRIG_NO_KERNEL=1 is set (operator opt-out / test fixture)
|
||||
// - A managed rig named `kernel` already exists in the DB
|
||||
// Surfaces a 3-part-error (fact / reason / fix) when neither Claude
|
||||
// Code nor Codex is authenticated. Failures during the bootstrap
|
||||
// itself are logged but do NOT crash the daemon — the daemon serves
|
||||
// without the kernel and the operator can retry manually.
|
||||
|
||||
import nodePath from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import type { RigRepository } from "./rig-repository.js";
|
||||
import type { BootstrapOrchestrator } from "./bootstrap-orchestrator.js";
|
||||
|
||||
export type RuntimeAuthStatus = "ok" | "unavailable";
|
||||
|
||||
export interface RuntimeProbeResult {
|
||||
claudeCode: RuntimeAuthStatus;
|
||||
codex: RuntimeAuthStatus;
|
||||
}
|
||||
|
||||
export interface KernelBootDeps {
|
||||
rigRepo: RigRepository;
|
||||
bootstrapOrchestrator: BootstrapOrchestrator;
|
||||
/** Absolute path to the daemon's specs dir (passed in so tests can
|
||||
* inject a fixture root). Production callsite resolves to the
|
||||
* packaged `packages/daemon/specs/` location. */
|
||||
specsDir: string;
|
||||
/** Runtime auth probe — defaults to live shellouts; tests inject. */
|
||||
probeRuntimes?: () => Promise<RuntimeProbeResult>;
|
||||
/** Logger sink; defaults to console.log/warn so daemon stdout/stderr
|
||||
* carry the boot trace. */
|
||||
log?: (level: "info" | "warn" | "error", message: string) => void;
|
||||
}
|
||||
|
||||
export interface KernelBootResult {
|
||||
outcome:
|
||||
| "booted"
|
||||
| "skipped_no_kernel_flag"
|
||||
| "skipped_already_managed"
|
||||
| "auth_blocked"
|
||||
| "spec_missing"
|
||||
| "bootstrap_failed";
|
||||
/** Variant filename used when outcome=booted; null otherwise. */
|
||||
variant?: string | null;
|
||||
/** Human-readable detail for logs + tests. */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** Entry point. Idempotent: safe to call on every daemon startup. */
|
||||
export async function bootKernelIfNeeded(deps: KernelBootDeps): Promise<KernelBootResult> {
|
||||
const log = deps.log ?? defaultLog;
|
||||
|
||||
// 1. --no-kernel opt-out (CLI flag projected via OPENRIG_NO_KERNEL).
|
||||
if (process.env["OPENRIG_NO_KERNEL"] === "1") {
|
||||
log("info", "kernel-boot: OPENRIG_NO_KERNEL=1 — skipping kernel auto-boot");
|
||||
return { outcome: "skipped_no_kernel_flag" };
|
||||
}
|
||||
|
||||
// 2. Already-managed short-circuit. If a rig named `kernel` exists
|
||||
// (e.g., from a prior boot or operator-migrated substrate kernel),
|
||||
// the builtin path stays out of the way.
|
||||
if (kernelAlreadyManaged(deps.rigRepo)) {
|
||||
log("info", "kernel-boot: kernel rig already managed; skipping builtin boot");
|
||||
return { outcome: "skipped_already_managed" };
|
||||
}
|
||||
|
||||
// 3. Probe runtime auth state to pick a variant.
|
||||
const probe = await (deps.probeRuntimes ?? defaultProbeRuntimes)();
|
||||
|
||||
if (probe.claudeCode === "unavailable" && probe.codex === "unavailable") {
|
||||
log("error", authBlockMessage());
|
||||
return { outcome: "auth_blocked", detail: authBlockMessage() };
|
||||
}
|
||||
|
||||
const variant = selectVariant(probe);
|
||||
const specPath = nodePath.join(deps.specsDir, "rigs/launch/kernel", variant);
|
||||
|
||||
if (!existsSync(specPath)) {
|
||||
log("warn", `kernel-boot: spec missing at ${specPath} — skipping`);
|
||||
return { outcome: "spec_missing", detail: specPath };
|
||||
}
|
||||
|
||||
// 4. Cold boot via the in-process bootstrap pipeline.
|
||||
try {
|
||||
log("info", `kernel-boot: booting kernel rig from ${variant}`);
|
||||
const result = await deps.bootstrapOrchestrator.bootstrap({
|
||||
mode: "apply",
|
||||
sourceRef: specPath,
|
||||
sourceKind: "rig_spec",
|
||||
autoApprove: true,
|
||||
});
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
log("warn", `kernel-boot: bootstrap finished with errors: ${result.errors.join("; ")}`);
|
||||
return { outcome: "bootstrap_failed", detail: result.errors.join("; ") };
|
||||
}
|
||||
return { outcome: "booted", variant };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log("warn", `kernel-boot: bootstrap threw: ${msg}`);
|
||||
return { outcome: "bootstrap_failed", detail: msg };
|
||||
}
|
||||
}
|
||||
|
||||
/** True when a rig named `kernel` already exists in the DB. */
|
||||
export function kernelAlreadyManaged(rigRepo: RigRepository): boolean {
|
||||
const rigs = rigRepo.listRigs();
|
||||
return rigs.some((r) => r.name === "kernel");
|
||||
}
|
||||
|
||||
/** Choose a rig variant from the auth probe. */
|
||||
export function selectVariant(probe: RuntimeProbeResult): string {
|
||||
if (probe.claudeCode === "ok" && probe.codex === "ok") return "rig.yaml";
|
||||
if (probe.claudeCode === "ok") return "rig-claude-only.yaml";
|
||||
if (probe.codex === "ok") return "rig-codex-only.yaml";
|
||||
// Caller is expected to short-circuit before reaching here on the
|
||||
// both-unavailable path; defensive default keeps the type narrow.
|
||||
return "rig.yaml";
|
||||
}
|
||||
|
||||
/** Default auth probe — shells out to the runtime CLIs. */
|
||||
async function defaultProbeRuntimes(): Promise<RuntimeProbeResult> {
|
||||
const { exec } = await import("node:child_process");
|
||||
const { promisify } = await import("node:util");
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
async function tryProbe(cmd: string): Promise<RuntimeAuthStatus> {
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(cmd, { timeout: 5000 });
|
||||
const out = `${stdout}\n${stderr}`.toLowerCase();
|
||||
// Conservative parse: any indicator the CLI considers itself
|
||||
// unauthenticated marks the runtime unavailable. Tighter parsing
|
||||
// can land in a follow-up.
|
||||
if (out.includes("not logged") || out.includes("not authenticated") || out.includes("login required")) {
|
||||
return "unavailable";
|
||||
}
|
||||
return "ok";
|
||||
} catch {
|
||||
return "unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
const [claudeCode, codex] = await Promise.all([
|
||||
tryProbe("claude auth status"),
|
||||
tryProbe("codex login status"),
|
||||
]);
|
||||
|
||||
return { claudeCode, codex };
|
||||
}
|
||||
|
||||
/** Honest 3-part-error message for the auth-block path. Per IMPL-PRD
|
||||
* §6.3 + the building-agent-software skill discipline. */
|
||||
export function authBlockMessage(): string {
|
||||
return [
|
||||
"Error: Kernel rig cannot boot — no AI runtime is authenticated.",
|
||||
"Reason: Kernel rig requires at least one of Claude Code or Codex authenticated. Both are unavailable.",
|
||||
"Fix: Run `claude auth login` to authenticate Claude Code, OR `codex login` to authenticate Codex. Then run `rig daemon start` (or `rig setup`) again.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function defaultLog(level: "info" | "warn" | "error", message: string): void {
|
||||
if (level === "error") console.error(message);
|
||||
else if (level === "warn") console.warn(message);
|
||||
else console.log(message);
|
||||
}
|
||||
@@ -135,6 +135,14 @@ export class MissionControlReadLayer {
|
||||
this.defaultOperatorSession = deps.defaultOperatorSession ?? `operator-${userInfo().username}@kernel`;
|
||||
}
|
||||
|
||||
/** V0.3.1 slice 05 — public getter for the resolved operator seat
|
||||
* session. Route handlers (listDestinations, audit history) read
|
||||
* this so the picker + audit filter always offer the configured
|
||||
* operator seat even when the kernel rig isn't booted yet. */
|
||||
getDefaultOperatorSession(): string {
|
||||
return this.defaultOperatorSession;
|
||||
}
|
||||
|
||||
async readView(
|
||||
viewName: MissionControlViewName,
|
||||
opts?: { operatorSession?: string },
|
||||
|
||||
@@ -101,7 +101,10 @@ export function missionControlRoutes(opts?: MissionControlRoutesOpts): Hono {
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
function listDestinations(db: Database.Database): MissionControlDestination[] {
|
||||
function listDestinations(
|
||||
db: Database.Database,
|
||||
operatorSeatFallback?: string | null,
|
||||
): MissionControlDestination[] {
|
||||
const destinations = new Map<string, MissionControlDestination>();
|
||||
const addDestination = (candidate: MissionControlDestination) => {
|
||||
const sessionName = candidate.sessionName.trim();
|
||||
@@ -184,6 +187,24 @@ export function missionControlRoutes(opts?: MissionControlRoutesOpts): Hono {
|
||||
}
|
||||
}
|
||||
|
||||
// V0.3.1 slice 05 — belt-and-suspenders for kernel-down state: if
|
||||
// the configured operator seat isn't in the topology + queue
|
||||
// history yet (fresh install, kernel hasn't booted, or kernel
|
||||
// crashed), still include it so the picker can route to the
|
||||
// seat the rest of the daemon's mission-control read layer will
|
||||
// resolve. Source label intentionally stays "queue" so the picker
|
||||
// sort order keeps live topology entries on top.
|
||||
if (operatorSeatFallback) {
|
||||
const trimmed = operatorSeatFallback.trim();
|
||||
if (trimmed && !destinations.has(trimmed)) {
|
||||
destinations.set(trimmed, {
|
||||
sessionName: trimmed,
|
||||
label: trimmed,
|
||||
source: "queue",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...destinations.values()].sort((a, b) => {
|
||||
if (a.source !== b.source) return a.source === "topology" ? -1 : 1;
|
||||
return a.label.localeCompare(b.label);
|
||||
@@ -233,10 +254,16 @@ export function missionControlRoutes(opts?: MissionControlRoutesOpts): Hono {
|
||||
|
||||
// GET /destinations — phone-friendly route/handoff candidates. MUST precede
|
||||
// /views/:view-name catchall with the other Mission Control literal routes.
|
||||
// V0.3.1 slice 05 — resolve the operator-seat fallback from the
|
||||
// mission-control read layer's defaultOperatorSession (which itself
|
||||
// tracks the workspace.operator_seat_name setting) so the picker
|
||||
// always offers the configured operator seat even when the kernel
|
||||
// hasn't booted yet.
|
||||
app.get("/destinations", (c) => {
|
||||
const db = getDb(c);
|
||||
if (!db) return c.json({ destinations: [] });
|
||||
return c.json({ destinations: listDestinations(db) });
|
||||
const operatorSeat = getReadLayer(c).getDefaultOperatorSession();
|
||||
return c.json({ destinations: listDestinations(db, operatorSeat) });
|
||||
});
|
||||
|
||||
// SSE for mission_control.* events. MUST precede /views/:view-name
|
||||
|
||||
@@ -481,6 +481,33 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
serviceOrchestrator, rigRepo,
|
||||
});
|
||||
|
||||
// V0.3.1 slice 05 kernel-rig-as-default — auto-boot the kernel rig
|
||||
// on daemon-start. Idempotent: --no-kernel (OPENRIG_NO_KERNEL=1)
|
||||
// skips; an already-managed `kernel` rig short-circuits; the
|
||||
// variant (dual / claude-only / codex-only) is selected by probing
|
||||
// `claude auth status` + `codex login status`; both-unavailable
|
||||
// surfaces a 3-part-error and the daemon continues without the
|
||||
// kernel. The kernel rig is the only rig the daemon auto-boots —
|
||||
// other rigs require explicit operator-initiated `rig up` /
|
||||
// `rig restore` (per amended IMPL-PRD §16.2).
|
||||
try {
|
||||
const { bootKernelIfNeeded } = await import("./domain/kernel-boot.js");
|
||||
const kernelBootResult = await bootKernelIfNeeded({
|
||||
rigRepo,
|
||||
bootstrapOrchestrator,
|
||||
specsDir: nodePath.resolve(nodePath.dirname(new URL(import.meta.url).pathname), "..", "specs"),
|
||||
});
|
||||
try {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`kernel-boot: outcome=${kernelBootResult.outcome}${kernelBootResult.variant ? ` variant=${kernelBootResult.variant}` : ""}`);
|
||||
} catch { /* logging must never throw */ }
|
||||
} catch (err) {
|
||||
try {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`kernel-boot: skipped due to error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
} catch { /* logging must never throw */ }
|
||||
}
|
||||
|
||||
// Discovery services
|
||||
const tmuxScanner = new TmuxDiscoveryScanner({ tmuxAdapter });
|
||||
const sessionFingerprinter = new SessionFingerprinter({
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
// V0.3.1 slice 05 kernel-rig-as-default — kernel auto-boot tests.
|
||||
//
|
||||
// Covers HG-2 (variant selection), HG-3 (auth-block 3-part error),
|
||||
// HG-4 (already-managed short-circuit), HG-6 (--no-kernel flag via
|
||||
// OPENRIG_NO_KERNEL env). Lifecycle in production is bootstrap-driven
|
||||
// (BootstrapOrchestrator.bootstrap()); these tests inject a mocked
|
||||
// orchestrator + probe so the logic is exercised without invoking the
|
||||
// in-process bootstrap pipeline.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
bootKernelIfNeeded,
|
||||
selectVariant,
|
||||
kernelAlreadyManaged,
|
||||
authBlockMessage,
|
||||
type KernelBootDeps,
|
||||
type RuntimeProbeResult,
|
||||
} from "../src/domain/kernel-boot.js";
|
||||
import type { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import type { BootstrapOrchestrator } from "../src/domain/bootstrap-orchestrator.js";
|
||||
|
||||
function makeRigRepo(existingRigs: Array<{ name: string }>): RigRepository {
|
||||
return { listRigs: () => existingRigs } as unknown as RigRepository;
|
||||
}
|
||||
|
||||
function makeBootstrapMock(result?: { errors?: string[]; throwError?: Error }) {
|
||||
return {
|
||||
bootstrap: vi.fn(async () => {
|
||||
if (result?.throwError) throw result.throwError;
|
||||
return {
|
||||
runId: "test",
|
||||
status: "ok",
|
||||
stages: [],
|
||||
errors: result?.errors ?? [],
|
||||
warnings: [],
|
||||
};
|
||||
}),
|
||||
} as unknown as BootstrapOrchestrator;
|
||||
}
|
||||
|
||||
let tmpSpecsDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpSpecsDir = mkdtempSync(join(tmpdir(), "kernel-boot-"));
|
||||
// Populate the 3 variant files so existsSync(specPath) succeeds.
|
||||
const kernelDir = join(tmpSpecsDir, "rigs/launch/kernel");
|
||||
mkdirSync(kernelDir, { recursive: true });
|
||||
writeFileSync(join(kernelDir, "rig.yaml"), "name: kernel\n");
|
||||
writeFileSync(join(kernelDir, "rig-claude-only.yaml"), "name: kernel\n");
|
||||
writeFileSync(join(kernelDir, "rig-codex-only.yaml"), "name: kernel\n");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.OPENRIG_NO_KERNEL;
|
||||
if (tmpSpecsDir) rmSync(tmpSpecsDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("selectVariant — auth-state → variant mapping", () => {
|
||||
it("picks rig.yaml when both runtimes available", () => {
|
||||
expect(selectVariant({ claudeCode: "ok", codex: "ok" })).toBe("rig.yaml");
|
||||
});
|
||||
it("picks rig-claude-only.yaml when only Claude available", () => {
|
||||
expect(selectVariant({ claudeCode: "ok", codex: "unavailable" })).toBe("rig-claude-only.yaml");
|
||||
});
|
||||
it("picks rig-codex-only.yaml when only Codex available", () => {
|
||||
expect(selectVariant({ claudeCode: "unavailable", codex: "ok" })).toBe("rig-codex-only.yaml");
|
||||
});
|
||||
});
|
||||
|
||||
describe("kernelAlreadyManaged — short-circuit predicate", () => {
|
||||
it("returns true when a rig named 'kernel' exists", () => {
|
||||
expect(kernelAlreadyManaged(makeRigRepo([{ name: "kernel" }, { name: "other" }]))).toBe(true);
|
||||
});
|
||||
it("returns false when no rig is named 'kernel' (case-sensitive)", () => {
|
||||
expect(kernelAlreadyManaged(makeRigRepo([{ name: "Kernel" }, { name: "other" }]))).toBe(false);
|
||||
});
|
||||
it("returns false on an empty rig list", () => {
|
||||
expect(kernelAlreadyManaged(makeRigRepo([]))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("authBlockMessage — 3-part error contract", () => {
|
||||
it("includes Error/Reason/Fix lines per building-agent-software skill discipline", () => {
|
||||
const msg = authBlockMessage();
|
||||
expect(msg).toMatch(/^Error:/m);
|
||||
expect(msg).toMatch(/^Reason:/m);
|
||||
expect(msg).toMatch(/^Fix:/m);
|
||||
expect(msg).toContain("claude auth login");
|
||||
expect(msg).toContain("codex login");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bootKernelIfNeeded — short-circuit branches", () => {
|
||||
it("skips with outcome=skipped_no_kernel_flag when OPENRIG_NO_KERNEL=1", async () => {
|
||||
process.env.OPENRIG_NO_KERNEL = "1";
|
||||
const deps: KernelBootDeps = {
|
||||
rigRepo: makeRigRepo([]),
|
||||
bootstrapOrchestrator: makeBootstrapMock(),
|
||||
specsDir: tmpSpecsDir,
|
||||
probeRuntimes: async () => ({ claudeCode: "ok", codex: "ok" }),
|
||||
log: () => {},
|
||||
};
|
||||
const result = await bootKernelIfNeeded(deps);
|
||||
expect(result.outcome).toBe("skipped_no_kernel_flag");
|
||||
expect((deps.bootstrapOrchestrator as unknown as { bootstrap: ReturnType<typeof vi.fn> }).bootstrap).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips with outcome=skipped_already_managed when a kernel rig already exists", async () => {
|
||||
const deps: KernelBootDeps = {
|
||||
rigRepo: makeRigRepo([{ name: "kernel" }]),
|
||||
bootstrapOrchestrator: makeBootstrapMock(),
|
||||
specsDir: tmpSpecsDir,
|
||||
probeRuntimes: async () => ({ claudeCode: "ok", codex: "ok" }),
|
||||
log: () => {},
|
||||
};
|
||||
const result = await bootKernelIfNeeded(deps);
|
||||
expect(result.outcome).toBe("skipped_already_managed");
|
||||
expect((deps.bootstrapOrchestrator as unknown as { bootstrap: ReturnType<typeof vi.fn> }).bootstrap).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns auth_blocked when neither runtime is available", async () => {
|
||||
const deps: KernelBootDeps = {
|
||||
rigRepo: makeRigRepo([]),
|
||||
bootstrapOrchestrator: makeBootstrapMock(),
|
||||
specsDir: tmpSpecsDir,
|
||||
probeRuntimes: async () => ({ claudeCode: "unavailable", codex: "unavailable" }),
|
||||
log: () => {},
|
||||
};
|
||||
const result = await bootKernelIfNeeded(deps);
|
||||
expect(result.outcome).toBe("auth_blocked");
|
||||
expect(result.detail).toMatch(/^Error: Kernel rig cannot boot/);
|
||||
expect((deps.bootstrapOrchestrator as unknown as { bootstrap: ReturnType<typeof vi.fn> }).bootstrap).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns spec_missing when the chosen variant file doesn't exist", async () => {
|
||||
rmSync(join(tmpSpecsDir, "rigs/launch/kernel/rig.yaml"));
|
||||
const deps: KernelBootDeps = {
|
||||
rigRepo: makeRigRepo([]),
|
||||
bootstrapOrchestrator: makeBootstrapMock(),
|
||||
specsDir: tmpSpecsDir,
|
||||
probeRuntimes: async () => ({ claudeCode: "ok", codex: "ok" }),
|
||||
log: () => {},
|
||||
};
|
||||
const result = await bootKernelIfNeeded(deps);
|
||||
expect(result.outcome).toBe("spec_missing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bootKernelIfNeeded — happy path", () => {
|
||||
it("invokes BootstrapOrchestrator.bootstrap with the dual variant when both runtimes available", async () => {
|
||||
const bootstrap = makeBootstrapMock();
|
||||
const deps: KernelBootDeps = {
|
||||
rigRepo: makeRigRepo([]),
|
||||
bootstrapOrchestrator: bootstrap,
|
||||
specsDir: tmpSpecsDir,
|
||||
probeRuntimes: async () => ({ claudeCode: "ok", codex: "ok" }),
|
||||
log: () => {},
|
||||
};
|
||||
const result = await bootKernelIfNeeded(deps);
|
||||
expect(result.outcome).toBe("booted");
|
||||
expect(result.variant).toBe("rig.yaml");
|
||||
const bootstrapMock = (bootstrap as unknown as { bootstrap: ReturnType<typeof vi.fn> }).bootstrap;
|
||||
expect(bootstrapMock).toHaveBeenCalledOnce();
|
||||
const opts = bootstrapMock.mock.calls[0]![0];
|
||||
expect(opts.mode).toBe("apply");
|
||||
expect(opts.sourceRef).toBe(join(tmpSpecsDir, "rigs/launch/kernel", "rig.yaml"));
|
||||
expect(opts.sourceKind).toBe("rig_spec");
|
||||
expect(opts.autoApprove).toBe(true);
|
||||
});
|
||||
|
||||
it("returns bootstrap_failed and surfaces error detail when orchestrator reports errors", async () => {
|
||||
const deps: KernelBootDeps = {
|
||||
rigRepo: makeRigRepo([]),
|
||||
bootstrapOrchestrator: makeBootstrapMock({ errors: ["preflight: tmux missing"] }),
|
||||
specsDir: tmpSpecsDir,
|
||||
probeRuntimes: async () => ({ claudeCode: "ok", codex: "ok" }),
|
||||
log: () => {},
|
||||
};
|
||||
const result = await bootKernelIfNeeded(deps);
|
||||
expect(result.outcome).toBe("bootstrap_failed");
|
||||
expect(result.detail).toContain("tmux missing");
|
||||
});
|
||||
|
||||
it("returns bootstrap_failed and surfaces thrown error message", async () => {
|
||||
const deps: KernelBootDeps = {
|
||||
rigRepo: makeRigRepo([]),
|
||||
bootstrapOrchestrator: makeBootstrapMock({ throwError: new Error("network blip") }),
|
||||
specsDir: tmpSpecsDir,
|
||||
probeRuntimes: async () => ({ claudeCode: "ok", codex: "ok" }),
|
||||
log: () => {},
|
||||
};
|
||||
const result = await bootKernelIfNeeded(deps);
|
||||
expect(result.outcome).toBe("bootstrap_failed");
|
||||
expect(result.detail).toBe("network blip");
|
||||
});
|
||||
|
||||
it("uses the claude-only variant when only Claude is available", async () => {
|
||||
const bootstrap = makeBootstrapMock();
|
||||
const deps: KernelBootDeps = {
|
||||
rigRepo: makeRigRepo([]),
|
||||
bootstrapOrchestrator: bootstrap,
|
||||
specsDir: tmpSpecsDir,
|
||||
probeRuntimes: async () => ({ claudeCode: "ok", codex: "unavailable" }),
|
||||
log: () => {},
|
||||
};
|
||||
const result = await bootKernelIfNeeded(deps);
|
||||
expect(result.outcome).toBe("booted");
|
||||
expect(result.variant).toBe("rig-claude-only.yaml");
|
||||
});
|
||||
|
||||
it("uses the codex-only variant when only Codex is available", async () => {
|
||||
const bootstrap = makeBootstrapMock();
|
||||
const deps: KernelBootDeps = {
|
||||
rigRepo: makeRigRepo([]),
|
||||
bootstrapOrchestrator: bootstrap,
|
||||
specsDir: tmpSpecsDir,
|
||||
probeRuntimes: async () => ({ claudeCode: "unavailable", codex: "ok" }),
|
||||
log: () => {},
|
||||
};
|
||||
const result = await bootKernelIfNeeded(deps);
|
||||
expect(result.outcome).toBe("booted");
|
||||
expect(result.variant).toBe("rig-codex-only.yaml");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user