feat(slice-05): bundle history route + CLI subcommand (Item 4 Checkpoint 5.2) (OPR.0.3.2.5)

Item 4 part 2: adds the read-side surfaces (GET /api/bundles/history +
rig bundle history CLI subcommand) over the bundle-audit module from
0af652dc. Empty audit file returns []; rig + since filters honored.
The /install handler audit-write integration lands at Checkpoint 5.3
(closes Item 4 end-to-end).

Files touched:
- packages/daemon/src/routes/bundles.ts: import BundleAuditReader +
  getDefaultOpenRigPath; new auditFsOps() + bundleAuditPath() helpers
  (function-level reads — audit-every-layer compliant); new GET
  /api/bundles/history handler with rig + since query params
- packages/cli/src/commands/bundle.ts: new rig bundle history
  subcommand with --rig / --since / --json flags; renders empty
  message when no records; renders compact text table by default
- packages/daemon/test/bundle-routes.test.ts: 2 new tests — empty
  audit file returns [] + records with all three filter shapes (none,
  rig-only, since-only, combined). Test isolation via OPENRIG_HOME env
  override to a per-test tmpDir.
- packages/cli/test/bundle.test.ts: 3 new tests — text rendering,
  --json output shape, --rig query-string passthrough. Mock server
  handles GET /api/bundles/history with rig filter.

Diff: 4 files, +192 / 0 lines.

Verified:
- 40/40 daemon (was 30 + 10 audit; +2 new route tests; bundle-audit
  10/10 unchanged)
- 22/22 CLI (was 19; +3 new history tests)
- 62/62 total bundle tests in the path I just ran
- scope-and-versioning coherence grep clean
- audit-every-layer: zero new module-level constants reading
  env/host/version (auditFsOps + bundleAuditPath both call-time
  reads; reader receives FsOps via DI)

Pre-commit discipline applied (banked): git diff --cached --stat
verified +192/0 across 4 intended files BEFORE commit.

PRD bounce conditions verified:
- No schema bump
- No new SQLite migrations (JSONL only)
- No event types
- No core-routing or bootstrap mutation
- No sidebar entries touched
- scope-and-versioning sections 1+3 preserved

What this commit does NOT ship (Checkpoint 5.3 — closes Item 4):
- /install handler writes audit records on each completion path
  (plan-success, apply-completed, apply-partial, apply-failed) so
  the history endpoint surfaces real records in production usage
- Audit record composition (composes installedAt + bundlePath +
  archiveHash + targetRig{Id,Name} + sourceHost + daemonVersion +
  cliVersion + outcome at install time)

Item 4 sub-chain:
- 0af652dc (Item 4 part 1: audit module, CLEAR) <- prior tip
- THIS COMMIT — Item 4 part 2: history route + CLI
- Next (5.3) — /install audit-write integration; closes Item 4

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-18 14:05:10 -07:00
1 parent 0af652dca7
commit b0ae54ad57
4 files changed
+192

No files matched your search

+43
View File
@@ -176,5 +176,48 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
if (res.data["rigId"]) console.log(`Rig: ${res.data["rigId"]}`);
});
// rig bundle history — Item 4 / slice-05 Checkpoint 5.2
cmd.command("history")
.description("List bundle install audit records from ~/.openrig/bundle-audit.jsonl")
.option("--rig <name>", "Filter to records whose targetRigName matches")
.option("--since <iso>", "Filter to records installedAt >= this ISO timestamp")
.option("--json", "JSON output")
.action(async (opts: { rig?: string; since?: string; json?: boolean }) => {
const deps = getDepsF();
const client = await getClient(deps);
if (!client) { process.exitCode = 1; return; }
const qs = new URLSearchParams();
if (opts.rig) qs.set("rig", opts.rig);
if (opts.since) qs.set("since", opts.since);
const query = qs.toString();
const path = query.length > 0 ? `/api/bundles/history?${query}` : "/api/bundles/history";
const res = await client.get<Record<string, unknown>>(path);
if (opts.json) {
console.log(JSON.stringify(res.data));
if (res.status >= 400) process.exitCode = 2;
return;
}
if (res.status >= 400) {
console.error(res.data["error"] ?? "History fetch failed");
process.exitCode = 2;
return;
}
const records = Array.isArray(res.data["records"]) ? res.data["records"] as Array<Record<string, unknown>> : [];
if (records.length === 0) {
console.log("No bundle install audit records found.");
return;
}
console.log(`Bundle install history (${records.length} record${records.length === 1 ? "" : "s"}):`);
for (const r of records) {
const at = r["installedAt"] ?? "?";
const rig = r["targetRigName"] ?? "?";
const outcome = r["outcome"] ?? "?";
const bundle = r["bundlePath"] ?? "?";
console.log(` ${at} ${outcome.toString().padEnd(8)} rig=${rig} ${bundle}`);
}
});
return cmd;
}
+41
View File
@@ -105,6 +105,17 @@ describe("Bundle CLI", () => {
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "completed", runId: "run-2", rigId: "rig-1" }));
}
} else if (req.url?.startsWith("/api/bundles/history") && req.method === "GET") {
// Capture query for assertion if needed; for now return a static fixture
const url = new URL(req.url, "http://x");
const rigFilter = url.searchParams.get("rig");
const allRecs = [
{ installedAt: "2026-05-18T10:00:00Z", bundlePath: "/tmp/a.rigbundle", targetRigName: "alpha", outcome: "success" },
{ installedAt: "2026-05-18T11:00:00Z", bundlePath: "/tmp/b.rigbundle", targetRigName: "beta", outcome: "failed" },
];
const recs = rigFilter ? allRecs.filter((r) => r.targetRigName === rigFilter) : allRecs;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ records: recs, total: recs.length }));
} else {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
@@ -351,6 +362,36 @@ describe("Bundle CLI", () => {
expect(installBody!["force"]).toBe(false);
});
// Item 4 / slice-05 Checkpoint 5.2: rig bundle history subcommand
it("bundle history renders records as text by default", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "bundle", "history"]);
});
expect(logs.some((l) => l.includes("Bundle install history"))).toBe(true);
expect(logs.some((l) => l.includes("alpha"))).toBe(true);
expect(logs.some((l) => l.includes("beta"))).toBe(true);
expect(logs.some((l) => l.includes("success"))).toBe(true);
});
it("bundle history --json outputs parseable JSON with records array", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "bundle", "history", "--json"]);
});
const parsed = JSON.parse(logs.join(""));
expect(Array.isArray(parsed.records)).toBe(true);
expect(parsed.records.length).toBe(2);
expect(parsed.total).toBe(2);
});
it("bundle history --rig filter passes through to the query string", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "bundle", "history", "--rig", "alpha", "--json"]);
});
const parsed = JSON.parse(logs.join(""));
expect(parsed.records).toHaveLength(1);
expect(parsed.records[0].targetRigName).toBe("alpha");
});
// 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 = [];
+40
View File
@@ -19,6 +19,8 @@ import type { PodBundleManifest, BundleProvenance, BundleCompatibility } from ".
import { fileURLToPath } from "node:url";
import { detectBundleConflicts, type BundleConflict } from "../domain/bundle-conflict-detector.js";
import type { RigRepository } from "../domain/rig-repository.js";
import { BundleAuditReader, type BundleAuditFsOps } from "../domain/bundle-audit.js";
import { getDefaultOpenRigPath } from "../openrig-compat.js";
/**
* Read the daemon's own package.json version at call time (Item 1 / slice-05).
@@ -274,6 +276,25 @@ function podAssemblerFsOps(): PodAssemblerFsOps {
};
}
/** Item 4 / slice-05 Checkpoint 5.2: real BundleAuditFsOps backed by node:fs. */
function auditFsOps(): BundleAuditFsOps {
return {
appendFile: (p, c) => fs.appendFileSync(p, c, "utf-8"),
readFile: (p) => fs.readFileSync(p, "utf-8"),
exists: (p) => fs.existsSync(p),
mkdirp: (p) => fs.mkdirSync(p, { recursive: true }),
};
}
/**
* Audit file path resolved at call time via getDefaultOpenRigPath
* ("bundle-audit.jsonl"). Function-level read (no module-level constant)
* so OPENRIG_HOME env changes between requests / tests are honored.
*/
function bundleAuditPath(): string {
return getDefaultOpenRigPath("bundle-audit.jsonl");
}
// POST /api/bundles/create
bundleRoutes.post("/create", async (c) => {
const { eventBus } = getDeps(c);
@@ -488,6 +509,25 @@ bundleRoutes.post("/inspect", async (c) => {
}
});
// GET /api/bundles/history — Item 4 / slice-05 Checkpoint 5.2
// Returns the install audit JSONL records (optionally filtered by rig name
// and / or since timestamp). Read-only — no audit-write side effects.
// Empty audit file returns []. Reader fails-open on malformed JSONL lines
// (forward-compat with future record-shape evolutions).
bundleRoutes.get("/history", async (c) => {
const rig = c.req.query("rig");
const since = c.req.query("since");
const reader = new BundleAuditReader({
opts: { auditPath: bundleAuditPath() },
fsOps: auditFsOps(),
});
const records = reader.list({
rig: typeof rig === "string" && rig.length > 0 ? rig : undefined,
since: typeof since === "string" && since.length > 0 ? since : undefined,
});
return c.json({ records, total: records.length }, 200);
});
// POST /api/bundles/install — reuses full bootstrap lifecycle
bundleRoutes.post("/install", async (c) => {
const { bootstrapOrchestrator, bootstrapRepo, eventBus, rigRepo } = getDeps(c);
@@ -968,6 +968,74 @@ describe("Bundle API routes", () => {
}
});
// Item 4 / slice-05 Checkpoint 5.2: GET /api/bundles/history surfaces the
// bundle-audit JSONL records (optionally filtered). Empty file -> []; rig
// filter scopes; since filter scopes.
it("GET /api/bundles/history returns empty list when no audit records exist", async () => {
const origHome = process.env.OPENRIG_HOME;
const auditHome = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-history-test-"));
process.env.OPENRIG_HOME = auditHome;
try {
const res = await app.request("/api/bundles/history");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.records).toEqual([]);
expect(body.total).toBe(0);
} finally {
if (origHome === undefined) delete process.env.OPENRIG_HOME;
else process.env.OPENRIG_HOME = origHome;
fs.rmSync(auditHome, { recursive: true, force: true });
}
});
it("GET /api/bundles/history returns records from the audit file with filters honored", async () => {
const origHome = process.env.OPENRIG_HOME;
const auditHome = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-history-test-"));
process.env.OPENRIG_HOME = auditHome;
try {
// Seed the audit JSONL directly (bypasses any writer; tests the reader path)
const auditPath = path.join(auditHome, "bundle-audit.jsonl");
const recs = [
{ installedAt: "2026-05-18T10:00:00Z", bundlePath: "/tmp/a.rigbundle", targetRigName: "alpha", outcome: "success" },
{ installedAt: "2026-05-18T11:00:00Z", bundlePath: "/tmp/b.rigbundle", targetRigName: "beta", outcome: "failed" },
{ installedAt: "2026-05-18T12:00:00Z", bundlePath: "/tmp/c.rigbundle", targetRigName: "alpha", outcome: "partial" },
];
fs.writeFileSync(auditPath, recs.map((r) => JSON.stringify(r)).join("\n") + "\n", "utf-8");
// Unfiltered: all 3
const all = await app.request("/api/bundles/history");
expect(all.status).toBe(200);
const allBody = await all.json();
expect(allBody.total).toBe(3);
expect(allBody.records).toHaveLength(3);
// Filter rig=alpha: 2 records
const alpha = await app.request("/api/bundles/history?rig=alpha");
const alphaBody = await alpha.json();
expect(alphaBody.total).toBe(2);
expect(alphaBody.records.every((r: { targetRigName: string }) => r.targetRigName === "alpha")).toBe(true);
// Filter since=11:00: 2 records (the 11:00 and 12:00 ones)
const since = await app.request("/api/bundles/history?since=2026-05-18T11:00:00Z");
const sinceBody = await since.json();
expect(sinceBody.total).toBe(2);
expect(sinceBody.records.map((r: { installedAt: string }) => r.installedAt)).toEqual([
"2026-05-18T11:00:00Z",
"2026-05-18T12:00:00Z",
]);
// Combined rig=alpha + since=11:00: 1 record (the 12:00 alpha one)
const combo = await app.request("/api/bundles/history?rig=alpha&since=2026-05-18T11:00:00Z");
const comboBody = await combo.json();
expect(comboBody.total).toBe(1);
expect(comboBody.records[0].installedAt).toBe("2026-05-18T12:00:00Z");
} finally {
if (origHome === undefined) delete process.env.OPENRIG_HOME;
else process.env.OPENRIG_HOME = origHome;
fs.rmSync(auditHome, { recursive: true, force: true });
}
});
// T11: Install concurrency lock
it("concurrent bundle install returns 409", async () => {
// Acquire lock manually