Store agent logs in per-task JSONL files instead of the legacy SQLite table. - add a file-backed agent log store with JSONL append/read/prune helpers and task-scoped source refs - migrate legacy SQLite agentLogEntries data into task files, rewrite goal citation references, and preserve soft-deleted logs for forensics - update task store, settings, docs, dashboard route coverage, and add regression tests for migration, retention, and log access Files changed: .changeset/fn-5911-agent-log-jsonl.md | 5 + AGENTS.md | 2 +- docs/diagnostics.md | 2 +- docs/settings-reference.md | 2 + docs/soft-delete-verification-matrix.md | 7 +- docs/storage.md | 8 +- .../src/__tests__/agent-log-file-store.test.ts | 123 ++++++ .../core/src/__tests__/agent-log-migration.test.ts | 186 +++++++++ .../core/src/__tests__/agent-log-retention.test.ts | 208 ++++++++++ packages/core/src/__tests__/db-migrate.test.ts | 14 +- packages/core/src/__tests__/db.test.ts | 39 +- .../src/__tests__/goal-citations-store.test.ts | 38 +- packages/core/src/__tests__/goals-schema.test.ts | 2 +- packages/core/src/__tests__/insight-store.test.ts | 10 +- .../src/__tests__/merge-request-record.test.ts | 2 +- packages/core/src/__tests__/mission-store.test.ts | 2 +- packages/core/src/__tests__/run-audit.test.ts | 2 +- packages/core/src/__tests__/secrets-schema.test.ts | 6 +- .../src/__tests__/soft-delete-agent-logs.test.ts | 71 ++-- .../src/__tests__/store-agent-log-file.test.ts | 91 +++++ .../core/src/__tests__/store-merge-queue.test.ts | 2 +- packages/core/src/__tests__/store-test-helpers.ts | 43 +- packages/core/src/__tests__/store-upsert.test.ts | 37 +- packages/core/src/__tests__/task-documents.test.ts | 2 +- packages/core/src/agent-log-constants.ts | 25 ++ packages/core/src/agent-log-file-store.ts | 267 ++++++++++++ packages/core/src/db.ts | 51 ++- packages/core/src/settings-schema.ts | 1 + packages/core/src/store.ts | 453 ++++++++++++--------- packages/core/src/types.ts | 10 +- .../__tests__/agent-log-routes.integration.test.ts | 48 +++ .../src/__tests__/evaluator-evidence.test.ts | 47 ++- packages/engine/src/self-healing.ts | 12 + .../src/store/__tests__/roadmap-store.test.ts | 4 +- 34 files changed, 1477 insertions(+), 345 deletions(-) Fusion-Task-Id: FN-5911 Fusion-Task-Lineage: 07c42f3a-87cf-4558-8f01-ac8460b5558b
268 lines
8.1 KiB
TypeScript
268 lines
8.1 KiB
TypeScript
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
|
|
import type { AgentLogEntry } from "./types.js";
|
|
import {
|
|
AGENT_LOG_FILENAME,
|
|
buildAgentLogSourceRef,
|
|
truncateAgentLogDetail,
|
|
} from "./agent-log-constants.js";
|
|
import { createLogger } from "./logger.js";
|
|
|
|
const log = createLogger("agent-log-file-store");
|
|
|
|
export interface StoredAgentLogEntry extends AgentLogEntry {
|
|
lineNo: number;
|
|
sourceRef: string;
|
|
}
|
|
|
|
export interface AgentLogFileStoreReadOptions {
|
|
limit?: number;
|
|
offset?: number;
|
|
type?: AgentLogEntry["type"];
|
|
startTime?: string;
|
|
endTime?: string | null;
|
|
}
|
|
|
|
export interface AgentLogFileAppendInput {
|
|
timestamp: string;
|
|
taskId: string;
|
|
text: string;
|
|
type: AgentLogEntry["type"];
|
|
detail?: string | null;
|
|
agent?: AgentLogEntry["agent"] | null;
|
|
}
|
|
|
|
interface AgentLogJsonlRow {
|
|
timestamp: string;
|
|
taskId: string;
|
|
text: string;
|
|
type: AgentLogEntry["type"];
|
|
detail?: string;
|
|
agent?: AgentLogEntry["agent"];
|
|
}
|
|
|
|
export function getAgentLogFilePath(taskDir: string): string {
|
|
return join(taskDir, AGENT_LOG_FILENAME);
|
|
}
|
|
|
|
export function appendAgentLogEntriesSync(
|
|
taskDir: string,
|
|
entries: AgentLogFileAppendInput[],
|
|
): StoredAgentLogEntry[] {
|
|
if (entries.length === 0) return [];
|
|
|
|
const filePath = getAgentLogFilePath(taskDir);
|
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
const startingLineNo = countLineNumbers(filePath);
|
|
const payload = entries
|
|
.map((entry) => serializeEntry(entry))
|
|
.join("");
|
|
appendFileSync(filePath, payload, "utf8");
|
|
|
|
return entries.map((entry, index) => materializeEntry(entry, startingLineNo + index + 1));
|
|
}
|
|
|
|
export function readAgentLogEntries(
|
|
taskDir: string,
|
|
options: AgentLogFileStoreReadOptions = {},
|
|
): StoredAgentLogEntry[] {
|
|
const entries = readAllAgentLogEntries(taskDir, options);
|
|
const offset = Math.max(0, options.offset ?? 0);
|
|
if (options.limit == null) {
|
|
return offset === 0 ? entries : entries.slice(0, Math.max(0, entries.length - offset));
|
|
}
|
|
const limit = Math.max(0, options.limit);
|
|
const endExclusive = Math.max(0, entries.length - offset);
|
|
const startInclusive = Math.max(0, endExclusive - limit);
|
|
return entries.slice(startInclusive, endExclusive);
|
|
}
|
|
|
|
export function countAgentLogEntries(
|
|
taskDir: string,
|
|
options: Omit<AgentLogFileStoreReadOptions, "limit" | "offset"> = {},
|
|
): number {
|
|
return readAllAgentLogEntries(taskDir, options).length;
|
|
}
|
|
|
|
export function readAgentLogEntriesByTimeRange(
|
|
taskDir: string,
|
|
startTime: string,
|
|
endTime: string | null,
|
|
options: Omit<AgentLogFileStoreReadOptions, "startTime" | "endTime"> = {},
|
|
): StoredAgentLogEntry[] {
|
|
return readAllAgentLogEntries(taskDir, {
|
|
...options,
|
|
startTime,
|
|
endTime,
|
|
});
|
|
}
|
|
|
|
function readAllAgentLogEntries(
|
|
taskDir: string,
|
|
options: Omit<AgentLogFileStoreReadOptions, "limit" | "offset"> = {},
|
|
): StoredAgentLogEntry[] {
|
|
const filePath = getAgentLogFilePath(taskDir);
|
|
if (!existsSync(filePath)) {
|
|
return [];
|
|
}
|
|
|
|
const content = readFileSync(filePath, "utf8");
|
|
if (content.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const lines = content.split("\n");
|
|
const entries: StoredAgentLogEntry[] = [];
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const rawLine = lines[index];
|
|
if (!rawLine) continue;
|
|
const lineNo = index + 1;
|
|
try {
|
|
const parsed = JSON.parse(rawLine) as Partial<AgentLogJsonlRow>;
|
|
if (
|
|
typeof parsed.timestamp !== "string"
|
|
|| typeof parsed.taskId !== "string"
|
|
|| typeof parsed.text !== "string"
|
|
|| typeof parsed.type !== "string"
|
|
) {
|
|
throw new Error("missing required agent-log fields");
|
|
}
|
|
const entry = materializeEntry(parsed as AgentLogFileAppendInput, lineNo);
|
|
if (options.type != null && entry.type !== options.type) {
|
|
continue;
|
|
}
|
|
if (options.startTime != null && entry.timestamp < options.startTime) {
|
|
continue;
|
|
}
|
|
if (options.endTime != null && entry.timestamp > options.endTime) {
|
|
continue;
|
|
}
|
|
entries.push(entry);
|
|
} catch (error) {
|
|
log.warn(`Skipping malformed JSONL line ${lineNo} in ${filePath}`, error);
|
|
}
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
function serializeEntry(entry: AgentLogFileAppendInput): string {
|
|
const normalizedDetail = truncateAgentLogDetail(entry.detail, entry.type);
|
|
const row: AgentLogJsonlRow = {
|
|
timestamp: entry.timestamp,
|
|
taskId: entry.taskId,
|
|
text: entry.text,
|
|
type: entry.type,
|
|
...(normalizedDetail !== undefined && { detail: normalizedDetail }),
|
|
...(entry.agent != null && { agent: entry.agent }),
|
|
};
|
|
return `${JSON.stringify(row)}\n`;
|
|
}
|
|
|
|
function materializeEntry(entry: AgentLogFileAppendInput, lineNo: number): StoredAgentLogEntry {
|
|
const normalizedDetail = truncateAgentLogDetail(entry.detail, entry.type);
|
|
return {
|
|
timestamp: entry.timestamp,
|
|
taskId: entry.taskId,
|
|
text: entry.text,
|
|
type: entry.type,
|
|
...(normalizedDetail !== undefined && { detail: normalizedDetail }),
|
|
...(entry.agent != null && { agent: entry.agent }),
|
|
lineNo,
|
|
sourceRef: buildAgentLogSourceRef(entry.taskId, lineNo),
|
|
};
|
|
}
|
|
|
|
function countLineNumbers(filePath: string): number {
|
|
if (!existsSync(filePath)) {
|
|
return 0;
|
|
}
|
|
const content = readFileSync(filePath, "utf8");
|
|
if (content.length === 0) {
|
|
return 0;
|
|
}
|
|
const lines = content.split("\n");
|
|
return lines.at(-1) === "" ? lines.length - 1 : lines.length;
|
|
}
|
|
|
|
/**
|
|
* Prune agent log JSONL files by removing entries older than the retention cutoff.
|
|
* Only affects tasks whose directory exists under `tasksDir`.
|
|
*
|
|
* @param tasksDir - Root `.fusion/tasks/` directory
|
|
* @param retentionDays - Number of days to retain; 0 or negative disables pruning
|
|
* @param scanTaskIds - Optional set of task IDs to scope pruning to. If omitted, all task subdirectories are scanned.
|
|
* @returns Counts of pruned files and approximate bytes freed.
|
|
*/
|
|
export function pruneAgentLogFiles(
|
|
tasksDir: string,
|
|
retentionDays: number,
|
|
scanTaskIds?: Set<string>,
|
|
): { prunedFiles: number; prunedEntries: number; freedBytes: number } {
|
|
if (!Number.isFinite(retentionDays) || retentionDays <= 0 || !existsSync(tasksDir)) {
|
|
return { prunedFiles: 0, prunedEntries: 0, freedBytes: 0 };
|
|
}
|
|
|
|
const cutoffIso = new Date(Date.now() - retentionDays * 86_400_000).toISOString();
|
|
let prunedFiles = 0;
|
|
let prunedEntries = 0;
|
|
let freedBytes = 0;
|
|
|
|
const entries = readdirSync(tasksDir, { withFileTypes: true });
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
if (scanTaskIds != null && !scanTaskIds.has(entry.name)) continue;
|
|
|
|
const taskDirPath = join(tasksDir, entry.name);
|
|
const filePath = getAgentLogFilePath(taskDirPath);
|
|
if (!existsSync(filePath)) continue;
|
|
|
|
try {
|
|
const content = readFileSync(filePath, "utf8");
|
|
if (content.length === 0) continue;
|
|
|
|
const lines = content.split("\n");
|
|
const keptLines: string[] = [];
|
|
let removed = 0;
|
|
|
|
for (const line of lines) {
|
|
if (!line) continue;
|
|
try {
|
|
const parsed = JSON.parse(line) as { timestamp?: unknown };
|
|
const ts = typeof parsed.timestamp === "string" ? parsed.timestamp : null;
|
|
if (ts != null && ts < cutoffIso) {
|
|
removed += 1;
|
|
continue;
|
|
}
|
|
} catch {
|
|
// Malformed line — keep it (don't destroy data we can't parse)
|
|
}
|
|
keptLines.push(line);
|
|
}
|
|
|
|
if (removed > 0) {
|
|
const newSize = keptLines.map((l) => l.length + 1).reduce((a, b) => a + b, 0);
|
|
freedBytes += content.length - newSize;
|
|
prunedEntries += removed;
|
|
|
|
if (keptLines.length === 0) {
|
|
unlinkSync(filePath);
|
|
prunedFiles += 1;
|
|
} else {
|
|
// Atomic-ish rewrite: write to temp then rename
|
|
const tmpPath = filePath + ".tmp";
|
|
writeFileSync(tmpPath, keptLines.join("\n") + "\n", "utf8");
|
|
renameSync(tmpPath, filePath);
|
|
prunedFiles += 1;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
log.warn(`Failed to prune agent log file ${filePath}`, err);
|
|
}
|
|
}
|
|
|
|
return { prunedFiles, prunedEntries, freedBytes };
|
|
}
|