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:
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user