From 9fa0958eb8e948e6635ec03b3ce5cbcc8e9b2773 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 15:46:27 +0800 Subject: [PATCH] docs(service): explain run admission and event validation --- src/lib/canvas-service/runs.ts | 42 ++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/src/lib/canvas-service/runs.ts b/src/lib/canvas-service/runs.ts index 3119e55..e348c76 100644 --- a/src/lib/canvas-service/runs.ts +++ b/src/lib/canvas-service/runs.ts @@ -2,6 +2,7 @@ * Foundry生成任务协议适配。启动受理、执行事件、取消和用户应用输出是不同阶段,分别验证身份与作用范围。 * 受理成功映射为请求applied,只代表启动动作已经生效,不代表付费生成已经完成。 * 原请求/原计划/目标代次贯穿事件追尾,模型结果仅作为ACK提案交回runtime协调。 + * 端口不保存 RunRecord、游标或 timer;compile/accept/canonical 同步计算,网络只在 submit/query/catalog/poll 中委托 transport。 */ import type { Id, Json, Registry, RunInput } from '../canvas-core/types.ts'; import { equal, fault, freeze, readMap, validJson } from '../canvas-core/values.ts'; @@ -25,7 +26,8 @@ const string = (value: unknown): string => { if (typeof value !== 'string' || !v const integer = (value: unknown, min=0): number => { if (typeof value!=='number'||!Number.isSafeInteger(value)||value(); for(const step of steps){const id=string(step.step_id);if(ids.has(id)||step.canonical_root!==rootId||!uuid.test(string(step.board_id)))throw fault('run_identity_mismatch');ids.add(id);string(step.node_id);integer(step.target_incarnation,1);} @@ -52,13 +55,16 @@ function planSteps(plan: unknown, rootId: Id) { } /** Admission completion is distinct from paid execution completion. Credentials remain in the transport. */ -/** 为一个根项目提供模型目录、请求编译/结果查询、受理回执和事件读取的RunPort。 */ +/** 绑定 workspace/root/registry/transport,返回无任务私有状态的 RunPort,身份/取消与 I/O 预算由 transport 执行。 + * options 借用而非深捕获,宿主应保持服务配置稳定;工厂不开始轮询、读取目录或产生付费任务。 */ export function createFoundryRuns(options: {workspaceId: Id; rootId: Id; registry: Registry; transport: ServiceTransport}): RunPort { if(!uuid.test(options.workspaceId)||!uuid.test(options.rootId))throw fault('invalid_service_scope'); /** 所有任务端点以其入口画布定位,即使输出目标后来移到另一canvas也保留原run所属board。 */ const path=(board: Id)=>`workspaces/${encodeURIComponent(options.workspaceId)}/materials/${encodeURIComponent(board)}/canvas/ai`; /** 验证启动受理:请求/操作/目标代次、queued任务ID、owner版本+1及冻结计划均匹配。 - * frozen_versions仅列实际读入计划的快照;启动预期版本向量则覆盖所有活动canvas。 */ + * 请求 expectedVersions 覆盖活动 canvas,plan.frozen_versions 可只列实际读取的子集,但必须含 owner 与每个步骤 board。 + * owner 冻结版本为请求基线+1,其它列出的版本仍等于原请求基线;不要求计划读遍全部 canvas。 + * 返回 queued/cursor=0 的 admission 与 owner 局部模型证明,不更新根、不将受理成功当成生成输出完成。 */ function admissionReceipt(request:FrozenRequest,receipt:Json){ const raw=payload(request),body=object(receipt),runId=string(body.run_id),board=request.ownerCanvasId!; if(request.kind!=='run.start'||body.protocol_version!==2||body.request_id!==request.id||body.operation_id!==raw.operation_id||body.admission!=='accepted'||body.action!==undefined&&body.action!=='run.start'||body.status!=='queued'||!uuid.test(runId)||body.target_incarnation!==raw.target_incarnation||body.document_format_version!==1||body.canvas_version!==request.expectedVersions[board]+1)throw fault('run_identity_mismatch'); @@ -72,7 +78,9 @@ export function createFoundryRuns(options: {workspaceId: Id; rootId: Id; registr const admission:RunRecord=freeze({id:runId,requestId:request.id,operationId:request.operationIds[0],boardId:board,target:{id:raw.node_id as Id,incarnation:raw.target_incarnation as number},plan:body.plan as Json,status:'queued',cursor:0,hasMore:true,steps:[],outputs:[],outputEvents:[],attempt:0}); return{model,admission,rawModel:body.model}; } - /** 验证控制动作回执。cancel只返回任务状态与空文档证明;apply还需目标输出/代次及画布版本+1。 */ + /** 校验同 request/operation/run/action;cancel 验 run_status 并返回空版本/no-op 文档证明,后续还须读尾页。 + * apply 再核 node/output/targetCanvas/incarnation、目标版本恰好基线+1;不以本地乐观内容替代服务证明。 + * 完整上下文响应先裁成目标 canvas 局部证明,清 tombstones/removedCanvasIds,不声称本动作可改写其它画布。 */ function actionReceipt(request:FrozenRequest,receipt:Json){ const raw=payload(request),body=object(receipt); if(!request.runId||body.protocol_version!==2||body.request_id!==request.id||body.operation_id!==request.operationIds[0]||body.run_id!==request.runId||body.action!==request.kind||body.status!=='applied')throw fault('run_identity_mismatch'); @@ -90,7 +98,9 @@ export function createFoundryRuns(options: {workspaceId: Id; rootId: Id; registr return{action:{runId:request.runId,outputId:raw.output_id as Id},model,rawModel:scoped}; } /** submit/query共用解释路径,查询始终用原requestId。终结not-applied须完整匹配动作身份, - * unknown/传输异常保持不确定;启动的accepted仅结清受理动作,执行完成另看事件页。 */ + * unknown/传输异常保持不确定;启动 accepted 仅结清受理动作,执行完成另看事件页。 + * terminal not-applied 分支按完整证据匹配,不另要求2xx;普通错误/404不能伪造同等确定性。 + * 这是粗分类:applied 控制回执仍须 actionReceipt 校 runId/输出/模型;不会在 execute 直接解决 ledger。 */ async function execute(request: FrozenRequest,query: boolean,signal: AbortSignal): Promise { try{ const raw=payload(request),suffix=request.kind==='run.start'?'runs':`runs/${encodeURIComponent(request.runId!)}/${request.kind==='run.cancel'?'cancel':'apply'}`; @@ -106,7 +116,8 @@ export function createFoundryRuns(options: {workspaceId: Id; rootId: Id; registr }catch(error){const code=(error as {code?:string}).code;return{outcome:'unresolved',error:code??'ai_transport_unknown',...(code?{blocked:true}:{})};} } return Object.freeze({ - /** 读取可用模型与输入/输出模态,验证字段形状并复制结果;不把目录读取当成启动生成。 */ + /** GET 目录后逐项校 id/模态数组/available/JSON,捕获并冻结;其它 JSON 扩展字段保留。 + * 不去重 model.id、不缓存目录或证明随后启动仍可用;目录读取本身不受理生成任务。 */ async catalog(boardId,signal){ if(!uuid.test(boardId))throw fault('invalid_service_scope'); const response=await options.transport({responseFormat:'canvas-v2',method:'GET',path:`${path(boardId)}/models`,signal}),body=object(response.body); @@ -114,7 +125,9 @@ export function createFoundryRuns(options: {workspaceId: Id; rootId: Id; registr const models=array(body.models).map(value=>{const model=object(value);string(model.id);for(const key of ['output_modalities','input_modalities'])for(const modality of array(model[key]))string(modality);if(typeof model.available!=='boolean'||!validJson(model))throw fault('invalid_run_response');return JSON.parse(JSON.stringify(model)) as RunModel;}); return freeze({models,...(typeof body.unavailable_reason==='string'?{unavailableReason:body.unavailable_reason}:{})}); }, - /** 从逻辑意图及当前ACK编译请求;不发送网络,也不用尚未确认的working目标替代服务端基线。 */ + /** 以意图与当前 ACK 编译 payload/参与者/expectedVersions,不发送网络、不在此 JSON.stringify 冻结 body。 + * cancel 沿原 run 入口且无版本写入,apply 用 ACK 目标 owner/代次,start 用 ACK 完整活动 canvas 向量。 + * 返回对象可冻结但不是 durable 请求;requestId 与后续冻结/保存/发送由 scheduler 管理。 */ compile(intent,requestId,snapshot){ if(intent.operationType==='run.cancel'||intent.operationType==='run.apply'){ const action=intent.action,run=snapshot.runtime.runs.get(action.runId); @@ -140,7 +153,8 @@ export function createFoundryRuns(options: {workspaceId: Id; rootId: Id; registr }, // 请求完成与服务端任务执行分离:提交/查询后,accept再核对受理或控制语义。 submit:(request,signal)=>execute(request,false,signal),query:(request,signal)=>execute(request,true,signal), - /** 恢复路径也使用相同回执解释器,生成可对照fresh权威模型的证明。 */ + /** 历史 applied 恢复也走相同 admission/action 解释器,返回可与 fresh 模型比对的证明。 + * 不读取服务、不把该证明自动采纳到 Root;缺证据/版本不符仍由解释器拒绝。 */ verifyApplied(request,receipt){return request.kind==='run.start'?admissionReceipt(request,receipt):actionReceipt(request,receipt);}, /** 受理回执转为RunRecord与owner ACK候选,实际根发布/本地重放交给engine。 */ accept(request,status,snapshot){ @@ -154,8 +168,9 @@ export function createFoundryRuns(options: {workspaceId: Id; rootId: Id; registr const parsed=actionReceipt(request,status.canonical); return{...parsed.action,...(parsed.rawModel?{proposal:mergeFoundryModel(parsed.rawModel,request.canvasIds,snapshot,options.registry,options.rootId)}:{})}; }, - /** 从保存游标之后读取最多100条有序事件。验证任务/固定计划、连续seq及迁移后的目标上下文, - * 只返回一页数据,不在适配器推进持久游标;runtime协调成功才与文档一起发布next。 */ + /** 用 after_seq=run.cursor/limit=100 读取一页,核相同 run_id/plan、连续 seq 与 next_seq,不推进持久游标。 + * step.applied/output 须属于计划步骤;applied 与跨 board 输出额外核目标代次/迁移上下文,普通扩展事件仅保留 JSON。 + * 实际100项限制在响应解析后检查,不是传输字节/解析内存上限;runtime 协调成功才将 next 与文档同根发布。 */ async poll(run,signal){ const response=await options.transport({responseFormat:'canvas-v2',method:'GET',path:`${path(run.boardId)}/runs/${encodeURIComponent(run.id)}?after_seq=${run.cursor}&limit=100`,signal}),body=object(response.body); if(response.status!==200)throw fault(response.status===404?'run_receipt_expired':typeof body.error==='string'?body.error:'run_poll_failed'); @@ -175,10 +190,13 @@ export function createFoundryRuns(options: {workspaceId: Id; rootId: Id; registr } // next必须恰为最后事件序号;hasMore却没有任何事件会造成空转,作为非法响应拒绝。 if(integer(body.next_seq)!==cursor||events.length>100||body.has_more&&events.length===0)throw fault('invalid_run_response'); + // events 重新 JSON 捕获;outputs/steps 沿用已验证 body 的数组并冻结,不从它们直接生成本地编辑。 return freeze({status:body.status as RunStatus,events:JSON.parse(JSON.stringify(events)) as RunEvent[],next:cursor,hasMore:body.has_more,outputs:array(body.outputs) as Json[],steps:array(body.steps) as Json[],...(typeof body.error==='string'?{error:body.error}:{})}); }, /** 解释可合图事件:目标代次必须仍有效,局部结果只能改本owner,完整结果才允许跨分区归属变化。 - * 合并后再检查事件目标的owner/代次和版本,防止合法外壳里夹带另一目标的模型。 */ + * 读取当前 ACK 目标:局部模型须仍在该 board,complete 上下文才允许变 owner,且共同根必须匹配。 + * 调用合并 helper 后再核提案目标 owner/代次与事件 canvas_version;不在这里更新 working、history 或事件游标。 + * 不重新核事件属于哪个 RunRecord/计划,依赖前置 poll 与 engine 的 run/request/controlEpoch 栅栏。 */ canonical(event,snapshot){ const board=string(event.board_id),version=integer(event.canvas_version,1),current=snapshot.runtime.acknowledged.elements.get(string(event.node_id)); const context=object(event.model).complete===true;