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
+2
View File
@@ -37,6 +37,8 @@ export const answerUiSpecSchema = z.object({ root: key, elements: z.record(z.obj
});
export const answerUiInputSchema = z.object({ block_id: key, fallback_text: z.string().min(1).max(4000), spec: answerUiSpecSchema }).strict();
export const answerUiBlockSchema = answerUiInputSchema.extend({ version: z.literal("answer-ui@1") })
.refine((block) => JSON.stringify(block).length <= 100000, "界面块过大");
export const parseAnswerUiInput = (value: unknown) => {
const parsed = answerUiInputSchema.safeParse(value);
return parsed.success ? { version: "answer-ui@1" as const, ...parsed.data } : null;
+4 -3
View File
@@ -1,5 +1,5 @@
export type InputDelivery = "steer" | "queue";
export type ResponseInput = { id: string; text: string; delivery: InputDelivery; status: "pending" | "delivered" };
export type ResponseInput = { id: string; text: string; delivery: InputDelivery; status: "pending" | "delivered" | "cancelled" };
type InboxRuntime = {
enqueueUserInput: (sessionId: string, input: ResponseInput) => Promise<void>;
listPendingUserInputs: (sessionId: string) => Promise<ResponseInput[]>;
@@ -19,7 +19,7 @@ export class ResponseInputTracker {
markReady() { this.releaseReady(); }
close() { this.accepting = false; this.releaseReady(); }
get hasAdditionalInput() { return this.items.size > 0; }
get hasAdditionalInput() { return [...this.items.values()].some((input) => input.status !== "cancelled"); }
async restore() {
for (const input of await this.runtime.listPendingUserInputs(this.sessionId)) this.items.set(input.id, input);
@@ -53,8 +53,9 @@ export class ResponseInputTracker {
observe(type: string, data: { inboxID?: string; delivery?: InputDelivery }) {
const input = data.inboxID ? this.items.get(data.inboxID) : undefined;
if (!input) return;
if (!input || input.status !== "pending") return;
if (type === "session.inbox.delivered") input.status = "delivered";
else if (type === "session.inbox.cancelled") input.status = "cancelled";
else if (type === "session.inbox.delivery.changed" && data.delivery) input.delivery = data.delivery;
else return;
this.changed({ ...input });
+2
View File
@@ -18,6 +18,8 @@ export const responseRequestSchema = z.object({
conversation: z.union([z.string().min(1).max(128), z.object({ id: z.string().min(1).max(128) }).strict()]).optional(),
metadata: z.record(z.string().max(64), z.string().max(512)).refine((value) => Object.keys(value).length <= 16).optional(),
tjwater: z.object({
topic_id: z.string().uuid().optional(),
client_message_id: z.string().min(1).max(128).optional(),
delivery: z.enum(["steer", "queue"]).optional(),
input_id: z.string().regex(/^msg_[a-f0-9]{32}$/).optional(),
approval_mode: z.enum(["request", "always"]).default("request"),
+326 -15
View File
@@ -3318,8 +3318,8 @@ export interface paths {
get?: never;
put?: never;
/**
* Search Fengxian places
* @description 调用天地图地名搜索,固定上海市奉贤区范围,只返回前 10 个可定位候选,不提供分页。
* Search project places
* @description 调用天地图地名搜索,按当前项目 map_config.place_search 的行政区范围,只返回前 10 个可定位候选,不提供分页。
*/
post: operations["post_place_search_requests"];
delete?: never;
@@ -3918,6 +3918,26 @@ export interface paths {
patch: operations["patch_reservoirs_y"];
trace?: never;
};
"/api/v1/reverse-geocoding-requests": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Reverse geocode a project map point
* @description 查询 EPSG:4326 地图取点的地名和地址,复用当前项目的地名搜索配置并保留输入坐标。
*/
post: operations["post_reverse_geocoding_requests"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/rule-properties": {
parameters: {
query?: never;
@@ -5056,6 +5076,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/timeseries/scada-readings/latest": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* 获取SCADA设备最新数据
* @description Read one latest persisted row per project device without applying a time window.
*/
get: operations["get_timeseries_scada_readings_latest"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/timeseries/scada-readings/{device_id}/field": {
parameters: {
query?: never;
@@ -5687,6 +5727,7 @@ export interface components {
description?: string | null;
/** Gs Workspace */
gs_workspace: string;
map_config?: components["schemas"]["ProjectMapConfig"];
/** Map Extent */
map_extent?: {
[key: string]: unknown;
@@ -5713,6 +5754,7 @@ export interface components {
description?: string | null;
/** Gs Workspace */
gs_workspace: string;
map_config?: components["schemas"]["ProjectMapConfig"];
/** Map Extent */
map_extent?: {
[key: string]: unknown;
@@ -5740,6 +5782,7 @@ export interface components {
description?: string | null;
/** Gs Workspace */
gs_workspace?: string | null;
map_config?: components["schemas"]["ProjectMapConfig"] | null;
/** Map Extent */
map_extent?: {
[key: string]: unknown;
@@ -5992,6 +6035,39 @@ export interface components {
selected_report?: components["schemas"]["InvestigationReportRevision"] | null;
task: components["schemas"]["OperationalTaskDetail"];
};
/** AnswerUiBlock */
AnswerUiBlock: {
/** Block Id */
block_id: string;
/** Fallback Text */
fallback_text: string;
spec: components["schemas"]["AnswerUiSpec"];
/**
* Version
* @constant
*/
version: "answer-ui@1";
};
/** AnswerUiElement */
AnswerUiElement: {
/** Children */
children: string[];
/** Props */
props: {
[key: string]: unknown;
};
/** Type */
type: string;
};
/** AnswerUiSpec */
AnswerUiSpec: {
/** Elements */
elements: {
[key: string]: components["schemas"]["AnswerUiElement"];
};
/** Root */
root: string;
};
/**
* AuditLogResponse
* @description 审计日志响应
@@ -6914,6 +6990,8 @@ export interface components {
stop_reason?: string | null;
/** Title */
title: string;
/** Ui Blocks */
ui_blocks?: components["schemas"]["ReportUiBlock"][];
};
/**
* InvestigationReportInput
@@ -6947,6 +7025,8 @@ export interface components {
stop_reason?: string | null;
/** Title */
title: string;
/** Ui Blocks */
ui_blocks?: components["schemas"]["ReportUiBlock"][];
};
/** InvestigationReportPage */
InvestigationReportPage: {
@@ -7759,18 +7839,32 @@ export interface components {
/** Total */
total: number;
};
/** PlaceSearchConfig */
PlaceSearchConfig: {
/** Admin Code */
admin_code: string;
/**
* Enabled
* @default false
*/
enabled: boolean;
/**
* Provider
* @default tianditu
* @constant
*/
provider: "tianditu";
/** Region */
region: string;
};
/** PlaceSearchItem */
PlaceSearchItem: {
/** Address */
address: string;
/** Category */
category?: string | null;
/**
* District
* @default 奉贤区
* @constant
*/
district: "奉贤区";
/** District */
district: string;
/** Id */
id: string;
/** Latitude */
@@ -7789,12 +7883,8 @@ export interface components {
PlaceSearchResponse: {
/** Items */
items: components["schemas"]["PlaceSearchItem"][];
/**
* Region
* @default 奉贤区
* @constant
*/
region: "奉贤区";
/** Region */
region: string;
/** Total */
total: number;
/**
@@ -7938,6 +8028,12 @@ export interface components {
*/
pool_min_size: number;
};
/** ProjectMapConfig */
ProjectMapConfig: {
place_search?: components["schemas"]["PlaceSearchConfig"] | null;
} & {
[key: string]: unknown;
};
/** ProjectMemberCreateRequest */
ProjectMemberCreateRequest: {
/**
@@ -7994,6 +8090,7 @@ export interface components {
description?: string | null;
/** Gs Workspace */
gs_workspace: string;
map_config?: components["schemas"]["ProjectMapConfig"];
/** Map Extent */
map_extent?: {
[key: string]: unknown;
@@ -8110,6 +8207,19 @@ export interface components {
/** Task Ids */
task_ids: string[];
};
/** ReportUiBlock */
ReportUiBlock: {
block: components["schemas"]["AnswerUiBlock"];
/** Evidence Refs */
evidence_refs: string[];
};
/** ReverseGeocodingRequest */
ReverseGeocodingRequest: {
/** Latitude */
latitude: number;
/** Longitude */
longitude: number;
};
/** RunSimulationManuallyByDateRest */
RunSimulationManuallyByDateRest: {
/**
@@ -8155,6 +8265,46 @@ export interface components {
/** Y */
y?: number | null;
};
/**
* ScadaLatestReadingResponse
* @description Latest persisted observation for one project SCADA device.
*/
ScadaLatestReadingResponse: {
/** Cleaned Value */
cleaned_value?: number | null;
/** Device Id */
device_id: string;
/** Device Type */
device_type: string;
/** Measurement Unit */
measurement_unit: string;
/** Monitored Value */
monitored_value?: number | null;
/** Observed At */
observed_at?: string | null;
/** Transmission Frequency */
transmission_frequency: string;
/** Transmission Mode */
transmission_mode: string;
};
/**
* ScadaLatestReadingsResponse
* @description Bounded device page with each device's latest persisted observation.
*/
ScadaLatestReadingsResponse: {
/** Items */
items: components["schemas"]["ScadaLatestReadingResponse"][];
/** Latest Observed At */
latest_observed_at?: string | null;
/** Limit */
limit: number;
/** Observed Count */
observed_count: number;
/** Offset */
offset: number;
/** Total */
total: number;
};
/** ScadaReadingBatchItem */
ScadaReadingBatchItem: {
/** Cleaned Value */
@@ -8494,7 +8644,7 @@ export interface components {
*/
queried_at: string;
/** Source */
source: ("workbench_runtime" | "scada_devices" | "scada_readings" | "peripheral_points" | "peripheral_work_orders" | "peripheral_observations" | "peripheral_test_data") | ("run_simulation" | "run_contaminant_simulation" | "analyze_valve_closure" | "analyze_flushing" | "analyze_pressure_regulation" | "optimize_sensor_placement" | "run_burst_detection" | "run_burst_location" | "analyze_valve_isolation" | "run_leakage_identification" | "get_element_properties" | "list_elements" | "get_network_options" | "get_network_schema" | "list_scada_devices" | "get_scada_device" | "query_scada_readings" | "query_model_baseline_series" | "get_model_baseline_at_time" | "query_model_baseline_field" | "query_analysis_series" | "query_analysis_values" | "list_analysis_runs" | "get_analysis_run" | "list_analysis_results" | "list_sensor_placement_runs" | "get_sensor_placement_run" | "query_element_history" | "get_element_gis_properties" | "get_source_gis_properties" | "get_workbench_runtime" | "get_task_context" | "query_peripheral_data" | "get_task_evidence");
source: ("workbench_runtime" | "scada_devices" | "scada_readings" | "peripheral_points" | "peripheral_work_orders" | "peripheral_observations" | "peripheral_test_data") | ("get_mutation_status" | "query_nearby_elements" | "list_regions" | "get_region" | "get_region_nodes" | "get_node_connections" | "query_network_extent" | "list_major_nodes" | "get_model_configuration" | "list_model_configurations" | "search_project_places" | "reverse_geocode" | "get_project_info" | "export_project_model" | "get_sensor_placement_candidate" | "export_sensor_placement" | "list_task_schedules" | "get_task_run_history" | "calculate_demand_allocation" | "run_burst_simulation" | "edit_model_element" | "edit_model_configuration" | "clean_scada_readings" | "write_scada_readings" | "delete_scada_readings" | "save_simulation_scenario" | "create_sensor_placement" | "update_sensor_placement" | "create_operational_task" | "create_task_schedule" | "update_task_schedule" | "retry_operational_task" | "cancel_task_run" | "run_simulation" | "run_contaminant_simulation" | "analyze_valve_closure" | "analyze_flushing" | "analyze_pressure_regulation" | "optimize_sensor_placement" | "run_burst_detection" | "run_burst_location" | "analyze_valve_isolation" | "run_leakage_identification" | "get_element_properties" | "list_elements" | "get_network_options" | "get_network_schema" | "list_scada_devices" | "get_scada_device" | "query_scada_readings" | "query_model_baseline_series" | "get_model_baseline_at_time" | "query_model_baseline_field" | "query_analysis_series" | "query_analysis_values" | "list_analysis_runs" | "get_analysis_run" | "list_analysis_results" | "list_sensor_placement_runs" | "get_sensor_placement_run" | "query_element_history" | "get_element_gis_properties" | "get_source_gis_properties" | "get_workbench_runtime" | "get_task_context" | "query_peripheral_data" | "get_task_evidence");
/** Source Ref */
source_ref: string;
/** Truncated */
@@ -29690,6 +29840,86 @@ export interface operations {
};
};
};
post_reverse_geocoding_requests: {
parameters: {
query?: never;
header: {
"X-Project-Id": string;
};
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ReverseGeocodingRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PlaceSearchItem"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Insufficient permission */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Resource conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Validation error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Dependency unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
};
};
get_rule_properties: {
parameters: {
query?: never;
@@ -35600,6 +35830,87 @@ export interface operations {
};
};
};
get_timeseries_scada_readings_latest: {
parameters: {
query?: {
/** @description 返回设备数上限 */
limit?: number;
/** @description 设备目录偏移量 */
offset?: number;
};
header: {
"X-Project-Id": string;
};
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ScadaLatestReadingsResponse"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Insufficient permission */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Resource conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Validation error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Dependency unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
};
};
patch_timeseries_scada_readings_device_id_field: {
parameters: {
query: {
+50
View File
@@ -0,0 +1,50 @@
import { constants } from "node:fs";
import { open } from "node:fs/promises";
import { createHash, randomUUID } from "node:crypto";
import type { CallToolResult } from "@modelcontextprotocol/client";
import { config } from "../config.js";
import { isManagedConversationWorkspace, resolveConversationWorkspace, SANDBOX_UID, SANDBOX_GID } from "../runtime/conversationWorkspace.js";
import type { RuntimeSessionContext } from "../runtime/sessionContext.js";
const formats = new Map([
["application/zip", ".zip"],
["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".xlsx"],
]);
export const artifactFilename = /^mcp-[a-f0-9-]{36}\.(zip|xlsx)$/;
export async function openArtifactDirectory(workspace: string, sessionId: string) {
const canonical = await resolveConversationWorkspace(workspace, config.CONVERSATION_WORKSPACE_ROOT);
if (!isManagedConversationWorkspace(canonical, config.CONVERSATION_WORKSPACE_ROOT, sessionId)) throw new Error("Export workspace owner mismatch");
const root = await open(canonical, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
try { return await open(`/proc/self/fd/${root.fd}/files`, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); }
finally { await root.close(); }
}
export async function saveMcpArtifact(result: CallToolResult, context: RuntimeSessionContext, workspace: string): Promise<CallToolResult> {
if (result.isError) return result;
const output = result.structuredContent as Record<string, unknown> | undefined;
const data = output?.data as Record<string, unknown> | undefined;
const file = data?.file as Record<string, unknown> | undefined;
if (!file) throw new Error("Missing export file");
const extension = formats.get(String(file.mime_type));
if (!extension || file.encoding !== "base64" || typeof file.data !== "string" || file.data.length > 7 * 1024 * 1024) throw new Error("Invalid export format");
const bytes = Buffer.from(file.data, "base64");
if (!bytes.length || bytes.length > 5 * 1024 * 1024 || bytes.length !== file.size || bytes.toString("base64") !== file.data || createHash("sha256").update(bytes).digest("hex") !== file.sha256) throw new Error("Export integrity check failed");
const filename = `mcp-${randomUUID()}${extension}`;
const directory = await openArtifactDirectory(workspace, context.sessionId);
try {
const path = `/proc/self/fd/${directory.fd}/${filename}`;
const destination = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
try {
await destination.writeFile(bytes);
if (typeof process.getuid === "function" && process.getuid() === 0) await destination.chown(SANDBOX_UID, SANDBOX_GID);
}
finally { await destination.close(); }
} finally { await directory.close(); }
const url = `/api/v1/session/${encodeURIComponent(context.clientSessionId)}/files/${filename}`;
const next = { ...output, data: { file: { name: file.name, mime_type: file.mime_type, size: bytes.length,
sha256: file.sha256, relative_path: `files/${filename}`, download_url: url,
markdown: `[下载 ${extension === ".zip" ? "模型文件" : "监测点清单"}](${url})`,
} } };
return { ...result, structuredContent: next, content: [{ type: "text", text: JSON.stringify(next) }] };
}
+8 -1
View File
@@ -8,6 +8,7 @@ import { localQueryDescriptions, localQueryInputs } from "./local-query-tools.js
import { allowedQueryTools } from "../runtime/query-mcp-policy.js";
import { localEvidenceResult } from "./evidence-result.js";
import { nativeQueryTimeout, queryTimeouts } from "./query-timeouts.js";
import { saveMcpArtifact } from "./artifact-files.js";
export type LocalQueryName = typeof LOCAL_QUERY_TOOLS[number];
const productNames = new Set<string>(PRODUCT_MCP_TOOLS);
@@ -16,6 +17,7 @@ export function createQueryMcpHandler(options: {
internalToken: string; productClient: Pick<McpQueryClient, "query"> | null; productTools: Tool[];
resolveReader: (id: string) => Promise<QueryOwner | null>;
executeLocal: (name: LocalQueryName, args: Record<string, unknown>, context: RuntimeSessionContext, signal: AbortSignal) => Promise<unknown>;
artifactWorkspace?: (context: RuntimeSessionContext) => Promise<string>;
}) {
const definitions: Tool[] = [...options.productTools, ...LOCAL_QUERY_TOOLS.map((name) => ({ name, description: localQueryDescriptions[name],
inputSchema: z.toJSONSchema(localQueryInputs[name], { io: "input" }) as Tool["inputSchema"], annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: name === "geocode" } }))];
@@ -38,7 +40,12 @@ export function createQueryMcpHandler(options: {
// JSON-RPC IDs may restart on a native MCP reconnect. Use a host
// request key, stable across this client's auth/transport retries,
// so a later query cannot collide with a saved evidence receipt.
return localEvidenceResult(await options.productClient.query(id, owner, definition.name, query, randomUUID(), signal));
const result = await options.productClient.query(id, owner, definition.name, query, randomUUID(), signal);
if (["export_project_model", "export_sensor_placement"].includes(definition.name) && !result.isError) {
if (!options.artifactWorkspace) return rejected("Export workspace unavailable");
return await saveMcpArtifact(result, owner.context, await options.artifactWorkspace(owner.context));
}
return localEvidenceResult(result);
}
const value = await options.executeLocal(definition.name as LocalQueryName, args, owner.context, signal);
signal.throwIfAborted();
+15
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { supplyLayerSchema } from "./task-presentation.js";
import { answerUiBlockSchema } from "../chat/answer-ui.js";
const text = z.string().trim().min(1);
export const investigationDiagramSchema = z.object({
@@ -54,8 +55,22 @@ export const taskReportSchema = z.object({
stop_reason: text.nullable().default(null),
data_gaps: z.array(dataGapSchema),
diagram: investigationDiagramSchema.nullish(),
ui_blocks: z.array(z.object({
block: answerUiBlockSchema,
evidence_refs: z.array(text.max(128)).min(1).max(100),
}).strict()).max(8).optional(),
}).strict().superRefine((report, ctx) => {
const invalid = (message: string) => ctx.addIssue({ code: z.ZodIssueCode.custom, message });
const blocks = report.ui_blocks ?? [];
if (Buffer.byteLength(JSON.stringify(blocks), "utf8") > 400000) invalid("报告交互内容超过 400 KB");
if (new Set(blocks.map((item) => item.block.block_id)).size !== blocks.length) invalid("报告界面块编号重复");
for (const item of blocks) {
if (item.evidence_refs.some((ref) => !report.evidence_refs.includes(ref))) invalid("界面依据必须属于报告依据");
for (const node of Object.values(item.block.spec.elements)) {
if (node.type === "Evidence" && !item.evidence_refs.includes(String(node.props.evidence_id ?? "")))
invalid("证据组件必须引用界面块的已有依据");
}
}
if (new Set(report.data_gaps.map((gap) => gap.gap_id)).size !== report.data_gaps.length) invalid("数据缺口编号重复");
const blocking = report.data_gaps.some((gap) => gap.blocking);
if (report.outcome === "insufficient_data") {
+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 });
+19
View File
@@ -397,6 +397,25 @@ export class OpencodeRuntimeAdapter {
}
}
async cancelPendingUserInput(sessionId: string, id: string): Promise<ResponseInput> {
const item = (await this.listPendingUserInputs(sessionId)).find((input) => input.id === id);
if (!item) throw Object.assign(new Error("Message has already been consumed or is unavailable"), { status: 409 });
const client = await this.ensureClient();
try { await client.session.inbox.cancel({ sessionID: sessionId, inboxID: id }); }
catch (error) {
if (!(await this.listPendingUserInputs(sessionId)).some((input) => input.id === id)) {
throw Object.assign(new Error("Message has already been consumed or is unavailable"), { status: 409 });
}
throw error;
}
// Native cancel also succeeds for an already consumed ID. Check the native
// message record so a delivery racing cancellation never restores a sent draft.
if (await this.agentMessageState(sessionId, id) !== "missing") {
throw Object.assign(new Error("Message has already been consumed or is unavailable"), { status: 409 });
}
return { ...item, status: "cancelled" };
}
async prompt(sessionId: string, text: string, model?: RuntimeModelOverride) {
const client = await this.ensureClient();
const startedAt = Date.now();
+2 -2
View File
@@ -1,4 +1,4 @@
import { PRODUCT_MCP_TOOL_IDS, QUERY_MCP_TOOL_IDS, queryMcpToolId } from "./product-mcp-tools.js";
import { PRODUCT_MCP_TOOL_IDS, QUERY_MCP_AUTOALLOW_TOOL_IDS, PRODUCT_MCP_WRITE_TOOL_IDS, queryMcpToolId } from "./product-mcp-tools.js";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { normalizeMcpConfig } from "./mcp-config.js";
@@ -54,7 +54,7 @@ export function serviceOpencodeConfig(input: Record<string, unknown>, models: Ag
return {
...input,
...compileOpencodeModels(models),
permissions: sessionWorkspacePermissions([...(input.permissions ?? []) as PermissionRule[], ...QUERY_MCP_TOOL_IDS.filter((name) => !deniedProductQuery(name, (input.permissions ?? []) as PermissionRule[])).map((action): PermissionRule => ({ action, resource: "*", effect: "allow" }))], workspaceRoot),
permissions: sessionWorkspacePermissions([...(input.permissions ?? []) as PermissionRule[], ...QUERY_MCP_AUTOALLOW_TOOL_IDS.filter((name) => !deniedProductQuery(name, (input.permissions ?? []) as PermissionRule[])).map((action): PermissionRule => ({ action, resource: "*", effect: "allow" })), ...PRODUCT_MCP_WRITE_TOOL_IDS.filter((name) => !deniedProductQuery(name, (input.permissions ?? []) as PermissionRule[])).map((action): PermissionRule => ({ action, resource: "*", effect: "ask" }))], workspaceRoot),
snapshots: false,
mcp: normalizeMcpConfig(input.mcp),
plugins: ["*", ...((input.plugins ?? []) as unknown[]), resolve(".opencode/tjwater-plugin"),
+49 -2
View File
@@ -1,36 +1,83 @@
export const PRODUCT_MCP_READ_TOOLS = [
"get_mutation_status",
"export_project_model",
"export_sensor_placement",
"get_analysis_run",
"get_element_gis_properties",
"get_element_properties",
"get_model_baseline_at_time",
"get_model_configuration",
"get_network_options",
"get_network_schema",
"get_node_connections",
"get_project_info",
"get_region",
"get_region_nodes",
"get_scada_device",
"get_sensor_placement_candidate",
"get_sensor_placement_run",
"get_source_gis_properties",
"get_task_context",
"get_task_evidence",
"get_task_run_history",
"get_workbench_runtime",
"list_analysis_results",
"list_analysis_runs",
"list_elements",
"list_major_nodes",
"list_model_configurations",
"list_regions",
"list_scada_devices",
"list_sensor_placement_runs",
"list_task_schedules",
"query_analysis_series",
"query_analysis_values",
"query_element_history",
"query_model_baseline_field",
"query_model_baseline_series",
"query_nearby_elements",
"query_network_extent",
"query_peripheral_data",
"query_scada_readings",
"reverse_geocode",
"search_project_places",
] as const;
export const PRODUCT_MCP_ANALYSIS_TOOLS = ["run_simulation", "run_burst_detection", "run_burst_location", "analyze_valve_isolation", "run_leakage_identification", "run_contaminant_simulation", "analyze_valve_closure", "analyze_flushing", "analyze_pressure_regulation", "optimize_sensor_placement"] as const;
export const PRODUCT_MCP_TOOLS = [...PRODUCT_MCP_READ_TOOLS, ...PRODUCT_MCP_ANALYSIS_TOOLS] as const;
export const PRODUCT_MCP_ANALYSIS_TOOLS = [
"analyze_flushing",
"analyze_pressure_regulation",
"analyze_valve_closure",
"analyze_valve_isolation",
"calculate_demand_allocation",
"optimize_sensor_placement",
"run_burst_detection",
"run_burst_location",
"run_burst_simulation",
"run_contaminant_simulation",
"run_leakage_identification",
"run_simulation",
] as const;
export const PRODUCT_MCP_WRITE_TOOLS = [
"cancel_task_run",
"clean_scada_readings",
"create_operational_task",
"create_sensor_placement",
"create_task_schedule",
"delete_scada_readings",
"edit_model_configuration",
"edit_model_element",
"retry_operational_task",
"save_simulation_scenario",
"update_sensor_placement",
"update_task_schedule",
"write_scada_readings",
] as const;
export const PRODUCT_MCP_TOOLS = [...PRODUCT_MCP_READ_TOOLS, ...PRODUCT_MCP_ANALYSIS_TOOLS, ...PRODUCT_MCP_WRITE_TOOLS] as const;
export const QUERY_MCP_SERVER = "mcp_tjwater";
export const queryMcpToolId = (name: string) => `${QUERY_MCP_SERVER}_${name}`;
export const PRODUCT_MCP_TOOL_IDS = PRODUCT_MCP_TOOLS.map(queryMcpToolId);
export const PRODUCT_MCP_READ_TOOL_IDS = PRODUCT_MCP_READ_TOOLS.map(queryMcpToolId);
export const PRODUCT_MCP_WRITE_TOOL_IDS = PRODUCT_MCP_WRITE_TOOLS.map(queryMcpToolId);
export const LOCAL_QUERY_TOOLS = ["geocode", "session_search", "list_investigation_tasks", "get_investigation_task", "read_agent_messages", "todoread"] as const;
export const LOCAL_QUERY_TOOL_IDS = LOCAL_QUERY_TOOLS.map(queryMcpToolId);
export const QUERY_MCP_TOOL_IDS = [...PRODUCT_MCP_TOOL_IDS, ...LOCAL_QUERY_TOOL_IDS];
export const QUERY_MCP_AUTOALLOW_TOOL_IDS = [...PRODUCT_MCP_READ_TOOL_IDS, ...PRODUCT_MCP_ANALYSIS_TOOLS.map(queryMcpToolId), ...LOCAL_QUERY_TOOL_IDS];
+2 -2
View File
@@ -1,11 +1,11 @@
import type { RuntimeSessionContext } from "./sessionContext.js";
import { LOCAL_QUERY_TOOLS, PRODUCT_MCP_TOOLS, PRODUCT_MCP_READ_TOOLS, queryMcpToolId } from "./product-mcp-tools.js";
import { LOCAL_QUERY_TOOLS, PRODUCT_MCP_TOOLS, PRODUCT_MCP_READ_TOOLS, PRODUCT_MCP_WRITE_TOOLS, queryMcpToolId } from "./product-mcp-tools.js";
const projectLocal = new Set(["geocode", "list_investigation_tasks", "get_investigation_task", "read_agent_messages"]);
export function allowedQueryTools(context: RuntimeSessionContext, reader = false): string[] {
if (context.learningMode === "review") return [];
const project = !!context.projectId && !context.projectKey.startsWith("personal-") && ["ordinary_chat", "task_execution", "task_collaboration"].includes(context.purpose ?? "");
if (reader) return project ? PRODUCT_MCP_READ_TOOLS.map(queryMcpToolId) : [];
return [...(project ? PRODUCT_MCP_TOOLS : []), ...LOCAL_QUERY_TOOLS.filter((name) =>
return [...(project ? PRODUCT_MCP_TOOLS.filter((name) => !(PRODUCT_MCP_WRITE_TOOLS as readonly string[]).includes(name) || context.purpose === "ordinary_chat") : []), ...LOCAL_QUERY_TOOLS.filter((name) =>
(!projectLocal.has(name) || project) && !(context.purpose === "task_execution" && ["list_investigation_tasks", "get_investigation_task"].includes(name)))].map(queryMcpToolId);
}
+1
View File
@@ -279,6 +279,7 @@ const warmupOpencodeRuntime = async () => {
const sandboxProbe = await probeLandlockSandbox();
const queryMcp = startQueryMcpServer({
artifactWorkspace: (context) => opencodeRuntime.ensureSessionWorkspace(context.sessionId, context),
internalToken, productClient: productMcpClient,
productTools: productMcpClient ? await productMcpClient.prepare() : [],
resolveReader: resolveTaskReader,
+63
View File
@@ -0,0 +1,63 @@
import { createHash } from "node:crypto";
export class HistoryCursorError extends Error {}
type Cursor = { scope: string; kind: string; position?: number; anchor?: string; updatedAt?: string; id?: string };
export function historyScope(value: unknown) {
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
}
function encode(value: Cursor) {
return Buffer.from(JSON.stringify(value)).toString("base64url");
}
function decode(value: string, scope: string, kind: string): Cursor {
try {
const cursor = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as Cursor;
if (cursor.scope !== scope || cursor.kind !== kind) throw new Error();
return cursor;
} catch {
throw new HistoryCursorError("invalid history cursor");
}
}
export function sessionHistoryPage<T extends { sessionId: string; updatedAt: string; title?: string; searchSnippet?: string }>(
records: T[], options: { scope: string; q?: string; limit: number; cursor?: string },
) {
const query = (options.q ?? "").trim().toLocaleLowerCase();
const scope = historyScope([options.scope, query]);
const boundary = options.cursor ? decode(options.cursor, scope, "sessions") : null;
if (boundary && (typeof boundary.updatedAt !== "string" || typeof boundary.id !== "string")) {
throw new HistoryCursorError("invalid history cursor");
}
const ordered = records.filter((record) => (record.title ?? "新对话").toLocaleLowerCase().includes(query) || Boolean(query && record.searchSnippet))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || b.sessionId.localeCompare(a.sessionId));
const remaining = boundary ? ordered.filter((record) => record.updatedAt < boundary.updatedAt!
|| (record.updatedAt === boundary.updatedAt && record.sessionId.localeCompare(boundary.id!) < 0)) : ordered;
const sessions = remaining.slice(0, options.limit);
const has_more = remaining.length > sessions.length;
const last = sessions.at(-1);
return { sessions, has_more, next_cursor: has_more && last
? encode({ scope, kind: "sessions", updatedAt: last.updatedAt, id: last.sessionId }) : null };
}
function messageAnchor(message: unknown) {
if (message && typeof message === "object" && "id" in message && typeof message.id === "string") return message.id;
return historyScope(message);
}
export function sessionMessagePage(messages: unknown[], options: { scope: string; limit: number; before?: string }) {
const boundary = options.before ? decode(options.before, options.scope, "messages") : null;
const end = boundary?.position ?? messages.length;
if (!Number.isInteger(end) || end < 0 || end > messages.length || (boundary &&
(end >= messages.length || messageAnchor(messages[end]) !== boundary.anchor))) {
throw new HistoryCursorError("message history changed; reload this conversation");
}
const start = Math.max(0, end - options.limit);
return {
messages: messages.slice(start, end),
message_page: { has_more: start > 0, before_cursor: start > 0
? encode({ scope: options.scope, kind: "messages", position: start, anchor: messageAnchor(messages[start]) }) : null },
};
}
+21
View File
@@ -0,0 +1,21 @@
/** Search only visible user/assistant text, never tool arguments or hidden reasoning. */
export function matchHistoryText(messages: unknown[], query: string): string | undefined {
const needle = query.trim().toLocaleLowerCase();
if (!needle) return;
for (const value of messages) {
if (!value || typeof value !== "object") continue;
const message = value as Record<string, unknown>;
if (message.role !== "user" && message.role !== "assistant") continue;
const parts = Array.isArray(message.parts) ? message.parts : [message];
for (const value of parts) {
if (!value || typeof value !== "object") continue;
const part = value as Record<string, unknown>;
if (part.type !== "text" || typeof part.text !== "string") continue;
const index = part.text.toLocaleLowerCase().indexOf(needle);
if (index < 0) continue;
const start = Math.max(0, index - 36);
const end = Math.min(part.text.length, index + needle.length + 80);
return `${start ? "…" : ""}${part.text.slice(start, end).replace(/\s+/g, " ")}${end < part.text.length ? "…" : ""}`;
}
}
}
+10 -3
View File
@@ -1,5 +1,6 @@
import { join } from "node:path";
import { SessionTopicStore } from "./topic-store.js";
import { config } from "../config.js";
import { type SessionWorkspaceStore } from "./workspace-store.js";
import {
@@ -30,6 +31,7 @@ export type SessionRecord = {
requestId?: string;
modelId?: string;
title?: string;
topicId?: string | null;
triggerId?: string;
triggerSource?: string;
triggerSubjectType?: string;
@@ -63,14 +65,17 @@ type EnsureSessionMetadataInput = SessionMetadataContext & {
runId?: string;
requestId?: string;
modelId?: string;
topicId?: string;
};
export class SessionMetadataStore {
readonly topics: SessionTopicStore;
private readonly ensureQueues = new Map<string, Promise<void>>();
private index?: Map<string, Map<string, SessionRecord>>;
private indexLoading?: Promise<void>;
private indexVersion = 0;
constructor(private readonly baseDir = config.SESSION_METADATA_STORAGE_DIR, private readonly workspaces?: SessionWorkspaceStore) {
this.topics = new SessionTopicStore(join(baseDir, "topics"));
workspaces?.setDeletionCheck(async (context) => Boolean((await this.get(context, context.sessionId, { includeDeleted: true }))?.deletedAt));
}
@@ -133,6 +138,7 @@ export class SessionMetadataStore {
runId: input.runId?.trim(),
requestId: input.requestId?.trim(),
modelId: input.modelId?.trim(),
topicId: input.topicId,
...(input.trigger ? {
triggerId: input.trigger.triggerId,
triggerSource: input.trigger.source,
@@ -175,7 +181,7 @@ export class SessionMetadataStore {
async touch(
record: SessionRecord,
updates: Partial<Pick<SessionRecord, "title" | "status">> = {},
updates: Partial<Pick<SessionRecord, "title" | "status" | "topicId">> = {},
) {
return this.serialize(record.sessionId, async () => {
const current = await this.get({ ...record, userId: record.ownerUserId }, record.sessionId);
@@ -314,12 +320,13 @@ const matchesContext = (record: SessionRecord, context: SessionMetadataContext)
(context.projectId === undefined || record.projectId === context.projectId);
const normalizeSessionUpdates = (
updates: Partial<Pick<SessionRecord, "title" | "status">>,
updates: Partial<Pick<SessionRecord, "title" | "status" | "topicId">>,
) => {
const normalized: Partial<Pick<SessionRecord, "title" | "status">> = {};
const normalized: Partial<Pick<SessionRecord, "title" | "status" | "topicId">> = {};
if (updates.status === "active" || updates.status === "archived") {
normalized.status = updates.status;
}
if (updates.topicId === null || typeof updates.topicId === "string") normalized.topicId = updates.topicId;
if (typeof updates.title === "string") {
const trimmed = updates.title.trim();
if (trimmed) {
+66
View File
@@ -0,0 +1,66 @@
import { createHash, randomUUID } from "node:crypto";
import { join } from "node:path";
import { atomicWriteJson, readJsonFile } from "../utils/fileStore.js";
export type TopicScope = { actorKey: string; projectKey: string; userId?: string };
export type SessionTopic = { id: string; name: string; createdAt: string; updatedAt: string };
/** Topics organize one user's chats within an authorized data scope. */
export class SessionTopicStore {
private queues = new Map<string, Promise<void>>();
constructor(private readonly directory: string) {}
async list(scope: TopicScope): Promise<SessionTopic[]> {
return (await readJsonFile<{ topics: SessionTopic[] }>(this.path(scope)))?.topics ?? [];
}
async get(scope: TopicScope, id: string) {
return (await this.list(scope)).find((topic) => topic.id === id);
}
create(scope: TopicScope, name: string) {
return this.update(scope, (topics) => {
const now = new Date().toISOString();
const topic = { id: randomUUID(), name, createdAt: now, updatedAt: now };
topics.push(topic);
return topic;
});
}
rename(scope: TopicScope, id: string, name: string) {
return this.update(scope, (topics) => {
const topic = topics.find((entry) => entry.id === id);
if (topic) { topic.name = name; topic.updatedAt = new Date().toISOString(); }
return topic;
});
}
remove(scope: TopicScope, id: string) {
return this.update(scope, (topics) => {
const index = topics.findIndex((topic) => topic.id === id);
if (index < 0) return false;
topics.splice(index, 1);
return true;
});
}
private async update<T>(scope: TopicScope, change: (topics: SessionTopic[]) => T): Promise<T> {
const path = this.path(scope);
const previous = this.queues.get(path) ?? Promise.resolve();
const result = previous.catch(() => undefined).then(async () => {
const topics = await this.list(scope);
const value = change(topics);
await atomicWriteJson(path, { topics }, 0o600);
return value;
});
const pending = result.then(() => undefined, () => undefined);
this.queues.set(path, pending);
try { return await result; }
finally { if (this.queues.get(path) === pending) this.queues.delete(path); }
}
private path(scope: TopicScope) {
const hash = createHash("sha256").update(JSON.stringify([scope.actorKey, scope.projectKey, scope.userId ?? null])).digest("hex");
return join(this.directory, `${hash}.json`);
}
}