mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
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:
1 parent
a660b47592
commit
0af652dca7
2 files changed
+307
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user