动态工作流(一):门面与编译器
主 Agent 用 TypeScript 写工作流脚本:门面 API 长什么样,编译器怎样在纯内存的虚拟宿主里做类型检查、报出 9001~9009 诊断,用 taint 不动点和时序遍历算出四种图,再把结果类型合成 JSON Schema、把脚本降级成沙箱能跑的 JavaScript。
动态工作流(dynamic workflow,代码里常缩写成 dwf)是 ZCode 编排多个子 Agent 的方式:主 Agent 不一个个地派活,而是写一段 TypeScript 脚本,脚本里用 agent() 建子会话、用 ask<T>() 派任务,再用普通的循环、分支和 Promise.all 把它们串起来。编译器先做类型检查和静态分析,用户看过分析出的阶段图并确认,脚本才被降级成 JavaScript,放进沙箱子进程里执行。它和子 Agent那种一次委托一件事的做法不同,也和专家工作流那种阶段固定的图调度不同:编排逻辑是模型当场写出来的代码。
整个功能分三篇。本篇讲纯函数库 @zcode/dynamic-workflow(apps/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#1、actor#2、world-read#1、join#1、report#1、artifact#1(apps/zcode-cli/packages/dynamic-workflow/src/analysis/sites.ts:310、sites.ts:349、sites.ts:366、sites.ts:399、sites.ts:417、sites.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/coreor@zcode/bootstrap.
核对源码,src/ 下唯一的外部依赖是 typescript(apps/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 test(README.md:74),但开源版本里没有 tests/ 目录,package.json 也没有 test 脚本(package.json:18),只剩一个调试脚本 scratch/run.mjs。所以下文的例子是笔者按门面写的,并把克隆里的这个包复制出来编译后实际跑过。
门面:一个名词,一个动词
门面是一份 .d.ts 文本,以字符串常量的形式嵌在包里,文件名固定为 workflow-facade.d.ts(apps/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) |
门面里没有 createActor、join 这类名字: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,也不能 export、import。这份脚本编译零诊断,产物清单是 [{"id":"report","kind":"markdown"}]。评审员的名字按文件拼出来,所以不会撞上下面的 9006。
编译流水线
入口是 analyzeWorkflowScript(apps/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: [] 让 process、fetch、require 在编译期就报“找不到名字”,纯度从这里开始。编译器宿主完全虚拟:脚本、门面和 TypeScript 标准库全部从一个内存 Map 里取,writeFile 直接抛错(compile.ts:135)。不读磁盘是因为 CLI 最终打成 SEA 单文件,运行时没有 node_modules,照常用 ts.createCompilerHost 就读不到 lib.es2022.d.ts(apps/zcode-cli/packages/dynamic-workflow/scripts/generate-libs.mjs:1)。构建脚本从 lib.es2022.d.ts 出发,顺着 /// <reference lib> 把整条引用链收进 src/compiler/libs.generated.ts(generate-libs.mjs:40),装的 typescript 版本没变就直接退出(generate-libs.mjs:32)。笔者用 TypeScript 5.9.3 跑,收进了 57 个文件。README 点明了这样做的另一个好处:开发与打包后的行为完全一致,缺了哪个 lib 在包测试里就会暴露(README.md:133)。
诊断文本也为模型的自我修复改写过。脚本顶层写 declare 或 export,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 自己的码段:
| 码 | 出处 | 含义 |
|---|---|---|
| 9001 | apps/zcode-cli/packages/dynamic-workflow/src/analysis/facade-misuse.ts:20 | 门面函数只能直接调用:const spawn = agent、解构出 ask、把 Agent 转成结构兼容的本地类型,都会让调用失去站点 |
| 9002 | apps/zcode-cli/packages/dynamic-workflow/src/schema/types.ts:78 | 结果类型或 report 的实参不能序列化成 JSON(any、Date、函数、类实例、Promise……) |
| 9003 | apps/zcode-cli/packages/dynamic-workflow/src/analysis/world-run.ts:18 | world.run 的第一个实参不是编译期字面量 |
| 9004 | apps/zcode-cli/packages/dynamic-workflow/src/analysis/phases.ts:19 | phase() 的名字不是字面量、为空,或不是独立语句 |
| 9005 | apps/zcode-cli/packages/dynamic-workflow/src/analysis/actor-names.ts:43 | 两个 agent() 用了同一个字面量名字 |
| 9006 | actor-names.ts:54 | 在 fan-out 体内用固定名字建 actor,元素多于一个时运行期必然重名 |
| 9007 | apps/zcode-cli/packages/dynamic-workflow/src/analysis/artifacts.ts:37 | 产物 id 不是字面量、为空、超长、含非法字符,同一 id 跨了两种成员,或 report 的标签指错 |
| 9008 | artifacts.ts:48 | 看板声明写在循环、回调或条件分支里 |
| 9009 | artifacts.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 对脚本做一次“融合”解释,产出 AnalysisCore(apps/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”,内部记着原因 data、control、fifo、seq、carry(apps/zcode-cli/packages/dynamic-workflow/src/analysis/causality-graph.ts:26) |
控制流图 projectControlFlow | 执行下一步可能去哪 | 节点是“出现”而不是站点(被调用两次的 helper 出两个节点),边有 next、branch、loop、exit、fork、join、jump、throw、may-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 体里,集合可能为空,不是每次都会发出,所以是 maybe;stack 表示各元素的实例同时存在,界面上叠成一摞卡片(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拒绝并建议改用unknown,unknown发射成空 schema{};undefined只允许出现在可选属性上;bigint、symbol、函数、类实例、thenable 与Date、Map、Error、各种 TypedArray 等内建对象一律拒绝(emit.ts:106、emit.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 一律退回 generic(apps/zcode-cli/packages/dynamic-workflow/src/schema/actor-submit-profiles.ts:10、actor-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.enterPhase,Promise.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 作为 scriptHash(dynamic-workflow-run-submit.ts:524)。哈希算在原文而不是降级后的函数体上,因为恢复运行时比对的是原文。
下一篇:动态工作流(二):引擎、沙箱与日志回放——降级后的脚本怎样在 vm 子进程里跑,AskScheduler 怎样派活、修复与记账,日志又怎样让中断的 run 从原处接着跑。