feat(FN-1257): add runContext audit trail for task mutations

- Add RunMutationContext type to track which agent run caused a mutation
- Thread runContext through TaskStore.logEntry, addComment, addSteeringComment, and pauseTask
- Propagate runContext from HeartbeatMonitor.executeHeartbeat to task store operations
- Propagate runContext from TaskExecutor.execute to task store operations
- Add GET /api/agents/:id/runs/:runId/mutations endpoint to query mutations by runId
- Add createTaskLogToolWithContext for heartbeat tools with run context support
- Add comprehensive tests for RunMutationContext across store and heartbeat modules
- Update memory.md with RunMutationContext usage convention
This commit is contained in:
gsxdsm
2026-04-09 13:59:43 -07:00
parent e26321790c
commit 7d484dd972
12 changed files with 674 additions and 167 deletions

View File

@@ -481,4 +481,58 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
expect((response.body as any).run).toBeDefined();
});
});
describe("GET /api/agents/:id/runs/:runId/mutations", () => {
beforeEach(() => {
// Reset scoped store mock
mockScopedStore = createMockScopedStore();
(createScopedStore as any).mockReturnValue(mockScopedStore);
});
it("returns mutation trail for a valid run", async () => {
const mockRun = createMockRun();
mockGetRunDetail.mockResolvedValue(mockRun);
// Mock getMutationsForRun on the scoped store
const mockMutations = [
{ timestamp: "2026-01-01T00:01:00.000Z", action: "Action 1", runContext: { runId: "run-123", agentId: "agent-001" } },
{ timestamp: "2026-01-01T00:02:00.000Z", action: "Action 2", runContext: { runId: "run-123", agentId: "agent-001" } },
];
mockScopedStore.getMutationsForRun = vi.fn().mockResolvedValue(mockMutations);
const response = await request(app, "GET", "/api/agents/agent-001/runs/run-123/mutations");
expect(response.status).toBe(200);
expect(response.body).toEqual({
runId: "run-123",
mutations: mockMutations,
});
expect(mockScopedStore.getMutationsForRun).toHaveBeenCalledWith("run-123");
});
it("returns 404 for unknown run", async () => {
mockGetRunDetail.mockResolvedValue(null);
const response = await request(app, "GET", "/api/agents/agent-001/runs/run-unknown/mutations");
expect(response.status).toBe(404);
expect(response.body).toHaveProperty("error");
});
it("returns empty mutations array for run with no correlated entries", async () => {
const mockRun = createMockRun();
mockGetRunDetail.mockResolvedValue(mockRun);
// Mock getMutationsForRun returning empty array
mockScopedStore.getMutationsForRun = vi.fn().mockResolvedValue([]);
const response = await request(app, "GET", "/api/agents/agent-001/runs/run-empty/mutations");
expect(response.status).toBe(200);
expect(response.body).toEqual({
runId: "run-empty",
mutations: [],
});
});
});
});

View File

@@ -162,6 +162,22 @@ function slugifyPresetName(name: string): string {
return slug || "preset";
}
/**
* Extract RunMutationContext from the X-Run-Context header.
* Used to correlate dashboard mutations with agent runs for audit trails.
*/
function extractRunContext(req: { headers: { [key: string]: string | string[] | undefined } }): import("@fusion/core").RunMutationContext | undefined {
const header = req.headers['x-run-context'];
if (typeof header !== 'string') return undefined;
try {
const parsed = JSON.parse(header);
if (parsed && typeof parsed.runId === 'string' && typeof parsed.agentId === 'string') {
return parsed as import("@fusion/core").RunMutationContext;
}
} catch { /* invalid JSON, ignore */ }
return undefined;
}
function validateModelPresets(value: unknown): ModelPreset[] | undefined {
if (value === undefined) return undefined;
if (!Array.isArray(value)) {
@@ -9192,6 +9208,39 @@ Output ONLY the prompt text (no markdown, no explanations).`;
}
});
/**
* GET /api/agents/:id/runs/:runId/mutations
* Get the mutation trail for a specific agent run.
* Returns all TaskLogEntry objects correlated with the given runId via runContext.
*/
router.get("/agents/:id/runs/:runId/mutations", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
// Verify the run exists
const run = await agentStore.getRunDetail(req.params.id, req.params.runId);
if (!run) {
throw notFound("Run not found");
}
// Query mutation trail
const mutations = await scopedStore.getMutationsForRun(req.params.runId);
res.json({ runId: req.params.runId, mutations });
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
if (err.message?.includes("not found")) {
throw notFound(err.message);
} else {
rethrowAsApiError(err);
}
}
});
/**
* GET /api/agents/:id/chain-of-command
* Fetch agent reporting chain from self to top-most manager.