/** * [ref] A9 — 路由域装配缝(`handle()` 按路由域拆多文件 + 装配器)。 * * `createHttpServer` 建**一次** `RouteCtxBase`(deps/registry/helpers/local 四格),`handle()` 每请求把它 * 加上一格 `req`(每请求局部量)交给按序尝试的域表。域模块只从这里取自己那份窄切片,**在函数首行解构** * (`const { deps } = ctx;` / `const { readJson } = ctx.helpers;`)——于是搬过去的路由体逐字不变。 * * 「已处理」语义:域入口 `handle(req, res, url, ctx): Promise`,true=本域已应答(调用方 * 立即 return),false=没匹配上、继续下一个域。实现姿势见 routes/*.ts:路由体原样放进一个返回 void 的 * 内层函数,函数尾把 `miss.fell = true`——原来的每一个裸 `return;` 于是天然等价于「已处理」,一处都不用改写 * (嵌套闭包里的 `return;` 语义也原样保住)。 * * 分层:routes/* 只许值 import http/ 的叶子(send.ts / principal-gate.ts / route-ctx.ts / tar.ts …)与 src 下 * 的普通模块,**绝不可值 import server.ts**——那条边会闭合运行时装载环(test/module-cycle-gate.test.ts)。 * 本文件对 server.ts 的引用一律 `import type`(tsc 擦除,非装载边)。 */ import type { IncomingMessage, ServerResponse } from "node:http"; import type { HostDecision } from "../host-decision.js"; import type { TaskStream, TaskSpec, TaskResult, QuestionAnswer, CheckpointToken, ResumeOutcome, RunInternals, Runner, EngineNotice } from "@sema-agent/core"; import type { FlatServiceDeps, RequestAuth } from "./server.js"; import type { TaskRequestBody, DecideBinding } from "./wire-types.js"; import type { IdempotencyCache, SubmitOutcome } from "./idempotency.js"; import type { ImagesLocal } from "./routes/images.js"; import type { CapabilitiesLocal } from "./routes/capabilities.js"; import type { SessionsLocal } from "./routes/sessions.js"; import type { SessionSyncLocal } from "./routes/session-sync.js"; import type { PlanApprovePermissionModeAfter } from "../task-settings.js"; import type { VerifyRoundsSpec } from "./verify-rounds.js"; /** 跨域可变状态 = lens1 §C2 实测的那 8 条「运行期登记簿」,一个对象。 */ export interface RunRegistry { idemCache: IdempotencyCache; inflightRuns: Map; preemptableRuns: Map; cancelledViaVerb: Set; steerableRuns: Map; wakeParkMints: Map>; /** 裸 number 的登记簿(`let` 拆不出闭包)—— 装进盒子才能跨模块自增/自减。 */ counters: { uncountedBillableInflight: number; admittedInflight: number; }; } /** 跨 ≥5 域的无状态助手(依赖只有 deps/config),外加同族的体读取器与 owner 门。 */ export interface RouteHelpers { readJson(req: IncomingMessage): Promise; readRawBody(req: IncomingMessage, max: number): Promise; rateLimited(req: IncomingMessage, res: ServerResponse): boolean; quotaExceeded(req: IncomingMessage, res: ServerResponse): boolean; leaseDenied(req: IncomingMessage, res: ServerResponse): Promise; /** * 🔴 **[ref]:按**付费**方(不是请求方)判的成本配额准入 —— {@link quotaExceeded} 的孪生。** * * `quotaExceeded` 键在 `gatedPrincipal(req)` = 谁点的按钮;本门键在**这笔钱记谁的账**。两者在自助 * 场景恒相等,在「显式 operator 代别人动手」的场景不等 —— 那正是配额上限被绕过的那一支。 * 参数 `owner` 由调用点从**已属主校验过的那一行**取(run 行的 `owner` / checkpoint 的 scope), * 本门不自己推身份(推得出来就说明调用点还有第二个身份源,那才是病)。 * `undefined`(匿名/开放行)⇒ 无付费方 ⇒ 不 gated,与 `quotaExceeded` 的成文语义同判。 * 拒 = 429 + `Retry-After`,码/文案/extras 与 `quotaExceeded` 逐字同一套。 * 位序纪律:**必须排在本路由的 owner 门之后**(反枚举 —— 否则 429 会变成「这行存在且它属主超顶了」 * 的谕示);且排在真正驱动模型之前。 */ ownerQuotaDenied(res: ServerResponse, owner: string | undefined): boolean; /** 🔴 **[ref]:{@link ownerQuotaDenied} 的 lease 孪生 —— 按**付费**方判的 fleet-lease 准入。** * `leaseDenied` 键在请求方;本门键在付费方(`driveResumeLeg` 的 `fleetLease.admit(卡属主)` 早就是这个 * 语义,本位把它交给路由域)。两门在自助场景恒同答,在「operator 代别人动手」时不同 —— 那正是 * 「拿自己的额度去烧别人的账」的那一支。同样必须排在 owner 门之后、驱动之前。 */ ownerLeaseDenied(res: ServerResponse, owner: string | undefined): Promise; /** [ref]-T1:治理窗 pre-admission(异步 202 车道的唯一 429 出路;key 公式与 core 单源)。 */ usageWindowDenied(req: IncomingMessage, res: ServerResponse): Promise; safeDecode(seg: string): string | null; isFleetWide(req: IncomingMessage): boolean; /** 属主门。`notFoundForm` 选 not-yours 臂的 404 拼法:缺省 `"run"`(not_found.run 长文,[ref]);session * 资源面传 `"session"`(not_found.session + "session not found")与其 unknown 臂同码同串(G52 [ref])。 */ runOwnerOk(req: IncomingMessage, res: ServerResponse, owner: string | null, notFoundForm?: "run" | "session"): boolean; sessionOwnerScope(req: IncomingMessage): { fleetWide: boolean; gateOwner: string | null; }; sessionOwnerScopeForWrite(req: IncomingMessage): { fleetWide: boolean; gateOwner: string | null; }; runSessionAcceptOk(req: IncomingMessage, res: ServerResponse, run: { sessionId: string | null; }, face: string, notFoundError?: string): boolean; } /** 单域独占状态(lens1 实测 12 条)——不进 registry,跟着各自的域模块走;每个 server 实例一份 * (模块级会把 rate-limiter / 缓存跨实例串味,测试里一个进程建几十个 server)。 */ export interface RouteLocals { images: ImagesLocal; /** S-481:`GET /v1/capabilities/mcp` / `POST /v1/capabilities/mcp/probe` 的 TTL 缓存与每 principal 限速窗。 */ capabilities: CapabilitiesLocal; sessions: SessionsLocal; sessionSync: SessionSyncLocal; } /** 每请求局部量里**跨域**的那几个(lens1 §C2:record 的日志闩 + tasks/stream 写、prelude 读的两个)。 */ export interface RouteRequestState { streamTaskId: string | undefined; streamDetached: boolean; /** 凭证派生的调用方系统身份(`source`)。handle() 的鉴权门是**唯一**赋值点,域模块只读。 */ source: string | null; /** * [ref] 件2(cli [ref] sema-bug4 (c)):本请求驱动的那条 run 的**终局**(`completed` / `failed` / * `suspended` / …)与结构化失败码。`handle()` 的 `request` 日志行读它 —— 让「端点 200 ≠ 模型成功」 * 这条歧义消失(取证:模型 connect timeout 的那一刻,日志里唯一的行是 `request status:200`)。 * * 赋值点在 `routes/tasks.ts`(`/v1/tasks` 与 `/v1/tasks/stream` 两腿的终局到手处);拿不到 run 结果 * (受理即拒 / 幂等重放 / 非任务端点)⇒ **键缺席**,绝不写 null —— 缺席 = 「这条请求没驱动 run」, * 与「run 结果不明」是两句话,而 `null` 会把两者压成一个形。 */ runStatus?: string; runErrorCode?: string; } /** B4①(命名化):`prepareSpec` 的返回形(tasks.ts/runs.ts 两域共同消费的属性面)——原为 6 成员内联匿名对象。 * 命名后好处不只是过 B4 门:两个消费域现在都能对同一个符号做 `import type` 标注,而不是各自重推断结构。 */ export interface PreparedTaskSubmission { spec: TaskSpec; /** [ref]:本次提交的执行 Runner —— 场景 hands=none 时是无手孪生(不挂 executionEnvFactory)。 * 两个消费域一律用它,不再直接摸 `deps.runner`(那正是「场景表态了、执行点没听」的缝)。 */ runner: Runner; auth?: RequestAuth; verify?: VerifyRoundsSpec; cascade?: boolean; jobId?: string; body: TaskRequestBody; /** L-167:**装配期**铸下的、这条会话的用户面 `EngineNotice`(今天只有请求腿 MCP 注入被丢弃一族)。 * 装配跑在这条腿的通告口开张之前,所以铸点交不出去 —— 由腿在 `registerEngineNoticeLeg` 那一拍 * 经 `pending` 一起投(投递单点仍是那一只注册助手,不新开第二条机制)。缺席 = 本次装配没铸下任何通告。 */ engineNotices?: readonly EngineNotice[]; /** * S-470(device lane 首绑写协议,design §4.3.2 两阶段 placement)—— **已校验、未写库**的 placement 意图。 * * 缺席的两种含义都等价于「本腿没有 placement 要提交」:①本部署不是 device 车道(绝大多数); * ②本腿不是 fresh 根提交(resume 家族根本不经 `prepareSpec`)。在场 ⇒ 每个 `runStore.createRun(` * 成功点**必须**把它交给 `commitPlacement`(唯一写入属主;类级机器门钉住每个调用点都接了)。 */ bindingIntent?: import("./device-placement.js").DevicePlacementIntent; } /** * 一条会话的**续跑重建输入**(`checkpoint_ctx` 的行体)—— 提交腿写一次,续跑腿读它重建 spec。 * * S-433 F2 起命名化(此前是五处结构等价的内联字面 `{ body: TaskRequestBody; memoryScope?: string }`): * 它同时是 {@link DriveResumeArgs.resumeCtx} 的形,而那一位是**写回**口 —— 读形与写形是同一件东西这句话 * 得由类型说,不能靠五处手抄碰巧一致。 */ export interface CheckpointCtx { body: TaskRequestBody; /** 提交时这条会话绑定的记忆域(重建 `RequestAuth` 用);缺席 = 没有记忆面。 */ memoryScope?: string; } /** B4①(命名化):`driveResumeIntoRunLog` 的入参形(resume 家族共用)——原为 9 成员内联匿名对象。 */ export interface DriveResumeArgs { token: CheckpointToken; /** * 🔴 **S-433 合并复审 F2 [high] —— 「本次续跑真正用的那个体」,由本腿在赢下 CAS 之后写回耐久层** * (验真后修;必填 —— `driveResumeIntoRunLog` 今天恰有**四个**调用点:legacy `/decide`(`resumeCheckpoint`)、 * `resumePreempted`、`resumeWake`、`resumePlanReview`,四条一视同仁)。 * * 病(修前):`resumePlanReview` 在 `driveResumeIntoRunLog` **受理(2xx)之后**才 `putCtx(sessionId, * {...ctx, body})`,而 `ctx` 是请求**开头** `getCtx` 读到的那一份,两个店的 `putCtx` 又都是按 * `session_id` **无条件 upsert**(`checkpoint-store-sql.ts` / `local-checkpoint-store.ts`)。受理点之后 * 本腿已经可以跑完并 `setTerminal` 释放 `task_active` —— 别的副本随即 `createRun` + 写一份**新的** ctx, * 然后这条迟到的写把它整只盖掉。那是一条**跨副本的丢写**,而被盖掉的恰恰是权限模式。 * * 修法(单一写者,栅栏 = CAS 赢点,不引版本号/不加特判):写点移进 `driveResumeLeg`,落在 * `resumeStream` / `resumeWithVerification` **解析之后**(core 的 pre-CAS 守卫 + 原子 CAS 已经赢了)、 * 本腿驱终局之前。此时 park 行仍攥着 `task_active`,在本腿 `setTerminal` 之前**谁也建不了新 run** ⇒ * 写与释放天然串行;输掉 CAS 的决裁在 `resumeStream` 处就 reject,**永远到不了写点**(「先写回则输家 * 放宽」这个顾虑同时成立)。 * * 不变量(规则变小,四条驱动腿一视同仁):**`ctx.body` 恒等于最近一次赢得 CAS 的续跑实际用的体**。 * 体没被改写的腿传**原 ctx** —— 同内容 upsert 幂等,不需要「只有 plan_review 才写」这条特判。 * * 🔴 `sessionId` 自带一位:写回的键**恒是读进来的那一个**(各腿的 `sessionId` 形参,**不是** * `cp.sessionId`)—— 读写不同键会写出一条谁也读不回来的行。 * * 🔴 写失败**响亮但不致命**(codex r1 两条 [high] 验真后定):core 的 `resumeStream` 是 **EAGER** 的 —— * 写点这一拍 run 已经在执行,裸抛会把一条活腿判成 failed + 释放 claim 而执行还在继续。⇒ 一个臂、 * 四条腿同守:计数 `resume_ctx_not_persisted_total` + 一行 warn,本次续跑照常。方向论证(不是 [ref] 的 * 静默放宽):写失败 ⇒ 耐久体保持**上一次**的值,而这四条腿里只有 `resumePlanReview` 改写这个体、 * 且只会**放宽** ⇒ 落盘的恒不比本次续跑更宽。旧的 `plan_approve_mode_not_persisted`(plan 专用)随本批删除, * 由上面这只家族级的取代。全文见 `resume-legs.ts` 的 `commitResumeCtx` 顶注。 */ resumeCtx: { sessionId: string; ctx: CheckpointCtx; }; /** [ref]:续跑腿的执行 Runner。resume 会用同一条 resolveSpec 重解析场景,故与首跑选同一只 —— 否则 * 一次续跑就把手带 band 重新 mount 回来(收窄只在首跑成立 = 没成立)。 */ runner: Runner; sessionId: string; principal: string | undefined; fleetScope: string; taskConfig: Omit; resumeObjective: string; outcome: ResumeOutcome; verifyRounds: VerifyRoundsSpec | undefined; /** * **R-1**(车CJ 收车档 §10 R-1;S-185 批落地)—— 本条 resume 腿**装配期**铸下的用户面通告 * (`PreparedTaskSubmission.engineNotices` 的 resume 孪生;今天唯一一族:`mcp.injection_dropped`)。 * * 为什么 resume 腿也要投:一条 park 任务的 body 是**存量**的,它自己那几条丢弃在首跑那一拍确实已经 * 披露过一次 —— 但**部署侧在 park 期间新占了一个名字**(center 加了同名 MCP server)这一形,只有 * 续跑这一拍才第一次发生,而它此前只有运维日志、用户面一个字都没有。⇒ 不投的代价是「我批准之后 * 我的 MCP 又少了一台,而没有任何人告诉我」;投的代价是存量丢弃在每次续跑各重说一遍(每腿每名一条, * 由铸点的 per-leg 去重与 32 名上限天然兜住)。两害相权取后者:重复是噪声,缺席是失明。 * * 缺席/空 ⇒ 与修前逐字相同(腿开口时 `pending` 不带任何东西)。 */ engineNotices?: readonly EngineNotice[]; /** codex M2: side-effects that must land AFTER the markResuming CAS is WON (decide accepted, sibling races * lost — never fires on the 409/404 paths) and BEFORE the model leg drives (so the resumed leg's own next * ask sees them — the decide leg's exemption grant). Contract: must not throw (callers swallow internally); * awaited so the ordering guarantee is real, and a store write here is ms-scale vs the model leg. */ onResumeCommitted?: () => Promise; /** [ref]/[ref]:调用方声明「本腿的 park 是终局后纯 park,session claim 已随终局 finalize 删除」—— * getActiveTaskId 缺席时**铸新 canonical taskId + createRun 全生命周期行**(wake 续跑=同 session 新一轮 * turn),而不是无行裸跑(不可取消/无计费归因/无 durable append/fleet 不可见,且 core `spec.taskId ?? * sessionId` fallback 会把 lastRunId 铸成 sessionId)。`source` 进 run 行元数据(runMeta 同列)。 * decide/answer/plan_review 腿**不设**此位:它们的 claim 在 park 期间特意保留,缺席=行被 reap 的竞态, * 维持既有兜底(checkpoint token 同拍过期 ⇒ resumeStream 拒),不铸新行。 */ mintFreshRun?: { source: string; }; /** * S-185 车CM —— **workflow 出身 park 的决定**,随本次续跑走 core 的可信 run 通道 * (`RunInternals.workflowParkedResume`)。 * * 为什么是这里、为什么是这个形:core 给部署方留的**唯一**通道就是它(d.ts 逐字:「A host that decided a * workflow child's parked checkpoint **launches the run that re-invokes** `Workflow({resumeFromRunId})` * with the decision here」)。宿主这一跑的模型再调一次 `Workflow({resumeFromRunId})` 时,工具的 * `parkedResume` dep 只挑出 `runId` 命中这次 resume 源的条目,交给 `RunWorkflowOptions.parkedResume`, * 于是那个 parked ordinal 带着人的决定继续,而不是再 park 一次。 * * 🔴 **TRUSTED**:它绝不是 `TaskSpec` 字段、也绝不是工具参数(模型不能决定一次审批)。唯一的铸点是 * `/decide` 的第三条车道(判据属主 = `parked-decide.ts` 的 `planWorkflowParkedDecide` 顶注)。 * 缺席 ⇒ 本位对这条腿是完整 no-op,与本批之前逐字相同。 * * 🔴 **形不手抄:直接是 core 的那一位**(`NonNullable`)。手抄一份 * 结构等价的字面(本位 7.73.0 及以前就是)会让 `token: string` / `inheritedGate: unknown` 这种**更宽**的 * 形悄悄通过,再靠下游一个 `as NonNullable` 把它洗进 core 的 branded 位 —— 那个转型 * 正是「边界必 schema、禁自铸」要挡的东西(裸 string 当赎回键传进去,编译期一个字都不响)。派生之后 * core 改这一位(本版就把 `WorkflowParkedResume.outcome` 改成了可选)在本仓是**编译事件**,不是靠人去 * 对表的一行注。 */ workflowParkedResume?: NonNullable; /** [ref]([ref] Inkglow 案 → [ref]① 定谳):**200 受理语义**——调用方声明「我的消费方按 poll/SSE 跟 * 终态,别让 HTTP 等模型往返」。在场为 `true` 时本腿在**受理点**(`resumeStream` 解析之后)立刻把 * `200 {taskId, status:"resuming", bindingEnforced:true}` 交回调用方,模型腿(流迭代 + 行驱动)继续 * 在同一条 promise 链上跑完 —— 不是新写口,逐字还是那条 `createSerialLedgerAppend` 单写串行链。 * * 🔴 **受理点为什么在那里**(判据,不是随手挑的):`resumeStream` 内部跑完 core 的 pre-CAS 守卫 + 原子 * CAS(本文件下游注与 core 的 E23 记账都逐字这么写),它一解析,这条 run 已经在执行、也已经没有任何 * 「会变成拒绝」的判定留在后面。⇒ 受理点之前的三类判定**全部保持同步**:lease admission(429)、 * markResuming CAS(409 `conflict.not_resumable`)、pre-CAS CheckpointError(绑定不符 409 / * 卡不在 404)。异步化的只有模型往返段。 * * ⚠️ **verify 腿(`verifyRounds` 在场)不受本位影响**:core 的 `resumeWithVerification` 是一次整包 * await,没有「CAS 已过、模型还没跑」这个缝可停 —— 在那条臂上提前受理就等于把绑定判别也异步化了 * (那是决策证明的一部分,不能丢)。故它维持同步长调用形,如实登记在 CHANGELOG/契约文档。 * * **今天传 `true` 的调用方**([ref] 件 S-1 之后 = 全部有人在等响应的 HTTP 腿):legacy 任务级 * `/v1/approvals/:sessionId/decide`、`/v1/assistant/tasks/:id/resume`、`…/plan_review`、 * `/v1/sessions/:id/wake`、以及 live 回决腿的 parked 赎回席(`redeemParkedAsk`)。 * **唯一不设此位的**是内部 D-D SLA deny-sweep:它没有在等响应的人,提前受理对它零收益,而且那条 * 循环靠 `await` 串行给每 tick 的重活限流(见其调用处的登记注)。 */ acceptEarly?: boolean; /** * 🔴 **[ref](codex 交叉复审 r2-[medium] 验真后修):这条腿是**调用方发起**的吗?** * * `true` = 有人在 HTTP 上点了按钮(resume / plan_review / wake / decide / 赎回席)⇒ 吃按**付费方** * (卡属主)判的成本配额准入(`ownerCostQuotaDenied`)。缺席/`false` = **部署自己的结算腿** * (内部 D-D SLA deny-sweep)⇒ **不吃**那道门。 * * 为什么系统腿必须豁免(亲核后改的判据,首版曾一并拦下 sweep):sweep 的活是把过了 SLA 的卡**结算掉** * (graceful deny → 行收敛 → 释放 `task_active` claim)。拦住它并不能省钱,只会让超顶租户的卡**挂着** * —— 而挂多久的兜底**比首版注释里写的差得多**:`checkpoint-store-sql.ts` 的 `reapExpired` 在 * **deadline 分支**上逐字排除了 `APPROVAL_GATE_KINDS`(`gate_kind NOT IN (…) OR tool_name='AskUserQuestion'`), * 所以一张过期的 human / irreversible_ask 卡根本不走那条腿;真正兜住它的只有绝对 backstop * `terminal_at_ms`(`TERMINAL_BACKSTOP_MS` 默认 **30 天**)。于是「拦 sweep」= 把一个由 * `APPROVAL_TIMEOUT_SEC` 界定的 SLA 换成「等成本窗滚过(`COST_QUOTA_WINDOW_SEC` 默认 24h)、最坏等 30 天」, * 并且整段时间攥着会话 claim。**方向也自洽**:deny 是**停下**这条 run 的动作,为了省钱而拒绝执行「停」 * 与 preempt 路由早就成文的那句同一条(「否则超顶租户连自己的花费都停不下来」)。 * * 判别子取 `req` 的在场与否 —— 这在 `resumeCheckpoint` 里**已经是**「系统腿 vs 调用方腿」的既有判据 * (绑定强制、parked 分流两处都写着 `req !== undefined` 与「内部 D-D SLA deny-sweep(req 缺席)豁免」), * 本位只是把同一条判据显式化成一个有名字的位,而不是让下游去猜。 * ⚠️ **射程覆盖两道门**:紧邻的 fleet-lease 准入(`ownerFleetLeaseDenied`)读**同一位** —— [ref] 件⑤② * (codex r3-[high])把它一并改吃了 `callerInitiated`,理由与三问逐字同 cost 半场。 * 🔴 本句是 [ref] R4 改写的(2026-09-03,7.57.0 发车前合并重扫 wf_1abaaa9a):此处原文写着「紧邻的 * fleet-lease 准入**没有**这条豁免(对 sweep 一视同仁),本批不动它,分歧已登记为遗留」—— 那是**同一批 * 件⑤② 落地之前**的话,件⑤② 修好后这条 JSDoc 没跟着改,于是注释与代码正相反(亲读 * `server.ts` 的 `ownerFleetLeaseDenied` 消费点:两处都是 `args.callerInitiated === true` / * `req !== undefined`)。两道门在这一位上**无分歧**,[ref] 上也没有这一项。 */ callerInitiated?: boolean; /** * 🔴 **[ref] codex 对抗复审 r1-F3 [medium](验真后修):本次请求的属主 lease 准入**已经做过了**。** * * `resumeCheckpoint` 在 parked 子代赎回分流**之前**先判一次属主 lease([ref] R3:那条腿提前 `return`, * 结构上够不到 `driveResumeLeg` 的同一道门)。分流**未命中**时请求落回 legacy 腿,于是同一条请求、 * 同一个付费方会向 center 协商**两次**。cost 半场可以「过两次门」(本地同步、纯读);lease 半场**不行**: * `FleetLeaseManager.admit` 的五条 fail-open 路径(center 不可达 / 5xx / 超时 / 401-403 / 畸形响应) * 一律**不写缓存态**(亲读 `fleet-lease.ts` 的 `issueOrRenewInner`),而 single-flight 只合并**并发**调用 —— * 两道门是串行的 ⇒ 半开 center 下一个请求连吃两次 `LEASE_FETCH_TIMEOUT_MS`,持续故障时整条 legacy * resume 车道把控制面负载翻倍。 * * 所以由上游把「已受理」这个事实传下来,`driveResumeLeg` 据此跳过重复协商。**安全性**:两处的键是 * **同一个表达式**(`decodeCheckpointScope(cp.scope)`,上游门与下游 `auth.principal` 同源),射程条件也 * 同一条(`req !== undefined` ⇔ `callerInitiated`)⇒ 跳过的是一次**逐字相同**的判定,不是一道门。 * 缺席/`false` ⇒ 照常自己判(其余三条驱动腿与 sweep 一字不变)。 */ ownerLeaseAdmitted?: boolean; } /** 提交/续跑「腿」= 跨域共享的**有状态**长流程(不像 {@link RouteHelpers} 那样只依赖 deps/config:它们要写 * run log、发 fleet 帧、走 markResuming CAS、驱动模型)。A9 上批的停点就在这里——tasks/runs 两域共用 * `prepareSpec`,approvals/assistant/notify-wake 三域共用 resume 家族,把它们跟着任一域搬都会造出 * routes/* 互相值 import 的第二条环。做法:留在 `createHttpServer` 闭包里(实现逐字不动),只把**函数引用** * 装进这一格,域模块经 `ctx.legs.*` 调用。类型化 ⇒ 腿的形漂了是编译红,不是运行时静默 404。 */ export interface RouteLegs { /** POST /v1/tasks · /v1/tasks/stream · /v1/runs 的共同前段:读体 → 校验 → resolveSpec。null = 已应答(400/…)。 * * `body` = **已解析好的**请求体,给那些体不是「这条 HTTP 请求的 JSON 正文」的调用方 * (DESIGN-269 车2:A2A JSON-RPC 腿已经把正文读成了一条 `message/send` 请求,再 `readJson(req)` * 一次只会读到空流)。缺席 ⇒ 照旧自己读体,既有三个调用方一字不改。 * ⚠️ 这是**入口形**的分歧,不是校验面的分歧:传进来的体与自己读的体走**同一段**校验/授权/ * resolveSpec —— 分叉出第二条校验路径正是本参数存在的理由的反面。 * * `onTypedFailure` = 「typed 拒绝**别写响应**,交回给我」。默认(缺席)行为不变:`HttpError` 走 * `sendError` 连 `extra` 一起回显。DESIGN-269 车2 的 A2A 腿必须传它 —— 那些 `extra`(尤其 * `scenario_unknown` 的**全部场景名**)对壳是指路材料,对一个外部 peer 是内部词表外泄。 */ prepareSpec(req: IncomingMessage, res: ServerResponse, body?: TaskRequestBody, onTypedFailure?: (err: import("../security.js").HttpError) => void, /** [ref] 件C:**腿身份**(不是能力表态)。`liveStream: true` 只由 `POST /v1/tasks/stream` 的装配点按 * URL 传 —— 那是全树唯一挂 HITL 活流投递面的腿,逐腿 `interactionPosture` 判据的第一条合取项。 * 缺席 ⇒ 无人腿(bg / A2A / resume),行为字节不变。 */ legFacts?: { liveStream?: boolean; }): Promise; /** 同步腿的终局记账(计费/配额/指标),tasks 域用。 */ finalizeTaskResult(result: TaskResult, principal: string | undefined, objective: string, sessionId: string | undefined): void; /** approvals 决策腿:session → pending checkpoint → markResuming CAS → 驱动续跑。 * `hostDecision` = **这次结算的事实**(谁结束了这次等待 + 通道自报的归属),必填:本腿有两个调用方 * (HTTP `/decide` = 人;内部 D-D SLA deny-sweep = 窗到期),而函数内部分辨不出谁在叫它 —— 必填参数把 * 「漏报出处」变成编译错,不是一条下游把「没人答」渲染成「有人拒」的静默假出处。 * 🔴 S-136(core 7.6.0 S6-A):宿主报事实、core 铸词 —— server 不再传结算词(旧形传 core 的三词之一, * 于是「哪个词配哪个 decision」这条相容规则要 server 自己记,而记错的方向恰是上面那句要防的)。 */ resumeCheckpoint(sessionId: string, decision: "approve" | "deny", reason: string | undefined, hostDecision: HostDecision, req?: IncomingMessage, answer?: QuestionAnswer, binding?: DecideBinding, /** [ref] codex R4(验真后修):remember 豁免的 grant 挂点。**工具名不再由路由自己读**(那是与本函数 * 载入行分歧的第三次 scope-less 读,三读分歧下会给他人工具落豁免=提权)——由本函数把**已属主校验的 * 载入 cp** 的工具名(`validatedToolName`,非 tool_approval / AskUserQuestion / legacy null ⇒ `null` * = 无可记)一并回传,路由只对这个逐字落店。`overrideSessionId` = parked 腿的 root/host 会话 grant 锚。 */ onResumeCommitted?: (grant: { overrideSessionId?: string; validatedToolName: string | null; }) => Promise, /** [ref]:透传给 {@link DriveResumeArgs.acceptEarly}(200 受理语义)。HTTP `/decide` 腿传 `true`; * 内部 D-D SLA deny-sweep 不传 —— 它没有在等响应的人,提前受理对它零收益而少一条同步失败面。 */ acceptEarly?: boolean, /** [ref]:调用方已验身份的裁定 —— stale 臂铸 `currentPending` 指路键前在被读回的行上重跑 decide 门 * 同一属主判(缺席 ⇒ 恒不造键;判据属主=server.ts 铸键点注)。 */ decider?: { principal?: string; explicitOperator: boolean; }): Promise<{ status: number; body: object; }>; /** 全 resume 家族的共同下半场(lease admission + 写 run log + fleet 发布)。 */ driveResumeIntoRunLog(args: DriveResumeArgs): Promise<{ status: number; body: object; }>; /** assistant 抢占腿的复位(gate-kind 守卫 + 行级属主复核 + 驱动)。 */ resumePreempted(sessionId: string, req: IncomingMessage | undefined, /** [ref] 件 S-1:透传给 {@link DriveResumeArgs.acceptEarly}(200 受理语义)。HTTP `/resume` 腿传 `true`。 */ acceptEarly?: boolean, /** [ref]②([ref]):调用方已验身份的裁定 —— 腿内在被真正载入的 checkpoint 行上重跑属主判 * ([ref] R3 同形;缺席 = 匿名 dev 部署,不咬)。 */ decider?: { principal?: string; explicitOperator: boolean; }): Promise<{ status: number; body: object; }>; /** notify-wake 域:唤醒一条 parked checkpoint。 */ resumeWake(sessionId: string, message: string | undefined, caller: string | undefined, req: IncomingMessage | undefined, /** [ref] 件 S-1:透传给 {@link DriveResumeArgs.acceptEarly}(200 受理语义)。HTTP `/wake` 腿传 `true` * —— 本腿走 `mintFreshRun`,受理体里的 `taskId` 因此是**新铸的**那个(壳的 poll 锚)。 */ acceptEarly?: boolean, /** S-185 车CM:透传给 {@link DriveResumeArgs.workflowParkedResume}(语义与 TRUSTED 纪律的属主在那条 * 顶注)。HTTP `/wake` 腿**不设**此位 —— 它是 `/decide` 第三条车道的专用席,而两条腿共用这一条驱动 * 路径正是 `notify-wake.ts` 那条「wake 会烧模型,所以复用 resume 家族的同一条腿、不自建第二条驱动 * 路径」的成文纪律。 */ workflowParkedResume?: DriveResumeArgs["workflowParkedResume"]): Promise<{ status: number; body: object; }>; /** assistant plan_review 三态腿。 */ resumePlanReview(sessionId: string, decision: "approve" | "edit" | "reject", editedPlan: string | undefined, reason: string | undefined, req: IncomingMessage | undefined, /** [ref]/[ref] 件5:`decide_404` 判别日志的附加位(只进日志,不上 wire;身份只记有无不记值)。 */ log?: { taskId?: string; principalPresent?: boolean; }, /** [ref] 件 S-1:透传给 {@link DriveResumeArgs.acceptEarly}(200 受理语义)。HTTP `/plan_review` 腿传 `true`。 */ acceptEarly?: boolean, /** [ref]②([ref]):调用方已验身份的裁定(直连门 = HMAC 验出的 principal)—— 腿内行级属主复核 * ([ref] R3 同形;缺席 = 匿名 dev 部署,不咬)。 */ decider?: { principal?: string; explicitOperator: boolean; }, /** S-433:批准之后这条会话继续用的 permission mode(闭集 `PLAN_APPROVE_PERMISSION_MODES_AFTER`;只在 `approve` * 上有意义,缺席 ⇒ `"default"`)。语义与判据的唯一属主 = `resume-legs.ts` 的同名形参顶注。 */ permissionModeAfter?: PlanApprovePermissionModeAfter): Promise<{ status: number; body: object; }>; } /** * [ref] 件 S-2 —— resume 族**受理点的断连留痕**(裁定:维持 detach,补观察面)。 * * ## 裁定(fable,与 S-1 同批;这里是它的唯一属主) * * S-1 落地后 resume 族的四条 HTTP 腿都在**受理点**回执: * · 受理**前**断连 ⇒ 这条腿还没赢 CAS、还没驱模型 ⇒ **零副作用**,没有什么要留痕的; * · 受理**后**本就**没有长连接可断** —— HTTP 已经走了,结果全在 durable 账本 * (`GET /v1/runs/:id` 与 `/events` 全程可查),所以这一族的语义**就是 detach**,而且是审过之后 * 留下的 detach,不是「从来没装过 abort」的 detach。 * * 因此**刻意不做**两件事:①不给 resume 族加 `x-detach-on-disconnect`(那是 `POST /v1/tasks/stream` * 的旋钮 —— 它有一条**真在流的长连接**,断连即 abort 才是它的默认;resume 族受理后无连接可断,一个 * 永远为真的 opt-in 只会让消费方以为存在第二种行为);②不因断连 abort(受理即宣告这条 run 归账本 * 管;断连 abort 等于让一次**网络抖动**杀掉一条已受理的续跑)。 * * 做的只有这一件:**受理那一刻若原连接已经没了,打一条 info**。不改任何行为,纯观察面 —— * 修前运维在这种形上能拿到的只有一条 `status:"499"` 的 request 行,连是哪条 run 都不知道。 * * 判据 = `res.destroyed || res.closed`(且 `!res.writableEnded` —— 正常收尾的 close 不是断连), * 与 `src/http/routes/tasks.ts` 的断连回看**逐字同一条**(那里的顶注写了为什么 `req` 的 'close' * 靠不住)。best-effort:探测失真只会少打/多打一条日志,不影响这条 run 的任何结局。 * * @param res 本次请求的响应对象(受理点尚未写过它)。 * @param out `driveResumeIntoRunLog` 的回执 —— 只有**受理形**(200 + `status:"resuming"`)才留痕: * 同步的 4xx/409/404 是「这条请求被拒了」,与断连无关,记进来会把真信号淹掉。 */ export declare function noteResumeClientGone(res: ServerResponse, out: { status: number; body: object; }, logger: { info?: (msg: string, fields?: Record) => void; } | undefined): void; /** * [ref]②([ref])—— resume 族四腿(`/decide` / `/assistant/tasks/:id/resume` / `/plan_review` / * `/wake`)的**唯一**回执发送口。 * * 为什么是一个函数而不是「在四处各加一行 `res.setHeader`」:这四条腿此前已经各自记得调 * {@link noteResumeClientGone},而那正是「靠人记得」的形 —— S-1 落地时就漏挂过一腿(三腿有、 * `/decide` 没有,见 `approvals-assistant.ts` 那条注)。把留痕 + 退避头 + 发送折进一次调用, * 第五条腿加进来时只有一个正确写法。 * * 🔴 **`Retry-After` 的判据与体里那份同源**:头只在体**真带** `retryAfterSec` 且是**有限非负数**时发。 * 全仓每一处带 `retryAfterSec` 的响应(429 `limit.rate_exceeded` / `limit.cost_quota_exceeded` / * `quota_exhausted` / `usage.window_exhausted` / rule-import retry)都同发这个标准头,唯独 resume 族的 * retriable 409 漏了。头与体不是重复,是**两个读者**:体那份给应用逻辑,头那份给通道 * (代理 / SDK / fetch 重试中间件读的是头,读不到就只能盲等或立刻重投 —— 而立刻重投必然又是一次空转)。 * 体里没有等待 ⇒ 头也不发:凭空补一个退避值就是编造一条服务端并没有做出的时间承诺。 * * @param extraBody 这条腿自己要并进回执的键(今天只有 `/decide` 的 `rememberApplied`)。 */ export declare function sendResumeOutcome(res: ServerResponse, out: { status: number; body: object; }, logger: { info?: (msg: string, fields?: Record) => void; } | undefined, extraBody?: Record): void; export interface RouteCtxBase { deps: FlatServiceDeps; registry: RunRegistry; helpers: RouteHelpers; local: RouteLocals; legs: RouteLegs; } export interface RouteCtx extends RouteCtxBase { req: RouteRequestState; } /** * 域入口的签名(S-146 第三刀 车②)。 * * 🔴 **`url` 与「已处理」布尔一并删掉**,零别名([ref] 硬 breaking):分派器已经**匹配过一次**并把 * 结果整个交进来(`match`),所以一只 handler 既不需要再判路,也不可能「没匹配上」——「返回 false * 放过给下一域」那一形在第三刀之后结构上不存在(路径归属由声明表一次定死),留着一个恒为 true 的 * 返回值就是给它留门。域内的 `RouteMiss` 闩随之删除。 * * 阀门(`RouteRow.enabled`)是唯一的「本域今天不在」通道,它在**匹配之前**判,请求落到分派器尾的 * 全局 404(诚实缺席,不是 501)。 */ export type RouteHandler = (req: IncomingMessage, res: ServerResponse, match: RouteMatch, ctx: RouteCtx) => Promise; /** * 分派结果 —— handler 能看到的**全部**路由事实。 * * · `decl` 命中的那一行(`id` 是域内闭集词:handler `switch (match.decl.id)` + `assertNever`,漏一口 = 编译红); * · `params` 匹配式**具名捕获组** → 段值。没参与匹配的可选组**不入表**(值缺席),与旧 `m[i]` 的 * `undefined` 同形 —— 所以必填段照旧写 `!`,可选段照旧判 `undefined`; * · `search` 原始查询串(`"?a=b"` 或 `""`)。域内取查询参数一律 `new URLSearchParams(match.search)`: * 与旧的 `new URL(req.url ?? "", "http://x").searchParams` **逐字同义**(前导 `?` 与百分号 * 解码的处置相同),但读的是分派器已经剖好的那一份,而不是让每只域再从 `req.url` 剖一遍。 * 🔴 `req.url` / `req.method` 自此不进任何域的判路 —— `test/route-shape-roster.test.ts` * 的反向格(域内判路必须为零)是它的机器钉。 */ export interface RouteMatch { readonly decl: RouteDispatchDecl & { readonly id: Id; }; readonly params: Readonly>; readonly search: string; } /** 一张 `X_ROUTES` 里**可分派**行的 `id` 闭集(纯门行没有 `id`,不进这只并集)。 */ export type RouteIdsOf = Extract["id"]; /** * 闭集 `switch` 的收尾臂([ref] 形):所有 `id` 都被 `case` 吃掉之后,这里的实参类型是 `never` —— * 漏一口就是**编译红**。运行期到得了这一行只可能是有人绕过类型把野值塞进了声明表,所以它**响** * (抛),不静默 200 也不静默 404。 */ export declare function assertNever(x: never): never; /** 改写门是**方法感知**的(它的撤销动词是 DELETE)。闭集 —— 加第五个动词是编译红。 */ export type RouteMethod = "GET" | "POST" | "PUT" | "DELETE"; /** 闭集的**规范序**(405 的 `Allow` 头按它排,于是同一条路径的头字节恒定,不随声明序抖)。 * `satisfies` 让它与 {@link RouteMethod} 双向闭合:漏一个动词 / 多一个都是编译红。 */ export declare const ROUTE_METHOD_ORDER: readonly ["GET", "POST", "PUT", "DELETE"]; export interface RouteDeclFacts { /** 「这条 url **烧模型**」—— drain / model-roster-pending / 无 service-token 三道 503 的共同判别。 * 没有这一列 = 不烧模型(零默认值:要它为真必须显式写出来,漏写的方向是**少收一笔账单**而不是 * 少一道门 —— 反了才是灾难,所以三道门读的是 true 这一侧)。 */ readonly billable?: true; /** 「本条 url 在**没有任何 service credential** 的部署形下,这几个方法必须 fail-closed」。 * 判据(两个合取项)= 「授权的唯一输入是 principal 头」∧「持久改写,或爆炸半径跨租户的治理读」; * 逐口论证在各 `routes/*.ts` 的头注里,声明行只记结论。 */ readonly credentialGated?: readonly RouteMethod[]; /** 「这条 url **是**计费提交面,但它到底提不提交由**请求体里的方法**决定」—— 单 URL 多方法的 * JSON-RPC 端点。两道**可用性** 503(drain / roster-pending)按 url 一刀切会把同端点上的纯读一起 * 关掉,所以分派器对本行**跳过**那两道,改由域模块在写分支里施加同样的两道(文案逐字同源)。 * 🔴 只豁免那两道,**不豁免安全轴**:`billable` 本身仍为 true,无 service token 那道 fail-closed * 503 照旧罩着(它判的是「这台机能不能收写」,与方法无关,且必须在解析请求体之前)。 */ readonly methodDispatched?: true; } /** **可分派行**多出来的两列(S-146 第三刀):身份 + 它真认的方法。纯门行两列都没有(见下)。 */ interface RouteDeclIdentity { /** **全表唯一**的行名(kebab 闭集词)。这是声明行的身份:域 handler 用 * `switch (match.decl.id)` 闭集分派(漏一口 = 编译红),所以它必须稳定、唯一、且与路径解耦 * (路径会改形,身份不该跟着改)。全表唯一性由 `test/route-shape-roster.test.ts` 层A 的良构格钉住。 */ readonly id: string; /** 本行真被响应的 HTTP 方法 —— 车② 起它是**分派器**的方法判据:路径命中而方法不在列 ⇒ 分派器 * 统一 405 + `Allow`(并集按**路径**取,跨行跨域),handler 一个字都不再判方法。 * * 🔴 因此本列必须是**逐口真值**,不能是并集。车② 为此做了两件事(改前的两条结构性口径,原文在 * 车① 的本注里):① 5 条「一行覆盖多条子路径、各子路径方法集不同」的并集行按动词拆成逐口行 * (`session-verb` / `run-subagent-verb` / `run-task-verb` / `session-sync-verb` / `session-sync-blobs`); * ② 2 条「一条路径住着两只域」的跨域行按方法拆成两行各归各域(`/v1/tasks` = trace-usage 的 GET + * tasks 的 POST;`/v1/memory/export` = memory-policy 的 GET + memory-bundle 的 POST)。 */ readonly methods: readonly RouteMethod[]; } /** * **可分派行**:一条真路由。匹配式二选一 —— `path`(精确;标签**就是** path 自身,所以没有 label 列, * 少一处手抄)或 `pattern`(形状;捕获组一律**具名**,段值经 `RouteMatch.params` 交给 handler)。 */ export type RouteDispatchDecl = (RouteDeclFacts & RouteDeclIdentity & { readonly path: string; }) | (RouteDeclFacts & RouteDeclIdentity & { readonly pattern: RegExp; readonly label?: string; }); /** * **纯门行**(facts-only):一段前缀上的门性质,**不是路由**。 * * 🔴 它刻意比任何标签行都宽(`/v1/devices/` 下将来长出的任何写动词天然在改写门内),正因为宽,它 * **不可分派**:型上就没有 `id` 也没有 `methods` —— 于是「分派器会不会拿一条门行去应答请求」这个 * 问题在类型上就不存在,不靠谁记得在 `if` 里排除它。它只喂三只门谓词与 `routeLabel`。 */ export type RouteGateDecl = RouteDeclFacts & { readonly prefix: string; readonly label?: string; }; /** 一张声明表的行 = 可分派行 ∪ 纯门行(闭集)。 */ export type RouteDecl = RouteDispatchDecl | RouteGateDecl; /** * [ref]([ref] 存在性 oracle 封口)——`not_found.run` 的**唯一**文案源,unknown 与 not-yours 两臂共用。 * 修前:unknown 臂发这条长指路文案、not-yours 臂发短文「run not found」,同 code 下按**文案长度**即可 * 枚举他人 run id 的存在性(822 行「no existence oracle」注释的意图被长短差打穿)。not-yours 臂只可能 * 拿到非 a\* id(store 查到行才有 owner 比对;a\* 与 wa\* 形 id 永不在 run store)⇒ 非 a\* 长文串足以让 * 两臂逐字节同形,指路价值不丢。钉=audit-fixes-http「[ref]」格(两臂全文比对);文案变更走 * api-error-text-freeze 基线流程。 */ /** * **「决定/续跑已受理」那一枚 200 收据的单一铸点**(S-218)。 * * 两条腿投同一份体:①resume 家族的受理点(`driveResumeLeg`,宿主停在 park 上时);②`/decide` 的 * workflow 第三条车道在宿主**不在** park 上时新铸的那一跑。契约上这两条腿说的是同一句话 —— * 「决定已被受理,拿 `taskId` 去 poll `GET /v1/runs/:id` / `/events` 跟终态」——所以消费端**一条分支 * 都不该加**;写成两份字面量,漂开的那天没有任何东西会说话。 * * `bindingEnforced` 的语义不因铸点而变(「这次决定按绑定坐标受理,不是盲目放行」),故两腿同带。 */ export declare function acceptedResumingReceipt(taskId: string, sessionId: string): { status: number; body: object; }; export declare const RUN_NOT_FOUND_MESSAGE = "run not found \u2014 the id belongs to no run in this deployment's run store (a run from another server process, or an in-memory store that did not survive a restart, is not visible here)"; /** [ref] 前缀=域判别子既成契约:a\* 与 wa\* 形 id 给 TaskOutput/journal 指路;其余给 {@link RUN_NOT_FOUND_MESSAGE}。 */ export declare function runNotFoundMessage(id: string): string; export {}; //# sourceMappingURL=route-ctx.d.ts.map