curtiseng/spatiotemporal
时空可组合性演算的 Rust 实现:可撤销 effect、响应式 coeffect、fiber 惯性生命周期
项目介绍Project Overview
spatiotemporal 是论文《A Programming Paradigm for Spatiotemporal Composability》核心演算的 Rust 实现,把可撤销 effect 与响应式 coeffect 做成可组合抽象:组件运行时被装卸、干净拆除由库保证,不依赖作者。配套 agent harness 把文档、工具、LLM、界面都收进 fiber 树,配置热重载换实现只需关旧行插新行。需注意:0.4 版本仅单线程,Context::effect 同步跑完,无 HMR 与拦截层。
spatiotemporal is a Rust port of the composability calculus from "A Programming Paradigm for Spatiotemporal Composability." It packages reversible effects and reactive coeffects as usable abstractions, so components can be mounted and unmounted at runtime with cleanup guaranteed by the library, not by each author. A bundled agent harness turns documents, tools, LLMs, and UI into fibers in one tree; hot config reload swaps implementations by toggling rows. Caveat: the 0.4 release is single-threaded only, runs Context::effect to completion synchronously, and ships no HMR or interception layer.
请帮我了解并安装插件:【spatiotemporal】【https://github.com/curtiseng/spatiotemporal】
把上面这条消息直接发给当前会话里的 DSH,让它帮你了解并安装。安装命令不一定准确,发给 DSH 更稳。Send this message to DSH in your current session. CLI install commands may not be accurate across systems — DSH will figure it out for you.
或使用命令行安装(适合开发者)Or use CLI install (for developers)
命令行安装CLI Install
dsh plugin --profile web add github:curtiseng/spatiotemporal
把 curtiseng/spatiotemporal 加入你的 DSH 配置(web profile)即可启用。
READMEREADME
spatiotemporal
时空可组合性演算的 Rust 实现。
论文《A Programming Paradigm for Spatiotemporal Composability》第 5 章给出了一个核心库,把可撤销 effect 与响应式 coeffect 实现成可用的编程抽象;原文的参考实现 Cordis 是 TypeScript 写的。这里是同一套语义的 Rust 版本:核心库(5.1 节)加声明式配置层(5.2.1 节)。
一句话概括它解决什么问题:让组件可以在运行中被装上和拆掉,而拆得干净这件事由抽象保证,不依赖每个组件作者的勤谨程度。
// 组件只声明「我需要 storage」,不关心是谁在提供。
impl Component for Worker {
fn inject(&self) -> Vec<KeyId> { vec![KeyId::of::<StorageKey>()] }
fn apply(&self, ctx: Context, steps: Steps) -> LocalBoxFuture<'_, Result<()>> {
Box::pin(async move {
let place = ctx.resolve::<StorageKey>()?.place();
steps.step(move || async move { leave(place).await })?; // 登记这一步的逆
Ok(())
})
}
}
提供者被换掉时,这个组件自己会去激活、重新激活,且顺序有保证——它不需要写任何卸载路径,也不需要监听任何事件。cargo run --example swap_provider 能看到全过程。
试试 Agent
spatiotemporal-agent/ 是演算之上的插件化 agent harness,形态对齐 DeepSeek Harness (dsh):文档、工具、LLM、界面全是 cordis.yml 里的一行行 fiber,换实现 = disabled + insert,不必改宿主代码。

rustup target add wasm32-wasip2
./spatiotemporal-agent/scripts/build-guests.sh # outline.wasm
export DEEPSEEK_API_KEY=sk-…
cargo run -p spatiotemporal-agent -- --creation # http://127.0.0.1:8787
推荐首轮:切创造模式 → define_script 安装 code-stats → 批准 → 「统计代码行数」。script 叶子通过 host.callTool 编排 native 的 bash / read,结果见 DEMO.md。
| 能力 | 说明 |
|---|---|
| 四种基质 | 同一棵 fiber 树里 native / wasm / script / process 叶子并存(outline、cite、stats、bash…) |
| 三档 profile | 标准 demo → 编码(--coding,更多 tool 轮次、注入 CODING.prompt.md)→ 创造(热装 script、define_script 走审批) |
| 工具链路可视化 | 每轮展示思考步骤 + 工具调用时间线;步骤持久化到 session JSONL |
| 工作区切换 | 浏览器下拉或 API 切换项目目录;fs/bash 沙箱根热更新;最近目录记在 .agent/workspaces.json |
| 多会话 + 会话级隔离 | 每工作区独立 .agent/sessions/;聊天 JSONL + 会话 patch JSON;创造模式热装只进当前会话 |
| 配置热对账 | 浏览器或 POST /api/mode 切换 profile,无需重启进程 |
无 API key 时用 cargo run -p spatiotemporal-agent -- --smoke(LLM 换 echo、界面换 probe,CI 同命令)。启动细节、环境变量、试玩脚本见 spatiotemporal-agent/README.md 与 DEMO.md。
三个概念
可撤销 effect。 每个改动上下文的动作都配一个逆,逆是值(Inverse),卸载时按 LIFO 回放。这是上下文被改动的唯一原语:coeffect 供给与组件实例化都归约到它,所以经由上下文做的任何事都被自动追踪。
响应式 coeffect。 组件声明它需要哪些键;提供者出现即激活它,提供者离开即去激活它,与它无关的变化不打扰它。依赖不可用不是错误,只是保持非活动。
惯性。 一次转换(加载或卸载)一旦开始就跑到完成,期间的目标变化被记下但不打断它;完成时若目标已变,立刻链接进下一次转换。这是并发正确性的来源,也是最容易实现错的一处。
快速开始
[dependencies]
spatiotemporal = "0.5"
完整的最小例子见 src/lib.rs 顶部的文档,cargo test --doc 会真的把它跑一遍。
仓库结构
Cargo.toml # 内核,同时是 workspace 根
src/ tests/ examples/
crates/spatiotemporal-wasm/ # wasm 基质适配器,独立版本、独立发布
crates/spatiotemporal-script/ # QuickJS 脚本基质,模型现写的代码走这里
crates/spatiotemporal-process/ # 子进程(NDJSON stdio)基质
spatiotemporal-agent/ # 插件化 agent harness + Web UI(不发布 crates.io)
cordis.yml / cordis.*.yml # 组合与 profile patch(标准 / 编码 / 创造 / smoke)
assets/index.html # 浏览器 UI(工作区、多会话、工具链路)
.agent/
workspaces.json # 当前 / 最近工作区(启动 cwd 下,不入库)
sessions/ # 每工作区:{id}.jsonl(聊天)+ {id}.patch.json(会话 patch)
default-members 只含内核,所以 cargo test 不会去编 wasmtime 或 QuickJS——内核有 5 个依赖,而 wasmtime / rquickjs 各自再带一大坨。Agent 单独编:cargo run -p spatiotemporal-agent(见上一节)。整个 workspace 的 MSRV 统一为 1.94(wasmtime 47 的要求)。
配置热重载
Loader 把一棵配置树对账成一组活着的 fiber。配置变了就把差异增量地施加上去——没有任何新机制:每次变更最终都是 use_component 与 dispose,所以配置热重载是可撤销 effect 的一个应用,而不是它之外的另一套东西。
一份基础配置加一层用户 patch,形状照 dsh 的 cordis.yml + cordis.patch.yml:
# cordis.yml
- id: sandbox
name: dsh-sandbox-local
- id: tool-bash # 它声明了 sandbox,但不知道是谁在提供
name: dsh-tool-bash
- id: tool-web
name: dsh-tool-web
config:
fetch: false
# cordis.patch.yml —— 用户层,叠加在上面
- id: tool-web
config:
fetch: true # 按 id 的 patch 替换整个 config,没改的字段也要重述
searchTimeoutMs: 60000
- id: sandbox
disabled: true # 换实现 = 关掉旧行
- insert:
- id: sandbox-remote # 加上新行
name: dsh-sandbox-remote
保存之后,tool-web 那一行重挂,sandbox 换成远端,而 tool-bash 自己去激活再重新激活——它没有写任何重连逻辑。cargo run --example watch_config 用 notify 把这套跑起来,包括「写坏配置不会杀死运行中的树」那一支。文件监听刻意留在库外面:它属于宿主的职责。
这一层有五个值得单独说的决定:
先构造,再拆除。 注册表里没有的名字、不合法的配置,都在任何 fiber 被动过之前同步失败。所以一次写坏的编辑不会先把系统拆一半——这是 dsh「先导入变化后的模块名,再 dispose 活动 fiber」的同一条。
补偿事务,不是不可见的原子替换。 任意组件的 effect 无法快照,所以不承诺中间状态不可见。候选失败时拆掉候选、重建先前的行,并如实报告 Error::Rollback 而不是声称树被完好保留。重建出来的是新的 fiber:可撤销性保证逆会被运行,不保证时间被倒流。
串行且合并。 同一个 loader 上的并发 apply 不会交错,第二个调用只更新「期望状态」然后立刻返回,由正在跑的那一轮拾取。这不是吞吐取舍而是正确性要求:对账过程中会 await,两轮交错的对账会在同一行上交叉执行 create 与回滚。dsh 在这里踩过一个三方死锁(回滚等 HMR 拆卸、HMR 等自己的 refresh、refresh 排在正在回滚的 apply 后面),tests/loader.rs 里那条测试就是钉这个的。
patch 里的 name 是断言而不是赋值。 对不上就整条跳过并留一条警告。理由是一层 patch 可能是为另一套组合写的,而 id 撞车时静默地重配了另一个插件,比这条 patch 不生效危险得多。这条也照 dsh。
注册表必须显式建立。 Rust 没有运行时模块注册表(论文 6.4 节把这条列为原生语言的固有差异),所以 name → 构造器 这张表要手写。交换条件是:加一个新组件要动宿主一行代码并重新编译,但已注册组件的开关、重配、插入、移除全都不需要重启——而那正是配置热重载所要的全部。
给动态基质留的三个口子
原生组件全是编译期的:名字是字面量,依赖写成 KeyId::of::<K>(),执行器用库自带的那个。一个 wasm 组件、一段模型现写的代码、一个子进程都不是。
要紧的是基质不需要是内核概念。Registry 已经是 name → 构造器,wasm、script、remote 插件都只是 Component 的不同实现——各自的 apply 去调 wasmtime、QuickJS 或子进程,把注册动作用 steps.step 登记逆。所以适配器属于独立的 crate(wasmtime 一家就带上百个依赖,而这个 crate 现在只有四个),内核只让出三处:
名字可以是运行时的。 Component::name() 返回 &str 而不是 &'static str。静态名字照常写字面量,动态的可以来自 wasm 文件名或直接拼出来。
依赖可以用字符串声明。 KeyId 的同一性依据是 TypeId,运行时凭字符串构造不出来,所以有一张 KeyRegistry 做翻译:
let mut keys = KeyRegistry::new();
keys.add::<Tools>().add::<Shell>();
// guest 的 WIT 导入报上来的字符串,在这里变成能填进 inject 的键
let declared = keys.resolve_all(&["tools", "shell"])?;
这张表跟 Registry 是一对:那张说「哪些组件可以被装上」,这张说「哪些能力可以被按名字声明」。两张都由宿主显式建立,于是guest 说不出宿主没登记的键——能力面的边界就在这里,而不在 guest 的诚实程度上。同名不同键会 panic,因为静默顶掉前一个等于让一个 guest 拿到别人的能力。
执行器可以是宿主自己的。 论文注 2 说任务创建是宿主的职责,现在它是一个可注入的 Spawn。内核本身不含任何 IO,要让子进程或套接字成为一等 fiber,就得把带 IO 的执行器接进来:
struct LocalSetSpawner(tokio::task::LocalSet); // 示意
impl Spawn for LocalSetSpawner {
fn spawn(&self, task: LocalBoxFuture<'static, ()>) {
self.0.spawn_local(task);
}
}
let kernel = Kernel::new(Rc::new(spawner)); // App 是自带执行器的那个便利壳
一处值得知道的实现细节:inertia 是 Shared,谁 await 谁承担 poll,所以 quiesce() 会把它等的那次转换就地驱动完,不依赖宿主执行器是否勤快。丢弃任务的实际后果是「没有任何依赖方去等的转换会一直停在飞行中」,而不是死锁。
wasm 插件
crates/spatiotemporal-wasm 把这三个口子用上了:一个 WebAssembly 组件成为一等 fiber,跟原生组件受同一套规则约束。
// 宿主暴露它愿意让 guest 看见的能力,每一项都要写出投影。
let mut caps = Capabilities::new();
caps.expose::<Db, _>(|db| db.dsn());
// 授予哪些能力由宿主的配置决定,不是 guest 报上来的。
let plugin = WasmPlugin::open("plugins/tool-fs.wasm", Rc::new(caps), vec!["db".into()])?;
let handle = ctx.use_component(Rc::new(plugin));
装上时 guest 的 load 跑一遍,unload 被登记成这个 fiber 的逆——于是它跟原生组件的逆排在同一个 LIFO 序列里,由同一套惯性状态机调度。db 的提供者被换掉,这个 wasm 插件自己会去激活再重新激活,跟原生组件的行为逐字相同。
四个决定值得单独说:
能力由宿主授予,不由 guest 申请。 granted 来自配置,而不是去问 guest 要什么。这样 inject 属于配置的一部分、能被静态检视,不必先把 guest 跑起来才知道它要什么;也让它成为一个授权模型——第三方送来一个 .wasm,是运维决定它能看见什么。授予了一个宿主没暴露的名字就整体拒绝,绝不静默丢掉那一项。
能力在加载时刻取一次快照。 这不是对动态性的妥协,恰好就是这套语义:论文里一个 fiber 的 committed view 在它整段活跃期内是固定的,依赖一变它就被重载。所以「装上时取一次」和「每次调用都去查」在可观察行为上没有差别。快照顺带挡掉了重入——store 里不放 Context,guest 就没法在自己的转换还没结束时回头调内核。这一条还是被类型系统逼出来的:wasmtime_wasi 的 WasiView: Send,而 Rc 不是 Send。
每一项能力都要宿主写出投影。 跨 WIT 边界的值只能是 WIT 类型,而原生 coeffect 是 Rc<dyn Trait>。所以「guest 不能引入新的 coeffect 种类」这条限制,在代码里就落成了 Capabilities 那张投影表——表里没有的东西,guest 连名字都报不出来。
guest 的逆有燃料上限。 论文承诺逆会被调用,可没承诺逆自己规矩。一个 unload 里死循环的 guest 会把整次卸载拖死,而卸载没有别的出路。用燃料而不是墙钟期限,是因为它不需要另起线程去推 epoch,而且确定性——同一个 guest 每次都在同一条指令上耗尽。guests/runaway 就是这么一个赖着不走的 guest,对应的测试能跑完本身就是结论。
测试要真的 .wasm 产物:
cd crates/spatiotemporal-wasm
./scripts/build-guests.sh # 需要 rustup target add wasm32-wasip2
cargo test -p spatiotemporal-wasm
产物不入库——预编译的二进制没法评审也没法复现。测试找不到它会直接失败并让你回来跑这个脚本,而不是静默跳过;跳过会得到一个「绿了但什么都没测」的测试。
脚本插件
crates/spatiotemporal-script 是同一套口子的另一面:guest 不是 .wasm 文件,是一段字符串。这是「模型这一轮现写一段代码、当场装上、用完拆掉」那条自进化路径。
let plugin = ScriptPlugin::from_source(
"dyn-3",
r#"
export function load() { host.log("装上了 " + host.capability("db")); }
export function unload() { host.log("拆掉了"); }
"#,
Rc::new(caps),
vec!["db".into()],
)?;
能力面与 wasm 那份同形:host.log、host.capability,guest 导出 load / unload。语法错误在 from_source 就失败,任何 fiber 都还没被动过——这是 loader「先构造再拆除」在脚本基质上的落点。缺 unload 当成空操作:模型现写的代码经常忘了清理,fiber 仍须能拆掉。
抢占机制不同。QuickJS 没有指令燃料,用的是 Runtime::set_interrupt_handler:引擎定期问一次「该停了吗」,返回 true 就抛出不可捕获的异常。额度是回调次数而不是指令数,所以和 wasm 的 fuel 没有换算关系。对应的测试同样能跑完本身就是结论。
cargo test -p spatiotemporal-script
guest 还可以 host.registerTool(name, description, fn) 和 host.registerLlm(model, fn)(wasm 侧是 WIT 的 register-tool / register-llm + 导出 invoke)。登记本身的逆由宿主持有:脚本把自己的 unload 删掉,工具和 LLM 绑定照样会从桌上消失。
guest 还可 host.callTool(name, argsJson)(script)或 WIT call-tool(wasm),在叶子内编排宿主工具表里的其它 tool——与 LLM 调 tool 同路径,无需 grant fs/shell。
子进程插件
crates/spatiotemporal-process 把 MCP 类可执行 guest 接成一等 fiber。宿主与 guest 用 NDJSON(一行一个 JSON)说 load / invoke / unload;跨边界仍是字符串,能力由宿主授予。
let plugin = ProcessPlugin::open("plugins/mcp-bridge", Rc::new(caps), vec!["markdown".into()])?
.with_args(vec!["--stdio".into()]);
let handle = ctx.use_component(Rc::new(plugin));
unload 不回应时宿主会在墙钟超时后 kill 子进程——子进程没有 wasm 燃料,测试里 guests/runaway 就是用来钉这条的。
测试要真的 guest 产物:
cd crates/spatiotemporal-process
./scripts/build-guests.sh
cargo test -p spatiotemporal-process
Agent 细节
spatiotemporal-agent/README.md 列出完整 cordis.yml 插件表、创造模式审批与 compaction 配置。下面是常用 name 与基质对照(换实现见 cordis.smoke.yml):
配置里的 name |
基质 | 提供 |
|---|---|---|
doc / read-doc |
native | markdown 能力,登记「读全文」 |
wasm(outline) |
wasm | 抽标题大纲 |
script(cite / stats) |
script | 引用原文、统计字数 |
deepseek / echo |
native / script | llm(HTTP 或 smoke echo) |
web / probe |
native | surface(浏览器或 CLI 探测) |
creation-tools |
native | 创造模式元工具(inspect_*、define_script…) |
API Key 只放进环境变量,不要写进 yaml 或提交到 git。
与论文的对应
| 论文 | 这里 |
|---|---|
| $\Gamma_\infty$,一等上下文 | Context |
| $\mathrm{effect}_\Gamma(e)$(算法 1) | Context::effect |
| $\mathfrak{E}^{\mathrm{iter}}_\Gamma$(定义 51) | Steps::step |
| $\mathrm{set}(k,v)$、$\mathrm{get}(k)$(算法 2) | Context::set、Context::lookup |
notify(算法 3) |
Runtime::notify |
| $\mathrm{isolate}(k,r)$(定义 29) | Context::isolate |
ctx.use(算法 4) |
Context::use_component |
refresh / reload / unload(算法 5) |
同名私有函数 |
| proxy 中介的上下文访问(算法 6) | Context::resolve |
fiber.state(定义 44 的 $\theta$) |
State |
fiber.uid、fiber.committed、fiber.inertia |
代际索引、已提交视图、Shared future |
| O-Insert / O-Retire / O-Remove | use_component / FiberHandle::dispose / 卸载完成后移出竞技场 |
| L-Leave 与 L-Unload 上的守卫 | refresh 先标记 Unloading,unload 先排空依赖方 |
| 5.2.1 节的组件加载器 | Loader、Registry、compose |
注 2 的 create_task |
Spawn、Kernel(宿主可自带执行器) |
Rust 里的七个设计决定
论文 6.4 节说这套范式与语言无关,但要求宿主语言在两个维度上满足若干条件。Rust 满足它们,只是路径和 TypeScript 不同。
1. 逆用 FnOnce,于是「恢复至多一次」是类型保证。 TypeScript 版需要一个 armed 布尔来防止逆被跑两次;这里所有权移动本身就是那个保证。
2. fiber 存在竞技场里,互相只存 key。 不用 Rc<RefCell<Fiber>>,原因有两个:父子双向引用会构成 Rc 环,而算法 3 的 notify 要在遍历 fiber 的同时改它们,共享可变借用必然在运行时炸。竞技场把两个问题一起消掉,代价是所有访问都要过 with_fiber。
3. uid 用代际索引。 论文要求 uid「新鲜取得且永不复用」,好让被替换的提供者不与替换者混同。slotmap 的代际正是这个语义,于是这条要求由类型系统兜住,而不是靠自增计数器的纪律。
4. 惯性句柄用 Shared 而不是 JoinHandle。 算法 5 第 25 行要求多个依赖方同时等待同一次转换,而 Rust 的 future 是单消费者、JoinHandle 不能克隆。JavaScript 的 promise 可以被任意多次 await,这个差异必须显式处理。
5. 取消是协作式的,绝不 abort。 守卫在每个 step 边界检查 target,语义是「停在边界、保留已累积的逆」。在任意 await 点砍断任务会让已产出的逆丢失,直接违反定理 64 的部分回滚。
6. 效应迭代器改成登记面。 gen / async gen 块至今仍是 nightly,所以不用「yield 出逆」,而是让组件往 Steps 上推——每次 step 就是一个步骤边界。这里比论文更保守一点:先登记逆再检查守卫,因此被中断的那一步同样会被回滚。
7. 没有 Proxy,所以访问走类型化访问器。 论文算法 6 用 JavaScript 的 Proxy 中介 ctx[key],Rust 没有对等物。Context::resolve::<K>() 做同一件事:沿 fiber 链向上走,在第一个已提交该键的 fiber 处授权,未声明就是 Undeclared。论文 6.4 节预言的另一条路——用过程宏把 inject 声明提升到编译期检查——尚未实现,但接口是照着那个方向留的。
另外一处不得不显式化:Rust 没有稳定的 async Drop(async_drop 仍在 nightly,dyn 支持正是当前的阻塞项),而 unload 必须 await 各个逆。所以撤回是显式的 dispose().await,不能挂在 Drop 上。
测试对着定理写
cargo test 跑 54 个(内核与配置层),cargo test -p spatiotemporal-wasm 另跑 7 个,cargo test -p spatiotemporal-script 另跑 8 个。核心库那部分每个都指向论文的一条性质:
| 测试 | 论文 |
|---|---|
inverses_run_in_lifo_order |
定理 61,恢复精确性 |
recovery_is_at_most_once |
算法 1 的自我释放 |
failure_rolls_back_completed_steps |
4.3.4 节失败,L-Raise |
disposing_parent_cascades_to_children |
定义 47,实例化是父级的普通 effect |
transition_is_inert_while_in_flight |
4.3.3 节惯性 + 定理 64 终结恢复 |
activation_follows_the_provider |
定义 26,变化的三种分类 |
switching_provider_reloads_the_consumer |
定义 46,用提供者而非值标识绑定 |
isolate_splits_the_binding |
定义 29,隔离 |
access_is_mediated_by_the_declaration |
5.1.4 节 + 6.3 节,基于能力的访问控制 |
dependency_is_readable_during_own_teardown |
定理 63,coeffect 定序 |
teardown_cascades_from_the_far_end |
定理 66,终止性 |
insertion_order_does_not_affect_the_end_state |
定理 68,合流性 |
dependency_cycle_leaves_both_inactive |
6.5 节,环只是永不被满足 |
其中定理 63 那条最值得看:一个「因为依赖走了才被拆解」的组件,在自己的拆解过程中仍然能读到那个正在离去的依赖。论文说这条性质由三行代码的位置保证,测试就是在钉这三行的位置。
配置层那部分(tests/loader.rs、tests/config.rs)钉的是对账语义:
| 测试 | 钉的是 |
|---|---|
unchanged_rows_are_left_alone |
增量对账的全部意义:改一行不该让整棵树重启 |
a_config_change_reloads_only_that_row |
只有那一行动,而且先拆后装 |
an_unknown_name_fails_before_touching_anything |
先构造再拆除 |
a_failing_row_rolls_back_to_the_previous_tree |
补偿事务 |
a_row_waiting_for_its_dependency_is_not_a_failure |
依赖不可用只是非活动,是有效的 pending 配置项 |
swapping_a_provider_row_reloads_its_consumers |
改一行配置换掉实现,消费者自己跟上 |
concurrent_applies_are_serialized_and_coalesced |
串行化是正确性要求 |
a_name_in_a_patch_is_an_assertion_not_an_assignment |
patch 的 name 是断言 |
tests/dynamic.rs 钉的是给动态基质留的那三个口子:运行时名字能一路带到 fiber 上、按字符串声明的依赖与 KeyId::of 声明的行为完全一致(包括提供者走了就去激活)、以及转换只在宿主真的驱动任务之后才推进。
crates/spatiotemporal-wasm/tests/wasm.rs 钉的是跨语言边界之后这些性质还在:
| 测试 | 钉的是 |
|---|---|
a_wasm_component_lives_and_dies_like_any_fiber |
guest 的 unload 就是这个 fiber 的逆 |
the_name_comes_from_the_file |
运行时名字的实际用处 |
only_granted_capabilities_are_visible |
能力边界不在 guest 的诚实程度上 |
granting_an_unexposed_capability_is_refused |
全有或全无,绝不静默降级 |
a_wasm_plugin_waits_for_its_dependency |
定义 26 那三种分类对 wasm 插件同样成立 |
a_runaway_inverse_is_bounded |
卡住的逆会被燃料抢占,卸载仍然完成 |
the_adapter_reports_errors_as_component_failures |
wasm 侧的问题以组件失败出现,不是运行时崩溃 |
crates/spatiotemporal-script/tests/script.rs 钉的是同一组性质在字符串 guest 上还在,外加一条脚本特有的:
| 测试 | 钉的是 |
|---|---|
a_script_lives_and_dies_like_any_fiber |
guest 的 unload 就是这个 fiber 的逆 |
the_name_is_whatever_the_host_called_it |
运行时名字,模型现写的代码没有文件名 |
only_granted_capabilities_are_visible |
能力边界不在 guest 的诚实程度上 |
a_script_waits_for_its_dependency |
定义 26 对脚本同样成立 |
a_runaway_inverse_is_bounded |
卡住的逆会被 interrupt handler 抢占 |
the_adapter_reports_errors_as_component_failures |
语法错误在构造时失败,fiber 还没被动过 |
a_script_without_load_fails_as_a_component |
没有 load 就是普通的组件失败 |
还没有做的
这是 0.4,范围到论文 5.1 节加 5.2.1 节。以下都是明确的缺口,不是疏漏:
- 单线程。
Rc+RefCell+LocalPool。多线程版本要把所有 disposer 与 apply 的返回 future 加上Send + 'static,组件作者会明显感到约束。 Context::effect在调用点跑到完成,不作为并发任务。所以「飞行中的 effect 被 dispose 中止」这一支没实现;组件层(apply)的守卫与部分回滚是完整的。- 没有拦截(定义 31 的
@@intercept)。访问控制元数据与细粒度策略还没有对应物。 - 配置树是平的。 没有 group/嵌套子树,因此
insert只能追加到顶层。dsh 的组合包用嵌套行来把一组能力归到一个宿主行之下,那需要「一个组件持有自己的子对账器」,还没做。 - 没有热模块替换(5.2.2 节)。这在 Rust 里没有好答案,见论文 6.4 节:原生代码没有模块注册表,
dlopen/dlclose会撞上TypeId跨编译单元不一致、卸载时悬垂 vtable 等问题;wasm 组件模型更干净但要付序列化边界的代价。注意这跟配置热重载是两件事——后者已经在了,而且它才是长驻进程真正需要的那件(dsh 在两个发行形态里都把宿主端模块 HMR 关掉了,却给配置层补挂一个只看配置的 watcher)。 - 没有服务代理(6.2 节),因此没有负载均衡、滚动更新与跨进程调用。
- 没有过程宏,
inject仍是运行时声明。 - wasm / 脚本 / 子进程适配器只到叶子服务。 guest 可以登记工具或把自己登记成 LLM,还不能引入新的 coeffect 种类给原生插件消费,也不能收事件。子进程基质在
crates/spatiotemporal-process;MCP 桥接仍要宿主自己写 guest 可执行文件。
许可
MIT。
这是对论文的一次独立实现,与论文作者、Cordis 项目均无隶属关系。概念、算法与定理编号均出自该论文(Yifan Shi、Wei Zhang、Tianyi Cui)。想先把演算本身搞懂的话,有一门配套的通俗课:可组合性课堂。
nexu-io/open-design
ruvnet/ruflo
amruthpillai/reactive-resume
esengine/DeepSeek-Reasonix
volcengine/OpenViking
Molunerfinn/PicGo
titanwings/distilly
titanwings/colleague-skill