Files
pi-config/extensions/pi-crew/src/runtime/crew-agent-records.ts

254 lines
11 KiB
TypeScript

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<string, { expiresAt: number; records: CrewAgentRecord[]; inFlight?: Promise<CrewAgentRecord[]> }>();
function setAsyncAgentReaderCache(filePath: string, entry: { expiresAt: number; records: CrewAgentRecord[]; inFlight?: Promise<CrewAgentRecord[]> }): 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<CrewAgentRecord[]>(agentsPath(manifest)) ?? []);
} catch {
return [];
}
}
export async function readCrewAgentsAsync(manifest: TeamRunManifest): Promise<CrewAgentRecord[]> {
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<CrewAgentRecord[]> => {
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<CrewAgentRecord>(safeExistingAgentFile(manifest, taskOrAgentId, "status.json"));
} catch {
return undefined;
}
}
const agentEventSeqCache = new Map<string, { size: number; mtimeMs: number; seq: number }>();
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<string, unknown>;
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,
};
}