动态工作流(一):门面与编译器

主 Agent 用 TypeScript 写工作流脚本:门面 API 长什么样,编译器怎样在纯内存的虚拟宿主里做类型检查、报出 9001~9009 诊断,用 taint 不动点和时序遍历算出四种图,再把结果类型合成 JSON Schema、把脚本降级成沙箱能跑的 JavaScript。

作者 David更新于 31 篇(共 47 篇)

动态工作流(dynamic workflow,代码里常缩写成 dwf)是 ZCode 编排多个子 Agent 的方式:主 Agent 不一个个地派活,而是写一段 TypeScript 脚本,脚本里用 agent() 建子会话、用 ask<T>() 派任务,再用普通的循环、分支和 Promise.all 把它们串起来。编译器先做类型检查和静态分析,用户看过分析出的阶段图并确认,脚本才被降级成 JavaScript,放进沙箱子进程里执行。它和子 Agent那种一次委托一件事的做法不同,也和专家工作流那种阶段固定的图调度不同:编排逻辑是模型当场写出来的代码。

整个功能分三篇。本篇讲纯函数库 @zcode/dynamic-workflowapps/zcode-cli/packages/dynamic-workflow)的前半截:给模型看的门面、编译器、静态分析、schema 合成与 lowering。同一个包里的执行引擎、沙箱与日志回放在下一篇;工具、子会话与落库在第三篇

为什么让模型写脚本

包的 README 开头一句话交代了分工(apps/zcode-cli/packages/dynamic-workflow/README.md:3):

Self-contained library for the dynamic workflow feature: the TypeScript facade the main agent writes scripts against, and the compiler that recovers rigor from those scripts (typecheck, schema synthesis, dependency inference, site identity).

代码里没有一段正面比较“脚本、JSON、DSL 哪个好”的文字。从代码看,选脚本的理由有三层:

  • 表达力CreateWorkflow 的工具说明把卖点写成“plain control flow (loops, conditionals, fan-out) and typed intermediate results”(apps/zcode-cli/packages/core/src/tool/handlers/create-workflow-description.ts:8)。按结果重试、按文件逐个评审再逐条复核,用普通代码就是几行。同仓库的专家工作流走的是另一条路:固定的八个阶段加一个图调度器,规划器只能按固定 schema 返回 JSON 形式的扩图,内容是节点、边与集合(apps/zcode-cli/packages/core/src/workflow/expert/parsers/planner-result.ts:22),能编排什么受这套结构约束。
  • 模型写得顺。编译选项按模型的习惯调过:strict 开着,noUncheckedIndexedAccess 却刻意关掉,注释说训练语料几乎都关着它,开着时 items[i] 引起的 TS2532、TS18048 占了本地运行里 undefined 类诊断的 96%(apps/zcode-cli/packages/dynamic-workflow/src/compiler/compile.ts:42)。
  • 严谨性由编译器找回来。类型实参就是结果的 schema,调用点就是日志的键,world.run 的命令字面量就是授权清单。脚本可以写得随意,能动手的地方都被钉死。

这里反复出现的“站点”(site),指脚本源码里的一处门面调用。编译器按种类、按源码顺序给它编号:ask#1actor#2world-read#1join#1report#1artifact#1apps/zcode-cli/packages/dynamic-workflow/src/analysis/sites.ts:310sites.ts:349sites.ts:366sites.ts:399sites.ts:417sites.ts:331)。这些编号不只是展示用的坐标:日志以“站点 × 第几次执行”为键,逐字节相同的脚本恢复运行时就靠它回放(下一篇);只有修订后的脚本导入前驱结果时,才改按“actor 名字 + 这个 actor 的第几个 ask + 输入哈希”匹配、不看位置(第三篇)。README 说站点编号“used as display and graph coordinates only”(README.md:144),只对后一种情况成立。

包的边界与目录

README 把纯度写成硬约束(README.md:9):

This package is pure: no session spawning, no storage, no disk or network I/O (the TS stdlib is embedded, not read from disk — see "Embedded libs"). It never imports from @zcode/core or @zcode/bootstrap.

核对源码,src/ 下唯一的外部依赖是 typescriptapps/zcode-cli/packages/dynamic-workflow/package.json:27)。

目录职责
src/facade/门面 .d.ts(字符串常量)、门面成员注册表、各类上限常量
src/compiler/虚拟宿主里的类型检查,createWorkflowProgram 是其余各趟共用的底座
src/analysis/站点表、编写期诊断、解释器、四种投影、Mermaid 与文本序列化
src/schema/ask<T> 到 JSON Schema、子集校验器、每个 actor 的 submit profile
src/lowering/擦类型、插站点 ID,产出沙箱的输入
src/engine/执行引擎核心,见下一篇
src/projections.ts不 import typescript 的投影子集,给浏览器端在冻结的分析结果上重算图

README 还描述了 tests/workflows/tests/graphs/ 两套夹具和 pnpm testREADME.md:74),但开源版本里没有 tests/ 目录,package.json 也没有 test 脚本(package.json:18),只剩一个调试脚本 scratch/run.mjs。所以下文的例子是笔者按门面写的,并把克隆里的这个包复制出来编译后实际跑过。

门面:一个名词,一个动词

门面是一份 .d.ts 文本,以字符串常量的形式嵌在包里,文件名固定为 workflow-facade.d.tsapps/zcode-cli/packages/dynamic-workflow/src/facade/dts.ts:15)。它的自我定位是“One noun (the actor), one verb (the task).”(dts.ts:6)。核心只有两个声明(dts.ts:35):

/**
 * An actor: a persistent conversational context that executes tasks serially.
 * Context accumulates across asks; concurrent asks on one actor queue FIFO.
 */
declare interface Agent {
  /**
   * Assign one task. T is the task's output value: an interface you define in
   * this script with a plain "interface" declaration (no "declare" modifier; the
   * harness synthesizes its runtime schema from the type), or the final response
   * text when the type argument is omitted.
   */
  ask<T = string>(instructions: string): Node<T>;
}
  // ...
declare function agent(name?: string, persona?: string | AgentPersona): Agent;

完整的门面由八个命名段拼接而成(dts.ts:427)。EvalWorkflowSnippet 用的试验台门面只取其中的实参、日志、世界读取与 world.run 四段(dts.ts:448),所以片段里写 agent() 会直接得到“Cannot find name”。门面还原样拼进了 CreateWorkflow 的工具说明(create-workflow-description.ts:103),模型看到的 API 与编译器检查的是同一份文本。全部成员:

成员作用要点
agent(name?, persona?)建一个 actor,即一个持久子会话非空名字是身份:同一 run 内重名整个 run 失败,修订重跑也按名字复用缓存(dts.ts:53
Agent.ask<T>(instructions)派一个任务,返回 Node<T>同一 actor 上的并发 ask 按先进先出排队;省略类型实参时结果是最终回复文本
Promise.all / allSettled汇合不是门面函数,站点表按全局 Promise 识别成 join 站点(sites.ts:360
log(message)进度消息没有站点,不写节点行,只作为一条事件记下
report(item, artifactId?)发布一条中间结果进日志;每 run 最多 256 条、单条序列化后不超过 32KB,超了整个 run 失败(dts.ts:86
artifact.file / markdown发布交付物异步、失败可 catch;每 run 32 个 id、每个 id 16 版、单文件 20 MiB(dts.ts:188
artifact.chart / table / metrics / board声明看板同步声明,由带标签的 report 喂数据
phase(name)阶段标记名字须是字面量、调用须独立成句
files.glob / read / grep只读观察工作区进日志;glob 与 grep 上限 2000 条,grep 结果不超过 256KB,超限拒绝而不截断(dts.ts:293
git.changedFiles / diff / status / log只读 git 观察固定 argv、不经 shell;diff 上限 512KB,log 最多 100 条(dts.ts:342
world.run(cmd, args?, opts?)执行命令cmd 须是字面量;非零退出码作为返回值而不是异常;默认超时 300000 毫秒、不设上限(dts.ts:388
args运行实参保存的工作流按声明校验后注入,内联脚本恒为空对象(dts.ts:424

门面里没有 createActorjoin 这类名字:createActor 是降级后的宿主调用,汇合就是 Promise.all。门面注释还要求“每个阶段至少含一个 ask 或一次 world.run”(dts.ts:247),这条只是写作规范,编译器不检查:笔者试过一个只有普通语句的阶段,零诊断,它只是不出现在阶段图里。

一个完整的脚本

下面这份脚本是笔者按门面写的示意:并行评审改动过的 .ts 文件,把每条发现 report 出去,跑一遍测试,失败就派一个修复员,最后让撰写人汇总成 Markdown 交付物。

interface Finding {
  /** 工作区相对路径 */
  file: string;
  /** @minimum 1 */
  line: number;
  severity: "high" | "medium" | "low";
  /** @maxLength 200 */
  summary: string;
}

interface Review {
  findings: Finding[];
}

phase("逐个评审改动的文件");
const changed = (await git.changedFiles()).filter((p) => p.endsWith(".ts"));
const reviews = await Promise.all(
  changed.map((path) =>
    agent(`评审员-${path}`).ask<Review>(`评审 ${path} 的改动,只报告有依据的问题。`),
  ),
);
const findings = reviews.flatMap((r) => r.findings);
for (const f of findings) report(f);

phase("确认测试仍然通过");
const test = await world.run("pnpm", ["test"]);
if (test.exitCode !== 0) {
  await agent("修复员").ask(`测试失败,修复它:\n${test.stdout.slice(-4000)}`);
}

phase("汇总并产出报告");
const summary = await agent("报告撰写人").ask(`把这些发现写成 Markdown:${JSON.stringify(findings)}`);
await artifact.markdown("report", summary, { title: "评审报告", primary: true });
return { findings, testsPassed: test.exitCode === 0 };

顶层 await 与末尾的 return 都合法,interface 前不能写 declare,也不能 exportimport。这份脚本编译零诊断,产物清单是 [{"id":"report","kind":"markdown"}]。评审员的名字按文件拼出来,所以不会撞上下面的 9006。

编译流水线

入口是 analyzeWorkflowScriptapps/zcode-cli/packages/dynamic-workflow/src/analysis/analyze.ts:64),注释里写明的顺序是 typecheck、collectSites、diagnostics、interpret、four projections(README.md:32)。schema 合成与 lowering 不在这条函数里,而是挂在同一个 ts.Program 上的另外两趟:

图表加载中…

虚拟宿主:为什么不读磁盘

脚本先被包进一个 async 函数,前缀恰好一行(compile.ts:31),这正是运行时执行函数体的形状,所以顶层 await 与末尾 return 都合法;诊断的行号再减去这一行,换回作者脚本的坐标(compile.ts:105)。编译选项(compile.ts:35):

const COMPILER_OPTIONS: ts.CompilerOptions = {
  allowJs: false,
  lib: ["lib.es2022.d.ts"],
  module: ts.ModuleKind.ESNext,
  moduleResolution: ts.ModuleResolutionKind.Bundler,
  noEmit: true,
  skipLibCheck: true,
  // `strict` on, `noUncheckedIndexedAccess` deliberately OFF. Models write TS as
  // if the flag were off — training corpora almost universally have it off — so
  // with it on, TS2532/TS18048 on `items[i]` was the single largest source of
  // compile failures (96% of the undefined-family diagnostics in local runs),
  // almost all on indexes whose bounds the surrounding logic already proved.
  // `strictNullChecks` stays: `.find()` / `match()` / optional properties are
  // real hazards and their messages name the cause.
  strict: true,
  target: ts.ScriptTarget.ES2022,
  // No ambient @types: the script sees ES2022 + the facade and nothing else.
  // `process`, `fetch`, `require` etc. fail typechecking — the purity contract
  // starts at compile time.
  types: [],
};

types: []processfetchrequire 在编译期就报“找不到名字”,纯度从这里开始。编译器宿主完全虚拟:脚本、门面和 TypeScript 标准库全部从一个内存 Map 里取,writeFile 直接抛错(compile.ts:135)。不读磁盘是因为 CLI 最终打成 SEA 单文件,运行时没有 node_modules,照常用 ts.createCompilerHost 就读不到 lib.es2022.d.tsapps/zcode-cli/packages/dynamic-workflow/scripts/generate-libs.mjs:1)。构建脚本从 lib.es2022.d.ts 出发,顺着 /// <reference lib> 把整条引用链收进 src/compiler/libs.generated.tsgenerate-libs.mjs:40),装的 typescript 版本没变就直接退出(generate-libs.mjs:32)。笔者用 TypeScript 5.9.3 跑,收进了 57 个文件。README 点明了这样做的另一个好处:开发与打包后的行为完全一致,缺了哪个 lib 在包测试里就会暴露(README.md:133)。

诊断文本也为模型的自我修复改写过。脚本顶层写 declareexport,TypeScript 只报一句 TS1184“Modifiers cannot appear here.”,不说是哪个修饰符;编译器按出错的那段文字换成能照做的说明(compile.ts:166)。

站点表与编写期诊断

collectSites 在包装函数体上走一遍 AST,所有门面调用都经 checker 解析到声明再归类,不看拼写(sites.ts:36)。门面成员的身份由“声明容器 + 成员名”决定:git.log 和顶层的 log() 同名,只按名字判断,要么给每条进度消息都铸一个站点,要么把 git.log 的站点丢掉(apps/zcode-cli/packages/dynamic-workflow/src/facade/registry.ts:13)。phase() 被收集却不算站点:没有 ID、不占任何计数器,加减阶段标记不会挪动别的站点编号(sites.ts:149)。

站点表之后是一串诊断。编号是包内自定义的,刻意避开 TypeScript 自己的码段:

出处含义
9001apps/zcode-cli/packages/dynamic-workflow/src/analysis/facade-misuse.ts:20门面函数只能直接调用:const spawn = agent、解构出 ask、把 Agent 转成结构兼容的本地类型,都会让调用失去站点
9002apps/zcode-cli/packages/dynamic-workflow/src/schema/types.ts:78结果类型或 report 的实参不能序列化成 JSON(anyDate、函数、类实例、Promise……)
9003apps/zcode-cli/packages/dynamic-workflow/src/analysis/world-run.ts:18world.run 的第一个实参不是编译期字面量
9004apps/zcode-cli/packages/dynamic-workflow/src/analysis/phases.ts:19phase() 的名字不是字面量、为空,或不是独立语句
9005apps/zcode-cli/packages/dynamic-workflow/src/analysis/actor-names.ts:43两个 agent() 用了同一个字面量名字
9006actor-names.ts:54在 fan-out 体内用固定名字建 actor,元素多于一个时运行期必然重名
9007apps/zcode-cli/packages/dynamic-workflow/src/analysis/artifacts.ts:37产物 id 不是字面量、为空、超长、含非法字符,同一 id 跨了两种成员,或 report 的标签指错
9008artifacts.ts:48看板声明写在循环、回调或条件分支里
9009artifacts.ts:55两个不同的 id 都写了字面量 primary: true

9001 的检查分三趟:引用扫描、对“有门面签名却没登记成站点”的直接调用兜底,以及在类型转换边界上抓“把门面值伪装成本地类型”(facade-misuse.ts:48)。理由写在规则说明里:日志键、回放与界面都以静态站点 ID 为坐标,一个只能经逃逸出去的函数值才到达的门面调用没有站点,放宽处理表示不了它,悄悄丢掉又不可靠,所以在编译期拒绝(facade-misuse.ts:39)。9003、9004、9007 同一个道理:命令集、阶段名和产物清单要在确认窗里给用户看,运行期才成形的值没有可展示的东西(world-run.ts:4)。

有几处值得留意:

  • 9006 是唯一不扣下图的诊断,ok 照样为假。它说的是“跑起来会重名”,形状本身可以分析,扣下图反而让作者看不到是哪个 fan-out 出的问题(analyze.ts:92)。9005 与 9006 分成两个码,是因为后者理论上会误报(集合可能只有一个元素),读端需要区分,而仓库禁止按错误文本分流(actor-names.ts:45)。
  • 9002 不在 analyzeWorkflowScript 里,它由 synthesizeAskSchemas 产出(apps/zcode-cli/packages/dynamic-workflow/src/schema/synthesize.ts:74)。CreateWorkflow 的处理函数只调用前者(apps/zcode-cli/packages/core/src/tool/handlers/workflow-script-analysis.ts:20),于是像 ask<{ at: Date }> 这样的脚本会通过分析、弹出确认窗,直到提交时由 run service 的 compileOnce 抛错(apps/zcode-cli/packages/bootstrap/src/app/dynamic-workflow-run-submit.ts:540)。这一段留到第三篇。
  • 反过来,compileOnce 只复验类型检查、schema(9002)与 world.run 字面量(9003)三项,其余编写期诊断全靠处理函数那一趟(dynamic-workflow-run-submit.ts:524);所以重名诊断不是提交路径上的强制门,具名唯一性的权威始终是引擎运行期的查重(actor-names.ts:30)。

笔者用上面那份编译器跑了几个错例,信息都是给模型读的整句,例如 9003:

world.run's first argument must be a compile-time string literal ("lean" or a no-substitution template): the script's command set is shown to the user at confirmation and only those commands are executable.

解释器:taint、不动点与时序遍历

诊断干净后,interpret 对脚本做一次“融合”解释,产出 AnalysisCoreapps/zcode-cli/packages/dynamic-workflow/src/analysis/interpret.ts:21)。分两半。

数据流的一半是 taint 分析。抽象值带三样东西:可能流入它的站点标签(occs)、静态可知的字段(fields,让 x.f[a, b] 保持字段敏感)、它可能是哪些脚本函数(fns,让高阶代码的调用图保持完整);所有合并都是只增不减的并集(apps/zcode-cli/packages/dynamic-workflow/src/analysis/domain.ts:8)。分析是 gen-only 的 may-flow:没有消毒、没有强更新,事实只会增加,所以用一个全局环境、忽略语句先后,就能同时覆盖所有执行顺序;循环里被重新赋值的变量也自然并进同一个绑定(apps/zcode-cli/packages/dynamic-workflow/src/analysis/taint.ts:49)。整段脚本反复迭代到不动点(taint.ts:85):

  converge(): void {
    let iterations = 0;
    do {
      this.s.changed = false;
      iterations += 1;
      if (iterations > ITERATION_CAP) {
        throw new Error(`taint analysis failed to converge after ${ITERATION_CAP} iterations`);
      }
      this.iterate();
      this.s.propagateReachability();
      this.s.recomputePromotion();
    } while (this.s.changed);
  }

上限 ITERATION_CAP 是 100(taint.ts:61)。单调加有限格保证收敛,撞上限是 bug,抛出来而不是悄悄截断。xs.map(fn)for...of 这些迭代候选,只有当循环体真的够到门面站点时才被“提升”成 fan-out 节点(sites.ts:208);哪些库函数会逐元素调用回调,集中登记在一张表里(apps/zcode-cli/packages/dynamic-workflow/src/analysis/callbacks.ts:61)。

时序的一半是一次遍历。不动点收敛之后,按求值顺序走一遍脚本体,记下每一步何时发出、每个 await 屏障落在哪里、外面包着哪个语法区域(apps/zcode-cli/packages/dynamic-workflow/src/analysis/causality-order.ts:34)。await 的解析也分两半:求值被 await 的表达式时发出的步骤,确定在此处结算;被 await 的值身上带着的标签,靠上一半的 taint 结果当“神谕”来回答,用来解决存进变量、传进 helper 或汇合后的 promise。两半都为空时屏障放宽到所有在飞的步骤。放宽会多排序、低估并行,注释称之为“诚实的方向”,少排序才是绝不允许的(causality-order.ts:53)。函数体只在调用处内联展开,调用了谁同样读神谕,不从语法上猜;递归按调用图的自可达性预先算好,展开体包一个 loop 区域并切断重入(causality-order.ts:60)。

最后是“铸造”:一切需要源码偏移或 checker 的东西,都在这一刻消化成纯数据,比如站点落在哪个 fan-out 里、fan-out 的最终编号、产物类型的字符串(apps/zcode-cli/packages/dynamic-workflow/src/analysis/core.ts:13)。从此四种图都是 core 上的纯投影,投影模块不许 import typescript,这也是 projections.ts 能打给浏览器用的前提(apps/zcode-cli/packages/dynamic-workflow/src/projections.ts:1)。

四种投影

投影回答的问题节点与边
站点图 projectSiteGraph谁的输出可能流进谁节点是 ask、world-read、join、fan-out 站点加虚拟的 source、sink;data 边表示“A 的输出可能喂给 B”,context 边连起共用同一 actor 的 ask(apps/zcode-cli/packages/dynamic-workflow/src/analysis/types.ts:84
因果图 projectCausalityGraph什么必须在什么之前步骤(ask 与世界读取)的偏序,actor 是车道而不是节点;边只画一种箭头“runs after”,内部记着原因 datacontrolfifoseqcarryapps/zcode-cli/packages/dynamic-workflow/src/analysis/causality-graph.ts:26
控制流图 projectControlFlow执行下一步可能去哪节点是“出现”而不是站点(被调用两次的 helper 出两个节点),边有 nextbranchloopexitforkjoinjumpthrowmay-throw,再按阶段取商(apps/zcode-cli/packages/dynamic-workflow/src/analysis/flow-graph.ts:18
交接图 projectHandoffGraph每个阶段谁参与、谁交给谁每阶段每条车道一张卡,fan-out 家族在基数已知且不超过 8 时逐个出卡(apps/zcode-cli/packages/dynamic-workflow/src/analysis/handoff-graph.ts:49),卡之间是因果图的阶段内边取商后再归约

确认窗里的图由因果图、控制流图和交接图三份拼成(apps/zcode-cli/packages/core/src/tool/handlers/workflow-analysis-display.ts:5)。上面那份评审脚本的因果图,文本形式里最能说明问题的几行是(节选):

step world-read#1 world-read "git-changed-files" @16:28 lane=workspace phase=phase#1 region=seq#1 always
step ask#1 ask "ask" label-head="评审员-" @19:26 lane=actor#1 phase=phase#1 region=fanout#1 maybe stack
step world-read#2 world-read "run pnpm" @26:26 lane=workspace phase=phase#2 region=seq#1 always
step ask#2 ask "修复员" @28:22 lane=actor#2 phase=phase#2 region=branch#1 maybe
step ask#3 ask "报告撰写人" @32:38 lane=actor#3 phase=phase#3 region=seq#1 always
edge world-read#1 -> ask#1 data maybe exact
edge ask#1 -> world-read#2 seq maybe
edge ask#1 -> ask#3 data maybe exact
edge world-read#2 -> ask#2 control maybe
edge ask#2 -> ask#3 seq maybe

评审员在 fan-out 体里,集合可能为空,不是每次都会发出,所以是 maybestack 表示各元素的实例同时存在,界面上叠成一摞卡片(apps/zcode-cli/packages/dynamic-workflow/src/analysis/causality-graph-types.ts:57);修复员只在分支里发生,它和测试之间是 control 边;撰写人的指令里插了 findings,所以和评审员之间是 data 边。评审员的名字是模板拼的,图上只拿得到前缀“评审员-”:把模板折成具体名字需要展开 map,而这正是图要拒绝的(apps/zcode-cli/packages/dynamic-workflow/src/analysis/types.ts:19)。

结果类型怎样变成 JSON Schema

ask<T> 带了类型实参、且 T 解析后不是原始 string 的,才算“有类型”的 ask;ask()ask<string>() 的结果就是最终回复文本,不出 schema(synthesize.ts:32)。发射器由 checker 的结构化视图驱动,泛型、别名、映射类型都已被展平(apps/zcode-cli/packages/dynamic-workflow/src/schema/emit.ts:6):

  • any 拒绝并建议改用 unknownunknown 发射成空 schema {}undefined 只允许出现在可选属性上;bigintsymbol、函数、类实例、thenable 与 DateMapError、各种 TypedArray 等内建对象一律拒绝(emit.ts:106emit.ts:34)。
  • 全是字面量的联合发射成 enum,否则是 anyOf;成员超过 100 个按“病态宽联合”拒绝,真实场景多是模板字面量类型展开出的笛卡尔积(apps/zcode-cli/packages/dynamic-workflow/src/schema/types.ts:80)。
  • 没有字符串索引签名的对象一律 additionalProperties: false。注释坦白这不忠于 TypeScript 的结构类型,理由是模型多给键几乎总是误解,报出来就是一条清楚的修复提示(emit.ts:252)。
  • 递归类型只在真的自引用时才提升进 $defs$ref 指过去,其余具名类型原地内联(emit.ts:10)。

属性上的 JSDoc 会被收集:正文成为 description@minimum@maximum@exclusiveMinimum@exclusiveMaximum@minLength@maxLength@pattern@format@minItems@maxItems@default 变成对应的约束,其余标签静默忽略(apps/zcode-cli/packages/dynamic-workflow/src/schema/jsdoc.ts:4)。示意脚本里的 Finding 合成出来是这样(节选,紧凑排版):

{
  "type": "object",
  "properties": {
    "file": { "type": "string", "description": "工作区相对路径" },
    "line": { "type": "number", "minimum": 1 },
    "severity": { "enum": ["high", "medium", "low"] },
    "summary": { "type": "string", "maxLength": 200 }
  },
  "required": ["file", "line", "severity", "summary"],
  "additionalProperties": false
}

配套的校验器只认这个子集,每条违规是“路径、期望、实得”三元组,格式化成一行 <path>: expected <expected>, got <got>,直接放进给模型的修复回合(apps/zcode-cli/packages/dynamic-workflow/src/schema/validate.ts:18)。引擎只依赖一个注入的 ValidateFn,不 import 校验器本身,repair 的次数与流程在下一篇。

schema 还决定子会话拿到哪种 submit_result 工具。编译期按站点图把每个 ask 归到可能的 actor 上:一个 actor 能收到的 typed ask 全部同一个 schema,就是 mono,工具声明直接写成那个 schema,对这个 actor 整个生命周期不变,不打掉提示词缓存;schema 不止一种是 generic;全是无类型 ask 就是 untyped,不注册这个工具。只要有一个 ask 的 receiver 没解析出来,所有 actor 一律退回 genericapps/zcode-cli/packages/dynamic-workflow/src/schema/actor-submit-profiles.ts:10actor-submit-profiles.ts:33)。

lowering:擦掉类型,插上站点 ID

最后一步把分析干净的脚本变成沙箱能跑的 JavaScript,分两趟(apps/zcode-cli/packages/dynamic-workflow/src/lowering/lower.ts:19)。第一趟在原 AST 上做 transform,按站点表持有的节点身份命中每个门面调用,改写成 __host.*,绝不按名字重新识别;第二趟对打过桩的文本做 transpileModule 级的类型擦除(lower.ts:263)。示意脚本降级后的开头几行:

__host.enterPhase("\u9010\u4E2A\u8BC4\u5BA1\u6539\u52A8\u7684\u6587\u4EF6");
const changed = (await __host.worldRead("world-read#1", "git-changed-files", [])).filter((p) => p.endsWith(".ts"));
const reviews = await Promise.all(changed.map((path) => __host.ask("ask#1", __host.createActor("actor#1", `评审员-${path}`), `评审 ${path} 的改动,只报告有依据的问题。`)));
const findings = reviews.flatMap((r) => r.findings);
for (const f of findings)
    __host.report("report#1", f);

第一行的阶段名是 printer 新造的字符串字面量,非 ASCII 字符被转义成了 \u 序列。对照可以看出几条规则:agent(...) 变成 __host.createActor(站点, ...)x.ask<T>(...) 变成 __host.ask(站点, x, ...) 并丢掉类型实参,世界读取的实参原样按位置打包成数组,phase 变成 __host.enterPhasePromise.all 与 fan-out 是沙箱里的普通 promise,原样保留(lower.ts:24)。world.run 在这里也是一次 worldRead,op 是 run;到了日志里它单列一种节点,这是下一篇的事。

几个细节:

  • 可选链上的 ask(maybe?.ask(x))不能直接改写,否则 receiver 为空时会带着 undefined 调进引擎、让整个 run 以 UnknownActor 失败。改写成一个判空三目,非标识符的 receiver 经临时变量只求值一次,和 tsc 自己降级可选链的做法一样(lower.ts:184)。
  • 门面里唯一的“值”args 按标识符改写成 __host.args,而不是往函数体里注入一个 const args:用户若自己再声明一个 args,注入会变成运行期的重复声明错误(lower.ts:125)。
  • 产物契约是一个 async 函数体,唯一的自由标识符是 __host,里面不含任何 schema,引擎按站点 ID 从编译产物里查(lower.ts:45)。printer 与 transpile 都是纯函数,同样的输入得到逐字节相同的输出(lower.ts:59)。

生产路径上,这一切由 run service 的 compileOnce 在一个 ts.Program 上一次做完:类型检查、站点表、schema、world.run 命令集、每个 actor 的 submit profile、lowering,再对作者原文算 sha256 作为 scriptHashdynamic-workflow-run-submit.ts:524)。哈希算在原文而不是降级后的函数体上,因为恢复运行时比对的是原文。

下一篇:动态工作流(二):引擎、沙箱与日志回放——降级后的脚本怎样在 vm 子进程里跑,AskScheduler 怎样派活、修复与记账,日志又怎样让中断的 run 从原处接着跑。

本页目录