184 lines
8.3 KiB
TypeScript
184 lines
8.3 KiB
TypeScript
import type { AgentRunStore } from "./store.js";
|
|
import type { CommandRecord, JsonRecord, JsonValue, RunEvent, RunRecord, RunnerJobRecord, TerminalStatus } from "../common/types.js";
|
|
import { outputBytesFromPayload, outputTruncatedFromPayload } from "../common/output.js";
|
|
|
|
export async function buildRunResult(store: AgentRunStore, runId: string, commandId?: string): Promise<JsonRecord> {
|
|
const run = await store.getRun(runId);
|
|
const command = await selectCommand(store, runId, commandId);
|
|
const events = await store.listEvents(runId, 0, 500);
|
|
const scopedEvents = command ? eventsForCommand(events, command.id) : events;
|
|
const jobs = await store.listRunnerJobs(runId, command?.id);
|
|
const latestJob = jobs.at(-1) ?? null;
|
|
const commandTerminal = command ? terminalFromCommand(command) : null;
|
|
const terminalEventStatus = terminalFromEvents(scopedEvents);
|
|
const terminal = commandTerminal ?? terminalEventStatus ?? run.terminalStatus;
|
|
const terminalSource = commandTerminal ? "command-record" : terminalEventStatus ? "terminal_status-event" : run.terminalStatus ? "run-record" : "none";
|
|
const failureKind = command ? failureKindFromEvents(scopedEvents) : run.failureKind ?? failureKindFromEvents(scopedEvents);
|
|
const failureMessage = command ? messageFromEvents(scopedEvents) : run.failureMessage ?? messageFromEvents(scopedEvents);
|
|
const reply = assistantReply(scopedEvents);
|
|
const blocker = terminal === "blocked" || terminal === "failed" ? { failureKind, message: failureMessage } : null;
|
|
return {
|
|
runId: run.id,
|
|
commandId: command?.id ?? commandId ?? null,
|
|
attemptId: latestJob?.attemptId ?? attemptFromEvents(events),
|
|
runnerId: latestJob?.runnerId ?? null,
|
|
jobName: latestJob?.jobName ?? null,
|
|
namespace: latestJob?.namespace ?? null,
|
|
status: command?.state ?? run.status,
|
|
runStatus: run.status,
|
|
commandState: command?.state ?? null,
|
|
terminalStatus: terminal,
|
|
terminalSource,
|
|
completed: terminal === "completed",
|
|
reply,
|
|
failureKind,
|
|
failureMessage,
|
|
blocker,
|
|
lastSeq: events.at(-1)?.seq ?? 0,
|
|
eventCount: events.length,
|
|
artifactSummary: artifactSummary(scopedEvents),
|
|
sessionRef: sessionSummary(run),
|
|
resourceBundleRef: resourceBundleSummary(run, events),
|
|
runnerJobCount: jobs.length,
|
|
};
|
|
}
|
|
|
|
async function selectCommand(store: AgentRunStore, runId: string, commandId?: string): Promise<CommandRecord | null> {
|
|
if (commandId) {
|
|
const command = await store.getCommand(commandId);
|
|
return command.runId === runId ? command : null;
|
|
}
|
|
const commands = await store.listCommands(runId, 0, 100);
|
|
return commands.at(-1) ?? null;
|
|
}
|
|
|
|
function terminalFromEvents(events: RunEvent[]): TerminalStatus | null {
|
|
for (const event of [...events].reverse()) {
|
|
if (event.type !== "terminal_status") continue;
|
|
const value = event.payload.terminalStatus;
|
|
if (value === "completed" || value === "failed" || value === "blocked" || value === "cancelled") return value;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function terminalFromCommand(command: CommandRecord): TerminalStatus | null {
|
|
if (command.state === "completed") return "completed";
|
|
if (command.state === "failed") return "failed";
|
|
if (command.state === "cancelled") return "cancelled";
|
|
return null;
|
|
}
|
|
|
|
function eventsForCommand(events: RunEvent[], commandId: string): RunEvent[] {
|
|
const scoped = events.filter((event) => event.payload.commandId === commandId || typeof event.payload.commandId !== "string");
|
|
return scoped.length > 0 ? scoped : events;
|
|
}
|
|
|
|
function failureKindFromEvents(events: RunEvent[]): string | null {
|
|
for (const event of [...events].reverse()) {
|
|
const value = event.payload.failureKind;
|
|
if (typeof value === "string") return value;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function messageFromEvents(events: RunEvent[]): string | null {
|
|
for (const event of [...events].reverse()) {
|
|
const value = event.payload.message;
|
|
if (typeof value === "string" && value.length > 0) return value;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function assistantReply(events: RunEvent[]): string {
|
|
const assistantEvents = events.filter((event) => event.type === "assistant_message");
|
|
const authoritative = assistantEvents
|
|
.filter((event) => event.payload.replyAuthority === true || event.payload.final === true)
|
|
.map((event) => textPayload(event.payload))
|
|
.filter((text) => text.length > 0);
|
|
const latestAuthoritative = authoritative.at(-1);
|
|
if (latestAuthoritative) return latestAuthoritative;
|
|
const completedAgentMessages = assistantEvents
|
|
.filter((event) => event.payload.source === "completed-agent-message")
|
|
.map((event) => textPayload(event.payload))
|
|
.filter((text) => text.length > 0);
|
|
const latestCompletedAgentMessage = completedAgentMessages.at(-1);
|
|
if (latestCompletedAgentMessage) return latestCompletedAgentMessage;
|
|
return assistantEvents.map((event) => textPayload(event.payload)).filter((text) => text.length > 0).join("");
|
|
}
|
|
|
|
function textPayload(payload: JsonRecord): string {
|
|
for (const key of ["text", "content", "delta"]) {
|
|
const value = payload[key];
|
|
if (typeof value === "string") return value;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function artifactSummary(events: RunEvent[]): JsonRecord {
|
|
let commandOutputEvents = 0;
|
|
let diffEvents = 0;
|
|
let toolCallEvents = 0;
|
|
let outputChars = 0;
|
|
let outputBytes = 0;
|
|
let outputTruncatedEvents = 0;
|
|
const streamSummary: Record<string, { events: number; outputBytes: number; outputTruncated: boolean }> = {
|
|
stdout: { events: 0, outputBytes: 0, outputTruncated: false },
|
|
stderr: { events: 0, outputBytes: 0, outputTruncated: false },
|
|
};
|
|
for (const event of events) {
|
|
if (event.type === "command_output") {
|
|
commandOutputEvents += 1;
|
|
const text = textPayload(event.payload);
|
|
outputChars += text.length;
|
|
const bytes = outputBytesFromPayload(event.payload);
|
|
outputBytes += bytes;
|
|
const truncated = outputTruncatedFromPayload(event.payload);
|
|
if (truncated) outputTruncatedEvents += 1;
|
|
const stream = event.payload.stream === "stderr" ? "stderr" : "stdout";
|
|
streamSummary[stream].events += 1;
|
|
streamSummary[stream].outputBytes += bytes;
|
|
streamSummary[stream].outputTruncated ||= truncated;
|
|
}
|
|
if (event.type === "diff") diffEvents += 1;
|
|
if (event.type === "tool_call") toolCallEvents += 1;
|
|
}
|
|
return { commandOutputEvents, diffEvents, toolCallEvents, outputChars, outputBytes, truncatedEvents: outputTruncatedEvents, outputTruncatedEvents, stdoutSummary: streamSummary.stdout, stderrSummary: streamSummary.stderr };
|
|
}
|
|
|
|
function attemptFromEvents(events: RunEvent[]): string | null {
|
|
for (const event of [...events].reverse()) {
|
|
const value = event.payload.attemptId;
|
|
if (typeof value === "string") return value;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function sessionSummary(run: RunRecord): JsonRecord | null {
|
|
if (!run.sessionRef) return null;
|
|
return {
|
|
sessionId: run.sessionRef.sessionId,
|
|
conversationId: run.sessionRef.conversationId ?? null,
|
|
threadId: run.sessionRef.threadId ?? null,
|
|
expiresAt: run.sessionRef.expiresAt ?? null,
|
|
metadataKeys: Object.keys(run.sessionRef.metadata ?? {}).sort(),
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function resourceBundleSummary(run: RunRecord, events: RunEvent[]): JsonRecord | null {
|
|
if (!run.resourceBundleRef) return null;
|
|
const materialized = events.find((event) => event.type === "backend_status" && event.payload.phase === "resource-bundle-materialized")?.payload ?? null;
|
|
return {
|
|
kind: run.resourceBundleRef.kind,
|
|
repoUrl: run.resourceBundleRef.repoUrl,
|
|
commitId: run.resourceBundleRef.commitId,
|
|
bundles: {
|
|
count: run.resourceBundleRef.bundles.length,
|
|
items: run.resourceBundleRef.bundles.map((item) => ({ name: item.name ?? null, repoUrl: item.repoUrl ?? run.resourceBundleRef?.repoUrl ?? null, commitId: item.commitId ?? run.resourceBundleRef?.commitId ?? null, subpath: item.subpath, targetPath: item.targetPath, valuesPrinted: false })),
|
|
valuesPrinted: false,
|
|
},
|
|
promptRefs: run.resourceBundleRef.promptRefs ? { count: run.resourceBundleRef.promptRefs.length, names: run.resourceBundleRef.promptRefs.map((item) => item.name), required: run.resourceBundleRef.promptRefs.filter((item) => item.required === true).map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], required: [], valuesPrinted: false },
|
|
materialized: materialized as JsonValue,
|
|
};
|
|
}
|