import { type Router } from "express"; import { z } from "zod"; import { type ChatSessionBridge } from "../chat/sessionBridge.js"; import { agentModelOptions } from "../chat/models.js"; import { config } from "../config.js"; import { getLocalAgentContext, getTrustedProductCredential, } from "../context/localContext.js"; import { type FrontendActionCoordinator } from "../frontendAction/coordinator.js"; import { logger } from "../logger.js"; import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; import { type SessionMetadataStore, type SessionRecord, type SessionPurpose, readPurpose, } from "../sessions/metadataStore.js"; import { type SessionTranscriptStore } from "../sessions/transcriptStore.js"; import { type SessionUiStateStore } from "../sessions/uiStateStore.js"; import { toActorKey, toProjectKey } from "../utils/fileStore.js"; import { type ChatRunState } from "./chatRunState.js"; import { buildForkedSessionUiState } from "./chatSession.js"; import { type StreamSubscriber } from "./chatUiState.js"; import { agentTriggerContextSchema } from "./agentTrigger.js"; import { bindOperationalTaskCredential, clearOperationalTaskLease, operationalTaskClient, releaseOperationalTaskCredential, } from "../product/operational-task-client.js"; const createSessionPayloadSchema = z.object({ session_id: z.string().max(128).optional(), parent_session_id: z.string().max(128).optional(), trigger_context: agentTriggerContextSchema.optional(), }); const taskExecutionPayloadSchema = z.object({ task_id: z.string().uuid(), run_id: z.string().uuid(), execution_request_id: z.string().uuid(), workflow: z.string().trim().min(1).max(120).optional(), }); const sessionListQuerySchema = z.object({ purpose: z.enum(["ordinary_chat", "task_execution", "task_collaboration"]).optional(), task_id: z.string().uuid().optional(), }); const forkPayloadSchema = z.object({ session_id: z.string().max(128).optional(), keep_message_count: z.coerce.number().int().min(0), }); type RegisterChatSessionRoutesOptions = { frontendActionCoordinator: FrontendActionCoordinator; runState: ChatRunState; runtime: OpencodeRuntimeAdapter; sessionBridge: ChatSessionBridge; sessionMetadataStore: SessionMetadataStore; sessionTranscriptStore: SessionTranscriptStore; sessionUiStateStore: SessionUiStateStore; }; const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({ sessionId: sessionRecord.sessionId, }); const toSse = (event: string, data: Record) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; export const registerChatSessionRoutes = ( chatRouter: Router, options: RegisterChatSessionRoutesOptions, ) => { const { frontendActionCoordinator, runState, runtime, sessionBridge, sessionMetadataStore, sessionTranscriptStore, sessionUiStateStore, } = options; const { activeRuns, lastRunStatuses } = runState; const getSessionRunStatus = runState.getStatus; const startTaskExecution = ( record: SessionRecord, credential: NonNullable>, model: string, workflow?: string, ) => { queueMicrotask(() => { const baseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? `http://127.0.0.1:${config.PORT}`; void fetch(new URL("/api/v1/agent/chat/stream", baseUrl), { method: "POST", headers: { Authorization: credential.authorization, "Content-Type": "application/json", "X-Project-Id": credential.projectId, }, body: JSON.stringify({ session_id: record.sessionId, model, message: `执行工况任务${workflow ? `:${workflow}` : ""}。先读取后端任务上下文,再按任务类型更新计划、证据和报告。`, }), }).then(async (response) => { const reader = response.body?.getReader(); if (reader) { while (!(await reader.read()).done) { // Drain the stream without retaining the model response in memory. } } if (!response.ok) { clearOperationalTaskLease(record.sessionId); logger.error({ sessionId: record.sessionId, status: response.status }, "background task execution failed"); } }).catch((error) => { clearOperationalTaskLease(record.sessionId); logger.error({ err: error, sessionId: record.sessionId }, "background task execution request failed"); }); }); }; const prepareTaskExecution = async ( record: SessionRecord, credential: NonNullable>, input: z.infer, ) => { bindOperationalTaskCredential(record.sessionId, credential); try { const taskContext = await operationalTaskClient.getAgentContext(record.sessionId, record.taskId!); const task = taskContext.task && typeof taskContext.task === "object" ? taskContext.task as Record : null; const activeRun = task?.active_run && typeof task.active_run === "object" ? task.active_run as Record : null; if ( !activeRun || activeRun.run_id !== input.run_id || activeRun.execution_request_id !== input.execution_request_id || typeof activeRun.revision !== "number" || typeof activeRun.status !== "string" ) { throw new Error("task context did not include the requested active run"); } if (activeRun.status === "queued") { await operationalTaskClient.claimTask(record.sessionId, record.taskId!, input.run_id, { requestId: input.execution_request_id, expectedRevision: activeRun.revision, sourceSessionId: record.sessionId, }); return "running"; } if ( ["claimed", "running"].includes(activeRun.status) && activeRun.agent_session_id === record.sessionId ) { return "running"; } throw new Error(`task run is not executable from this session (${activeRun.status})`); } finally { releaseOperationalTaskCredential(record.sessionId); } }; chatRouter.post("/session", async (req, res) => { const parsed = createSessionPayloadSchema.safeParse(req.body ?? {}); if (!parsed.success) { res.status(400).json({ message: "invalid request payload", detail: parsed.error.flatten(), }); return; } const authContext = getLocalAgentContext(req); const projectId = authContext.projectId; const userId = authContext.userId; const actorKey = authContext.actorKey; const projectKey = authContext.projectKey; const trigger = parsed.data.trigger_context; const purpose: SessionPurpose = trigger ? "task_execution" : "ordinary_chat"; const taskId = trigger?.subject_id; if (trigger) { const existingTriggeredSession = await sessionMetadataStore.findByTrigger( { actorKey, projectId, projectKey, userId }, trigger.trigger_id, ); if (existingTriggeredSession) { res.status(200).json({ session_id: existingTriggeredSession.sessionId, created_at: existingTriggeredSession.createdAt, updated_at: existingTriggeredSession.updatedAt, status: existingTriggeredSession.status, title: existingTriggeredSession.title, parent_session_id: existingTriggeredSession.parentSessionId, created: false, event_duplicate: true, trigger_id: trigger.trigger_id, }); return; } } const requestedSessionId = parsed.data.session_id?.trim(); let sessionId = requestedSessionId; if (!sessionId) { try { sessionId = (await runtime.createSession()).id; } catch (error) { sessionId = sessionBridge.createClientSessionId(); logger.warn( { err: error, sessionId }, "runtime unavailable while creating chat session; using local degraded session", ); } } const { record, created } = await sessionMetadataStore.ensure({ actorKey, parentSessionId: parsed.data.parent_session_id, projectId, projectKey, sessionId, purpose, taskId, trigger: trigger ? { triggerId: trigger.trigger_id, source: trigger.source, subjectType: trigger.subject_type, subjectId: trigger.subject_id, workflow: trigger.workflow, occurredAt: trigger.occurred_at, } : undefined, userId, }); res.status(created ? 201 : 200).json({ session_id: record.sessionId, created_at: record.createdAt, updated_at: record.updatedAt, status: record.status, title: record.title, parent_session_id: record.parentSessionId, created, event_duplicate: false, trigger_id: record.triggerId, purpose: readPurpose(record), task_id: record.taskId, }); }); chatRouter.post("/task/:task_id/collaboration/session", async (req, res) => { const taskId = z.string().uuid().safeParse(req.params.task_id); if (!taskId.success) { res.status(400).json({ message: "valid task_id is required" }); return; } const authContext = getLocalAgentContext(req); const credential = getTrustedProductCredential(req); if (!credential) { res.status(401).json({ message: "task collaboration requires product credentials" }); return; } const metadataContext = { actorKey: authContext.actorKey, projectId: authContext.projectId, projectKey: authContext.projectKey, userId: authContext.userId, }; const existing = await sessionMetadataStore.findByTask( metadataContext, taskId.data, "task_collaboration", ); const validationSessionId = existing?.sessionId ?? `task-access-${crypto.randomUUID()}`; bindOperationalTaskCredential(validationSessionId, credential); try { await operationalTaskClient.getAgentContext(validationSessionId, taskId.data); } catch (error) { res.status(error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 502).json({ message: error instanceof Error ? error.message : "task access validation failed", }); return; } finally { releaseOperationalTaskCredential(validationSessionId); } if (existing) { res.json({ session_id: existing.sessionId, purpose: readPurpose(existing), task_id: existing.taskId, created: false, status: existing.status, }); return; } const runtimeSession = await runtime.createSession(); const { record } = await sessionMetadataStore.ensure({ ...metadataContext, sessionId: runtimeSession.id, purpose: "task_collaboration", taskId: taskId.data, }); const titled = await sessionMetadataStore.touch(record, { title: "任务协作" }); res.status(201).json({ session_id: titled.sessionId, purpose: readPurpose(titled), task_id: titled.taskId, created: true, status: titled.status, }); }); chatRouter.post("/task-executions", async (req, res) => { const parsed = taskExecutionPayloadSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ message: "invalid request payload", detail: parsed.error.flatten() }); return; } const credential = getTrustedProductCredential(req); if (!credential) { res.status(401).json({ message: "task execution requires product credentials" }); return; } const authContext = getLocalAgentContext(req); const metadataContext = { actorKey: authContext.actorKey, projectId: authContext.projectId, projectKey: authContext.projectKey, userId: authContext.userId, }; const existing = await sessionMetadataStore.findByRequest(metadataContext, parsed.data.execution_request_id); if (existing) { if ( existing.taskId !== parsed.data.task_id || existing.runId !== parsed.data.run_id || readPurpose(existing) !== "task_execution" ) { res.status(409).json({ message: "request_id is already bound to another task" }); return; } if (activeRuns.get(existing.sessionId)?.status === "running") { res.status(202).json({ session_id: existing.sessionId, task_id: existing.taskId, request_id: existing.requestId, run_id: existing.runId, purpose: readPurpose(existing), created: false, status: "running", model: existing.modelId, }); return; } const taskModel = existing.modelId; if (!taskModel) { res.status(409).json({ message: "task execution session has no model binding" }); return; } try { await prepareTaskExecution(existing, credential, parsed.data); } catch (error) { res.status(error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 409).json({ message: error instanceof Error ? error.message : "task execution recovery failed", }); return; } res.status(202).json({ session_id: existing.sessionId, task_id: existing.taskId, request_id: existing.requestId, run_id: existing.runId, purpose: readPurpose(existing), created: false, status: "running", model: taskModel, }); startTaskExecution(existing, credential, taskModel, parsed.data.workflow); return; } let taskModel: string; try { taskModel = await runtime.resolveAvailableModel([ config.OPERATIONAL_TASK_MODEL ?? config.OPENCODE_MODEL, ...agentModelOptions.map((option) => option.id), ]); } catch (error) { res.status(503).json({ message: error instanceof Error ? error.message : "no operational task model is available", }); return; } const runtimeSession = await runtime.createSession(); const { record } = await sessionMetadataStore.ensure({ ...metadataContext, sessionId: runtimeSession.id, purpose: "task_execution", taskId: parsed.data.task_id, runId: parsed.data.run_id, requestId: parsed.data.execution_request_id, modelId: taskModel, }); try { await prepareTaskExecution(record, credential, parsed.data); } catch (error) { await sessionMetadataStore.remove(record); clearOperationalTaskLease(record.sessionId); res.status(error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 502).json({ message: error instanceof Error ? error.message : "task claim failed", }); return; } res.status(202).json({ session_id: record.sessionId, task_id: record.taskId, request_id: record.requestId, run_id: record.runId, purpose: readPurpose(record), created: true, status: "running", model: taskModel, }); startTaskExecution(record, credential, taskModel, parsed.data.workflow); }); chatRouter.get("/sessions", async (req, res) => { const query = sessionListQuerySchema.safeParse(req.query); if (!query.success) { res.status(400).json({ message: "invalid session filter", detail: query.error.flatten() }); return; } const authContext = getLocalAgentContext(req); const projectId = authContext.projectId; const userId = authContext.userId; const actorKey = authContext.actorKey; const projectKey = authContext.projectKey; const records = await sessionMetadataStore.list({ actorKey, projectId, projectKey, userId, }, { purpose: query.data.purpose ?? "ordinary_chat", taskId: query.data.task_id, }); res.json({ sessions: records.map((record) => ({ id: record.sessionId, title: record.title ?? "新对话", created_at: record.createdAt, updated_at: record.updatedAt, status: record.status, parent_session_id: record.parentSessionId, is_streaming: activeRuns.get(record.sessionId)?.status === "running", run_status: getSessionRunStatus(record.sessionId), purpose: readPurpose(record), task_id: record.taskId, trigger: record.triggerWorkflow === "water_quality_complaint" ? { subject_id: record.triggerSubjectId, workflow: record.triggerWorkflow, occurred_at: record.triggerOccurredAt, } : undefined, })), }); }); chatRouter.get("/session/:session_id", async (req, res) => { const sessionId = req.params.session_id?.trim(); const authContext = getLocalAgentContext(req); const projectId = authContext.projectId; const userId = authContext.userId; const actorKey = authContext.actorKey; const projectKey = authContext.projectKey; if (!sessionId) { res.status(400).json({ message: "session_id is required" }); return; } const sessionRecord = await sessionMetadataStore.get( { actorKey, projectId, projectKey, userId, }, sessionId, ); if (!sessionRecord) { res.status(404).json({ message: "session not found" }); return; } const state = await sessionUiStateStore.read( toSessionUiStateContext(sessionRecord), ); res.json({ id: sessionRecord.sessionId, title: sessionRecord.title ?? "新对话", is_title_manually_edited: state?.isTitleManuallyEdited ?? false, created_at: sessionRecord.createdAt, updated_at: sessionRecord.updatedAt, status: sessionRecord.status, session_id: sessionRecord.sessionId, messages: state?.messages ?? [], execution_plans: state?.executionPlans ?? [], parent_session_id: sessionRecord.parentSessionId, is_streaming: activeRuns.get(sessionRecord.sessionId)?.status === "running", run_status: getSessionRunStatus(sessionRecord.sessionId), purpose: readPurpose(sessionRecord), task_id: sessionRecord.taskId, }); }); chatRouter.get("/session/:session_id/stream", async (req, res) => { const sessionId = req.params.session_id?.trim(); const authContext = getLocalAgentContext(req); const projectId = authContext.projectId; const userId = authContext.userId; const actorKey = authContext.actorKey; const projectKey = authContext.projectKey; if (!sessionId) { res.status(400).json({ message: "session_id is required" }); return; } const sessionRecord = await sessionMetadataStore.get( { actorKey, projectId, projectKey, userId }, sessionId, ); if (!sessionRecord) { res.status(404).json({ message: "session not found" }); return; } res.status(200); res.setHeader("Content-Type", "text/event-stream; charset=utf-8"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("Connection", "keep-alive"); res.setHeader("X-Accel-Buffering", "no"); res.flushHeaders?.(); const run = activeRuns.get(sessionRecord.sessionId); const state = await sessionUiStateStore.read(toSessionUiStateContext(sessionRecord)); res.write( toSse("state", { session_id: sessionRecord.sessionId, messages: readPurpose(sessionRecord) === "task_collaboration" ? state?.messages ?? [] : run?.messages ?? state?.messages ?? [], execution_plans: run ? undefined : state?.executionPlans, is_streaming: run?.status === "running", run_status: getSessionRunStatus(sessionRecord.sessionId), }), ); if (!run || run.status !== "running") { res.end(); return; } const subscriber: StreamSubscriber = { write: (event, data) => { if (!res.writableEnded && !res.destroyed) { res.write(toSse(event, data)); } }, close: () => { if (!res.writableEnded && !res.destroyed) { res.end(); } }, }; run.subscribers.add(subscriber); const cleanup = () => { run.subscribers.delete(subscriber); }; req.on("close", cleanup); res.on("close", cleanup); }); chatRouter.patch("/session/:session_id/title", async (req, res) => { const sessionId = req.params.session_id?.trim(); const title = typeof req.body?.title === "string" ? req.body.title.trim() : ""; const isTitleManuallyEdited = typeof req.body?.is_title_manually_edited === "boolean" ? req.body.is_title_manually_edited : undefined; const authContext = getLocalAgentContext(req); const projectId = authContext.projectId; const userId = authContext.userId; const actorKey = authContext.actorKey; const projectKey = authContext.projectKey; if (!sessionId || !title) { res.status(400).json({ message: "session_id and title are required" }); return; } const sessionRecord = await sessionMetadataStore.get( { actorKey, projectId, projectKey, userId }, sessionId, ); if (!sessionRecord) { res.status(404).json({ message: "session not found" }); return; } const nextSessionRecord = await sessionMetadataStore.touch(sessionRecord, { title, }); const state = await sessionUiStateStore.read( toSessionUiStateContext(nextSessionRecord), ); if (state) { await sessionUiStateStore.write( toSessionUiStateContext(nextSessionRecord), { ...state, isTitleManuallyEdited: isTitleManuallyEdited ?? state.isTitleManuallyEdited, }, ); } res.json({ id: nextSessionRecord.sessionId, title: nextSessionRecord.title, updated_at: nextSessionRecord.updatedAt, }); }); chatRouter.delete("/session/:session_id", async (req, res) => { const sessionId = req.params.session_id?.trim(); const authContext = getLocalAgentContext(req); const projectId = authContext.projectId; const userId = authContext.userId; const actorKey = authContext.actorKey; const projectKey = authContext.projectKey; if (!sessionId) { res.status(400).json({ message: "session_id is required" }); return; } const sessionRecord = await sessionMetadataStore.get( { actorKey, projectId, projectKey, userId }, sessionId, ); if (!sessionRecord) { res.status(204).end(); return; } await sessionUiStateStore.remove(toSessionUiStateContext(sessionRecord)); await sessionBridge.deleteSession({ clientSessionId: sessionRecord.sessionId, sessionId: sessionRecord.sessionId, }); frontendActionCoordinator.cancelSession(sessionRecord.sessionId); activeRuns.delete(sessionRecord.sessionId); lastRunStatuses.delete(sessionRecord.sessionId); await sessionMetadataStore.remove(sessionRecord); res.status(204).end(); }); chatRouter.post("/fork", async (req, res) => { const parsed = forkPayloadSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ message: "invalid request payload", detail: parsed.error.flatten(), }); return; } try { const authContext = getLocalAgentContext(req); const projectId = authContext.projectId; const traceId = req.header("x-trace-id") ?? undefined; const userId = authContext.userId; const actorKey = authContext.actorKey; const projectKey = authContext.projectKey; const sourceSessionId = parsed.data.session_id?.trim(); const sourceSessionRecord = sourceSessionId ? await sessionMetadataStore.get( { actorKey, projectId, projectKey, userId, }, sourceSessionId, ) : null; if (sourceSessionRecord && readPurpose(sourceSessionRecord) !== "ordinary_chat") { res.status(409).json({ message: "task sessions cannot be forked into ordinary chat history" }); return; } const forkSession = await runtime.createSession(); const { record: targetSessionRecord } = await sessionMetadataStore.ensure({ actorKey, parentSessionId: sourceSessionId, projectId, projectKey, sessionId: forkSession.id, userId, }); const nextSessionId = targetSessionRecord.sessionId; if (sourceSessionId) { await sessionTranscriptStore.cloneThread( { actorKey, clientSessionId: sourceSessionId, projectKey, sessionId: sourceSessionId, }, { actorKey, clientSessionId: nextSessionId, projectKey, sessionId: nextSessionId, }, parsed.data.keep_message_count, ); } const sourceState = sourceSessionRecord ? await sessionUiStateStore.read( toSessionUiStateContext(sourceSessionRecord), ) : null; const forkTitle = sourceSessionRecord?.title ? `${sourceSessionRecord.title} 副本` : "新对话副本"; const titledTargetSessionRecord = await sessionMetadataStore.touch( targetSessionRecord, { title: forkTitle }, ); await sessionUiStateStore.write( toSessionUiStateContext(titledTargetSessionRecord), buildForkedSessionUiState(sourceState, { keepMessageCount: parsed.data.keep_message_count, targetSessionId: nextSessionId, }), ); logger.info( { sourceSessionId: parsed.data.session_id, sessionId: nextSessionId, traceId, projectId, keepMessageCount: parsed.data.keep_message_count, }, "forked chat session", ); res.status(200).json({ session_id: nextSessionId, }); } catch (error) { const detail = error instanceof Error ? error.message : String(error); logger.error({ err: error }, "chat fork failed"); res.status(500).json({ message: "chat fork failed", detail, }); } }); };