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
+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,