baa-conductor

git clone 

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
M apps/conductor-daemon/src/index.test.js
+1, -1
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");
M apps/conductor-daemon/src/local-api.ts
+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   }
M tasks/T-S041.md
+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+