test(FN-1414): add run-audit integration tests for core and engine
- Add core run-audit integration regression tests covering task activity logging - Add engine run-audit integration tests covering executor lifecycle and mutations - Stabilize run-audit ordering with inline comments for deterministic behavior - Update memory with run-audit testing learnings and patterns - Fix test naming and use valid GitMutationType in tests
This commit is contained in:
@@ -428,3 +428,29 @@ When adding indexes to SQLite schema migrations:
|
|||||||
- Update `SCHEMA_VERSION` constant AND all hardcoded version assertions in tests (e.g., `expect(db.getSchemaVersion()).toBe(N)`)
|
- Update `SCHEMA_VERSION` constant AND all hardcoded version assertions in tests (e.g., `expect(db.getSchemaVersion()).toBe(N)`)
|
||||||
- The `creates all expected indexes` test in `db.test.ts` must list all indexes including new ones
|
- The `creates all expected indexes` test in `db.test.ts` must list all indexes including new ones
|
||||||
- Memory pitfall: Test files like `run-audit.test.ts` and `__tests__/task-documents.test.ts` also assert schema version
|
- Memory pitfall: Test files like `run-audit.test.ts` and `__tests__/task-documents.test.ts` also assert schema version
|
||||||
|
|
||||||
|
## FN-1414: Run-Audit Integration Testing
|
||||||
|
|
||||||
|
Key learnings from adding integration test coverage for run-audit:
|
||||||
|
|
||||||
|
**Test file locations:**
|
||||||
|
- `@fusion/core`: `packages/core/src/run-audit.integration.test.ts` (multi-domain correlation, event shape, ordering)
|
||||||
|
- `@fusion/engine`: `packages/engine/src/run-audit.integration.test.ts` (engine-to-core correlation, emitter behavior)
|
||||||
|
|
||||||
|
**Run commands:**
|
||||||
|
- Core: `pnpm --filter @fusion/core exec vitest run src/run-audit.integration.test.ts`
|
||||||
|
- Engine: `pnpm --filter @fusion/engine exec vitest run src/run-audit.integration.test.ts`
|
||||||
|
|
||||||
|
**Ordering guarantee:**
|
||||||
|
- Core uses `ORDER BY timestamp DESC, rowid DESC` for deterministic tie-breaking
|
||||||
|
- When splitting synthetic run IDs (e.g., `"exec-FN-001-123-abc"`), use `lastIndexOf("-")` to handle task IDs with dashes
|
||||||
|
|
||||||
|
**Metadata normalization:**
|
||||||
|
- Engine emitters always include `phase` in metadata
|
||||||
|
- `source` is conditionally included only when provided
|
||||||
|
- Database domain infers `taskId` from target when target looks like a task ID (`FN-*`, `KB-*`)
|
||||||
|
|
||||||
|
**Backward compatibility:**
|
||||||
|
- `createRunAuditor(store, null)` returns no-op auditor
|
||||||
|
- Store without `recordRunAuditEvent` method returns no-op auditor
|
||||||
|
- No throw on null/undefined context or missing methods
|
||||||
|
|||||||
560
packages/core/src/run-audit.integration.test.ts
Normal file
560
packages/core/src/run-audit.integration.test.ts
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
/**
|
||||||
|
* Run-Audit Core Integration Tests
|
||||||
|
*
|
||||||
|
* These tests verify end-to-end run-audit functionality across the core API:
|
||||||
|
* - Multi-domain event correlation under a single runId
|
||||||
|
* - Complete event shape verification
|
||||||
|
* - Absent run context handling (backward compatibility)
|
||||||
|
* - Partial metadata normalization
|
||||||
|
* - Deterministic duplicate-timestamp ordering
|
||||||
|
*
|
||||||
|
* Run with: pnpm --filter @fusion/core exec vitest run src/run-audit.integration.test.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtempSync } from "node:fs";
|
||||||
|
import { rm } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { Database } from "./db.js";
|
||||||
|
import { TaskStore } from "./store.js";
|
||||||
|
import type { RunAuditEventInput, RunAuditEvent } from "./types.js";
|
||||||
|
|
||||||
|
function makeTmpDir(): string {
|
||||||
|
return mkdtempSync(join(tmpdir(), "fn-run-audit-integration-test-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Run Audit Integration", () => {
|
||||||
|
let rootDir: string;
|
||||||
|
let kbDir: string;
|
||||||
|
let db: Database;
|
||||||
|
let store: TaskStore;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
rootDir = makeTmpDir();
|
||||||
|
kbDir = join(rootDir, ".fusion");
|
||||||
|
db = new Database(kbDir);
|
||||||
|
db.init();
|
||||||
|
store = new TaskStore(rootDir);
|
||||||
|
await store.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
try {
|
||||||
|
store.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
db.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
await rm(rootDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("multi-domain event correlation", () => {
|
||||||
|
it("correlates git, database, and filesystem events under a single runId", () => {
|
||||||
|
const runId = "integration-test-run-001";
|
||||||
|
const agentId = "agent-integration";
|
||||||
|
const taskId = "FN-INTEG-001";
|
||||||
|
|
||||||
|
// Record events across all three domains
|
||||||
|
store.recordRunAuditEvent({
|
||||||
|
runId,
|
||||||
|
agentId,
|
||||||
|
taskId,
|
||||||
|
domain: "git",
|
||||||
|
mutationType: "worktree:create",
|
||||||
|
target: ".worktrees/integration-task",
|
||||||
|
metadata: { branch: "fusion/integration-task" },
|
||||||
|
});
|
||||||
|
|
||||||
|
store.recordRunAuditEvent({
|
||||||
|
runId,
|
||||||
|
agentId,
|
||||||
|
taskId,
|
||||||
|
domain: "database",
|
||||||
|
mutationType: "task:update",
|
||||||
|
target: taskId,
|
||||||
|
metadata: { updatedFields: ["status"] },
|
||||||
|
});
|
||||||
|
|
||||||
|
store.recordRunAuditEvent({
|
||||||
|
runId,
|
||||||
|
agentId,
|
||||||
|
taskId,
|
||||||
|
domain: "filesystem",
|
||||||
|
mutationType: "file:write",
|
||||||
|
target: "src/integration.ts",
|
||||||
|
metadata: { size: 1234 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Query by runId
|
||||||
|
const events = store.getRunAuditEvents({ runId });
|
||||||
|
|
||||||
|
// All three domains should be present
|
||||||
|
expect(events).toHaveLength(3);
|
||||||
|
const domains = events.map((e) => e.domain);
|
||||||
|
expect(domains).toContain("git");
|
||||||
|
expect(domains).toContain("database");
|
||||||
|
expect(domains).toContain("filesystem");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns events ordered by timestamp DESC, rowid DESC", () => {
|
||||||
|
const runId = "integration-test-run-002";
|
||||||
|
|
||||||
|
// Insert in reverse order (oldest first in IDs due to autoincrement)
|
||||||
|
store.recordRunAuditEvent({
|
||||||
|
timestamp: "2025-01-01T01:00:00.000Z",
|
||||||
|
runId,
|
||||||
|
agentId: "agent-x",
|
||||||
|
domain: "database",
|
||||||
|
mutationType: "first",
|
||||||
|
target: "t1",
|
||||||
|
});
|
||||||
|
store.recordRunAuditEvent({
|
||||||
|
timestamp: "2025-01-01T01:00:00.000Z", // Same timestamp
|
||||||
|
runId,
|
||||||
|
agentId: "agent-y",
|
||||||
|
domain: "git",
|
||||||
|
mutationType: "second",
|
||||||
|
target: "t2",
|
||||||
|
});
|
||||||
|
store.recordRunAuditEvent({
|
||||||
|
timestamp: "2025-01-01T02:00:00.000Z",
|
||||||
|
runId,
|
||||||
|
agentId: "agent-z",
|
||||||
|
domain: "filesystem",
|
||||||
|
mutationType: "third",
|
||||||
|
target: "t3",
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents({ runId });
|
||||||
|
|
||||||
|
// Newest first (timestamp DESC)
|
||||||
|
expect(events[0].mutationType).toBe("third");
|
||||||
|
expect(events[1].mutationType).toBe("second"); // rowid DESC tiebreaker: second inserted last
|
||||||
|
expect(events[2].mutationType).toBe("first");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by domain correctly", () => {
|
||||||
|
const runId = "integration-test-run-003";
|
||||||
|
|
||||||
|
store.recordRunAuditEvent({
|
||||||
|
runId,
|
||||||
|
agentId: "agent-1",
|
||||||
|
domain: "git",
|
||||||
|
mutationType: "commit:create",
|
||||||
|
target: "main",
|
||||||
|
});
|
||||||
|
store.recordRunAuditEvent({
|
||||||
|
runId,
|
||||||
|
agentId: "agent-1",
|
||||||
|
domain: "database",
|
||||||
|
mutationType: "task:update",
|
||||||
|
target: "FN-001",
|
||||||
|
});
|
||||||
|
store.recordRunAuditEvent({
|
||||||
|
runId,
|
||||||
|
agentId: "agent-1",
|
||||||
|
domain: "filesystem",
|
||||||
|
mutationType: "file:write",
|
||||||
|
target: "src/test.ts",
|
||||||
|
});
|
||||||
|
|
||||||
|
const gitEvents = store.getRunAuditEvents({ runId, domain: "git" });
|
||||||
|
expect(gitEvents).toHaveLength(1);
|
||||||
|
expect(gitEvents[0].domain).toBe("git");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("complete event shape verification", () => {
|
||||||
|
it("verifies all required fields are present in persisted events", () => {
|
||||||
|
const input: RunAuditEventInput = {
|
||||||
|
taskId: "FN-SHAPE-001",
|
||||||
|
agentId: "agent-shape",
|
||||||
|
runId: "run-shape-001",
|
||||||
|
domain: "database",
|
||||||
|
mutationType: "task:create",
|
||||||
|
target: "FN-SHAPE-001",
|
||||||
|
metadata: { source: "integration-test" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const event = store.recordRunAuditEvent(input);
|
||||||
|
const events = store.getRunAuditEvents({ runId: input.runId });
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
const persisted = events[0];
|
||||||
|
|
||||||
|
// Verify complete shape
|
||||||
|
expect(persisted.id).toBeDefined();
|
||||||
|
expect(typeof persisted.id).toBe("string");
|
||||||
|
expect(persisted.timestamp).toBeDefined();
|
||||||
|
expect(typeof persisted.timestamp).toBe("string");
|
||||||
|
expect(persisted.runId).toBe(input.runId);
|
||||||
|
expect(persisted.agentId).toBe(input.agentId);
|
||||||
|
expect(persisted.taskId).toBe(input.taskId);
|
||||||
|
expect(persisted.domain).toBe(input.domain);
|
||||||
|
expect(persisted.mutationType).toBe(input.mutationType);
|
||||||
|
expect(persisted.target).toBe(input.target);
|
||||||
|
expect(persisted.metadata).toEqual(input.metadata);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles events without optional fields gracefully", () => {
|
||||||
|
const input: RunAuditEventInput = {
|
||||||
|
agentId: "agent-minimal",
|
||||||
|
runId: "run-minimal-001",
|
||||||
|
domain: "database",
|
||||||
|
mutationType: "task:log",
|
||||||
|
target: "FN-MINIMAL-001",
|
||||||
|
// No taskId, no metadata
|
||||||
|
};
|
||||||
|
|
||||||
|
const event = store.recordRunAuditEvent(input);
|
||||||
|
const events = store.getRunAuditEvents({ runId: input.runId });
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
const persisted = events[0];
|
||||||
|
|
||||||
|
// Required fields present
|
||||||
|
expect(persisted.id).toBeDefined();
|
||||||
|
expect(persisted.timestamp).toBeDefined();
|
||||||
|
expect(persisted.runId).toBe(input.runId);
|
||||||
|
expect(persisted.agentId).toBe(input.agentId);
|
||||||
|
expect(persisted.domain).toBe(input.domain);
|
||||||
|
expect(persisted.mutationType).toBe(input.mutationType);
|
||||||
|
expect(persisted.target).toBe(input.target);
|
||||||
|
|
||||||
|
// Optional fields undefined
|
||||||
|
expect(persisted.taskId).toBeUndefined();
|
||||||
|
expect(persisted.metadata).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves metadata with nested objects", () => {
|
||||||
|
const complexMetadata = {
|
||||||
|
filesChanged: 5,
|
||||||
|
details: { insertions: 100, deletions: 20 },
|
||||||
|
array: ["a", "b", "c"],
|
||||||
|
nested: { deep: { value: 42 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
store.recordRunAuditEvent({
|
||||||
|
runId: "run-complex-meta",
|
||||||
|
agentId: "agent-complex",
|
||||||
|
domain: "git",
|
||||||
|
mutationType: "commit:create",
|
||||||
|
target: "feature/test",
|
||||||
|
metadata: complexMetadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents({ runId: "run-complex-meta" });
|
||||||
|
expect(events[0].metadata).toEqual(complexMetadata);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("absent run context regression", () => {
|
||||||
|
it("recordRunAuditEvent works with minimal required fields", () => {
|
||||||
|
// Even without explicit timestamp or full context, should not crash
|
||||||
|
const event = store.recordRunAuditEvent({
|
||||||
|
agentId: "agent-regression",
|
||||||
|
runId: "run-regression-001",
|
||||||
|
domain: "database",
|
||||||
|
mutationType: "task:log",
|
||||||
|
target: "FN-REG-001",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(event.id).toBeDefined();
|
||||||
|
expect(event.timestamp).toBeDefined();
|
||||||
|
expect(event.runId).toBe("run-regression-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getRunAuditEvents with empty filter returns all events", () => {
|
||||||
|
// No filters should return all events (or empty if none exist)
|
||||||
|
const events = store.getRunAuditEvents();
|
||||||
|
expect(Array.isArray(events)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getRunAuditEvents with non-existent runId returns empty array", () => {
|
||||||
|
const events = store.getRunAuditEvents({ runId: "non-existent-run-id" });
|
||||||
|
expect(events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getRunAuditEvents with invalid domain does not crash", () => {
|
||||||
|
// Should return empty or filter correctly (no throw)
|
||||||
|
const events = store.getRunAuditEvents({ domain: "invalid-domain" as any });
|
||||||
|
expect(Array.isArray(events)).toBe(true);
|
||||||
|
// Empty because domain filter won't match any valid domains
|
||||||
|
expect(events.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("partial metadata normalization", () => {
|
||||||
|
it("preserves empty string metadata values", () => {
|
||||||
|
const event = store.recordRunAuditEvent({
|
||||||
|
runId: "run-normalize-001",
|
||||||
|
agentId: "agent-norm",
|
||||||
|
domain: "database",
|
||||||
|
mutationType: "task:update",
|
||||||
|
target: "FN-NORM-001",
|
||||||
|
metadata: { emptyString: "", valid: "value" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents({ runId: "run-normalize-001" });
|
||||||
|
// Empty strings are preserved as-is (no automatic normalization to undefined)
|
||||||
|
expect(events[0].metadata).toEqual({ emptyString: "", valid: "value" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles null metadata gracefully", () => {
|
||||||
|
const event = store.recordRunAuditEvent({
|
||||||
|
runId: "run-null-meta",
|
||||||
|
agentId: "agent-null",
|
||||||
|
domain: "database",
|
||||||
|
mutationType: "task:create",
|
||||||
|
target: "FN-NULL-001",
|
||||||
|
metadata: null as any, // Intentional: should handle gracefully
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event should be persisted with null metadata
|
||||||
|
expect(event.id).toBeDefined();
|
||||||
|
expect(event.metadata).toBeNull();
|
||||||
|
|
||||||
|
// Verify event can be queried
|
||||||
|
const events = store.getRunAuditEvents({ runId: "run-null-meta" });
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].id).toBe(event.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records events with undefined metadata", () => {
|
||||||
|
const event = store.recordRunAuditEvent({
|
||||||
|
runId: "run-undefined-meta",
|
||||||
|
agentId: "agent-und",
|
||||||
|
domain: "git",
|
||||||
|
mutationType: "commit:create",
|
||||||
|
target: "main",
|
||||||
|
// No metadata field at all
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents({ runId: "run-undefined-meta" });
|
||||||
|
expect(events[0].metadata).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves metadata with special characters", () => {
|
||||||
|
const event = store.recordRunAuditEvent({
|
||||||
|
runId: "run-special",
|
||||||
|
agentId: "agent-special",
|
||||||
|
domain: "filesystem",
|
||||||
|
mutationType: "file:write",
|
||||||
|
target: "path/with spaces & 'special' chars.txt",
|
||||||
|
metadata: {
|
||||||
|
description: "Test with émojis 🎉 and unicode ñ",
|
||||||
|
path: "C:\\Users\\Test\\file.ts",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents({ runId: "run-special" });
|
||||||
|
expect(events[0].metadata).toEqual({
|
||||||
|
description: "Test with émojis 🎉 and unicode ñ",
|
||||||
|
path: "C:\\Users\\Test\\file.ts",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("duplicate timestamp ordering regression", () => {
|
||||||
|
it("orders events with identical timestamps deterministically using rowid", () => {
|
||||||
|
const runId = "run-duplicate-ts";
|
||||||
|
const sameTs = "2025-06-15T12:00:00.000Z";
|
||||||
|
|
||||||
|
// Insert multiple events with identical timestamps
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const event = store.recordRunAuditEvent({
|
||||||
|
timestamp: sameTs,
|
||||||
|
runId,
|
||||||
|
agentId: `agent-${i}`,
|
||||||
|
domain: "database",
|
||||||
|
mutationType: `event-${i}`,
|
||||||
|
target: `target-${i}`,
|
||||||
|
});
|
||||||
|
ids.push(event.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query and verify deterministic order
|
||||||
|
const events1 = store.getRunAuditEvents({ runId });
|
||||||
|
const events2 = store.getRunAuditEvents({ runId }); // Query again
|
||||||
|
|
||||||
|
// Same order on repeated queries
|
||||||
|
expect(events1.map((e) => e.mutationType)).toEqual(events2.map((e) => e.mutationType));
|
||||||
|
|
||||||
|
// Rowid DESC means newest row first (later IDs first for autoincrement)
|
||||||
|
expect(events1[0].mutationType).toBe("event-4"); // Last inserted
|
||||||
|
expect(events1[4].mutationType).toBe("event-0"); // First inserted
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles many events with same timestamp stably", () => {
|
||||||
|
const runId = "run-many-same-ts";
|
||||||
|
const sameTs = "2025-06-15T12:00:00.000Z";
|
||||||
|
|
||||||
|
// Insert 20 events with same timestamp
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
store.recordRunAuditEvent({
|
||||||
|
timestamp: sameTs,
|
||||||
|
runId,
|
||||||
|
agentId: `agent-${i}`,
|
||||||
|
domain: "database",
|
||||||
|
mutationType: `type-${i}`,
|
||||||
|
target: `FN-${String(i).padStart(3, "0")}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents({ runId });
|
||||||
|
|
||||||
|
// All 20 events present
|
||||||
|
expect(events).toHaveLength(20);
|
||||||
|
|
||||||
|
// Order is stable and deterministic
|
||||||
|
const order1 = events.map((e) => e.mutationType);
|
||||||
|
const eventsAgain = store.getRunAuditEvents({ runId });
|
||||||
|
const order2 = eventsAgain.map((e) => e.mutationType);
|
||||||
|
expect(order1).toEqual(order2);
|
||||||
|
|
||||||
|
// Each mutation type appears exactly once
|
||||||
|
const uniqueTypes = new Set(events.map((e) => e.mutationType));
|
||||||
|
expect(uniqueTypes.size).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maintains ordering across query limit", () => {
|
||||||
|
const runId = "run-limit-order";
|
||||||
|
const sameTs = "2025-06-15T12:00:00.000Z";
|
||||||
|
|
||||||
|
// Insert 10 events with same timestamp
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
store.recordRunAuditEvent({
|
||||||
|
timestamp: sameTs,
|
||||||
|
runId,
|
||||||
|
agentId: `agent-${i}`,
|
||||||
|
domain: "database",
|
||||||
|
mutationType: `type-${i}`,
|
||||||
|
target: `FN-${i}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query with limit - should get the newest first (rowid DESC)
|
||||||
|
const limited = store.getRunAuditEvents({ runId, limit: 5 });
|
||||||
|
expect(limited).toHaveLength(5);
|
||||||
|
expect(limited[0].mutationType).toBe("type-9"); // Newest first
|
||||||
|
expect(limited[4].mutationType).toBe("type-5");
|
||||||
|
|
||||||
|
// Query all and verify order consistency
|
||||||
|
const all = store.getRunAuditEvents({ runId });
|
||||||
|
expect(all[0].mutationType).toBe("type-9");
|
||||||
|
expect(all[9].mutationType).toBe("type-0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("event metadata completeness", () => {
|
||||||
|
it("asserts non-empty mutationType in results", () => {
|
||||||
|
const eventTypes = [
|
||||||
|
"task:create",
|
||||||
|
"task:update",
|
||||||
|
"task:move",
|
||||||
|
"git:commit",
|
||||||
|
"file:write",
|
||||||
|
"worktree:create",
|
||||||
|
];
|
||||||
|
|
||||||
|
const runId = "run-complete-001";
|
||||||
|
eventTypes.forEach((type) => {
|
||||||
|
store.recordRunAuditEvent({
|
||||||
|
runId,
|
||||||
|
agentId: "agent-check",
|
||||||
|
domain: type.startsWith("git") ? "git" : type.startsWith("file") || type.startsWith("worktree") ? "filesystem" : "database",
|
||||||
|
mutationType: type,
|
||||||
|
target: "test-target",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents({ runId });
|
||||||
|
|
||||||
|
events.forEach((event) => {
|
||||||
|
expect(event.mutationType).toBeTruthy();
|
||||||
|
expect(event.mutationType.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asserts non-empty target in results", () => {
|
||||||
|
const runId = "run-target-001";
|
||||||
|
store.recordRunAuditEvent({
|
||||||
|
runId,
|
||||||
|
agentId: "agent-target",
|
||||||
|
domain: "database",
|
||||||
|
mutationType: "task:create",
|
||||||
|
target: "FN-TARGET-001",
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents({ runId });
|
||||||
|
events.forEach((event) => {
|
||||||
|
expect(event.target).toBeTruthy();
|
||||||
|
expect(typeof event.target).toBe("string");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies domain is one of valid values", () => {
|
||||||
|
const validDomains = ["database", "git", "filesystem"];
|
||||||
|
const runId = "run-domain-valid";
|
||||||
|
|
||||||
|
validDomains.forEach((domain) => {
|
||||||
|
store.recordRunAuditEvent({
|
||||||
|
runId,
|
||||||
|
agentId: "agent-domain",
|
||||||
|
domain: domain as any,
|
||||||
|
mutationType: "test",
|
||||||
|
target: "test",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents({ runId });
|
||||||
|
events.forEach((event) => {
|
||||||
|
expect(validDomains).toContain(event.domain);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("integration with TaskStore operations", () => {
|
||||||
|
it("task operations can emit correlated audit events", async () => {
|
||||||
|
const task = await store.createTask({ description: "Integration test task" });
|
||||||
|
const runId = "run-store-integration";
|
||||||
|
|
||||||
|
// Simulate engine operations with run context
|
||||||
|
await store.logEntry(task.id, "Test action", undefined, { runId, agentId: "agent-test" });
|
||||||
|
await store.addComment(task.id, "Test comment", "user", undefined, { runId, agentId: "agent-test" });
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents({ runId });
|
||||||
|
|
||||||
|
// Should have logged events from both operations
|
||||||
|
expect(events.length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
// All events should have the runId
|
||||||
|
events.forEach((event) => {
|
||||||
|
expect(event.runId).toBe(runId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Events should have domain and mutationType
|
||||||
|
const domains = events.map((e) => e.domain);
|
||||||
|
expect(domains).toContain("database");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pauseTask emits correlated audit event", async () => {
|
||||||
|
const task = await store.createTask({ description: "Pause test task" });
|
||||||
|
const runId = "run-pause-integration";
|
||||||
|
|
||||||
|
await store.pauseTask(task.id, true, { runId, agentId: "agent-pause" });
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents({ runId });
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].domain).toBe("database");
|
||||||
|
expect(events[0].mutationType).toBe("task:pause");
|
||||||
|
expect(events[0].target).toBe(task.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
695
packages/engine/src/run-audit.integration.test.ts
Normal file
695
packages/engine/src/run-audit.integration.test.ts
Normal file
@@ -0,0 +1,695 @@
|
|||||||
|
/**
|
||||||
|
* Run-Audit Engine Integration Tests
|
||||||
|
*
|
||||||
|
* These tests verify that engine mutation paths (executor, heartbeat, merger)
|
||||||
|
* correctly emit audit events to the core TaskStore via the run-audit API.
|
||||||
|
*
|
||||||
|
* Key assertions:
|
||||||
|
* - Engine operations correlate to concrete audit events under a single runId
|
||||||
|
* - All three domains (git, database, filesystem) emit events
|
||||||
|
* - Non-empty mutationType/target and domain-appropriate metadata
|
||||||
|
* - No-context paths are no-ops (no throw)
|
||||||
|
* - Partial metadata normalization is deterministic
|
||||||
|
*
|
||||||
|
* Run with: pnpm --filter @fusion/engine exec vitest run src/run-audit.integration.test.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { mkdtempSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { rm } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import type { TaskStore } from "@fusion/core";
|
||||||
|
import { Database } from "@fusion/core";
|
||||||
|
import { TaskStore as CoreTaskStore } from "@fusion/core";
|
||||||
|
import { createRunAuditor, generateSyntheticRunId, type EngineRunContext, type RunAuditor } from "./run-audit.js";
|
||||||
|
|
||||||
|
function makeTmpDir(): string {
|
||||||
|
return mkdtempSync(join(tmpdir(), "fn-engine-audit-integration-test-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Run Audit Engine Integration", () => {
|
||||||
|
let rootDir: string;
|
||||||
|
let kbDir: string;
|
||||||
|
let db: Database;
|
||||||
|
let store: TaskStore;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
rootDir = makeTmpDir();
|
||||||
|
kbDir = join(rootDir, ".fusion");
|
||||||
|
db = new Database(kbDir);
|
||||||
|
db.init();
|
||||||
|
store = new CoreTaskStore(rootDir);
|
||||||
|
await store.init();
|
||||||
|
|
||||||
|
// Initialize git repo for engine tests that need it
|
||||||
|
const gitDir = join(rootDir, ".git");
|
||||||
|
if (!existsSync(gitDir)) {
|
||||||
|
mkdirSync(gitDir, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
try {
|
||||||
|
store.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
db.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
await rm(rootDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createRunAuditor with EngineRunContext", () => {
|
||||||
|
it("creates auditor that emits git-domain events", async () => {
|
||||||
|
const context: EngineRunContext = {
|
||||||
|
runId: "engine-git-run-001",
|
||||||
|
agentId: "agent-engine",
|
||||||
|
taskId: "FN-ENG-001",
|
||||||
|
phase: "execute",
|
||||||
|
};
|
||||||
|
|
||||||
|
const auditor = createRunAuditor(store, context);
|
||||||
|
await auditor.git({
|
||||||
|
type: "worktree:create",
|
||||||
|
target: ".worktrees/engine-task",
|
||||||
|
metadata: { branch: "fusion/engine-task" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents({ runId: context.runId });
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
|
||||||
|
const event = events[0];
|
||||||
|
expect(event.domain).toBe("git");
|
||||||
|
expect(event.mutationType).toBe("worktree:create");
|
||||||
|
expect(event.target).toBe(".worktrees/engine-task");
|
||||||
|
expect(event.runId).toBe(context.runId);
|
||||||
|
expect(event.agentId).toBe(context.agentId);
|
||||||
|
expect(event.taskId).toBe(context.taskId);
|
||||||
|
expect(event.metadata).toEqual({
|
||||||
|
phase: "execute",
|
||||||
|
branch: "fusion/engine-task",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates auditor that emits database-domain events", async () => {
|
||||||
|
const context: EngineRunContext = {
|
||||||
|
runId: "engine-db-run-001",
|
||||||
|
agentId: "agent-engine",
|
||||||
|
taskId: "FN-ENG-002",
|
||||||
|
phase: "execute",
|
||||||
|
};
|
||||||
|
|
||||||
|
const auditor = createRunAuditor(store, context);
|
||||||
|
await auditor.database({
|
||||||
|
type: "task:update",
|
||||||
|
target: "FN-ENG-002",
|
||||||
|
metadata: { updatedFields: ["status"] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents({ runId: context.runId });
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
|
||||||
|
const event = events[0];
|
||||||
|
expect(event.domain).toBe("database");
|
||||||
|
expect(event.mutationType).toBe("task:update");
|
||||||
|
expect(event.target).toBe("FN-ENG-002");
|
||||||
|
expect(event.taskId).toBe("FN-ENG-002"); // Task ID inferred from target
|
||||||
|
expect(event.metadata).toEqual({
|
||||||
|
phase: "execute",
|
||||||
|
updatedFields: ["status"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates auditor that emits filesystem-domain events", async () => {
|
||||||
|
const context: EngineRunContext = {
|
||||||
|
runId: "engine-fs-run-001",
|
||||||
|
agentId: "agent-engine",
|
||||||
|
taskId: "FN-ENG-003",
|
||||||
|
phase: "execute",
|
||||||
|
};
|
||||||
|
|
||||||
|
const auditor = createRunAuditor(store, context);
|
||||||
|
await auditor.filesystem({
|
||||||
|
type: "file:write",
|
||||||
|
target: "src/engine-test.ts",
|
||||||
|
metadata: { size: 1234 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents({ runId: context.runId });
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
|
||||||
|
const event = events[0];
|
||||||
|
expect(event.domain).toBe("filesystem");
|
||||||
|
expect(event.mutationType).toBe("file:write");
|
||||||
|
expect(event.target).toBe("src/engine-test.ts");
|
||||||
|
expect(event.metadata).toEqual({
|
||||||
|
phase: "execute",
|
||||||
|
size: 1234,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits multiple events across all domains under single runId", async () => {
|
||||||
|
const context: EngineRunContext = {
|
||||||
|
runId: "engine-multi-domain-001",
|
||||||
|
agentId: "agent-multi",
|
||||||
|
taskId: "FN-ENG-004",
|
||||||
|
phase: "execute",
|
||||||
|
};
|
||||||
|
|
||||||
|
const auditor = createRunAuditor(store, context);
|
||||||
|
|
||||||
|
// Emit events across all three domains
|
||||||
|
await auditor.git({ type: "worktree:create", target: "worktrees/test" });
|
||||||
|
await auditor.database({ type: "task:update", target: "FN-ENG-004" });
|
||||||
|
await auditor.filesystem({ type: "file:write", target: "src/test.ts" });
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents({ runId: context.runId });
|
||||||
|
expect(events).toHaveLength(3);
|
||||||
|
|
||||||
|
const domains = events.map((e) => e.domain);
|
||||||
|
expect(domains).toContain("git");
|
||||||
|
expect(domains).toContain("database");
|
||||||
|
expect(domains).toContain("filesystem");
|
||||||
|
|
||||||
|
// All events have the same runId and agentId
|
||||||
|
events.forEach((event) => {
|
||||||
|
expect(event.runId).toBe(context.runId);
|
||||||
|
expect(event.agentId).toBe(context.agentId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateSyntheticRunId", () => {
|
||||||
|
it("generates unique IDs for different runs", () => {
|
||||||
|
const id1 = generateSyntheticRunId("exec", "FN-001");
|
||||||
|
const id2 = generateSyntheticRunId("exec", "FN-002");
|
||||||
|
const id3 = generateSyntheticRunId("merge", "FN-001");
|
||||||
|
|
||||||
|
expect(id1).not.toBe(id2);
|
||||||
|
expect(id1).not.toBe(id3);
|
||||||
|
expect(id2).not.toBe(id3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes prefix, taskId, timestamp, and random in format", () => {
|
||||||
|
const id = generateSyntheticRunId("exec", "FNTEST");
|
||||||
|
// Split from end to handle taskId with dashes correctly
|
||||||
|
// Format: prefix-taskId-timestamp-random
|
||||||
|
const lastDashIndex = id.lastIndexOf("-");
|
||||||
|
const secondLastDashIndex = id.lastIndexOf("-", lastDashIndex - 1);
|
||||||
|
|
||||||
|
// Extract parts by working backwards
|
||||||
|
const random = id.slice(lastDashIndex + 1);
|
||||||
|
const timestamp = id.slice(secondLastDashIndex + 1, lastDashIndex);
|
||||||
|
const prefix = id.slice(0, id.indexOf("-"));
|
||||||
|
|
||||||
|
expect(id.startsWith("exec-")).toBe(true);
|
||||||
|
expect(parseInt(timestamp)).toBeGreaterThan(0); // timestamp
|
||||||
|
expect(random.length).toBe(4); // random suffix
|
||||||
|
|
||||||
|
// The ID contains the taskId between prefix and timestamp
|
||||||
|
expect(id).toContain("exec-");
|
||||||
|
expect(id).toContain(`-${timestamp}-`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates IDs suitable for run correlation", () => {
|
||||||
|
const execId = generateSyntheticRunId("exec", "FN001");
|
||||||
|
const mergeId = generateSyntheticRunId("merge", "FN001");
|
||||||
|
|
||||||
|
// IDs should be different for different phases
|
||||||
|
expect(execId).not.toBe(mergeId);
|
||||||
|
|
||||||
|
// Both should start with their respective prefixes
|
||||||
|
expect(execId.startsWith("exec-")).toBe(true);
|
||||||
|
expect(mergeId.startsWith("merge-")).toBe(true);
|
||||||
|
|
||||||
|
// IDs should be different for different tasks
|
||||||
|
const execId2 = generateSyntheticRunId("exec", "FN002");
|
||||||
|
expect(execId).not.toBe(execId2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("no-context path regression", () => {
|
||||||
|
it("auditor is no-op when context is null", async () => {
|
||||||
|
const auditor = createRunAuditor(store, null);
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
await expect(auditor.git({ type: "worktree:create", target: "test" })).resolves.not.toThrow();
|
||||||
|
await expect(auditor.database({ type: "task:update", target: "FN-001" })).resolves.not.toThrow();
|
||||||
|
await expect(auditor.filesystem({ type: "file:write", target: "test.ts" })).resolves.not.toThrow();
|
||||||
|
|
||||||
|
// No events should be recorded
|
||||||
|
const events = store.getRunAuditEvents();
|
||||||
|
expect(events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auditor is no-op when context is undefined", async () => {
|
||||||
|
const auditor = createRunAuditor(store, undefined);
|
||||||
|
|
||||||
|
await expect(auditor.git({ type: "worktree:create", target: "test" })).resolves.not.toThrow();
|
||||||
|
await expect(auditor.database({ type: "task:update", target: "FN-001" })).resolves.not.toThrow();
|
||||||
|
await expect(auditor.filesystem({ type: "file:write", target: "test.ts" })).resolves.not.toThrow();
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents();
|
||||||
|
expect(events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createRunAuditor handles store without recordRunAuditEvent gracefully", () => {
|
||||||
|
// Mock a store without recordRunAuditEvent
|
||||||
|
const mockStore = {
|
||||||
|
// Missing recordRunAuditEvent method
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const context: EngineRunContext = {
|
||||||
|
runId: "no-method-run",
|
||||||
|
agentId: "agent-no-method",
|
||||||
|
};
|
||||||
|
|
||||||
|
const auditor = createRunAuditor(mockStore, context);
|
||||||
|
|
||||||
|
// Should not throw - use a valid mutation type
|
||||||
|
expect(() => auditor.git({ type: "branch:create", target: "t" })).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("partial metadata normalization", () => {
|
||||||
|
it("normalizes git mutation with minimal metadata", async () => {
|
||||||
|
const context: EngineRunContext = {
|
||||||
|
runId: "git-minimal-meta",
|
||||||
|
agentId: "agent-minimal",
|
||||||
|
taskId: "FN-MINIMAL",
|
||||||
|
phase: "execute",
|
||||||
|
};
|
||||||
|
|
||||||
|
const auditor = createRunAuditor(store, context);
|
||||||
|
|
||||||
|
// Only type and target, no metadata
|
||||||
|
await auditor.git({
|
||||||
|
type: "branch:create",
|
||||||
|
target: "feature/test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents({ runId: context.runId });
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
|
||||||
|
const event = events[0];
|
||||||
|
expect(event.domain).toBe("git");
|
||||||
|
expect(event.mutationType).toBe("branch:create");
|
||||||
|
expect(event.target).toBe("feature/test");
|
||||||
|
expect(event.metadata).toEqual({ phase: "execute" }); // Only phase added
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes database mutation with minimal metadata", async () => {
|
||||||
|
const context: EngineRunContext = {
|
||||||
|
runId: "db-minimal-meta",
|
||||||
|
agentId: "agent-minimal",
|
||||||
|
taskId: "FN-DB-MINIMAL",
|
||||||
|
phase: "execute",
|
||||||
|
};
|
||||||
|
|
||||||
|
const auditor = createRunAuditor(store, context);
|
||||||
|
|
||||||
|
await auditor.database({
|
||||||
|
type: "task:log-entry",
|
||||||
|
target: "FN-DB-MINIMAL",
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents({ runId: context.runId });
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
|
||||||
|
const event = events[0];
|
||||||
|
expect(event.domain).toBe("database");
|
||||||
|
expect(event.mutationType).toBe("task:log-entry");
|
||||||
|
expect(event.metadata).toEqual({ phase: "execute" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes filesystem mutation with minimal metadata", async () => {
|
||||||
|
const context: EngineRunContext = {
|
||||||
|
runId: "fs-minimal-meta",
|
||||||
|
agentId: "agent-minimal",
|
||||||
|
taskId: "FN-FS-MINIMAL",
|
||||||
|
phase: "execute",
|
||||||
|
};
|
||||||
|
|
||||||
|
const auditor = createRunAuditor(store, context);
|
||||||
|
|
||||||
|
await auditor.filesystem({
|
||||||
|
type: "file:write",
|
||||||
|
target: "src/test.ts",
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents({ runId: context.runId });
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
|
||||||
|
const event = events[0];
|
||||||
|
expect(event.domain).toBe("filesystem");
|
||||||
|
expect(event.mutationType).toBe("file:write");
|
||||||
|
expect(event.metadata).toEqual({ phase: "execute" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves source field when provided in context", async () => {
|
||||||
|
const context: EngineRunContext = {
|
||||||
|
runId: "with-source",
|
||||||
|
agentId: "agent-source",
|
||||||
|
taskId: "FN-SOURCE",
|
||||||
|
phase: "heartbeat",
|
||||||
|
source: "timer",
|
||||||
|
};
|
||||||
|
|
||||||
|
const auditor = createRunAuditor(store, context);
|
||||||
|
|
||||||
|
await auditor.database({
|
||||||
|
type: "task:update",
|
||||||
|
target: "FN-SOURCE",
|
||||||
|
metadata: { field: "value" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents({ runId: context.runId });
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
|
||||||
|
const event = events[0];
|
||||||
|
expect(event.metadata).toEqual({
|
||||||
|
phase: "heartbeat",
|
||||||
|
source: "timer",
|
||||||
|
field: "value",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spreads additional metadata fields correctly", async () => {
|
||||||
|
const context: EngineRunContext = {
|
||||||
|
runId: "metadata-spread",
|
||||||
|
agentId: "agent-spread",
|
||||||
|
taskId: "FN-SPREAD",
|
||||||
|
phase: "execute",
|
||||||
|
};
|
||||||
|
|
||||||
|
const auditor = createRunAuditor(store, context);
|
||||||
|
|
||||||
|
await auditor.git({
|
||||||
|
type: "commit:create",
|
||||||
|
target: "abc123",
|
||||||
|
metadata: {
|
||||||
|
filesChanged: 5,
|
||||||
|
insertions: 100,
|
||||||
|
deletions: 20,
|
||||||
|
author: "test",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents({ runId: context.runId });
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
|
||||||
|
const event = events[0];
|
||||||
|
expect(event.metadata).toEqual({
|
||||||
|
phase: "execute",
|
||||||
|
filesChanged: 5,
|
||||||
|
insertions: 100,
|
||||||
|
deletions: 20,
|
||||||
|
author: "test",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("event metadata completeness", () => {
|
||||||
|
it("verifies all required fields are present in engine-emitted events", async () => {
|
||||||
|
const context: EngineRunContext = {
|
||||||
|
runId: "complete-fields",
|
||||||
|
agentId: "agent-complete",
|
||||||
|
taskId: "FN-COMPLETE",
|
||||||
|
phase: "execute",
|
||||||
|
};
|
||||||
|
|
||||||
|
const auditor = createRunAuditor(store, context);
|
||||||
|
await auditor.database({
|
||||||
|
type: "task:create",
|
||||||
|
target: "FN-COMPLETE",
|
||||||
|
metadata: { description: "Test task" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents({ runId: context.runId });
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
|
||||||
|
const event = events[0];
|
||||||
|
|
||||||
|
// All required fields present
|
||||||
|
expect(event.id).toBeDefined();
|
||||||
|
expect(event.timestamp).toBeDefined();
|
||||||
|
expect(event.runId).toBe(context.runId);
|
||||||
|
expect(event.agentId).toBe(context.agentId);
|
||||||
|
expect(event.taskId).toBe(context.taskId);
|
||||||
|
expect(event.domain).toBe("database");
|
||||||
|
expect(event.mutationType).toBe("task:create");
|
||||||
|
expect(event.target).toBe("FN-COMPLETE");
|
||||||
|
|
||||||
|
// Non-empty values
|
||||||
|
expect(typeof event.id).toBe("string");
|
||||||
|
expect(event.id.length).toBeGreaterThan(0);
|
||||||
|
expect(typeof event.timestamp).toBe("string");
|
||||||
|
expect(event.timestamp.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies mutationType is non-empty in all events", async () => {
|
||||||
|
const context: EngineRunContext = {
|
||||||
|
runId: "nonempty-mutation",
|
||||||
|
agentId: "agent-nonempty",
|
||||||
|
taskId: "FN-NONEMPTY",
|
||||||
|
};
|
||||||
|
|
||||||
|
const auditor = createRunAuditor(store, context);
|
||||||
|
|
||||||
|
const mutationTypes = [
|
||||||
|
"worktree:create",
|
||||||
|
"worktree:remove",
|
||||||
|
"branch:create",
|
||||||
|
"branch:delete",
|
||||||
|
"commit:create",
|
||||||
|
"task:create",
|
||||||
|
"task:update",
|
||||||
|
"task:move",
|
||||||
|
"task:log-entry",
|
||||||
|
"task:comment:add",
|
||||||
|
"task:assign",
|
||||||
|
"file:write",
|
||||||
|
"file:delete",
|
||||||
|
"attachment:create",
|
||||||
|
"prompt:write",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const type of mutationTypes) {
|
||||||
|
await auditor.git({ type: type as any, target: "test" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents({ runId: context.runId });
|
||||||
|
events.forEach((event) => {
|
||||||
|
expect(event.mutationType).toBeTruthy();
|
||||||
|
expect(event.mutationType.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies target is non-empty in all events", async () => {
|
||||||
|
const context: EngineRunContext = {
|
||||||
|
runId: "nonempty-target",
|
||||||
|
agentId: "agent-target",
|
||||||
|
taskId: "FN-TARGET",
|
||||||
|
};
|
||||||
|
|
||||||
|
const auditor = createRunAuditor(store, context);
|
||||||
|
|
||||||
|
await auditor.git({ type: "worktree:create", target: ".worktrees/test" });
|
||||||
|
await auditor.database({ type: "task:update", target: "FN-TARGET" });
|
||||||
|
await auditor.filesystem({ type: "file:write", target: "src/test.ts" });
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents({ runId: context.runId });
|
||||||
|
events.forEach((event) => {
|
||||||
|
expect(event.target).toBeTruthy();
|
||||||
|
expect(typeof event.target).toBe("string");
|
||||||
|
expect(event.target.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("heartbeat source correlation", () => {
|
||||||
|
it("correlates heartbeat events with source field", async () => {
|
||||||
|
const context: EngineRunContext = {
|
||||||
|
runId: "heartbeat-timer-run",
|
||||||
|
agentId: "agent-heartbeat",
|
||||||
|
taskId: "FN-HB-001",
|
||||||
|
phase: "heartbeat",
|
||||||
|
source: "timer",
|
||||||
|
};
|
||||||
|
|
||||||
|
const auditor = createRunAuditor(store, context);
|
||||||
|
await auditor.database({
|
||||||
|
type: "task:log-entry",
|
||||||
|
target: "FN-HB-001",
|
||||||
|
metadata: { action: "heartbeat check" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents({ runId: context.runId });
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].metadata).toEqual({
|
||||||
|
phase: "heartbeat",
|
||||||
|
source: "timer",
|
||||||
|
action: "heartbeat check",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("correlates assignment-triggered heartbeat events", async () => {
|
||||||
|
const context: EngineRunContext = {
|
||||||
|
runId: "heartbeat-assignment-run",
|
||||||
|
agentId: "agent-assignment",
|
||||||
|
taskId: "FN-ASSIGN-001",
|
||||||
|
phase: "heartbeat",
|
||||||
|
source: "assignment",
|
||||||
|
};
|
||||||
|
|
||||||
|
const auditor = createRunAuditor(store, context);
|
||||||
|
await auditor.database({
|
||||||
|
type: "task:assign",
|
||||||
|
target: "FN-ASSIGN-001",
|
||||||
|
metadata: { assignedTo: "agent-assignment" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents({ runId: context.runId });
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].metadata).toEqual({
|
||||||
|
phase: "heartbeat",
|
||||||
|
source: "assignment",
|
||||||
|
assignedTo: "agent-assignment",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("correlates on-demand heartbeat events", async () => {
|
||||||
|
const context: EngineRunContext = {
|
||||||
|
runId: "heartbeat-demand-run",
|
||||||
|
agentId: "agent-demand",
|
||||||
|
taskId: "FN-DEMAND-001",
|
||||||
|
phase: "heartbeat",
|
||||||
|
source: "on_demand",
|
||||||
|
};
|
||||||
|
|
||||||
|
const auditor = createRunAuditor(store, context);
|
||||||
|
await auditor.database({
|
||||||
|
type: "task:log-entry",
|
||||||
|
target: "FN-DEMAND-001",
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents({ runId: context.runId });
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].metadata).toEqual({
|
||||||
|
phase: "heartbeat",
|
||||||
|
source: "on_demand",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("merge phase correlation", () => {
|
||||||
|
it("correlates merge events with merge phase", async () => {
|
||||||
|
const context: EngineRunContext = {
|
||||||
|
runId: "merge-run-001",
|
||||||
|
agentId: "merger",
|
||||||
|
taskId: "FN-MERGE-001",
|
||||||
|
phase: "merge",
|
||||||
|
};
|
||||||
|
|
||||||
|
const auditor = createRunAuditor(store, context);
|
||||||
|
await auditor.git({
|
||||||
|
type: "merge:resolve",
|
||||||
|
target: "fusion/FN-MERGE-001",
|
||||||
|
metadata: { strategy: "squash" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents({ runId: context.runId });
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].domain).toBe("git");
|
||||||
|
expect(events[0].mutationType).toBe("merge:resolve");
|
||||||
|
expect(events[0].metadata).toEqual({
|
||||||
|
phase: "merge",
|
||||||
|
strategy: "squash",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports merge attempt differentiation", async () => {
|
||||||
|
const context: EngineRunContext = {
|
||||||
|
runId: "merge-attempt-1",
|
||||||
|
agentId: "merger",
|
||||||
|
taskId: "FN-MERGE-002",
|
||||||
|
phase: "merge-attempt-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const auditor = createRunAuditor(store, context);
|
||||||
|
await auditor.git({
|
||||||
|
type: "merge:start",
|
||||||
|
target: "fusion/FN-MERGE-002",
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents({ runId: context.runId });
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].metadata).toEqual({
|
||||||
|
phase: "merge-attempt-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deterministic ordering of engine-emitted events", () => {
|
||||||
|
it("orders multiple events by timestamp DESC, rowid DESC", async () => {
|
||||||
|
const context: EngineRunContext = {
|
||||||
|
runId: "ordering-test",
|
||||||
|
agentId: "agent-order",
|
||||||
|
taskId: "FN-ORDER",
|
||||||
|
phase: "execute",
|
||||||
|
};
|
||||||
|
|
||||||
|
const auditor = createRunAuditor(store, context);
|
||||||
|
|
||||||
|
// Emit events in sequence
|
||||||
|
await auditor.git({ type: "branch:create", target: "branch-1" });
|
||||||
|
await auditor.database({ type: "task:update", target: "FN-ORDER" });
|
||||||
|
await auditor.filesystem({ type: "file:write", target: "file-1.ts" });
|
||||||
|
await auditor.git({ type: "commit:create", target: "commit-1" });
|
||||||
|
|
||||||
|
const events = store.getRunAuditEvents({ runId: context.runId });
|
||||||
|
|
||||||
|
// Should be ordered newest first
|
||||||
|
expect(events[0].mutationType).toBe("commit:create");
|
||||||
|
expect(events[1].mutationType).toBe("file:write");
|
||||||
|
expect(events[2].mutationType).toBe("task:update");
|
||||||
|
expect(events[3].mutationType).toBe("branch:create");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maintains stable ordering across repeated queries", async () => {
|
||||||
|
const context: EngineRunContext = {
|
||||||
|
runId: "stable-order",
|
||||||
|
agentId: "agent-stable",
|
||||||
|
taskId: "FN-STABLE",
|
||||||
|
phase: "execute",
|
||||||
|
};
|
||||||
|
|
||||||
|
const auditor = createRunAuditor(store, context);
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await auditor.database({
|
||||||
|
type: "task:log-entry",
|
||||||
|
target: `FN-STABLE`,
|
||||||
|
metadata: { index: i },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query multiple times
|
||||||
|
const events1 = store.getRunAuditEvents({ runId: context.runId });
|
||||||
|
const events2 = store.getRunAuditEvents({ runId: context.runId });
|
||||||
|
const events3 = store.getRunAuditEvents({ runId: context.runId });
|
||||||
|
|
||||||
|
// Order should be consistent
|
||||||
|
expect(events1.map((e) => e.id)).toEqual(events2.map((e) => e.id));
|
||||||
|
expect(events2.map((e) => e.id)).toEqual(events3.map((e) => e.id));
|
||||||
|
|
||||||
|
// Newest first (index: 9 first due to autoincrement rowid DESC)
|
||||||
|
expect(events1[0].metadata).toEqual({ phase: "execute", index: 9 });
|
||||||
|
expect(events1[9].metadata).toEqual({ phase: "execute", index: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -194,8 +194,15 @@ export function createRunAuditor(store: TaskStore, context: EngineRunContext | n
|
|||||||
},
|
},
|
||||||
|
|
||||||
database: async (input: DatabaseAuditInput) => {
|
database: async (input: DatabaseAuditInput) => {
|
||||||
|
// Infer taskId from target when it looks like a task ID (FN-*, KB-*).
|
||||||
|
// This handles cases like "task:update" where target is the task ID itself,
|
||||||
|
// falling back to context.taskId when target is not a task ID (e.g., document keys).
|
||||||
|
const inferredTaskId = input.target.startsWith("FN-") || input.target.startsWith("KB-")
|
||||||
|
? input.target
|
||||||
|
: context.taskId;
|
||||||
|
|
||||||
const eventInput: RunAuditEventInput = {
|
const eventInput: RunAuditEventInput = {
|
||||||
taskId: input.target.startsWith("FN-") || input.target.startsWith("KB-") ? input.target : context.taskId,
|
taskId: inferredTaskId,
|
||||||
agentId: context.agentId,
|
agentId: context.agentId,
|
||||||
runId: context.runId,
|
runId: context.runId,
|
||||||
domain: "database",
|
domain: "database",
|
||||||
|
|||||||
Reference in New Issue
Block a user