mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 03:39:51 -10:00
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 from0af652dc. 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:
1 parent
0af652dca7
commit
b0ae54ad57
4 files changed
+192
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user