@sema-agent/server 7.26.0 → 7.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/USAGE.md +29 -0
- package/dist/boot/config-center.js +5 -1
- package/dist/boot/resolve-spec.js +13 -1
- package/dist/boot/runner-deps.js +10 -0
- package/dist/config-center/facade.d.ts +2 -2
- package/dist/config-center/facade.js +1 -1
- package/dist/config-center/restart-signal.d.ts +1 -1
- package/dist/config-center/restart-signal.js +6 -1
- package/dist/config-center/skills-mcp.d.ts +28 -3
- package/dist/config-center/skills-mcp.js +64 -6
- package/dist/config-center/types.d.ts +28 -0
- package/dist/config-types.d.ts +72 -3
- package/dist/config.d.ts +2 -1
- package/dist/config.js +109 -3
- package/dist/http/route-ctx.d.ts +12 -2
- package/dist/http/routes/a2a-serve.d.ts +75 -0
- package/dist/http/routes/a2a-serve.js +710 -0
- package/dist/http/routes/capabilities.js +20 -0
- package/dist/http/routes/fleet.js +6 -1
- package/dist/http/routes/runs.d.ts +27 -1
- package/dist/http/routes/runs.js +163 -120
- package/dist/http/routes/sessions.d.ts +11 -0
- package/dist/http/routes/sessions.js +100 -3
- package/dist/http/server.d.ts +17 -0
- package/dist/http/server.js +65 -6
- package/dist/http/wire-types.d.ts +12 -1
- package/dist/main.js +17 -0
- package/dist/observability/metrics.js +11 -0
- package/dist/run-local.js +14 -1
- package/dist/task-a2a.d.ts +114 -0
- package/dist/task-a2a.js +245 -0
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { DEFAULT_EFFORT_LEVELS, expandTiers } from "@sema-agent/core";
|
|
2
2
|
import { isModelAllowlisted } from "../../model-select.js";
|
|
3
3
|
import { cwdHonored } from "../../task-cwd.js";
|
|
4
|
+
import { a2aInjectionHonored } from "../../task-a2a.js";
|
|
4
5
|
import { mcpInjectionHonored } from "../../task-mcp.js";
|
|
5
6
|
import { sendJson, sendError } from "../send.js";
|
|
6
7
|
import { resolveStreamApprovalGate } from "../../tool-approval.js";
|
|
@@ -284,6 +285,25 @@ async function handleCapabilitiesBody(req, res, url, ctx, miss) {
|
|
|
284
285
|
// `.mcp.json`) — any SINGLE-USER deployment (task-mcp.ts mcpInjectionHonored = `requirePrincipal!==true`), on
|
|
285
286
|
// ANY execution lane. FALSE on multi-tenant (per-request MCP ignored) so the shell never pretends its `.mcp.json` took effect.
|
|
286
287
|
mcpInjection: mcpInjectionHonored(deps.config),
|
|
288
|
+
// [DESIGN-269 车1 件4] A2A client seam, two bits with deliberately DIFFERENT predicates (they answer
|
|
289
|
+
// different questions — one bit could not be honest about both):
|
|
290
|
+
// · `a2a` = the READ face `GET /v1/sessions/:id/a2a` exists. Unconditional, exactly like `mcp`: with
|
|
291
|
+
// no peers configured it returns an honest empty panel, so there is no 501 path to gate on.
|
|
292
|
+
// · `a2aInjection` = this deployment HONORS a caller-supplied `body.a2aPeers`. Three vetoes, all owned
|
|
293
|
+
// by `task-a2a.ts a2aInjectionHonored` (single-user ∧ `a2a` unlocked ∧ compliance permits) — false
|
|
294
|
+
// means the request still 200s and the field is warned+ignored (「字段门」形, 附录 B 判据 3②),
|
|
295
|
+
// EXCEPT under a lock, where the submit refuses 400 `config.locked_key` (the lock veto is what keeps
|
|
296
|
+
// "says yes ⟺ route works" true in that shape).
|
|
297
|
+
a2a: true,
|
|
298
|
+
a2aInjection: a2aInjectionHonored(deps.config),
|
|
299
|
+
// [DESIGN-269 车2] server-as-peer 半场的**旋钮实况**:这台 worker 是否作为一个 A2A agent 对外
|
|
300
|
+
// 存在。true ⟺ `A2A_SERVE_ENABLED` 开 ⟺ `GET /.well-known/agent-card.json` 与 `POST /v1/a2a`
|
|
301
|
+
// 两条路由存在;false ⟺ 两条都 404(设计稿 §3.3「卡都不发=对外不存在」)。缺席即关,所以
|
|
302
|
+
// 「说 yes ⟺ 路由在」是结构性的,不靠人盯。
|
|
303
|
+
// ⚠️ 辖域说明:本位说的是**路由在不在**,不是「每个方法都能跑」——`message/send` / `tasks/get`
|
|
304
|
+
// 还需要 durable run store(缺它时两个方法回具名 JSON-RPC -32004,不是静默降级)。把 runStore
|
|
305
|
+
// 折进本位会让「卡还在发、位却是 false」这种更糟的谎出现。
|
|
306
|
+
a2aServe: deps.config.a2aServe !== undefined,
|
|
287
307
|
// E8 (shell-host contract): per-model usage echo on TaskStats.modelUsage — sourced from durable model_usage events,
|
|
288
308
|
// so it requires BOTH the durable run store AND the tracker that feeds them (a store-without-
|
|
289
309
|
// tracker deploy would otherwise lie). Absent either → the shell shows aggregate cost only.
|
|
@@ -74,7 +74,12 @@ function streamFleet(req, res, bus, callerScope, callerSession = null, completio
|
|
|
74
74
|
// stays 1): older shells ignore unknown fields.
|
|
75
75
|
// `type: "meta"` 与 `event: meta` 同名双发(tasks/trace-usage/approvals 三流同款):部分代理只转发
|
|
76
76
|
// `data:` 行,按 data.type 分派的消费端否则认不出首帧。additive,老消费端忽略未知键。
|
|
77
|
-
|
|
77
|
+
// [4119] P-31 Q2(cli design/285,additive):session-bound 连接回显**本连接实际生效**的 session id。
|
|
78
|
+
// 客户端 `server_fail_closed` 可信臂零内容自校——它信的是「server 按会话过滤了」这句话本身;把过滤
|
|
79
|
+
// 判据说出来,客户端才能与本 ledger 会话锚**比对相等**后解封最强档,而不是在 keyed 多会话宿主上
|
|
80
|
+
// 永远诚实封顶。键与 `sessionScoped` 对偶:unscoped 连接缺席(不发空串);值=`callerSession` 逐字
|
|
81
|
+
// (即 streamFleet 真用于行过滤的那一个量,不另铸第二读点)。
|
|
82
|
+
res.write(`event: meta\ndata: ${JSON.stringify({ type: "meta", version: 1, scoped: callerScope !== null, sessionScoped: callerSession !== null, ...(callerSession !== null ? { session: callerSession } : {}), bgNotifyFailClosed: true })}\n\n`);
|
|
78
83
|
const seenTasks = new Set();
|
|
79
84
|
const seenWf = new Set();
|
|
80
85
|
// #3 fleet per-session scope (clay 2026-06-30): a row is visible iff it matches the caller's principal scope AND
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* `cancelledViaVerb` first-writer-wins 判别、`steerableRuns` 活流句柄、`wakeParkMints` park 铸造闩)。
|
|
14
14
|
*/
|
|
15
15
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
16
|
-
import type { RouteCtx } from "../route-ctx.js";
|
|
16
|
+
import type { RouteCtx, PreparedTaskSubmission } from "../route-ctx.js";
|
|
17
17
|
export declare const RUN_ID_RE: RegExp;
|
|
18
18
|
export declare const RUN_CANCEL_RE: RegExp;
|
|
19
19
|
export declare const RUN_STEER_RE: RegExp;
|
|
@@ -35,4 +35,30 @@ export declare const RUN_TASK_STOP_RE: RegExp;
|
|
|
35
35
|
export declare function handleRuns(req: IncomingMessage, res: ServerResponse, url: string, ctx: RouteCtx): Promise<boolean>;
|
|
36
36
|
/** 子代理 / 任务句柄 / HITL 应答段——`handle()` 里它在 SVC-5 workflow steer **之后**,故是独立的域入口。 */
|
|
37
37
|
export declare function handleRunVerbs(req: IncomingMessage, res: ServerResponse, url: string, ctx: RouteCtx): Promise<boolean>;
|
|
38
|
+
/**
|
|
39
|
+
* DESIGN-269 车2 件2 —— `POST /v1/runs` 的**建行 + 驱动**段,从路由体里原样搬出来的具名腿。
|
|
40
|
+
*
|
|
41
|
+
* 🔴 为什么提取:A2A 的 `message/send`(routes/a2a-serve.ts)必须走**同一条**提交链 —— 第二条提交
|
|
42
|
+
* 路径就是第二个写者,而这条链上挂着 durable taskId 幂等重放、lease/治理窗 pre-admission、
|
|
43
|
+
* `createRun` 的会话 claim、checkpoint ctx 落库、fleet 行发布、resume/rewind 锚点捕获、审批 ALS……
|
|
44
|
+
* 手抄一份就是把这十来件事复制成两份会漂的实现(而漂开的那天没有任何东西会说话)。
|
|
45
|
+
*
|
|
46
|
+
* **纯搬运**:函数体逐字来自搬走前的路由体(唯一改动=统一去缩进两格 + 三个「已应答」的裸 `return;`
|
|
47
|
+
* 写成 `return null;`,语义一字未变 —— 调用方拿到 `null` 就是「腿已经自己应答了」)。
|
|
48
|
+
*
|
|
49
|
+
* 契约:
|
|
50
|
+
* · 返回 `null` = **已应答**(400 `body.taskId` 坏形 / 409 foreign owner / 202 durable 重放 /
|
|
51
|
+
* lease·治理窗拒),调用方立即 return,不得再写响应体。
|
|
52
|
+
* · 返回 `{status, body}` = 本次提交的收据(202 = 新行已建并在跑;409 = 会话占用冲突,**没有**建行)。
|
|
53
|
+
* 调用方自己决定怎么发(HTTP 路由 `sendJson`;A2A 腿翻成协议对象或具名 RPC 错误)。
|
|
54
|
+
* · 调用前必须先过 `prepareSpec`(本腿吃它的产物)与 `deps.runStore` 在场判据 —— 后者本腿内另有
|
|
55
|
+
* 防御性复检(缺席 ⇒ 501),但两个调用方都在自己的入口先判过,那才是各自面上的正确错误形。
|
|
56
|
+
*/
|
|
57
|
+
export declare function createDurableRun(req: IncomingMessage, res: ServerResponse, ctx: RouteCtx, prepared: PreparedTaskSubmission,
|
|
58
|
+
/** in-memory 幂等键(`POST /v1/runs` = `Idempotency-Key` 头派生;A2A = `message.messageId` 派生)。
|
|
59
|
+
* undefined = 本次提交不参与 in-memory 去重(durable `body.taskId` 重放是独立的第二道)。 */
|
|
60
|
+
idemKey: string | undefined): Promise<{
|
|
61
|
+
status: number;
|
|
62
|
+
body: unknown;
|
|
63
|
+
} | null>;
|
|
38
64
|
//# sourceMappingURL=runs.d.ts.map
|
package/dist/http/routes/runs.js
CHANGED
|
@@ -301,126 +301,11 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
301
301
|
const prepared = await prepareSpec(req, res);
|
|
302
302
|
if (!prepared)
|
|
303
303
|
return;
|
|
304
|
-
|
|
305
|
-
//
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
// the GET /v1/runs/:id gate (a foreign taskId reads as 404-shaped conflict, not another tenant's run ids).
|
|
310
|
-
const rawClientTaskId = prepared.body.taskId;
|
|
311
|
-
if (rawClientTaskId !== undefined && (typeof rawClientTaskId !== "string" || !isUuidV7(rawClientTaskId))) {
|
|
312
|
-
sendError(res, 400, "request.id_invalid", "body.taskId must be a uuidv7 string (caller-minted idempotency key)");
|
|
313
|
-
return;
|
|
314
|
-
}
|
|
315
|
-
const clientTaskId = rawClientTaskId;
|
|
316
|
-
if (clientTaskId) {
|
|
317
|
-
const existing = await runStore.getRun(clientTaskId);
|
|
318
|
-
if (existing) {
|
|
319
|
-
const verified = verifiedPrincipal(req, deps.config);
|
|
320
|
-
if (existing.owner !== null && existing.owner !== verified) {
|
|
321
|
-
sendError(res, 409, "conflict.run_exists", "taskId already exists"); // foreign owner: conflict, no replay (no cross-tenant oracle)
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
|
-
sendJson(res, 202, { taskId: existing.taskId, sessionId: existing.sessionId, status: existing.status });
|
|
325
|
-
return;
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
// 🔴 复审 C1:lease gate for a genuinely-NEW submission (past the idempotency replay above). Keyed on the
|
|
329
|
-
// VERIFIED submitter = the principal billed for this run's spend (correct principal for the sync door).
|
|
330
|
-
// design/166-T1:治理窗 pre-admission 同位(idempotency replay 之后——重放已建 run 必须拿回 202 收据,
|
|
331
|
-
// 不得因窗满改判 429;异步 202 车道的唯一 429 出路,core 进场拒是竞态窗兜底)。
|
|
332
|
-
if ((await leaseDenied(req, res)) || (await usageWindowDenied(req, res)))
|
|
333
|
-
return;
|
|
334
|
-
const resp = await idemCache.run(idemKey, async () => {
|
|
335
|
-
const sessionId = prepared.spec.sessionId ?? uuidv7();
|
|
336
|
-
const taskId = clientTaskId ?? uuidv7();
|
|
337
|
-
const created = await runStore.createRun(taskId, sessionId, prepared.auth?.principal ?? null, deps.instanceId ?? "default", runMeta(prepared, source));
|
|
338
|
-
// #245 S1(codex S1-F2):新 run 行落库即清同 taskId 的旧世代活性残留——session purge 后客户端合法
|
|
339
|
-
// 复用自带 taskId 重提交时,旧记录会被读成「刚有活性」的假证据(map 无 generation 轴,清是唯一诚实解)。
|
|
340
|
-
if (created.ok)
|
|
341
|
-
clearTurnActivity(taskId);
|
|
342
|
-
if (created.ok)
|
|
343
|
-
deps.sessionTitler?.maybeTitle(sessionId, prepared.spec.objective, typeof prepared.spec.model === "string" ? prepared.spec.model : prepared.spec.model?.id); // fire-and-forget;model 跟 turn([1992]②)
|
|
344
|
-
if (!created.ok) {
|
|
345
|
-
// race twin: two concurrent same-taskId submits (gateway retry racing the original) — the loser's
|
|
346
|
-
// session claim collides with ITS OWN taskId → idempotent 202 replay, not a 409 (the run exists, once).
|
|
347
|
-
if (clientTaskId && created.activeTaskId === clientTaskId) {
|
|
348
|
-
return { status: 202, body: { taskId: clientTaskId, sessionId, status: "running" } };
|
|
349
|
-
}
|
|
350
|
-
// [2255]① 真出路材料:activeTaskStatus + parked 时 pendingGate{kind,decidePath}(best-effort,失败退旧形)
|
|
351
|
-
return { status: 409, body: await buildActiveRunConflict({ runStore, checkpointStore: deps.checkpointStore, governance: deps.config, turnActivity: readTurnActivityMs }, sessionId, created.activeTaskId) };
|
|
352
|
-
}
|
|
353
|
-
// Durable F4: persist the resume rebuild inputs (sessionId-keyed) so an operator can resume from any
|
|
354
|
-
// replica even after this worker is gone — core's checkpoint blob can't carry service scenario context.
|
|
355
|
-
if (deps.checkpointStore) {
|
|
356
|
-
await deps.checkpointStore.putCtx(sessionId, { body: prepared.body, memoryScope: prepared.auth?.memoryScope });
|
|
357
|
-
}
|
|
358
|
-
// E18 resume-at: capture each completed turn's (message eventId → leaf entryId) anchor so the shell can later
|
|
359
|
-
// rewind to that message. Closes over THIS run's sessionId/owner + the session store's getLeafId + the anchor
|
|
360
|
-
// store. undefined ⇒ no anchor store / no getLeafId (the env-only deploy) → resume-at simply isn't captured.
|
|
361
|
-
const anchorOwner = prepared.auth?.principal ?? null;
|
|
362
|
-
const getLeafId = deps.sessionStorage?.getLeafId?.bind(deps.sessionStorage);
|
|
363
|
-
const captureTurnAnchor = deps.resumeAnchorStore && getLeafId
|
|
364
|
-
? async (eventId) => {
|
|
365
|
-
const leaf = await getLeafId(sessionId);
|
|
366
|
-
if (leaf)
|
|
367
|
-
await deps.resumeAnchorStore.put(sessionId, eventId, leaf, anchorOwner);
|
|
368
|
-
}
|
|
369
|
-
: undefined;
|
|
370
|
-
// R8 (CC-parity rewind): the USER-message anchor — keyed by THIS run's taskId (the handle the shell holds
|
|
371
|
-
// from the 202 response), value = the user-message `entryId` straight from `message_committed{role:"user"}`
|
|
372
|
-
// (always a valid resumeAt target). No `getLeafId` (the entryId is on the event), so it works even on a
|
|
373
|
-
// store that lacks getLeafId. This is the "rewind to the prompt" target = the code-restore parity path.
|
|
374
|
-
const captureUserMessageAnchor = deps.resumeAnchorStore
|
|
375
|
-
? async (entryId) => { await deps.resumeAnchorStore.put(sessionId, taskId, entryId, anchorOwner); }
|
|
376
|
-
: undefined;
|
|
377
|
-
// MF-Fleet: a background (202) run is a durable run → it gets a live fleet row exactly like the sync leg.
|
|
378
|
-
// runInBackground fires onStart/onEvent/onTerminal across ALL its legs (verify/cascade/plain-stream). No-op
|
|
379
|
-
// when no fleetBus is wired (the publisher is a null-object).
|
|
380
|
-
const fleetPub = fleetRunPublisher(deps.fleetBus, {
|
|
381
|
-
runId: taskId,
|
|
382
|
-
// 🔒 Scope the fleet row with the SAME identity GET /v1/fleet/stream FILTERS by — `gatedPrincipal`
|
|
383
|
-
// (crypto-verified on a direct door), NOT `prepared.auth.principal` (= the spoofable header). On a direct
|
|
384
|
-
// door the two diverge: a spoofed header tags the row under a victim's scope (cross-tenant leak) or under
|
|
385
|
-
// "default" while the verified owner's stream filters it OUT (the row vanishes). TOC review #6.
|
|
386
|
-
scope: gatedPrincipal(req, deps.config) ?? "default",
|
|
387
|
-
// core 1.154 nested-subagent tree: rootTaskId = the run's CANONICAL core taskId (`spec.taskId ?? sessionId`).
|
|
388
|
-
// The background leg sets `spec.taskId = taskId` (runs.ts), so canonical == taskId == runId — a forwarded
|
|
389
|
-
// subagent whose `parentTaskId` equals this nests under the run; deeper ones nest under their parent subagent.
|
|
390
|
-
rootTaskId: taskId,
|
|
391
|
-
...fleetRunLabels(prepared.spec.objective), // BC-1: name = short objective preview (description = live-activity, set by the publisher onEvent tool_start)
|
|
392
|
-
});
|
|
393
|
-
void runInBackground(prepared.runner, // #196:场景 hands 判别位选出的 Runner(hands=none ⇒ 无手孪生)
|
|
394
|
-
{ ...prepared.spec, sessionId }, runStore, taskId, deps.metrics, prepared.auth?.principal, prepared.verify, prepared.cascade ? cascadeConfig(deps.config.cascadeLadder, prepared.spec.limits?.maxCostUsd) : undefined, deps.instrumentDegenerate, deps.planCacheProbe, deps.config.traceThinking, inflightRuns, preemptableRuns, steerableRuns, deps.modelUsage, deps.elicitation, // E23: per-run elicitation context (onElicit routes inbound MCP elicitations to this run's stream)
|
|
395
|
-
gatedPrincipal(req, deps.config) ?? null, // E23: the VERIFIED principal that may answer (same source the respond gate uses — never the spoofable header)
|
|
396
|
-
captureTurnAnchor, // E18: per-turn (message eventId → leaf entryId) anchor capture
|
|
397
|
-
fleetPub, // MF-Fleet: the run-scoped fleet-row publisher (onStart/onEvent/onTerminal across all legs)
|
|
398
|
-
captureUserMessageAnchor, // R8: per-prompt (taskId → user-message entryId) anchor — CC "rewind to the prompt"
|
|
399
|
-
deps.question, // §4④: per-run AskUserQuestion context (onQuestion routes the agent's asks to this run's stream)
|
|
400
|
-
deps.workflowCompletionInbox, // P1 ①② follow-on: drain pending workflow completions into this leg's durable log at start
|
|
401
|
-
deps.subagentSteerRegistry, // C2 (core 1.219): register sync-delegation steer handles under this run's taskId
|
|
402
|
-
(m, x) => deps.logger?.info?.(m, x), // emit-target diagnostics for the bg leg's inbox drain
|
|
403
|
-
deps.sendUserFile, // SendUserFile 切片2:file_link 帧走本腿 durable append
|
|
404
|
-
deps.promptManifests, // [998]②: drained into a durable prompt_assembled event by this leg
|
|
405
|
-
// #151 车3 刀 3b:bg 腿的审批 ALS(此前这条腿无 ctx ⇒ 每只 ask 恒 "unavailable" ⇒ 恒 park)。
|
|
406
|
-
// 协议上场判据在**路由层**求值(单一谓词),执行腿只消费布尔——runs.ts 是纯执行面,不读 backend/config。
|
|
407
|
-
deps.toolApproval
|
|
408
|
-
? {
|
|
409
|
-
coordinator: deps.toolApproval,
|
|
410
|
-
streamApprovalOn: resolveStreamApprovalGate({
|
|
411
|
-
toolApprovalEnabled: true,
|
|
412
|
-
streamApprovalEnabled: deps.config.streamApproval?.enabled === true,
|
|
413
|
-
backend: deps.backend,
|
|
414
|
-
parkFacility: deps.checkpointStore !== undefined,
|
|
415
|
-
}).active,
|
|
416
|
-
// 两个窗旋钮随传:窗=0 的三源判定要在**本腿开始执行的时刻**做(那是 runInBackground 内部),
|
|
417
|
-
// 路由层只负责把配置递过去 —— runs.ts 是纯执行面,不读 config。
|
|
418
|
-
windowMs: deps.config.streamApproval?.windowMs ?? 0,
|
|
419
|
-
windowMarginMs: deps.config.streamAskWindowMarginMs,
|
|
420
|
-
}
|
|
421
|
-
: undefined);
|
|
422
|
-
return { status: 202, body: { taskId, sessionId, status: "running" } };
|
|
423
|
-
}, (r) => r.status === 202); // don't cache a transient 409 (session-active, billed nothing) — let a retry re-run (council)
|
|
304
|
+
// DESIGN-269 车2 件2:建行+驱动段整段搬进具名腿 {@link createDurableRun} —— A2A 的 `message/send`
|
|
305
|
+
// 复用**同一条**提交链(第二条提交路径=第二写者,禁)。本处逐字等价于搬走前:腿返回收据,路由发它。
|
|
306
|
+
const resp = await createDurableRun(req, res, ctx, prepared, idemKey);
|
|
307
|
+
if (!resp)
|
|
308
|
+
return; // 腿已应答(400 body.taskId 坏形 / 409 foreign owner / 202 durable 重放 / 门拒)
|
|
424
309
|
sendJson(res, resp.status, resp.body);
|
|
425
310
|
return;
|
|
426
311
|
}
|
|
@@ -2085,4 +1970,162 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
2085
1970
|
}
|
|
2086
1971
|
miss.fell = true;
|
|
2087
1972
|
}
|
|
1973
|
+
/**
|
|
1974
|
+
* DESIGN-269 车2 件2 —— `POST /v1/runs` 的**建行 + 驱动**段,从路由体里原样搬出来的具名腿。
|
|
1975
|
+
*
|
|
1976
|
+
* 🔴 为什么提取:A2A 的 `message/send`(routes/a2a-serve.ts)必须走**同一条**提交链 —— 第二条提交
|
|
1977
|
+
* 路径就是第二个写者,而这条链上挂着 durable taskId 幂等重放、lease/治理窗 pre-admission、
|
|
1978
|
+
* `createRun` 的会话 claim、checkpoint ctx 落库、fleet 行发布、resume/rewind 锚点捕获、审批 ALS……
|
|
1979
|
+
* 手抄一份就是把这十来件事复制成两份会漂的实现(而漂开的那天没有任何东西会说话)。
|
|
1980
|
+
*
|
|
1981
|
+
* **纯搬运**:函数体逐字来自搬走前的路由体(唯一改动=统一去缩进两格 + 三个「已应答」的裸 `return;`
|
|
1982
|
+
* 写成 `return null;`,语义一字未变 —— 调用方拿到 `null` 就是「腿已经自己应答了」)。
|
|
1983
|
+
*
|
|
1984
|
+
* 契约:
|
|
1985
|
+
* · 返回 `null` = **已应答**(400 `body.taskId` 坏形 / 409 foreign owner / 202 durable 重放 /
|
|
1986
|
+
* lease·治理窗拒),调用方立即 return,不得再写响应体。
|
|
1987
|
+
* · 返回 `{status, body}` = 本次提交的收据(202 = 新行已建并在跑;409 = 会话占用冲突,**没有**建行)。
|
|
1988
|
+
* 调用方自己决定怎么发(HTTP 路由 `sendJson`;A2A 腿翻成协议对象或具名 RPC 错误)。
|
|
1989
|
+
* · 调用前必须先过 `prepareSpec`(本腿吃它的产物)与 `deps.runStore` 在场判据 —— 后者本腿内另有
|
|
1990
|
+
* 防御性复检(缺席 ⇒ 501),但两个调用方都在自己的入口先判过,那才是各自面上的正确错误形。
|
|
1991
|
+
*/
|
|
1992
|
+
export async function createDurableRun(req, res, ctx, prepared,
|
|
1993
|
+
/** in-memory 幂等键(`POST /v1/runs` = `Idempotency-Key` 头派生;A2A = `message.messageId` 派生)。
|
|
1994
|
+
* undefined = 本次提交不参与 in-memory 去重(durable `body.taskId` 重放是独立的第二道)。 */
|
|
1995
|
+
idemKey) {
|
|
1996
|
+
const { deps } = ctx;
|
|
1997
|
+
const { idemCache, inflightRuns, preemptableRuns, steerableRuns } = ctx.registry;
|
|
1998
|
+
const { leaseDenied, usageWindowDenied } = ctx.helpers;
|
|
1999
|
+
const source = ctx.req.source;
|
|
2000
|
+
if (!deps.runStore) {
|
|
2001
|
+
// 防御性复检:两个调用方都在自己的入口先判过(各自面上的正确错误形不同),走到这里说明有人漏判。
|
|
2002
|
+
sendError(res, 501, "capability.run_store_required", "async runs require a durable run store (DB_BACKEND=mysql|pg) (SESSION_BACKEND=tidb)");
|
|
2003
|
+
return null;
|
|
2004
|
+
}
|
|
2005
|
+
const runStore = deps.runStore;
|
|
2006
|
+
// dispatch-gateway failover prerequisite: OPTIONAL caller-minted `body.taskId` idempotency. Unlike the
|
|
2007
|
+
// Idempotency-Key header (in-memory, per-instance), this replay reads the DURABLE run store, so a gateway that
|
|
2008
|
+
// re-routes a submit to ANOTHER instance after a network error cannot start a second run. uuidv7-validated
|
|
2009
|
+
// (same alphabet the server mints — a caller can't inject an arbitrary PK shape); owner-gated replay mirrors
|
|
2010
|
+
// the GET /v1/runs/:id gate (a foreign taskId reads as 404-shaped conflict, not another tenant's run ids).
|
|
2011
|
+
const rawClientTaskId = prepared.body.taskId;
|
|
2012
|
+
if (rawClientTaskId !== undefined && (typeof rawClientTaskId !== "string" || !isUuidV7(rawClientTaskId))) {
|
|
2013
|
+
sendError(res, 400, "request.id_invalid", "body.taskId must be a uuidv7 string (caller-minted idempotency key)");
|
|
2014
|
+
return null;
|
|
2015
|
+
}
|
|
2016
|
+
const clientTaskId = rawClientTaskId;
|
|
2017
|
+
if (clientTaskId) {
|
|
2018
|
+
const existing = await runStore.getRun(clientTaskId);
|
|
2019
|
+
if (existing) {
|
|
2020
|
+
const verified = verifiedPrincipal(req, deps.config);
|
|
2021
|
+
if (existing.owner !== null && existing.owner !== verified) {
|
|
2022
|
+
sendError(res, 409, "conflict.run_exists", "taskId already exists"); // foreign owner: conflict, no replay (no cross-tenant oracle)
|
|
2023
|
+
return null;
|
|
2024
|
+
}
|
|
2025
|
+
// 🔴 codex 轮 H2b 随修:这一臂原本自己 `sendJson(202)` 然后 return null(= 已应答)。搬成具名腿
|
|
2026
|
+
// 之后那个姿势有第二个调用方了 —— A2A 腿拿到 null 只能沉默,而**响应已经以裸 HTTP 202 发出去**,
|
|
2027
|
+
// 对一个 JSON-RPC 调用方就是一帧协议外的垃圾。改成**返回收据**:`/v1/runs` 那侧照旧
|
|
2028
|
+
// `sendJson(res, resp.status, resp.body)`,逐字节同形;A2A 那侧把同一份收据渲成协议对象。
|
|
2029
|
+
// 判词:腿只产收据,**协议渲染归调用方** —— 一条腿服务两种线协议时,这是唯一不撒谎的分工。
|
|
2030
|
+
return { status: 202, body: { taskId: existing.taskId, sessionId: existing.sessionId, status: existing.status } };
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
// 🔴 复审 C1:lease gate for a genuinely-NEW submission (past the idempotency replay above). Keyed on the
|
|
2034
|
+
// VERIFIED submitter = the principal billed for this run's spend (correct principal for the sync door).
|
|
2035
|
+
// design/166-T1:治理窗 pre-admission 同位(idempotency replay 之后——重放已建 run 必须拿回 202 收据,
|
|
2036
|
+
// 不得因窗满改判 429;异步 202 车道的唯一 429 出路,core 进场拒是竞态窗兜底)。
|
|
2037
|
+
if ((await leaseDenied(req, res)) || (await usageWindowDenied(req, res)))
|
|
2038
|
+
return null;
|
|
2039
|
+
const resp = await idemCache.run(idemKey, async () => {
|
|
2040
|
+
const sessionId = prepared.spec.sessionId ?? uuidv7();
|
|
2041
|
+
const taskId = clientTaskId ?? uuidv7();
|
|
2042
|
+
const created = await runStore.createRun(taskId, sessionId, prepared.auth?.principal ?? null, deps.instanceId ?? "default", runMeta(prepared, source));
|
|
2043
|
+
// #245 S1(codex S1-F2):新 run 行落库即清同 taskId 的旧世代活性残留——session purge 后客户端合法
|
|
2044
|
+
// 复用自带 taskId 重提交时,旧记录会被读成「刚有活性」的假证据(map 无 generation 轴,清是唯一诚实解)。
|
|
2045
|
+
if (created.ok)
|
|
2046
|
+
clearTurnActivity(taskId);
|
|
2047
|
+
if (created.ok)
|
|
2048
|
+
deps.sessionTitler?.maybeTitle(sessionId, prepared.spec.objective, typeof prepared.spec.model === "string" ? prepared.spec.model : prepared.spec.model?.id); // fire-and-forget;model 跟 turn([1992]②)
|
|
2049
|
+
if (!created.ok) {
|
|
2050
|
+
// race twin: two concurrent same-taskId submits (gateway retry racing the original) — the loser's
|
|
2051
|
+
// session claim collides with ITS OWN taskId → idempotent 202 replay, not a 409 (the run exists, once).
|
|
2052
|
+
if (clientTaskId && created.activeTaskId === clientTaskId) {
|
|
2053
|
+
return { status: 202, body: { taskId: clientTaskId, sessionId, status: "running" } };
|
|
2054
|
+
}
|
|
2055
|
+
// [2255]① 真出路材料:activeTaskStatus + parked 时 pendingGate{kind,decidePath}(best-effort,失败退旧形)
|
|
2056
|
+
return { status: 409, body: await buildActiveRunConflict({ runStore, checkpointStore: deps.checkpointStore, governance: deps.config, turnActivity: readTurnActivityMs }, sessionId, created.activeTaskId) };
|
|
2057
|
+
}
|
|
2058
|
+
// Durable F4: persist the resume rebuild inputs (sessionId-keyed) so an operator can resume from any
|
|
2059
|
+
// replica even after this worker is gone — core's checkpoint blob can't carry service scenario context.
|
|
2060
|
+
if (deps.checkpointStore) {
|
|
2061
|
+
await deps.checkpointStore.putCtx(sessionId, { body: prepared.body, memoryScope: prepared.auth?.memoryScope });
|
|
2062
|
+
}
|
|
2063
|
+
// E18 resume-at: capture each completed turn's (message eventId → leaf entryId) anchor so the shell can later
|
|
2064
|
+
// rewind to that message. Closes over THIS run's sessionId/owner + the session store's getLeafId + the anchor
|
|
2065
|
+
// store. undefined ⇒ no anchor store / no getLeafId (the env-only deploy) → resume-at simply isn't captured.
|
|
2066
|
+
const anchorOwner = prepared.auth?.principal ?? null;
|
|
2067
|
+
const getLeafId = deps.sessionStorage?.getLeafId?.bind(deps.sessionStorage);
|
|
2068
|
+
const captureTurnAnchor = deps.resumeAnchorStore && getLeafId
|
|
2069
|
+
? async (eventId) => {
|
|
2070
|
+
const leaf = await getLeafId(sessionId);
|
|
2071
|
+
if (leaf)
|
|
2072
|
+
await deps.resumeAnchorStore.put(sessionId, eventId, leaf, anchorOwner);
|
|
2073
|
+
}
|
|
2074
|
+
: undefined;
|
|
2075
|
+
// R8 (CC-parity rewind): the USER-message anchor — keyed by THIS run's taskId (the handle the shell holds
|
|
2076
|
+
// from the 202 response), value = the user-message `entryId` straight from `message_committed{role:"user"}`
|
|
2077
|
+
// (always a valid resumeAt target). No `getLeafId` (the entryId is on the event), so it works even on a
|
|
2078
|
+
// store that lacks getLeafId. This is the "rewind to the prompt" target = the code-restore parity path.
|
|
2079
|
+
const captureUserMessageAnchor = deps.resumeAnchorStore
|
|
2080
|
+
? async (entryId) => { await deps.resumeAnchorStore.put(sessionId, taskId, entryId, anchorOwner); }
|
|
2081
|
+
: undefined;
|
|
2082
|
+
// MF-Fleet: a background (202) run is a durable run → it gets a live fleet row exactly like the sync leg.
|
|
2083
|
+
// runInBackground fires onStart/onEvent/onTerminal across ALL its legs (verify/cascade/plain-stream). No-op
|
|
2084
|
+
// when no fleetBus is wired (the publisher is a null-object).
|
|
2085
|
+
const fleetPub = fleetRunPublisher(deps.fleetBus, {
|
|
2086
|
+
runId: taskId,
|
|
2087
|
+
// 🔒 Scope the fleet row with the SAME identity GET /v1/fleet/stream FILTERS by — `gatedPrincipal`
|
|
2088
|
+
// (crypto-verified on a direct door), NOT `prepared.auth.principal` (= the spoofable header). On a direct
|
|
2089
|
+
// door the two diverge: a spoofed header tags the row under a victim's scope (cross-tenant leak) or under
|
|
2090
|
+
// "default" while the verified owner's stream filters it OUT (the row vanishes). TOC review #6.
|
|
2091
|
+
scope: gatedPrincipal(req, deps.config) ?? "default",
|
|
2092
|
+
// core 1.154 nested-subagent tree: rootTaskId = the run's CANONICAL core taskId (`spec.taskId ?? sessionId`).
|
|
2093
|
+
// The background leg sets `spec.taskId = taskId` (runs.ts), so canonical == taskId == runId — a forwarded
|
|
2094
|
+
// subagent whose `parentTaskId` equals this nests under the run; deeper ones nest under their parent subagent.
|
|
2095
|
+
rootTaskId: taskId,
|
|
2096
|
+
...fleetRunLabels(prepared.spec.objective), // BC-1: name = short objective preview (description = live-activity, set by the publisher onEvent tool_start)
|
|
2097
|
+
});
|
|
2098
|
+
void runInBackground(prepared.runner, // #196:场景 hands 判别位选出的 Runner(hands=none ⇒ 无手孪生)
|
|
2099
|
+
{ ...prepared.spec, sessionId }, runStore, taskId, deps.metrics, prepared.auth?.principal, prepared.verify, prepared.cascade ? cascadeConfig(deps.config.cascadeLadder, prepared.spec.limits?.maxCostUsd) : undefined, deps.instrumentDegenerate, deps.planCacheProbe, deps.config.traceThinking, inflightRuns, preemptableRuns, steerableRuns, deps.modelUsage, deps.elicitation, // E23: per-run elicitation context (onElicit routes inbound MCP elicitations to this run's stream)
|
|
2100
|
+
gatedPrincipal(req, deps.config) ?? null, // E23: the VERIFIED principal that may answer (same source the respond gate uses — never the spoofable header)
|
|
2101
|
+
captureTurnAnchor, // E18: per-turn (message eventId → leaf entryId) anchor capture
|
|
2102
|
+
fleetPub, // MF-Fleet: the run-scoped fleet-row publisher (onStart/onEvent/onTerminal across all legs)
|
|
2103
|
+
captureUserMessageAnchor, // R8: per-prompt (taskId → user-message entryId) anchor — CC "rewind to the prompt"
|
|
2104
|
+
deps.question, // §4④: per-run AskUserQuestion context (onQuestion routes the agent's asks to this run's stream)
|
|
2105
|
+
deps.workflowCompletionInbox, // P1 ①② follow-on: drain pending workflow completions into this leg's durable log at start
|
|
2106
|
+
deps.subagentSteerRegistry, // C2 (core 1.219): register sync-delegation steer handles under this run's taskId
|
|
2107
|
+
(m, x) => deps.logger?.info?.(m, x), // emit-target diagnostics for the bg leg's inbox drain
|
|
2108
|
+
deps.sendUserFile, // SendUserFile 切片2:file_link 帧走本腿 durable append
|
|
2109
|
+
deps.promptManifests, // [998]②: drained into a durable prompt_assembled event by this leg
|
|
2110
|
+
// #151 车3 刀 3b:bg 腿的审批 ALS(此前这条腿无 ctx ⇒ 每只 ask 恒 "unavailable" ⇒ 恒 park)。
|
|
2111
|
+
// 协议上场判据在**路由层**求值(单一谓词),执行腿只消费布尔——runs.ts 是纯执行面,不读 backend/config。
|
|
2112
|
+
deps.toolApproval
|
|
2113
|
+
? {
|
|
2114
|
+
coordinator: deps.toolApproval,
|
|
2115
|
+
streamApprovalOn: resolveStreamApprovalGate({
|
|
2116
|
+
toolApprovalEnabled: true,
|
|
2117
|
+
streamApprovalEnabled: deps.config.streamApproval?.enabled === true,
|
|
2118
|
+
backend: deps.backend,
|
|
2119
|
+
parkFacility: deps.checkpointStore !== undefined,
|
|
2120
|
+
}).active,
|
|
2121
|
+
// 两个窗旋钮随传:窗=0 的三源判定要在**本腿开始执行的时刻**做(那是 runInBackground 内部),
|
|
2122
|
+
// 路由层只负责把配置递过去 —— runs.ts 是纯执行面,不读 config。
|
|
2123
|
+
windowMs: deps.config.streamApproval?.windowMs ?? 0,
|
|
2124
|
+
windowMarginMs: deps.config.streamAskWindowMarginMs,
|
|
2125
|
+
}
|
|
2126
|
+
: undefined);
|
|
2127
|
+
return { status: 202, body: { taskId, sessionId, status: "running" } };
|
|
2128
|
+
}, (r) => r.status === 202); // don't cache a transient 409 (session-active, billed nothing) — let a retry re-run (council)
|
|
2129
|
+
return resp;
|
|
2130
|
+
}
|
|
2088
2131
|
//# sourceMappingURL=runs.js.map
|
|
@@ -25,6 +25,17 @@ export interface SessionsLocal {
|
|
|
25
25
|
degraded?: boolean;
|
|
26
26
|
}>;
|
|
27
27
|
}>;
|
|
28
|
+
/** DESIGN-269 件4:`/a2a` 面板的短 TTL single-flight 缓存。与 `mcpStatusCache` **分开一张表**而不是
|
|
29
|
+
* 共用带前缀的键:两张面板的体不同形(`peers` vs `servers`),塞进一张表就得靠 unknown 兜住两形,
|
|
30
|
+
* 于是「取到的是哪一形」变成运行期约定 —— 两张表让它回到类型上。 */
|
|
31
|
+
a2aStatusCache: Map<string, {
|
|
32
|
+
at: number;
|
|
33
|
+
panel: Promise<{
|
|
34
|
+
peers: unknown[];
|
|
35
|
+
asOf: string;
|
|
36
|
+
degraded?: boolean;
|
|
37
|
+
}>;
|
|
38
|
+
}>;
|
|
28
39
|
}
|
|
29
40
|
export declare function createSessionsLocal(): SessionsLocal;
|
|
30
41
|
export declare function handleSessions(req: IncomingMessage, res: ServerResponse, url: string, ctx: RouteCtx): Promise<boolean>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { materializeMcpTools, sanitizePathComponent, SessionPolicyError } from "@sema-agent/core";
|
|
2
|
-
import { mcpForScenario } from "../../config-center/facade.js";
|
|
1
|
+
import { materializeA2aTools, materializeMcpTools, sanitizePathComponent, SessionPolicyError } from "@sema-agent/core";
|
|
2
|
+
import { a2aForScenario, mcpForScenario } from "../../config-center/facade.js";
|
|
3
3
|
import { isUuidV7, isUuidShape } from "../../security.js";
|
|
4
4
|
import { windowMessages, truncateMessageBlobs } from "../../audit.js";
|
|
5
5
|
import { redactSecrets } from "../../trace/redact.js";
|
|
@@ -10,6 +10,14 @@ import { sendJson, sendError, sseHeaders } from "../send.js";
|
|
|
10
10
|
import { gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
|
|
11
11
|
const MCP_STATUS_TTL_MS = 5_000;
|
|
12
12
|
const MCP_MATERIALIZE_TIMEOUT_MS = 10_000;
|
|
13
|
+
// DESIGN-269 件4:A2A 读面的两枚同族时限。**刻意与 MCP 分开命名而不复用同一个常量**:两条腿的物化
|
|
14
|
+
// 成本不同族(MCP 可能 spawn 子进程/开长连;A2A 只是几个 HTTPS 卡请求),将来任一侧要调窗时不该被
|
|
15
|
+
// 另一侧的名字绑架。数值今天相同,语义各自独立。
|
|
16
|
+
const A2A_STATUS_TTL_MS = 5_000;
|
|
17
|
+
const A2A_MATERIALIZE_TIMEOUT_MS = 10_000;
|
|
18
|
+
/** 面板上每个 peer 最多回多少条工具名(codex finding 5)。超出只截**表**,`toolCount` 仍是真数、
|
|
19
|
+
* `toolNamesTruncated:true` 显式说明 —— 消费端读得出「还有更多」,而不是拿到一份看着完整的假名单。 */
|
|
20
|
+
const A2A_PANEL_MAX_TOOL_NAMES = 200;
|
|
13
21
|
export function createSessionsLocal() {
|
|
14
22
|
return {
|
|
15
23
|
// [1196] session-events SSE 活跃连接计数(连接帽谓词;帽=deps.sessionEventsMaxConnections ?? 256)。
|
|
@@ -23,6 +31,9 @@ export function createSessionsLocal() {
|
|
|
23
31
|
// correct; stores the in-flight PROMISE so concurrent first-polls collapse to ONE materialize. Bounded by TTL +
|
|
24
32
|
// a hard size cap (cleared on overflow). The cached value is the PROJECTED panel (no live connections held).
|
|
25
33
|
mcpStatusCache: new Map(),
|
|
34
|
+
// DESIGN-269 件4 —— 同上那套(TTL + single-flight + 硬条数帽 + 按 (scenario, principal) 分键,因为
|
|
35
|
+
// `principalHeader` 注入让不同调用方看到的 peer 授权面可能不同),A2A 半场。
|
|
36
|
+
a2aStatusCache: new Map(),
|
|
26
37
|
};
|
|
27
38
|
}
|
|
28
39
|
export async function handleSessions(req, res, url, ctx) {
|
|
@@ -36,7 +47,7 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
|
|
|
36
47
|
const { deps } = ctx;
|
|
37
48
|
const { readJson, safeDecode, runOwnerOk, sessionOwnerScope, sessionOwnerScopeForWrite } = ctx.helpers;
|
|
38
49
|
const sse = ctx.local.sessions;
|
|
39
|
-
const { mcpStatusCache } = ctx.local.sessions;
|
|
50
|
+
const { mcpStatusCache, a2aStatusCache } = ctx.local.sessions;
|
|
40
51
|
// E11 (shell-host contract): GET /v1/sessions/:id/init — the one-call startup bundle, so the shell renders its
|
|
41
52
|
// banner from ONE request instead of fanning out to /v1/models + /v1/policy + /v1/capabilities. Worker-global
|
|
42
53
|
// config surface (same posture as /v1/policy): principal OPTIONAL, enforced only under REQUIRE_PRINCIPAL.
|
|
@@ -147,6 +158,92 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
|
|
|
147
158
|
return;
|
|
148
159
|
}
|
|
149
160
|
}
|
|
161
|
+
// [DESIGN-269 车1 件4] GET /v1/sessions/:id/a2a — per-peer A2A status for the shell's agent panel.
|
|
162
|
+
// 逐点对称上面的 /mcp 面(按需物化 → 投影 → TTL single-flight 缓存 → 超时竞速 + 迟到 dispose),
|
|
163
|
+
// 差别只在**投影形状**,而那是被裁定过的(设计稿 v1.2 §2.5,cli [4107] 采纳级意见):
|
|
164
|
+
//
|
|
165
|
+
// 🔴 **不发裸 `status`**。core 的 `A2aPeerStatus.status:"ready"` 的真语义是「本次探测时,卡取到了、
|
|
166
|
+
// skills 铸出来了」——A2A **不持连接**(每次调用都是独立 HTTPS 请求),所以它永远不是「peer 现在活着」。
|
|
167
|
+
// 一个叫 `status:"ready"` 的键发给三个消费端,必有一端渲成「在线」,而那句话我们证不了。所以投影的
|
|
168
|
+
// 键名自己承重语义:`cardFetched: boolean`(卡取到没有)+ `probedAt: number`(**本次**探测时刻)+
|
|
169
|
+
// 可选 `error`。「在线/离线」这个词在本读面结构性不存在,不是靠文档约束不许渲。
|
|
170
|
+
// `asOf` 与 `probedAt` 并存不是冗余:前者是**面板**的物化时刻(ISO,与 /mcp 同形,壳渲 "as of …"),
|
|
171
|
+
// 后者是**每个 peer** 的探测时刻戳(机器可读,消费端算新鲜度用)。
|
|
172
|
+
{
|
|
173
|
+
const am = /^\/v1\/sessions\/([^/]+)\/a2a$/.exec(url);
|
|
174
|
+
if (am && req.method === "GET") {
|
|
175
|
+
if (deps.config.requirePrincipal && !gatedPrincipal(req, deps.config)) {
|
|
176
|
+
sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const specs = a2aForScenario(deps.config.a2aPeers, deps.config.defaultScenario);
|
|
180
|
+
if (!specs || specs.length === 0) {
|
|
181
|
+
sendJson(res, 200, { peers: [], asOf: new Date().toISOString() }); // no peers configured → honest empty panel
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const principal = gatedPrincipal(req, deps.config); // threaded for per-user peer authz (principalHeader injection)
|
|
185
|
+
const cacheKey = `${deps.config.defaultScenario}::${principal ?? ""}`;
|
|
186
|
+
const cached = a2aStatusCache.get(cacheKey);
|
|
187
|
+
if (!cached || Date.now() - cached.at > A2A_STATUS_TTL_MS) {
|
|
188
|
+
if (a2aStatusCache.size > 1024)
|
|
189
|
+
a2aStatusCache.clear(); // hard size cap (per-principal keys)
|
|
190
|
+
const panel = (async () => {
|
|
191
|
+
// Bound the card fetches so a hanging peer can't pin the request; dispose the late resolver if it
|
|
192
|
+
// completes after the timeout (materializeA2aTools holds no socket, but its dispose IS the lifetime
|
|
193
|
+
// statement — a tool that outlives this probe must fail loudly instead of calling a remote agent).
|
|
194
|
+
const matP = materializeA2aTools(specs, principal);
|
|
195
|
+
let timer;
|
|
196
|
+
const mat = await Promise.race([matP.catch(() => null), new Promise((r) => { timer = setTimeout(() => r(null), A2A_MATERIALIZE_TIMEOUT_MS); })]);
|
|
197
|
+
if (timer)
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
const probedAt = Date.now();
|
|
200
|
+
if (!mat) {
|
|
201
|
+
void matP.then((m) => m?.dispose().catch(() => undefined)).catch(() => undefined);
|
|
202
|
+
return { peers: [], asOf: new Date(probedAt).toISOString(), degraded: true }; // timed out / failed to materialize
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
return {
|
|
206
|
+
asOf: new Date(probedAt).toISOString(),
|
|
207
|
+
peers: mat.statuses.map((s) => ({
|
|
208
|
+
name: s.name,
|
|
209
|
+
// 🔴 翻译点(唯一):core 的闭集 status → 承重键名。`"ready"` = 卡取到了 + skills 铸出来了。
|
|
210
|
+
cardFetched: s.status === "ready",
|
|
211
|
+
probedAt,
|
|
212
|
+
// 卡自述的两串是 UNTRUSTED(core 已中性化+限长);端点是协商结果,诊断用。
|
|
213
|
+
...(s.agentName ? { agentName: s.agentName } : {}),
|
|
214
|
+
...(s.agentDescription ? { agentDescription: s.agentDescription } : {}),
|
|
215
|
+
// 🔒 自审第三镜(泄密面):`endpoint` 是**卡自述**的协商结果 —— 远端可控文本,而且 URL 形
|
|
216
|
+
// 天然能带 userinfo(`https://user:pass@host/rpc`,core 的 `new URL()` 原样保留)。core 对
|
|
217
|
+
// name/description 有中性化+限长,对 endpoint 只验协议(http/https),所以脱敏与限长是**本
|
|
218
|
+
// 读面自己的活**(投影是它的输出,不是 core 的)。2048 = 通用 URL 上限量级,越界的是病态值。
|
|
219
|
+
...(s.endpoint ? { endpoint: redactSecrets(String(s.endpoint)).slice(0, 2048) } : {}),
|
|
220
|
+
// 🔴 codex 对抗复审 finding 5(medium,验真后采纳):技能**条数**是远端可控且无界的
|
|
221
|
+
// (core 限单个名字的长度,不限卡里的条数;卡体上限 8MiB),而这条只读 GET 按
|
|
222
|
+
// (scenario, principal) 分键缓存 ⇒ 多身份可放大。发**有界表 + 真计数 + 显式旗**,
|
|
223
|
+
// 而不是静默截断:一个会撒谎的读面比一个大的读面更贵。
|
|
224
|
+
...(s.toolNames
|
|
225
|
+
? {
|
|
226
|
+
toolCount: s.toolNames.length,
|
|
227
|
+
toolNames: s.toolNames.slice(0, A2A_PANEL_MAX_TOOL_NAMES),
|
|
228
|
+
...(s.toolNames.length > A2A_PANEL_MAX_TOOL_NAMES ? { toolNamesTruncated: true } : {}),
|
|
229
|
+
}
|
|
230
|
+
: {}),
|
|
231
|
+
// 与 /mcp 同规:原始错误可能带内部路径/凭据形 —— 脱敏 + 截断。
|
|
232
|
+
// 🔒 `headers`/`principalHeader` 在本投影里**根本没有出口**(白名单式铸键,不是删黑名单)。
|
|
233
|
+
...(s.error ? { error: redactSecrets(String(s.error)).slice(0, 200) } : {}),
|
|
234
|
+
})),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
await mat.dispose().catch(() => undefined);
|
|
239
|
+
}
|
|
240
|
+
})();
|
|
241
|
+
a2aStatusCache.set(cacheKey, { at: Date.now(), panel });
|
|
242
|
+
}
|
|
243
|
+
sendJson(res, 200, await a2aStatusCache.get(cacheKey).panel);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
150
247
|
// [1075]② 会话追随轻探针:GET /v1/sessions/:id/head → { leafId }(+ ETag/304)。leafId=会话树叶
|
|
151
248
|
// 指针,任何 append 都变=天然版本号——web 空闲期低成本轮询(If-None-Match 命中=304 零 body),
|
|
152
249
|
// 变了才拉 tail 增量。刻意不做 session 级 SSE(真订阅需跨副本通知面,写可发生在任意副本而流挂在
|
package/dist/http/server.d.ts
CHANGED
|
@@ -534,6 +534,23 @@ export declare function createHttpServer(rawDeps: ServiceDeps): http.Server & {
|
|
|
534
534
|
/** POST endpoints that trigger BILLABLE work — the fail-closed auth guard must cover ALL of them (council: the
|
|
535
535
|
* guard's inline list had drifted from the handlers and missed `/v1/approvals/:id/decide`, which resumes a run
|
|
536
536
|
* via resumeCheckpoint/store.decide → paid tokens). Keep this in sync when adding a billable POST route. */
|
|
537
|
+
/**
|
|
538
|
+
* 「这条 url **是**计费提交面,但它到底提不提交由**请求体里的方法**决定」—— 目前只有 A2A 的
|
|
539
|
+
* JSON-RPC 端点(单 URL 承载 `message/send` 写 + `tasks/get` 读,因为 agent card 只能公告一个 `url`)。
|
|
540
|
+
*
|
|
541
|
+
* 用途:两道**可用性** 503(drain / model-roster-pending)按 url 一刀切会把 `tasks/get` 这条纯读一起
|
|
542
|
+
* 关掉,而单副本部署恰好在重启/依赖降级窗口里最需要取回已完成的结果(codex 轮2 R2-3,验真后修)。
|
|
543
|
+
* 本谓词让 `handle()` 对这条 url **跳过**那两道,改由域模块在 `message/send` 分支里施加同样的两道
|
|
544
|
+
* (`routes/a2a-serve.ts` 的 `submitUnavailable`,文案逐字同源)。
|
|
545
|
+
*
|
|
546
|
+
* 🔴 **只豁免那两道,不豁免安全轴**:`isBillableSubmitPath` 本身**不变**(它仍答 true),所以
|
|
547
|
+
* ① 无 service token 的部署上那道 fail-closed 503 照旧罩着这条 url —— 它判的是「这台机能不能收写」,
|
|
548
|
+
* 与方法无关,而且门必须在解析请求体**之前**;
|
|
549
|
+
* ② billable 申明门钉的仍是那只纯谓词(表里 `/v1/a2a` 依旧 billable=true)。
|
|
550
|
+
* 新成员进本表 = 承诺「该域在自己的方法分支里补齐那两道」,漏补就是一条静默旁路;所以本表只该有
|
|
551
|
+
* 「单 URL 多方法」这一种成员,加一条要连同它的域内补门一起过评审。
|
|
552
|
+
*/
|
|
553
|
+
export declare function isMethodDispatchedSubmitPath(url: string): boolean;
|
|
537
554
|
export declare function isBillableSubmitPath(url: string): boolean;
|
|
538
555
|
/**
|
|
539
556
|
* A-010.23(#209 件5)—— **改写门**:不烧模型、但在**没有任何 service credential** 的部署形下必须与
|