mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 11:51:37 -10:00
feat(slice-05): provenance plumbing in /create + /inspect routes and rig bundle create CLI (Item 1 close-out) (OPR.0.3.2.5)
Closes Item 1 of slice-05: end-to-end provenance pipeline. CLI captures operator-side context at invoke time and sends in the create request; daemon injects its own version server-side; assembler persists into the manifest (commits82d39715+3b7432e4); inspect route surfaces provenance back in the response JSON for downstream consumers (UI inspector lands at Checkpoint 5). CLI side (packages/cli/src/commands/bundle.ts): - New --notes flag on rig bundle create for operator-authored notes - New getCliVersion helper reads the CLI package.json at call time (no module-level constant — audit-every-layer discipline) - New buildClientProvenance helper assembles the request-body provenance from os.hostname() + process.env.OPENRIG_SESSION_NAME + getCliVersion() + operator notes; reads all at call time - create action wires provenance into the request body Route side (packages/daemon/src/routes/bundles.ts): - New getDaemonVersion helper reads the daemon package.json at call time (no module-level constant) - New provenanceFromRequestBody helper sanitizes client-sent fields (only string-typed known fields accepted; unknown fields dropped) - /create handler: extracts provenance from body, injects daemonVersion server-side, passes to both v1 and v2 assemblers - /inspect handler: v2 path now includes provenance in the response manifest projection; v1 path already surfaces it via the normalizer extension from82d39715Tests: - packages/daemon/test/bundle-routes.test.ts +2 tests: provenance round-trip through /create + /inspect, and backward-compat path (no provenance in request leaves manifest provenance undefined) - packages/cli/test/bundle.test.ts +3 tests: --notes wires into request body provenance.notes; hostname + cliVersion auto-populate with no flag; OPENRIG_SESSION_NAME env populates authorSession Diff: 4 files, +210 / -4 lines. Verified: 80/80 daemon bundle suite + 12/12 CLI bundle suite green; 0 regressions across changed surfaces. Branch chain:82d39715(type layer, guard CLEAR) ->3b7432e4(assembler capture, guard CLEAR) -> this commit (CLI + route close-out). PRD bounce conditions verified: - No schema bump (manifest extended with optional field only, landed in82d39715) - No new SQLite migrations - No event types - No core-routing or bootstrap mutation - No sidebar entries touched - scope-and-versioning sections 1 and 3 coherence re-audit clean Watched risks applied: - Audit-every-layer (function-level call sites AND module-level constants reading env/host/version): zero module-level constants introduced. Both getCliVersion and getDaemonVersion read at function call time. provenanceFromRequestBody reads only from the typed request body. Verified via grep for top-level const reads of os, process.env, or JSON.parse(package.json) shapes in both edited files — zero hits. - Optional-field backward-compat both directions: missing provenance in request body results in undefined provenance passed to assembler (manifest.provenance omitted from bundle.yaml). Bundles created pre-Item-1 install unchanged. Older daemons reading new manifests silently ignore provenance (validator accepts unknown fields). What is NOT in this commit (graduates to Checkpoint 5 / Item 5): - CLI rig bundle inspect text output renders a Provenance section alongside the existing Bundle / Digest valid / Integrity lines - UI BundleInspector renders provenance block Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
3b7432e41c
commit
6b81d3b6d5
4 files changed
+210
-4
No files matched your search
@@ -1,10 +1,46 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import nodePath from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
/**
|
||||
* Read the CLI's own package.json version at call time (Item 1 / slice-05).
|
||||
* Function-level read on purpose: module-level constants would mask test
|
||||
* isolation per the audit-every-layer discipline.
|
||||
*/
|
||||
function getCliVersion(): string {
|
||||
try {
|
||||
const here = fileURLToPath(import.meta.url);
|
||||
const pkgPath = nodePath.join(nodePath.dirname(here), "..", "..", "package.json");
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { version?: unknown };
|
||||
return typeof pkg.version === "string" ? pkg.version : "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the provenance block the CLI sends to /api/bundles/create. Reads
|
||||
* hostname, session name (from canonical OPENRIG_SESSION_NAME env), and
|
||||
* CLI version at call time. Operator notes come from the --notes flag.
|
||||
* Daemon adds daemonVersion server-side.
|
||||
*/
|
||||
function buildClientProvenance(notes: string | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = {
|
||||
sourceHost: os.hostname(),
|
||||
cliVersion: getCliVersion(),
|
||||
};
|
||||
const session = process.env.OPENRIG_SESSION_NAME;
|
||||
if (typeof session === "string" && session.length > 0) out.authorSession = session;
|
||||
if (typeof notes === "string" && notes.length > 0) out.notes = notes;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function bundleCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("bundle").description("Manage rig bundles");
|
||||
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
@@ -26,8 +62,9 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
|
||||
.option("--bundle-version <ver>", "Bundle version", "0.1.0")
|
||||
.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("--json", "JSON output")
|
||||
.action(async (spec: string, opts: { output: string; name: string; bundleVersion: string; includePackages?: string[]; rigRoot?: string; json?: boolean }) => {
|
||||
.action(async (spec: string, opts: { output: string; name: string; bundleVersion: string; includePackages?: string[]; rigRoot?: string; notes?: string; json?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
const client = await getClient(deps);
|
||||
if (!client) { process.exitCode = 1; return; }
|
||||
@@ -36,6 +73,7 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
|
||||
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),
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
|
||||
@@ -211,4 +211,66 @@ describe("Bundle CLI", () => {
|
||||
expect(createBody).toBeTruthy();
|
||||
expect(createBody!["rigRoot"]).toMatch(/\/my\/project/);
|
||||
});
|
||||
|
||||
// Item 1 / slice-05: --notes flag is captured into provenance in the request body
|
||||
it("bundle create --notes wires the operator note into provenance.notes in the request body", async () => {
|
||||
capturedCreateBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "create", "/tmp/rig.yaml",
|
||||
"-o", "/tmp/test.rigbundle",
|
||||
"--notes", "checkpoint-2-part-3 fixture",
|
||||
]);
|
||||
});
|
||||
const createBody = capturedCreateBodies[capturedCreateBodies.length - 1];
|
||||
expect(createBody).toBeTruthy();
|
||||
const provenance = createBody!["provenance"] as Record<string, unknown> | undefined;
|
||||
expect(provenance).toBeTruthy();
|
||||
expect(provenance!["notes"]).toBe("checkpoint-2-part-3 fixture");
|
||||
});
|
||||
|
||||
// Item 1 / slice-05: provenance auto-includes hostname + cliVersion at invoke time
|
||||
it("bundle create automatically includes hostname + cliVersion in provenance (no flag needed)", 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();
|
||||
const provenance = createBody!["provenance"] as Record<string, unknown> | undefined;
|
||||
expect(provenance).toBeTruthy();
|
||||
// hostname and cliVersion auto-populate (real os.hostname() + CLI package.json read)
|
||||
expect(typeof provenance!["sourceHost"]).toBe("string");
|
||||
expect((provenance!["sourceHost"] as string).length).toBeGreaterThan(0);
|
||||
expect(typeof provenance!["cliVersion"]).toBe("string");
|
||||
expect((provenance!["cliVersion"] as string).length).toBeGreaterThan(0);
|
||||
// notes is undefined when --notes not passed (no empty string sent)
|
||||
expect(provenance!["notes"]).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 = [];
|
||||
const origEnv = process.env.OPENRIG_SESSION_NAME;
|
||||
process.env.OPENRIG_SESSION_NAME = "velocity-driver@openrig-velocity";
|
||||
try {
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "create", "/tmp/rig.yaml",
|
||||
"-o", "/tmp/test.rigbundle",
|
||||
]);
|
||||
});
|
||||
} finally {
|
||||
if (origEnv === undefined) delete process.env.OPENRIG_SESSION_NAME;
|
||||
else process.env.OPENRIG_SESSION_NAME = origEnv;
|
||||
}
|
||||
const createBody = capturedCreateBodies[capturedCreateBodies.length - 1];
|
||||
expect(createBody).toBeTruthy();
|
||||
const provenance = createBody!["provenance"] as Record<string, unknown> | undefined;
|
||||
expect(provenance).toBeTruthy();
|
||||
expect(provenance!["authorSession"]).toBe("velocity-driver@openrig-velocity");
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,44 @@ 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 } from "../domain/bundle-types.js";
|
||||
import type { PodBundleManifest } from "../domain/bundle-types.js";
|
||||
import type { PodBundleManifest, BundleProvenance } from "../domain/bundle-types.js";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* Read the daemon's own package.json version at call time (Item 1 / slice-05).
|
||||
* Function-level read on purpose: module-level constants would mask test
|
||||
* isolation per the audit-every-layer discipline. The read is cheap and
|
||||
* only happens on bundle create.
|
||||
*/
|
||||
function getDaemonVersion(): string {
|
||||
try {
|
||||
const here = fileURLToPath(import.meta.url);
|
||||
const pkgPath = nodePath.join(nodePath.dirname(here), "..", "..", "package.json");
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { version?: unknown };
|
||||
return typeof pkg.version === "string" ? pkg.version : "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize raw provenance from request body into a BundleProvenance object.
|
||||
* Only string fields are accepted; unknown/non-string fields are dropped
|
||||
* silently. Returns undefined if the input is missing or has no usable
|
||||
* fields. Daemon-side daemonVersion injection is the caller's responsibility.
|
||||
*/
|
||||
function provenanceFromRequestBody(raw: unknown): BundleProvenance | undefined {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
||||
const p = raw as Record<string, unknown>;
|
||||
const result: BundleProvenance = {};
|
||||
if (typeof p["sourceHost"] === "string") result.sourceHost = p["sourceHost"];
|
||||
if (typeof p["authorSession"] === "string") result.authorSession = p["authorSession"];
|
||||
if (typeof p["sourceRigId"] === "string") result.sourceRigId = p["sourceRigId"];
|
||||
if (typeof p["sourceRigName"] === "string") result.sourceRigName = p["sourceRigName"];
|
||||
if (typeof p["cliVersion"] === "string") result.cliVersion = p["cliVersion"];
|
||||
if (typeof p["notes"] === "string") result.notes = p["notes"];
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
import type { FsOps } from "../domain/package-resolver.js";
|
||||
|
||||
export const bundleRoutes = new Hono();
|
||||
@@ -86,6 +123,12 @@ bundleRoutes.post("/create", async (c) => {
|
||||
const rigRoot = typeof body["rigRoot"] === "string" ? body["rigRoot"] : undefined;
|
||||
const includePackages = Array.isArray(body["includePackages"]) ? body["includePackages"] as string[] : undefined;
|
||||
|
||||
// Item 1 / slice-05: build provenance from request body + inject daemonVersion server-side
|
||||
const clientProvenance = provenanceFromRequestBody(body["provenance"]);
|
||||
const provenance: BundleProvenance | undefined = clientProvenance
|
||||
? { ...clientProvenance, daemonVersion: getDaemonVersion() }
|
||||
: undefined;
|
||||
|
||||
if (!specPath || !bundleName || !bundleVersion || !outputPath) {
|
||||
return c.json({ error: "specPath, bundleName, bundleVersion, and outputPath are required" }, 400);
|
||||
}
|
||||
@@ -105,7 +148,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 });
|
||||
const result = assembler.assemble({ rigRoot: effectiveRigRoot, rigSpecPath: nodePath.resolve(specPath), outputDir: tmpStaging, bundleName, bundleVersion, provenance });
|
||||
|
||||
const integrity = computeIntegrity(tmpStaging, integrityFsOps());
|
||||
result.manifest.integrity = integrity;
|
||||
@@ -162,7 +205,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,
|
||||
specPath: nodePath.resolve(specPath), packages, outputDir: tmpStaging, bundleName, bundleVersion, provenance,
|
||||
});
|
||||
|
||||
const integrity = computeIntegrity(tmpStaging, integrityFsOps());
|
||||
@@ -243,6 +286,9 @@ bundleRoutes.post("/inspect", async (c) => {
|
||||
algorithm: integritySection.algorithm ?? "sha256",
|
||||
files: integritySection.files ?? {},
|
||||
} : undefined,
|
||||
// Item 1 / slice-05: surface provenance raw from manifest so JSON-path
|
||||
// consumers (UI inspector, jq pipelines) see it. Field is optional.
|
||||
provenance: rawParsed["provenance"] as Record<string, unknown> | undefined,
|
||||
};
|
||||
const integrityCompat = integritySection ? {
|
||||
schemaVersion: 2,
|
||||
|
||||
@@ -102,6 +102,66 @@ describe("Bundle API routes", () => {
|
||||
expect(fs.existsSync(outputPath)).toBe(true);
|
||||
});
|
||||
|
||||
// Item 1 / slice-05: provenance round-trip through /create + /inspect
|
||||
it("POST /api/bundles/create accepts provenance + /inspect surfaces it (v1 round-trip)", async () => {
|
||||
const { specPath } = seedPackage();
|
||||
const bundlePath = path.join(tmpDir, "prov-test.rigbundle");
|
||||
|
||||
const createRes = await app.request("/api/bundles/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
specPath, bundleName: "prov-test", bundleVersion: "0.1.0", outputPath: bundlePath,
|
||||
provenance: {
|
||||
sourceHost: "route-test-host",
|
||||
authorSession: "velocity-driver@openrig-velocity",
|
||||
cliVersion: "0.3.2",
|
||||
notes: "route-test fixture",
|
||||
},
|
||||
}),
|
||||
});
|
||||
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.provenance).toBeDefined();
|
||||
expect(inspectBody.manifest.provenance.sourceHost).toBe("route-test-host");
|
||||
expect(inspectBody.manifest.provenance.authorSession).toBe("velocity-driver@openrig-velocity");
|
||||
expect(inspectBody.manifest.provenance.cliVersion).toBe("0.3.2");
|
||||
expect(inspectBody.manifest.provenance.notes).toBe("route-test fixture");
|
||||
// Server-side daemonVersion injection — read from daemon package.json at call time
|
||||
expect(typeof inspectBody.manifest.provenance.daemonVersion).toBe("string");
|
||||
expect(inspectBody.manifest.provenance.daemonVersion.length).toBeGreaterThan(0);
|
||||
// createdAt mirrored from root
|
||||
expect(inspectBody.manifest.provenance.createdAt).toBe(inspectBody.manifest.createdAt);
|
||||
});
|
||||
|
||||
it("POST /api/bundles/create with no provenance produces a bundle whose manifest omits provenance (backward compat)", async () => {
|
||||
const { specPath } = seedPackage();
|
||||
const bundlePath = path.join(tmpDir, "no-prov.rigbundle");
|
||||
|
||||
const createRes = await app.request("/api/bundles/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ specPath, bundleName: "no-prov", bundleVersion: "0.1.0", outputPath: bundlePath }),
|
||||
});
|
||||
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.provenance).toBeUndefined();
|
||||
});
|
||||
|
||||
// T2: Inspect returns manifest
|
||||
it("POST /api/bundles/inspect returns manifest + integrity", async () => {
|
||||
const { specPath } = seedPackage();
|
||||
|
||||
Reference in new issue
Block a user