import { randomUUID } from "node:crypto"; import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import path from "node:path"; import { cancelAgentRunChatTurn, describeAgentRunAdapterAvailability, submitAgentRunChatTurn, } from "../cloud/code-agent-agentrun-adapter.ts"; import { createCodeAgentTraceStore } from "../cloud/code-agent-trace-store.ts"; import type { WorkbenchApplication, WorkbenchEventPublisher, WorkbenchTurnInput } from "./contracts.ts"; type NativeState = { sessions: Record>; turns: Record>; }; export function createNativeAgentRunWorkbenchApplication(options: { stateFile: string; env: Record; fetchImpl?: typeof fetch; now?: () => string; eventPublisher?: WorkbenchEventPublisher | null; }): WorkbenchApplication { const env = options.env; const fetchImpl = options.fetchImpl ?? fetch; const now = options.now ?? (() => new Date().toISOString()); const traceStore = createCodeAgentTraceStore(); const eventPublisher = options.eventPublisher ?? null; const mutate = async (fn: (state: NativeState) => T | Promise) => { const state = await load(options.stateFile); const result = await fn(state); await mkdir(path.dirname(options.stateFile), { recursive: true }); const temporary = `${options.stateFile}.${process.pid}.tmp`; await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, "utf8"); await rename(temporary, options.stateFile); return result; }; return { async snapshot() { return load(options.stateFile); }, async health() { const state = await load(options.stateFile); const availability = describeAgentRunAdapterAvailability(env, { params: { providerProfile: env.HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE } }); return { serviceId: "hwlab-workbench-native-agentrun", status: availability.ready ? "ready" : "blocked", mode: "agentrun-native", providerProfile: text(env.HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE), agentRun: availability.agentRun, sessionCount: Object.keys(state.sessions).length, turnCount: Object.keys(state.turns).length, valuesPrinted: false, }; }, async createSession({ actor, params }) { return mutate((state) => { const sessionId = validOptionalId(params.sessionId, "ses_") ?? `ses_${randomUUID()}`; const conversationId = validOptionalId(params.conversationId, "cnv_") ?? `cnv_${randomUUID()}`; const timestamp = now(); const session = { sessionId, conversationId, projectId: text(params.projectId) || "pikasTech/HWLAB", ownerUserId: actor.id, ownerRole: actor.role ?? "user", status: "idle", providerProfile: text(params.providerProfile) || text(env.HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE) || "gpt.pika", createdAt: timestamp, updatedAt: timestamp, mode: "agentrun-native", messages: [] as Record[], }; state.sessions[sessionId] = session; return { status: "created", session, valuesRedacted: true, secretMaterialStored: false }; }); }, async admitTurn(input) { const admitted = await mutate((state) => { const session = ownedSession(state, input.sessionId, input.actor.id); const timestamp = validTimestamp(input.params.submittedAt) ?? now(); const message = requiredText(input.params.message, "message"); state.turns[input.traceId] = { traceId: input.traceId, sessionId: input.sessionId, conversationId: session.conversationId, ownerUserId: input.actor.id, status: "admitted", message, createdAt: timestamp, updatedAt: timestamp, terminal: false, }; session.messages.push({ messageId: `msg_user_${input.traceId.slice(4)}`, role: "user", content: message, traceId: input.traceId, status: "completed", createdAt: timestamp }); session.status = "running"; session.lastTraceId = input.traceId; session.updatedAt = timestamp; return { ...input, params: { ...input.params, message, submittedAt: timestamp, providerProfile: text(input.params.providerProfile) || session.providerProfile } }; }); await eventPublisher?.publish({ traceId: admitted.traceId, sessionId: admitted.sessionId, event: { type: "backend_status", phase: "request-admitted", status: "running", summary: "请求已接纳,正在启动 AgentRun", message: "请求已接纳,正在启动 AgentRun", createdAt: admitted.params.submittedAt, terminal: false, valuesPrinted: false, }, }); return admitted; }, async dispatchTurn(input: WorkbenchTurnInput) { const state = await load(options.stateFile); const session = ownedSession(state, input.sessionId, input.actor.id); const params = { ...input.params, sessionId: input.sessionId, conversationId: session.conversationId, projectId: session.projectId, ownerUserId: input.actor.id, ownerRole: input.actor.role ?? "user", providerProfile: text(input.params.providerProfile) || session.providerProfile, shortConnection: true, }; const publishingTrace = publishingDispatchTraceStore(traceStore, eventPublisher, input); let admitted; try { admitted = await submitAgentRunChatTurn({ traceId: input.traceId, params, options: { env, fetchImpl }, traceStore: publishingTrace.store }); } catch (error) { await publishingTrace.flush().catch(() => undefined); throw error; } await publishingTrace.flush().catch(() => undefined); await projectRunning(admitted, input.traceId); return { status: "running", terminal: false, traceId: input.traceId, sessionId: input.sessionId, agentRun: admitted.agentRun, terminalAuthority: "hwlab.event.v1", resultSynthesized: false, valuesPrinted: false, }; }, async close() { await eventPublisher?.close?.(); }, async cancelTurn(input) { const state = await load(options.stateFile); const turn = state.turns[input.traceId]; if (!turn || turn.ownerUserId !== input.actor.id) throw codedError("turn_not_found", "turn is not visible to the actor"); const currentResult = turn.agentRun ? { status: "running", agentRun: turn.agentRun } : null; const canceled = await cancelAgentRunChatTurn({ traceId: input.traceId, currentResult, options: { env, fetchImpl }, traceStore }); if (!canceled) throw codedError("turn_not_running", "turn has no active AgentRun command"); return { status: "cancel-requested", terminal: false, traceId: input.traceId, agentRun: canceled.agentRun, terminalAuthority: "hwlab.event.v1", resultSynthesized: false, valuesPrinted: false, }; }, }; async function projectRunning(result: any, traceId: string) { return mutate((state) => { const turn = state.turns[traceId]; if (!turn) throw codedError("turn_not_admitted", "turn must be admitted before dispatch"); Object.assign(turn, { status: "running", updatedAt: now(), runId: result.agentRun?.runId ?? null, commandId: result.agentRun?.commandId ?? null, runnerJobId: result.agentRun?.runnerJobId ?? null, agentRun: result.agentRun ?? null, }); }); } } function publishingDispatchTraceStore(base: ReturnType, publisher: WorkbenchEventPublisher | null, input: WorkbenchTurnInput) { const pending: Promise[] = []; const store = { ...base, append(traceId: string, event: Record = {}, meta: Record = {}) { const normalized = base.append(traceId, event, meta); if (publisher && dispatchVisibilityEvent(event)) { pending.push(publishDispatchVisibility(publisher, input, { ...event, createdAt: normalized?.createdAt ?? event.createdAt })); } return normalized; }, }; return { store, async flush() { await Promise.all(pending); } }; } function dispatchVisibilityEvent(event: Record): boolean { const label = text(event.label); const visible = label.startsWith("agentrun:dispatch-retry:") || label.startsWith("agentrun:dispatch-retry-exhausted:") || label.startsWith("agentrun:dispatch-failed:"); return visible && (event.willRetry === true || event.terminal === true); } async function publishDispatchVisibility(publisher: WorkbenchEventPublisher, input: WorkbenchTurnInput, event: Record) { const retrying = event.willRetry === true; const exhausted = event.terminal === true; const failureKind = text(event.failureKind || event.errorCode) || "agentrun-dispatch-error"; const summary = dispatchFailureSummary(failureKind); await publisher.publish({ traceId: input.traceId, sessionId: input.sessionId, event: { type: retrying ? "backend_status" : "error", phase: retrying ? "dispatch-retry-scheduled" : "dispatch-retry-exhausted", failureDomain: "infrastructure", component: "agentrun-manager", code: failureKind, failureKind, summary, message: summary, retryable: event.retryable === true, willRetry: retrying, retryPhase: retrying ? "retryScheduled" : "retryExhausted", attempt: event.retryAttempt, maxAttempts: event.retryMax, backoffMs: event.retryDelayMs, nextRetryAt: event.nextRetryAt, createdAt: event.createdAt, runId: event.runId ?? null, commandId: event.commandId ?? null, terminal: false, valuesPrinted: false, }, }); if (!exhausted) return; await publisher.publish({ traceId: input.traceId, sessionId: input.sessionId, event: { type: "terminal_status", terminalStatus: "failed", status: "failed", failureKind, errorCode: failureKind, message: `${summary},有限重试已耗尽。`, createdAt: event.createdAt, runId: event.runId ?? null, commandId: event.commandId ?? null, terminal: true, valuesPrinted: false, }, }); } function dispatchFailureSummary(failureKind: string): string { if (failureKind === "connection-refused" || failureKind === "agentrun-connect-failed") return "AgentRun 服务暂时不可达"; if (failureKind.includes("timeout")) return "AgentRun 服务响应超时"; return "AgentRun 调度基础设施暂时不可用"; } async function load(stateFile: string): Promise { const parsed = JSON.parse(await readFile(stateFile, "utf8").catch(() => '{"sessions":{},"turns":{}}')); return { sessions: object(parsed.sessions), turns: object(parsed.turns) }; } function object(value: unknown) { return value && typeof value === "object" && !Array.isArray(value) ? value as Record> : {}; } function text(value: unknown) { return String(value ?? "").trim(); } function validTimestamp(value: unknown) { const timestamp = text(value); return timestamp && Number.isFinite(Date.parse(timestamp)) ? new Date(Date.parse(timestamp)).toISOString() : null; } function requiredText(value: unknown, field: string) { const result = text(value); if (!result) throw codedError("invalid_input", `${field} is required`); return result; } function validOptionalId(value: unknown, prefix: string) { const id = text(value); if (!id) return null; if (!id.startsWith(prefix)) throw codedError("invalid_input", `${prefix.slice(0, -1)} id is invalid`); return id; } function ownedSession(state: NativeState, sessionId: string, ownerUserId: string) { const session = state.sessions[sessionId]; if (!session || session.ownerUserId !== ownerUserId) throw codedError("session_not_found", "session is not visible to the actor"); return session; } function codedError(code: string, message: string) { return Object.assign(new Error(message), { code }); }