mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-10 11:41:37 -10:00
fix(slice-11): emit workflow_spec.removed audit event on deletion
QA VM-dogfood verdict at f68f453 (qitem-20260511065129-a4e881a1):
single BLOCKING miss — deletion path removed the workflow_specs cache
row but did not produce an audit-log entry. OQ-4 contract requires
BOTH: "Remove cache row when file disappears + audit-log entry. Clean
Library + traceable." HG-3 verification: deletion produces both cache
row removal AND audit-log entry visible in event log / audit surface.
Adds RigEvent variant `workflow_spec.removed` carrying sourcePath +
spec_id/name/version (when known) + reason="file_disappeared". The
folder scanner emits one event per row removed; emission is opt-in
via the new optional `eventBus` field on ScanWorkflowSpecFolderOpts,
so unit-test callers that don't care about audit can omit it (the
back-compat test asserts this). Route layer plumbs the eventBus
context var into every scanWorkflowSpecFolder invocation.
Tests (TDD red→green):
- workflow_spec.removed emitted once per deleted file with sourcePath
matching the row that disappeared
- multiple deletions produce N distinct events with distinct paths
(drift-discriminator per banked feedback_poc_regression_must_discriminate)
- omitting eventBus → no emission (back-compat)
- daemon full sweep: 3161/3161 green (+3 new tests)
- daemon tsc clean
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
f68f453aec
commit
8aa1f4d976
4 files changed
+107
-8
No files matched your search
@@ -25,6 +25,7 @@ import { createHash } from "node:crypto";
|
||||
import type Database from "better-sqlite3";
|
||||
import type { WorkflowSpec } from "./workflow-types.js";
|
||||
import type { WorkflowSpecCache } from "./workflow-spec-cache.js";
|
||||
import type { EventBus } from "./event-bus.js";
|
||||
|
||||
export interface SpecLibraryWorkflowEntry {
|
||||
id: string;
|
||||
@@ -342,6 +343,11 @@ export interface ScanWorkflowSpecFolderOpts {
|
||||
/** Daemon's bundled builtin starter dir; used to skip removal logic
|
||||
* for built-in rows (the scanner only owns the folder it walks). */
|
||||
builtinDir: string | null;
|
||||
/** Optional EventBus — when wired, the scanner emits a
|
||||
* workflow_spec.removed audit event for each cache row removed because
|
||||
* its source file disappeared (OQ-4 acceptance: deletion + audit log).
|
||||
* Omitted in unit tests that don't care about emission. */
|
||||
eventBus?: EventBus;
|
||||
}
|
||||
|
||||
export interface ScanWorkflowSpecFolderResult {
|
||||
@@ -451,12 +457,37 @@ export function scanWorkflowSpecFolder(
|
||||
// rows or rows the scanner doesn't own (e.g., other workflows folders).
|
||||
const folderPrefix = opts.folder.endsWith(path.sep) ? opts.folder : `${opts.folder}${path.sep}`;
|
||||
const cachedUnderFolder = opts.db
|
||||
.prepare(`SELECT source_path FROM workflow_specs WHERE source_path LIKE ?`)
|
||||
.all(`${folderPrefix}%`) as Array<{ source_path: string }>;
|
||||
for (const { source_path } of cachedUnderFolder) {
|
||||
if (seenPaths.has(source_path)) continue;
|
||||
const removed = opts.cache.removeBySourcePath(source_path);
|
||||
if (removed > 0) result.removed += removed;
|
||||
.prepare(
|
||||
`SELECT source_path, spec_id, name, version FROM workflow_specs WHERE source_path LIKE ?`,
|
||||
)
|
||||
.all(`${folderPrefix}%`) as Array<{
|
||||
source_path: string;
|
||||
spec_id: string | null;
|
||||
name: string | null;
|
||||
version: string | null;
|
||||
}>;
|
||||
for (const row of cachedUnderFolder) {
|
||||
if (seenPaths.has(row.source_path)) continue;
|
||||
const removed = opts.cache.removeBySourcePath(row.source_path);
|
||||
if (removed > 0) {
|
||||
result.removed += removed;
|
||||
// OQ-4 audit-log: record the disappearance so operators can trace
|
||||
// which spec was reaped when. Emission failures must not abort the
|
||||
// scan — the cache row is already gone and the next scan would be
|
||||
// a no-op for this path.
|
||||
if (opts.eventBus) {
|
||||
try {
|
||||
opts.eventBus.emit({
|
||||
type: "workflow_spec.removed",
|
||||
sourcePath: row.source_path,
|
||||
specId: row.spec_id ?? null,
|
||||
specName: row.name ?? null,
|
||||
specVersion: row.version ?? null,
|
||||
reason: "file_disappeared",
|
||||
});
|
||||
} catch { /* best-effort audit emission */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -193,6 +193,12 @@ export type RigEvent =
|
||||
| { type: "workflow.completed"; instanceId: string; workflowName: string }
|
||||
| { type: "workflow.failed"; instanceId: string; workflowName: string; reason: string }
|
||||
| { type: "workflow.routing_table_changed"; rigName: string; cause: string }
|
||||
// Slice 11 (release-0.3.1 workflow-spec-folder-discovery): folder-scan
|
||||
// deletion path. Emitted once per workflow_specs cache row removed
|
||||
// because its source file disappeared from the scanned workflows folder.
|
||||
// Provides a traceable audit-log entry for OQ-4 ("Remove cache row when
|
||||
// file disappears + audit-log entry. Clean Library + traceable.").
|
||||
| { type: "workflow_spec.removed"; sourcePath: string; specId: string | null; specName: string | null; specVersion: string | null; reason: "file_disappeared" }
|
||||
// PL-005 Phase A: Mission Control / Queue Observability events.
|
||||
// Action audit + cross-CLI-version drift detection. view_refreshed
|
||||
// is emitted when a Mission Control view is recomputed (SSE
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
scanWorkflowSpecFolder,
|
||||
} from "../domain/spec-library-workflow-scanner.js";
|
||||
import type { WorkflowSpecCache } from "../domain/workflow-spec-cache.js";
|
||||
import type { EventBus } from "../domain/event-bus.js";
|
||||
|
||||
export function specLibraryRoutes(): Hono {
|
||||
const router = new Hono();
|
||||
@@ -31,9 +32,10 @@ export function specLibraryRoutes(): Hono {
|
||||
// cache-only behavior.
|
||||
const cache = c.get("workflowSpecCache" as never) as WorkflowSpecCache | undefined;
|
||||
const folder = c.get("workflowsFolderDir" as never) as string | undefined;
|
||||
const eventBus = c.get("eventBus" as never) as EventBus | undefined;
|
||||
if (cache && folder) {
|
||||
try {
|
||||
scanWorkflowSpecFolder({ db, cache, folder, builtinDir: builtinDir ?? null });
|
||||
scanWorkflowSpecFolder({ db, cache, folder, builtinDir: builtinDir ?? null, eventBus });
|
||||
} catch { /* best-effort — folder scan failure must not break Library list */ }
|
||||
}
|
||||
// Re-scan workflow specs on each list request — cheap (single
|
||||
|
||||
@@ -12,10 +12,12 @@ import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { workflowSpecsSchema } from "../src/db/migrations/033_workflow_specs.js";
|
||||
import { workflowSpecsDiagnosticSchema } from "../src/db/migrations/040_workflow_specs_diagnostic.js";
|
||||
import { WorkflowSpecCache } from "../src/domain/workflow-spec-cache.js";
|
||||
import { scanWorkflowSpecFolder } from "../src/domain/spec-library-workflow-scanner.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
|
||||
const VALID_YAML = `workflow:
|
||||
id: folder-test
|
||||
@@ -76,7 +78,7 @@ describe("scanWorkflowSpecFolder (slice 11)", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [coreSchema, workflowSpecsSchema, workflowSpecsDiagnosticSchema]);
|
||||
migrate(db, [coreSchema, eventsSchema, workflowSpecsSchema, workflowSpecsDiagnosticSchema]);
|
||||
cache = new WorkflowSpecCache(db);
|
||||
folder = mkdtempSync(join(tmpdir(), "wf-folder-"));
|
||||
});
|
||||
@@ -178,6 +180,64 @@ describe("scanWorkflowSpecFolder (slice 11)", () => {
|
||||
rmSync(externalPath);
|
||||
});
|
||||
|
||||
it("emits workflow_spec.removed audit event for each deleted file (HG-3)", () => {
|
||||
// OQ-4 acceptance criterion: deletion produces BOTH cache row removal
|
||||
// AND audit-log entry. Without the event emission, the Library shows a
|
||||
// clean state but operators can't trace WHICH spec disappeared WHEN.
|
||||
writeFileSync(join(folder, "wf.yaml"), VALID_YAML);
|
||||
writeFileSync(join(folder, "wf2.yaml"), VALID_YAML_TWO);
|
||||
const eventBus = new EventBus(db);
|
||||
scanWorkflowSpecFolder({ db, cache, folder, builtinDir: null, eventBus });
|
||||
|
||||
rmSync(join(folder, "wf.yaml"));
|
||||
const removedFilePath = join(folder, "wf.yaml");
|
||||
const result = scanWorkflowSpecFolder({ db, cache, folder, builtinDir: null, eventBus });
|
||||
expect(result.removed).toBe(1);
|
||||
|
||||
const events = db
|
||||
.prepare(`SELECT type, payload FROM events WHERE type = 'workflow_spec.removed'`)
|
||||
.all() as Array<{ type: string; payload: string }>;
|
||||
expect(events).toHaveLength(1);
|
||||
const payload = JSON.parse(events[0]!.payload) as { type: string; sourcePath: string; reason: string };
|
||||
expect(payload.type).toBe("workflow_spec.removed");
|
||||
expect(payload.sourcePath).toBe(removedFilePath);
|
||||
expect(payload.reason).toBe("file_disappeared");
|
||||
});
|
||||
|
||||
it("emits one workflow_spec.removed event per file when multiple disappear", () => {
|
||||
// Drift-discriminator for the emission loop: two distinct deletions must
|
||||
// produce two distinct events with distinct sourcePaths (not one batched
|
||||
// event nor one repeated).
|
||||
writeFileSync(join(folder, "a.yaml"), VALID_YAML);
|
||||
writeFileSync(join(folder, "b.yaml"), VALID_YAML_TWO);
|
||||
const eventBus = new EventBus(db);
|
||||
scanWorkflowSpecFolder({ db, cache, folder, builtinDir: null, eventBus });
|
||||
|
||||
rmSync(join(folder, "a.yaml"));
|
||||
rmSync(join(folder, "b.yaml"));
|
||||
const result = scanWorkflowSpecFolder({ db, cache, folder, builtinDir: null, eventBus });
|
||||
expect(result.removed).toBe(2);
|
||||
|
||||
const events = db
|
||||
.prepare(`SELECT payload FROM events WHERE type = 'workflow_spec.removed' ORDER BY seq`)
|
||||
.all() as Array<{ payload: string }>;
|
||||
expect(events).toHaveLength(2);
|
||||
const paths = events.map((e) => (JSON.parse(e.payload) as { sourcePath: string }).sourcePath).sort();
|
||||
expect(paths).toEqual([join(folder, "a.yaml"), join(folder, "b.yaml")]);
|
||||
});
|
||||
|
||||
it("does NOT emit workflow_spec.removed when eventBus is omitted (back-compat)", () => {
|
||||
writeFileSync(join(folder, "wf.yaml"), VALID_YAML);
|
||||
scanWorkflowSpecFolder({ db, cache, folder, builtinDir: null });
|
||||
rmSync(join(folder, "wf.yaml"));
|
||||
const result = scanWorkflowSpecFolder({ db, cache, folder, builtinDir: null });
|
||||
expect(result.removed).toBe(1);
|
||||
const events = db
|
||||
.prepare(`SELECT COUNT(*) as n FROM events WHERE type = 'workflow_spec.removed'`)
|
||||
.get() as { n: number };
|
||||
expect(events.n).toBe(0);
|
||||
});
|
||||
|
||||
it("drift-discriminator: same scan emits 3 distinct outcomes for 3 distinct files", () => {
|
||||
// Per banked feedback_poc_regression_must_discriminate — fixtures
|
||||
// distinct enough that the scanner's per-file decision branches are
|
||||
|
||||
Reference in new issue
Block a user