fix(FN-2116): restore agent run logs
This commit is contained in:
@@ -15,7 +15,7 @@ import { AgentStore } from "./agent-store.js";
|
||||
import { TaskStore } from "./store.js";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { mkdtempSync, existsSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createHash } from "node:crypto";
|
||||
import { CheckoutConflictError, type AgentCapability, type AgentState } from "./types.js";
|
||||
@@ -52,6 +52,48 @@ describe("AgentStore", () => {
|
||||
const agentsDir = join(rootDir, "agents");
|
||||
expect(existsSync(agentsDir)).toBe(true);
|
||||
});
|
||||
|
||||
it("imports legacy agent run JSON files into SQLite once", async () => {
|
||||
const legacyRoot = makeTmpDir();
|
||||
try {
|
||||
const agentsDir = join(legacyRoot, "agents");
|
||||
const runDir = join(agentsDir, "agent-legacy-runs");
|
||||
mkdirSync(runDir, { recursive: true });
|
||||
writeFileSync(join(agentsDir, "agent-legacy.json"), JSON.stringify({
|
||||
id: "agent-legacy",
|
||||
name: "Legacy",
|
||||
role: "executor",
|
||||
state: "idle",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
metadata: {},
|
||||
}));
|
||||
writeFileSync(join(runDir, "run-legacy.json"), JSON.stringify({
|
||||
id: "run-legacy",
|
||||
agentId: "agent-legacy",
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
endedAt: "2026-01-01T00:00:01.000Z",
|
||||
status: "completed",
|
||||
contextSnapshot: { taskId: "FN-001" },
|
||||
stdoutExcerpt: "done",
|
||||
}));
|
||||
|
||||
const legacyStore = new AgentStore({ rootDir: legacyRoot });
|
||||
await legacyStore.init();
|
||||
const run = await legacyStore.getRunDetail("agent-legacy", "run-legacy");
|
||||
|
||||
expect(run).toMatchObject({
|
||||
id: "run-legacy",
|
||||
agentId: "agent-legacy",
|
||||
status: "completed",
|
||||
contextSnapshot: { taskId: "FN-001" },
|
||||
stdoutExcerpt: "done",
|
||||
});
|
||||
expect(await legacyStore.importLegacyFileRuns()).toBe(0);
|
||||
} finally {
|
||||
await rm(legacyRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── createAgent ───────────────────────────────────────────────────
|
||||
|
||||
@@ -154,6 +154,7 @@ export class AgentStore extends EventEmitter {
|
||||
async init(): Promise<void> {
|
||||
const _ = this.db;
|
||||
await mkdir(this.agentsDir, { recursive: true });
|
||||
await this.importLegacyFileDataOnce();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -192,6 +193,99 @@ export class AgentStore extends EventEmitter {
|
||||
return imported;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-way migration helper for legacy structured run JSON files.
|
||||
* Runtime reads come from SQLite; this only seeds old projects.
|
||||
*/
|
||||
async importLegacyFileRuns(): Promise<number> {
|
||||
const entries = await readdir(this.agentsDir, { withFileTypes: true }).catch(() => []);
|
||||
const runDirs = entries.filter((entry) => entry.isDirectory() && entry.name.endsWith("-runs"));
|
||||
|
||||
let imported = 0;
|
||||
for (const dir of runDirs) {
|
||||
const agentId = dir.name.replace(/-runs$/, "");
|
||||
const runDir = join(this.agentsDir, dir.name);
|
||||
const runFiles = await readdir(runDir).catch(() => [] as string[]);
|
||||
|
||||
for (const file of runFiles) {
|
||||
if (!file.endsWith(".json")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await readFile(join(runDir, file), "utf-8");
|
||||
const run = JSON.parse(content) as Partial<AgentHeartbeatRun>;
|
||||
if (
|
||||
typeof run.id !== "string" ||
|
||||
typeof run.startedAt !== "string" ||
|
||||
!["active", "completed", "terminated", "failed"].includes(String(run.status))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedRun: AgentHeartbeatRun = {
|
||||
id: run.id,
|
||||
agentId: typeof run.agentId === "string" ? run.agentId : agentId,
|
||||
startedAt: run.startedAt,
|
||||
endedAt: typeof run.endedAt === "string" ? run.endedAt : null,
|
||||
status: run.status as AgentHeartbeatRun["status"],
|
||||
invocationSource: run.invocationSource,
|
||||
triggerDetail: run.triggerDetail,
|
||||
processPid: run.processPid,
|
||||
exitCode: run.exitCode,
|
||||
sessionIdBefore: run.sessionIdBefore,
|
||||
sessionIdAfter: run.sessionIdAfter,
|
||||
usageJson: run.usageJson,
|
||||
resultJson: run.resultJson,
|
||||
contextSnapshot: run.contextSnapshot,
|
||||
stdoutExcerpt: run.stdoutExcerpt,
|
||||
stderrExcerpt: run.stderrExcerpt,
|
||||
};
|
||||
|
||||
const result = this.db.prepare(`
|
||||
INSERT OR IGNORE INTO agentRuns (id, agentId, data, startedAt, endedAt, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
normalizedRun.id,
|
||||
normalizedRun.agentId,
|
||||
JSON.stringify(normalizedRun),
|
||||
normalizedRun.startedAt,
|
||||
normalizedRun.endedAt,
|
||||
normalizedRun.status,
|
||||
);
|
||||
imported += result.changes;
|
||||
} catch {
|
||||
// Legacy run files may be partially written or manually edited; ignore them.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (imported > 0) {
|
||||
this.db.bumpLastModified();
|
||||
}
|
||||
return imported;
|
||||
}
|
||||
|
||||
private async importLegacyFileDataOnce(): Promise<void> {
|
||||
const migrationKey = "agentLegacyFileImportVersion";
|
||||
const migrationVersion = "2";
|
||||
const row = this.db.prepare("SELECT value FROM __meta WHERE key = ?").get(migrationKey) as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
if (row?.value === migrationVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.importLegacyFileAgents();
|
||||
await this.importLegacyFileRuns();
|
||||
this.db.prepare(`
|
||||
INSERT INTO __meta (key, value)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
`).run(migrationKey, migrationVersion);
|
||||
this.db.bumpLastModified();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new agent with "idle" state.
|
||||
* @param input - Creation parameters
|
||||
|
||||
@@ -14949,7 +14949,7 @@ describe("GET /api/agents/:id/runs/:runId/logs", () => {
|
||||
expect(res.body[1]).toMatchObject({ taskId: "FN-001", type: "tool" });
|
||||
});
|
||||
|
||||
it("returns empty array for run without contextSnapshot.taskId", async () => {
|
||||
it("returns synthesized logs for run without contextSnapshot.taskId", async () => {
|
||||
// Create a run without contextSnapshot
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||
@@ -14957,6 +14957,7 @@ describe("GET /api/agents/:id/runs/:runId/logs", () => {
|
||||
const run = await agentStore.startHeartbeatRun(agentId);
|
||||
run.endedAt = new Date().toISOString();
|
||||
run.status = "completed";
|
||||
run.stdoutExcerpt = "Ambient heartbeat completed";
|
||||
// No contextSnapshot
|
||||
await agentStore.saveRun(run);
|
||||
|
||||
@@ -14971,7 +14972,13 @@ describe("GET /api/agents/:id/runs/:runId/logs", () => {
|
||||
const res = await REQUEST(app, "GET", `/api/agents/${agentId}/runs/${run.id}/logs`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
expect(res.body).toEqual([
|
||||
expect.objectContaining({
|
||||
taskId: "agent-run",
|
||||
type: "text",
|
||||
text: "Ambient heartbeat completed",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns 404 for non-existent run", async () => {
|
||||
|
||||
@@ -744,6 +744,41 @@ function logEntryToTimelineEntry(entry: import("@fusion/core").AgentLogEntry): T
|
||||
};
|
||||
}
|
||||
|
||||
function runExcerptToAgentLogs(run: import("@fusion/core").AgentHeartbeatRun): import("@fusion/core").AgentLogEntry[] {
|
||||
const entries: import("@fusion/core").AgentLogEntry[] = [];
|
||||
const taskId = typeof run.contextSnapshot?.taskId === "string" ? run.contextSnapshot.taskId : "agent-run";
|
||||
|
||||
if (run.stdoutExcerpt?.trim()) {
|
||||
entries.push({
|
||||
timestamp: run.endedAt ?? run.startedAt,
|
||||
taskId,
|
||||
type: "text",
|
||||
text: run.stdoutExcerpt,
|
||||
});
|
||||
}
|
||||
|
||||
if (run.stderrExcerpt?.trim()) {
|
||||
entries.push({
|
||||
timestamp: run.endedAt ?? run.startedAt,
|
||||
taskId,
|
||||
type: "tool_error",
|
||||
text: "stderr",
|
||||
detail: run.stderrExcerpt,
|
||||
});
|
||||
}
|
||||
|
||||
if (run.resultJson && Object.keys(run.resultJson).length > 0 && entries.length === 0) {
|
||||
entries.push({
|
||||
timestamp: run.endedAt ?? run.startedAt,
|
||||
taskId,
|
||||
type: "text",
|
||||
text: JSON.stringify(run.resultJson, null, 2),
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function trimTaskDetailActivityLog<T extends Task>(task: T): T {
|
||||
if (!Array.isArray(task.log) || task.log.length <= TASK_DETAIL_ACTIVITY_LOG_LIMIT) {
|
||||
return task;
|
||||
@@ -13066,7 +13101,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
// not the task active during a historical run.
|
||||
const taskId = run.contextSnapshot?.taskId as string | undefined;
|
||||
if (!taskId) {
|
||||
res.json([]);
|
||||
res.json(runExcerptToAgentLogs(run));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -13075,7 +13110,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
run.startedAt,
|
||||
run.endedAt,
|
||||
);
|
||||
res.json(logs);
|
||||
res.json(logs.length > 0 ? logs : runExcerptToAgentLogs(run));
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
|
||||
Reference in New Issue
Block a user