feat: 补齐 HWLAB 基线 AgentRun 执行元语

This commit is contained in:
Codex
2026-06-01 13:43:27 +08:00
parent 4dc697fe23
commit f4ee644233
17 changed files with 555 additions and 18 deletions
+15 -5
View File
@@ -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
View File
@@ -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 {
+53
View File
@@ -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";
}
+17
View File
@@ -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
View File
@@ -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";