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:
mvschwarzandClaude Opus 4.7 committed 2026-05-10 23:55:12 -07:00
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;
+6
View File
@@ -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
+3 -1
View File
@@ -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