import * as fs from "node:fs"; import * as path from "node:path"; import type { TeamRunManifest, TeamTaskState } from "../state/types.ts"; import { atomicWriteJson, readJsonFile } from "../state/atomic-write.ts"; import { readJsonFileCoalesced } from "../utils/file-coalescer.ts"; import type { CrewAgentProgress, CrewAgentRecord, CrewRuntimeKind } from "./crew-agent-runtime.ts"; import { taskStatusToAgentStatus } from "./crew-agent-runtime.ts"; import { logInternalError } from "../utils/internal-error.ts"; import { assertSafePathId, resolveRealContainedPath } from "../utils/safe-paths.ts"; import { redactSecretString, redactSecrets } from "../utils/redaction.ts"; export function agentsPath(manifest: TeamRunManifest): string { return path.join(manifest.stateRoot, "agents.json"); } export function agentsRoot(manifest: TeamRunManifest): string { return path.join(manifest.stateRoot, "agents"); } function safeAgentTaskId(taskId: string): string { return assertSafePathId("taskId", taskId.includes(":") ? taskId.split(":").pop()! : taskId); } export function agentStateDir(manifest: TeamRunManifest, taskId: string): string { return path.join(agentsRoot(manifest), safeAgentTaskId(taskId)); } export function ensureAgentStateDir(manifest: TeamRunManifest, taskId: string): string { const root = agentsRoot(manifest); fs.mkdirSync(root, { recursive: true }); if (fs.lstatSync(root).isSymbolicLink()) throw new Error(`Invalid agents root: ${root}`); const dir = agentStateDir(manifest, taskId); fs.mkdirSync(dir, { recursive: true }); if (fs.lstatSync(dir).isSymbolicLink()) throw new Error(`Invalid agent state directory: ${dir}`); resolveRealContainedPath(root, path.basename(dir)); return dir; } function safeExistingAgentFile(manifest: TeamRunManifest, taskId: string, fileName: string): string { const filePath = path.join(agentStateDir(manifest, taskId), fileName); if (!fs.existsSync(filePath)) return filePath; if (fs.lstatSync(filePath).isSymbolicLink()) throw new Error(`Invalid agent state file: ${filePath}`); return resolveRealContainedPath(agentsRoot(manifest), path.join(safeAgentTaskId(taskId), fileName)); } export function agentStateFile(manifest: TeamRunManifest, taskId: string, fileName: string): string { ensureAgentStateDir(manifest, taskId); return safeExistingAgentFile(manifest, taskId, fileName); } export function agentStatusPath(manifest: TeamRunManifest, taskId: string): string { return path.join(agentStateDir(manifest, taskId), "status.json"); } export function agentEventsPath(manifest: TeamRunManifest, taskId: string): string { return path.join(agentStateDir(manifest, taskId), "events.jsonl"); } export function agentOutputPath(manifest: TeamRunManifest, taskId: string): string { return path.join(agentStateDir(manifest, taskId), "output.log"); } const AGENT_READER_TTL_MS = 200; const ASYNC_AGENT_READER_CACHE_MAX_ENTRIES = 128; const asyncAgentReaderCache = new Map }>(); function setAsyncAgentReaderCache(filePath: string, entry: { expiresAt: number; records: CrewAgentRecord[]; inFlight?: Promise }): void { const now = Date.now(); for (const [key, cached] of asyncAgentReaderCache) { if (cached.expiresAt <= now && !cached.inFlight) asyncAgentReaderCache.delete(key); } if (asyncAgentReaderCache.has(filePath)) asyncAgentReaderCache.delete(filePath); asyncAgentReaderCache.set(filePath, entry); while (asyncAgentReaderCache.size > ASYNC_AGENT_READER_CACHE_MAX_ENTRIES) { const oldest = asyncAgentReaderCache.keys().next().value; if (!oldest) break; asyncAgentReaderCache.delete(oldest); } } export function readCrewAgents(manifest: TeamRunManifest): CrewAgentRecord[] { try { return readJsonFileCoalesced(agentsPath(manifest), AGENT_READER_TTL_MS, () => readJsonFile(agentsPath(manifest)) ?? []); } catch { return []; } } export async function readCrewAgentsAsync(manifest: TeamRunManifest): Promise { const filePath = agentsPath(manifest); const now = Date.now(); const cached = asyncAgentReaderCache.get(filePath); if (cached && cached.expiresAt > now) return cached.records; if (cached?.inFlight) return cached.inFlight; const inFlight = (async (): Promise => { try { const parsed = JSON.parse(await fs.promises.readFile(filePath, "utf-8")) as unknown; const records = Array.isArray(parsed) ? redactSecrets(parsed) as CrewAgentRecord[] : []; setAsyncAgentReaderCache(filePath, { expiresAt: Date.now() + AGENT_READER_TTL_MS, records }); return records; } catch { setAsyncAgentReaderCache(filePath, { expiresAt: Date.now() + AGENT_READER_TTL_MS, records: [] }); return []; } })(); setAsyncAgentReaderCache(filePath, { expiresAt: now + AGENT_READER_TTL_MS, records: cached?.records ?? [], inFlight }); return inFlight; } export function saveCrewAgents(manifest: TeamRunManifest, records: CrewAgentRecord[]): void { fs.mkdirSync(manifest.stateRoot, { recursive: true }); const filePath = agentsPath(manifest); atomicWriteJson(filePath, redactSecrets(records)); asyncAgentReaderCache.delete(filePath); for (const record of records) writeCrewAgentStatus(manifest, record); } export function upsertCrewAgent(manifest: TeamRunManifest, record: CrewAgentRecord): void { const records = readCrewAgents(manifest).filter((item) => item.id !== record.id); records.push(record); saveCrewAgents(manifest, records); writeCrewAgentStatus(manifest, record); } export function writeCrewAgentStatus(manifest: TeamRunManifest, record: CrewAgentRecord): void { ensureAgentStateDir(manifest, record.taskId); atomicWriteJson(agentStatusPath(manifest, record.taskId), redactSecrets(record)); } export function readCrewAgentStatus(manifest: TeamRunManifest, taskOrAgentId: string): CrewAgentRecord | undefined { try { return readJsonFile(safeExistingAgentFile(manifest, taskOrAgentId, "status.json")); } catch { return undefined; } } const agentEventSeqCache = new Map(); function nextAgentEventSeq(filePath: string): number { if (!fs.existsSync(filePath)) return 1; const stat = fs.statSync(filePath); const cached = agentEventSeqCache.get(filePath); if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) return cached.seq + 1; let max = 0; for (const line of fs.readFileSync(filePath, "utf-8").split(/\r?\n/)) { if (!line.trim()) continue; try { const parsed = JSON.parse(line) as { seq?: unknown }; if (typeof parsed.seq === "number" && Number.isFinite(parsed.seq)) max = Math.max(max, parsed.seq); else max += 1; } catch { max += 1; } } agentEventSeqCache.set(filePath, { size: stat.size, mtimeMs: stat.mtimeMs, seq: max }); return max + 1; } export function appendCrewAgentEvent(manifest: TeamRunManifest, taskId: string, event: unknown): void { ensureAgentStateDir(manifest, taskId); const filePath = agentStateFile(manifest, taskId, "events.jsonl"); const seq = nextAgentEventSeq(filePath); fs.appendFileSync(filePath, `${JSON.stringify(redactSecrets({ seq, time: new Date().toISOString(), event }))}\n`, "utf-8"); try { const stat = fs.statSync(filePath); agentEventSeqCache.set(filePath, { size: stat.size, mtimeMs: stat.mtimeMs, seq }); } catch (error) { logInternalError("crew-agent-records.stat", error, `filePath=${filePath}`); } } export interface CrewAgentEventCursorOptions { sinceSeq?: number; limit?: number; } export function readCrewAgentEvents(manifest: TeamRunManifest, taskId: string): unknown[] { return readCrewAgentEventsCursor(manifest, taskId).events; } export function readCrewAgentEventsCursor(manifest: TeamRunManifest, taskId: string, options: CrewAgentEventCursorOptions = {}): { path: string; events: unknown[]; nextSeq: number; total: number } { let filePath: string; try { filePath = agentEventsPath(manifest, taskId); } catch { return { path: "", events: [], nextSeq: options.sinceSeq ?? 0, total: 0 }; } if (!fs.existsSync(filePath)) return { path: filePath, events: [], nextSeq: options.sinceSeq ?? 0, total: 0 }; try { filePath = safeExistingAgentFile(manifest, taskId, "events.jsonl"); } catch { return { path: "", events: [], nextSeq: options.sinceSeq ?? 0, total: 0 }; } const sinceSeq = typeof options.sinceSeq === "number" && Number.isInteger(options.sinceSeq) && options.sinceSeq >= 0 ? options.sinceSeq : 0; const limit = typeof options.limit === "number" && Number.isInteger(options.limit) && options.limit >= 0 ? options.limit : undefined; const parsed = fs.readFileSync(filePath, "utf-8").split(/\r?\n/).filter(Boolean).map((line, index) => { try { const event = JSON.parse(line) as Record; if (typeof event.seq !== "number") event.seq = index + 1; return event; } catch { return { seq: index + 1, raw: line }; } }); const filtered = parsed.filter((event) => typeof event.seq === "number" && event.seq > sinceSeq); const events = limit !== undefined ? filtered.slice(0, limit) : filtered; const returnedMaxSeq = events.reduce((max, event) => typeof event.seq === "number" ? Math.max(max, event.seq) : max, sinceSeq); return { path: filePath, events, nextSeq: returnedMaxSeq, total: filtered.length }; } export function appendCrewAgentOutput(manifest: TeamRunManifest, taskId: string, text: string): void { if (!text.trim()) return; ensureAgentStateDir(manifest, taskId); fs.appendFileSync(agentStateFile(manifest, taskId, "output.log"), `${redactSecretString(text)}\n`, "utf-8"); } export function emptyCrewAgentProgress(): CrewAgentProgress { return { recentTools: [], recentOutput: [], toolCount: 0 }; } function modelFromTask(task: TeamTaskState): string | undefined { const attempts = task.modelAttempts; if (!attempts?.length) return undefined; return attempts.find((attempt) => attempt.success)?.model ?? attempts.at(-1)?.model; } export function recordFromTask(manifest: TeamRunManifest, task: TeamTaskState, runtime: CrewRuntimeKind): CrewAgentRecord { return { id: `${manifest.runId}:${task.id}`, runId: manifest.runId, taskId: task.id, agent: task.agent, role: task.role, runtime, status: taskStatusToAgentStatus(task.status), startedAt: task.startedAt ?? new Date().toISOString(), completedAt: task.finishedAt, resultArtifactPath: task.resultArtifact?.path, transcriptPath: task.transcriptArtifact?.path ?? task.logArtifact?.path, statusPath: agentStatusPath(manifest, task.id), eventsPath: agentEventsPath(manifest, task.id), outputPath: agentOutputPath(manifest, task.id), toolUses: task.agentProgress?.toolCount, jsonEvents: task.jsonEvents, model: modelFromTask(task), routing: task.modelRouting, usage: task.usage, progress: task.agentProgress, error: task.error, }; }