feat(slice-05): bundle-audit module — JSONL writer + reader (Item 4 Checkpoint 5.1) (OPR.0.3.2.5)

Opens Item 4 (install audit trail). Ships the pure-data audit
infrastructure: BundleAuditWriter (append-only JSONL) +
BundleAuditReader (with rig + since filters). The route handler
integration (GET /api/bundles/history) + CLI subcommand (rig bundle
history) + writer wiring into /install land at Checkpoint 5.2.

Honors PRD §"Item 4 Install audit trail": append-only JSONL at
~/.openrig/bundle-audit.jsonl; no new SQLite schema or migration;
matches the JSONL audit convention used by Transcript / Log Cleanup
v0 and UI Enhancement Pack v0.

What ships:
- New packages/daemon/src/domain/bundle-audit.ts (~135 lines):
  - BundleAuditRecord interface (installedAt, bundlePath, archiveHash,
    targetRigId, targetRigName, sourceHost, daemonVersion, cliVersion,
    outcome — outcome is "success" / "failed" / "partial")
  - BundleAuditFsOps injection point (appendFile / readFile / exists /
    mkdirp) mirroring the codebase pattern for testability
  - BundleAuditOpts (auditPath)
  - BundleAuditWriter class with append() — mkdirp parent + write
    JSON-line + newline
  - BundleAuditFilters (rig + since)
  - BundleAuditReader class with list(filters) — read + split-by-line
    + parse + apply filters + return in append order
  - Malformed-line tolerance: forward-compat skips bad lines silently
- New packages/daemon/test/bundle-audit.test.ts: 10 tests covering
  empty-file (returns empty array), single-record round-trip, mkdirp
  creates parent dir, JSONL format (one object per line + trailing
  newline), append-order preserved, rig filter (scopes to matching
  targetRigName), since filter (scopes to records >= cutoff), combined
  rig+since (AND semantics), malformed-line skipping, outcome-field
  round-trip across success/failed/partial values

Diff: 2 new files, +307 lines. No existing files modified.

Verified:
- 10/10 new bundle-audit tests green
- Discriminator confirmed: temporarily making writer.append a no-op
  fails 8/10 tests (positive append-and-read assertions); 2 stay
  green for the same not-asserting-append reason (empty-list test
  bypasses writer; malformed-line test seeds state directly bypassing
  writer)
- scope-and-versioning sections 1 and 3 coherence grep clean
- audit-every-layer: no module-level env/host/version reads — module
  is pure-data with FsOps injected

Pre-commit discipline applied (banked lesson): git diff --cached
--stat inspected after explicit git add of 2 intended files; +307
lines / 0 deletions / 2 files match intent exactly.

PRD bounce conditions verified:
- No schema bump (JSONL, not SQL)
- No new SQLite migrations (file-based)
- No event types
- No core-routing or bootstrap-orchestrator mutation
- No sidebar entries touched
- scope-and-versioning coherence preserved

What this commit does NOT ship (Checkpoint 5.2):
- /install handler writes audit record on each install completion
  (success / failed / partial)
- GET /api/bundles/history route serving filtered records
- CLI bundle history subcommand with --rig / --since / --json flags

Item 4 sub-chain on this branch:
- a660b475 (Item 3 B1 rig_spec safety repair, CLEAR) <- prior tip
- THIS COMMIT — Item 4 part 1: bundle-audit module
- Next (5.2) — /install audit-write integration + /api/bundles/history
  route + CLI bundle history subcommand

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-18 13:58:15 -07:00
1 parent a660b47592
commit 0af652dca7
2 files changed
+307

No files matched your search

+135
View File
@@ -0,0 +1,135 @@
/**
* Bundle install audit trail (Item 4 / slice-05 Checkpoint 5.1).
*
* Append-only JSONL log of bundle install events. Matches the JSONL audit-
* trail convention used by Transcript / Log Cleanup v0 and UI Enhancement
* Pack v0 — no new SQLite schema or migration.
*
* Default location is ~/.openrig/bundle-audit.jsonl. The writer is a pure
* data sink: callers (the /api/bundles/install route handler) compose the
* record at the right moment in the install lifecycle and hand it to
* BundleAuditWriter.append. The reader exposes a small filter API
* (rig name + since timestamp) for the /api/bundles/history endpoint and
* the `rig bundle history` CLI subcommand (both land at Checkpoint 5.2).
*
* FsOps injection mirrors the codebase pattern for testability: real
* implementation reads/writes node:fs; tests substitute an in-memory mock.
*/
import nodePath from "node:path";
/** A single bundle install audit row. */
export interface BundleAuditRecord {
/** ISO timestamp of when the install event was recorded. */
installedAt: string;
/** Source bundle path the install operated on. */
bundlePath: string;
/** SHA-256 archive hash (when available from bundle's sibling digest). */
archiveHash?: string;
/** ULID of the rig the install targeted (when applicable). */
targetRigId?: string;
/** Name of the rig the install targeted (when applicable). */
targetRigName?: string;
/** Hostname recorded in the bundle's provenance (Item 1), when present. */
sourceHost?: string;
/** Daemon version at install time. */
daemonVersion?: string;
/** CLI version at install time (when CLI sent it; Item 1 + Item 2 path). */
cliVersion?: string;
/** Install outcome — keep honest (partial = some stages ok, some not). */
outcome: "success" | "failed" | "partial";
}
/** Filesystem injection point — real impl wraps node:fs. Tests substitute in-memory. */
export interface BundleAuditFsOps {
appendFile: (path: string, content: string) => void;
readFile: (path: string) => string;
exists: (path: string) => boolean;
mkdirp: (path: string) => void;
}
/** Configuration for the audit writer + reader. */
export interface BundleAuditOpts {
/** Absolute path to the JSONL file (default location: ~/.openrig/bundle-audit.jsonl). */
auditPath: string;
}
/** Append-only writer for bundle install audit records. */
export class BundleAuditWriter {
private opts: BundleAuditOpts;
private fs: BundleAuditFsOps;
constructor(deps: { opts: BundleAuditOpts; fsOps: BundleAuditFsOps }) {
this.opts = deps.opts;
this.fs = deps.fsOps;
}
/**
* Append one record to the JSONL file. Creates the parent directory if
* missing. Each record is one JSON object on its own line (operator can
* tail -f for live monitoring). Throws on filesystem errors via fsOps.
*/
append(record: BundleAuditRecord): void {
this.fs.mkdirp(nodePath.dirname(this.opts.auditPath));
const line = `${JSON.stringify(record)}\n`;
this.fs.appendFile(this.opts.auditPath, line);
}
}
/** Filters supported by the audit reader (subset of /api/bundles/history). */
export interface BundleAuditFilters {
/** When set, only return records whose targetRigName equals this value. */
rig?: string;
/** When set, only return records whose installedAt is >= this ISO timestamp. */
since?: string;
}
/** Reader for the bundle install audit JSONL file. */
export class BundleAuditReader {
private opts: BundleAuditOpts;
private fs: BundleAuditFsOps;
constructor(deps: { opts: BundleAuditOpts; fsOps: BundleAuditFsOps }) {
this.opts = deps.opts;
this.fs = deps.fsOps;
}
/**
* Return all audit records (optionally filtered). Malformed lines are
* skipped silently — forward-compat with future record-shape evolutions
* that don't parse under the current shape; the readable lines still
* surface. Records are returned in append order (oldest first).
*/
list(filters?: BundleAuditFilters): BundleAuditRecord[] {
if (!this.fs.exists(this.opts.auditPath)) return [];
const content = this.fs.readFile(this.opts.auditPath);
const lines = content.split("\n").filter((l) => l.length > 0);
const records: BundleAuditRecord[] = [];
for (const line of lines) {
try {
const parsed: unknown = JSON.parse(line);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
records.push(parsed as BundleAuditRecord);
}
} catch {
// Malformed line — skip silently.
}
}
return applyFilters(records, filters);
}
}
/** Apply the supported filters to an in-memory record array. */
function applyFilters(records: BundleAuditRecord[], filters?: BundleAuditFilters): BundleAuditRecord[] {
if (!filters) return records;
let out = records;
if (filters.rig !== undefined && filters.rig.length > 0) {
const rigName = filters.rig;
out = out.filter((r) => r.targetRigName === rigName);
}
if (filters.since !== undefined && filters.since.length > 0) {
const since = filters.since;
out = out.filter((r) => r.installedAt >= since);
}
return out;
}
+172
View File
@@ -0,0 +1,172 @@
import { describe, it, expect } from "vitest";
import {
BundleAuditWriter,
BundleAuditReader,
type BundleAuditFsOps,
type BundleAuditRecord,
} from "../src/domain/bundle-audit.js";
// Item 4 / slice-05 Checkpoint 5.1: bundle-audit unit tests.
// Discriminator pattern: any test asserting append + read round-trip must
// fail if writer.append or reader.list is broken.
function mockFs(): BundleAuditFsOps & { _state: Map<string, string>; _mkdirpCalls: string[] } {
const state = new Map<string, string>();
const mkdirpCalls: string[] = [];
return {
_state: state,
_mkdirpCalls: mkdirpCalls,
appendFile: (p: string, c: string) => {
state.set(p, (state.get(p) ?? "") + c);
},
readFile: (p: string) => state.get(p) ?? "",
exists: (p: string) => state.has(p),
mkdirp: (p: string) => {
mkdirpCalls.push(p);
},
};
}
const FIXTURE_PATH = "/test/.openrig/bundle-audit.jsonl";
function makeWriter(fs: BundleAuditFsOps) {
return new BundleAuditWriter({ opts: { auditPath: FIXTURE_PATH }, fsOps: fs });
}
function makeReader(fs: BundleAuditFsOps) {
return new BundleAuditReader({ opts: { auditPath: FIXTURE_PATH }, fsOps: fs });
}
function record(overrides?: Partial<BundleAuditRecord>): BundleAuditRecord {
return {
installedAt: "2026-05-18T12:00:00Z",
bundlePath: "/tmp/test.rigbundle",
archiveHash: "a".repeat(64),
targetRigId: "01H000000000000000000001",
targetRigName: "test-rig",
sourceHost: "test-host.local",
daemonVersion: "0.3.2",
cliVersion: "0.3.2",
outcome: "success",
...overrides,
};
}
describe("BundleAuditWriter + BundleAuditReader", () => {
// A1: empty file → empty list
it("reading an empty audit file returns an empty array", () => {
const fs = mockFs();
const reader = makeReader(fs);
expect(reader.list()).toEqual([]);
});
// A2: append + read single record round-trips
it("append + list round-trips a single record verbatim", () => {
const fs = mockFs();
const writer = makeWriter(fs);
const reader = makeReader(fs);
const r = record();
writer.append(r);
const out = reader.list();
expect(out).toHaveLength(1);
expect(out[0]).toEqual(r);
});
// A3: append creates the parent dir
it("first append creates the parent directory via mkdirp", () => {
const fs = mockFs();
const writer = makeWriter(fs);
writer.append(record());
expect(fs._mkdirpCalls.length).toBeGreaterThan(0);
expect(fs._mkdirpCalls[0]).toBe("/test/.openrig");
});
// A4: each record is one JSONL line terminated by newline
it("each append writes exactly one JSON object followed by a newline (JSONL format)", () => {
const fs = mockFs();
const writer = makeWriter(fs);
writer.append(record({ targetRigName: "rig-a" }));
writer.append(record({ targetRigName: "rig-b" }));
const raw = fs._state.get(FIXTURE_PATH)!;
const lines = raw.split("\n");
// Two records → two non-empty lines + a trailing empty (from trailing \n)
expect(lines.length).toBe(3);
expect(lines[2]).toBe("");
expect(JSON.parse(lines[0]!).targetRigName).toBe("rig-a");
expect(JSON.parse(lines[1]!).targetRigName).toBe("rig-b");
});
// A5: list returns records in append order
it("list returns records in append order (oldest first)", () => {
const fs = mockFs();
const writer = makeWriter(fs);
writer.append(record({ installedAt: "2026-05-18T10:00:00Z", targetRigName: "rig-a" }));
writer.append(record({ installedAt: "2026-05-18T11:00:00Z", targetRigName: "rig-b" }));
writer.append(record({ installedAt: "2026-05-18T12:00:00Z", targetRigName: "rig-c" }));
const out = makeReader(fs).list();
expect(out.map((r) => r.targetRigName)).toEqual(["rig-a", "rig-b", "rig-c"]);
});
// A6: rig filter scopes to matching records
it("rig filter returns only records whose targetRigName matches", () => {
const fs = mockFs();
const writer = makeWriter(fs);
writer.append(record({ targetRigName: "alpha" }));
writer.append(record({ targetRigName: "beta" }));
writer.append(record({ targetRigName: "alpha" }));
const out = makeReader(fs).list({ rig: "alpha" });
expect(out).toHaveLength(2);
expect(out.every((r) => r.targetRigName === "alpha")).toBe(true);
});
// A7: since filter scopes to records at-or-after the cutoff
it("since filter returns only records whose installedAt is >= the cutoff", () => {
const fs = mockFs();
const writer = makeWriter(fs);
writer.append(record({ installedAt: "2026-05-18T10:00:00Z" }));
writer.append(record({ installedAt: "2026-05-18T11:00:00Z" }));
writer.append(record({ installedAt: "2026-05-18T12:00:00Z" }));
const out = makeReader(fs).list({ since: "2026-05-18T11:00:00Z" });
expect(out).toHaveLength(2);
expect(out.map((r) => r.installedAt)).toEqual([
"2026-05-18T11:00:00Z",
"2026-05-18T12:00:00Z",
]);
});
// A8: combined rig + since filters
it("rig + since filters both apply (AND semantics)", () => {
const fs = mockFs();
const writer = makeWriter(fs);
writer.append(record({ installedAt: "2026-05-18T10:00:00Z", targetRigName: "alpha" }));
writer.append(record({ installedAt: "2026-05-18T11:00:00Z", targetRigName: "beta" }));
writer.append(record({ installedAt: "2026-05-18T12:00:00Z", targetRigName: "alpha" }));
const out = makeReader(fs).list({ rig: "alpha", since: "2026-05-18T11:00:00Z" });
expect(out).toHaveLength(1);
expect(out[0]!.installedAt).toBe("2026-05-18T12:00:00Z");
});
// A9: malformed lines are skipped silently (forward-compat)
it("malformed JSONL lines are skipped silently; valid lines still surface", () => {
const fs = mockFs();
fs._state.set(FIXTURE_PATH, [
JSON.stringify(record({ targetRigName: "a" })),
"not-valid-json{",
JSON.stringify(record({ targetRigName: "b" })),
].join("\n") + "\n");
const out = makeReader(fs).list();
expect(out).toHaveLength(2);
expect(out.map((r) => r.targetRigName)).toEqual(["a", "b"]);
});
// A10: outcome field is preserved literally (success / failed / partial)
it("outcome field round-trips for all three values", () => {
const fs = mockFs();
const writer = makeWriter(fs);
writer.append(record({ outcome: "success", targetRigName: "ok" }));
writer.append(record({ outcome: "failed", targetRigName: "bad" }));
writer.append(record({ outcome: "partial", targetRigName: "halfway" }));
const out = makeReader(fs).list();
expect(out.map((r) => r.outcome)).toEqual(["success", "failed", "partial"]);
});
});