docs(service): explain run admission and event validation
This commit is contained in:
@@ -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<min)throw fault('invalid_run_response');return value; };
|
||||
|
||||
/** 校验冻结请求的协议、owner、单操作覆盖及载荷;cancel无文档版本,
|
||||
* apply只写目标画布,start则包含生成输入上下文的预期版本,并复用输入大小校验。 */
|
||||
* apply只写目标画布,start则包含生成输入上下文的预期版本,并复用输入大小校验。
|
||||
* 解析原 FrozenRequest.body 仅为核验,不重写已冻结字符串;协议版本与单操作覆盖不等于服务已经执行。 */
|
||||
function payload(request: FrozenRequest) {
|
||||
const raw=object(JSON.parse(request.body));
|
||||
if(!request.ownerCanvasId||!uuid.test(request.ownerCanvasId)||!['run.start','run.cancel','run.apply'].includes(request.kind??'')||request.protocolVersion!==2||!uuid.test(request.id)||raw.protocol_version!==2||raw.request_id!==request.id||request.operationIds.length!==1||raw.operation_id!==request.operationIds[0]||!uuid.test(string(raw.operation_id)))throw fault('invalid_run_request');
|
||||
@@ -44,7 +46,8 @@ function payload(request: FrozenRequest) {
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
/** 检查计划步骤非空、step_id唯一、同一根项目及有效目标代次/画布;不在客户端重新规划执行DAG。 */
|
||||
/** 校验非空 steps、唯一 step_id、共同 canonical_root、board UUID 与目标 ID/正代次,保留原步骤对象。
|
||||
* 不重建 DAG、不验证模型目录或依赖是否可执行,也不证明整个 plan 所有扩展字段语义。 */
|
||||
function planSteps(plan: unknown, rootId: Id) {
|
||||
const steps=array(object(plan).steps).map(object),ids=new Set<string>();
|
||||
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<OperationStatus> {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user