mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 03:39:51 -10:00
feat(slice-05): compatibility assembler capture + route + CLI plumbing (Item 2 part 2) (OPR.0.3.2.5)
Mirrors the Item 1 part 2 + 3 pattern (3b7432e4+6b81d3b6) condensed into one commit for compatibility. Builds on1fd11b76(type layer): assemblers populate manifest.compatibility from opts; route /create accepts compatibility from request body; route /inspect surfaces it in camelCase via the exported normalizer (both v1 path through the normalizer and v2 path through explicit projection — avoiding the B1 trap from Item 1 by including a v2 round-trip test in the SAME commit as the v2 projection); CLI bundle create exposes --min-daemon-version and --min-cli-version flags. Install-time version check + --skip-version-check operator override land at Checkpoint 3.3 (closes Item 2). Files touched: - packages/daemon/src/domain/bundle-assembler.ts (v1): AssembleOptions extended with optional compatibility; assemble() populates manifest.compatibility when provided - packages/daemon/src/domain/pod-bundle-assembler.ts (v2): same shape - packages/daemon/src/routes/bundles.ts: new compatibilityFromRequestBody helper (sanitizes request body); /create passes compatibility through to both assemblers; /inspect v2 projection now includes compatibility via normalizeCompatibilityBlock (camelCase contract consistent with v1 via the normalizer) - packages/cli/src/commands/bundle.ts: --min-daemon-version and --min-cli-version flags on create; compatibility block built and included in request body only when at least one flag is set - packages/daemon/test/bundle-assembler.test.ts: 2 v1 capture tests - packages/daemon/test/pod-bundle-assembler.test.ts: 2 v2 capture tests - packages/daemon/test/bundle-routes.test.ts: 2 round-trip tests (v1 create -> inspect camelCase + negative snake_case assertion; v2 create -> inspect camelCase + negative snake_case assertion) - packages/cli/test/bundle.test.ts: 3 CLI tests (both flags wired; partial only --min-daemon-version; neither flag omits the field entirely from request body) Diff: 8 files, +302 / -8 lines. Verified: - 99/99 daemon bundle suite green (was 93; +6 new) - 15/15 CLI bundle suite green (was 12; +3 new) - 114/114 total bundle tests across both packages - scope-and-versioning sections 1 and 3 coherence re-audit clean - audit-every-layer: zero new module-level constants read env/host/version PRD bounce conditions verified: - No schema bump (consumes optional manifest field added in1fd11b76) - No new SQLite migrations - No event types - No core-routing or bootstrap mutation - No sidebar entries touched Watched risks applied: - Audit-every-layer: assembler reads compatibility from opts only (no env reads in either assembler). Route helper compatibilityFromRequestBody reads from typed request body only. CLI builds compatibility from explicit operator flags only. Zero module-level constants reading env, host, or version sources introduced in this commit (verified via grep — empty). - Optional-field backward-compat both directions: missing opts.compatibility produces manifest with no compatibility section (older daemons consuming new manifests ignore unknown fields; newer daemons consuming older manifests treat missing as no-op for the install-time check at 3.3). - B1 trap avoided: v2 route inspect projection ships TOGETHER with the v2 round-trip test in this same commit. Discriminator: removing either the v2 projection line in routes/bundles.ts or the v1 path's normalizer wiring (from1fd11b76) makes the matching round-trip test fail. Chain on this branch: -1fd11b76(Item 2 type layer, CLEAR) <- prior tip - THIS COMMIT — Item 2 part 2: assembler + route + CLI plumbing - Next (3.3) — install-time version check + --skip-version-check Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
1fd11b762b
commit
f10a19901a
8 files changed
+302
-8
No files matched your search
@@ -63,17 +63,27 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
|
||||
.option("--include-packages <refs...>", "Package refs to include (default: all from spec)")
|
||||
.option("--rig-root <root>", "Root directory for pod-aware resolution")
|
||||
.option("--notes <text>", "Operator notes captured in bundle provenance metadata")
|
||||
.option("--min-daemon-version <ver>", "Minimum daemon version required to install this bundle (Item 2 compatibility)")
|
||||
.option("--min-cli-version <ver>", "Minimum CLI version required to install this bundle (Item 2 compatibility)")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (spec: string, opts: { output: string; name: string; bundleVersion: string; includePackages?: string[]; rigRoot?: string; notes?: string; json?: boolean }) => {
|
||||
.action(async (spec: string, opts: { output: string; name: string; bundleVersion: string; includePackages?: string[]; rigRoot?: string; notes?: string; minDaemonVersion?: string; minCliVersion?: string; json?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
const client = await getClient(deps);
|
||||
if (!client) { process.exitCode = 1; return; }
|
||||
|
||||
// Item 2 / slice-05: build compatibility from operator flags. Only included
|
||||
// in the request body when at least one of the two flags is set.
|
||||
const compatibility: Record<string, string> = {};
|
||||
if (opts.minDaemonVersion) compatibility.minDaemonVersion = opts.minDaemonVersion;
|
||||
if (opts.minCliVersion) compatibility.minCliVersion = opts.minCliVersion;
|
||||
const hasCompatibility = Object.keys(compatibility).length > 0;
|
||||
|
||||
const res = await client.post<Record<string, unknown>>("/api/bundles/create", {
|
||||
specPath: spec, bundleName: opts.name, bundleVersion: opts.bundleVersion, outputPath: opts.output,
|
||||
includePackages: opts.includePackages,
|
||||
rigRoot: opts.rigRoot ? nodePath.resolve(opts.rigRoot) : undefined,
|
||||
provenance: buildClientProvenance(opts.notes),
|
||||
...(hasCompatibility ? { compatibility } : {}),
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
|
||||
@@ -251,6 +251,57 @@ describe("Bundle CLI", () => {
|
||||
expect(provenance!["notes"]).toBeUndefined();
|
||||
});
|
||||
|
||||
// Item 2 / slice-05: --min-daemon-version + --min-cli-version flags wire into request body compatibility
|
||||
it("bundle create --min-daemon-version and --min-cli-version wire into request body compatibility", async () => {
|
||||
capturedCreateBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "create", "/tmp/rig.yaml",
|
||||
"-o", "/tmp/test.rigbundle",
|
||||
"--min-daemon-version", "0.3.2",
|
||||
"--min-cli-version", "0.3.2",
|
||||
]);
|
||||
});
|
||||
const createBody = capturedCreateBodies[capturedCreateBodies.length - 1];
|
||||
expect(createBody).toBeTruthy();
|
||||
const compatibility = createBody!["compatibility"] as Record<string, unknown> | undefined;
|
||||
expect(compatibility).toBeTruthy();
|
||||
expect(compatibility!["minDaemonVersion"]).toBe("0.3.2");
|
||||
expect(compatibility!["minCliVersion"]).toBe("0.3.2");
|
||||
});
|
||||
|
||||
// Item 2 / slice-05: --min-daemon-version alone (partial) still wires
|
||||
it("bundle create --min-daemon-version alone wires partial compatibility into request body", async () => {
|
||||
capturedCreateBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "create", "/tmp/rig.yaml",
|
||||
"-o", "/tmp/test.rigbundle",
|
||||
"--min-daemon-version", "0.3.2",
|
||||
]);
|
||||
});
|
||||
const createBody = capturedCreateBodies[capturedCreateBodies.length - 1];
|
||||
expect(createBody).toBeTruthy();
|
||||
const compatibility = createBody!["compatibility"] as Record<string, unknown> | undefined;
|
||||
expect(compatibility).toBeTruthy();
|
||||
expect(compatibility!["minDaemonVersion"]).toBe("0.3.2");
|
||||
expect(compatibility!["minCliVersion"]).toBeUndefined();
|
||||
});
|
||||
|
||||
// Item 2 / slice-05: no flags → compatibility omitted (no empty object sent)
|
||||
it("bundle create with neither min-version flag omits compatibility from request body", async () => {
|
||||
capturedCreateBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "create", "/tmp/rig.yaml",
|
||||
"-o", "/tmp/test.rigbundle",
|
||||
]);
|
||||
});
|
||||
const createBody = capturedCreateBodies[capturedCreateBodies.length - 1];
|
||||
expect(createBody).toBeTruthy();
|
||||
expect(createBody!["compatibility"]).toBeUndefined();
|
||||
});
|
||||
|
||||
// Item 1 / slice-05: authorSession populates when OPENRIG_SESSION_NAME env is set
|
||||
it("bundle create includes authorSession when OPENRIG_SESSION_NAME env is set", async () => {
|
||||
capturedCreateBodies = [];
|
||||
|
||||
@@ -2,7 +2,7 @@ import nodePath from "node:path";
|
||||
import { LegacyRigSpecCodec as RigSpecCodec } from "./rigspec-codec.js"; // TODO: AS-T08b — migrate to pod-aware RigSpec
|
||||
import { LegacyRigSpecSchema as RigSpecSchema } from "./rigspec-schema.js"; // TODO: AS-T08b — migrate to pod-aware RigSpec
|
||||
// TODO: AS-T12 — migrate to pod-aware bundle types
|
||||
import { serializeLegacyBundleManifest as serializeBundleManifest, type LegacyBundleManifest as BundleManifest, type BundleProvenance } from "./bundle-types.js";
|
||||
import { serializeLegacyBundleManifest as serializeBundleManifest, type LegacyBundleManifest as BundleManifest, type BundleProvenance, type BundleCompatibility } from "./bundle-types.js";
|
||||
|
||||
export interface AssemblerFsOps {
|
||||
readFile: (path: string) => string;
|
||||
@@ -35,6 +35,14 @@ export interface AssembleOptions {
|
||||
* (backward compat).
|
||||
*/
|
||||
provenance?: BundleProvenance;
|
||||
/**
|
||||
* Optional Item-2 compatibility input. Caller declares minimum
|
||||
* daemon + CLI versions and optional schema_version reaffirmation.
|
||||
* Missing block = no compatibility recorded (backward compat); the
|
||||
* install-time version check at /api/bundles/install (Checkpoint 3.3)
|
||||
* is a no-op for bundles without compatibility.
|
||||
*/
|
||||
compatibility?: BundleCompatibility;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,6 +136,9 @@ export class LegacyBundleAssembler {
|
||||
createdAt: opts.provenance.createdAt ?? createdAt,
|
||||
};
|
||||
}
|
||||
if (opts.compatibility) {
|
||||
manifest.compatibility = { ...opts.compatibility };
|
||||
}
|
||||
|
||||
// Write bundle.yaml
|
||||
this.fs.writeFile(
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
|
||||
import { RigSpecCodec } from "./rigspec-codec.js";
|
||||
import { RigSpecSchema } from "./rigspec-schema.js";
|
||||
import { resolveAgentRef, type AgentResolverFsOps } from "./agent-resolver.js";
|
||||
import { serializePodBundleManifest, type PodBundleManifest, type PodBundleAgentEntry, type PodBundleAgentImportEntry, type BundleProvenance } from "./bundle-types.js";
|
||||
import { serializePodBundleManifest, type PodBundleManifest, type PodBundleAgentEntry, type PodBundleAgentImportEntry, type BundleProvenance, type BundleCompatibility } from "./bundle-types.js";
|
||||
import type { RigSpec, StartupBlock } from "./types.js";
|
||||
|
||||
export interface PodAssemblerFsOps extends AgentResolverFsOps {
|
||||
@@ -28,6 +28,12 @@ export interface PodAssembleOptions {
|
||||
* (backward compat).
|
||||
*/
|
||||
provenance?: BundleProvenance;
|
||||
/**
|
||||
* Optional Item-2 compatibility input. Caller declares minimum
|
||||
* daemon + CLI versions and optional schema_version reaffirmation.
|
||||
* Missing block = no compatibility recorded (backward compat).
|
||||
*/
|
||||
compatibility?: BundleCompatibility;
|
||||
}
|
||||
|
||||
export interface PodAssembleResult {
|
||||
@@ -188,6 +194,9 @@ export class PodBundleAssembler {
|
||||
createdAt: opts.provenance.createdAt ?? createdAt,
|
||||
};
|
||||
}
|
||||
if (opts.compatibility) {
|
||||
manifest.compatibility = { ...opts.compatibility };
|
||||
}
|
||||
|
||||
// Write manifest
|
||||
this.fs.writeFile(
|
||||
|
||||
@@ -14,8 +14,8 @@ import { LegacyRigSpecCodec } from "../domain/rigspec-codec.js";
|
||||
import { LegacyRigSpecSchema } from "../domain/rigspec-schema.js";
|
||||
import { RigSpecCodec } from "../domain/rigspec-codec.js";
|
||||
import { RigSpecSchema } from "../domain/rigspec-schema.js";
|
||||
import { parseLegacyBundleManifest as parseBundleManifest, normalizeLegacyBundleManifest as normalizeBundleManifest, serializePodBundleManifest, parsePodBundleManifest, validatePodBundleManifest, normalizeProvenanceBlock } from "../domain/bundle-types.js";
|
||||
import type { PodBundleManifest, BundleProvenance } from "../domain/bundle-types.js";
|
||||
import { parseLegacyBundleManifest as parseBundleManifest, normalizeLegacyBundleManifest as normalizeBundleManifest, serializePodBundleManifest, parsePodBundleManifest, validatePodBundleManifest, normalizeProvenanceBlock, normalizeCompatibilityBlock } from "../domain/bundle-types.js";
|
||||
import type { PodBundleManifest, BundleProvenance, BundleCompatibility } from "../domain/bundle-types.js";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
@@ -35,6 +35,21 @@ function getDaemonVersion(): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize raw compatibility from request body into a BundleCompatibility
|
||||
* object. Only known typed fields accepted; unknown fields dropped silently.
|
||||
* Returns undefined if input is missing or has no usable fields.
|
||||
*/
|
||||
function compatibilityFromRequestBody(raw: unknown): BundleCompatibility | undefined {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
||||
const c = raw as Record<string, unknown>;
|
||||
const result: BundleCompatibility = {};
|
||||
if (typeof c["minDaemonVersion"] === "string") result.minDaemonVersion = c["minDaemonVersion"];
|
||||
if (typeof c["minCliVersion"] === "string") result.minCliVersion = c["minCliVersion"];
|
||||
if (typeof c["schemaVersion"] === "number") result.schemaVersion = c["schemaVersion"];
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize raw provenance from request body into a BundleProvenance object.
|
||||
* Only string fields are accepted; unknown/non-string fields are dropped
|
||||
@@ -129,6 +144,9 @@ bundleRoutes.post("/create", async (c) => {
|
||||
? { ...clientProvenance, daemonVersion: getDaemonVersion() }
|
||||
: undefined;
|
||||
|
||||
// Item 2 / slice-05: build compatibility from request body (no server-side fields)
|
||||
const compatibility = compatibilityFromRequestBody(body["compatibility"]);
|
||||
|
||||
if (!specPath || !bundleName || !bundleVersion || !outputPath) {
|
||||
return c.json({ error: "specPath, bundleName, bundleVersion, and outputPath are required" }, 400);
|
||||
}
|
||||
@@ -148,7 +166,7 @@ bundleRoutes.post("/create", async (c) => {
|
||||
const tmpStaging = fs.mkdtempSync(nodePath.join(os.tmpdir(), "pod-bundle-create-"));
|
||||
try {
|
||||
const assembler = new PodBundleAssembler({ fsOps: podAssemblerFsOps() });
|
||||
const result = assembler.assemble({ rigRoot: effectiveRigRoot, rigSpecPath: nodePath.resolve(specPath), outputDir: tmpStaging, bundleName, bundleVersion, provenance });
|
||||
const result = assembler.assemble({ rigRoot: effectiveRigRoot, rigSpecPath: nodePath.resolve(specPath), outputDir: tmpStaging, bundleName, bundleVersion, provenance, compatibility });
|
||||
|
||||
const integrity = computeIntegrity(tmpStaging, integrityFsOps());
|
||||
result.manifest.integrity = integrity;
|
||||
@@ -205,7 +223,7 @@ bundleRoutes.post("/create", async (c) => {
|
||||
try {
|
||||
const assembler = new BundleAssembler({ fsOps: assemblerFsOps() });
|
||||
const manifest = assembler.assemble({
|
||||
specPath: nodePath.resolve(specPath), packages, outputDir: tmpStaging, bundleName, bundleVersion, provenance,
|
||||
specPath: nodePath.resolve(specPath), packages, outputDir: tmpStaging, bundleName, bundleVersion, provenance, compatibility,
|
||||
});
|
||||
|
||||
const integrity = computeIntegrity(tmpStaging, integrityFsOps());
|
||||
@@ -291,6 +309,10 @@ bundleRoutes.post("/inspect", async (c) => {
|
||||
// (v1 path normalizes through normalizeLegacyBundleManifest below).
|
||||
// Field is optional; undefined when bundle has no provenance.
|
||||
provenance: normalizeProvenanceBlock(rawParsed["provenance"]),
|
||||
// Item 2 / slice-05: surface compatibility normalized to camelCase
|
||||
// (same single-contract reason as provenance above). v1 already
|
||||
// surfaces via the normalizer at the end of this handler.
|
||||
compatibility: normalizeCompatibilityBlock(rawParsed["compatibility"]),
|
||||
};
|
||||
const integrityCompat = integritySection ? {
|
||||
schemaVersion: 2,
|
||||
|
||||
@@ -5,7 +5,7 @@ import os from "node:os";
|
||||
// TODO: AS-T12 — migrate to pod-aware bundle assembler
|
||||
import { LegacyBundleAssembler as BundleAssembler, type AssemblerFsOps } from "../src/domain/bundle-assembler.js";
|
||||
// TODO: AS-T12 — migrate to pod-aware bundle types
|
||||
import { parseLegacyBundleManifest as parseBundleManifest, validateLegacyBundleManifest as validateBundleManifest, normalizeLegacyBundleManifest as normalizeBundleManifest, type BundleProvenance } from "../src/domain/bundle-types.js";
|
||||
import { parseLegacyBundleManifest as parseBundleManifest, validateLegacyBundleManifest as validateBundleManifest, normalizeLegacyBundleManifest as normalizeBundleManifest, type BundleProvenance, type BundleCompatibility } from "../src/domain/bundle-types.js";
|
||||
|
||||
const VALID_SPEC = `
|
||||
schema_version: 1
|
||||
@@ -317,4 +317,53 @@ describe("BundleAssembler", () => {
|
||||
const diskYaml = fs.readFileSync(path.join(outputDir, "bundle.yaml"), "utf-8");
|
||||
expect(diskYaml).not.toContain("provenance:");
|
||||
});
|
||||
|
||||
// Item 2 — compatibility capture (slice-05 Checkpoint 3.2)
|
||||
it("captures full compatibility from opts into manifest", () => {
|
||||
const specPath = writeSpec();
|
||||
const pkgDir = writePkg("pkg", { "package.yaml": "name: pkg" });
|
||||
const outputDir = path.join(tmpDir, "staging");
|
||||
const assembler = new BundleAssembler({ fsOps: realFsOps() });
|
||||
|
||||
const compatibility: BundleCompatibility = {
|
||||
minDaemonVersion: "0.3.2",
|
||||
minCliVersion: "0.3.2",
|
||||
schemaVersion: 1,
|
||||
};
|
||||
const manifest = assembler.assemble({
|
||||
specPath, outputDir, bundleName: "test", bundleVersion: "1.0",
|
||||
packages: [{ name: "pkg", version: "1.0", sourcePath: pkgDir, originalSource: "local:./pkg", manifestHash: "h1" }],
|
||||
compatibility,
|
||||
});
|
||||
|
||||
expect(manifest.compatibility).toBeDefined();
|
||||
expect(manifest.compatibility?.minDaemonVersion).toBe("0.3.2");
|
||||
expect(manifest.compatibility?.minCliVersion).toBe("0.3.2");
|
||||
expect(manifest.compatibility?.schemaVersion).toBe(1);
|
||||
|
||||
// Round-trip via the on-disk bundle.yaml
|
||||
const diskYaml = fs.readFileSync(path.join(outputDir, "bundle.yaml"), "utf-8");
|
||||
const parsed = parseBundleManifest(diskYaml);
|
||||
const validation = validateBundleManifest(parsed, { requireIntegrity: false });
|
||||
expect(validation.valid).toBe(true);
|
||||
const normalized = normalizeBundleManifest(parsed);
|
||||
expect(normalized.compatibility?.minDaemonVersion).toBe("0.3.2");
|
||||
expect(normalized.compatibility?.minCliVersion).toBe("0.3.2");
|
||||
});
|
||||
|
||||
it("omits compatibility when opts.compatibility not provided (backward compat)", () => {
|
||||
const specPath = writeSpec();
|
||||
const pkgDir = writePkg("pkg", { "package.yaml": "name: pkg" });
|
||||
const outputDir = path.join(tmpDir, "staging");
|
||||
const assembler = new BundleAssembler({ fsOps: realFsOps() });
|
||||
|
||||
const manifest = assembler.assemble({
|
||||
specPath, outputDir, bundleName: "test", bundleVersion: "1.0",
|
||||
packages: [{ name: "pkg", version: "1.0", sourcePath: pkgDir, originalSource: "local:./pkg", manifestHash: "h1" }],
|
||||
});
|
||||
|
||||
expect(manifest.compatibility).toBeUndefined();
|
||||
const diskYaml = fs.readFileSync(path.join(outputDir, "bundle.yaml"), "utf-8");
|
||||
expect(diskYaml).not.toContain("compatibility:");
|
||||
});
|
||||
});
|
||||
@@ -487,6 +487,100 @@ describe("Bundle API routes", () => {
|
||||
expect(body.digestValid).toBe(true);
|
||||
});
|
||||
|
||||
// Item 2 / slice-05 / Checkpoint 3.2: v1 create -> inspect compatibility round-trip.
|
||||
// Discriminator: removing the v1 inspect normalizer's compatibility surfacing
|
||||
// OR the route /create compatibility-extraction must make this test fail.
|
||||
it("POST /api/bundles/create accepts compatibility + /inspect surfaces it (v1 round-trip)", async () => {
|
||||
const { specPath } = seedPackage();
|
||||
const bundlePath = path.join(tmpDir, "compat-test.rigbundle");
|
||||
|
||||
const createRes = await app.request("/api/bundles/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
specPath, bundleName: "compat-test", bundleVersion: "0.1.0", outputPath: bundlePath,
|
||||
compatibility: { minDaemonVersion: "0.3.2", minCliVersion: "0.3.2", schemaVersion: 1 },
|
||||
}),
|
||||
});
|
||||
expect(createRes.status).toBe(201);
|
||||
|
||||
const inspectRes = await app.request("/api/bundles/inspect", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ bundlePath }),
|
||||
});
|
||||
expect(inspectRes.status).toBe(200);
|
||||
const inspectBody = await inspectRes.json();
|
||||
expect(inspectBody.manifest.compatibility).toBeDefined();
|
||||
expect(inspectBody.manifest.compatibility.minDaemonVersion).toBe("0.3.2");
|
||||
expect(inspectBody.manifest.compatibility.minCliVersion).toBe("0.3.2");
|
||||
expect(inspectBody.manifest.compatibility.schemaVersion).toBe(1);
|
||||
// Negative — snake_case keys must NOT be present (camelCase contract)
|
||||
expect(inspectBody.manifest.compatibility.min_daemon_version).toBeUndefined();
|
||||
});
|
||||
|
||||
// Item 2 / slice-05 / Checkpoint 3.2: v2 create -> inspect compatibility round-trip.
|
||||
// Avoids the B1 trap from Item 1: this test ships in the SAME commit as the v2 inspect
|
||||
// compatibility projection in routes/bundles.ts. Discriminator: removing the v2
|
||||
// compatibility-projection line must make this test fail.
|
||||
it("POST /api/bundles/inspect with v2 bundle surfaces compatibility in camelCase (create -> inspect round-trip)", async () => {
|
||||
const agentsDir = path.join(tmpDir, "agents", "impl");
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(agentsDir, "agent.yaml"), [
|
||||
'name: impl-agent',
|
||||
'version: "1.0.0"',
|
||||
'resources:',
|
||||
' skills: []',
|
||||
'profiles:',
|
||||
' default:',
|
||||
' uses:',
|
||||
' skills: []',
|
||||
].join("\n"));
|
||||
|
||||
const specPath = path.join(tmpDir, "rig.yaml");
|
||||
fs.writeFileSync(specPath, [
|
||||
'version: "0.2"',
|
||||
'name: v2-compat-test',
|
||||
'pods:',
|
||||
' - id: dev',
|
||||
' label: Dev',
|
||||
' members:',
|
||||
' - id: impl',
|
||||
' agent_ref: "local:agents/impl"',
|
||||
' profile: default',
|
||||
' runtime: claude-code',
|
||||
' cwd: .',
|
||||
' edges: []',
|
||||
'edges: []',
|
||||
].join("\n"));
|
||||
|
||||
const bundlePath = path.join(tmpDir, "v2-compat.rigbundle");
|
||||
const createRes = await app.request("/api/bundles/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
specPath, bundleName: "v2-compat", bundleVersion: "0.1.0", outputPath: bundlePath,
|
||||
compatibility: { minDaemonVersion: "0.3.2", minCliVersion: "0.3.2" },
|
||||
}),
|
||||
});
|
||||
expect(createRes.status).toBe(201);
|
||||
|
||||
const inspectRes = await app.request("/api/bundles/inspect", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ bundlePath }),
|
||||
});
|
||||
expect(inspectRes.status).toBe(200);
|
||||
const inspectBody = await inspectRes.json();
|
||||
expect(inspectBody.manifest.schemaVersion).toBe(2);
|
||||
expect(inspectBody.manifest.compatibility).toBeDefined();
|
||||
expect(inspectBody.manifest.compatibility.minDaemonVersion).toBe("0.3.2");
|
||||
expect(inspectBody.manifest.compatibility.minCliVersion).toBe("0.3.2");
|
||||
// Negative — snake_case keys must NOT be present (camelCase contract)
|
||||
expect(inspectBody.manifest.compatibility.min_daemon_version).toBeUndefined();
|
||||
expect(inspectBody.manifest.compatibility.min_cli_version).toBeUndefined();
|
||||
});
|
||||
|
||||
// Item 1 / slice-05 / guard B1 repair: pod-aware (v2) create -> inspect provenance round-trip.
|
||||
// Asserts the inspect response surfaces provenance in normalized camelCase,
|
||||
// matching the v1 contract. Discriminator: removing the v2 inspect projection
|
||||
|
||||
@@ -580,6 +580,54 @@ describe("PodBundleAssembler", () => {
|
||||
|
||||
expect(result.manifest.provenance).toBeUndefined();
|
||||
});
|
||||
|
||||
// Item 2 — compatibility capture (slice-05 Checkpoint 3.2)
|
||||
it("v2: captures compatibility from opts into manifest", () => {
|
||||
const spec = makeRigSpec();
|
||||
const files: Record<string, string> = {
|
||||
[`${RIG_ROOT}/rig.yaml`]: rigSpecYaml(spec),
|
||||
[`${RIG_ROOT}/agents/impl/agent.yaml`]: validAgentYaml("impl"),
|
||||
};
|
||||
const fs = mockFs(files);
|
||||
const assembler = new PodBundleAssembler({ fsOps: fs });
|
||||
|
||||
const result = assembler.assemble({
|
||||
rigRoot: RIG_ROOT,
|
||||
rigSpecPath: `${RIG_ROOT}/rig.yaml`,
|
||||
outputDir: "/tmp/bundle-staging-compat",
|
||||
bundleName: "test-bundle",
|
||||
bundleVersion: "1.0.0",
|
||||
compatibility: {
|
||||
minDaemonVersion: "0.3.2",
|
||||
minCliVersion: "0.3.2",
|
||||
schemaVersion: 2,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.manifest.compatibility).toBeDefined();
|
||||
expect(result.manifest.compatibility?.minDaemonVersion).toBe("0.3.2");
|
||||
expect(result.manifest.compatibility?.minCliVersion).toBe("0.3.2");
|
||||
expect(result.manifest.compatibility?.schemaVersion).toBe(2);
|
||||
});
|
||||
|
||||
it("v2: omits compatibility when opts.compatibility not provided (backward compat)", () => {
|
||||
const spec = makeRigSpec();
|
||||
const files: Record<string, string> = {
|
||||
[`${RIG_ROOT}/rig.yaml`]: rigSpecYaml(spec),
|
||||
[`${RIG_ROOT}/agents/impl/agent.yaml`]: validAgentYaml("impl"),
|
||||
};
|
||||
const fs = mockFs(files);
|
||||
const assembler = new PodBundleAssembler({ fsOps: fs });
|
||||
|
||||
const result = assembler.assemble({
|
||||
rigRoot: RIG_ROOT,
|
||||
rigSpecPath: `${RIG_ROOT}/rig.yaml`,
|
||||
outputDir: "/tmp/bundle-staging-no-compat",
|
||||
bundleName: "test", bundleVersion: "1.0",
|
||||
});
|
||||
|
||||
expect(result.manifest.compatibility).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PodBundleManifest validation", () => {
|
||||
|
||||
Reference in new issue
Block a user