- commit
- 3d8e5b5
- parent
- 284efd8
- author
- im_wower
- date
- 2026-03-29 00:34:36 +0800 CST
feat: add artifact query routes for messages, executions, and sessions
Add 6 read-only query routes to the conductor HTTP server:
- GET /v1/messages, /v1/messages/{id}
- GET /v1/executions, /v1/executions/{id}
- GET /v1/sessions, /v1/sessions/latest
All routes support pagination (limit/offset) and filtering. List endpoints
return summaries with artifact URLs; detail endpoints include full content.
Routes are registered in /describe/business and /v1/capabilities.
Closes T-S041.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
3 files changed,
+292,
-7
1@@ -2748,7 +2748,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
2 assert.doesNotMatch(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/browser\/actions/u);
3 assert.doesNotMatch(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/browser\/claude\/open/u);
4 assert.doesNotMatch(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/system\/pause/u);
5- assert.doesNotMatch(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/exec/u);
6+ assert.doesNotMatch(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/exec"/u);
7 assert.doesNotMatch(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/runs/u);
8 assert.equal(businessDescribePayload.data.codex.backend, "independent_codexd");
9 assert.equal(businessDescribePayload.data.browser.request_contract.route.path, "/v1/browser/request");
+273,
-1
1@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
2 import { randomUUID } from "node:crypto";
3 import { join } from "node:path";
4 import {
5+ buildArtifactPublicUrl,
6 getArtifactContentType,
7 type ArtifactStore
8 } from "../../../packages/artifact-db/dist/index.js";
9@@ -593,6 +594,48 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
10 method: "GET",
11 pathPattern: "/v1/runs/:run_id",
12 summary: "读取单个 run"
13+ },
14+ {
15+ id: "artifact.messages.list",
16+ kind: "read",
17+ method: "GET",
18+ pathPattern: "/v1/messages",
19+ summary: "查询消息列表(分页、按 platform/conversation 过滤)"
20+ },
21+ {
22+ id: "artifact.messages.read",
23+ kind: "read",
24+ method: "GET",
25+ pathPattern: "/v1/messages/:message_id",
26+ summary: "读取单条消息详情"
27+ },
28+ {
29+ id: "artifact.executions.list",
30+ kind: "read",
31+ method: "GET",
32+ pathPattern: "/v1/executions",
33+ summary: "查询执行记录列表(分页、按 message/target/tool 过滤)"
34+ },
35+ {
36+ id: "artifact.executions.read",
37+ kind: "read",
38+ method: "GET",
39+ pathPattern: "/v1/executions/:instruction_id",
40+ summary: "读取单条执行记录详情"
41+ },
42+ {
43+ id: "artifact.sessions.list",
44+ kind: "read",
45+ method: "GET",
46+ pathPattern: "/v1/sessions",
47+ summary: "查询会话索引(分页、按 platform 过滤)"
48+ },
49+ {
50+ id: "artifact.sessions.latest",
51+ kind: "read",
52+ method: "GET",
53+ pathPattern: "/v1/sessions/latest",
54+ summary: "最近活跃会话及关联消息和执行 URL"
55 }
56 ];
57
58@@ -3909,7 +3952,13 @@ function routeBelongsToSurface(
59 "controllers.list",
60 "tasks.list",
61 "tasks.read",
62- "tasks.logs.read"
63+ "tasks.logs.read",
64+ "artifact.messages.list",
65+ "artifact.messages.read",
66+ "artifact.executions.list",
67+ "artifact.executions.read",
68+ "artifact.sessions.list",
69+ "artifact.sessions.latest"
70 ].includes(route.id);
71 }
72
73@@ -5957,6 +6006,217 @@ async function handleRunRead(context: LocalApiRequestContext): Promise<Conductor
74 return buildSuccessEnvelope(context.requestId, 200, summarizeRun(run));
75 }
76
77+const ARTIFACT_LIST_MAX_LIMIT = 200;
78+const ARTIFACT_DEFAULT_MESSAGE_LIMIT = 50;
79+const ARTIFACT_DEFAULT_EXECUTION_LIMIT = 50;
80+const ARTIFACT_DEFAULT_SESSION_LIMIT = 20;
81+const ARTIFACT_LATEST_SESSION_LIMIT = 10;
82+
83+async function handleArtifactMessagesList(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
84+ const store = requireArtifactStore(context.artifactStore);
85+ const platform = readOptionalQueryString(context.url, "platform");
86+ const conversationId = readOptionalQueryString(context.url, "conversation_id");
87+ const limit = readPositiveIntegerQuery(context.url, "limit", ARTIFACT_DEFAULT_MESSAGE_LIMIT, ARTIFACT_LIST_MAX_LIMIT);
88+ const offset = readNonNegativeIntegerQuery(context.url, "offset", 0);
89+ const publicBaseUrl = store.getPublicBaseUrl();
90+
91+ const messages = await store.listMessages({ platform, conversationId, limit, offset });
92+
93+ return buildSuccessEnvelope(context.requestId, 200, {
94+ count: messages.length,
95+ filters: { platform: platform ?? null, conversation_id: conversationId ?? null, limit, offset },
96+ messages: messages.map((m) => ({
97+ id: m.id,
98+ platform: m.platform,
99+ conversation_id: m.conversationId,
100+ role: m.role,
101+ summary: m.summary,
102+ observed_at: m.observedAt,
103+ artifact_url: buildArtifactPublicUrl(publicBaseUrl, m.staticPath)
104+ }))
105+ });
106+}
107+
108+async function handleArtifactMessageRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
109+ const store = requireArtifactStore(context.artifactStore);
110+ const messageId = context.params.message_id;
111+
112+ if (!messageId) {
113+ throw new LocalApiHttpError(400, "invalid_request", "Route parameter \"message_id\" is required.");
114+ }
115+
116+ const message = await store.getMessage(messageId);
117+
118+ if (message == null) {
119+ throw new LocalApiHttpError(404, "not_found", `Message "${messageId}" was not found.`, {
120+ resource: "message",
121+ resource_id: messageId
122+ });
123+ }
124+
125+ const publicBaseUrl = store.getPublicBaseUrl();
126+
127+ return buildSuccessEnvelope(context.requestId, 200, {
128+ id: message.id,
129+ platform: message.platform,
130+ conversation_id: message.conversationId,
131+ role: message.role,
132+ raw_text: message.rawText,
133+ summary: message.summary,
134+ observed_at: message.observedAt,
135+ page_url: message.pageUrl,
136+ page_title: message.pageTitle,
137+ organization_id: message.organizationId,
138+ created_at: message.createdAt,
139+ artifact_url: buildArtifactPublicUrl(publicBaseUrl, message.staticPath)
140+ });
141+}
142+
143+async function handleArtifactExecutionsList(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
144+ const store = requireArtifactStore(context.artifactStore);
145+ const messageId = readOptionalQueryString(context.url, "message_id");
146+ const target = readOptionalQueryString(context.url, "target");
147+ const tool = readOptionalQueryString(context.url, "tool");
148+ const limit = readPositiveIntegerQuery(context.url, "limit", ARTIFACT_DEFAULT_EXECUTION_LIMIT, ARTIFACT_LIST_MAX_LIMIT);
149+ const offset = readNonNegativeIntegerQuery(context.url, "offset", 0);
150+ const publicBaseUrl = store.getPublicBaseUrl();
151+
152+ const executions = await store.listExecutions({ messageId, target, tool, limit, offset });
153+
154+ return buildSuccessEnvelope(context.requestId, 200, {
155+ count: executions.length,
156+ filters: { message_id: messageId ?? null, target: target ?? null, tool: tool ?? null, limit, offset },
157+ executions: executions.map((e) => ({
158+ instruction_id: e.instructionId,
159+ message_id: e.messageId,
160+ target: e.target,
161+ tool: e.tool,
162+ result_ok: e.resultOk,
163+ result_summary: e.resultSummary,
164+ executed_at: e.executedAt,
165+ artifact_url: buildArtifactPublicUrl(publicBaseUrl, e.staticPath)
166+ }))
167+ });
168+}
169+
170+async function handleArtifactExecutionRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
171+ const store = requireArtifactStore(context.artifactStore);
172+ const instructionId = context.params.instruction_id;
173+
174+ if (!instructionId) {
175+ throw new LocalApiHttpError(400, "invalid_request", "Route parameter \"instruction_id\" is required.");
176+ }
177+
178+ const execution = await store.getExecution(instructionId);
179+
180+ if (execution == null) {
181+ throw new LocalApiHttpError(404, "not_found", `Execution "${instructionId}" was not found.`, {
182+ resource: "execution",
183+ resource_id: instructionId
184+ });
185+ }
186+
187+ const publicBaseUrl = store.getPublicBaseUrl();
188+
189+ return buildSuccessEnvelope(context.requestId, 200, {
190+ instruction_id: execution.instructionId,
191+ message_id: execution.messageId,
192+ target: execution.target,
193+ tool: execution.tool,
194+ params: tryParseJson(execution.params),
195+ params_kind: execution.paramsKind,
196+ result_ok: execution.resultOk,
197+ result_data: tryParseJson(execution.resultData),
198+ result_summary: execution.resultSummary,
199+ result_error: execution.resultError,
200+ http_status: execution.httpStatus,
201+ executed_at: execution.executedAt,
202+ created_at: execution.createdAt,
203+ artifact_url: buildArtifactPublicUrl(publicBaseUrl, execution.staticPath)
204+ });
205+}
206+
207+async function handleArtifactSessionsList(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
208+ const store = requireArtifactStore(context.artifactStore);
209+ const platform = readOptionalQueryString(context.url, "platform");
210+ const limit = readPositiveIntegerQuery(context.url, "limit", ARTIFACT_DEFAULT_SESSION_LIMIT, ARTIFACT_LIST_MAX_LIMIT);
211+ const offset = readNonNegativeIntegerQuery(context.url, "offset", 0);
212+ const publicBaseUrl = store.getPublicBaseUrl();
213+
214+ const sessions = await store.listSessions({ platform, limit, offset });
215+
216+ return buildSuccessEnvelope(context.requestId, 200, {
217+ count: sessions.length,
218+ filters: { platform: platform ?? null, limit, offset },
219+ sessions: sessions.map((s) => ({
220+ id: s.id,
221+ platform: s.platform,
222+ conversation_id: s.conversationId,
223+ started_at: s.startedAt,
224+ last_activity_at: s.lastActivityAt,
225+ message_count: s.messageCount,
226+ execution_count: s.executionCount,
227+ summary: s.summary,
228+ artifact_url: buildArtifactPublicUrl(publicBaseUrl, s.staticPath)
229+ }))
230+ });
231+}
232+
233+async function handleArtifactSessionsLatest(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
234+ const store = requireArtifactStore(context.artifactStore);
235+ const publicBaseUrl = store.getPublicBaseUrl();
236+
237+ const sessions = await store.getLatestSessions(ARTIFACT_LATEST_SESSION_LIMIT);
238+
239+ return buildSuccessEnvelope(context.requestId, 200, {
240+ count: sessions.length,
241+ sessions: sessions.map((s) => ({
242+ id: s.id,
243+ platform: s.platform,
244+ conversation_id: s.conversationId,
245+ started_at: s.startedAt,
246+ last_activity_at: s.lastActivityAt,
247+ message_count: s.messageCount,
248+ execution_count: s.executionCount,
249+ summary: s.summary,
250+ artifact_url: buildArtifactPublicUrl(publicBaseUrl, s.staticPath)
251+ }))
252+ });
253+}
254+
255+function tryParseJson(value: string | null): JsonValue | null {
256+ if (value == null) {
257+ return null;
258+ }
259+
260+ try {
261+ return JSON.parse(value) as JsonValue;
262+ } catch {
263+ return value;
264+ }
265+}
266+
267+function readNonNegativeIntegerQuery(url: URL, fieldName: string, defaultValue: number): number {
268+ const rawValue = url.searchParams.get(fieldName);
269+
270+ if (rawValue == null || rawValue.trim() === "") {
271+ return defaultValue;
272+ }
273+
274+ const numeric = Number(rawValue);
275+
276+ if (!Number.isInteger(numeric) || numeric < 0) {
277+ throw new LocalApiHttpError(
278+ 400,
279+ "invalid_request",
280+ `Query parameter "${fieldName}" must be a non-negative integer.`,
281+ { field: fieldName }
282+ );
283+ }
284+
285+ return numeric;
286+}
287+
288 async function dispatchBusinessRoute(
289 routeId: string,
290 context: LocalApiRequestContext,
291@@ -6039,6 +6299,18 @@ async function dispatchBusinessRoute(
292 return handleRunsList(context);
293 case "runs.read":
294 return handleRunRead(context);
295+ case "artifact.messages.list":
296+ return handleArtifactMessagesList(context);
297+ case "artifact.messages.read":
298+ return handleArtifactMessageRead(context);
299+ case "artifact.executions.list":
300+ return handleArtifactExecutionsList(context);
301+ case "artifact.executions.read":
302+ return handleArtifactExecutionRead(context);
303+ case "artifact.sessions.list":
304+ return handleArtifactSessionsList(context);
305+ case "artifact.sessions.latest":
306+ return handleArtifactSessionsLatest(context);
307 default:
308 throw new LocalApiHttpError(404, "not_found", `No local route matches "${context.url.pathname}".`);
309 }
+18,
-5
1@@ -2,7 +2,7 @@
2
3 ## 状态
4
5-- 当前状态:`待开始`
6+- 当前状态:`已完成`
7 - 规模预估:`S`
8 - 依赖任务:`T-S039`
9 - 建议执行者:`Codex` 或 `Claude`(简单路由,边界清晰,都能做)
10@@ -141,21 +141,34 @@ T-S039 建了数据库,T-S040 接入了写入。但目前只能通过静态文
11
12 ### 开始执行
13
14-- 执行者:
15-- 开始时间:
16+- 执行者:Claude
17+- 开始时间:2026-03-29
18 - 状态变更:`待开始` → `进行中`
19
20 ### 完成摘要
21
22-- 完成时间:
23+- 完成时间:2026-03-29
24 - 状态变更:`进行中` → `已完成`
25 - 修改了哪些文件:
26+ - `packages/artifact-db/src/store.ts` — 新增 `getPublicBaseUrl()` getter
27+ - `apps/conductor-daemon/src/local-api.ts` — 新增 6 个查询路由 + handler + 注册到 describe/capabilities
28+ - `apps/conductor-daemon/src/index.test.js` — 修正 business describe 断言正则(`/v1/exec` → `/v1/exec"` 避免误匹配 `/v1/executions`)
29 - 核心实现思路:
30+ - 在 `LOCAL_API_ROUTES` 数组末尾新增 6 条路由定义(artifact.messages.list/read、artifact.executions.list/read、artifact.sessions.list/latest)
31+ - 将 6 个路由 ID 加入 `routeBelongsToSurface` 的 business 白名单,自动注册到 `/describe/business` 和 `/v1/capabilities`
32+ - 实现 6 个 handler 函数,复用 `ArtifactStore` 已有的 `listMessages`/`getMessage`/`listExecutions`/`getExecution`/`listSessions`/`getLatestSessions`
33+ - 列表路由只返回 summary + artifact_url,不返回 raw_text/result_data;详情路由返回完整内容
34+ - artifact_url 通过 `buildArtifactPublicUrl(store.getPublicBaseUrl(), staticPath)` 生成,包含完整域名
35+ - 新增 `readNonNegativeIntegerQuery` 用于解析 offset 参数,`tryParseJson` 用于安全解析执行记录中的 JSON 字段
36 - 跑了哪些测试:
37+ - `pnpm build` — 全量编译通过
38+ - `pnpm test` — conductor-daemon 52 个测试全部通过,codexd 5 个测试全部通过
39
40 ### 执行过程中遇到的问题
41
42-> 记录执行过程中遇到的阻塞、环境问题、临时绕过方案等。合并时由合并者判断是否需要修复或建新任务。
43+- 新增的 `/v1/executions` 路径命中了已有测试中 `/\/v1\/exec/u` 正则断言(原意是排除 host-ops 的 `/v1/exec`),修正为 `/\/v1\/exec"/u` 精确匹配。
44
45 ### 剩余风险
46
47+- 无
48+