feat: add scoped history and artifacts
Generic Container CI/CD / test-build-publish (push) Failing after 8m25s
Agent CI/CD / build-test-publish-and-deploy (push) Failing after 8m25s

This commit is contained in:
2026-10-22 18:40:20 +08:00
parent bc4e9834e5
commit 7d47a2df86
48 changed files with 2632 additions and 78 deletions
+35
View File
@@ -0,0 +1,35 @@
import { constants } from "node:fs";
import { open } from "node:fs/promises";
import type { Router } from "express";
import { getLocalAgentContext } from "../context/localContext.js";
import { artifactFilename, openArtifactDirectory } from "../mcp/artifact-files.js";
import type { SessionMetadataStore } from "../sessions/metadataStore.js";
import type { OpencodeRuntimeAdapter } from "../runtime/opencode.js";
export function registerArtifactRoutes(router: Router, options: {
sessionMetadataStore: SessionMetadataStore;
runtime: Pick<OpencodeRuntimeAdapter, "ensureSessionWorkspace">;
}) {
router.get("/session/:session_id/files/:filename", async (req, res) => {
const filename = req.params.filename;
if (!artifactFilename.test(filename)) { res.status(404).end(); return; }
try {
const record = await options.sessionMetadataStore.get(getLocalAgentContext(req), req.params.session_id);
if (!record) { res.status(404).end(); return; }
const workspace = await options.runtime.ensureSessionWorkspace(record.sessionId, record);
const directory = await openArtifactDirectory(workspace, record.sessionId);
try {
const file = await open(`/proc/self/fd/${directory.fd}/${filename}`, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const stat = await file.stat();
if (!stat.isFile() || stat.size > 5 * 1024 * 1024) { res.status(404).end(); return; }
res.setHeader("Content-Type", filename.endsWith(".zip") ? "application/zip" : "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
res.setHeader("Cache-Control", "private, no-store");
res.setHeader("X-Content-Type-Options", "nosniff");
res.send(await file.readFile());
} finally { await file.close(); }
} finally { await directory.close(); }
} catch { res.status(404).end(); }
});
}
+43
View File
@@ -0,0 +1,43 @@
import type { Router } from "express";
import { z } from "zod";
import { getLocalAgentContext } from "../context/localContext.js";
import { readPurpose, type SessionMetadataStore } from "../sessions/metadataStore.js";
const nameSchema = z.object({ name: z.string().trim().min(1).max(80) }).strict();
const membershipSchema = z.object({ topic_id: z.string().uuid().nullable() }).strict();
export function registerChatTopicRoutes(router: Router, sessions: SessionMetadataStore) {
router.get("/topics", async (req, res) => {
res.json({ topics: await sessions.topics.list(getLocalAgentContext(req)) });
});
router.post("/topics", async (req, res) => {
const parsed = nameSchema.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ message: "主题名称须为 1 至 80 个字符" }); return; }
res.status(201).json(await sessions.topics.create(getLocalAgentContext(req), parsed.data.name));
});
router.patch("/topics/:topic_id", async (req, res) => {
const parsed = nameSchema.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ message: "主题名称须为 1 至 80 个字符" }); return; }
const topic = await sessions.topics.rename(getLocalAgentContext(req), req.params.topic_id, parsed.data.name);
if (!topic) { res.status(404).json({ message: "主题不存在" }); return; }
res.json(topic);
});
router.delete("/topics/:topic_id", async (req, res) => {
// Removing a grouping never deletes conversations. Old IDs resolve as unclassified.
const removed = await sessions.topics.remove(getLocalAgentContext(req), req.params.topic_id);
if (!removed) { res.status(404).json({ message: "主题不存在" }); return; }
res.status(204).end();
});
router.patch("/session/:session_id/topic", async (req, res) => {
const parsed = membershipSchema.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ message: "invalid topic membership" }); return; }
const context = getLocalAgentContext(req);
const record = await sessions.get(context, req.params.session_id);
if (!record || readPurpose(record) === "task_execution") { res.status(404).json({ message: "session not found" }); return; }
if (parsed.data.topic_id && !await sessions.topics.get(context, parsed.data.topic_id)) {
res.status(404).json({ message: "主题不存在" }); return;
}
await sessions.touch(record, { topicId: parsed.data.topic_id });
res.json({ session_id: record.sessionId, topic_id: parsed.data.topic_id });
});
}
+2
View File
@@ -27,6 +27,7 @@ import { registerResponseInputRoutes } from "./response-input-routes.js";
import { registerMcpStatusRoutes } from "./mcpRoutes.js";
import { registerChatSuggestionRoutes } from "./chatSuggestionRoutes.js";
import { registerAgentMessageRoutes } from "./agent-message-routes.js";
import { registerArtifactRoutes } from "./artifact-routes.js";
import type { AgentMessageCoordinator } from "../chat/agent-message-coordinator.js";
export type BuildChatRouterOptions = {
@@ -76,6 +77,7 @@ export const buildChatRouter = (options: BuildChatRouterOptions) => {
models: agentModelOptions,
});
});
registerArtifactRoutes(chatRouter, { sessionMetadataStore, runtime });
chatRouter.get("/ui-registry", (_req, res) => {
res.json(uiRegistryResponse);
+95 -7
View File
@@ -1,3 +1,5 @@
import { registerChatTopicRoutes } from "./chat-topic-routes.js";
import { matchHistoryText } from "../sessions/history-search.js";
import { credentialRefreshCoordinator } from "../product/credential-refresh.js";
import { type Router } from "express";
import { z } from "zod";
@@ -21,6 +23,7 @@ import {
} from "../sessions/metadataStore.js";
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import { HistoryCursorError, historyScope, sessionHistoryPage, sessionMessagePage } from "../sessions/history-page.js";
import { type LearningOrchestrator } from "../learning/orchestrator.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import { type ChatRunState } from "./chatRunState.js";
@@ -38,6 +41,7 @@ const createSessionPayloadSchema = z.object({
session_id: z.string().max(128).optional(),
parent_session_id: z.string().max(128).optional(),
trigger_context: agentTriggerContextSchema.optional(),
topic_id: z.string().uuid().optional(),
});
const taskExecutionPayloadSchema = z.object({
@@ -50,6 +54,19 @@ const taskExecutionPayloadSchema = z.object({
const sessionListQuerySchema = z.object({
purpose: z.enum(["ordinary_chat", "task_execution", "task_collaboration"]).optional(),
task_id: z.string().uuid().optional(),
include_collaboration: z.enum(["true", "false"]).optional(),
q: z.string().trim().max(200).optional(),
topic_id: z.union([z.string().uuid(), z.literal("unclassified")]).optional(),
limit: z.coerce.number().int().min(1).max(100).optional(),
cursor: z.string().max(2048).optional(),
});
const messageQuerySchema = z.object({
message_limit: z.coerce.number().int().min(1).max(100).optional(),
});
const olderMessagesQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(40),
before: z.string().max(2048).optional(),
});
const forkPayloadSchema = z.object({
@@ -91,6 +108,7 @@ export const registerChatSessionRoutes = (
sessionTranscriptStore,
sessionUiStateStore,
} = options;
registerChatTopicRoutes(chatRouter, sessionMetadataStore);
const { activeRuns, lastRunStatuses } = runState;
const getSessionRunStatus = runState.getStatus;
@@ -191,6 +209,9 @@ export const registerChatSessionRoutes = (
const userId = authContext.userId;
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
if (parsed.data.topic_id && !await sessionMetadataStore.topics.get(authContext, parsed.data.topic_id)) {
res.status(404).json({ message: "主题不存在" }); return;
}
const trigger = parsed.data.trigger_context;
if (trigger && !projectId) {
res.status(403).json({ code: "PROJECT_CONTEXT_REQUIRED", message: "任务会话必须关联项目" });
@@ -244,6 +265,7 @@ export const registerChatSessionRoutes = (
const { record, created } = await sessionMetadataStore.ensure({
actorKey,
parentSessionId: parsed.data.parent_session_id,
topicId: parsed.data.topic_id,
projectId,
projectKey,
sessionId,
@@ -267,6 +289,7 @@ export const registerChatSessionRoutes = (
status: record.status,
title: record.title,
parent_session_id: record.parentSessionId,
topic_id: record.topicId ?? null,
created,
event_duplicate: false,
trigger_id: record.triggerId,
@@ -460,19 +483,52 @@ export const registerChatSessionRoutes = (
const userId = authContext.userId;
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
const records = await sessionMetadataStore.list({
const includeCollaboration = query.data.include_collaboration === "true";
let records = await sessionMetadataStore.list({
actorKey,
projectId,
projectKey,
userId,
}, {
purpose: query.data.purpose ?? "ordinary_chat",
purpose: includeCollaboration ? undefined : query.data.purpose ?? "ordinary_chat",
taskId: query.data.task_id,
});
if (includeCollaboration) records = records.filter((record) => ["ordinary_chat", "task_collaboration"].includes(readPurpose(record)));
const topics = new Map((await sessionMetadataStore.topics.list(authContext)).map((topic) => [topic.id, topic]));
const topicId = query.data.topic_id;
if (topicId && topicId !== "unclassified" && !topics.has(topicId)) {
res.status(404).json({ message: "主题不存在" }); return;
}
records = records.filter((record) => !topicId || (topicId === "unclassified"
? !record.topicId || !topics.has(record.topicId) : record.topicId === topicId));
const searchable: Array<SessionRecord & { searchSnippet?: string }> = [];
for (const record of records) {
const titleMatches = (record.title ?? "新对话").toLocaleLowerCase().includes((query.data.q ?? "").toLocaleLowerCase());
const state = query.data.q && !titleMatches ? await sessionUiStateStore.read(toSessionUiStateContext(record)) : null;
const messages = activeRuns.get(record.sessionId)?.messages ?? state?.messages ?? [];
searchable.push({ ...record, searchSnippet: query.data.q && !titleMatches ? matchHistoryText(messages, query.data.q) : undefined });
}
let page;
try {
page = sessionHistoryPage(searchable, {
scope: historyScope([actorKey, projectKey, userId, query.data.purpose ?? "ordinary_chat", query.data.task_id, topicId, includeCollaboration]),
q: query.data.q, limit: query.data.limit ?? (query.data.q || query.data.cursor ? 30 : Math.max(records.length, 1)),
cursor: query.data.cursor,
});
} catch (error) {
if (!(error instanceof HistoryCursorError)) throw error;
res.status(400).json({ message: error.message });
return;
}
res.json({
sessions: records.map((record) => ({
has_more: page.has_more,
next_cursor: page.next_cursor,
sessions: page.sessions.map((record) => ({
id: record.sessionId,
title: record.title ?? "新对话",
topic_id: record.topicId && topics.has(record.topicId) ? record.topicId : null,
topic_name: record.topicId ? topics.get(record.topicId)?.name : undefined,
search_snippet: record.searchSnippet,
created_at: record.createdAt,
updated_at: record.updatedAt,
status: record.status,
@@ -490,7 +546,27 @@ export const registerChatSessionRoutes = (
});
});
chatRouter.get("/session/:session_id/messages", async (req, res) => {
const query = olderMessagesQuerySchema.safeParse(req.query);
if (!query.success) { res.status(400).json({ message: "invalid message pagination" }); return; }
const context = getLocalAgentContext(req);
const record = await sessionMetadataStore.get(context, req.params.session_id);
if (!record) { res.status(404).json({ message: "session not found" }); return; }
const state = await sessionUiStateStore.read(toSessionUiStateContext(record));
const messages = readPurpose(record) === "task_collaboration" ? state?.messages ?? []
: activeRuns.get(record.sessionId)?.messages ?? state?.messages ?? [];
try {
res.json(sessionMessagePage(messages, { scope: historyScope([record.actorKey, record.projectKey, record.sessionId]),
...query.data }));
} catch (error) {
if (!(error instanceof HistoryCursorError)) throw error;
res.status(400).json({ message: error.message });
}
});
chatRouter.get("/session/:session_id", async (req, res) => {
const query = messageQuerySchema.safeParse(req.query);
if (!query.success) { res.status(400).json({ message: "invalid message pagination" }); return; }
const sessionId = req.params.session_id?.trim();
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
@@ -519,15 +595,21 @@ export const registerChatSessionRoutes = (
const state = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord),
);
const messages = readPurpose(sessionRecord) === "task_collaboration" ? state?.messages ?? []
: activeRuns.get(sessionRecord.sessionId)?.messages ?? state?.messages ?? [];
const messagePayload = query.data.message_limit ? sessionMessagePage(messages, {
scope: historyScope([sessionRecord.actorKey, sessionRecord.projectKey, sessionId]), limit: query.data.message_limit,
}) : { messages };
res.json({
id: sessionRecord.sessionId,
title: sessionRecord.title ?? "新对话",
topic_id: sessionRecord.topicId && await sessionMetadataStore.topics.get(authContext, sessionRecord.topicId) ? sessionRecord.topicId : null,
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 ?? [],
...messagePayload,
execution_plans: state?.executionPlans ?? [],
parent_session_id: sessionRecord.parentSessionId,
is_streaming: activeRuns.get(sessionRecord.sessionId)?.status === "running",
@@ -538,6 +620,8 @@ export const registerChatSessionRoutes = (
});
chatRouter.get("/session/:session_id/stream", async (req, res) => {
const query = messageQuerySchema.safeParse(req.query);
if (!query.success) { res.status(400).json({ message: "invalid message pagination" }); return; }
const sessionId = req.params.session_id?.trim();
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
@@ -567,12 +651,15 @@ export const registerChatSessionRoutes = (
const run = activeRuns.get(sessionRecord.sessionId);
const state = await sessionUiStateStore.read(toSessionUiStateContext(sessionRecord));
const messages = readPurpose(sessionRecord) === "task_collaboration" ? state?.messages ?? []
: run?.messages ?? state?.messages ?? [];
const messagePayload = query.data.message_limit ? sessionMessagePage(messages, {
scope: historyScope([sessionRecord.actorKey, sessionRecord.projectKey, sessionId]), limit: query.data.message_limit,
}) : { messages };
res.write(
toSse("state", {
session_id: sessionRecord.sessionId,
messages: readPurpose(sessionRecord) === "task_collaboration"
? state?.messages ?? []
: run?.messages ?? state?.messages ?? [],
...messagePayload,
execution_plans: run ? undefined : state?.executionPlans,
is_streaming: run?.status === "running",
run_status: getSessionRunStatus(sessionRecord.sessionId),
@@ -740,6 +827,7 @@ export const registerChatSessionRoutes = (
const { record: targetSessionRecord } = await sessionMetadataStore.ensure({
actorKey,
parentSessionId: sourceSessionId,
topicId: sourceSessionRecord?.topicId ?? undefined,
projectId,
projectKey,
sessionId: forkSession.id,
+11 -3
View File
@@ -181,8 +181,8 @@ const toLegacyMessages = (messages: UIMessage[]) =>
];
});
const createFallbackUserUiMessage = (content: string): UIMessage => ({
id: `user-${Date.now().toString(36)}`,
const createFallbackUserUiMessage = (content: string, id?: string): UIMessage => ({
id: id ?? `user-${randomUUID()}`,
role: "user",
parts: [{ type: "text", text: content }],
});
@@ -326,6 +326,9 @@ export const registerChatStreamRoute = (
});
return;
}
if (input.tjwater.topic_id && !await sessionMetadataStore.topics.get(authContext, input.tjwater.topic_id)) {
res.status(404).json({ message: "主题不存在" }); return;
}
const requestedSessionId = resolveRequestedStreamSessionId({
sessionId: parsed.data.session_id,
promptText,
@@ -403,6 +406,7 @@ export const registerChatStreamRoute = (
projectId,
projectKey,
sessionId: binding.sessionId,
topicId: existingSessionRecord?.topicId ?? input.tjwater.topic_id,
userId,
});
const activeSessionRecord = await sessionMetadataStore.touch(ensuredSessionRecord);
@@ -454,6 +458,10 @@ export const registerChatStreamRoute = (
toSessionUiStateContext(activeSessionRecord),
);
const persistedUiMessages = toUiMessages(initialSessionState?.messages);
if (input.tjwater.client_message_id && persistedUiMessages.some((message) => message.id === input.tjwater.client_message_id)) {
res.status(409).json({ message: "client_message_id already exists in this conversation" });
return;
}
let executionPlanHistory = initialSessionState?.executionPlans ?? readExecutionPlans(initialSessionState?.messages ?? []);
const visiblePersistedMessages = sessionPurpose === "task_collaboration"
? toCollaborationMessages(persistedUiMessages)
@@ -466,7 +474,7 @@ export const registerChatStreamRoute = (
? visibleRequestMessages
: [
...visiblePersistedMessages,
createFallbackUserUiMessage(promptText),
createFallbackUserUiMessage(promptText, input.tjwater.client_message_id),
];
let latestUiMessages = requestMessages;
const baseMessages = toLegacyMessages(requestMessages);
+3 -2
View File
@@ -6,6 +6,7 @@ import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
import { type McpRuntimeReport, type McpSessionPolicy } from "../runtime/mcp-config.js";
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
import { allowedQueryTools } from "../runtime/query-mcp-policy.js";
import { PRODUCT_MCP_WRITE_TOOL_IDS } from "../runtime/product-mcp-tools.js";
import type { QueryOwner } from "../product/mcp-query-client.js";
export function registerMcpPolicyBridge(app: Express, options: {
@@ -24,8 +25,8 @@ export function registerMcpPolicyBridge(app: Express, options: {
const grants = new Set(readonlyTools);
const policy: McpSessionPolicy = {
mode: context.learningMode === "review" ? "disabled" : !direct || context.purpose === "task_execution" ? "readonly" : "interactive",
readonly_tools: readonlyTools,
query_tools: allowedQueryTools(context, !direct).filter((name) => grants.has(name)),
readonly_tools: readonlyTools.filter((name) => !PRODUCT_MCP_WRITE_TOOL_IDS.includes(name)),
query_tools: allowedQueryTools(context, !direct).filter((name) => grants.has(name) || PRODUCT_MCP_WRITE_TOOL_IDS.includes(name)),
};
res.json(policy);
});
+7 -1
View File
@@ -11,7 +11,7 @@ const update = z.object({ delivery: z.enum(["steer", "queue"]) }).strict();
export function registerResponseInputRoutes(router: Router, options: {
runtime: OpencodeRuntimeAdapter; sessionMetadataStore: SessionMetadataStore; runState: ChatRunState;
}) {
for (const method of ["get", "patch"] as const) {
for (const method of ["get", "patch", "delete"] as const) {
router[method](method === "get" ? "/session/:session_id/inputs" : "/session/:session_id/inputs/:input_id", async (req, res) => {
const sessionId = String(req.params.session_id);
try {
@@ -22,6 +22,12 @@ export function registerResponseInputRoutes(router: Router, options: {
return;
}
const inputId = id.parse((req.params as Record<string, string>).input_id);
if (method === "delete") {
const input = await options.runtime.cancelPendingUserInput(sessionId, inputId);
options.runState.activeRuns.get(sessionId)?.inputs?.observe("session.inbox.cancelled", { inboxID: inputId });
res.json(input);
return;
}
const { delivery } = update.parse(req.body);
const input = await options.runtime.updatePendingUserInput(sessionId, inputId, delivery);
options.runState.activeRuns.get(sessionId)?.inputs?.observe("session.inbox.delivery.changed", { inboxID: inputId, delivery });