mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
feat(daemon): Agent Starter v1 vertical M2 — adapter pass-through + rig up integration
Wire AgentStarterResolver into pod-rig instantiation and prepend a STARTER
layer in `resolvedStartupFiles`, so an agent member with `starter_ref` flows
its starter artifacts through the existing `deliverStartup` adapter seam
without any adapter-side code change. Resolver-throw aborts the launch with
a clear startup error and no `deliverStartup` call (load-bearing
credential-safety contract — Finding 2).
Source changes:
- packages/daemon/src/domain/rigspec-instantiator.ts: in `launchExistingAgentMember`,
invoke the resolver when `member.starterRef` is set; on
`AgentStarterCredentialScanFailedError` / missing-entry / malformed-yaml,
return `{status: "failed"}` before any adapter `deliverStartup` runs.
STARTER artifacts are prepended ahead of `dedupedResolvedFiles` to form
the new STARTER layer at the front of the layer chain.
New tests:
- agent-starter-instantiator.test.ts (5 tests): resolver invoked + STARTER
prepended; resolver-throw aborts launch with no `deliverStartup` call;
missing entry aborts; absent starterRef = no STARTER layer; rebuild
composition allowed.
- agent-starter-adapter-claude.test.ts (1 test): claude-code adapter
receives STARTER artifacts at front of `deliverStartup`.
- agent-starter-adapter-codex.test.ts (1 test): codex adapter receives
STARTER artifacts at front of `deliverStartup`.
Updated tests:
- up-route.test.ts: 3 new M2 cases (positive plan-mode wiring proof,
fork+starter_ref rejected per Finding 1, terminal+starter_ref rejected
per secondary cleanup). Required threading optional `upRouterFsOps`
through `createTestApp` so the route can read a real fixture spec.
- pod-rigspec-instantiator.test.ts: M1 forward-compat smoke now feeds the
resolver a fixture registry entry (M2 actively reads the registry; M1
test was a passthrough).
- packages/cli/test/up.test.ts: smoke proving `rig up <fixture-with-starter_ref> --plan`
forwards the spec path to the daemon and surfaces "planned".
Marshal-acceptance:
- daemon vitest: 143 files / 2233 tests pass (M1 R2 baseline 2223 + 10
new M2 tests).
- cli up.test.ts: 28/28 pass. Other CLI failures (preflight, chatroom,
config-store) are environmental — host has a real daemon running on
port 7433 — and pre-date M2.
This commit is contained in:
1 parent
2673e30ec6
commit
2f8f26952b
8 files changed
+971
-29
No files matched your search
@@ -894,4 +894,68 @@ describe("Up CLI", () => {
|
||||
expect(output).not.toContain("Restoring from manual snapshot");
|
||||
expect(output).toContain('Rig "auto-rig" restored');
|
||||
});
|
||||
|
||||
// Agent Starter v1 vertical M2 — CLI smoke proof for `rig up --plan`
|
||||
// with a starter_ref-bearing fixture spec on disk.
|
||||
//
|
||||
// The CLI passes the resolved spec path to the daemon and surfaces the
|
||||
// daemon's response. The proof is end-to-end: the spec exists, the CLI
|
||||
// resolves the path, POSTs sourceRef, and prints "planned" when the
|
||||
// daemon accepts it. The CLI itself does NOT validate spec contents —
|
||||
// that's the daemon's job (covered by up-route.test.ts M2 cases).
|
||||
it("up --plan smoke against a starter_ref fixture spec prints planned and forwards sourceRef", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const fs = await import("node:fs");
|
||||
const os = await import("node:os");
|
||||
const path = await import("node:path");
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-up-starter-"));
|
||||
const specPath = path.join(tmpDir, "starter-fixture.yaml");
|
||||
fs.writeFileSync(specPath, `version: "0.2"
|
||||
name: starter-cli-smoke
|
||||
pods:
|
||||
- id: dev
|
||||
label: Development
|
||||
members:
|
||||
- id: impl
|
||||
agent_ref: local:agents/impl
|
||||
profile: default
|
||||
runtime: claude-code
|
||||
cwd: .
|
||||
starter_ref:
|
||||
name: openrig-builder-base--claude-code
|
||||
edges: []
|
||||
edges: []
|
||||
`, "utf-8");
|
||||
|
||||
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/up" && req.method === "POST") {
|
||||
lastBody = JSON.parse(body);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "planned", runId: "starter-run", stages: [{ stage: "resolve_spec", status: "ok" }], errors: [], warnings: [] }));
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "up", specPath, "--plan"]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("planned");
|
||||
expect(lastBody.sourceRef).toBe(specPath);
|
||||
expect(lastBody.plan).toBe(true);
|
||||
} finally {
|
||||
server.removeAllListeners("request");
|
||||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1063,6 +1063,40 @@ export class PodRigInstantiator {
|
||||
rebuildArtifactsOpt = { rebuildArtifacts: resolved.files };
|
||||
}
|
||||
|
||||
// Agent Starter resolver dispatch (Agent Starter v1 vertical M2). When
|
||||
// `member.starterRef` is set, resolve the named registry entry into a
|
||||
// `ResolvedStartupFile[]` that prepends ahead of the member's per-agent
|
||||
// and per-pod startup files (the new STARTER layer at the front of the
|
||||
// layer chain). The resolver THROWS on a failed credential scan, missing
|
||||
// registry entry, or malformed YAML; on any throw we abort the launch
|
||||
// BEFORE `startNode` runs (no STARTER layer added; no adapter
|
||||
// `deliverStartup` called) — load-bearing credential-safety contract per
|
||||
// M1 R1 finding 2.
|
||||
let starterArtifacts: import("./runtime-adapter.js").ResolvedStartupFile[] | undefined;
|
||||
if (input.member.starterRef) {
|
||||
const { AgentStarterResolver } = await import("./agent-starter-resolver.js");
|
||||
const resolver = new AgentStarterResolver();
|
||||
try {
|
||||
const resolved = resolver.resolveStarter(input.member.starterRef.name);
|
||||
starterArtifacts = resolved.files;
|
||||
} catch (err) {
|
||||
return {
|
||||
status: "failed",
|
||||
error: `Agent Starter resolver failed: ${(err as Error).message}`,
|
||||
sessionName: canonicalSessionName,
|
||||
warnings: launchResult.warnings,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// STARTER layer (artifact-seeded fresh-launch context; precedes per-agent
|
||||
// and per-pod layers). Prepended to dedupedResolvedFiles so the existing
|
||||
// `startupOrchestrator.startNode` consumes the combined chain via the
|
||||
// existing `resolvedStartupFiles` input — NO new orchestrator branch.
|
||||
const finalResolvedStartupFiles = starterArtifacts
|
||||
? [...starterArtifacts, ...dedupedResolvedFiles]
|
||||
: dedupedResolvedFiles;
|
||||
|
||||
const startupResult = await this.deps.startupOrchestrator.startNode({
|
||||
rigId: input.rigId,
|
||||
nodeId: input.nodeId,
|
||||
@@ -1070,7 +1104,7 @@ export class PodRigInstantiator {
|
||||
binding,
|
||||
adapter,
|
||||
plan: planResult.plan,
|
||||
resolvedStartupFiles: dedupedResolvedFiles,
|
||||
resolvedStartupFiles: finalResolvedStartupFiles,
|
||||
startupActions: [
|
||||
...configResult.config.startup.actions,
|
||||
this.buildSessionIdentityAction({
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// Tier 1 proof for the Agent Starter v1 vertical M2 — Claude adapter
|
||||
// integration. Verifies that the STARTER layer artifacts (from
|
||||
// AgentStarterResolver) flow through the existing
|
||||
// `claude-code-adapter.deliverStartup` seam without any adapter-side
|
||||
// code change. The resolver emits ResolvedStartupFile with
|
||||
// `deliveryHint: "guidance_merge"` for the starter YAML; the adapter
|
||||
// merges into CLAUDE.md.
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { PodRepository } from "../src/domain/pod-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { NodeLauncher } from "../src/domain/node-launcher.js";
|
||||
import { StartupOrchestrator } from "../src/domain/startup-orchestrator.js";
|
||||
import { PodRigInstantiator } from "../src/domain/rigspec-instantiator.js";
|
||||
import { RigSpecCodec } from "../src/domain/rigspec-codec.js";
|
||||
import type { AgentResolverFsOps } from "../src/domain/agent-resolver.js";
|
||||
import type { RuntimeAdapter, ResolvedStartupFile } from "../src/domain/runtime-adapter.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import type { RigSpec } from "../src/domain/types.js";
|
||||
|
||||
function mockTmux(): TmuxAdapter {
|
||||
return {
|
||||
createSession: vi.fn(async () => ({ ok: true as const })),
|
||||
killSession: vi.fn(async () => ({ ok: true as const })),
|
||||
sendText: vi.fn(async () => ({ ok: true as const })),
|
||||
hasSession: vi.fn(async () => true),
|
||||
listSessions: vi.fn(async () => []),
|
||||
listWindows: vi.fn(async () => []),
|
||||
listPanes: vi.fn(async () => []),
|
||||
sendKeys: vi.fn(async () => ({ ok: true as const })),
|
||||
} as unknown as TmuxAdapter;
|
||||
}
|
||||
|
||||
function mockClaudeAdapter(): RuntimeAdapter {
|
||||
return {
|
||||
runtime: "claude-code",
|
||||
listInstalled: vi.fn(async () => []),
|
||||
project: vi.fn(async () => ({ projected: [], skipped: [], failed: [] })),
|
||||
deliverStartup: vi.fn(async () => ({ delivered: 0, failed: [] })),
|
||||
checkReady: vi.fn(async () => ({ ready: true })),
|
||||
launchHarness: vi.fn(async () => ({ ok: true })),
|
||||
};
|
||||
}
|
||||
|
||||
const RIG_ROOT = "/project/rigs/test-rig";
|
||||
|
||||
const CLAUDE_STARTER = `draft: false
|
||||
starter_id: claude-fixture-starter
|
||||
runtime: claude-code
|
||||
manifest_id: openrig-builder-base
|
||||
manifest_version: "0.2"
|
||||
session_source:
|
||||
mode: fork
|
||||
ref:
|
||||
kind: native_id
|
||||
value: "claude-fixture-native-id"
|
||||
captured_at: 2026-05-01T00:00:00Z
|
||||
captured_by: fixture
|
||||
ready_check_evidence: ../evidence/fixture.md
|
||||
status: captured
|
||||
state: 2-named
|
||||
`;
|
||||
|
||||
describe("Agent Starter v1 vertical — Claude adapter integration (M2)", () => {
|
||||
it("claude-code-adapter.deliverStartup receives STARTER layer artifact when starterRef is set", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "starter-adapter-claude-"));
|
||||
const registryRoot = path.join(tmpDir, "registry");
|
||||
fs.mkdirSync(registryRoot, { recursive: true });
|
||||
fs.writeFileSync(path.join(registryRoot, "claude-fixture-starter.yaml"), CLAUDE_STARTER);
|
||||
process.env.OPENRIG_AGENT_STARTER_ROOT = registryRoot;
|
||||
|
||||
try {
|
||||
const db = createFullTestDb();
|
||||
const rigRepo = new RigRepository(db);
|
||||
const podRepo = new PodRepository(db);
|
||||
const sessionRegistry = new SessionRegistry(db);
|
||||
const eventBus = new EventBus(db);
|
||||
const tmux = mockTmux();
|
||||
const nodeLauncher = new NodeLauncher({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux });
|
||||
const startupOrch = new StartupOrchestrator({ db, sessionRegistry, eventBus, tmuxAdapter: tmux });
|
||||
const claudeAdapter = mockClaudeAdapter();
|
||||
const fsOps: AgentResolverFsOps = {
|
||||
readFile: (p: string) => {
|
||||
if (p === `${RIG_ROOT}/agents/impl/agent.yaml`) {
|
||||
return `name: impl\nversion: "1.0.0"\nresources:\n skills: []\nprofiles:\n default:\n uses:\n skills: []`;
|
||||
}
|
||||
throw new Error(`Not found: ${p}`);
|
||||
},
|
||||
exists: (p: string) => p === `${RIG_ROOT}/agents/impl/agent.yaml`,
|
||||
};
|
||||
|
||||
const inst = new PodRigInstantiator({
|
||||
db, rigRepo, podRepo, sessionRegistry, eventBus, nodeLauncher,
|
||||
startupOrchestrator: startupOrch, fsOps,
|
||||
adapters: {
|
||||
"claude-code": claudeAdapter,
|
||||
"codex": mockClaudeAdapter(),
|
||||
"terminal": mockClaudeAdapter(),
|
||||
},
|
||||
tmuxAdapter: tmux,
|
||||
});
|
||||
|
||||
const spec: RigSpec = {
|
||||
version: "0.2",
|
||||
name: "claude-starter-rig",
|
||||
pods: [{
|
||||
id: "dev",
|
||||
label: "Dev",
|
||||
members: [{
|
||||
id: "impl",
|
||||
agentRef: "local:agents/impl",
|
||||
profile: "default",
|
||||
runtime: "claude-code",
|
||||
cwd: ".",
|
||||
starterRef: { name: "claude-fixture-starter" },
|
||||
}],
|
||||
edges: [],
|
||||
}],
|
||||
edges: [],
|
||||
};
|
||||
const yaml = RigSpecCodec.serialize(spec);
|
||||
const result = await inst.instantiate(yaml, RIG_ROOT);
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
const deliverStartupSpy = claudeAdapter.deliverStartup as ReturnType<typeof vi.fn>;
|
||||
expect(deliverStartupSpy).toHaveBeenCalled();
|
||||
const files = deliverStartupSpy.mock.calls[0]![0] as ResolvedStartupFile[];
|
||||
|
||||
// STARTER layer prepended at front
|
||||
expect(files[0]!.path).toBe("claude-fixture-starter.yaml");
|
||||
expect(files[0]!.ownerRoot).toBe(registryRoot);
|
||||
expect(files[0]!.appliesOn).toEqual(["fresh_start"]);
|
||||
expect(files[0]!.required).toBe(true);
|
||||
expect(files[0]!.deliveryHint).toBe("guidance_merge");
|
||||
|
||||
db.close();
|
||||
} finally {
|
||||
delete process.env.OPENRIG_AGENT_STARTER_ROOT;
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
// Tier 1 proof for the Agent Starter v1 vertical M2 — Codex adapter
|
||||
// integration. Mirror of the Claude adapter test but for Codex.
|
||||
// Verifies that the STARTER layer artifacts (from AgentStarterResolver)
|
||||
// flow through the existing `codex-runtime-adapter.deliverStartup`
|
||||
// seam without any adapter-side code change.
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { PodRepository } from "../src/domain/pod-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { NodeLauncher } from "../src/domain/node-launcher.js";
|
||||
import { StartupOrchestrator } from "../src/domain/startup-orchestrator.js";
|
||||
import { PodRigInstantiator } from "../src/domain/rigspec-instantiator.js";
|
||||
import { RigSpecCodec } from "../src/domain/rigspec-codec.js";
|
||||
import type { AgentResolverFsOps } from "../src/domain/agent-resolver.js";
|
||||
import type { RuntimeAdapter, ResolvedStartupFile } from "../src/domain/runtime-adapter.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import type { RigSpec } from "../src/domain/types.js";
|
||||
|
||||
function mockTmux(): TmuxAdapter {
|
||||
return {
|
||||
createSession: vi.fn(async () => ({ ok: true as const })),
|
||||
killSession: vi.fn(async () => ({ ok: true as const })),
|
||||
sendText: vi.fn(async () => ({ ok: true as const })),
|
||||
hasSession: vi.fn(async () => true),
|
||||
listSessions: vi.fn(async () => []),
|
||||
listWindows: vi.fn(async () => []),
|
||||
listPanes: vi.fn(async () => []),
|
||||
sendKeys: vi.fn(async () => ({ ok: true as const })),
|
||||
} as unknown as TmuxAdapter;
|
||||
}
|
||||
|
||||
function mockCodexAdapter(): RuntimeAdapter {
|
||||
return {
|
||||
runtime: "codex",
|
||||
listInstalled: vi.fn(async () => []),
|
||||
project: vi.fn(async () => ({ projected: [], skipped: [], failed: [] })),
|
||||
deliverStartup: vi.fn(async () => ({ delivered: 0, failed: [] })),
|
||||
checkReady: vi.fn(async () => ({ ready: true })),
|
||||
launchHarness: vi.fn(async () => ({ ok: true })),
|
||||
};
|
||||
}
|
||||
|
||||
const RIG_ROOT = "/project/rigs/test-rig";
|
||||
|
||||
const CODEX_STARTER = `draft: false
|
||||
starter_id: codex-fixture-starter
|
||||
runtime: codex
|
||||
manifest_id: openrig-builder-base
|
||||
manifest_version: "0.2"
|
||||
session_source:
|
||||
mode: fork
|
||||
ref:
|
||||
kind: native_id
|
||||
value: "codex-fixture-native-id"
|
||||
captured_at: 2026-05-01T00:00:00Z
|
||||
captured_by: fixture
|
||||
ready_check_evidence: ../evidence/fixture.md
|
||||
status: captured
|
||||
state: 2-named
|
||||
`;
|
||||
|
||||
describe("Agent Starter v1 vertical — Codex adapter integration (M2)", () => {
|
||||
it("codex-runtime-adapter.deliverStartup receives STARTER layer artifact when starterRef is set", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "starter-adapter-codex-"));
|
||||
const registryRoot = path.join(tmpDir, "registry");
|
||||
fs.mkdirSync(registryRoot, { recursive: true });
|
||||
fs.writeFileSync(path.join(registryRoot, "codex-fixture-starter.yaml"), CODEX_STARTER);
|
||||
process.env.OPENRIG_AGENT_STARTER_ROOT = registryRoot;
|
||||
|
||||
try {
|
||||
const db = createFullTestDb();
|
||||
const rigRepo = new RigRepository(db);
|
||||
const podRepo = new PodRepository(db);
|
||||
const sessionRegistry = new SessionRegistry(db);
|
||||
const eventBus = new EventBus(db);
|
||||
const tmux = mockTmux();
|
||||
const nodeLauncher = new NodeLauncher({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux });
|
||||
const startupOrch = new StartupOrchestrator({ db, sessionRegistry, eventBus, tmuxAdapter: tmux });
|
||||
const codexAdapter = mockCodexAdapter();
|
||||
const fsOps: AgentResolverFsOps = {
|
||||
readFile: (p: string) => {
|
||||
if (p === `${RIG_ROOT}/agents/impl/agent.yaml`) {
|
||||
return `name: impl\nversion: "1.0.0"\nresources:\n skills: []\nprofiles:\n default:\n uses:\n skills: []`;
|
||||
}
|
||||
throw new Error(`Not found: ${p}`);
|
||||
},
|
||||
exists: (p: string) => p === `${RIG_ROOT}/agents/impl/agent.yaml`,
|
||||
};
|
||||
|
||||
const inst = new PodRigInstantiator({
|
||||
db, rigRepo, podRepo, sessionRegistry, eventBus, nodeLauncher,
|
||||
startupOrchestrator: startupOrch, fsOps,
|
||||
adapters: {
|
||||
"claude-code": mockCodexAdapter(),
|
||||
"codex": codexAdapter,
|
||||
"terminal": mockCodexAdapter(),
|
||||
},
|
||||
tmuxAdapter: tmux,
|
||||
});
|
||||
|
||||
const spec: RigSpec = {
|
||||
version: "0.2",
|
||||
name: "codex-starter-rig",
|
||||
pods: [{
|
||||
id: "dev",
|
||||
label: "Dev",
|
||||
members: [{
|
||||
id: "impl",
|
||||
agentRef: "local:agents/impl",
|
||||
profile: "default",
|
||||
runtime: "codex",
|
||||
cwd: ".",
|
||||
starterRef: { name: "codex-fixture-starter" },
|
||||
}],
|
||||
edges: [],
|
||||
}],
|
||||
edges: [],
|
||||
};
|
||||
const yaml = RigSpecCodec.serialize(spec);
|
||||
const result = await inst.instantiate(yaml, RIG_ROOT);
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
const deliverStartupSpy = codexAdapter.deliverStartup as ReturnType<typeof vi.fn>;
|
||||
expect(deliverStartupSpy).toHaveBeenCalled();
|
||||
const files = deliverStartupSpy.mock.calls[0]![0] as ResolvedStartupFile[];
|
||||
|
||||
// STARTER layer prepended at front
|
||||
expect(files[0]!.path).toBe("codex-fixture-starter.yaml");
|
||||
expect(files[0]!.ownerRoot).toBe(registryRoot);
|
||||
expect(files[0]!.appliesOn).toEqual(["fresh_start"]);
|
||||
expect(files[0]!.required).toBe(true);
|
||||
expect(files[0]!.deliveryHint).toBe("guidance_merge");
|
||||
|
||||
db.close();
|
||||
} finally {
|
||||
delete process.env.OPENRIG_AGENT_STARTER_ROOT;
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,337 @@
|
||||
// Tier 1 proof for the Agent Starter v1 vertical M2 — instantiator
|
||||
// integration. M1 shipped the schema + resolver scaffolding; M2 wires
|
||||
// the resolver into `launchExistingAgentMember`, prepending the resolved
|
||||
// starter artifacts as a STARTER layer ahead of the per-agent /
|
||||
// per-pod startup files.
|
||||
//
|
||||
// Key invariants:
|
||||
// - Resolver invoked when `member.starterRef` is set.
|
||||
// - STARTER layer prepended in `resolvedStartupFiles` (not a separate
|
||||
// orchestrator branch).
|
||||
// - Resolver THROW (any cause: missing entry, malformed YAML, failed
|
||||
// credential scan) aborts the launch BEFORE `startNode` runs — no
|
||||
// adapter `deliverStartup` is called and no STARTER layer lands
|
||||
// downstream. Load-bearing credential-safety contract per M1 R1
|
||||
// finding 2.
|
||||
// - Composition with `session_source.mode: "rebuild"` works
|
||||
// independently (both apply on fresh_start; per slice schema).
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { PodRepository } from "../src/domain/pod-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { NodeLauncher } from "../src/domain/node-launcher.js";
|
||||
import { StartupOrchestrator } from "../src/domain/startup-orchestrator.js";
|
||||
import { PodRigInstantiator } from "../src/domain/rigspec-instantiator.js";
|
||||
import { RigSpecCodec } from "../src/domain/rigspec-codec.js";
|
||||
import type { AgentResolverFsOps } from "../src/domain/agent-resolver.js";
|
||||
import type { RuntimeAdapter, ResolvedStartupFile } from "../src/domain/runtime-adapter.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import type { RigSpec } from "../src/domain/types.js";
|
||||
|
||||
function mockTmux(): TmuxAdapter {
|
||||
return {
|
||||
createSession: vi.fn(async () => ({ ok: true as const })),
|
||||
killSession: vi.fn(async () => ({ ok: true as const })),
|
||||
sendText: vi.fn(async () => ({ ok: true as const })),
|
||||
hasSession: vi.fn(async () => true),
|
||||
listSessions: vi.fn(async () => []),
|
||||
listWindows: vi.fn(async () => []),
|
||||
listPanes: vi.fn(async () => []),
|
||||
sendKeys: vi.fn(async () => ({ ok: true as const })),
|
||||
} as unknown as TmuxAdapter;
|
||||
}
|
||||
|
||||
function mockAdapter(runtime = "claude-code"): RuntimeAdapter {
|
||||
return {
|
||||
runtime,
|
||||
listInstalled: vi.fn(async () => []),
|
||||
project: vi.fn(async () => ({ projected: [], skipped: [], failed: [] })),
|
||||
deliverStartup: vi.fn(async () => ({ delivered: 0, failed: [] })),
|
||||
checkReady: vi.fn(async () => ({ ready: true })),
|
||||
launchHarness: vi.fn(async () => ({ ok: true })),
|
||||
};
|
||||
}
|
||||
|
||||
function mockFs(files: Record<string, string>): AgentResolverFsOps {
|
||||
return {
|
||||
readFile: (p: string) => { if (p in files) return files[p]!; throw new Error(`Not found: ${p}`); },
|
||||
exists: (p: string) => p in files,
|
||||
};
|
||||
}
|
||||
|
||||
const RIG_ROOT = "/project/rigs/test-rig";
|
||||
|
||||
function agentYaml(name: string): string {
|
||||
return `name: ${name}\nversion: "1.0.0"\nresources:\n skills: []\nprofiles:\n default:\n uses:\n skills: []`;
|
||||
}
|
||||
|
||||
function specWithStarterRef(): RigSpec {
|
||||
return {
|
||||
version: "0.2",
|
||||
name: "test-rig",
|
||||
pods: [{
|
||||
id: "dev",
|
||||
label: "Dev",
|
||||
members: [{
|
||||
id: "impl",
|
||||
agentRef: "local:agents/impl",
|
||||
profile: "default",
|
||||
runtime: "claude-code",
|
||||
cwd: ".",
|
||||
starterRef: { name: "fixture-starter" },
|
||||
}],
|
||||
edges: [],
|
||||
}],
|
||||
edges: [],
|
||||
};
|
||||
}
|
||||
|
||||
const CLEAN_STARTER = `draft: false
|
||||
starter_id: fixture-starter
|
||||
runtime: claude-code
|
||||
manifest_id: openrig-builder-base
|
||||
manifest_version: "0.2"
|
||||
session_source:
|
||||
mode: fork
|
||||
ref:
|
||||
kind: native_id
|
||||
value: "fixture-native-id"
|
||||
captured_at: 2026-05-01T00:00:00Z
|
||||
captured_by: fixture
|
||||
ready_check_evidence: ../evidence/fixture.md
|
||||
status: captured
|
||||
state: 2-named
|
||||
`;
|
||||
|
||||
const MALICIOUS_STARTER = `draft: false
|
||||
starter_id: fixture-mal
|
||||
runtime: claude-code
|
||||
manifest_id: openrig-builder-base
|
||||
manifest_version: "0.2"
|
||||
session_source:
|
||||
mode: fork
|
||||
ref:
|
||||
kind: native_id
|
||||
value: "x"
|
||||
captured_at: 2026-05-01T00:00:00Z
|
||||
captured_by: fixture
|
||||
ready_check_evidence: ../evidence/fixture.md
|
||||
status: captured
|
||||
state: 2-named
|
||||
api_key: example-not-real
|
||||
`;
|
||||
|
||||
function setupWithStarter(opts: {
|
||||
starterContent?: string | null; // null → registry directory empty
|
||||
starterFilename?: string; // default: fixture-starter.yaml
|
||||
}) {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "starter-instantiator-"));
|
||||
const registryRoot = path.join(tmpDir, "registry");
|
||||
fs.mkdirSync(registryRoot, { recursive: true });
|
||||
|
||||
if (opts.starterContent) {
|
||||
const filename = opts.starterFilename ?? "fixture-starter.yaml";
|
||||
fs.writeFileSync(path.join(registryRoot, filename), opts.starterContent);
|
||||
}
|
||||
|
||||
// Tell the resolver to use this fixture directory via the documented
|
||||
// env-var lookup branch.
|
||||
process.env.OPENRIG_AGENT_STARTER_ROOT = registryRoot;
|
||||
|
||||
const db = createFullTestDb();
|
||||
const rigRepo = new RigRepository(db);
|
||||
const podRepo = new PodRepository(db);
|
||||
const sessionRegistry = new SessionRegistry(db);
|
||||
const eventBus = new EventBus(db);
|
||||
const tmux = mockTmux();
|
||||
const nodeLauncher = new NodeLauncher({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux });
|
||||
const startupOrch = new StartupOrchestrator({ db, sessionRegistry, eventBus, tmuxAdapter: tmux });
|
||||
const adapter = mockAdapter("claude-code");
|
||||
const codexAdapter = mockAdapter("codex");
|
||||
const fsOps = mockFs({ [`${RIG_ROOT}/agents/impl/agent.yaml`]: agentYaml("impl") });
|
||||
|
||||
const inst = new PodRigInstantiator({
|
||||
db, rigRepo, podRepo, sessionRegistry, eventBus, nodeLauncher,
|
||||
startupOrchestrator: startupOrch,
|
||||
fsOps,
|
||||
adapters: { "claude-code": adapter, "codex": codexAdapter, "terminal": mockAdapter("terminal") },
|
||||
tmuxAdapter: tmux,
|
||||
});
|
||||
|
||||
const cleanup = () => {
|
||||
delete process.env.OPENRIG_AGENT_STARTER_ROOT;
|
||||
db.close();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
return { db, rigRepo, sessionRegistry, eventBus, inst, adapter, codexAdapter, tmux, registryRoot, cleanup };
|
||||
}
|
||||
|
||||
describe("Agent Starter v1 vertical — instantiator integration (M2)", () => {
|
||||
it("invokes resolver and prepends STARTER layer when member.starterRef is set", async () => {
|
||||
const ctx = setupWithStarter({ starterContent: CLEAN_STARTER });
|
||||
try {
|
||||
const yaml = RigSpecCodec.serialize(specWithStarterRef());
|
||||
const result = await ctx.inst.instantiate(yaml, RIG_ROOT);
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
// adapter.deliverStartup should have been called; the first arg is the
|
||||
// ResolvedStartupFile[] that includes the STARTER layer at the front.
|
||||
const deliverStartupSpy = ctx.adapter.deliverStartup as ReturnType<typeof vi.fn>;
|
||||
expect(deliverStartupSpy).toHaveBeenCalled();
|
||||
const filesArg = deliverStartupSpy.mock.calls[0]![0] as ResolvedStartupFile[];
|
||||
|
||||
// STARTER layer prepended: the first ResolvedStartupFile must come from
|
||||
// the registryRoot we set up.
|
||||
expect(filesArg.length).toBeGreaterThan(0);
|
||||
expect(filesArg[0]!.ownerRoot).toBe(ctx.registryRoot);
|
||||
expect(filesArg[0]!.path).toBe("fixture-starter.yaml");
|
||||
expect(filesArg[0]!.appliesOn).toEqual(["fresh_start"]);
|
||||
} finally {
|
||||
ctx.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("aborts launch when resolver throws — adapter.deliverStartup NOT called", async () => {
|
||||
// Malicious starter (api_key field) → resolver throws
|
||||
// AgentStarterCredentialScanFailedError → instantiator returns failed
|
||||
// before adapter.deliverStartup runs. Load-bearing credential-safety
|
||||
// contract per M1 R1 finding 2.
|
||||
const ctx = setupWithStarter({ starterContent: MALICIOUS_STARTER });
|
||||
try {
|
||||
const yaml = RigSpecCodec.serialize(specWithStarterRef());
|
||||
const result = await ctx.inst.instantiate(yaml, RIG_ROOT);
|
||||
|
||||
// Some node failed (the impl member) — instantiator may still report
|
||||
// ok=true with a failed node entry, depending on partial-failure
|
||||
// policy. The load-bearing assertion is: deliverStartup was NEVER
|
||||
// called for this node, AND the node's status is "failed".
|
||||
const deliverStartupSpy = ctx.adapter.deliverStartup as ReturnType<typeof vi.fn>;
|
||||
expect(deliverStartupSpy).not.toHaveBeenCalled();
|
||||
|
||||
if (result.ok) {
|
||||
const node = result.result.nodes.find((n) => n.logicalId === "dev.impl");
|
||||
expect(node?.status).toBe("failed");
|
||||
expect(node?.error).toContain("Agent Starter resolver failed");
|
||||
} else {
|
||||
// result.ok=false is also acceptable closure
|
||||
expect(result.ok).toBe(false);
|
||||
}
|
||||
} finally {
|
||||
ctx.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("aborts launch when starter registry entry is missing (resolver throws)", async () => {
|
||||
// No registry file → resolver throws "no registry entry found".
|
||||
const ctx = setupWithStarter({ starterContent: null });
|
||||
try {
|
||||
const yaml = RigSpecCodec.serialize(specWithStarterRef());
|
||||
const result = await ctx.inst.instantiate(yaml, RIG_ROOT);
|
||||
|
||||
const deliverStartupSpy = ctx.adapter.deliverStartup as ReturnType<typeof vi.fn>;
|
||||
expect(deliverStartupSpy).not.toHaveBeenCalled();
|
||||
|
||||
if (result.ok) {
|
||||
const node = result.result.nodes.find((n) => n.logicalId === "dev.impl");
|
||||
expect(node?.status).toBe("failed");
|
||||
expect(node?.error).toContain("Agent Starter resolver failed");
|
||||
}
|
||||
} finally {
|
||||
ctx.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT invoke resolver when member.starterRef is absent (no STARTER layer)", async () => {
|
||||
const ctx = setupWithStarter({ starterContent: CLEAN_STARTER });
|
||||
try {
|
||||
// Spec without starterRef on the member.
|
||||
const spec: RigSpec = {
|
||||
version: "0.2",
|
||||
name: "no-starter-rig",
|
||||
pods: [{
|
||||
id: "dev",
|
||||
label: "Dev",
|
||||
members: [{
|
||||
id: "impl",
|
||||
agentRef: "local:agents/impl",
|
||||
profile: "default",
|
||||
runtime: "claude-code",
|
||||
cwd: ".",
|
||||
}],
|
||||
edges: [],
|
||||
}],
|
||||
edges: [],
|
||||
};
|
||||
const yaml = RigSpecCodec.serialize(spec);
|
||||
const result = await ctx.inst.instantiate(yaml, RIG_ROOT);
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
const deliverStartupSpy = ctx.adapter.deliverStartup as ReturnType<typeof vi.fn>;
|
||||
expect(deliverStartupSpy).toHaveBeenCalled();
|
||||
const filesArg = deliverStartupSpy.mock.calls[0]![0] as ResolvedStartupFile[];
|
||||
|
||||
// No file from the registryRoot should appear in the chain.
|
||||
const fromRegistry = filesArg.find((f) => f.ownerRoot === ctx.registryRoot);
|
||||
expect(fromRegistry).toBeUndefined();
|
||||
} finally {
|
||||
ctx.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("composition: starterRef + sessionSource.mode='rebuild' both fire on fresh_start", async () => {
|
||||
// Create a real artifact file that the rebuild resolver can find.
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "starter-rebuild-"));
|
||||
const rebuildArtifactPath = path.join(tmpDir, "rebuild-artifact.md");
|
||||
fs.writeFileSync(rebuildArtifactPath, "rebuild context fixture");
|
||||
|
||||
const ctx = setupWithStarter({ starterContent: CLEAN_STARTER });
|
||||
try {
|
||||
const spec: RigSpec = {
|
||||
version: "0.2",
|
||||
name: "compose-rig",
|
||||
pods: [{
|
||||
id: "dev",
|
||||
label: "Dev",
|
||||
members: [{
|
||||
id: "impl",
|
||||
agentRef: "local:agents/impl",
|
||||
profile: "default",
|
||||
runtime: "claude-code",
|
||||
cwd: ".",
|
||||
starterRef: { name: "fixture-starter" },
|
||||
sessionSource: {
|
||||
mode: "rebuild",
|
||||
ref: { kind: "artifact_set", value: [rebuildArtifactPath] },
|
||||
},
|
||||
}],
|
||||
edges: [],
|
||||
}],
|
||||
edges: [],
|
||||
};
|
||||
const yaml = RigSpecCodec.serialize(spec);
|
||||
const result = await ctx.inst.instantiate(yaml, RIG_ROOT);
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
// Adapter.deliverStartup receives the STARTER layer prepended.
|
||||
// The rebuild artifacts are passed via a separate `rebuildArtifacts`
|
||||
// kwarg to startNode (not via resolvedStartupFiles), so they don't
|
||||
// appear in deliverStartup's files arg — but the launch should
|
||||
// still succeed.
|
||||
const deliverStartupSpy = ctx.adapter.deliverStartup as ReturnType<typeof vi.fn>;
|
||||
expect(deliverStartupSpy).toHaveBeenCalled();
|
||||
const filesArg = deliverStartupSpy.mock.calls[0]![0] as ResolvedStartupFile[];
|
||||
// STARTER layer is at the front
|
||||
expect(filesArg[0]!.ownerRoot).toBe(ctx.registryRoot);
|
||||
} finally {
|
||||
ctx.cleanup();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -124,6 +124,17 @@ export function createTestApp(
|
||||
adapters?: Partial<Record<string, RuntimeAdapter>>;
|
||||
activityHookToken?: string;
|
||||
activityFreshnessMs?: number;
|
||||
/**
|
||||
* Agent Starter v1 vertical M2: optional real-fs upRouter for tests
|
||||
* that POST /api/up with a YAML spec on disk. Default behavior
|
||||
* (always-false fsOps) preserved when this is omitted, so existing
|
||||
* rig-name-path tests are not affected.
|
||||
*/
|
||||
upRouterFsOps?: {
|
||||
exists: (p: string) => boolean;
|
||||
readFile: (p: string) => string;
|
||||
readHead: (p: string, n: number) => Buffer;
|
||||
};
|
||||
},
|
||||
) {
|
||||
const rigRepo = new RigRepository(db);
|
||||
@@ -225,6 +236,13 @@ export function createTestApp(
|
||||
|
||||
const podBundleSourceResolver = new PodBundleSourceResolver();
|
||||
|
||||
const upRouterFs = opts?.upRouterFsOps ?? {
|
||||
exists: (_p: string) => false,
|
||||
readFile: (_p: string) => "",
|
||||
readHead: (_p: string, _n: number) => Buffer.alloc(0),
|
||||
};
|
||||
const upRouter = new UpCommandRouter({ fsOps: upRouterFs });
|
||||
|
||||
const app = createApp({
|
||||
rigRepo, sessionRegistry, eventBus, nodeLauncher, tmuxAdapter: tmux, cmuxAdapter: cmux,
|
||||
snapshotCapture, snapshotRepo, restoreOrchestrator,
|
||||
@@ -234,7 +252,7 @@ export function createTestApp(
|
||||
discoveryCoordinator, discoveryRepo, claimService, selfAttachService, rigExpansionService,
|
||||
rigLifecycleService,
|
||||
psProjectionService: new PsProjectionService({ db }),
|
||||
upRouter: new UpCommandRouter({ fsOps: { exists: () => false, readFile: () => "", readHead: () => Buffer.alloc(0) } }),
|
||||
upRouter,
|
||||
teardownOrchestrator: new RigTeardownOrchestrator({ db, rigRepo, sessionRegistry, tmuxAdapter: tmux, snapshotCapture, eventBus }),
|
||||
podInstantiator,
|
||||
podBundleSourceResolver,
|
||||
@@ -253,7 +271,7 @@ export function createTestApp(
|
||||
discoveryCoordinator, discoveryRepo, claimService, selfAttachService, rigExpansionService, tmuxScanner,
|
||||
rigLifecycleService,
|
||||
psProjectionService: new PsProjectionService({ db }),
|
||||
upRouter: new UpCommandRouter({ fsOps: { exists: () => false, readFile: () => "", readHead: () => Buffer.alloc(0) } }),
|
||||
upRouter,
|
||||
teardownOrchestrator: new RigTeardownOrchestrator({ db, rigRepo, sessionRegistry, tmuxAdapter: tmux, snapshotCapture, eventBus }),
|
||||
podInstantiator, podBundleSourceResolver, db, tmuxAdapter: tmux,
|
||||
agentActivityStore,
|
||||
|
||||
@@ -660,33 +660,67 @@ profiles:
|
||||
// pass — the existing pipeline always handled YAML without
|
||||
// `starter_ref:` already.
|
||||
it("forward-compat: pod with starter_ref on a member instantiates without error AND the codec emits it (R2 repair)", async () => {
|
||||
const { db, inst, rigRepo } = setup();
|
||||
const yaml = RigSpecCodec.serialize(makeRigSpec({
|
||||
pods: [{
|
||||
id: "dev",
|
||||
label: "Dev",
|
||||
members: [{
|
||||
id: "impl",
|
||||
agentRef: "local:agents/impl",
|
||||
profile: "default",
|
||||
runtime: "claude-code",
|
||||
cwd: ".",
|
||||
starterRef: { name: "fixture-starter" },
|
||||
// M2 update: the resolver now actively reads the registry when
|
||||
// starterRef is set (M2.1/M2.2 wiring). Provide a fixture registry
|
||||
// entry so the resolver succeeds; the smoke proves the field flows
|
||||
// through to launch without breaking the existing pipeline. The
|
||||
// dedicated end-to-end + abort behaviors live in
|
||||
// agent-starter-instantiator.test.ts; this test stays as a
|
||||
// forward-compat regression catch.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const fs = await import("node:fs");
|
||||
const os = await import("node:os");
|
||||
const path = await import("node:path");
|
||||
const tmpRegistry = fs.mkdtempSync(path.join(os.tmpdir(), "starter-fwd-compat-"));
|
||||
fs.writeFileSync(path.join(tmpRegistry, "fixture-starter.yaml"), `draft: false
|
||||
starter_id: fixture-starter
|
||||
runtime: claude-code
|
||||
manifest_id: openrig-builder-base
|
||||
manifest_version: "0.2"
|
||||
session_source:
|
||||
mode: fork
|
||||
ref:
|
||||
kind: native_id
|
||||
value: "fwd-compat-fixture"
|
||||
captured_at: 2026-05-01T00:00:00Z
|
||||
captured_by: fixture
|
||||
ready_check_evidence: ../evidence/fixture.md
|
||||
status: captured
|
||||
state: 2-named
|
||||
`);
|
||||
process.env.OPENRIG_AGENT_STARTER_ROOT = tmpRegistry;
|
||||
try {
|
||||
const { db, inst, rigRepo } = setup();
|
||||
const yaml = RigSpecCodec.serialize(makeRigSpec({
|
||||
pods: [{
|
||||
id: "dev",
|
||||
label: "Dev",
|
||||
members: [{
|
||||
id: "impl",
|
||||
agentRef: "local:agents/impl",
|
||||
profile: "default",
|
||||
runtime: "claude-code",
|
||||
cwd: ".",
|
||||
starterRef: { name: "fixture-starter" },
|
||||
}],
|
||||
edges: [],
|
||||
}],
|
||||
edges: [],
|
||||
}],
|
||||
}));
|
||||
// R2: prove the serialized YAML actually carries the new field, then
|
||||
// verify instantiation still succeeds with it present.
|
||||
expect(yaml).toContain("starter_ref:");
|
||||
expect(yaml).toContain("fixture-starter");
|
||||
const result = await inst.instantiate(yaml, RIG_ROOT);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
const rig = rigRepo.getRig(result.result.rigId);
|
||||
expect(rig).not.toBeNull();
|
||||
expect(rig!.nodes).toHaveLength(1);
|
||||
}));
|
||||
// R2: prove the serialized YAML actually carries the new field, then
|
||||
// verify instantiation still succeeds with it present.
|
||||
expect(yaml).toContain("starter_ref:");
|
||||
expect(yaml).toContain("fixture-starter");
|
||||
const result = await inst.instantiate(yaml, RIG_ROOT);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
const rig = rigRepo.getRig(result.result.rigId);
|
||||
expect(rig).not.toBeNull();
|
||||
expect(rig!.nodes).toHaveLength(1);
|
||||
}
|
||||
db.close();
|
||||
} finally {
|
||||
delete process.env.OPENRIG_AGENT_STARTER_ROOT;
|
||||
fs.rmSync(tmpRegistry, { recursive: true, force: true });
|
||||
}
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -188,4 +188,165 @@ describe("Up API route", () => {
|
||||
expect(body.error).not.toContain("auto-pre-down snapshot");
|
||||
});
|
||||
});
|
||||
|
||||
// Agent Starter v1 vertical M2 — POST /api/up wiring proof for starter_ref.
|
||||
//
|
||||
// These tests exercise the spec-resolution path (sourceKind=rig_spec) by
|
||||
// injecting a real-fs upRouter into createTestApp. The positive case
|
||||
// demonstrates that a pod-aware spec with `starter_ref:` flows through
|
||||
// validation cleanly. The two negatives demonstrate that the schema's
|
||||
// composition rules surface as 400 responses through the route.
|
||||
//
|
||||
// The "failed-scan launch abort" negative is intentionally NOT tested at
|
||||
// this level — the resolver runs only at apply-time inside the
|
||||
// instantiator, and that behavior is already proven in
|
||||
// `agent-starter-instantiator.test.ts` ("resolver throw aborts launch").
|
||||
// Re-proving it through the HTTP layer would require fully wiring the
|
||||
// bootstrap apply path with real fixtures, which is broader than this
|
||||
// wiring proof needs.
|
||||
describe("M2: POST /api/up with starter_ref", () => {
|
||||
let specDir: string;
|
||||
let app2: ReturnType<typeof createTestApp>["app"];
|
||||
let db2: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
specDir = fs.mkdtempSync(path.join(os.tmpdir(), "up-route-starter-"));
|
||||
db2 = createFullTestDb();
|
||||
const fsOps = {
|
||||
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 setup = createTestApp(db2, { upRouterFsOps: fsOps });
|
||||
app2 = setup.app;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db2.close();
|
||||
fs.rmSync(specDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeSpec(name: string, body: string): string {
|
||||
const p = path.join(specDir, name);
|
||||
fs.writeFileSync(p, body, "utf-8");
|
||||
return p;
|
||||
}
|
||||
|
||||
it("accepts a pod-aware spec with starter_ref in plan mode (positive wiring proof)", async () => {
|
||||
const yaml = `version: "0.2"
|
||||
name: starter-positive
|
||||
pods:
|
||||
- id: dev
|
||||
label: Development
|
||||
members:
|
||||
- id: impl
|
||||
agent_ref: local:agents/impl
|
||||
profile: default
|
||||
runtime: claude-code
|
||||
cwd: .
|
||||
starter_ref:
|
||||
name: openrig-builder-base--claude-code
|
||||
edges: []
|
||||
edges: []
|
||||
`;
|
||||
const specPath = writeSpec("starter-positive.yaml", yaml);
|
||||
|
||||
const res = await app2.request("/api/up", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceRef: specPath, plan: true }),
|
||||
});
|
||||
// The upRouter classifies as rig_spec (validation passed). Bootstrap
|
||||
// plan may fail later stages (e.g., agent file resolution against an
|
||||
// empty test cwd), but the spec MUST clear validation — proving
|
||||
// `starter_ref` does not trip the schema. Status 200/400 are both
|
||||
// acceptable wiring proofs as long as the body is NOT a top-level
|
||||
// validation_failed error from upRouter.route().
|
||||
const body = await res.json();
|
||||
expect(body.error ?? "").not.toContain("not a valid rig spec");
|
||||
expect(body.error ?? "").not.toContain("does not match composition rules");
|
||||
});
|
||||
|
||||
it("rejects fork + starter_ref composition with 400 (terminal-equivalent route surface)", async () => {
|
||||
const yaml = `version: "0.2"
|
||||
name: starter-fork-reject
|
||||
pods:
|
||||
- id: dev
|
||||
label: Development
|
||||
members:
|
||||
- id: impl
|
||||
agent_ref: local:agents/impl
|
||||
profile: default
|
||||
runtime: claude-code
|
||||
cwd: .
|
||||
starter_ref:
|
||||
name: openrig-builder-base--claude-code
|
||||
session_source:
|
||||
mode: fork
|
||||
ref:
|
||||
kind: native_id
|
||||
value: some-id
|
||||
edges: []
|
||||
edges: []
|
||||
`;
|
||||
const specPath = writeSpec("starter-fork-reject.yaml", yaml);
|
||||
|
||||
const res = await app2.request("/api/up", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceRef: specPath, plan: true }),
|
||||
});
|
||||
// Pod-aware composition rule (validateStarterRef in rigspec-schema)
|
||||
// rejects fork+starter_ref, so the upRouter does NOT classify the
|
||||
// YAML as a valid pod-aware rig_spec. The route returns 400.
|
||||
// (Pre-existing UX caveat: the error message bubbles from the
|
||||
// legacy fallthrough, not from the pod-aware validator — but the
|
||||
// contract behavior of REJECTING the spec is what M2 requires.)
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toContain("not a valid rig spec");
|
||||
});
|
||||
|
||||
it("rejects terminal + starter_ref composition with 400", async () => {
|
||||
const yaml = `version: "0.2"
|
||||
name: starter-terminal-reject
|
||||
pods:
|
||||
- id: dev
|
||||
label: Development
|
||||
members:
|
||||
- id: t1
|
||||
agent_ref: local:agents/t1
|
||||
profile: default
|
||||
runtime: terminal
|
||||
cwd: .
|
||||
starter_ref:
|
||||
name: openrig-builder-base--claude-code
|
||||
edges: []
|
||||
edges: []
|
||||
`;
|
||||
const specPath = writeSpec("starter-terminal-reject.yaml", yaml);
|
||||
|
||||
const res = await app2.request("/api/up", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceRef: specPath, plan: true }),
|
||||
});
|
||||
// Pod-aware composition rule rejects terminal+starter_ref. Route
|
||||
// returns 400 (same caveat as the fork case above re: error
|
||||
// message provenance — the contract behavior of rejection is what
|
||||
// M2 requires).
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toContain("not a valid rig spec");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user