feat: 补齐 HWLAB 基线 AgentRun 执行元语
This commit is contained in:
@@ -6,8 +6,9 @@ import { redactJson } from "../common/redaction.js";
|
||||
import type { BackendProfile, BackendTurnResult, CommandRecord, CommandState, CreateCommandInput, CreateRunInput, EventType, FailureKind, JsonRecord, JsonValue, RunEvent, RunnerJobRecord, RunnerRecord, RunRecord, RunStatus, SessionRecord, SessionRef, TerminalStatus } from "../common/types.js";
|
||||
import { newId, nowIso, stableHash } from "../common/validation.js";
|
||||
import type { AgentRunStore, SaveRunnerJobInput, StoreHealth } from "./store.js";
|
||||
import { commandStateFromTerminal, isTerminalCommandState, isTerminalRunStatus, sessionRefFromRecord, statusFromTerminal, summarizeResourceBundleRef, summarizeSessionRef } from "./store.js";
|
||||
import { backendCapabilitiesSqlValues } from "../common/backend-profiles.js";
|
||||
import { assertSessionBoundary, commandStateFromTerminal, isTerminalCommandState, isTerminalRunStatus, sessionRefFromRecord, statusFromTerminal, summarizeResourceBundleRef, summarizeSessionRef } from "./store.js";
|
||||
import { backendCapabilitiesSqlValues, mergeBackendCapability } from "../common/backend-profiles.js";
|
||||
import { normalizeRunEventPayload, requireEventType } from "../common/events.js";
|
||||
|
||||
interface PostgresStoreOptions {
|
||||
connectionString: string;
|
||||
@@ -509,7 +510,10 @@ CREATE TABLE IF NOT EXISTS agentrun_schema_migrations (
|
||||
|
||||
async backends(): Promise<JsonRecord[]> {
|
||||
const result = await this.pool.query("SELECT * FROM agentrun_backends ORDER BY profile ASC");
|
||||
return result.rows.map((row) => ({ profile: stringValue(row.profile), ...jsonRecord(row.capabilities), capacity: jsonValue(row.capacity), health: jsonValue(row.health), updatedAt: nullableIso(row.updated_at) }));
|
||||
return result.rows.map((row) => {
|
||||
const profile = stringValue(row.profile);
|
||||
return { ...mergeBackendCapability(profile, jsonRecord(row.capabilities)), capacity: jsonValue(row.capacity), health: jsonValue(row.health), updatedAt: nullableIso(row.updated_at) };
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
@@ -517,8 +521,10 @@ CREATE TABLE IF NOT EXISTS agentrun_schema_migrations (
|
||||
}
|
||||
|
||||
private async appendEventWithLockedRun(client: PoolClient, runId: string, type: EventType, payload: JsonRecord): Promise<RunEvent> {
|
||||
const eventType = requireEventType(type);
|
||||
const eventPayload = normalizeRunEventPayload(eventType, payload);
|
||||
const seq = await this.nextSeq(client, "agentrun_events", runId);
|
||||
const event: RunEvent = { id: newId("evt"), runId, seq, type, payload: redactJson(payload), createdAt: nowIso() };
|
||||
const event: RunEvent = { id: newId("evt"), runId, seq, type: eventType, payload: redactJson(eventPayload), createdAt: nowIso() };
|
||||
await client.query("INSERT INTO agentrun_events (id, run_id, seq, type, payload, created_at) VALUES ($1, $2, $3, $4, $5::jsonb, $6)", [event.id, event.runId, event.seq, event.type, JSON.stringify(event.payload), event.createdAt]);
|
||||
return event;
|
||||
}
|
||||
@@ -538,7 +544,11 @@ CREATE TABLE IF NOT EXISTS agentrun_schema_migrations (
|
||||
private async resolveSessionForRun(client: PoolClient, input: CreateRunInput, at: string): Promise<SessionRef | null> {
|
||||
if (!input.sessionRef) return null;
|
||||
const existing = await client.query("SELECT * FROM agentrun_sessions WHERE session_id = $1 FOR UPDATE", [input.sessionRef.sessionId]);
|
||||
if (existing.rows[0]) return sessionRefFromRecord(sessionFromRow(existing.rows[0]), input.sessionRef);
|
||||
if (existing.rows[0]) {
|
||||
const session = sessionFromRow(existing.rows[0]);
|
||||
assertSessionBoundary(session, input);
|
||||
return sessionRefFromRecord(session, input.sessionRef);
|
||||
}
|
||||
const record: SessionRecord = {
|
||||
sessionId: input.sessionRef.sessionId,
|
||||
tenantId: input.tenantId,
|
||||
|
||||
+21
-4
@@ -1,5 +1,6 @@
|
||||
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);
|
||||
@@ -7,7 +8,9 @@ export async function buildRunResult(store: AgentRunStore, runId: string, comman
|
||||
const events = await store.listEvents(runId, 0, 500);
|
||||
const jobs = await store.listRunnerJobs(runId, command?.id);
|
||||
const latestJob = jobs.at(-1) ?? null;
|
||||
const terminal = terminalFromEvents(events) ?? run.terminalStatus;
|
||||
const terminalEventStatus = terminalFromEvents(events);
|
||||
const terminal = terminalEventStatus ?? run.terminalStatus;
|
||||
const terminalSource = terminalEventStatus ? "terminal_status-event" : run.terminalStatus ? "run-record" : "none";
|
||||
const failureKind = run.failureKind ?? failureKindFromEvents(events);
|
||||
const reply = assistantReply(events);
|
||||
const blocker = terminal === "blocked" || terminal === "failed" ? { failureKind, message: run.failureMessage ?? messageFromEvents(events) } : null;
|
||||
@@ -22,6 +25,8 @@ export async function buildRunResult(store: AgentRunStore, runId: string, comman
|
||||
runStatus: run.status,
|
||||
commandState: command?.state ?? null,
|
||||
terminalStatus: terminal,
|
||||
terminalSource,
|
||||
completed: terminal === "completed",
|
||||
reply,
|
||||
failureKind,
|
||||
failureMessage: run.failureMessage ?? messageFromEvents(events),
|
||||
@@ -90,18 +95,30 @@ function artifactSummary(events: RunEvent[]): JsonRecord {
|
||||
let diffEvents = 0;
|
||||
let toolCallEvents = 0;
|
||||
let outputChars = 0;
|
||||
let truncatedEvents = 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;
|
||||
if (event.payload.truncated === true) truncatedEvents += 1;
|
||||
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, truncatedEvents };
|
||||
return { commandOutputEvents, diffEvents, toolCallEvents, outputChars, outputBytes, truncatedEvents: outputTruncatedEvents, outputTruncatedEvents, stdoutSummary: streamSummary.stdout, stderrSummary: streamSummary.stderr };
|
||||
}
|
||||
|
||||
function attemptFromEvents(events: RunEvent[]): string | null {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { JsonRecord, RunEvent, RunnerJobRecord, TerminalStatus } from "../common/types.js";
|
||||
|
||||
export function runnerJobStatusSummary(job: RunnerJobRecord, events: RunEvent[] = []): JsonRecord {
|
||||
const terminalEvent = latestTerminalEvent(events);
|
||||
const runner = recordAt(job.result, "runner");
|
||||
const jobIdentity = recordAt(job.result, "jobIdentity");
|
||||
const kubernetes = recordAt(job.result, "kubernetes");
|
||||
const retention = recordAt(job.result, "retention");
|
||||
const terminalStatus = terminalEvent?.payload.terminalStatus;
|
||||
return {
|
||||
id: job.id,
|
||||
runId: job.runId,
|
||||
commandId: job.commandId,
|
||||
attemptId: job.attemptId,
|
||||
runnerId: job.runnerId,
|
||||
namespace: job.namespace,
|
||||
jobName: job.jobName,
|
||||
managerUrl: job.managerUrl,
|
||||
image: job.image,
|
||||
sourceCommit: job.sourceCommit,
|
||||
serviceAccountName: job.serviceAccountName,
|
||||
phase: terminalStatus ? `terminal:${terminalStatus}` : kubernetes.created === true ? "created" : "recorded",
|
||||
terminalStatus: isTerminalStatus(terminalStatus) ? terminalStatus : null,
|
||||
failureKind: typeof terminalEvent?.payload.failureKind === "string" ? terminalEvent.payload.failureKind : null,
|
||||
exitCode: null,
|
||||
startedAt: null,
|
||||
finishedAt: terminalEvent?.createdAt ?? null,
|
||||
jobIdentity,
|
||||
podIdentity: recordAt(job.result, "podIdentity"),
|
||||
logPath: typeof runner.logPath === "string" ? runner.logPath : null,
|
||||
retention,
|
||||
kubernetes,
|
||||
createdAt: job.createdAt,
|
||||
updatedAt: job.updatedAt,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function latestTerminalEvent(events: RunEvent[]): RunEvent | null {
|
||||
for (const event of [...events].reverse()) {
|
||||
if (event.type === "terminal_status") return event;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function recordAt(record: JsonRecord, key: string): JsonRecord {
|
||||
const value = record[key];
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as JsonRecord : {};
|
||||
}
|
||||
|
||||
function isTerminalStatus(value: unknown): value is TerminalStatus {
|
||||
return value === "completed" || value === "failed" || value === "blocked" || value === "cancelled";
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { asRecord, validateCreateCommand, validateCreateRun } from "../common/va
|
||||
import type { ApiErrorBody, ApiOkBody, JsonRecord, JsonValue, RunEvent } from "../common/types.js";
|
||||
import { createKubernetesRunnerJob } from "./kubernetes-runner-job.js";
|
||||
import { buildRunResult } from "./result.js";
|
||||
import { runnerJobStatusSummary } from "./runner-job-status.js";
|
||||
|
||||
export interface ManagerServerOptions {
|
||||
store?: AgentRunStore;
|
||||
@@ -104,6 +105,22 @@ async function route({ method, url, body, store, sourceCommit, runnerJobDefaults
|
||||
},
|
||||
}) as unknown as JsonValue;
|
||||
}
|
||||
if (method === "GET" && runnerJobMatch) {
|
||||
const runId = runnerJobMatch[1] ?? "";
|
||||
const commandId = url.searchParams.get("commandId") ?? undefined;
|
||||
const jobs = await store.listRunnerJobs(runId, commandId);
|
||||
const events = await store.listEvents(runId, 0, 500);
|
||||
return { items: jobs.map((job) => runnerJobStatusSummary(job, events)), count: jobs.length, lastSeq: events.at(-1)?.seq ?? 0 };
|
||||
}
|
||||
const runnerJobShowMatch = path.match(/^\/api\/v1\/runs\/([^/]+)\/runner-jobs\/([^/]+)$/u);
|
||||
if (method === "GET" && runnerJobShowMatch) {
|
||||
const runId = runnerJobShowMatch[1] ?? "";
|
||||
const runnerJobId = runnerJobShowMatch[2] ?? "";
|
||||
const jobs = await store.listRunnerJobs(runId);
|
||||
const job = jobs.find((item) => item.id === runnerJobId);
|
||||
if (!job) throw new AgentRunError("schema-invalid", `runner job ${runnerJobId} was not found`, { httpStatus: 404 });
|
||||
return runnerJobStatusSummary(job, await store.listEvents(runId, 0, 500)) as JsonValue;
|
||||
}
|
||||
const commandShowMatch = path.match(/^\/api\/v1\/runs\/([^/]+)\/commands\/([^/]+)$/u);
|
||||
if (method === "GET" && commandShowMatch) return await store.getCommand(commandShowMatch[2] ?? "") as unknown as JsonValue;
|
||||
const commandResultMatch = path.match(/^\/api\/v1\/runs\/([^/]+)\/commands\/([^/]+)\/result$/u);
|
||||
|
||||
+17
-2
@@ -3,6 +3,7 @@ import { AgentRunError } from "../common/errors.js";
|
||||
import { newId, nowIso, stableHash } from "../common/validation.js";
|
||||
import { redactJson } from "../common/redaction.js";
|
||||
import { backendCapabilities } from "../common/backend-profiles.js";
|
||||
import { normalizeRunEventPayload, requireEventType } from "../common/events.js";
|
||||
|
||||
export type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
@@ -198,8 +199,10 @@ export class MemoryAgentRunStore implements AgentRunStore {
|
||||
|
||||
appendEvent(runId: string, type: RunEvent["type"], payload: JsonRecord): RunEvent {
|
||||
this.getRun(runId);
|
||||
const eventType = requireEventType(type);
|
||||
const eventPayload = normalizeRunEventPayload(eventType, payload);
|
||||
const events = this.eventsByRun.get(runId) ?? [];
|
||||
const event: RunEvent = { id: newId("evt"), runId, seq: events.length + 1, type, payload: redactJson(payload), createdAt: nowIso() };
|
||||
const event: RunEvent = { id: newId("evt"), runId, seq: events.length + 1, type: eventType, payload: redactJson(eventPayload), createdAt: nowIso() };
|
||||
events.push(event);
|
||||
this.eventsByRun.set(runId, events);
|
||||
return event;
|
||||
@@ -258,7 +261,10 @@ export class MemoryAgentRunStore implements AgentRunStore {
|
||||
private resolveSessionForRun(input: CreateRunInput, at: string): SessionRef | null {
|
||||
if (!input.sessionRef) return null;
|
||||
const existing = this.sessions.get(input.sessionRef.sessionId);
|
||||
if (existing) return sessionRefFromRecord(existing, input.sessionRef);
|
||||
if (existing) {
|
||||
assertSessionBoundary(existing, input);
|
||||
return sessionRefFromRecord(existing, input.sessionRef);
|
||||
}
|
||||
const record: SessionRecord = {
|
||||
sessionId: input.sessionRef.sessionId,
|
||||
tenantId: input.tenantId,
|
||||
@@ -298,6 +304,15 @@ export class MemoryAgentRunStore implements AgentRunStore {
|
||||
}
|
||||
}
|
||||
|
||||
export function assertSessionBoundary(existing: SessionRecord, input: CreateRunInput): void {
|
||||
if (existing.tenantId !== input.tenantId || existing.projectId !== input.projectId) {
|
||||
throw new AgentRunError("tenant-policy-denied", "sessionRef cannot be reused across tenant or project boundary", { httpStatus: 403, details: { sessionId: existing.sessionId, valuesPrinted: false } });
|
||||
}
|
||||
if (existing.backendProfile !== input.backendProfile) {
|
||||
throw new AgentRunError("schema-invalid", "sessionRef cannot be reused across backendProfile boundary", { httpStatus: 400, details: { sessionId: existing.sessionId, existingBackendProfile: existing.backendProfile, requestedBackendProfile: input.backendProfile, valuesPrinted: false } });
|
||||
}
|
||||
}
|
||||
|
||||
export function statusFromTerminal(terminalStatus: TerminalStatus): RunRecord["status"] {
|
||||
if (terminalStatus === "completed") return "completed";
|
||||
if (terminalStatus === "cancelled") return "cancelled";
|
||||
|
||||
Reference in New Issue
Block a user