图解 Pi AgentPi Agent, Illustratedv1.1.0
包Packages· chord
演示Slides
Infra已完成Donepackages/chord · v1.1.0

chord@earendil-works/chord

应用组合运行时:服务、复制状态、RPC 与插件。Application composition runtime for services, replicated state, RPC, and plugins.

1.1.0
版本version
29
源文件src files
8,817
源码行src LOC
21
测试文件test files
286
测试用例test cases
5
导出路径export paths
依赖Depends on无内部依赖no internal deps被依赖Used bycoding-agentdurableenvprotocolclientserver

这个包是什么What this package ischord 是 Pi 新架构的底座:一个facet 宿主把小的功能单元(facet)按依赖顺序装配、激活、热重载和释放;facet 之间只通过有类型的服务交流,而服务可以在同一进程里,也可以跨传输在另一个进程里;服务暴露的状态是复制状态——提供方用普通的赋值修改,chord 算出最小的 JSON delta 发给每个订阅者。另有 ./context(取消与调用范围的值)、./delta(独立的变更追踪器)和 ./node(用 esbuild 打包插件、带 SHA-256 校验地加载)。chord is the foundation of Pi's new architecture: a facet host assembles small units of functionality (facets) in dependency order, activates them, hot-reloads them and disposes them; facets talk only through typed services, which can live in the same process or in another one across a transport; the state services expose is replicated state — the provider changes it with plain assignments and chord computes the minimal JSON delta for every subscriber. Around that sit ./context (cancellation and call-scoped values), ./delta (a standalone change tracker) and ./node (bundling plugins with esbuild and loading them with SHA-256 verification).

共 8,817 行 TypeScript,唯一的运行时依赖是 esbuild,不依赖任何其他 pi 包。使用者:durable(41 个文件)、coding-agent(26 个文件)、server(7 个文件)、client(2 个文件)、env(2 个文件)、protocol(2 个文件)——coding-agent 里用到的都在 src/experimental/ 下。8,817 lines of TypeScript; the only runtime dependency is esbuild, and it depends on no other pi package. Users: durable (41 files), coding-agent (26 files), server (7 files), client (2 files), env (2 files), protocol (2 files) — everything coding-agent uses lives under src/experimental/.

01Infra

模块结构Module structure#

一句话结论Takeaway五块:API 与类型(api.ts、types.ts)、facet 宿主(facets/host.ts 里的 FacetKernel)、服务与复制状态(services/)、JSON delta(delta/,最大的一块)、以及 context 与 Node 打包加载(context/、node/)。五个导入路径各对应其中一块。Five blocks: API & types (api.ts, types.ts), the facet host (FacetKernel in facets/host.ts), services & replicated state (services/), JSON deltas (delta/, the largest), and context plus Node bundling and loading (context/, node/). Each of the five import paths maps to one of them.

chord 的模块、导入路径与 pi 中的使用者chord modules, import paths and the user inside pi
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
chord modules (8,817 lines of TypeScript)API 与类型API & typesFacet 宿主Facet host服务ServicesDeltaContext 与 NodeContext & Node导入路径、依赖与使用者Import paths, dependencies and users85index.ts根入口:重新导出root entry: re-exports110api.tscreateFacetHost() 等createFacetHost() …339types.tsFacet · Service · Context …120json.tsisJsonValue · copyJson906host.tsFacetKernel:激活 · 重载FacetKernel: activate · reload6loader.tsLoadedFacets 的释放disposing LoadedFacets713consumer.ts远程绑定:稳定外观remote binding: stable facade633provider.tsRemoteServiceProvider457state.ts复制状态 · 订阅replicated state · subscriptions248wire.ts$chord.service 控制调用$chord.service control calls176state-codec.ts每个订阅一套路径字典one path dictionary per subscription151instances.tskeyed 实例 · generationkeyed instances · generations113handle.tsServiceSlot · 代理视图ServiceSlot · proxy views2,205tracker.tstrack() · Draft · prepare/adopt694index.tsOp · WireOp · apply · 编解码Op · WireOp · apply · codec523diff.tsdiffRevisions()128apply-immutable-trusted.ts结构共享地重放structurally shared replay75revision-validator.ts校验不可信的 opvalidates untrusted ops121index.ts取消 · 调用范围的值cancellation · scoped values416bundle-loader.ts校验 SHA-256 · node:vmSHA-256 check · node:vm222bundle.tsesbuild → .cjs + 清单esbuild → .cjs + manifest232package.tspackage.json 中的 chord.facetschord.facets in package.json@earendil-works/chord@earendil-works/chord/context@earendil-works/chord/delta@earendil-works/chord/bundler@earendil-works/chord/nodeesbuild 0.28.2durable · 41coding-agent · 26server · 7client · 2env · 2protocol · 2setup 同步声明依赖;setup declares deps synchronously;宿主校验整张图后the host validates the whole graph,按依赖顺序激活。then activates in dependency order.较小的文件:Smaller files:services/errors.tsservices/loopback.tsservices/state-internals.tsdelta/draft.tsnode/manifest.tsbundler.ts · node.ts依赖:dep:被这些包导入(src 中的文件数):Imported by (files in src):标签 = 行数;不依赖任何其他 pi 包tag = lines; depends on no other pi packagefacets服务与状态services & statedeltacontext · node

要点Key points

  • 最大的单个文件是 delta/tracker.ts:它让提供方像改普通对象一样改状态(Draft 代理),再算出 op。The largest single file is delta/tracker.ts: it lets a provider change state like a plain object (a Draft proxy) and then computes the ops.
  • ./delta 和 ./context 可以单独使用,不需要 facet 宿主;./bundler 与 ./node 只在 Node 上需要。./delta and ./context work on their own without the facet host; ./bundler and ./node are only needed on Node.
  • durable 是最大的使用者:它的 viewState()、watch() 和任务图都是 chord 的复制状态,每次异步调用都带一个 chord Context。durable is the biggest user: its viewState(), watch() and task graph are chord replicated states, and every async call takes a chord Context.

工作原理How it works

  1. defineService<T>(id) 定义令牌,defineFacet({ id, setup }) 定义功能单元。defineService<T>(id) defines a token, defineFacet({ id, setup }) a unit of functionality.
  2. createFacetHost({ facets, serviceSources? }) 装配并激活,返回 { services, reload, dispose }。createFacetHost({ facets, serviceSources? }) assembles and activates, returning { services, reload, dispose }.
  3. 跨进程时,一边 createRemoteServiceEndpoint(provider),另一边 createRemoteServiceBinding({ transport, … })。Across processes, one side uses createRemoteServiceEndpoint(provider) and the other createRemoteServiceBinding({ transport, … }).

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/chord/src/index.tsL12export …包入口package entry
packages/chord/src/api.tsL22createFacetHost()宿主入口host entry
packages/chord/src/types.tsL281FacetEnvironmentsetup 的参数setup's argument
packages/chord/src/facets/host.tsL340FacetKernel宿主内核the host kernel
packages/chord/src/delta/tracker.tsL321track()变更追踪器the change tracker
02Infra

公共 APIPublic API#

一句话结论Takeaway5 个导入路径(另有 ./package.json)。根入口从 8 个模块重新导出 26 个值和 46 个类型:createFacetHost、defineFacet / defineService、replicatedState 与远程服务的线格式;打包与磁盘加载分别在 ./bundler 与 ./node。5 import paths (plus ./package.json). The root re-exports 26 values and 46 types from 8 modules: createFacetHost, defineFacet / defineService, replicatedState and the remote-service wire format; bundling and loading from disk live in ./bundler and ./node.

导入路径Import path目标Target说明Notes
@earendil-works/chord./dist/index.jsfacet 宿主、服务、复制状态、RPC 线格式与类型the facet host, services, replicated state, the RPC wire format and types
@earendil-works/chord/context./dist/context/index.js上下文与取消context and cancellation
@earendil-works/chord/delta./dist/delta/index.jsJSON 草稿与增量JSON drafts and deltas
@earendil-works/chord/bundler./dist/bundler.js把 facet 包打成可校验的 bundlebundle a facet package with integrity data
@earendil-works/chord/node./dist/node.jsNode 专用:从磁盘加载 bundleNode only: load bundles from disk
@earendil-works/chord/package.json./package.json包元数据package metadata

package.json · exportsL8

常用导出Key exports

模块Module常用导出Key exports用途Use
packages/chord/src/api.tscreateFacetHost启动宿主:加载 facet、激活、提供服务start a host: load facets, activate, provide services
packages/chord/src/api.tsdefineFacet defineService声明插件与服务令牌declare plugins and service tokens
packages/chord/src/api.tsreplicatedState跨边界复制的状态state replicated across the boundary
packages/chord/src/api.tscreateStaticFacetLoader combineFacetLoaders组合 facet 来源compose facet sources
packages/chord/src/api.tscreateRemoteServiceBinding把远端服务接进宿主bind a remote service into a host
packages/chord/src/services/provider.tsRemoteServiceProvider createRemoteServiceEndpoint把本地服务暴露给远端expose local services to a remote side

根入口 index.ts 的全部导出(按来源模块)Every root index.ts export, by source module

展开全部 8 个来源模块Show all 8 source modules26 values46 types
来源模块Source module值导出Value exports类型导出Type exports
packages/chord/src/api.tscombineFacetLoaders createFacetHost createRemoteServiceBinding createStaticFacetLoader defineFacet defineService replicatedState—
packages/chord/src/delta/index.ts—Draft
packages/chord/src/json.tscopyJson isJsonValueCopyJsonOptions
packages/chord/src/services/errors.tsisRemoteServiceErrorCode REMOTE_SERVICE_ERROR_CODES RemoteServiceErrorRemoteServiceErrorCode
packages/chord/src/services/provider.tscreateRemoteServiceEndpoint RemoteServiceProviderRemoteServiceEndpoint ServiceUpdatePublisher
packages/chord/src/services/state-codec.tscreateServiceStateDecoder createServiceStateEncoderServiceStateDecoder ServiceStateEncoder
packages/chord/src/services/wire.tscreateServiceCatalogueCall createServiceSubscribeCall createServiceUnsubscribeCall decodeServiceControlCall parseServiceCall parseServiceCatalogue parseServiceProviderUpdate parseServiceSubscriptionSnapshot parseWireServiceProviderUpdate parseWireServiceSubscriptionSnapshotServiceControlCall WireServiceInstanceSnapshot WireServiceMemberSnapshot WireServiceProviderUpdate WireServiceSubscriptionSnapshot
packages/chord/src/types.ts—AttachedReplicatedState Context ContextKey Facet FacetEnvironment FacetHost FacetLoader FacetOptions JsonRepresentation JsonValue +24

由构建脚本解析 export { … } from 语句生成。Generated by parsing the export { … } from statements at build time.

03Infra

Facet 的一代:装配与激活A facet generation: assembly and activation#

一句话结论Takeawayactivate() 依次经过 setup → assembling → connecting → activating → active:先同步调用每个 facet 的 setup(env) 收集它要用和要提供的服务,再整体校验(一个服务一个提供者、模式一致、无环),按拓扑顺序给出激活顺序,等所有远程绑定 ready,最后按顺序运行各自的 onActivate。释放按相反顺序进行。activate() goes setup → assembling → connecting → activating → active: each facet's setup(env) runs synchronously to collect what it uses and provides, the whole graph is validated (one provider per service, matching modes, no cycles), a topological sort gives the activation order, every remote binding is awaited until ready, and each onActivate runs in order. Disposal runs in reverse.

一个 facet 代:setup、校验、连接、激活与释放One facet generation: setup, validation, wiring, activation and release
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
One facet generation: setup → validate → connect → activate → disposesetup(env) 能做的事What setup(env) can doFacetEnvironmentassembling:validateFacets()assembling: validateFacets()例:两个 facetExample: two facetssetup同步调用 facet.setup(env)facet.setup(env), synchronouslyassembling外部目录 · 校验 · 排序catalogues · validate · orderconnecting等所有绑定 ready()wait for every binding ready()activating按拓扑顺序 activate()activate() in topological orderactive服务可用services usabledisposing逆序释放reverse-order disposalmodels-facetenv.provide(Models, impl)ui-facetenv.use(Models)任何一步失败:#terminate() 释放已建好的一切,原错误与清理错误一起以 AggregateError 抛出("Facet generation startup and cleanup failed")。A failure at any step: #terminate() disposes whatever was built and throws the original error with any cleanup errors as an AggregateError ("Facet generation startup and cleanup failed").env.use()声明对单例服务的硬依赖,返回稳定句柄hard dependency on a singleton; returns a stable handleenv.observe()依赖 keyed 服务,逐个观察活着的实例depend on a keyed service; observe each live instanceenv.provide()提供单例实现(必须是对象)provide a singleton implementation (an object)env.provideMany()拥有 keyed 服务,返回 spawn(key, impl)own a keyed service; returns spawn(key, impl)env.replicatedState()创建本 facet 的可变复制状态create mutable replicated stateenv.own()登记一个释放函数register a disposal functionenv.onActivate()依赖就绪后的异步初始化async init once dependencies are readyenv.onDeactivate()最终拆除(= own)final teardown (same as own)每个服务只能有一个提供者(facet 或宿主的外部来源)each service has exactly one provider (a facet or a host source)"is provided by both … and …"singleton / keyed 模式必须一致singleton / keyed modes must match"provided as both singleton and keyed"每个 use / observe 都必须有提供者every use / observe needs a provider"… but no facet provides it"依赖图无环:Kahn 拓扑排序给出激活顺序no cycles: a Kahn topological sort gives the activation order"Facet dependency cycle: …"激活:models-facet → ui-facetactivate: models-facet → ui-facet释放:ui-facet → models-facetdispose: ui-facet → models-facet每个 facet 内部,own() 登记的释放Inside each facet, own() disposals函数也按登记的逆序执行;also run in reverse registration order;错误收集后统一抛出。errors are collected and rethrown.setup 返回 Promise 会被拒绝("setup must be synchronous");use / provide 等只能在 setup 期间调用,句柄只能在 active 期间使用。A setup that returns a Promise is rejected ("setup must be synchronous"); use / provide and friends work only during setup, and handles only while active.facet 宿主facet host远程绑定remote bindings阶段顺序phase order依赖depends on

要点Key points

  • setup 必须同步:返回 Promise 会被拒绝。这让依赖图在任何异步工作开始前就是完整、可校验的。setup must be synchronous: returning a Promise is rejected. That makes the dependency graph complete and checkable before any async work starts.
  • 校验错误都带着服务和 facet 的名字:"is provided by both …"、"… but no facet provides it"、"Facet dependency cycle: …"。Validation errors name the service and facets: "is provided by both …", "… but no facet provides it", "Facet dependency cycle: …".
  • 任何一步失败,#terminate() 释放已经建好的部分,把原错误和清理错误合成一个 AggregateError 抛出。If any step fails, #terminate() disposes what was built and throws the original error together with cleanup errors as one AggregateError.
  • 每个 facet 内部,own() 登记的释放函数也按逆序执行——后建的先拆。Within each facet, the disposals registered with own() also run in reverse — last built, first torn down.

工作原理How it works

  1. FacetKernel.activate():对每个 facet 调 setup,得到 FacetShape(requires / provides)。FacetKernel.activate(): call setup for each facet to get its FacetShape (requires / provides).
  2. #resolveExternalServices() 读宿主的 serviceSources 目录 → validateFacets() → topologicalOrder()。#resolveExternalServices() reads the host's serviceSources catalogues → validateFacets() → topologicalOrder().
  3. 绑定服务 → 等待 ready() → 按 #activationOrder 逐个 activate()。Bind services → await ready() → activate() each facet in #activationOrder.

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/chord/src/facets/host.tsL398activate()激活一代activating a generation
packages/chord/src/facets/host.tsL808validateFacets()整图校验graph validation
packages/chord/src/facets/host.tsL858topologicalOrder()激活顺序activation order
packages/chord/src/facets/host.tsL753#terminate()失败清理cleanup on failure
packages/chord/src/facets/host.tsL796disposeFacetRecords()逆序释放reverse disposal
04Infra

热重载Hot reload#

一句话结论Takeawayreload(facets) 用新实现替换一组已激活的 facet,而使用方手里的句柄不断开:先 setup 候选并检查它们的 requires / provides 与旧的完全相同,再在旧实现仍然服务请求时激活候选,然后切换(单例重新绑定、远程提供者 replace()),再退役旧 facet、连接新的 keyed 实例。reload(facets) replaces a set of active facets with new implementations without disconnecting anyone's handles: set up the candidates and check their requires / provides are exactly the same, activate them while the old ones still serve, then cut over (rebind singletons, replace() remote providers), retire the old facets and connect the new keyed instances.

reload(facets):句柄不断开地替换实现reload(facets): replacing implementations without breaking handles
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
FacetHost.reload(facets): replace implementations without disconnecting handles失败时On failure为什么句柄不断开Why handles stay connectedservices/handle.ts检查Checkphase 必须是 active;ID 唯一且都已激活phase must be active; IDs unique and all active候选 setupCandidate setupsameFacetShape():requires / provides 不变sameFacetShape(): same requires / provides激活候选Activate candidates按原激活顺序;旧实现仍在服务请求in the existing order; the old ones still serve切换Cut over单例:local → bindSingleton · 远程 → replace()singletons: local → bindSingleton · remote → replace()退役旧 facetRetire old facets逆序 disposedispose in reverse order连接 keyedConnect keyed新实例拿到新的 generationnew instances get fresh generationsconsumer拿着代理视图holds a proxy viewServiceSlot宿主拥有owned by the hostold implnew impl切换之前(第 1–3 步)Before the cutover (steps 1–3)释放候选;宿主回到 active,旧实现照常工作。candidates are disposed; the host returns to active and the old ones keep working.只有清理也失败时才中止整个代。Only if cleanup fails too is the whole generation aborted.切换之后(第 4–6 步)After the cutover (steps 4–6)#abort(previous):撤销所有 facet 的服务访问并终止整个代,#abort(previous): revoke every facet's service access and terminate抛出 "Facet reload failed after cutover"。the generation; throws "Facet reload failed after cutover".每次访问成员时,代理都通过 slot.resolve() 读当前实现,Every member access resolves the current implementation through所以 bind(new) 之后,已有句柄立即调用新代码。slot.resolve(), so after bind(new) existing handles call the new code.unbind() 之后访问 → "Service … is disconnected"。Access after unbind() → "Service … is disconnected".README 推荐的重载流程:先 load() 候选的 LoadedFacets → 把 facets 交给 reload() → 失败则释放候选;The README's reload recipe: load() the candidate LoadedFacets → pass its facets to reload() → dispose the candidate on failure;成功切换之后才释放退役的那一代 LoadedFacets(代码随之可被回收)。dispose the retired LoadedFacets only after a successful cutover (its code can then be collected).keyed 实例按代区分(ServiceInstanceAddress = key + generation):重载后旧实例关闭,新实例以新 generation 出现。Keyed instances are generation-specific (ServiceInstanceAddress = key + generation): old instances close, new ones appear with new generations.123456步骤steps切换cutover失败处理failure handling

要点Key points

  • 形状不变是硬性要求:重载不能增删依赖或提供的服务("must preserve its service requirements and provisions")。改形状需要新的一代。Unchanged shape is a hard rule: a reload cannot add or remove dependencies or provisions ("must preserve its service requirements and provisions"). Changing the shape needs a new generation.
  • 切换之前失败:释放候选,宿主回到 active,旧实现继续工作。切换之后失败:整个代被中止("Facet reload failed after cutover")。A failure before the cutover disposes the candidates and returns to active with the old code still working. After the cutover, the whole generation is aborted ("Facet reload failed after cutover").
  • 句柄是代理:每次访问都通过 ServiceSlot.resolve() 取当前实现,所以重新 bind 之后已有句柄立即用上新代码。Handles are proxies: every access resolves the current implementation through ServiceSlot.resolve(), so after a rebind existing handles use the new code immediately.
  • keyed 实例按 generation 区分:重载后旧实例关闭,新实例以新的 generation 出现,观察者会看到 closed 再 spawned。Keyed instances are generation-specific: after a reload the old ones close and new ones appear with new generations, so observers see closed then spawned.

工作原理How it works

  1. 宿主先用 loader 的 load() 拿到候选的 LoadedFacets。The host first gets the candidate LoadedFacets from a loader's load().
  2. host.reload(loaded.facets);失败就释放候选。host.reload(loaded.facets); on failure, dispose the candidate.
  3. 成功后再释放退役的那一代 LoadedFacets,旧代码随之可以被回收。After success, dispose the retired LoadedFacets, so the old code can be collected.

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/chord/src/facets/host.tsL423reload()重载流程the reload flow
packages/chord/src/facets/host.tsL891sameFacetShape()形状检查shape check
packages/chord/src/facets/host.tsL747#abort()切换后失败failure after cutover
packages/chord/src/services/handle.tsL9ServiceSlot可重新绑定的槽the rebindable slot
packages/chord/src/services/instances.tsL18InstanceDirectorykeyed 实例与代keyed instances and generations
05Infra

服务、句柄与 ContextServices, handles and Context#

一句话结论Takeaway服务令牌是 defineService<T>(id, options?)。默认是远程服务:它的成员只能是 (…json, Context) => Promise<json> 或 ReplicatedState<json>,这样同一份接口既能在进程内用,也能跨传输用;{ local: true } 的服务可以是任意 TypeScript 形状,但永远不出本进程。提供方式有单例(provide / use)与 keyed(provideMany / observe)两种。A service token is defineService<T>(id, options?). By default it is remote: its members must be (…json, Context) => Promise<json> or ReplicatedState<json>, so one interface works both in-process and across a transport; a { local: true } service can have any TypeScript shape but never leaves the process. Provision is either singleton (provide / use) or keyed (provideMany / observe).

服务令牌、模式、稳定句柄与 ContextService tokens, modes, stable handles and Context
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
Services: tokens · modes · stable handles · Context服务令牌:defineService<T>(id, options?)Service tokens: defineService<T>(id, options?)两种提供方式Two provision modes一个调用的路径The path of one callContext:显式传递的调用上下文Context: an explicitly passed call contextchord/context远程(默认)remote (default)本地:{ local: true }local: { local: true }pi.transcriptpi.modelspi.agent-controllerpi.local.presentation-uisingletonprovide(S, impl) ↔ use(S)keyedprovideMany(S) ↔ observe(S, …)models.select(m, ctx)facet 拿到的句柄handle the facet holdsServiceSlot.resolve()取当前实现(重载可换)current implementation (reload-safe)impl.select(m, ctx)本地对象,或远程绑定的成员代理local object, or a remote member proxy成员只能是:members must be:(…json, Context) => Promise或 ReplicatedState<json>or ReplicatedState<json>任意 TypeScript 形状;any TypeScript shape;只在本进程内提供,provided in-process only,不能跨传输。never crosses a transport.coding-agent/src/experimental/services 中的例子:Examples from coding-agent/src/experimental/services:空 ID 会抛错;"$chord." 前缀保留给 chord 自己的控制调用。An empty ID throws; the "$chord." prefix is reserved for chord's own control calls.一个服务 ID 只有一个实现;One implementation per service ID;use() 返回稳定句柄。use() returns a stable handle.spawn(key, impl) 返回 close();spawn(key, impl) returns close();observer 对每个活实例被调用,the observer runs for each live实例关闭时执行其清理函数。instance and cleans up on close.来源:facet 提供,或宿主的 serviceSources(外部服务目录)提供——Source: a facet, or one of the host's serviceSources (external catalogues)两者不能同时提供同一个服务。— never both for the same service.远程成员最后一个A remote member's last参数必须是 Context,argument must be a Context,否则 service_invalid_value。else service_invalid_value.绑定关闭后调用 →Calling after the bindingservice_stale_instance。closes → service_stale_instance.BACKGROUND_CONTEXT · TODO_CONTEXT根上下文(TODO 标记"尚待接线")roots (TODO marks "not wired yet")createContextKey · withContextValue调用范围内的键值call-scoped valueswithAbortSignal · withCancel合并取消信号(AbortSignal.any)combine cancellation (AbortSignal.any)withoutAbortSignal保留值,去掉取消keep values, drop cancellationawaitWithContext(p, ctx)取消时只拒绝等待者,不中止 pcancellation rejects the waiter only, not p远程服务remote services本地服务local servicesfacet 侧facet sideContext

要点Key points

  • 远程约束在类型层面检查(RemoteServiceContract<T>),运行时在边界上再校验一次(validateRemoteServiceImplementation)。The remote constraint is checked in the type system (RemoteServiceContract<T>) and again at runtime at the boundary (validateRemoteServiceImplementation).
  • coding-agent 的实验服务就是例子:pi.transcript 只有一个 state: ReplicatedState<ConversationView>;pi.local.presentation-ui 是本地服务。coding-agent's experimental services are examples: pi.transcript has just state: ReplicatedState<ConversationView>; pi.local.presentation-ui is a local service.
  • 每个调用最后一个参数都是 Context:它携带取消信号和调用范围的值。awaitWithContext() 取消时只拒绝等待者,不会中止正在进行的工作。Every call's last argument is a Context: it carries cancellation and call-scoped values. awaitWithContext() on cancellation rejects only the waiter, never the work in progress.
  • ID 以 $chord. 开头的服务是保留的:那是 chord 自己的控制调用。Service IDs starting with $chord. are reserved: that prefix is chord's own control calls.

工作原理How it works

  1. export const Models = defineService<Models>("pi.models")——类型和令牌同名。export const Models = defineService<Models>("pi.models") — the type and the token share a name.
  2. 在 facet 的 setup 里:const models = env.use(Models);在 onActivate 或之后调用 await models.select(m, ctx)。In a facet's setup: const models = env.use(Models); from onActivate on, call await models.select(m, ctx).
  3. 取消:const { context, cancel } = withCancel(parent),把 context 传下去。Cancellation: const { context, cancel } = withCancel(parent) and pass context down.

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/chord/src/api.tsL80defineService()服务令牌service tokens
packages/chord/src/types.tsL182RemoteServiceContract远程约束the remote constraint
packages/chord/src/services/provider.tsL587validateRemoteServiceImplementation()运行时校验runtime check
packages/chord/src/context/index.tsL83withCancel()取消cancellation
packages/chord/src/context/index.tsL98awaitWithContext()只取消等待者cancel the waiter only
packages/coding-agent/src/experimental/services/transcript.tsL9pi.transcript一个真实的服务a real service
06Infra

跨进程的服务Services across processes#

一句话结论Takeaway提供方一侧由 RemoteServiceProvider 执行调用、管理订阅,createRemoteServiceEndpoint() 处理 $chord.service 控制调用(catalogue、subscribe、unsubscribe);使用方一侧的 createRemoteServiceBinding() 给出与本地一样的外观。中间的传输只要实现 invoke 与 subscribe,外层信封由应用决定。On the provider side, RemoteServiceProvider runs calls and manages subscriptions, and createRemoteServiceEndpoint() serves the $chord.service control calls (catalogue, subscribe, unsubscribe); on the consumer side, createRemoteServiceBinding() gives the same facade as a local service. The transport in between only implements invoke and subscribe; the outer envelope is up to the application.

跨进程服务:provider、transport 与 bindingServices across processes: provider, transport and binding
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
Services across processes: provider ⇄ transport ⇄ binding提供方进程Provider process传输(应用自选)Transport (application's choice)RemoteServiceTransport使用方进程Consumer process订阅更新的 6 种类型The 6 subscription update typesServiceProviderUpdate背压与错误Backpressure and errors服务实现service implementationfacet 的 provide / provideManya facet's provide / provideManyRemoteServiceProviderinvoke(call) · subscribe(id, mode)createRemoteServiceEndpoint()处理 "$chord.service" 控制调用serves "$chord.service" control callscreateRemoteServiceBinding()稳定外观:成员代理 + 复制状态stable facade: member proxies + replicasfacet 的 use() / observe()a facet's use() / observe()像本地对象一样调用calls it like a local objectstate.value · state.subscribe()按序重放 delta 的副本replica that replays deltas in ordernot_allowednot_foundmode_mismatchmember_not_foundmember_mismatchinstance_not_foundstale_instanceinvalid_value调用 [...args, ctx]calls [...args, ctx]invoke(call, context)subscribe(serviceId, mode, listener, ctx)chord 不规定外层协议:这些值可以放进chord does not prescribe the outer protocol:任何路由 / 请求 / 事件信封。wrap these values in any envelope.$chord.service 的三种控制调用:Three $chord.service control calls:· catalogue· subscribe(subscriptionId, serviceId, mode)· unsubscribe(subscriptionId)同进程:createLoopbackServiceTransport(provider)In-process: createLoopbackServiceTransport(provider)调用 / 订阅invoke / subscribe更新updatesstate某成员的一批 op,sequence 连续a batch of ops for one member, contiguous sequencereset整份快照:每个 state 都是 ["r", value]full snapshot: every state is ["r", value]unavailable当前没有提供者no provider right nowreplaced提供者被替换(如重载)the provider was replaced (e.g. reload)spawnedkeyed 新实例出现a new keyed instance appearedclosedkeyed 实例关闭a keyed instance closed每个订阅最多缓存 100 个待发更新;第 101 个到来时,At most 100 pending updates per subscription; when update 101整个队列被替换成一个 { type: "reset", snapshot }。arrives the whole queue becomes one { type: "reset", snapshot }.普通 state 更新绝不允许 sequence 跳号。An ordinary state update never permits a sequence gap.可以跨边界的 8 个错误码:The 8 error codes that may cross the boundary:错误码都带 service_ 前缀all codes carry a service_ prefix调用calls订阅更新subscription updates提供方provider使用方consumer

要点Key points

  • 订阅更新有 6 种:state(某成员的一批 op)、reset、unavailable、replaced、spawned、closed。Subscription updates come in 6 kinds: state (a batch of ops for one member), reset, unavailable, replaced, spawned, closed.
  • 背压:每个订阅最多 100 个待发更新;第 101 个到来时整个队列换成一个 reset,带着当前完整快照和新的 sequence 基线。Backpressure: at most 100 pending updates per subscription; when the 101st arrives the whole queue becomes one reset with a full current snapshot and new sequence baselines.
  • 普通 state 更新绝不允许跳号;传输适配器必须保持快照 / reset / 更新的顺序,不能丢批。An ordinary state update never permits a gap; transport adapters must keep snapshot / reset / update order and must not drop batches.
  • 能跨边界的错误只有 8 个 service_* 码,例如 service_not_found、service_stale_instance、service_invalid_value。Only 8 service_* codes may cross the boundary, e.g. service_not_found, service_stale_instance, service_invalid_value.

工作原理How it works

  1. 提供方:用宿主的 provider 创建 createRemoteServiceEndpoint(provider),把收到的调用交给它。Provider: create createRemoteServiceEndpoint(provider) from the host's provider and hand it incoming calls.
  2. 使用方:实现 RemoteServiceTransport;再实现一个 RemoteServiceSource(catalogue() + open()),在 open() 里用 createRemoteServiceBinding({ services, transport }),作为宿主的 serviceSources 传入——coding-agent 的 experimental/services/connection.ts 就是这样做的。Consumer: implement RemoteServiceTransport; then a RemoteServiceSource (catalogue() + open()) whose open() uses createRemoteServiceBinding({ services, transport }), passed to the host in serviceSources — as coding-agent's experimental/services/connection.ts does.
  3. 同进程测试:createLoopbackServiceTransport(provider) 直接把两边接起来。In-process tests: createLoopbackServiceTransport(provider) wires both sides directly.

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/chord/src/services/provider.tsL79RemoteServiceProvider提供方the provider
packages/chord/src/services/provider.tsL445#emit()100 → reset100 → reset
packages/chord/src/services/provider.tsL549createRemoteServiceEndpoint()控制调用control calls
packages/chord/src/services/consumer.tsL444RemoteServiceBindingImpl使用方外观the consumer facade
packages/chord/src/services/wire.tsL40$chord.service控制服务 IDcontrol service ID
packages/chord/src/services/errors.tsL1REMOTE_SERVICE_ERROR_CODES8 个错误码8 error codes
packages/chord/src/types.tsL308RemoteServiceSource外部服务来源external service sources
07Infra

复制状态Replicated state#

一句话结论Takeawayenv.replicatedState(initial) 返回一个可变的复制状态。change(ctx, draft => …) 在一个 Draft 上做普通的赋值,chord 算出 op、换上新的不可变版本、sequence 加一,然后发给两类订阅者:内部的精确订阅(同步拿到每个版本的 op,用来编码给远端)和公开的 subscribe()(每个订阅串行收到完整值)。env.replicatedState(initial) returns a mutable replicated state. change(ctx, draft => …) makes plain assignments on a Draft; chord computes the ops, swaps in the new immutable revision, increments the sequence and publishes to two kinds of subscriber: internal exact subscriptions (every revision's ops, synchronously, for encoding to remotes) and public subscribe() (complete values, serialized per subscription).

复制状态:一次 change() 如何变成远端的新值Replicated state: how one change() becomes a new value on the other side
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
Replicated state: how one change() becomes a new value elsewhere发布之后:两类订阅者After publishing: two kinds of subscriber远端副本:严格按序The remote replica: strictly in orderchange(ctx, draft => …)不可重入;回调必须同步no reentrancy; callback must be synctracker.beginChange()拿到 Draft 代理get a Draft proxymutate(draft)像普通对象一样改mutate like a plain objectchange.prepare()算出 ops(失败则 abort)compute ops (abort on throw)tracker.adopt(prepared)换根指针:新不可变版本swap the root: new immutable revisionpublish(value, ops)sequence + 1;没有 op 就不发布sequence + 1; no ops, no publish内部精确订阅internal exact subscriptions(ops, sequence, ctx):同步,拿到每个版本(ops, sequence, ctx): synchronous, every revisionstate.subscribe(fn)(value, ctx, delivery):每个订阅串行(value, ctx, delivery): serialized per subscriptionhydrate(7, [["r", v]])基线baselineupdate(8, ops)applyupdate(9, ops)applyupdate(11, …)跳号!gap!回调抛错 → 原值和 sequence 都不变。Callback throws → value and sequence unchanged.replace(ctx, value) 走同一条路径(prepareReplace)。replace(ctx, value) takes the same path (prepareReplace).→ 远程编码器 / provider 订阅→ remote encoders / provider subscriptions每个 client × state 独立编码encoded separately per client × state先 hydrate(当前值),再 update;hydrate (current value) first, then updates;最多 100 个待处理:溢出时只留最新的,at most 100 pending: on overflow keep the newest,所以公开的 sequence 可能跳号。so public sequences may skip.异步回调里读捕获的 value,不要读 state.value。In async callbacks read the captured value, not state.value.update 要求 sequence == 上一个 + 1;否则清空副本并抛出update requires sequence == previous + 1; otherwise the replica clears and throws"Replicated state update sequence has a gap"provider 侧的 reset 是唯一合法的"重新起跑":它带着新的基线 sequence,A provider reset is the only legal restart: it carries new baseline sequences,并清空该订阅的路径字典。and clears that subscription's path dictionaries.值是不可变、结构共享的:没变的子树在版本之间共用。Values are immutable and structurally shared: unchanged subtrees are reused.123456顺序orderdelta tracker订阅subscriptions违约violation

要点Key points

  • 一次 change() 恰好发布一个原子版本;回调抛错时值和 sequence 都不变;没有产生 op 就不发布。A successful change() publishes exactly one atomic revision; if the callback throws, value and sequence stay unchanged; no ops, no publication.
  • 回调必须同步,也不能在回调里再次 change("cannot be changed reentrantly")。The callback must be synchronous and cannot change the state again from inside ("cannot be changed reentrantly").
  • 公开订阅最多积压 100 个值,溢出时只留最新的——所以公开的 sequence 可能跳号;异步回调里应读捕获的 value 而不是 state.value。Public subscriptions keep at most 100 pending values and keep only the newest on overflow — so public sequences may skip; async callbacks should read the captured value, not state.value.
  • 远端副本严格按序:update 要求 sequence 正好是上一个加一,否则清空并抛出 "update sequence has a gap"。The remote replica is strictly ordered: update requires exactly the previous sequence plus one, else it clears and throws "update sequence has a gap".

工作原理How it works

  1. status.change(ctx, d => { d.output += "done\n"; d.count += 1 })。status.change(ctx, d => { d.output += "done\n"; d.count += 1 }).
  2. 内部:tracker.beginChange() → 回调 → prepare() → adopt() → publisher.publish(value, ops, ctx)。Internally: tracker.beginChange() → callback → prepare() → adopt() → publisher.publish(value, ops, ctx).
  3. 远端:ReplicatedStateReplica.hydrate(seq, ops) 建立基线,之后每个 update(seq, ops) 用 applyImmutable 重放。Remote: ReplicatedStateReplica.hydrate(seq, ops) sets the baseline, then each update(seq, ops) replays with applyImmutable.

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/chord/src/services/state.tsL184MutableReplicatedStateImpl可变状态mutable state
packages/chord/src/services/state.tsL202change()一次变更one change
packages/chord/src/services/state.tsL344ReplicatedStateReplica远端副本the remote replica
packages/chord/src/services/state.tsL390update()按序检查order check
packages/chord/src/services/state-codec.tsL60createServiceStateEncoder()每个订阅的编码器per-subscription encoder
08Infra

JSON deltaJSON deltas#

一句话结论Takeaway./delta 定义 7 种 op:r 替换根、s 设置、d 删除、a 字符串追加、t 字符串去头、p 数组 splice、m 数组重排。追踪器为字符串挑最省的写法(追加;或去头加追加——适合滚动日志;否则整体设置),op 太多时折叠成一个 r。线上格式 WireOp 只多两种压缩:路径编号和省略路径。./delta defines 7 ops: r replace root, s set, d delete, a string append, t string front-truncate, p array splice, m array permute. The tracker picks the cheapest string form (append; or truncate plus append — good for rolling logs; else a set) and folds into one r when there are too many ops. The wire format WireOp adds only two compressions: path ids and omitted paths.

Delta 的 7 种 op 与线上压缩The seven delta ops and on-the-wire compression
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
Delta: 7 ops and on-the-wire compressionOp(delta/index.ts)Op (delta/index.ts)字符串怎样变成 opHow a string change becomes opsWireOp:只多两种压缩WireOp: just two compressionsrsdatpm["r", value]替换根replace the root["s", path, value]设置字段 / 下标set a field / index["d", path]删除字段delete a field["a", path, str]字符串末尾追加append to a string["t", path, n]字符串去掉前 n 个字符drop the first n chars of a string["p", path, start, del, items]数组 splicearray splice["m", path, perm]数组重排:new[i] = old[perm[i]]reorder: new[i] = old[perm[i]]新值以旧值开头new starts with old→ ["a", path, tail]旧值的后缀是新值的前缀old suffix = new prefix→ ["t", …] + ["a", …](重叠扫描最多 65,536 字符:滚动日志)(overlap scan up to 65,536 chars: rolling logs)其他情况anything else→ ["s", path, new]超过 4,096 个 op → 折叠成一个 ["r", 整个根]more than 4,096 ops → fold into one ["r", whole root]["#", id, path]:路径第二次出现时定义一个编号["#", id, path]: defines an id on a path's second use之后用数字代替该路径later ops reference it by number缩短的元组:同一批内复用上一个 op 的路径shortened tuples reuse the previous op's path (within a batch)编码器 / 解码器每个订阅一对,每个实例 × 成员一本字典;one encoder / decoder per subscription, one dictionary per替换、不可用、关闭、重新 hydrate 时清空。instance × member; cleared on replace, unavailable, close, hydrate.例:README 里的 { output, count }Example: the README's { output, count }draft.output += "done\n"; draft.count += 1; // 两次修改draft.output += "done\n"; draft.count += 1; // two edits→ ops [["a", ["output"], "done\n"], ["s", ["count"], 3]]→ wire [["a", ["output"], "done\n"], ["s", ["count"], 3]] // 路径各用一次,不定义编号→ wire [["a", ["output"], "done\n"], ["s", ["count"], 3]] // each path used once: no ids下一批再追加 output(第二次使用)→ ["#", 0, ["output"]], ["a", 0, "…"]next batch appends to output (second use) → ["#", 0, ["output"]], ["a", 0, "…"]

要点Key points

  • 超过 4,096 个 op 时折叠成 ["r", 整个根];去头 + 追加的重叠扫描最多 65,536 个字符。More than 4,096 ops fold into ["r", whole root]; the truncate-plus-append overlap scan covers up to 65,536 characters.
  • 路径在第二次使用时才定义编号(["#", id, path]),因为定义比路径本身还长,只用一次的路径内联更省。A path gets an id on its second use (["#", id, path]), because a definition costs more than the path; paths used once stay inline.
  • 省略路径只在同一批内有效;跨批的唯一状态是编号字典,遇到 r 就清空——每个 r 都是一个恢复点。Omitted paths only work within a batch; the only cross-batch state is the id dictionary, cleared at every r — each r is a recovery point.
  • 值是不可变、结构共享的:没变的子树在版本之间共用,不做防御性拷贝也不冻结。Values are immutable and structurally shared: unchanged subtrees are reused across revisions, with no defensive copies and no freezing.

工作原理How it works

  1. const tracker = track(initial);const change = tracker.beginChange();改 change.state。const tracker = track(initial); const change = tracker.beginChange(); mutate change.state.
  2. const prepared = change.prepare();tracker.adopt(prepared);副本用 applyImmutable(prepared.base, prepared.ops)。const prepared = change.prepare(); tracker.adopt(prepared); a replica uses applyImmutable(prepared.base, prepared.ops).
  3. 跨边界:encoder().encode(ops) → 发送 → decoder().decode(wire)。Across a boundary: encoder().encode(ops) → send → decoder().decode(wire).

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/chord/src/delta/index.tsL32Op7 种 opthe 7 ops
packages/chord/src/delta/index.tsL52WireOp线上格式the wire format
packages/chord/src/delta/index.tsL523encoder()第二次使用时编号ids on second use
packages/chord/src/delta/tracker.tsL112MAX_DELTA_OPERATIONS折叠上限fold limit
packages/chord/src/delta/index.tsL409applyImmutable()重放replay
packages/chord/src/delta/diff.tsL513diffRevisions()两个版本的差异diffing two revisions
09Infra

打包与加载插件Bundling and loading plugins#

一句话结论TakeawaybundleFacetPackage() 读插件包 package.json 里的 chord.facets,用 esbuild 打成 .cjs(peer 依赖和 chord 本身保持 external),写出 chord-facets.json 清单,每个入口带 SHA-256 完整性值。createFacetBundleLoader() 加载时先校验哈希,再用 node:vm 的 compileFunction 编译,只允许 require 清单里声明过的 external。bundleFacetPackage() reads chord.facets from a plugin's package.json, bundles it with esbuild into .cjs (peer dependencies and chord itself stay external) and writes a chord-facets.json manifest with a SHA-256 integrity value per entry. createFacetBundleLoader() verifies the hash at load time, compiles with node:vm's compileFunction, and lets the code require only externals the manifest declares.

esbuild 打包、清单与校验后在 node:vm 中加载Bundling with esbuild, the manifest, and verified loading in node:vm
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
Bundling and loading facets: esbuild → manifest → verified, then run in node:vm打包时At bundle timenode/bundle.ts清单 chord-facets.jsonManifest chord-facets.jsonnode/manifest.ts加载时:createFacetBundleLoader(...).load()At load time: createFacetBundleLoader(...).load()node/bundle-loader.ts插件包plugin packagepackage.json 的 "chord": { facets }package.json "chord": { facets }bundleFacetPackage()esbuild:cjs · bundle · 单文件esbuild: cjs · bundled entries输出目录output directoryfacet-<hash>.cjs + chord-facets.jsoncreateFacetBundleLoaderload() → LoadedFacets读清单read manifest格式 / 版本必须匹配format / version must match校验 SHA-256verify SHA-256与 integrity 不符就拒绝reject on integrity mismatchnode:vm 编译compile in node:vmcompileFunction(code, …)受限 requirerestricted require未声明的 external → 报错undeclared external → error校验 facetsvalidate facets然后交给 reload()then hand to reload()· 依赖包(peer)和 @earendil-works/chord 都标为 externalpeers and @earendil-works/chord stay external· 入口名 facet-<12 位短哈希>-[hash],扩展名 .cjsentry names facet-<12-char hash>-[hash], .cjs· target node22.19 或 es2022;不支持动态 import;"use strict" 横幅target node22.19 or es2022; no dynamic import; "use strict" banner· 先写到 .<out>.tmp-<uuid>,再改名替换write to .<out>.tmp-<uuid>, then rename into place· 旧目录先移到 .old-<uuid>;替换失败 → 移回old dir moved to .old-<uuid>; swap fails → moved back{ "format": "chord.facet-bundle", "version": 2,"plugin": { "id": …, "version": … },"entries": [{"file": "facet-….cjs","integrity": "sha256-…","externalImports": ["…"],"sourceMap": … }] }也可以把整个包读成一个 artifact(readFacetBundleArtifact,格式 chord.facet-bundle-artifact)再用 createFacetBundleArtifactLoader 加载。A bundle can also be read as one artifact (readFacetBundleArtifact, format chord.facet-bundle-artifact) and loaded with createFacetBundleArtifactLoader.12345打包bundling产物artifacts加载loading流程flow

要点Key points

  • 输出先写到临时目录,再整体改名替换;旧目录先挪到 .old-<uuid>,替换失败就挪回来——加载方永远看不到写了一半的包。Output goes to a temporary directory and is renamed into place; the old directory is moved to .old-<uuid> first and moved back if the swap fails — a loader never sees a half-written bundle.
  • 入口文件名带内容哈希(facet-<短哈希>-[hash].cjs),不同版本不会互相覆盖。Entry file names carry content hashes (facet-<short hash>-[hash].cjs), so versions never overwrite each other.
  • 也可以把整个包读成一个 artifact(readFacetBundleArtifact)再加载,适合把插件作为一个值传递。A bundle can also be read as one artifact (readFacetBundleArtifact) and loaded from that, handy for passing a plugin around as one value.

工作原理How it works

  1. 构建:bundleFacetPackage({ packagePath, outdir })。Build: bundleFacetPackage({ packagePath, outdir }).
  2. 加载:createFacetBundleLoader({ manifestPath, entry, resolveExternal? }) → load() 得到 LoadedFacets;完整性校验默认开启(verifyIntegrity)。Load: createFacetBundleLoader({ manifestPath, entry, resolveExternal? }) → load() gives LoadedFacets; the integrity check is on by default (verifyIntegrity).
  3. 交给 host.reload(loaded.facets) 热替换,或作为新一代的 facets。Hand them to host.reload(loaded.facets) for a hot swap, or use them for a new generation.

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/chord/src/node/package.tsL30bundleFacetPackage()打包插件包bundling a package
packages/chord/src/node/bundle.tsL39bundleFacets()esbuild 与原子替换esbuild and the atomic swap
packages/chord/src/node/manifest.tsL18FacetBundleManifest清单格式manifest format
packages/chord/src/node/bundle-loader.tsL134createFacetBundleLoader()校验并加载verify and load
packages/chord/src/node/bundle-loader.tsL56readFacetBundleArtifact()artifact 形式artifact form
10Infra

扩展点Extension points#

defineFacet({ id, setup })写一个功能单元:在 setup 里声明 use / provide。Write a unit of functionality: declare use / provide in setup.
defineService<T>(id, { local? })定义一个服务契约,远程或本地。Define a service contract, remote or local.
RemoteServiceTransport实现 invoke / subscribe,把服务接到任何传输上。Implement invoke / subscribe to carry services over any transport.
FacetLoaderload() 返回 LoadedFacets:静态、组合或从打包文件加载。load() returns LoadedFacets: static, combined or from bundles.
RemoteServiceSourcecatalogue() + open():把另一个进程的服务目录接进宿主。catalogue() + open(): plug another process's service catalogue into a host.
createContextKey<T>()定义调用范围的值,随 Context 传下去。Define call-scoped values carried by a Context.
track() · encoder() / decoder()单独使用 delta 追踪与线上编码。Use delta tracking and wire encoding on their own.
11Infra

测试Tests#

一句话结论Takeaway测试按模块分文件:facet 宿主与加载器、服务(含线格式与更新投递)、复制状态(值、差分、草稿、投递与模糊测试)、增量(应用、克隆、差分)、上下文与打包,另有增量性能基准。Tests are split by area: the facet host and loaders, services (including the wire format and update delivery), replicated state (values, diffs, drafts, delivery and a fuzz test), deltas (apply, clone, diff), context and bundling, plus delta benchmarks.

另有 1 个测试文件未列出(共 21 个,286 个用例)。1 more test files not listed (21 files, 286 cases in total).

用例数 = 源码中 it( / test( 的出现次数(静态统计,不运行测试)。Cases = occurrences of it( / test( in the source (static count; tests were not run).

12Infra

交叉链接Cross-links#