mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
merge: slice 22 — rig up paper-cut fix-round (release-0.3.2)
This commit is contained in:
commit
ea03680de4
16 files changed
+678
-18
No files matched your search
@@ -7,6 +7,14 @@ import type { StatusDeps } from "./status.js";
|
||||
|
||||
const LONG_RUNNING_UP_TIMEOUT_MS = 120_000;
|
||||
|
||||
// OPR.0.3.2.22 Bug 3 helper — mirrors cwd-resolution.isPathInsideRoot in
|
||||
// the daemon so the CLI can decide whether a path-form sourceRef lives
|
||||
// inside the daemon install root without importing the daemon package.
|
||||
function isPathInsideRoot(candidate: string, root: string): boolean {
|
||||
const relative = nodePath.relative(nodePath.resolve(root), nodePath.resolve(candidate));
|
||||
return relative === "" || (!relative.startsWith("..") && !nodePath.isAbsolute(relative));
|
||||
}
|
||||
|
||||
export function upCommand(
|
||||
depsOverride?: StatusDeps & {
|
||||
lifecycleDeps?: LifecycleDeps;
|
||||
@@ -189,6 +197,46 @@ Examples:
|
||||
const isRigBundle = !isRigName && /\.rigbundle$/i.test(sourceRef);
|
||||
const targetRoot = opts.target ?? (isRigBundle ? process.cwd() : undefined);
|
||||
|
||||
// OPR.0.3.2.22 Bug 3 — extend the bare `rig up <builtin>` default-cwd
|
||||
// treatment to path-form. Builtin starter specs declare member-level
|
||||
// cwd: "." which resolves to the spec directory (inside the daemon's
|
||||
// install root). Without --cwd that trips getOpenRigInstallCwdError
|
||||
// at preflight. Bare-name form is already rescued at the
|
||||
// resolveLibrarySpec branch above (entry.sourceType === "builtin");
|
||||
// path-form `rig up <install-internal-spec>` is the remaining gap.
|
||||
//
|
||||
// Detection: source is path-form (not isRigName, not isRigBundle),
|
||||
// no --cwd was given, no defaultLibraryCwdOverride was set by the
|
||||
// bare-name branch above, AND the resolved path lives inside the
|
||||
// daemon's install root (fetched via /api/info). When all hold,
|
||||
// default cwdOverride to process.cwd() so the operator's project
|
||||
// dir is used as launch cwd, and print a one-line notice. If
|
||||
// /api/info is unavailable, fall through silently — the daemon
|
||||
// preflight will still surface the install-cwd error.
|
||||
//
|
||||
// Structural redesign of how builtin starter specs declare cwd is
|
||||
// out of scope (deferred to 0.3.3 per the slice triage).
|
||||
if (!opts.cwd && !isRigName && !isRigBundle && defaultLibraryCwdOverride === undefined) {
|
||||
try {
|
||||
// Short timeout: a healthy daemon answers /api/info in <100ms; if
|
||||
// it doesn't, fall through to the daemon's own preflight error
|
||||
// rather than adding a multi-second stall to every rig up.
|
||||
const infoRes = await client.get<{ installRoot?: string }>("/api/info", { timeoutMs: 2000 });
|
||||
const installRoot = infoRes.data?.installRoot;
|
||||
if (installRoot && isPathInsideRoot(sourceRef, installRoot)) {
|
||||
defaultLibraryCwdOverride = process.cwd();
|
||||
if (!opts.json) {
|
||||
console.log("Defaulting cwd to current directory because the spec lives inside the OpenRig install.");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// /api/info unavailable — fall through. The daemon preflight
|
||||
// returns getOpenRigInstallCwdError if this is in fact the
|
||||
// install-internal case; the operator gets the same hint they
|
||||
// would have gotten pre-Bug-3.
|
||||
}
|
||||
}
|
||||
|
||||
const res = await client.post<Record<string, unknown>>("/api/up", {
|
||||
sourceRef,
|
||||
plan: opts.plan ?? false,
|
||||
|
||||
@@ -137,7 +137,13 @@ describe("Up CLI", () => {
|
||||
lastBody = JSON.parse(body);
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
|
||||
return;
|
||||
}
|
||||
// OPR.0.3.2.22 Bug 3 — path-form sourceRef triggers a /api/info
|
||||
// call for install-root awareness; respond with a 404 so the CLI
|
||||
// falls through (this test does not exercise the install-internal
|
||||
// path-form gate).
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
|
||||
await captureLogs(async () => {
|
||||
@@ -220,7 +226,10 @@ describe("Up CLI", () => {
|
||||
lastBody = JSON.parse(body);
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
|
||||
return;
|
||||
}
|
||||
// OPR.0.3.2.22 Bug 3 /api/info fallback (404 → CLI falls through).
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
|
||||
await captureLogs(async () => {
|
||||
@@ -364,6 +373,104 @@ describe("Up CLI", () => {
|
||||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||||
});
|
||||
|
||||
// OPR.0.3.2.22 Bug 3 — path-form `rig up <spec-inside-install>` without
|
||||
// --cwd should default cwdOverride to process.cwd() so the spec's
|
||||
// member-level cwd: "." does not resolve into the OpenRig install root
|
||||
// and trip getOpenRigInstallCwdError at preflight. The bare-name form
|
||||
// is already rescued at the resolveLibrarySpec branch (covered by the
|
||||
// earlier library-name test); this pins the path-form gap.
|
||||
it("up path-form spec inside install root defaults cwdOverride to caller cwd and prints notice", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const os = await import("node:os");
|
||||
|
||||
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), "up-bug3-install-"));
|
||||
const specPath = path.join(installRoot, "rigs", "demo", "rig.yaml");
|
||||
fs.mkdirSync(path.dirname(specPath), { recursive: true });
|
||||
fs.writeFileSync(specPath, "version: '0.2'\nname: demo\npods: []\nedges: []\n");
|
||||
|
||||
let lastBody: Record<string, unknown> = {};
|
||||
let infoCalls = 0;
|
||||
const origListeners = server.listeners("request");
|
||||
server.removeAllListeners("request");
|
||||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||||
let body = "";
|
||||
for await (const chunk of req) body += chunk;
|
||||
if (req.url === "/api/info" && req.method === "GET") {
|
||||
infoCalls += 1;
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ installRoot }));
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/up" && req.method === "POST") {
|
||||
lastBody = JSON.parse(body);
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "not found" }));
|
||||
});
|
||||
|
||||
try {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "up", specPath]);
|
||||
});
|
||||
expect(infoCalls, "expected /api/info to be queried for install-root awareness").toBe(1);
|
||||
expect(lastBody.cwdOverride).toBe(process.cwd());
|
||||
expect(logs.some((l) => l.includes("Defaulting cwd to current directory because the spec lives inside the OpenRig install"))).toBe(true);
|
||||
} finally {
|
||||
server.removeAllListeners("request");
|
||||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||||
fs.rmSync(installRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("up path-form spec OUTSIDE install root leaves cwdOverride undefined", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const os = await import("node:os");
|
||||
|
||||
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), "up-bug3-install-"));
|
||||
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "up-bug3-project-"));
|
||||
const specPath = path.join(projectRoot, "rig.yaml");
|
||||
fs.writeFileSync(specPath, "version: '0.2'\nname: outside-rig\npods: []\nedges: []\n");
|
||||
|
||||
let lastBody: Record<string, unknown> = {};
|
||||
const origListeners = server.listeners("request");
|
||||
server.removeAllListeners("request");
|
||||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||||
let body = "";
|
||||
for await (const chunk of req) body += chunk;
|
||||
if (req.url === "/api/info" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ installRoot }));
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/up" && req.method === "POST") {
|
||||
lastBody = JSON.parse(body);
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "not found" }));
|
||||
});
|
||||
|
||||
try {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "up", specPath]);
|
||||
});
|
||||
expect(lastBody.cwdOverride).toBeUndefined();
|
||||
expect(logs.some((l) => l.includes("Defaulting cwd to current directory because the spec lives inside the OpenRig install"))).toBe(false);
|
||||
} finally {
|
||||
server.removeAllListeners("request");
|
||||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||||
fs.rmSync(installRoot, { recursive: true, force: true });
|
||||
fs.rmSync(projectRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("up apply uses a long-running daemon timeout budget", async () => {
|
||||
let timeoutMs: number | undefined;
|
||||
const post = vi.fn(async (_path: string, _body: unknown, options?: { timeoutMs?: number }) => {
|
||||
|
||||
@@ -777,6 +777,21 @@ export class BootstrapOrchestrator {
|
||||
status: "failed",
|
||||
detail: { code: bootResult.code, error: bootResult.error, receipt: bootResult.receipt },
|
||||
});
|
||||
// OPR.0.3.2.22 Bug 2 follow-up — serviceOrch.boot can already have
|
||||
// started compose resources before failing during status/wait. The
|
||||
// PodRigInstantiator will delete the rig record next, which cascades
|
||||
// away rig_services and the normal teardown handle — so any
|
||||
// already-started compose containers would orphan. Tear them down
|
||||
// here best-effort while the rig handle still exists. Teardown
|
||||
// errors are swallowed so they cannot mask the boot failure that
|
||||
// is the load-bearing return.
|
||||
try {
|
||||
await serviceOrch.teardown(rigId);
|
||||
} catch {
|
||||
// Best-effort. If teardown also fails, the boot-failure error
|
||||
// is what the operator needs; manual `docker compose down`
|
||||
// remains available with the compose file path from the spec.
|
||||
}
|
||||
return { ok: false, code: "service_boot_failed", message: `Service boot failed: ${bootResult.error}` };
|
||||
}
|
||||
|
||||
|
||||
@@ -645,7 +645,21 @@ export class PodRigInstantiator {
|
||||
return { ok: false, code: "preflight_failed", errors: preflight.errors, warnings: preflight.warnings };
|
||||
}
|
||||
|
||||
// 3. Create rig
|
||||
// 3. Compute launch order from edges (rejects cycles)
|
||||
//
|
||||
// OPR.0.3.2.22 Bug 2: this MUST run BEFORE createRig. Pre-fix, a
|
||||
// cycle in the spec left an orphan stopped-state rig record because
|
||||
// createRig had already committed before the cycle check ran. The
|
||||
// operator-facing consequence was the "ambiguous library-spec vs
|
||||
// restore-target" UX trap on the very next `rig up <builtin>` retry.
|
||||
let launchOrder: string[];
|
||||
try {
|
||||
launchOrder = this.computePodLaunchOrder(rigSpec);
|
||||
} catch (err) {
|
||||
return { ok: false, code: "cycle_error", message: (err as Error).message };
|
||||
}
|
||||
|
||||
// 4. Create rig (after cycle check passes)
|
||||
let rigId: string;
|
||||
try {
|
||||
const rig = this.deps.rigRepo.createRig(rigSpec.name);
|
||||
@@ -659,14 +673,6 @@ export class PodRigInstantiator {
|
||||
return { ok: false, code: "instantiate_error", message: (err as Error).message };
|
||||
}
|
||||
|
||||
// 4. Compute launch order from edges (rejects cycles)
|
||||
let launchOrder: string[];
|
||||
try {
|
||||
launchOrder = this.computePodLaunchOrder(rigSpec);
|
||||
} catch (err) {
|
||||
return { ok: false, code: "cycle_error", message: (err as Error).message };
|
||||
}
|
||||
|
||||
// 5. Create pods + nodes + edges, then launch in topological order
|
||||
const nodeResults: { logicalId: string; status: "launched" | "failed" | "attention_required"; error?: string; evidence?: string; sessionName?: string }[] = [];
|
||||
const nodeIdMap: Record<string, string> = {}; // "pod.member" -> node DB id
|
||||
@@ -701,10 +707,15 @@ export class PodRigInstantiator {
|
||||
const orderMap = new Map(launchOrder.map((id, i) => [id, i]));
|
||||
memberEntries.sort((a, b) => (orderMap.get(a.qualifiedId) ?? 999) - (orderMap.get(b.qualifiedId) ?? 999));
|
||||
|
||||
// Prelaunch hook: service gate runs after topology setup, before any node launch
|
||||
// Prelaunch hook: service gate runs after topology setup, before any node launch.
|
||||
//
|
||||
// OPR.0.3.2.22 Bug 2: if the hook fails, roll back the rig record so
|
||||
// the spec name is left free for a clean retry. Pods rely on rigs
|
||||
// via ON DELETE CASCADE so deleting the rig is sufficient.
|
||||
if (opts?.prelaunchHook) {
|
||||
const hookResult = await opts.prelaunchHook(rigId);
|
||||
if (!hookResult.ok) {
|
||||
this.deps.rigRepo.deleteRig(rigId);
|
||||
return { ok: false, code: "service_boot_failed", message: hookResult.message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,24 @@ function isYamlFile(filename: string): boolean {
|
||||
return filename.endsWith(".yaml") || filename.endsWith(".yml");
|
||||
}
|
||||
|
||||
// OPR.0.3.2.22 Bug 4 — noise directories that should never enter the
|
||||
// spec library walk. The set matches progress-indexer.ts:81 exactly.
|
||||
// frontmatter-validator.ts:103 is a related scanner that uses a
|
||||
// narrower subset (node_modules / .git / .worktrees / dist / build);
|
||||
// if this list and the progress-indexer list ever diverge, reconcile
|
||||
// there first. The `.worktrees` entry closes the stale-row class
|
||||
// where conveyor.yaml inside a worktree was sliding into the library
|
||||
// cache and producing the `rig specs show conveyor` ambiguous-match UX.
|
||||
const SKIP_DIRS = new Set([
|
||||
".worktrees",
|
||||
"node_modules",
|
||||
".git",
|
||||
"dist",
|
||||
"build",
|
||||
".turbo",
|
||||
".next",
|
||||
]);
|
||||
|
||||
function walkYamlFiles(rootPath: string): string[] {
|
||||
const files: string[] = [];
|
||||
const stack = [rootPath];
|
||||
@@ -65,6 +83,7 @@ function walkYamlFiles(rootPath: string): string[] {
|
||||
for (const entry of entries) {
|
||||
const absPath = join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
stack.push(absPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -326,6 +326,46 @@ export class WorkflowSpecCache {
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* OPR.0.3.2.22 Bug 4 — prune cached rows whose source_path lives in
|
||||
* noise directories that the post-Bug-4 walkYamlFiles SKIP_DIRS
|
||||
* guard now refuses to scan. Without this prune, rows that were
|
||||
* inserted before the SKIP_DIRS guard landed would survive forever
|
||||
* (the scanner cleanup at spec-library-workflow-scanner.ts only
|
||||
* fires for paths starting with the workspace `workflows/` folder
|
||||
* prefix). Called once at startup. Returns the number of rows
|
||||
* removed.
|
||||
*
|
||||
* installRoot guard (Bug 4 follow-up): when supplied, rows whose
|
||||
* source_path starts with the install root are PRESERVED even if
|
||||
* they match a noise pattern. This is the load-bearing safety for
|
||||
* shipped built-in workflow specs that live at
|
||||
* `<pkg>/dist/builtins/workflow-specs/` in production npm-published
|
||||
* daemons — without this guard the unscoped DELETE would nuke
|
||||
* every shipped built-in on every boot. Pass
|
||||
* `getOpenRigInstallRoot()` from cwd-resolution at the call site.
|
||||
* When omitted, no install-root preservation is applied (test-only
|
||||
* convenience for tests that operate fully outside any install).
|
||||
*/
|
||||
pruneNoiseDirRows(installRoot?: string): number {
|
||||
const guardClause = installRoot ? ` AND source_path NOT LIKE ? || '%'` : "";
|
||||
const params: string[] = installRoot ? [installRoot] : [];
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`DELETE FROM workflow_specs WHERE (
|
||||
source_path LIKE '%/.worktrees/%'
|
||||
OR source_path LIKE '%/node_modules/%'
|
||||
OR source_path LIKE '%/.git/%'
|
||||
OR source_path LIKE '%/dist/%'
|
||||
OR source_path LIKE '%/build/%'
|
||||
OR source_path LIKE '%/.turbo/%'
|
||||
OR source_path LIKE '%/.next/%'
|
||||
)${guardClause}`,
|
||||
)
|
||||
.run(...params);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
getByIdOrThrow(specId: string): WorkflowSpecRow {
|
||||
const row = this.db
|
||||
.prepare(`SELECT * FROM workflow_specs WHERE spec_id = ?`)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Hono } from "hono";
|
||||
import { getOpenRigInstallRoot } from "../domain/cwd-resolution.js";
|
||||
|
||||
// GET /api/info — daemon-info surface for tactical CLI awareness paths
|
||||
// (OPR.0.3.2.22 Bug 3 first consumer: CLI default-cwd extension for
|
||||
// path-form `rig up <install-internal-spec>` without --cwd).
|
||||
//
|
||||
// installRoot is the daemon's on-disk install root (the parent of the
|
||||
// daemon's package directory). The CLI uses it to detect when a spec
|
||||
// path lives inside the OpenRig install — the case that hits
|
||||
// getOpenRigInstallCwdError at preflight without a --cwd override.
|
||||
export function infoRoutes(): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/", (c) => {
|
||||
return c.json({
|
||||
installRoot: getOpenRigInstallRoot(),
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -294,12 +294,29 @@ upRoutes.post("/", async (c) => {
|
||||
}
|
||||
eventBus.emit({ type: "bootstrap.failed", runId: result.runId, sourceRef, error: result.errors[0] ?? "failed" });
|
||||
const hasBlocked = result.stages.some((s) => s.status === "blocked");
|
||||
// OPR.0.3.2.22 Bug 1 BONUS — surface the failure code at the TOP LEVEL
|
||||
// and include import_rig-stage codes (cycle_error, preflight_failed,
|
||||
// validation_failed, service_boot_failed) in the 4xx map. CLI up.ts
|
||||
// already branches on res.data["code"] for these codes; before this
|
||||
// fix the daemon left the code buried in stages[N].detail.code and
|
||||
// returned a bare 500, which is exactly what the openrig-comms
|
||||
// hero-flow dogfood hit on a fresh 0.3.1 install.
|
||||
let topLevelCode: string | undefined;
|
||||
const hasBadRequest = result.stages.some((s) => {
|
||||
if (s.stage !== "resolve_spec") return false;
|
||||
if (s.status !== "failed") return false;
|
||||
const detail = s.detail as { code?: string } | undefined;
|
||||
return detail?.code === "file_not_found" || detail?.code === "parse_error" || detail?.code === "validation_failed" || detail?.code === "bundle_error" || detail?.code === "cycle_error" || detail?.code === "invalid_cwd";
|
||||
const code = detail?.code;
|
||||
if (!code) return false;
|
||||
const isResolveSpec4xx = s.stage === "resolve_spec" && (code === "file_not_found" || code === "parse_error" || code === "validation_failed" || code === "bundle_error" || code === "cycle_error" || code === "invalid_cwd");
|
||||
const isImportRig4xx = s.stage === "import_rig" && (code === "validation_failed" || code === "preflight_failed" || code === "cycle_error" || code === "service_boot_failed");
|
||||
if (isResolveSpec4xx || isImportRig4xx) {
|
||||
topLevelCode ??= code;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return c.json(result, hasBlocked ? 409 : hasBadRequest ? 400 : 500);
|
||||
const failedBody = topLevelCode ? { ...result, code: topLevelCode } : result;
|
||||
return c.json(failedBody, hasBlocked ? 409 : hasBadRequest ? 400 : 500);
|
||||
} catch (err) {
|
||||
bootstrapRepo.updateRunStatus(run.id, "failed");
|
||||
eventBus.emit({ type: "bootstrap.failed", runId: run.id, sourceRef, error: (err as Error).message });
|
||||
|
||||
@@ -42,6 +42,7 @@ import type { PsProjectionService } from "./domain/ps-projection.js";
|
||||
import type { UpCommandRouter } from "./domain/up-command-router.js";
|
||||
import type { RigTeardownOrchestrator } from "./domain/rig-teardown.js";
|
||||
import { upRoutes } from "./routes/up.js";
|
||||
import { infoRoutes } from "./routes/info.js";
|
||||
import { downRoutes } from "./routes/down.js";
|
||||
import { kernelStatusRoutes } from "./routes/kernel-status.js";
|
||||
import type { TranscriptStore } from "./domain/transcript-store.js";
|
||||
@@ -483,6 +484,7 @@ export function createApp(deps: AppDeps): Hono {
|
||||
app.route("/api/bundles", bundleRoutes);
|
||||
app.route("/api/ps", psRoutes);
|
||||
app.route("/api/up", upRoutes);
|
||||
app.route("/api/info", infoRoutes());
|
||||
app.route("/api/down", downRoutes);
|
||||
app.route("/api/kernel", kernelStatusRoutes);
|
||||
app.route("/api/transcripts", transcriptRoutes());
|
||||
|
||||
@@ -907,6 +907,25 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// `<workspace.specs_root>/workflows`; SettingsStore resolves
|
||||
// workspaceSpecsRoot from env > config > workspace-default.
|
||||
deps.workflowSpecCache = workflowRuntime.specCache;
|
||||
|
||||
// OPR.0.3.2.22 Bug 4 — one-time prune of cache rows whose source_path
|
||||
// lives in noise directories (.worktrees, node_modules, etc.). The
|
||||
// post-Bug-4 walkYamlFiles SKIP_DIRS guard prevents NEW rows from
|
||||
// those locations, but legacy rows from prior daemon versions (or
|
||||
// operators who hand-imported a spec via path-form before SKIP_DIRS
|
||||
// shipped) would persist without this prune. Cheap (single DELETE
|
||||
// with bounded LIKE patterns) and safe (matches only the same
|
||||
// directories the walker now refuses to enter).
|
||||
//
|
||||
// installRoot guard: shipped built-in workflow specs live at
|
||||
// `<pkg>/dist/builtins/workflow-specs/` in production. Without the
|
||||
// install-root preservation clause, the `%/dist/%` pattern would
|
||||
// delete them on every boot (then loadStarterWorkflowSpecs re-seeds
|
||||
// — wasteful at best, broken if the loader ever skips re-seeding).
|
||||
// Pass the resolved install root so rows under it are preserved.
|
||||
const { getOpenRigInstallRoot } = await import("./domain/cwd-resolution.js");
|
||||
workflowRuntime.specCache.pruneNoiseDirRows(getOpenRigInstallRoot());
|
||||
|
||||
try {
|
||||
const settingsStore = new ContextPackSettingsStore();
|
||||
const cfg = settingsStore.resolveConfig();
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { infoRoutes } from "../src/routes/info.js";
|
||||
import { getOpenRigInstallRoot } from "../src/domain/cwd-resolution.js";
|
||||
|
||||
// OPR.0.3.2.22 Bug 3 — daemon-info surface for tactical CLI awareness
|
||||
// paths. The CLI consumes /api/info to decide when path-form
|
||||
// `rig up <spec>` lands inside the OpenRig install (the case that
|
||||
// trips getOpenRigInstallCwdError at preflight unless --cwd is given).
|
||||
describe("GET /api/info", () => {
|
||||
it("returns the daemon installRoot computed from cwd-resolution", async () => {
|
||||
const app = new Hono();
|
||||
app.route("/api/info", infoRoutes());
|
||||
|
||||
const res = await app.request("/api/info");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { installRoot: string };
|
||||
expect(body.installRoot).toBe(getOpenRigInstallRoot());
|
||||
expect(typeof body.installRoot).toBe("string");
|
||||
expect(body.installRoot.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -604,6 +604,66 @@ profiles:
|
||||
db.close();
|
||||
});
|
||||
|
||||
// OPR.0.3.2.22 Bug 2 — no orphan rig record on cycle_error.
|
||||
// Reorder fix: computePodLaunchOrder must run BEFORE createRig so a
|
||||
// detected cycle returns before any DB write happens. Before the fix,
|
||||
// every failed `rig up <builtin>` attempt left an orphan stopped-state
|
||||
// rig record that made retries report "ambiguous library-spec vs
|
||||
// restore-target" — the second-order UX trap behind the openrig-comms
|
||||
// hero-flow paper-cut.
|
||||
it("cycle_error: no orphan rig record persists (Bug 2 reorder)", async () => {
|
||||
const files = {
|
||||
[`${RIG_ROOT}/agents/a/agent.yaml`]: agentYaml("a"),
|
||||
[`${RIG_ROOT}/agents/b/agent.yaml`]: agentYaml("b"),
|
||||
};
|
||||
const { db, rigRepo, inst } = setup(files);
|
||||
const specName = "orphan-cycle-test-rig";
|
||||
const spec = makeRigSpec({
|
||||
name: specName,
|
||||
pods: [{
|
||||
id: "dev", label: "Dev",
|
||||
members: [
|
||||
{ id: "a", agentRef: "local:agents/a", profile: "default", runtime: "claude-code", cwd: "." },
|
||||
{ id: "b", agentRef: "local:agents/b", profile: "default", runtime: "claude-code", cwd: "." },
|
||||
],
|
||||
edges: [
|
||||
{ kind: "delegates_to", from: "a", to: "b" },
|
||||
{ kind: "delegates_to", from: "b", to: "a" },
|
||||
],
|
||||
}],
|
||||
});
|
||||
const yaml = RigSpecCodec.serialize(spec);
|
||||
const result = await inst.instantiate(yaml, RIG_ROOT);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok && "code" in result) {
|
||||
expect(result.code).toBe("cycle_error");
|
||||
}
|
||||
// Load-bearing: no rig record persisted under the spec name.
|
||||
const orphans = rigRepo.findRigsByName(specName);
|
||||
expect(orphans, `expected no orphan rig records after cycle_error, found ${JSON.stringify(orphans)}`).toHaveLength(0);
|
||||
db.close();
|
||||
});
|
||||
|
||||
// OPR.0.3.2.22 Bug 2 — rollback on service_boot_failed (prelaunch-hook
|
||||
// failure). Unlike cycle_error, the rig record + pods have been created
|
||||
// by the time the hook runs (the hook needs rigId), so the fix wraps
|
||||
// the failure return with rigRepo.deleteRig(rigId).
|
||||
it("service_boot_failed: rolls back the created rig record (Bug 2 prelaunch-hook rollback)", async () => {
|
||||
const { db, rigRepo, inst } = setup();
|
||||
const specName = "orphan-prelaunch-test-rig";
|
||||
const yaml = RigSpecCodec.serialize(makeRigSpec({ name: specName }));
|
||||
const result = await inst.instantiate(yaml, RIG_ROOT, {
|
||||
prelaunchHook: async () => ({ ok: false, code: "service_boot_failed", message: "test: service boot refused" }),
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok && "code" in result) {
|
||||
expect(result.code).toBe("service_boot_failed");
|
||||
}
|
||||
const orphans = rigRepo.findRigsByName(specName);
|
||||
expect(orphans, `expected no orphan rig records after service_boot_failed, found ${JSON.stringify(orphans)}`).toHaveLength(0);
|
||||
db.close();
|
||||
});
|
||||
|
||||
// NS-T05: orphan tmux sessions killed on total failure
|
||||
it("kills orphan tmux sessions on total failure", async () => {
|
||||
const files = {
|
||||
|
||||
@@ -185,14 +185,85 @@ pods:
|
||||
// Service boot stage should show failed
|
||||
const serviceStage = result.stages.find((s) => s.stage === "service_boot");
|
||||
expect(serviceStage).toBeDefined();
|
||||
// No agent sessions should have been launched
|
||||
const createdRig = rigRepo.findRigsByName("failing-services-rig")[0];
|
||||
expect(createdRig).toBeDefined();
|
||||
const sessions = createdRig ? setup.sessionRegistry.getSessionsForRig(createdRig.id) : [];
|
||||
expect(sessions).toHaveLength(0);
|
||||
// OPR.0.3.2.22 Bug 2: pre-fix, a service_boot_failed left an orphan rig
|
||||
// record (no sessions, but the rig existed) that produced the
|
||||
// "ambiguous library-spec vs restore-target" UX trap on the next retry.
|
||||
// Post-fix, the prelaunch-hook failure rolls back the rig record, so
|
||||
// the spec name stays free for a clean retry — and the "no agent
|
||||
// sessions launched" guarantee follows transitively from "no rig".
|
||||
const orphans = rigRepo.findRigsByName("failing-services-rig");
|
||||
expect(orphans, `expected no orphan rig records after service_boot_failed, found ${JSON.stringify(orphans)}`).toHaveLength(0);
|
||||
expect(serviceStage!.status).toBe("failed");
|
||||
});
|
||||
|
||||
// OPR.0.3.2.22 Bug 2 follow-up (guard BLOCKING on a29c7883) — when
|
||||
// boot returns ok:false after compose may have started (e.g.
|
||||
// wait_timeout), the prelaunch-hook MUST call teardown best-effort
|
||||
// before the rig record is deleted. Otherwise compose resources
|
||||
// orphan: rig_services cascade-removes with the rig, taking the
|
||||
// normal teardown handle with it. Discriminator: assert teardown
|
||||
// was called on boot failure AND the rig record is still removed.
|
||||
it("service boot failure tears down compose before rig record is deleted (no compose orphan)", async () => {
|
||||
const composePath = path.join(tmpDir, "docker-compose.yml");
|
||||
fs.writeFileSync(composePath, "version: '3'\nservices:\n vault:\n image: vault:1.15\n");
|
||||
|
||||
const specPath = writeSpec(`
|
||||
version: "0.2"
|
||||
name: teardown-on-boot-failure-rig
|
||||
services:
|
||||
kind: compose
|
||||
compose_file: docker-compose.yml
|
||||
wait_for:
|
||||
- url: http://localhost:8200/v1/sys/health
|
||||
pods:
|
||||
- id: infra
|
||||
label: Infra
|
||||
members:
|
||||
- id: server
|
||||
runtime: terminal
|
||||
agent_ref: "builtin:terminal"
|
||||
profile: none
|
||||
cwd: /tmp
|
||||
edges: []
|
||||
`);
|
||||
|
||||
const composeAdapter = new ComposeServicesAdapter(mockExec());
|
||||
const rigRepo = new RigRepository(db);
|
||||
const serviceOrch = new ServiceOrchestrator({ rigRepo, composeAdapter });
|
||||
|
||||
vi.spyOn(serviceOrch, "boot").mockResolvedValue({
|
||||
ok: false,
|
||||
code: "wait_timeout",
|
||||
error: "Service wait targets not healthy after 30s: HTTP probe failed: http://localhost:8200/v1/sys/health",
|
||||
receipt: {
|
||||
kind: "compose",
|
||||
composeFile: "docker-compose.yml",
|
||||
projectName: "teardown-on-boot-failure-rig",
|
||||
services: [{ name: "vault", status: "running", health: "starting" }],
|
||||
waitFor: [{ target: { url: "http://localhost:8200/v1/sys/health" }, status: "unhealthy", detail: "HTTP probe failed" }],
|
||||
capturedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
const teardownSpy = vi.spyOn(serviceOrch, "teardown").mockResolvedValue({ ok: true });
|
||||
|
||||
const setup = createTestApp(db);
|
||||
(setup.bootstrapOrchestrator as any).deps.serviceOrchestrator = serviceOrch;
|
||||
(setup.bootstrapOrchestrator as any).deps.rigRepo = rigRepo;
|
||||
|
||||
const result = await setup.bootstrapOrchestrator.bootstrap({
|
||||
sourceRef: specPath,
|
||||
mode: "apply",
|
||||
});
|
||||
|
||||
expect(result.status).toBe("failed");
|
||||
// Discriminator: teardown MUST be called when boot fails — that is
|
||||
// the bug guard caught (compose orphan downstream of rig deletion).
|
||||
expect(teardownSpy, "expected serviceOrch.teardown to be called best-effort on boot failure").toHaveBeenCalledTimes(1);
|
||||
// And the rig record is still rolled back (Bug 2 original contract).
|
||||
const orphans = rigRepo.findRigsByName("teardown-on-boot-failure-rig");
|
||||
expect(orphans).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("plan mode does not boot services", async () => {
|
||||
const composePath = path.join(tmpDir, "docker-compose.yml");
|
||||
fs.writeFileSync(composePath, "version: '3'\nservices:\n vault:\n image: vault:1.15\n");
|
||||
|
||||
@@ -169,4 +169,29 @@ describe("SpecLibraryService", () => {
|
||||
expect(rigs).toHaveLength(1);
|
||||
expect(rigs[0]!.relativePath).toBe("rigs/launch/demo/rig.yaml");
|
||||
});
|
||||
|
||||
// OPR.0.3.2.22 Bug 4 — walkYamlFiles now skips noise directories
|
||||
// (.worktrees, node_modules, .git, dist, build, .turbo, .next). The
|
||||
// conveyor.yaml under .worktrees/release-0.3.0-... is the load-bearing
|
||||
// case from the openrig-comms paper-cut report; this pins the
|
||||
// SKIP_DIRS guard at the SpecLibraryService.scan() seam.
|
||||
it("scan skips yaml files inside noise directories (SKIP_DIRS)", () => {
|
||||
writeFileSync(join(tmpDir, "canonical.yaml"), VALID_RIG_YAML);
|
||||
|
||||
const NOISE_DIRS = [".worktrees", "node_modules", ".git", "dist", "build", ".turbo", ".next"];
|
||||
for (const dir of NOISE_DIRS) {
|
||||
mkdirSync(join(tmpDir, dir, "deeper"), { recursive: true });
|
||||
writeFileSync(join(tmpDir, dir, "deeper", "noise.yaml"), VALID_RIG_YAML.replace("test-rig", `noise-${dir}`));
|
||||
}
|
||||
|
||||
const lib = createLibrary();
|
||||
lib.scan();
|
||||
|
||||
const entries = lib.list();
|
||||
expect(entries, `expected only canonical rig (got ${JSON.stringify(entries.map((e) => e.relativePath))})`).toHaveLength(1);
|
||||
expect(entries[0]!.sourcePath).toContain("canonical.yaml");
|
||||
for (const dir of NOISE_DIRS) {
|
||||
expect(entries.every((e) => !e.sourcePath.includes(`/${dir}/`)), `entry from ${dir} leaked through SKIP_DIRS`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -794,4 +794,115 @@ edges: []
|
||||
db = { close: () => undefined } as unknown as Database.Database;
|
||||
});
|
||||
});
|
||||
|
||||
// --- OPR.0.3.2.22 Bug 1 BONUS — import_rig stage failures with known codes
|
||||
// (cycle_error, preflight_failed, validation_failed, service_boot_failed)
|
||||
// surface as HTTP 4xx with the code at the TOP LEVEL of the response
|
||||
// body. CLI up.ts:208-227 already branches on res.data["code"] for
|
||||
// these codes — the daemon just has to put the code where the CLI
|
||||
// looks, instead of leaving operators staring at a bare 500 with the
|
||||
// real cause buried in stages[N].detail.code.
|
||||
//
|
||||
// The conveyor delegates_to cycle (f3449baf already on main) is the
|
||||
// direct manifestation reported by the openrig-comms hero-flow
|
||||
// dogfood. This regression also pins the more general contract.
|
||||
describe("OPR.0.3.2.22 Bug 1 BONUS: import_rig stage failures map to HTTP 4xx with top-level code", () => {
|
||||
let specDir: string;
|
||||
let app3: ReturnType<typeof createTestApp>["app"];
|
||||
let db3: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
specDir = fs.mkdtempSync(path.join(os.tmpdir(), "up-route-import-rig-fail-"));
|
||||
|
||||
for (const name of ["builder", "reviewer"]) {
|
||||
const agentDir = path.join(specDir, "agents", name);
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(agentDir, "agent.yaml"),
|
||||
`name: ${name}\nversion: "1.0.0"\nresources:\n skills: []\nprofiles:\n default:\n uses:\n skills: []\n`,
|
||||
);
|
||||
}
|
||||
|
||||
db3 = createFullTestDb();
|
||||
const realRouterFsOps = {
|
||||
exists: (p: string) => fs.existsSync(p),
|
||||
readFile: (p: string) => fs.readFileSync(p, "utf-8"),
|
||||
readHead: (p: string, n: number) => {
|
||||
const buf = Buffer.alloc(n);
|
||||
const fd = fs.openSync(p, "r");
|
||||
try { fs.readSync(fd, buf, 0, n, 0); } finally { fs.closeSync(fd); }
|
||||
return buf;
|
||||
},
|
||||
};
|
||||
const realInstantiatorFsOps = {
|
||||
exists: (p: string) => fs.existsSync(p),
|
||||
readFile: (p: string) => fs.readFileSync(p, "utf-8"),
|
||||
};
|
||||
const setup3 = createTestApp(db3, {
|
||||
upRouterFsOps: realRouterFsOps,
|
||||
podInstantiatorFsOps: realInstantiatorFsOps,
|
||||
});
|
||||
app3 = setup3.app;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db3.close();
|
||||
fs.rmSync(specDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("apply-mode delegates_to cycle returns HTTP 400 with code=cycle_error at top level", async () => {
|
||||
const yaml = `version: "0.2"
|
||||
name: cycle-bug-rig
|
||||
pods:
|
||||
- id: build
|
||||
label: Build
|
||||
members:
|
||||
- id: builder
|
||||
agent_ref: local:agents/builder
|
||||
profile: default
|
||||
runtime: claude-code
|
||||
cwd: .
|
||||
edges: []
|
||||
- id: review
|
||||
label: Review
|
||||
members:
|
||||
- id: reviewer
|
||||
agent_ref: local:agents/reviewer
|
||||
profile: default
|
||||
runtime: claude-code
|
||||
cwd: .
|
||||
edges: []
|
||||
edges:
|
||||
- from: build.builder
|
||||
to: review.reviewer
|
||||
kind: delegates_to
|
||||
- from: review.reviewer
|
||||
to: build.builder
|
||||
kind: delegates_to
|
||||
`;
|
||||
const specPath = path.join(specDir, "cycle.yaml");
|
||||
fs.writeFileSync(specPath, yaml, "utf-8");
|
||||
|
||||
const res = await app3.request("/api/up", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceRef: specPath }),
|
||||
});
|
||||
|
||||
const bodyForDebug = await res.clone().json();
|
||||
expect(res.status, `expected 400 for import_rig cycle_error, got ${res.status} with body ${JSON.stringify(bodyForDebug)}`).toBe(400);
|
||||
const body = await res.json() as { code?: string; stages?: Array<{ stage: string; status: string; detail?: { code?: string } }> };
|
||||
|
||||
// The load-bearing fix: code surfaces at TOP LEVEL where CLI looks.
|
||||
expect(body.code, `expected top-level code=cycle_error, got body=${JSON.stringify(body)}`).toBe("cycle_error");
|
||||
|
||||
// Provenance discriminator — proves the code came from import_rig stage
|
||||
// (not a resolve_spec misclassification that the existing 400-mapping
|
||||
// path was already handling).
|
||||
const importRigStage = (body.stages ?? []).find((s) => s.stage === "import_rig");
|
||||
expect(importRigStage, `expected import_rig stage in body.stages, got ${JSON.stringify(body.stages)}`).toBeDefined();
|
||||
expect(importRigStage!.status).toBe("failed");
|
||||
expect(importRigStage!.detail?.code).toBe("cycle_error");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -157,4 +157,75 @@ describe("WorkflowSpecCache (PL-004 Phase D)", () => {
|
||||
const found = cache.getByNameVersion("test-three-step", "1");
|
||||
expect(found?.specId).toBe(cached.specId);
|
||||
});
|
||||
|
||||
// OPR.0.3.2.22 Bug 4 — startup prune removes legacy cache rows whose
|
||||
// source_path lives in noise directories that walkYamlFiles' new
|
||||
// SKIP_DIRS guard now refuses to scan. Without this prune, stale
|
||||
// rows from before SKIP_DIRS shipped would survive forever and keep
|
||||
// showing up in `rig specs show` / `rig specs preview` candidates.
|
||||
it("pruneNoiseDirRows removes cache rows from .worktrees/node_modules paths and preserves canonical-path rows", () => {
|
||||
const fixtures: Array<{ path: string; specName: string; noise: boolean }> = [
|
||||
{ path: join(tmp, "workflows", "canon.yaml"), specName: "canonical-spec", noise: false },
|
||||
{ path: join(tmp, ".worktrees", "feature-branch", "workflows", "stale.yaml"), specName: "stale-worktree-spec", noise: true },
|
||||
{ path: join(tmp, "node_modules", "@vendor", "spec", "stale.yaml"), specName: "stale-node-modules-spec", noise: true },
|
||||
{ path: join(tmp, "dist", "stale.yaml"), specName: "stale-dist-spec", noise: true },
|
||||
];
|
||||
const { mkdirSync } = require("node:fs") as typeof import("node:fs");
|
||||
for (const f of fixtures) {
|
||||
mkdirSync(join(f.path, ".."), { recursive: true });
|
||||
writeFileSync(f.path, SAMPLE_SPEC.replace("test-three-step", f.specName));
|
||||
cache.readThrough(f.path);
|
||||
}
|
||||
|
||||
expect(cache.listAll()).toHaveLength(4);
|
||||
|
||||
const removed = cache.pruneNoiseDirRows();
|
||||
expect(removed, "expected 3 noise rows removed (.worktrees + node_modules + dist)").toBe(3);
|
||||
|
||||
const remaining = cache.listAll();
|
||||
expect(remaining).toHaveLength(1);
|
||||
expect(remaining[0]!.sourcePath).toBe(fixtures[0]!.path);
|
||||
});
|
||||
|
||||
it("pruneNoiseDirRows returns 0 when there are no noise rows", () => {
|
||||
const canonicalPath = join(tmp, "workflows", "canon.yaml");
|
||||
const { mkdirSync } = require("node:fs") as typeof import("node:fs");
|
||||
mkdirSync(join(canonicalPath, ".."), { recursive: true });
|
||||
writeFileSync(canonicalPath, SAMPLE_SPEC);
|
||||
cache.readThrough(canonicalPath);
|
||||
|
||||
expect(cache.pruneNoiseDirRows()).toBe(0);
|
||||
expect(cache.listAll()).toHaveLength(1);
|
||||
});
|
||||
|
||||
// OPR.0.3.2.22 Bug 4 follow-up (guard BLOCKING on 79d06f8d) —
|
||||
// shipped built-in workflow specs live at
|
||||
// `<pkg>/dist/builtins/workflow-specs/` in production
|
||||
// npm-published daemons. The unscoped prune from the prior commit
|
||||
// matched `%/dist/%` and would have nuked every built-in on every
|
||||
// boot. The installRoot guard preserves rows whose source_path
|
||||
// starts with the install root.
|
||||
it("pruneNoiseDirRows with installRoot preserves built-in rows under <installRoot>/dist while removing user noise", () => {
|
||||
const installRoot = join(tmp, "install", "@openrig", "daemon");
|
||||
const builtinPath = join(installRoot, "dist", "builtins", "workflow-specs", "shipped.yaml");
|
||||
const userNoisePath = join(tmp, "user-workspace", "some-project", "dist", "stale.yaml");
|
||||
|
||||
const { mkdirSync } = require("node:fs") as typeof import("node:fs");
|
||||
for (const p of [builtinPath, userNoisePath]) {
|
||||
mkdirSync(join(p, ".."), { recursive: true });
|
||||
}
|
||||
writeFileSync(builtinPath, SAMPLE_SPEC.replace("test-three-step", "shipped-builtin-spec"));
|
||||
writeFileSync(userNoisePath, SAMPLE_SPEC.replace("test-three-step", "user-noise-spec"));
|
||||
cache.readThrough(builtinPath);
|
||||
cache.readThrough(userNoisePath);
|
||||
|
||||
expect(cache.listAll()).toHaveLength(2);
|
||||
|
||||
const removed = cache.pruneNoiseDirRows(installRoot);
|
||||
expect(removed, "expected only the user-noise row removed; built-in must survive").toBe(1);
|
||||
|
||||
const remaining = cache.listAll();
|
||||
expect(remaining).toHaveLength(1);
|
||||
expect(remaining[0]!.sourcePath).toBe(builtinPath);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user