Merge pull request #6210 from deepseek-harness/chore/cordis-core-rc11

chore(cordis): upgrade Core to 4.0.0-rc.11
This commit is contained in:
Shigma
2026-10-28 21:34:50 +08:00
committed by GitHub
62 changed files with 860 additions and 523 deletions
+1 -1
View File
@@ -110,7 +110,7 @@ The root context of the application (every child context shares it). @experiment
```ts cordis-catalog
/** Base URL used to resolve relative plugin/module specifiers, if the runtime sets one. */
baseUrl?: string
baseUrl?: string | undefined
```
Base URL used to resolve relative plugin/module specifiers, if the runtime sets one.
+1 -1
View File
@@ -112,7 +112,7 @@ root: this
```ts cordis-catalog
/** Base URL used to resolve relative plugin/module specifiers, if the runtime sets one. */
baseUrl?: string
baseUrl?: string | undefined
```
用于解析相对插件/模块说明符的基础 URL,前提是运行时设置了该值。
+18 -18
View File
@@ -9,29 +9,29 @@
en: 9d809e3de33dd783
zh: b965aee4a65c6c1a
/events/ctx-parallel-name-args:
en: e583c71acb606915
zh: 0560679b20f8d7f0
en: 9c0a63f484e33428
zh: 09ee4c26931324af
/events/ctx-emit-name-args:
en: ff6f4023b66f8164
zh: af16d24d663a7eb7
en: b41bd01cf219cbd6
zh: 2e9660a05dc07cfc
/events/ctx-serial-name-args:
en: c431c0e3ac72e854
zh: c43b7d899ea8161c
en: 46708104f4d6a034
zh: f1b7fcb92a5b05c0
/events/ctx-bail-name-args:
en: 85b8b2178c3e3c33
zh: ad8af3e0743e231c
en: 6577e113d5b8ce40
zh: b925f8070cf387f7
/events/ctx-waterfall-name-args:
en: 3b32d0511b7da48b
zh: e0ac62e59d8e2152
en: 105fda32f60c7cea
zh: c67e6c6051c9a1af
/events/ctx-on-name-listener-options:
en: 74a1fe55f4a47ec8
zh: 87d37038af3bab0e
en: ec7c7e8a8b15d460
zh: 79eba67c31873b0b
/events/ctx-once-name-listener-options:
en: 64802f0580705984
zh: 3f6ce2d7ce1e760d
en: 27143e45f63622dd
zh: 095f804dfa47ad37
/events/eventoptions:
en: 937453f689836c80
zh: ebf0cc0f28127245
en: 14ed1031e4a7abee
zh: 2380e2f73f8931af
/events/dispatchmode:
en: 1c8548fb249ed794
zh: 86b92a28f39b2a12
en: 1081bbd7aa27baf8
zh: cc4434892b0bb93f
+9 -9
View File
@@ -26,7 +26,7 @@ Dispatch an event, running all listeners concurrently.
**Returns** a promise resolving once every listener has settled.
[Source](../../vendor/cordis/src/events.ts#L44)
[Source](../../vendor/cordis/src/events.ts#L43)
### ctx.emit(name, ...args)
@@ -46,7 +46,7 @@ Dispatch an event synchronously, ignoring listener return values.
- `name` — the event name.
- `args` — arguments passed to every listener.
[Source](../../vendor/cordis/src/events.ts#L53)
[Source](../../vendor/cordis/src/events.ts#L52)
### ctx.serial(name, ...args)
@@ -69,7 +69,7 @@ Dispatch an event, awaiting listeners in order until one bails.
**Returns** the first bail value (non-null, non-false, non-undefined), if any.
[Source](../../vendor/cordis/src/events.ts#L63)
[Source](../../vendor/cordis/src/events.ts#L62)
### ctx.bail(name, ...args)
@@ -92,7 +92,7 @@ Dispatch an event, calling listeners in order until one bails.
**Returns** the first bail value (non-null, non-false, non-undefined), if any.
[Source](../../vendor/cordis/src/events.ts#L73)
[Source](../../vendor/cordis/src/events.ts#L72)
### ctx.waterfall(name, ...args)
@@ -120,7 +120,7 @@ Each listener wraps the rest of the chain: calling `next()` invokes the next lis
**Returns** the outermost listener's return value.
[Source](../../vendor/cordis/src/events.ts#L86)
[Source](../../vendor/cordis/src/events.ts#L85)
### ctx.on(name, listener, options?)
@@ -144,7 +144,7 @@ Register an event listener owned by the current fiber.
**Returns** a disposer removing the listener; `true` if it was still registered.
[Source](../../vendor/cordis/src/events.ts#L97)
[Source](../../vendor/cordis/src/events.ts#L96)
### ctx.once(name, listener, options?)
@@ -168,7 +168,7 @@ Same as `on()`, but the listener disposes itself after its first call.
**Returns** a disposer removing the listener; `true` if it was still registered.
[Source](../../vendor/cordis/src/events.ts#L106)
[Source](../../vendor/cordis/src/events.ts#L105)
## EventOptions
@@ -184,7 +184,7 @@ interface EventOptions {
}
```
[Source](../../vendor/cordis/src/events.ts#L112)
[Source](../../vendor/cordis/src/events.ts#L111)
## DispatchMode
@@ -204,4 +204,4 @@ Event dispatch strategy used by the event service.
type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall'
```
[Source](../../vendor/cordis/src/events.ts#L32)
[Source](../../vendor/cordis/src/events.ts#L31)
+9 -9
View File
@@ -28,7 +28,7 @@ parallel<K extends keyof Events>(thisArg: NoInfer<ThisType<Events[K]>>, name: K,
**返回值**:一个 Promise,在所有监听器均已完成后兑现。
[源码](../../vendor/cordis/src/events.ts#L44)
[源码](../../vendor/cordis/src/events.ts#L43)
### ctx.emit(name, ...args)
@@ -48,7 +48,7 @@ emit<K extends keyof Events>(thisArg: NoInfer<ThisType<Events[K]>>, name: K, ...
- `name`:事件名称。
- `args`:传递给每个监听器的参数。
[源码](../../vendor/cordis/src/events.ts#L53)
[源码](../../vendor/cordis/src/events.ts#L52)
### ctx.serial(name, ...args)
@@ -71,7 +71,7 @@ serial<K extends keyof Events>(thisArg: NoInfer<ThisType<Events[K]>>, name: K, .
**返回值**:第一个提前终止值(非 null、非 false 且非 undefined);如果没有,则不返回此类值。
[源码](../../vendor/cordis/src/events.ts#L63)
[源码](../../vendor/cordis/src/events.ts#L62)
### ctx.bail(name, ...args)
@@ -94,7 +94,7 @@ bail<K extends keyof Events>(thisArg: NoInfer<ThisType<Events[K]>>, name: K, ...
**返回值**:第一个提前终止值(非 null、非 false 且非 undefined);如果没有,则不返回此类值。
[源码](../../vendor/cordis/src/events.ts#L73)
[源码](../../vendor/cordis/src/events.ts#L72)
### ctx.waterfall(name, ...args)
@@ -122,7 +122,7 @@ waterfall<K extends keyof Events>(thisArg: NoInfer<ThisType<Events[K]>>, name: K
**返回值**:最外层监听器的返回值。
[源码](../../vendor/cordis/src/events.ts#L86)
[源码](../../vendor/cordis/src/events.ts#L85)
### ctx.on(name, listener, options?)
@@ -146,7 +146,7 @@ on<K extends keyof Events>(name: K, listener: Events[K], options?: boolean | Eve
**返回值**:一个用于移除监听器的资源释放函数;如果调用该函数时监听器仍处于注册状态,则返回 `true`。
[源码](../../vendor/cordis/src/events.ts#L97)
[源码](../../vendor/cordis/src/events.ts#L96)
### ctx.once(name, listener, options?)
@@ -170,7 +170,7 @@ once<K extends keyof Events>(name: K, listener: Events[K], options?: boolean | E
**返回值**:一个用于移除监听器的资源释放函数;如果调用该函数时监听器仍处于注册状态,则返回 `true`。
[源码](../../vendor/cordis/src/events.ts#L106)
[源码](../../vendor/cordis/src/events.ts#L105)
## EventOptions
@@ -186,7 +186,7 @@ interface EventOptions {
}
```
[源码](../../vendor/cordis/src/events.ts#L112)
[源码](../../vendor/cordis/src/events.ts#L111)
## DispatchMode
@@ -206,4 +206,4 @@ interface EventOptions {
type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall'
```
[源码](../../vendor/cordis/src/events.ts#L32)
[源码](../../vendor/cordis/src/events.ts#L31)
+36 -33
View File
@@ -9,14 +9,14 @@
en: b5b3510f4b6dd13a
zh: a0c70471b6d779b5
/fiber/ctx-effect-execute-label:
en: ef3418fd5ffbe86d
zh: c98935435da8bc7e
en: 38bab93cf931eec1
zh: 50fed09622da4926
/fiber/ctx-fiber:
en: a10e38bf26b4ecfe
zh: 7c91817984c95c99
/fiber/the-fiber-class:
en: 0e0fcb134ed44957
zh: 326f4232aa0e65d1
en: 31ca1205552db9ef
zh: 5fa2e5b9a7396576
/fiber/the-fiber-class/fiber-uid:
en: 2f0eb3658bb8497e
zh: 65d3c17ca8bd4f95
@@ -24,50 +24,53 @@
en: 1b527d85759324f9
zh: 54aa40db2705b95c
/fiber/the-fiber-class/fiber-config:
en: 1efe137a7ef0e599
zh: bb4b9818fb48f4e9
en: 73b05dc38761b681
zh: d663668f3e8fd33f
/fiber/the-fiber-class/fiber-state:
en: 999ad29dfc29661b
zh: 20245d11c338e3f1
en: eda419fecddc4dc8
zh: 1a1b90f405cf3cbc
/fiber/the-fiber-class/fiber-dispose:
en: 29e304d183eb9104
zh: 9ac5023f518e06c4
/fiber/the-fiber-class/fiber-store:
en: c4185e5d051136a2
zh: 5714d9e4f5461ca3
en: 43f4ad79a728a492
zh: 71b216695d0afb22
/fiber/the-fiber-class/fiber-committed:
en: 0485c9d782bf90da
zh: 74304cd05e9c0796
/fiber/the-fiber-class/fiber-provisions:
en: 1cf8235d0ca54124
zh: 3d2e39064853165f
/fiber/the-fiber-class/fiber-inertia:
en: 2a3b1d3bc37bf986
zh: 83bd257ee685d38b
/fiber/the-fiber-class/fiber-name:
en: e5c2ecca13167a32
zh: 284a461f0f1cd35b
en: cdc0677ce1d83361
zh: a58305521a0626de
/fiber/the-fiber-class/fiber-assertactive:
en: cceb503899634d09
zh: a8381af1a23436f3
en: 0c3e8008ff2d69e2
zh: 7b454626d3e3a3b6
/fiber/the-fiber-class/fiber-effect-execute-label:
en: 55dcfacf295bbcf2
zh: 6e3ec27bb8da5a3d
en: 0e905aa798ce3830
zh: bbd0b0c537097d37
/fiber/the-fiber-class/fiber-geteffects:
en: 4d739a9c186fd9c2
zh: f80fd3b87e79b004
en: 729a1000bda8ee36
zh: 5bfc19df3addab80
/fiber/the-fiber-class/fiber-rawconfig:
en: 898aab51825bd8c3
zh: b9ab6339574002df
en: 80d4007e42bd8b47
zh: 14c11dc09c6402ce
/fiber/the-fiber-class/fiber-effectiveconfig:
en: 93ac680251e7023f
zh: 8ea7d7de5db39b8e
en: 8e9fcdcf458c8bde
zh: 36d21cafbf4f0198
/fiber/the-fiber-class/fiber-resolveconfig-config:
en: d7319852e2a1c3a9
zh: 3300fac049227006
en: f8a9ca27b288bd4c
zh: 03ce360b5200fbdb
/fiber/the-fiber-class/fiber-await:
en: c9a6544cacdbc28e
zh: 636bf93efad95bc9
en: 3f228c1861466386
zh: 43b3f3acfc3d314b
/fiber/the-fiber-class/fiber-restart:
en: 6ffc1baba98d689f
zh: 7e13bc92225c7e0a
en: 152116f669f52e95
zh: 11d3cd941ce32d88
/fiber/the-fiber-class/fiber-update-config-nosave:
en: 5c176a6e67073e92
zh: ee07edc8dbf7c1f3
en: a4f711258eb70e47
zh: fff4d880d820de10
/fiber/effect:
en: 007d5b214e8a8610
zh: d74c26fbb1b8aeb6
+45 -39
View File
@@ -34,7 +34,7 @@ Register a cleanup-aware effect on this fiber.
**Returns** a disposer that tears the effect down and settles once done.
[Source](../../vendor/cordis/src/fiber.ts#L415)
[Source](../../vendor/cordis/src/fiber.ts#L421)
### ctx.fiber
@@ -51,7 +51,7 @@ The fiber (plugin runtime instance) that owns this context.
Runtime instance of one plugin application.
A fiber tracks dependency state, validated config, lifecycle effects, and cleanup for the plugin context returned by `ctx.plugin()`.
A fiber tracks dependency state, raw and effective config, lifecycle effects, and cleanup for the plugin context returned by `ctx.plugin()`.
[Source](../../vendor/cordis/src/fiber.ts#L184)
@@ -80,11 +80,11 @@ The context this fiber's plugin runs in (extends the parent context).
### fiber.config
```ts cordis-catalog
/** The validated plugin config (updated by `update()`); read through {@link effectiveConfig}. */
/** Raw plugin input retained for the next activation. */
public config: any
```
The validated plugin config (updated by `update()`); read through effectiveConfig.
Raw plugin input retained for the next activation.
[Source](../../vendor/cordis/src/fiber.ts#L190)
@@ -97,7 +97,7 @@ public state
Current lifecycle state; transitions emit `internal/status`.
[Source](../../vendor/cordis/src/fiber.ts#L194)
[Source](../../vendor/cordis/src/fiber.ts#L192)
### fiber.dispose
@@ -108,16 +108,27 @@ public readonly dispose: () => Promise<void>
Dispose this fiber: unload the plugin, then settle once cleanup finished.
[Source](../../vendor/cordis/src/fiber.ts#L196)
[Source](../../vendor/cordis/src/fiber.ts#L194)
### fiber.store
### fiber.committed
```ts cordis-catalog
/** Snapshot of required service implementations while loaded; `undefined` otherwise. */
public store: Dict<Impl> | undefined
/** Required implementations retained through dependent teardown. */
public committed: Readonly<Dict<Impl>> | undefined
```
Snapshot of required service implementations while loaded; `undefined` otherwise.
Required implementations retained through dependent teardown.
[Source](../../vendor/cordis/src/fiber.ts#L196)
### fiber.provisions
```ts cordis-catalog
/** Service implementations owned by this fiber, including isolated provisions. */
public readonly provisions: Set<Impl>
```
Service implementations owned by this fiber, including isolated provisions.
[Source](../../vendor/cordis/src/fiber.ts#L198)
@@ -141,7 +152,7 @@ get name()
The plugin's display name, inherited from the nearest named ancestor, else `'root'`.
[Source](../../vendor/cordis/src/fiber.ts#L336)
[Source](../../vendor/cordis/src/fiber.ts#L342)
### fiber.assertActive()
@@ -159,7 +170,7 @@ Throw if the fiber has already been disposed.
**Returns** nothing when the fiber is still active.
[Source](../../vendor/cordis/src/fiber.ts#L351)
[Source](../../vendor/cordis/src/fiber.ts#L357)
### fiber.effect(execute, label?)
@@ -190,7 +201,7 @@ Register a cleanup-aware effect on this fiber.
**Returns** a disposer that tears the effect down and settles once done.
[Source](../../vendor/cordis/src/fiber.ts#L415)
[Source](../../vendor/cordis/src/fiber.ts#L421)
### fiber.getEffects()
@@ -207,7 +218,7 @@ Return metadata for currently registered effects.
**Returns** one `EffectMeta` tree per labeled live effect.
[Source](../../vendor/cordis/src/fiber.ts#L568)
[Source](../../vendor/cordis/src/fiber.ts#L574)
### fiber.rawConfig
@@ -218,21 +229,22 @@ get rawConfig(): unknown
Raw input retained for the next activation, without evaluating expressions or validating it.
[Source](../../vendor/cordis/src/fiber.ts#L642)
[Source](../../vendor/cordis/src/fiber.ts#L649)
### fiber.effectiveConfig
```ts cordis-catalog
/**
* The config object published by the existing activation/update lifecycle, without cloning or re-parsing.
* It is undefined before the first resolution and remains readable after unload; callers requiring a live instance check `state`.
* The object passed to plugin activation or the latest successful update that retained the instance.
* Candidate resolution does not publish it; volatile updates retain its references.
* Undefined before activation and readable after unload; callers requiring a live instance check `state`.
*/
get effectiveConfig(): unknown
```
The config object published by the existing activation/update lifecycle, without cloning or re-parsing. It is undefined before the first resolution and remains readable after unload; callers requiring a live instance check `state`.
The object passed to plugin activation or the latest successful update that retained the instance. Candidate resolution does not publish it; volatile updates retain its references. Undefined before activation and readable after unload; callers requiring a live instance check `state`.
[Source](../../vendor/cordis/src/fiber.ts#L655)
[Source](../../vendor/cordis/src/fiber.ts#L664)
### fiber.resolveConfig(config)
@@ -254,7 +266,7 @@ Evaluate and validate a candidate using this fiber's `internal/config` hooks and
**Returns** The candidate's resolved config, retaining the schema's result identity.
[Source](../../vendor/cordis/src/fiber.ts#L667)
[Source](../../vendor/cordis/src/fiber.ts#L676)
### fiber.await()
@@ -272,7 +284,7 @@ Wait for current lifecycle work and rethrow startup errors.
**Returns** this fiber, once it has settled into a stable state.
[Source](../../vendor/cordis/src/fiber.ts#L731)
[Source](../../vendor/cordis/src/fiber.ts#L754)
### fiber.restart()
@@ -290,35 +302,29 @@ Dispose and immediately reload this plugin with its current config.
**Returns** a promise resolving once the reload settled.
[Source](../../vendor/cordis/src/fiber.ts#L745)
[Source](../../vendor/cordis/src/fiber.ts#L768)
### fiber.update(config, noSave?)
```ts cordis-catalog
/**
* Validate and apply new config, then restart the plugin.
*
* Runs the `internal/update` waterfall first, so update hooks (and HMR)
* can veto or replace the restart.
*
* @param config — the new raw config; validated before anything restarts.
* @param noSave — hint for persistence hooks not to write the change back.
* @returns nothing; the restart runs behind the `internal/update` waterfall.
* @throws {ValidationError} when the new config fails validation.
* Resolve a candidate, retain its raw config and apply it through the update waterfall.
* @param config Raw config; pending fibers defer resolution until activation.
* @param noSave Suppress the persistence notification when true.
* @returns The awaited update/restart result, or undefined when a synchronous listener handles it.
* @throws {ValidationError} when an active fiber's candidate is invalid.
*/
update(config: any, noSave = false)
update(config: any, noSave = false): Awaitable<void>
```
Validate and apply new config, then restart the plugin.
Resolve a candidate, retain its raw config and apply it through the update waterfall.
Runs the `internal/update` waterfall first, so update hooks (and HMR) can veto or replace the restart.
- `config` — Raw config; pending fibers defer resolution until activation.
- `noSave` — Suppress the persistence notification when true.
- `config` — the new raw config; validated before anything restarts.
- `noSave` — hint for persistence hooks not to write the change back.
**Returns** The awaited update/restart result, or undefined when a synchronous listener handles it.
**Returns** nothing; the restart runs behind the `internal/update` waterfall.
[Source](../../vendor/cordis/src/fiber.ts#L764)
[Source](../../vendor/cordis/src/fiber.ts#L783)
## Effect
+47 -39
View File
@@ -36,7 +36,7 @@ effect(execute: () => Effect, label?: string): AsyncDisposable<Promise<void>>
**返回**一个用于撤销该作用的清理函数,并在清理完成后结算。
[源码](../../vendor/cordis/src/fiber.ts#L415)
[源码](../../vendor/cordis/src/fiber.ts#L421)
### ctx.fiber
@@ -53,7 +53,7 @@ fiber: Fiber
单次插件应用的运行时实例。
fiber 会跟踪 `ctx.plugin()` 返回的插件上下文所对应的依赖状态、经过校验的配置、生命周期作用和清理操作。
fiber 会跟踪 `ctx.plugin()` 返回的插件上下文所对应的依赖状态、原始配置与有效配置、生命周期作用和清理操作。
[源码](../../vendor/cordis/src/fiber.ts#L184)
@@ -82,11 +82,11 @@ public readonly ctx: Context
### fiber.config
```ts cordis-catalog
/** The validated plugin config (updated by `update()`); read through {@link effectiveConfig}. */
/** Raw plugin input retained for the next activation. */
public config: any
```
经过校验的插件配置(由 `update()` 更新);通过 `effectiveConfig` 读取。
为下次激活保留的原始插件输入。
[源码](../../vendor/cordis/src/fiber.ts#L190)
@@ -99,7 +99,7 @@ public state
当前生命周期状态;状态转换会发出 `internal/status`。
[源码](../../vendor/cordis/src/fiber.ts#L194)
[源码](../../vendor/cordis/src/fiber.ts#L192)
### fiber.dispose
@@ -110,16 +110,27 @@ public readonly dispose: () => Promise<void>
dispose 此 fiber:卸载插件,并在清理完成后结算。
[源码](../../vendor/cordis/src/fiber.ts#L196)
[源码](../../vendor/cordis/src/fiber.ts#L194)
### fiber.store
### fiber.committed
```ts cordis-catalog
/** Snapshot of required service implementations while loaded; `undefined` otherwise. */
public store: Dict<Impl> | undefined
/** Required implementations retained through dependent teardown. */
public committed: Readonly<Dict<Impl>> | undefined
```
加载期间所需服务实现的快照;其他情况下为 `undefined`。
在依赖方清理完成前保留的所需服务实现。
[源码](../../vendor/cordis/src/fiber.ts#L196)
### fiber.provisions
```ts cordis-catalog
/** Service implementations owned by this fiber, including isolated provisions. */
public readonly provisions: Set<Impl>
```
此 fiber 拥有的服务实现,包括隔离域中的服务。
[源码](../../vendor/cordis/src/fiber.ts#L198)
@@ -143,7 +154,7 @@ get name()
插件的显示名称,继承自最近的具名祖先;如果不存在,则为 `'root'`。
[源码](../../vendor/cordis/src/fiber.ts#L336)
[源码](../../vendor/cordis/src/fiber.ts#L342)
### fiber.assertActive()
@@ -161,7 +172,7 @@ assertActive()
**返回**:fiber 仍处于活动状态时不返回任何内容。
[源码](../../vendor/cordis/src/fiber.ts#L351)
[源码](../../vendor/cordis/src/fiber.ts#L357)
### fiber.effect(execute, label?)
@@ -192,7 +203,7 @@ effect(execute: () => Effect, label?: string): AsyncDisposable<Promise<void>>
**返回**一个用于撤销该作用的清理函数,并在清理完成后结算。
[源码](../../vendor/cordis/src/fiber.ts#L415)
[源码](../../vendor/cordis/src/fiber.ts#L421)
### fiber.getEffects()
@@ -209,7 +220,7 @@ getEffects()
**返回**:每个带标签的活动作用对应一棵 `EffectMeta` 树。
[源码](../../vendor/cordis/src/fiber.ts#L568)
[源码](../../vendor/cordis/src/fiber.ts#L574)
### fiber.rawConfig
@@ -218,23 +229,25 @@ getEffects()
get rawConfig(): unknown
```
保留供下次激活使用的原始输入,不执行表达式求值或验证。
[源码](../../vendor/cordis/src/fiber.ts#L642)
[源码](../../vendor/cordis/src/fiber.ts#L649)
### fiber.effectiveConfig
```ts cordis-catalog
/**
* The config object published by the existing activation/update lifecycle, without cloning or re-parsing.
* It is undefined before the first resolution and remains readable after unload; callers requiring a live instance check `state`.
* The object passed to plugin activation or the latest successful update that retained the instance.
* Candidate resolution does not publish it; volatile updates retain its references.
* Undefined before activation and readable after unload; callers requiring a live instance check `state`.
*/
get effectiveConfig(): unknown
```
既有激活/更新生命周期发布的配置对象,不克隆或重新解析。首次解析前为 undefined,卸载后仍可读取;需要运行实例的调用方应检查 `state`。
传给插件激活过程或最近一次成功保留实例的更新的对象。候选配置求值不发布结果;volatile 更新保留其中的引用。激活前为 undefined,卸载后仍可读取;需要运行中实例的调用方还应检查 `state`。
[源码](../../vendor/cordis/src/fiber.ts#L655)
[源码](../../vendor/cordis/src/fiber.ts#L664)
### fiber.resolveConfig(config)
@@ -250,13 +263,14 @@ get effectiveConfig(): unknown
resolveConfig(config: unknown): unknown
```
通过此 fiber 的 `internal/config` 钩子和运行时 schema 对候选值求值并验证。此操作不发布结果、不替换原始输入,也不提交运行中的 volatile 引用。钩子访问的注入服务必须已经可用。
- `config`:原始候选输入。
**返回**候选配置的解析结果,保留 schema 返回对象的身份。
[源码](../../vendor/cordis/src/fiber.ts#L667)
[源码](../../vendor/cordis/src/fiber.ts#L676)
### fiber.await()
@@ -274,7 +288,7 @@ async await()
**返回**:进入稳定状态后的此 fiber。
[源码](../../vendor/cordis/src/fiber.ts#L731)
[源码](../../vendor/cordis/src/fiber.ts#L754)
### fiber.restart()
@@ -292,35 +306,29 @@ dispose 此插件,并立即使用其当前配置重新加载。
**返回**一个在重新加载完成后兑现的 promise。
[源码](../../vendor/cordis/src/fiber.ts#L745)
[源码](../../vendor/cordis/src/fiber.ts#L768)
### fiber.update(config, noSave?)
```ts cordis-catalog
/**
* Validate and apply new config, then restart the plugin.
*
* Runs the `internal/update` waterfall first, so update hooks (and HMR)
* can veto or replace the restart.
*
* @param config — the new raw config; validated before anything restarts.
* @param noSave — hint for persistence hooks not to write the change back.
* @returns nothing; the restart runs behind the `internal/update` waterfall.
* @throws {ValidationError} when the new config fails validation.
* Resolve a candidate, retain its raw config and apply it through the update waterfall.
* @param config Raw config; pending fibers defer resolution until activation.
* @param noSave Suppress the persistence notification when true.
* @returns The awaited update/restart result, or undefined when a synchronous listener handles it.
* @throws {ValidationError} when an active fiber's candidate is invalid.
*/
update(config: any, noSave = false)
update(config: any, noSave = false): Awaitable<void>
```
校验并应用新配置,然后重新启动插件。
求值候选配置,保留原始输入,并通过更新 waterfall 应用结果。
首先运行 `internal/update` waterfall(瀑布式事件),因此更新钩子(以及 HMR(热模块替换))可以否决或取代重新启动操作。
- `config` — 原始配置;pending fiber 将求值推迟到激活时。
- `noSave` — 为 true 时不发出持久化通知。
- `config`:新的原始配置;在任何内容重新启动前进行校验。
- `noSave`:提示持久化钩子不要写回此变更。
**返回** 可等待的更新或重启结果;同步监听器处理更新时为 undefined。
**返回**无返回值;重启由 `internal/update` waterfall 执行。
[源码](../../vendor/cordis/src/fiber.ts#L764)
[源码](../../vendor/cordis/src/fiber.ts#L783)
## Effect
+10 -9
View File
@@ -21,15 +21,16 @@ This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verifie
## Inherited events (cordis core + loader/hmr/timer)
- `internal/plugin` — A plugin fiber was created. ([`vendor/cordis/src/events.ts:331`](../../vendor/cordis/src/events.ts))
- `internal/status` — A fiber changed lifecycle state. ([`vendor/cordis/src/events.ts:333`](../../vendor/cordis/src/events.ts))
- `internal/service` — Interception hook for a service binding (no core producer). ([`vendor/cordis/src/events.ts:341`](../../vendor/cordis/src/events.ts))
- `internal/update` — Waterfall: a fiber config update is being applied. ([`vendor/cordis/src/events.ts:343`](../../vendor/cordis/src/events.ts))
- `internal/config` — Waterfall: a fiber config is being resolved before validation. ([`vendor/cordis/src/events.ts:339`](../../vendor/cordis/src/events.ts))
- `internal/get` — Waterfall: a service is being read from the store. ([`vendor/cordis/src/events.ts:345`](../../vendor/cordis/src/events.ts))
- `internal/set` — Waterfall: a service is being written to the store. ([`vendor/cordis/src/events.ts:347`](../../vendor/cordis/src/events.ts))
- `internal/listener` — A listener was registered. ([`vendor/cordis/src/events.ts:349`](../../vendor/cordis/src/events.ts))
- `internal/dispatch` — An event is being dispatched to listeners. ([`vendor/cordis/src/events.ts:351`](../../vendor/cordis/src/events.ts))
- `internal/plugin` — A plugin fiber was created. ([`vendor/cordis/src/events.ts:337`](../../vendor/cordis/src/events.ts))
- `internal/status` — A fiber changed lifecycle state. ([`vendor/cordis/src/events.ts:339`](../../vendor/cordis/src/events.ts))
- `internal/service` — A service binding was refreshed in the matching isolation scope. ([`vendor/cordis/src/events.ts:341`](../../vendor/cordis/src/events.ts))
- `internal/update` — Waterfall: apply resolved config; omitting next() keeps the current instance. ([`vendor/cordis/src/events.ts:348`](../../vendor/cordis/src/events.ts))
- `internal/commit` — Persist raw config from a programmatic update unless noSave is set. ([`vendor/cordis/src/events.ts:355`](../../vendor/cordis/src/events.ts))
- `internal/config` — Waterfall: resolve a candidate after injections are active without publishing it. ([`vendor/cordis/src/events.ts:362`](../../vendor/cordis/src/events.ts))
- `internal/get` — Waterfall: a service is being read from the store. ([`vendor/cordis/src/events.ts:364`](../../vendor/cordis/src/events.ts))
- `internal/set` — Waterfall: a service is being written to the store. ([`vendor/cordis/src/events.ts:366`](../../vendor/cordis/src/events.ts))
- `internal/listener` — A listener was registered. ([`vendor/cordis/src/events.ts:368`](../../vendor/cordis/src/events.ts))
- `internal/dispatch` — An event is being dispatched to listeners. ([`vendor/cordis/src/events.ts:370`](../../vendor/cordis/src/events.ts))
- `exit` — The process is exiting on a signal. ([`vendor/loader/src/index.ts:25`](../../vendor/loader/src/index.ts))
- `loader/config-update` — The loader config tree changed. ([`vendor/loader/src/index.ts:26`](../../vendor/loader/src/index.ts))
- `loader/entry-init` — A config entry is being initialized. ([`vendor/loader/src/index.ts:27`](../../vendor/loader/src/index.ts))
+1 -2
View File
@@ -109,8 +109,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t
| `internal/dispatch` | - | [`terminal-bash`](../packages/terminal/terminal-bash) |
| `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` |
| `internal/service` | - | `client-ui-forge`, `gateway` |
| `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, `translator`, [`web`](../packages/web/web) |
| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) |
| `internal/status` | - | [`agent`](../packages/core/agent), `claude-code-mods`, `inspector`, `translator`, [`web`](../packages/web/web) |
| `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` |
| `locale/change` | `locale` (`emit`) | `locale` |
| `slots/changed` | `ui-renderer` (`emit`) | - |
+1 -2
View File
@@ -111,8 +111,7 @@
| `internal/dispatch` | - | [`terminal-bash`](../packages/terminal/terminal-bash) |
| `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` |
| `internal/service` | - | `client-ui-forge`, `gateway` |
| `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, `translator`, [`web`](../packages/web/web) |
| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) |
| `internal/status` | - | [`agent`](../packages/core/agent), `claude-code-mods`, `inspector`, `translator`, [`web`](../packages/web/web) |
| `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` |
| `locale/change` | `locale` (`emit`) | `locale` |
| `slots/changed` | `ui-renderer` (`emit`) | - |
+2 -2
View File
@@ -6,8 +6,8 @@
en: c8aa4d3384ea1306
zh: 5aa030587d5b634d
/vendored-package-rescope/name-mapping:
en: cc0f91dc4bb82605
zh: 81d5031752c6ea74
en: 239fa7993825ac62
zh: df35fa9f5ef42a05
/vendored-package-rescope/what-the-rename-does-not-touch:
en: 9bb7d09f8cccf695
zh: 2a8f3f60ae5116f3
+2 -2
View File
@@ -8,13 +8,13 @@ The Cordis framework and its foundation libraries are vendored under [`vendor/`]
| Directory | Upstream name | Published name | Upstream version | Role |
|---|---|---|---|---|
| `vendor/cordis/` | `cordis` | `@deepseek-ai/cordis` | 4.0.0-rc.7 | Framework core: `Context`, `Service`, `Fiber`, events |
| `vendor/cordis/` | `cordis` | `@deepseek-ai/cordis` | 4.0.0-rc.11 | Framework core: `Context`, `Service`, `Fiber`, events |
| `vendor/cosmokit/` | `cosmokit` | `@deepseek-ai/cosmokit` | 1.8.1 | Shared utilities the framework and Schemastery build on |
| `vendor/schemastery/` | `schemastery` | `@deepseek-ai/schemastery` | 3.18.0 | Config schemas (`Schema`) behind every plugin's `Config` |
| `vendor/loader/` | `@cordisjs/plugin-loader` | `@deepseek-ai/cordis-plugin-loader` | 1.0.0-rc.5 | `cordis.yml` loading, plugin resolution, repository cache |
| `vendor/include/` | `@cordisjs/plugin-include` | `@deepseek-ai/cordis-plugin-include` | 1.0.4 | Config includes and patch overlays |
| `vendor/group/` | `@cordisjs/plugin-group` | `@deepseek-ai/cordis-plugin-group` | 1.0.0 | Nested plugin groups |
| `vendor/timer/` | `@cordisjs/plugin-timer` | `@deepseek-ai/cordis-plugin-timer` | 1.1.2 | Disposal-aware timers on `ctx` |
| `vendor/timer/` | `@cordisjs/plugin-timer` | `@deepseek-ai/cordis-plugin-timer` | 1.1.4 | Disposal-aware timers on `ctx` |
| `vendor/hmr/` | `@cordisjs/plugin-hmr` | `@deepseek-ai/cordis-plugin-hmr` | 1.0.15 | Hot module replacement for plugins and config |
| `vendor/logger-console/` | `@cordisjs/plugin-logger-console` | `@deepseek-ai/cordis-plugin-logger-console` | 1.0.0 | Console logger exporter |
+2 -2
View File
@@ -8,13 +8,13 @@ Cordis 框架及其基础库以源码形式 vendored 在 [`vendor/`](../vendor/R
| 目录 | 上游名 | 发布名 | 上游版本 | 角色 |
|---|---|---|---|---|
| `vendor/cordis/` | `cordis` | `@deepseek-ai/cordis` | 4.0.0-rc.7 | 框架核心:`Context`、`Service`、`Fiber`、事件 |
| `vendor/cordis/` | `cordis` | `@deepseek-ai/cordis` | 4.0.0-rc.11 | 框架核心:`Context`、`Service`、`Fiber`、事件 |
| `vendor/cosmokit/` | `cosmokit` | `@deepseek-ai/cosmokit` | 1.8.1 | 框架与 Schemastery 共用的基础工具 |
| `vendor/schemastery/` | `schemastery` | `@deepseek-ai/schemastery` | 3.18.0 | 配置 schema(`Schema`),每个插件的 `Config` 都基于它 |
| `vendor/loader/` | `@cordisjs/plugin-loader` | `@deepseek-ai/cordis-plugin-loader` | 1.0.0-rc.5 | `cordis.yml` 装载、插件解析、repository 缓存 |
| `vendor/include/` | `@cordisjs/plugin-include` | `@deepseek-ai/cordis-plugin-include` | 1.0.4 | 配置包含与 patch 叠加 |
| `vendor/group/` | `@cordisjs/plugin-group` | `@deepseek-ai/cordis-plugin-group` | 1.0.0 | 嵌套插件分组 |
| `vendor/timer/` | `@cordisjs/plugin-timer` | `@deepseek-ai/cordis-plugin-timer` | 1.1.2 | `ctx` 上随 disposal 回收的定时器 |
| `vendor/timer/` | `@cordisjs/plugin-timer` | `@deepseek-ai/cordis-plugin-timer` | 1.1.4 | `ctx` 上随 disposal 回收的定时器 |
| `vendor/hmr/` | `@cordisjs/plugin-hmr` | `@deepseek-ai/cordis-plugin-hmr` | 1.0.15 | 插件与配置的热替换 |
| `vendor/logger-console/` | `@cordisjs/plugin-logger-console` | `@deepseek-ai/cordis-plugin-logger-console` | 1.0.0 | 控制台日志导出 |
@@ -333,7 +333,7 @@ function invalidRemoteEventFrame(): never {
}
function privateEvents(ctx: Context): PrivateEventContext {
return ctx
return ctx.events
}
function toError(reason: unknown, message: string): Error {
@@ -866,7 +866,7 @@ describe('Client Typert API', () => {
const ctx = await bench(call)
const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext
ctx.typert.contexts.registerClient('fixture', {
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
identity: candidate => 'fixtureId' in candidate ? (candidate as Context & { fixtureId?: string }).fixtureId : undefined,
resolve: id => id === 'agent-2' ? agentCtx : undefined,
})
const assembly = ctx.plugin(Object.assign(
@@ -897,7 +897,7 @@ describe('Client Typert API', () => {
const ctx = await bench(call)
const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext
ctx.typert.contexts.registerClient('fixture', {
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
identity: candidate => 'fixtureId' in candidate ? (candidate as Context & { fixtureId?: string }).fixtureId : undefined,
resolve: id => id === 'agent-2' ? agentCtx : undefined,
})
const assembly = ctx.plugin(Object.assign(
@@ -943,7 +943,7 @@ describe('Client Typert API', () => {
const ctx = await bench(call)
const agentCtx = ctx.extend({ fixtureId: 'agent-remounted' }) as FixtureContext
ctx.typert.contexts.registerClient('fixture', {
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
identity: candidate => 'fixtureId' in candidate ? (candidate as Context & { fixtureId?: string }).fixtureId : undefined,
resolve: id => id === 'agent-remounted' ? agentCtx : undefined,
})
const direct = directDescriptor()
@@ -75,14 +75,14 @@ class GoalService extends Service {
return {
agentId: agent.id,
title: request.title,
scope: (this.ctx as MarkedContext).fixtureScope ?? 'root',
scope: 'fixtureScope' in this.ctx ? (this.ctx as MarkedContext).fixtureScope : 'root',
}
}
@RemoteScope('gatewayFixture')
rename(request: { readonly title: string }): unknown {
this.calls.push('rename')
return { title: request.title, scope: (this.ctx as MarkedContext).fixtureScope ?? 'root' }
return { title: request.title, scope: 'fixtureScope' in this.ctx ? (this.ctx as MarkedContext).fixtureScope : 'root' }
}
@Remote
+1 -1
View File
@@ -184,7 +184,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => {
await client.plugin({ inject: plugin.inject, apply: plugin.apply })
}
client.typert.contexts.registerClient('agent', {
identity: candidate => candidate.builtAgentId,
identity: candidate => 'builtAgentId' in candidate ? candidate.builtAgentId : undefined,
})
const invalidResult = await client.remote.goals.create(rootAgent.id, { objective: 1 })
+6 -13
View File
@@ -340,9 +340,7 @@ async function recompose(
diagnostic: failure.entry.fiber?.uid ? reloadDiagnostic(failure) : undefined,
}))
// Removed entries leave the Loader store before their async disposers finish.
const previousFibers = [...ctx.loader.entries()].flatMap(row => row.fiber === undefined ? [] : [{
fiber: row.fiber, failed: row.fiber.state === FIBER_FAILED || row.fiber.state === FIBER_DISPOSED,
}])
const previousFibers = [...ctx.loader.entries()].flatMap(row => row.fiber === undefined ? [] : [row.fiber])
const { patches: previous = [], ...includeConfig } = entry.options.config as Include.Config
const current = withResolution ? ctx.get('pluginPackages')?.resolution : undefined
// A plain-data resolution has no inputs to recompute from.
@@ -386,12 +384,15 @@ async function recompose(
const prepared = prepareProfilePatches(ctx, patches, parentURL, binName)
applying = true
const retried = [...new Set([...running, ...withResolution ? [...ctx.loader.entries()].filter(importFailed) : []])]
// Group.update replaces its data array and attempts every row it still lists, including failed imports.
const priorData = new Map(retried.map(row => [row, row.parent.data]))
try {
await entry.update({ config: { ...includeConfig, patches: prepared } })
} finally {
await resume(ctx, retried)
// Only rows whose Group did not update still need an explicit retry after the awaited update.
await resume(ctx, retried.filter(row => row.parent.data === priorData.get(row)))
}
const results = await Promise.allSettled(previousFibers.map(({ fiber }) => fiber.await()))
await Promise.allSettled(previousFibers.map(fiber => fiber.await()))
await ctx.loader.await()
const failures = await inactiveEntries(ctx)
const introduced = failures.filter((failure) => {
@@ -403,9 +404,6 @@ async function recompose(
return !previousFailures.some(previous => sameFailure(previous, failure))
})
if (introduced.length > 0) throw new Error(activationDiagnostic(binName, introduced, false).trimEnd())
for (const [index, result] of results.entries()) {
if (result.status === 'rejected' && !previousFibers[index]?.failed) throw result.reason
}
ctx.emit('app-boot/config-reload')
return { warnings: failures.map(reloadDiagnostic) }
} finally {
@@ -1224,11 +1222,6 @@ export async function boot(
try {
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
ctx.provide('dshHomePath', dshHomePath)
// Fiber.update() discards the restart promise. Observe it before the
// waterfall returns; activation audits still report the failed fiber.
ctx.on('internal/update', (_config, _noSave, next: () => unknown) => {
void Promise.resolve(next()).catch((error: unknown) => { ctx.logger.error(error) })
}, { global: true, prepend: true })
await ctx.plugin(Loader)
await prepare?.(ctx)
stage = 'plugin tree failed to load'
@@ -90,7 +90,7 @@ export function captureStartupComposition(ctx: Context): StartupCompositionDiagn
const impl = store[key]
if (impl?.name !== service) continue
providers.push({
entry: impl.fiber.entry?.id ?? 'unknown', state: stateName(impl.fiber.state),
entry: impl.ctx.fiber.entry?.id ?? 'unknown', state: stateName(impl.ctx.fiber.state),
visibility: key === scope ? 'same-scope' : 'other-scope',
})
}
@@ -254,6 +254,11 @@ describe('Loader config interpolation', () => {
await provider?.update({ config: { value: 'recovered' } })
await provider?.update({ disabled: false })
await ctx.loader.await()
// rc.11 keeps a failed fiber failed after its dependency recovers; recovery requires an explicit update.
const failed = reader?.fiber
if (failed === undefined) throw new Error('reader entry has no fiber')
await expect(failed.await()).rejects.toThrow('rejected provider')
await failed.update(failed.rawConfig, true)
expect(ctx.get('readerResult')).toEqual({ value: 'recovered' })
} finally {
await ctx.fiber.dispose()
@@ -360,7 +365,7 @@ describe('profile reconciliation settlement', () => {
onTestFinished(() => ctx.fiber.dispose())
const entry = [...ctx.loader.entries()].find(row => row.options.id === 'candidate')
if (entry === undefined) throw new Error('candidate entry missing')
await entry.update({ config: { fail: true } })
await expect(entry.update({ config: { fail: true } })).rejects.toThrow('candidate activation failed')
await ctx.loader.await()
await reconcileProfilePatches(ctx, [], NAME)
expect([...ctx.loader.entries()].some(row => row.options.id === 'candidate')).toBe(false)
@@ -379,7 +384,7 @@ describe('profile reconciliation settlement', () => {
.rejects.toThrow('candidate activation failed')
})
it('reports an activation failure that settles while its entry is being removed', async () => {
it('logs a superseded activation failure without failing removal', async () => {
const dir = tmp()
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
@@ -390,11 +395,19 @@ describe('profile reconciliation settlement', () => {
})
onTestFinished(async () => { release.resolve(undefined); await ctx.fiber.dispose() })
const entry = [...ctx.loader.entries()].find(row => row.options.id === 'candidate')!
await entry.update({ config: { fail: true } })
const updating = entry.update({ config: { fail: true } })
await entered.promise
const result = expect(reconcileProfilePatches(ctx, [], NAME)).rejects.toThrow('in-flight failure')
const stopped = Promise.withResolvers<undefined>()
ctx.on('internal/plugin', (fiber) => {
if (fiber.uid === null && fiber.entry === entry) stopped.resolve(undefined)
})
const removing = reconcileProfilePatches(ctx, [], NAME)
await stopped.promise
release.resolve(undefined)
await result
await Promise.all([updating, removing])
expect([...ctx.loader.entries()].some(row => row.options.id === 'candidate')).toBe(false)
expect(ctx.logger.buffer.some(message => message.type === 'error'
&& message.args.some(value => String(value).includes('in-flight failure')))).toBe(true)
})
it('waits for a removed plugin to release its resources', async () => {
+5 -3
View File
@@ -678,10 +678,12 @@ class Hmr extends Service {
activated: Fiber[] = [],
) => {
for (const [previousFiber, implementation] of replacements.getActiveImplementations(this.ctx, plugin, fibers)) {
const fiber = previousFiber.fiber.parent.registry.plugin(implementation, previousFiber.config, this.getOuterStack).ctx.fiber
const wrapper = previousFiber.fiber.parent.registry.plugin(implementation, previousFiber.config, this.getOuterStack)
const fiber = wrapper.ctx.fiber
if (previousFiber.entry !== undefined) {
fiber.entry = previousFiber.entry
previousFiber.entry.fiber = fiber
// Like Loader activation, store the registry wrapper; identity checks compare `.ctx.fiber`.
previousFiber.entry.fiber = wrapper
}
activated.push(fiber)
}
@@ -831,7 +833,7 @@ class Hmr extends Service {
const owner = (fiber: Fiber): Entry | undefined => {
for (let current = fiber; current.uid !== 0; current = current.parent.fiber) {
const entry = current.entry
if (entry?.fiber === current && managed.has(entry) && affectedFibers.has(current)) return entry
if (entry?.fiber?.ctx.fiber === current && managed.has(entry) && affectedFibers.has(current)) return entry
}
return undefined
}
+1 -1
View File
@@ -218,7 +218,7 @@ it('rebinds configuration handlers when its provider is reconfigured within a re
})
await binding.await()
await hmr.runExclusive(async () => {
provider.update({ root: [], ignored: [], debounce: 1 })
await provider.update({ root: [], ignored: [], debounce: 1 })
await provider.await()
await binding.await()
})
@@ -236,7 +236,8 @@ export class ClientEntries {
} else if (entry.fiber === undefined) {
await this.replace(entry, row.id, row.rev, generation)
} else if (entry.fiber.state === FAILED) {
entry.fiber.update(entry.options.config)
// A retry with unchanged config is not a configuration change to persist.
await entry.fiber.update(entry.options.config, true)
}
} catch (error) {
failures.push({ id: row.id, message: String(error) })
@@ -57,15 +57,15 @@ describe('ui-chat Host settings', () => {
it('validates folded replies and restores the saved choice', async () => {
const ctx = new Context()
const configuration = await liveConfig(ctx, { Config, apply })
expect(plainConfig(configuration.fiber.config)).toMatchObject({ foldedReplies: 'all' })
expect(plainConfig(configuration.fiber.effectiveConfig)).toMatchObject({ foldedReplies: 'all' })
await configuration.update({ foldedReplies: 'final' })
const saved = plainConfig(configuration.fiber.config)
const saved = plainConfig(configuration.fiber.effectiveConfig)
if (typeof saved !== 'object' || saved === null) throw new Error('expected saved configuration object')
expect(saved).toMatchObject({ foldedReplies: 'final' })
await expect(configuration.update({ foldedReplies: 'none' })).rejects.toThrow()
await configuration.fiber.dispose()
const restored = await liveConfig(ctx, { Config, apply }, saved)
expect(plainConfig(restored.fiber.config)).toMatchObject({ foldedReplies: 'final' })
expect(plainConfig(restored.fiber.effectiveConfig)).toMatchObject({ foldedReplies: 'final' })
await restored.fiber.dispose()
})
@@ -615,12 +615,14 @@ describe('model-relative reference budgets', () => {
it('removes its pre-step listener when the resolver fiber is disposed', async () => {
const { ctx, agent, source, resolve, resolverFiber } = await setup()
const resolver = ctx.sessionReferenceResolver
resolve.mockClear()
await resolverFiber.dispose()
ctx.systemPrompt.variable('provider', () => 'disposed')
ctx.systemPrompt.variable('model', () => 'disposed')
await ctx.systemPrompt.assemble({ agent, scope: agent })
await resolver.prepare(agent, [], [{ sessionId: source.id }], { provider: 'seed', model: 'seed' })
expect(resolve).toHaveBeenLastCalledWith('seed', 'seed', undefined)
expect(resolve).not.toHaveBeenCalled()
await expect(resolver.prepare(agent, [], [{ sessionId: source.id }], { provider: 'seed', model: 'seed' }))
.rejects.toThrow('cannot get required service "sessionQuery" in inactive context')
const message = createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text: formatSessionReferenceMention({ sessionId: source.id }) }] })
const seed = { kind: 'enter' as const, messages: [message] }
await expect(agentEvents(ctx, agent).waterfall('agent/pre-step', { modelSelection: { provider: 'seed', model: 'seed' }, messages: [message], turn: 1, step: 1, signal: new AbortController().signal },
@@ -1497,17 +1497,23 @@ describe('cancellation and integration teardown', () => {
const probe = registerProbe(ctx)
const session = sessions.prepare(SessionId(`shutdown-${outcome}`), { meta: { cwd: '/workspace' } })
const events: SessionEvent[] = []
ctx.effect(function* () {
const detach = sessions.enter(session)
yield () => {
detach()
detached.resolve(undefined)
}
yield ctx.on('session/event', (subject, event) => {
if (subject === session) events.push(event)
})
sessions.announce(session)
yield () => releaseSession.promise
// A dependent of a root-provided service unloads with Auto, before the root's own effects.
await ctx.plugin({
inject: ['workingDirectory'],
apply: (scope: Context) => {
scope.effect(function* () {
const detach = sessions.enter(session)
yield () => {
detach()
detached.resolve(undefined)
}
yield scope.on('session/event', (subject, event) => {
if (subject === session) events.push(event)
})
sessions.announce(session)
yield () => releaseSession.promise
})
},
})
ctx.permissionPresets.set(session, AUTO_PRESET)
const agent = agentFor(session)
@@ -36,8 +36,8 @@
en: 7b48e62da7d73a85
zh: a7f8e46cecb726cf
/deepseek-ai-dsh-experimental-claude-code-mods/understand-the-implementation/the-chain:
en: 5330d8f5a6c9b992
zh: 8f6d167642a1c263
en: 655859f9ac0f0d25
zh: 19ed5070afcb5841
/deepseek-ai-dsh-experimental-claude-code-mods/understand-the-implementation/mapping-onto-the-harness:
en: 955ed36f58b0a5c4
zh: 65b2f0ddc1154be4
@@ -119,6 +119,8 @@ A call whose service is not composed rejects with the missing service's package
[`chain.ts`](src/chain.ts) runs one event through the selected hooks, outermost first, with the engine behavior at the bottom. Each hook's `next` delegates beneath once: a second call returns the first run, and a run a hook started is awaited before the hook's own answer settles. A hook that throws, times out, or settles without a result is skipped and reported once per failure kind, the result from beneath stands when it had already called `next`, and a `.catch` handler may answer in its place. The budget clock counts only the hook's own running time: it pauses inside `next` and inside every `$` call except `$.clock.sleep`. [`engine.ts`](src/engine.ts) owns the registry, per-session `$.state`, timers keyed by session and mod, and the two dispatch directions: an engine event reaches every selected hook, while a `$` call raised by one mod reaches only the mods loaded before it.
The bridge cancels detached calls when its fiber begins unloading, before dependent mod disposers wait for their timer callbacks. Provider cleanup then removes registrations and drains the remaining work.
### Mapping onto the harness
[`index.ts`](src/index.ts) registers the listeners. `tool.call` runs around `tools/execute`, so the harness permission decision precedes the chain; a `{ deny }` becomes an error result with the reason, a `{ result }` becomes a successful result when the tool is mod-registered or the value satisfies the tool's output schema, and an error-shaped result otherwise. A result a hook rewrote after `next` is installed as replacement content through `tools/post-execute`. A hook that passes rewritten arguments to `next` is skipped with a report, because the call's arguments are already logged. [`host-ops.ts`](src/host-ops.ts) holds the engine behavior for each `$` call over `ctx.get(...)` services; the directory owner is required and other services are read on demand. [`surfaces.ts`](src/surfaces.ts) keeps one band per session: it raises `ui.render`, validates and serializes the tree with [`elements.ts`](src/elements.ts), holds each `Button`'s `onPress` behind a per-drawing action id, subscribes the band to the `$.state` slots the drawing read, and streams generations to the Client through the `claudeCodeMods` Remote (`watchBand`, `pressBand`).
@@ -119,6 +119,8 @@ Blast Radius 的 DSH 包装使用当前运行的可执行文件提供 Node 计
[`chain.ts`](src/chain.ts) 让一个事件穿过选中的钩子,最外层在前,引擎行为在底部。每个钩子的 `next` 只向下委托一次:第二次调用返回第一次的运行结果,钩子已启动的下层运行会在其自身答案落定前被等待。抛出、超时或没有结果就结束的钩子会被跳过并按失败类型报告一次,若它已调用 `next` 则下层结果生效,`.catch` 处理器可代为作答。预算时钟只计钩子自身的运行时间:在 `next` 内与除 `$.clock.sleep` 外的每个 `$` 调用内暂停。[`engine.ts`](src/engine.ts) 拥有注册表、按会话的 `$.state`、按会话与模组键控的定时器,以及两个分发方向:引擎事件到达所有选中的钩子,而一个模组发起的 `$` 调用只到达在它之前加载的模组。
桥接服务在其 fiber 开始卸载时取消后台调用,随后依赖它的模组才等待各自的定时器回调结束。服务清理再移除注册并等待其余工作完成。
### 映射到 harness
[`index.ts`](src/index.ts) 注册监听器。`tool.call` 包裹 `tools/execute`,因此 harness 的权限决定先于链;`{ deny }` 成为带原因的错误结果,`{ result }` 在工具为模组注册或值满足工具输出 schema 时成为成功结果,否则为错误形态的结果。钩子在 `next` 之后改写的结果经 `tools/post-execute` 作为替换内容安装。向 `next` 传入改写参数的钩子会被跳过并报告,因为调用参数已经写入日志。[`host-ops.ts`](src/host-ops.ts) 持有每个 `$` 调用基于 `ctx.get(...)` 服务的引擎行为;目录所有者为必需服务,其他服务按需读取。[`surfaces.ts`](src/surfaces.ts) 为每个会话保留一条横幅:触发 `ui.render`,用 [`elements.ts`](src/elements.ts) 校验并序列化树,把每个 `Button` 的 `onPress` 保存在按绘制分配的动作 id 之后,让横幅订阅该次绘制读取的 `$.state` 槽位,并通过 `claudeCodeMods` Remote(`watchBand`、`pressBand`)把各代推流给 Client。
@@ -10,7 +10,7 @@
* @module @deepseek-ai/dsh-experimental-claude-code-mods
*/
import type { Context } from '@deepseek-ai/cordis'
import { FiberState, type Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-working-directory'
import z from '@deepseek-ai/schemastery'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
@@ -274,6 +274,10 @@ export class ClaudeCodeMods extends TypertRemoteService {
})
this.engine = engine
const registrations = this.registrations
// Dependent mod disposers join timer callbacks before this provider's cleanup runs.
ctx.on('internal/status', (fiber) => {
if (fiber === ctx.fiber && fiber.state === FiberState.UNLOADING) detached.cancel()
})
ctx.effect(() => async () => {
// Cancel first: a timer callback waiting inside a `$` call ends on this signal, and engine.dispose awaits it.
detached.cancel()
@@ -922,17 +922,19 @@ describe('loading diagnostics and configuration', () => {
const mod = writeMod('sleeper-mod', `
export function register(on) {
on('session.start', async ($, e, next) => {
$.clock.after(1, async () => { await $.clock.sleep(60_000) })
$.clock.after(1, async () => {
const sleeping = $.clock.sleep(60_000)
await $.ui.log('timer sleeping')
await sleeping
})
return next(e)
})
}
`)
const h = await harness([mod], new MockAdapter([]))
await h.agent()
await new Promise(resolve => setTimeout(resolve, 20))
const started = Date.now()
await vi.waitFor(() => { expect(h.info.mock.calls.flat().join(' ')).toContain('timer sleeping') })
await h.mods.dispose()
expect(Date.now() - started).toBeLessThan(5_000)
expect(h.warn).toHaveBeenCalledWith('claude-code-mods: sleeper-mod: timer callback failed: claude-code-mods disposed')
})
@@ -381,6 +381,28 @@ describe('forge watch', () => {
expect(vi.getTimerCount()).toBe(0)
})
// Cordis disposes a provider plugin that injects `forge` before the service,
// so only a registration owned outside the service's dependents can still
// be serving when the service itself stops.
it('discards a fetch that settles after the service is disposed', async () => {
const provider = new FakeProvider()
const release = held(provider, target => snapshot(target))
ctx = new Context()
const service = ctx.plugin(ForgeService, config)
await service
ctx.get('forge')!.register(provider)
const { updates } = collect(ctx)
await vi.advanceTimersByTimeAsync(0)
expect(provider.signals).toHaveLength(1)
const disposal = service.dispose()
release()
await disposal
expect(updates).toEqual([])
expect(vi.getTimerCount()).toBe(0)
})
it('releases a watch when the subscribing fiber is disposed', async () => {
const provider = new FakeProvider()
provider.results.set(key, snapshot(ref(1), { checksRollup: 'pending' }))
@@ -52,15 +52,15 @@ describe('Cordis collector live object identities', () => {
})
assert(spawned, 'Service consumer did not create a Fiber')
const fiber = await spawned.await()
const canonical: unknown = Object.getPrototypeOf(fiber.ctx)
const canonical: unknown = fiber.ctx
assert(Context.is(canonical))
expect(Object.hasOwn(fiber.ctx, SHADOW)).toBe(true)
expect(Object.hasOwn(fiber.ctx, 'fiber')).toBe(false)
expect(Object.hasOwn(fiber.ctx, SHADOW)).toBe(false)
expect(Object.hasOwn(fiber.ctx, 'fiber')).toBe(true)
expect(Object.hasOwn(canonical, 'fiber')).toBe(true)
expect(canonical.fiber).toBe(fiber)
expect(Object.getPrototypeOf(canonical)).toBe(consumer.ctx)
expect(Object.getPrototypeOf(fiber.parent)).toBe(consumer.ctx)
expect(Reflect.get(fiber.parent, SHADOW)).toBe(provider.ctx)
expect(fiber.parent).toBe(consumer.ctx)
expect(Reflect.get(fiber.parent, SHADOW)).toBeUndefined()
const snapshot = collector.snapshot()
expect(snapshot.truncated).toBe(false)
@@ -72,8 +72,8 @@ function nativeOwner(ctx: Context, provider: PaidTranslationProvider): NativeOwn
const entry = [...loader.entries()].find(row => row.options.id === directory.settingsNs && row.options.name === OWNER_PACKAGES[provider])
if (entry?.fiber?.state !== FiberState.ACTIVE) return undefined
return provider === 'deepseek-account'
? { llm, fiber: entry.fiber, provider, config: entry.fiber.effectiveConfig as AccountNativeConfig }
: { llm, fiber: entry.fiber, provider, config: entry.fiber.effectiveConfig as OfficialNativeConfig }
? { llm, fiber: entry.fiber.ctx.fiber, provider, config: entry.fiber.effectiveConfig as AccountNativeConfig }
: { llm, fiber: entry.fiber.ctx.fiber, provider, config: entry.fiber.effectiveConfig as OfficialNativeConfig }
}
function unchanged(ctx: Context, provider: PaidTranslationProvider, previous: EligibleOwner): NativeOwner | undefined {
@@ -261,6 +261,7 @@ describe('native Flash translation', () => {
it.each(['withdrawn', 'catalog', 'credential'])('rejects a native route %s during async preparation before audit or dispatch', async (change) => {
const b = await paidFixture(cleanups), entered = Promise.withResolvers<undefined>(), release = Promise.withResolvers<undefined>()
cleanups.push(async () => { release.resolve(undefined) })
const original = b.ctx.llm.prepareCall.bind(b.ctx.llm)
vi.spyOn(b.ctx.llm, 'prepareCall').mockImplementationOnce(async (config, signal) => {
const call = await original(config, signal)
@@ -270,8 +271,9 @@ describe('native Flash translation', () => {
const rejected = expect(pending).rejects.toMatchObject({ code: 'TRANSLATION_UNAVAILABLE' })
await entered.promise
const owner = [...b.ctx.loader.entries()].find(entry => entry.options.id === 'official-custom-id')!
let withdrawal: Promise<void> | undefined
if (change === 'withdrawn') {
await owner.fiber!.dispose()
withdrawal = owner.fiber!.dispose()
await rejected
} else if (change === 'catalog') {
const config = owner.fiber!.effectiveConfig as ReturnType<typeof OfficialConfig>
@@ -279,6 +281,7 @@ describe('native Flash translation', () => {
} else b.setConfigured(false)
release.resolve(undefined)
await rejected
await withdrawal
expect(b.requests).toEqual([])
expect(b.session?.snapshotEvents()).toEqual([])
})
+1 -1
View File
@@ -291,7 +291,7 @@ describe('GoalService creation and replay', () => {
await fiber.dispose()
expect(ctx.get('goals')).toBeUndefined()
expect(ctx.sessionProjections.checkpoint(stub.session).goal?.val).toBeUndefined()
expect(first.get(stub.agent)).toMatchObject({ id: goal.id })
expect(() => first.get(stub.agent)).toThrow('cannot get required service "agents" in inactive context')
await ctx.plugin(GoalService)
expect(ctx.goals).not.toBe(first)
+2 -2
View File
@@ -174,9 +174,9 @@ export function apply(ctx: Context, config: Config): void {
return next
}
profiles()
ctx.on('internal/config', function (this: import('@deepseek-ai/cordis').Fiber, _raw, next) {
ctx.on('internal/config', (fiber, next) => {
const raw: unknown = next()
if (this !== ctx.fiber) return raw
if (fiber !== ctx.fiber) return raw
const candidate = Config(raw as import('./config.ts').Options)
assertServiceable(
{ providers: structuredClone(candidate.providers.get()) } as import('./config.ts').Options,
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { chmod, mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { delimiter, join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import { Context, FiberState } from '@deepseek-ai/cordis'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp'
@@ -268,7 +268,7 @@ describe('lsp-stdio provider resolution', () => {
expect(signal.aborted).toBe(false)
const disposing = loading.dispose()
await expect(loading).rejects.toThrow('lsp-stdio setup disposed')
expect((await loading).state).toBe(FiberState.DISPOSED)
await expect(disposing).resolves.toBeUndefined()
expect(signal.aborted).toBe(true)
await ctx.fiber.dispose()
@@ -76,7 +76,7 @@ export function leakedServices(ctx: Context, mount: Fiber): string[] {
clearing it, so an own symbol always resolves; the guard exists only
because the store's index signature is optional. */
if (impl === undefined) continue
if (!withinFiber(impl.fiber, mount)) continue
if (!withinFiber(impl.ctx.fiber, mount)) continue
if (rootIsolate[impl.name] === key) leaked.push(impl.name)
}
return leaked.sort((left, right) => left.localeCompare(right))
@@ -106,7 +106,7 @@ export function serviceForMount<K extends string & keyof Context>(
/* v8 ignore next -- cordis deletes a store slot on disposal rather than clearing it */
if (impl === undefined) continue
if (impl.name !== name) continue
if (withinFiber(impl.fiber, mount.fiber)) return impl.value as Context[K]
if (withinFiber(impl.ctx.fiber, mount.fiber)) return impl.value as Context[K]
}
return undefined
}
@@ -267,7 +267,7 @@ it('does not start dispatch when storage opens after its owner begins unloading'
await settled
expect(ctx.get('storageDomain') === facility).toBe(true)
expect(launches()).toBe(0)
expect(startupFailure).toContain('cannot create effect on inactive context')
expect(startupFailure).toBe('')
expect(facility.get('schedule')).toBeUndefined()
expect(vi.getTimerCount()).toBe(0)
const reopened = await facility.open(scheduleDomain)
+2 -2
View File
@@ -264,7 +264,7 @@ export class SettingsForms extends Service {
* @throws If this instance already has a registered policy.
*/
configure(presentation: { auto?: boolean }, owner: Fiber = this.ctx.fiber): () => void {
const fiber = owner
const fiber = owner.ctx.fiber
if (this.presentations.has(fiber)) throw new Error('Settings presentation is already configured for this plugin instance')
const policy = { ...presentation }
this.presentations.set(fiber, policy)
@@ -309,7 +309,7 @@ export class SettingsForms extends Service {
if (form === undefined) return []
active.add(entry.id)
const raw = JSON.stringify([entry.fiber.uid, schema.toJSON(), entry.options.config ?? {}])
const autoGenerate = this.presentations.get(entry.fiber)?.auto ?? true
const autoGenerate = this.presentations.get(entry.fiber.ctx.fiber)?.auto ?? true
const previous = this.revisions.get(entry.id)
const revision = previous === undefined ? 0 : previous.revision + Number(previous.raw !== raw)
this.revisions.set(entry.id, { raw, revision, ns: entry.options.id as SettingsNamespace, autoGenerate })
+5 -6
View File
@@ -41,8 +41,8 @@ describe('Fiber configuration access', () => {
const wrapper = ctx.plugin(apply, initial)
await wrapper
const fiber = wrapper.ctx.fiber
const hook = vi.fn(function (this: Fiber, _config: unknown, next: () => unknown) {
expect(this).toBe(fiber)
const hook = vi.fn((target: Fiber, next: () => unknown) => {
expect(target).toBe(fiber)
return next()
})
fiber.ctx.on('internal/config', hook)
@@ -66,7 +66,7 @@ describe('Fiber configuration access', () => {
const wrapper = ctx.plugin(apply, initial)
await wrapper
const raw = { value: 2 }
wrapper.update(raw)
await wrapper.update(raw)
await wrapper.await()
expect(apply).toHaveBeenLastCalledWith(wrapper.ctx, raw)
expect(wrapper.rawConfig).toBe(raw)
@@ -78,14 +78,13 @@ describe('Fiber configuration access', () => {
it('retains a pending raw update until its injection is available', async () => {
const ctx = root()
ctx.provide('configAccessTest')
const apply = vi.fn()
const wrapper = ctx.plugin({ inject: ['configAccessTest'], apply }, { value: 1 })
const raw = { value: 2 }
wrapper.update(raw)
await wrapper.ctx.fiber.update(raw)
expect(wrapper.rawConfig).toBe(raw)
expect(wrapper.effectiveConfig).toBeUndefined()
ctx.set('configAccessTest', {})
ctx.provide('configAccessTest', {})
await wrapper
expect(wrapper.effectiveConfig).toBe(raw)
expect(apply).toHaveBeenCalledOnce()
+188
View File
@@ -0,0 +1,188 @@
/** Cordis rc.11 updates retain dependency ownership and publish only current configuration. */
import type { Awaitable } from '@deepseek-ai/cosmokit'
import { Context, FiberState } from '@deepseek-ai/cordis'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
declare module '@deepseek-ai/cordis' {
interface Context {
rc11Service: { alive: boolean }
rc11View: string
rc11Private: boolean
}
}
function root() {
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
return ctx
}
function barrier() {
const gate = Promise.withResolvers<undefined>()
onTestFinished(() => { gate.resolve(undefined) })
return gate
}
describe('Cordis rc.11 lifecycle', () => {
it.each([false, true])('awaits the latest update while loading (inject: %s)', async (inject) => {
const ctx = root()
ctx.provide('rc11Dependency', 1)
const started = barrier()
const release = barrier()
const applied: number[] = []
const disposed: number[] = []
const fiber = await ctx.plugin({ inject: inject ? ['rc11Dependency'] : [], async apply(owner: Context, config: { value: number }) {
applied.push(config.value)
if (config.value === 1) { started.resolve(undefined); await release.promise }
owner.effect(() => () => { disposed.push(config.value) })
} }, { value: 0 })
const first = fiber.update({ value: 1 })
await started.promise
const second = fiber.update({ value: 2 })
const third = fiber.update({ value: 3 })
release.resolve(undefined)
await Promise.all([first, second, third])
expect(applied).toEqual([0, 1, 3])
expect(disposed).toEqual([0, 1])
expect(fiber.state).toBe(FiberState.ACTIVE)
expect(fiber.effectiveConfig).toEqual({ value: 3 })
await fiber.dispose()
expect(disposed).toEqual([0, 1, 3])
})
it('cleans up a superseded activation failure and accepts the latest update', async () => {
const ctx = root()
const started = barrier()
const release = barrier()
const cleanup = vi.fn()
const fiber = ctx.plugin(async (owner, config: number) => {
owner.effect(() => cleanup)
if (config !== 1) return
started.resolve(undefined)
await release.promise
throw new Error('superseded activation')
}, 1)
await started.promise
const updating = fiber.update(2)
release.resolve(undefined)
await updating
expect(cleanup).toHaveBeenCalledOnce()
expect(fiber.state).toBe(FiberState.ACTIVE)
expect(fiber.effectiveConfig).toBe(2)
})
it.each(['dispose', 'restart'] as const)('retains isolated providers through dependent teardown on %s', async (action) => {
const ctx = root()
const realm = ctx.isolate('rc11Service')
const disposing = barrier()
const release = barrier()
const events: string[] = []
const provider = await ctx.plugin((owner) => {
const service = { alive: true }
owner.isolate('rc11Service', realm[Context.isolate].rc11Service).provide('rc11Service', service)
owner.effect(() => () => { events.push('provider'); service.alive = false })
})
const consumer = await realm.inject(['rc11Service'], (owner) => {
return async () => {
disposing.resolve(undefined)
await release.promise
expect(owner.rc11Service.alive).toBe(true)
events.push('consumer')
}
})
const task = provider[action]()
await disposing.promise
expect(events).toEqual([])
release.resolve(undefined)
await task
await consumer.await()
expect(events).toEqual(['consumer', 'provider'])
})
it('separates own isolated provisions from the committed injection', async () => {
const ctx = root()
ctx.provide('rc11View', 'outer')
await ctx.inject(['rc11View'], async (owner) => {
const isolated = owner.isolate('rc11View')
const withdraw = isolated.reflect.provide('rc11View', 'inner')
expect(owner.rc11View).toBe('outer')
expect(isolated.rc11View).toBe('inner')
await isolated.plugin((child) => { expect(child.rc11View).toBe('inner') })
await withdraw()
expect(owner.rc11View).toBe('outer')
expect(isolated.rc11View).toBe('outer')
})
await ctx.plugin((owner) => {
owner.isolate('rc11Private').provide('rc11Private', true)
expect(() => owner.rc11Private).toThrow('without inject')
})
})
it('publishes successful retained-instance updates and ignores stale or rejected completions', async () => {
const ctx = root()
const old = barrier()
const failed = Promise.withResolvers<undefined>()
const values: unknown[] = []
const fiber = await ctx.plugin((owner, config: number) => {
values.push(config)
owner.on('internal/update', (config: number) => {
if (config === 1) return old.promise
if (config === 3) return failed.promise
})
}, 0)
const first = fiber.update(1)
expect(fiber.effectiveConfig).toBe(0)
await fiber.update(2)
old.resolve(undefined)
await first
expect(fiber.effectiveConfig).toBe(2)
const third = fiber.update(3)
const rejected = expect(third).rejects.toThrow('update rejected')
failed.reject(new Error('update rejected'))
await rejected
expect(fiber.rawConfig).toBe(3)
expect(fiber.effectiveConfig).toBe(2)
expect(values).toEqual([0])
})
it.each(['raw retention', 'restart', 'dispose'] as const)('does not publish an old update after %s', async (action) => {
const ctx = root()
const release = barrier()
const configs: object[] = []
const fiber = await ctx.plugin((owner, config: object) => {
configs.push(config)
owner.on('internal/update', () => release.promise)
}, { value: 0 })
const old = fiber.update({ value: 1 })
if (action === 'raw retention') fiber.rawConfig = { value: 2 }
if (action === 'restart') await fiber.restart()
if (action === 'dispose') await fiber.dispose()
release.resolve(undefined)
await old
expect(fiber.effectiveConfig).toBe(configs.at(-1))
})
it('keeps the restarted config identity when a listener delegates', async () => {
const ctx = root()
const applied: unknown[] = []
const fiber = await ctx.plugin((owner, config) => {
applied.push(config)
owner.on('internal/update', (_config, next): Awaitable<void> => next())
}, {})
fiber.ctx.on('internal/config', (_fiber, next: () => object) => ({ ...next() }))
await fiber.update({ value: 1 })
expect(fiber.effectiveConfig).toBe(applied.at(-1))
})
it('supports symbol events and rejects repeated waterfall delegation', async () => {
const ctx = root()
const name = Symbol('rc11-event')
const callback = vi.fn()
ctx.on(name, callback)
ctx.emit(name, 1)
expect(callback).toHaveBeenCalledWith(1)
const waterfall = Symbol('rc11-waterfall')
ctx.on(waterfall, (next: () => number) => { next(); return next() })
expect(() => { ctx.waterfall(waterfall, () => 1) }).toThrow('next() called multiple times')
})
})
+10 -9
View File
@@ -1132,15 +1132,16 @@ export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = {
],
}],
inheritedEvents: [
{ name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:331' },
{ name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:333' },
{ name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:341' },
{ name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:343' },
{ name: 'internal/config', summary: 'Waterfall: a fiber config is being resolved before validation.', source: 'vendor/cordis/src/events.ts:339' },
{ name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:345' },
{ name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:347' },
{ name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:349' },
{ name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:351' },
{ name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:337' },
{ name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:339' },
{ name: 'internal/service', summary: 'A service binding was refreshed in the matching isolation scope.', source: 'vendor/cordis/src/events.ts:341' },
{ name: 'internal/update', summary: 'Waterfall: apply resolved config; omitting next() keeps the current instance.', source: 'vendor/cordis/src/events.ts:348' },
{ name: 'internal/commit', summary: 'Persist raw config from a programmatic update unless noSave is set.', source: 'vendor/cordis/src/events.ts:355' },
{ name: 'internal/config', summary: 'Waterfall: resolve a candidate after injections are active without publishing it.', source: 'vendor/cordis/src/events.ts:362' },
{ name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:364' },
{ name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:366' },
{ name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:368' },
{ name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:370' },
{ name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:25' },
{ name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:26' },
{ name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:27' },
+15 -14
View File
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import type { Awaitable } from '@deepseek-ai/cosmokit'
import { Context, type Fiber, type Plugin } from '@deepseek-ai/cordis'
import Loader, { Group } from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
@@ -110,7 +111,7 @@ describe('volatile Loader updates', () => {
it('bypasses custom update hooks for volatile changes and retains them for mixed changes', async () => {
const ctx = await runtimeContext()
const hooks: string[] = []
const globalHook = vi.fn((_config: unknown, _noSave: boolean, next: () => void) => { next() })
const globalHook = vi.fn((_config: unknown, next: () => Awaitable<void>) => next())
ctx.on('internal/update', globalHook, { global: true })
const fiber = await mountEntry(ctx, { Config, apply(owner: Context) {
owner.on('internal/update', (config: ReturnType<typeof Config>) => {
@@ -144,7 +145,7 @@ describe('volatile Loader updates', () => {
await fiber.await()
expect(apply).toHaveBeenCalledTimes(3)
const ordinary = await mountEntry(ctx, { Config: z.object({ value: z.string() }), apply() { apply() } }, { value: 'same' })
ordinary.update({ value: 'same' })
await ordinary.update({ value: 'same' })
await ordinary.await()
expect(apply).toHaveBeenCalledTimes(5)
})
@@ -243,9 +244,9 @@ it('reloads real YAML and persists plain values independently of custom update h
expect(include.store.group!.fiber).toBe(group)
expect(group.uid).toBe(groupUid)
expect(configs[0]!.title.get()).toBe('file-update')
entry.fiber!.update({ title: 'program-update', group: { list: ['item'] } })
await entry.fiber!.update({ title: 'program-update', group: { list: ['item'] } })
expect(entry.options.config).toEqual({ title: 'program-update', group: { list: ['item'] } })
entry.fiber!.update({ title: 'ephemeral' }, true)
await entry.fiber!.update({ title: 'ephemeral' }, true)
expect(entry.options.config).toEqual({ title: 'program-update', group: { list: ['item'] } })
await ctx.fiber.dispose()
const saved = await readFile(filename, 'utf8')
@@ -298,11 +299,11 @@ it('updates volatile children through Group and Include custom updates and saves
const originalConsumer = consumer.fiber
const initial = configs[0]!
group.fiber!.update([child('group-update')])
await group.fiber!.update([child('group-update')])
await ctx.loader.await()
expect(initial.title.get()).toBe('group-update')
expect(group.options.config).toEqual([child('group-update')])
includeEntry.fiber!.update({ path: includePath, patches: [{ id: 'consumer', config: { title: 'include-update' } }] })
await includeEntry.fiber!.update({ path: includePath, patches: [{ id: 'consumer', config: { title: 'include-update' } }] })
await ctx.loader.await()
expect(initial.title.get()).toBe('include-update')
expect(includeEntry.fiber).toBe(originalInclude)
@@ -315,14 +316,14 @@ it('updates volatile children through Group and Include custom updates and saves
const ordinary = await ctx.loader.create({ name: 'cordis:volatile-test', config: {} })
const entry = ctx.loader.resolve(ordinary)
entry.fiber!.update({ title: 'custom-update', fixed: 'ordinary' })
await entry.fiber!.update({ title: 'custom-update', fixed: 'ordinary' })
expect(childUpdates).toHaveBeenCalledOnce()
expect(entry.options.config).toEqual({ title: 'custom-update', fixed: 'ordinary' })
expect((entry.fiber!.effectiveConfig as ReturnType<typeof Config>).title.get()).toBe('hello')
entry.fiber!.update({ title: 'ephemeral', fixed: 'ordinary' }, true)
expect((entry.fiber!.effectiveConfig as ReturnType<typeof Config>).title.get()).toBe('custom-update')
await entry.fiber!.update({ title: 'ephemeral', fixed: 'ordinary' }, true)
expect(childUpdates).toHaveBeenCalledTimes(2)
expect(entry.options.config).toEqual({ title: 'custom-update', fixed: 'ordinary' })
expect(() => { entry.fiber!.update({ nested: { count: -1 } }) }).toThrow()
expect(() => { void entry.fiber!.update({ nested: { count: -1 } }) }).toThrow()
expect(childUpdates).toHaveBeenCalledTimes(2)
expect(entry.options.config).toEqual({ title: 'custom-update', fixed: 'ordinary' })
})
@@ -347,9 +348,9 @@ it('commits volatile edits in place and retains direct Fiber updates', async ()
const current = fiber.effectiveConfig as ReturnType<typeof Config>
expect(current.title.get()).toBe('mixed')
const hook = vi.fn((_config: unknown, _noSave: boolean, next: () => void) => { next() })
const hook = vi.fn((_config: unknown, next: () => Awaitable<void>) => next())
fiber.ctx.on('internal/update', hook)
fiber.update({ title: 'direct' })
await fiber.update({ title: 'direct' })
await fiber.await()
expect(hook).toHaveBeenCalledOnce()
expect(apply).toHaveBeenCalledTimes(3)
@@ -368,7 +369,7 @@ it('parses a volatile-only update through the config hook without patching the e
const current = fiber.effectiveConfig as ReturnType<typeof Config>
const resolveHook = vi.fn((_raw: unknown, next: () => unknown) => next())
fiber.ctx.on('internal/config', resolveHook)
const contextPatch = vi.fn((_entry: unknown, next: () => void) => { next() })
const contextPatch = vi.fn((_entry: unknown, next: () => Awaitable<void>) => next())
ctx.on('loader/patch-context', contextPatch)
trim.mockClear()
trace.length = 0
@@ -594,7 +595,7 @@ it('keeps an ordinary plugin mounted when an absent object becomes its explicit
it('keeps a noSave volatile value as the pending raw config across a no-op entry update', async () => {
const ctx = await runtimeContext()
const fiber = await mountEntry(ctx, { Config, apply() {} }, { title: 'saved' })
fiber.update({ title: 'ephemeral' }, true)
await fiber.update({ title: 'ephemeral' }, true)
await fiber.await()
expect((fiber.effectiveConfig as ReturnType<typeof Config>).title.get()).toBe('ephemeral')
expect(fiber.entry!.options.config).toEqual({ title: 'saved' })
@@ -12,12 +12,13 @@
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"session/title","data":{"title":"Use the bash tool to","messageSeqs":[8],"source":{"kind":"fallback"}}}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-startup-timeout","name":"bash","arguments":"{\"command\":\"sleep 30\",\"timeoutMs\":1,\"description\":\"Cancel a command during startup\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":1,"outputTokens":1},"stream":[{"type":"chunk","time":1791526663346,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791526663346,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-startup-timeout","name":"bash","arguments":"{\"command\":\"sleep 30\",\"timeoutMs\":1,\"description\":\"Cancel a command during startup\"}"}}},{"type":"chunk","time":1791526663346,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}},{"type":"chunk","time":1791526663346,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-startup-timeout","name":"bash","arguments":"{\"command\":\"sleep 30\",\"timeoutMs\":1,\"description\":\"Cancel a command during startup\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":1,"outputTokens":1},"stream":[{"type":"chunk","time":1791973104265,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791973104265,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-startup-timeout","name":"bash","arguments":"{\"command\":\"sleep 30\",\"timeoutMs\":1,\"description\":\"Cancel a command during startup\"}"}}},{"type":"chunk","time":1791973104266,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}},{"type":"chunk","time":1791973104266,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"bash-startup-timeout","name":"bash","arguments":"{\"command\":\"sleep 30\",\"timeoutMs\":1,\"description\":\"Cancel a command during startup\"}"}}
{"type":"job/registered","data":{"jobId":"{{id:1}}","owner":"{{session:1}}","kind":"bash","label":"sleep 30","startedAt":1791526663354,"toolCall":{"callId":"bash-startup-timeout","rootCallId":"bash-startup-timeout"}}}
{"type":"job/registered","data":{"jobId":"{{id:1}}","owner":"{{session:1}}","kind":"bash","label":"sleep 30","startedAt":1791973104275,"toolCall":{"callId":"bash-startup-timeout","rootCallId":"bash-startup-timeout"}}}
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"bash-startup-timeout"},"toolCallId":"bash-startup-timeout","content":[{"type":"text","text":"[still running after 1ms; moved to background job {{id:1}}]"}],"isError":false,"id":"{{message:5}}"},"meta":{"cwd":"{{cwd}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"step/start","data":{"turn":1,"step":2}}
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":1,"outputTokens":1},"stream":[{"type":"chunk","time":1791526663368,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1791526663368,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1791526663368,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}},{"type":"chunk","time":1791526663368,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":1,"outputTokens":1},"stream":[{"type":"chunk","time":1791973104295,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1791973104295,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1791973104295,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}},{"type":"chunk","time":1791973104295,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":2}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
{"type":"job/settled","data":{"jobId":"{{id:1}}","owner":"{{session:1}}","kind":"bash","label":"sleep 30","startedAt":1791973104275,"toolCall":{"callId":"bash-startup-timeout","rootCallId":"bash-startup-timeout"},"finishedAt":1791973104307,"status":"killed","cause":"teardown","producerReported":true,"detail":"signal: SIGTERM"}}
+24 -33
View File
@@ -16,7 +16,7 @@ Upstream workspace: `cordis-workspace` (local checkout: `~/repos/cordis-workspac
|---|---|---|---|---|---|
| `cosmokit/` | `@deepseek-ai/cosmokit` | `cosmokit` | 1.8.1 | https://github.com/deepseek-harness/cosmokit | `16f6fc058ade66e8ac5da0033d35a8d0f279f544` |
| `schemastery/` | `@deepseek-ai/schemastery` | `schemastery` | 3.18.0 | https://github.com/deepseek-harness/schemastery (`packages/core`) | `e67cee00ad725bd1534aee930a979ea3eec6f698` |
| `cordis/` | `@deepseek-ai/cordis` | `cordis` | 4.0.0-rc.7 | https://github.com/cordiverse/cordis (`packages/core`) | `56b3d4f725681cf4556c1a8695a709cc3b6eed74` |
| `cordis/` | `@deepseek-ai/cordis` | `cordis` | 4.0.0-rc.11 | https://github.com/cordiverse/cordis (`packages/core`) | `0b96d9fd517b998e44e2209560c9e5f0671ce933` |
| `loader/` | `@deepseek-ai/cordis-plugin-loader` | `@cordisjs/plugin-loader` | 1.0.0-rc.5 | https://github.com/cordiverse/cordis (`packages/loader`) | `56b3d4f725681cf4556c1a8695a709cc3b6eed74` |
| `include/` | `@deepseek-ai/cordis-plugin-include` | `@cordisjs/plugin-include` | 1.0.4 | https://github.com/deepseek-harness/cordis (`packages/include`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
| `group/` | `@deepseek-ai/cordis-plugin-group` | `@cordisjs/plugin-group` | 1.0.0 | https://github.com/deepseek-harness/cordis (`packages/group`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
@@ -24,7 +24,7 @@ Upstream workspace: `cordis-workspace` (local checkout: `~/repos/cordis-workspac
| `hmr/` | `@deepseek-ai/cordis-plugin-hmr` | `@cordisjs/plugin-hmr` | 1.0.15 | https://github.com/deepseek-harness/cordis (`packages/hmr`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
| `logger-console/` | `@deepseek-ai/cordis-plugin-logger-console` | `@cordisjs/plugin-logger-console` | 1.0.0 | https://github.com/deepseek-harness/cordis (`packages/logger-console`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
**Partial Loader sync:** the package baseline remains 1.0.0-rc.5. `loader/src/resolve.ts` and the bare-specifier fallback in `loader/src/config/tree.ts` use the resolver implementation from Cordis `v4.0.0-rc.11` (Loader 1.0.0-rc.8). The [resolver adaptations](#resolver-adaptations) below record the local differences from that newer source.
**Partial Loader sync:** the package baseline remains 1.0.0-rc.5. `loader/src/resolve.ts` and the bare-specifier fallback in `loader/src/config/tree.ts` use the resolver implementation from Cordis `v4.0.0-rc.11` (Loader 1.0.0-rc.8), and `loader/src/internal.ts` uses its runtime API detection, which classifies the internal module loader by its available module-job APIs instead of the Node major version; the Node compatibility matrix covers the detection. The [resolver adaptations](#resolver-adaptations) below record the local differences from that newer source.
Third-party dependencies of the vendored packages stay on npm: `@standard-schema/spec`, `js-yaml`, `chokidar`, `picomatch`, `@babel/code-frame`, `supports-color`, `node-addon-require-builtin`.
@@ -32,37 +32,28 @@ Intentionally **not** vendored (verified unused by this set): `reggol`, `@cordis
## Local modifications
Keep this log exhaustive — every divergence from upstream must be listed.
1. **`hmr/src/index.ts`**: removed the `./locales/en-US.yml` / `./locales/zh-CN.yml` imports, the `.i18n({...})` call on the `Config` schema, and the `src/locales/` directory. Rationale: those imports require a runtime YAML loader hook (`@cordisjs/unyaml`) that we do not vendor; the i18n texts only localize config descriptions.
2. **All `package.json` files**: regenerated for Harness releases with scoped names, release versions, publication metadata, precise bundled-runtime and `lib/types/**/*.d.ts` / `.d.ts.map` file entries, source exports where applicable, and declaration metadata pointing at `lib/types`. References within the vendored set use `workspace:~`. HMR declares `esbuild` as a direct dev dependency for its imported `BuildFailure` type, and Loader requires `node-addon-require-builtin@^0.1.4` to match published app runtimes.
3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references.
4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. Type-only dependencies use `import type` or inline `type` modifiers so ESM output does not retain erased interfaces.
5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface.
6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup.
7. **Cordis, Loader and Timer JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), module re-exports, shared utility helpers, `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Loader documentation covers `loader/src/{index,internal,config/entry,config/group,config/isolate,config/tree,config/utils}.ts`, including entry ownership, tree mutation methods, and the `!!js` discriminator. Timer retains its local class and helper summaries; its README is local documentation because the upstream package has none. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork.
8. **`include/src/index.ts` resilient file refresh and patch reapplication**: [#514](https://github.com/deepseek-harness/deepseek-harness/pull/514) validates a top-level entry array before caching parsed content, logs refresh failures, reapplies patches after file or Include-config edits, updates the Include config when it vetoes a restart, and uses `initial` only after `ENOENT`. These parse protections retain the running tree after an invalid file; plugin activation failures can leave a partially applied tree. Loader entry/group/tree mutations follow the pinned eager, non-transactional implementation and do not restore previous plugins or options. Covered by `packages/boot/app-boot/tests/{config-reload,user-patches}.spec.ts`.
9. **`hmr/src/index.ts` module-watcher readiness and native paths**: realpath the existing watch base, classify framework modules and attach listeners before reporting readiness, and compare config paths using both canonical and configured spellings. This preserves Node module-cache identity across Windows short-name paths and filesystem aliases. The main watcher uses `ignoreInitial: true` so startup reads do not trigger another refresh; exact profile patch watching is owned by `packages/boot/hmr/src/watch-config.ts`. Covered by `packages/boot/hmr/tests/watch-config.spec.ts`.
10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`. Conditional exports select `.mjs` for import and `.cjs` for require; without them, concurrent imports under module-hook hosts reach the CJS entry and can race its require of ESM Cosmokit (`ERR_REQUIRE_ESM_RACE_CONDITION`).
11. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. The entry list is deep-cloned when patches are present; without patches, the returned array retains the entry objects. Public patch and Include fields carry JSDoc for config tooling. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`.
12. **`loader/src/config/entry.ts` activation observer**: the detached `Entry.init()` completion observer handles both outcomes; the fiber retains its activation error for explicit consumer audits. Covered by `packages/boot/app-boot/tests/user-patches.spec.ts`.
13. **`include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change.
14. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained writes before and after child removal during Include teardown. The first drain preserves an existing terminal write failure even if child removal schedules a later write. Missing-file initialization awaits the write and forces a fresh parse before mounting the initial entries. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures.
15. **Lazy Loader config resolution across `cordis/src/{events,fiber}.ts`, `loader/src/{index,config/entry}.ts`, `include/src/index.ts`, and `hmr/src/index.ts`**: ports [cordiverse/cordis#41](https://github.com/cordiverse/cordis/pull/41), retaining raw fiber config and resolving it through `internal/config` only after declared injections are active. Provider replacement re-resolves the raw expression, pending updates retain it, and HMR transfers it. Resolution applies only to the entry root, so child plugins mounted by a row keep caller-owned config identity. Include declares the `EntryGroup.key` tree-carrier marker (as Group does): its config is entry and patch lists, so interpolation keeps it literal and a `!!js` expression inside a nested row's config resolves lazily in that row's own fiber (Include's own `path` therefore stays literal too). Deferred failures retain the owning row diagnostic, and tree teardown does not persist failure-driven self-disposal. `Fiber.rawConfig`, `Fiber.resolveConfig(candidate)` and `Fiber.effectiveConfig` centralize raw retention, candidate evaluation and reads of the lifecycle-published object. Accessors, updates and restarts use the canonical fiber, including calls through registry wrappers; candidate resolution never publishes or commits references. Loader volatile updates, config editing, settings, translator and both HMR providers use these accessors while the existing Core config fields and event protocol remain in place. Covered by `scripts/cordis-config-access.spec.ts`, `packages/boot/app-boot/tests/{app-boot,user-patches}.spec.ts`, `packages/boot/cmdline/tests/cmdline.spec.ts`, `apps/cli/tests/web-agent-presets.e2e.ts`, and the built custom-profile cases in `apps/cli/tests/built-bin.e2e.ts`.
16. **`cordis/package.json` publishes `src`**: added `src` to the `files` list, joining the other eight vendored packages. Cordis declares `"./src/*": "./src/*"` in its exports, so a tarball without `src` publishes an export map pointing at absent files; the release change judgement also reads `files` to decide whether a diff reaches the payload, and a package whose only published paths are build output has no tracked path to match.
17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).
18. **Entry `disabled` interpolation in `loader/src/config/entry.ts`**: a `disabled: !!js` expression evaluates against the loader context at every mount decision; the raw node stays in the options, so write-back keeps the `!!js` form. `disabled` is the only interpolated metadata field. Covered by `packages/boot/app-boot/tests/user-patches.spec.ts` and `apps/cli/tests/windows-shell.spec.ts`.
19. **`loader/src/internal.ts` runtime API detection (upstream in Cordis rc.11)**: classification by available module-job APIs already matches rc.11. It replaces the Node-major check in the pinned Loader rc.5 baseline; no additional detection patch is required when syncing to rc.11. Covered by the Node compatibility matrix.
20. **`loader/src/config/entry.ts` fiber identity**: stores the original fiber from the registry result’s context instead of its PromiseLike wrapper. Configuration updates and service notifications therefore mutate the same lifecycle state; updating a provider and consumer together cannot strand the consumer in `PENDING`. Covered by `packages/boot/hmr/tests/modules.spec.ts` and the built profile reload regression in `apps/cli/tests/built-bin.e2e.ts`.
21. **`cordis/src/logger.ts` exporter disposal**: each disposer retains its registration id, so removing an earlier exporter cannot delete a later console or telemetry exporter. Covered by startup collector cleanup in `packages/boot/app-boot/tests/app-boot.spec.ts` and disabled-feedback output in `packages/session/session-telemetry-otel/tests/loader-composition.e2e.ts`.
22. **Volatile config across `cosmokit`, `schemastery`, `cordis`, and `loader`**: Cosmokit supplies immutable references and framework-owned commits; `deepEqual` treats two references as equal, propagates strict comparison into arrays, compares every array slot with holes treated as undefined, handles cycles conservatively, compares URLs by normalized href, and compares other opaque objects by identity in strict mode. Schemastery adds `.volatile()`, optional/default inference, placement validation without eager lazy-schema expansion (a lazy inner is validated once when built), metadata-free serialized node compatibility, serialized metadata, reference-aware simplification, unsupported volatile values reported as `ValidationError` with their path, and `.default()` accepting the raw input type `Partial<S>` because a volatile field makes the output type carry references. Loader's save path calls `simplify` with its schema receiver (`unparse.call`), an independent fix for the unbound-`this` call. Cordis exports consumer types without changing Fiber lifecycle. Loader compares raw options strictly in `Entry.update`; for a config-only change on an active fiber it calls the pure `equalExceptVolatile` function in `config/diff.ts`, which narrows the Standard Schema to Schemastery's `Schema` type (a new type-only `@deepseek-ai/schemastery` dependency of `loader`) and recursively skips fixed volatile paths using schema metadata without executing config hooks or validation. Missing or null object nodes compare using their declared defaults, including nodes without volatile descendants; schema backedges retain raw comparison. Entry passes its actual schema and preserves ordinary context and custom-update lifecycles. For a volatile-only change, `Entry._commitVolatile` re-parses the raw config through `Fiber.resolveConfig()`, compares ordinary effective values, commits references and emits the instance-local `loader/volatile-update` declared in `loader/src/index.ts`; a changed ordinary effective value falls back to the ordinary remount, and an invalid candidate is logged while the raw config stays retained for the next activation. Cosmokit provides reference enumeration and commits, and `simplify` is called with its schema receiver. The package READMEs document these additions; schema tests live in `scripts/volatile-config.spec.ts`, Loader runtime and real-file tests in `scripts/loader-volatile-update.spec.ts`, and code replacement tests in the HMR module suite.
23. **`loader/src/config/entry.ts` module identity**: retains each entry's raw import result as `moduleNamespace`, before plugin export normalization. HMR matches that object to Node's cached module namespace and updates it after a successful reload. `EntryTree.import()` keeps its existing arguments and return value; `loader/README.md` documents the additive field.
24. **`timer/locale/{en,zh}.json` display metadata**: local English and Chinese titles and descriptions for the Settings plugin inventory. `timer/package.json` exports and publishes these locale resources.
Keep this log exhaustive — every divergence from upstream must be listed. Each entry has a stable ID: remove an entry when its divergence no longer exists, and never renumber or reuse IDs. The next new entry is V26.
- **V1 · `hmr/src/index.ts`**: removed the `./locales/en-US.yml` / `./locales/zh-CN.yml` imports, the `.i18n({...})` call on the `Config` schema, and the `src/locales/` directory. Rationale: those imports require a runtime YAML loader hook (`@cordisjs/unyaml`) that we do not vendor; the i18n texts only localize config descriptions.
- **V2 · All `package.json` files**: regenerated for Harness releases with scoped names, release versions, publication metadata, precise bundled-runtime and `lib/types/**/*.d.ts` / `.d.ts.map` file entries, source exports where applicable, and declaration metadata pointing at `lib/types`. References within the vendored set use `workspace:~`. HMR declares `esbuild` as a direct dev dependency for its imported `BuildFailure` type, and Loader requires `node-addon-require-builtin@^0.1.4` to match published app runtimes.
- **V3 · All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references.
- **V4 · Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. Type-only dependencies use `import type` or inline `type` modifiers so ESM output does not retain erased interfaces.
- **V5 · `schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface.
- **V6 · `cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup.
- **V7 · Cordis, Loader and Timer JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), module re-exports, shared utility helpers, `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Loader documentation covers `loader/src/{index,internal,config/entry,config/group,config/isolate,config/tree,config/utils}.ts`, including entry ownership, tree mutation methods, and the `!!js` discriminator. Timer retains its local class and helper summaries; its README is local documentation because the upstream package has none. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork.
- **V8 · `include/src/index.ts` resilient file refresh and patch reapplication**: [#514](https://github.com/deepseek-harness/deepseek-harness/pull/514) validates a top-level entry array before caching parsed content, logs refresh failures, reapplies patches after file or Include-config edits, updates the Include config when it vetoes a restart, and uses `initial` only after `ENOENT`. These parse protections retain the running tree after an invalid file; plugin activation failures can leave a partially applied tree. Loader entry/group/tree mutations follow the pinned eager, non-transactional implementation and do not restore previous plugins or options. Covered by `packages/boot/app-boot/tests/{config-reload,user-patches}.spec.ts`.
- **V9 · `hmr/src/index.ts` module-watcher readiness and native paths**: realpath the existing watch base, classify framework modules and attach listeners before reporting readiness, and compare config paths using both canonical and configured spellings. This preserves Node module-cache identity across Windows short-name paths and filesystem aliases. The main watcher uses `ignoreInitial: true` so startup reads do not trigger another refresh; exact profile patch watching is owned by `packages/boot/hmr/src/watch-config.ts`. Covered by `packages/boot/hmr/tests/watch-config.spec.ts`.
- **V10 · Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`. Conditional exports select `.mjs` for import and `.cjs` for require; without them, concurrent imports under module-hook hosts reach the CJS entry and can race its require of ESM Cosmokit (`ERR_REQUIRE_ESM_RACE_CONDITION`).
- **V11 · `include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. The entry list is deep-cloned when patches are present; without patches, the returned array retains the entry objects. Public patch and Include fields carry JSDoc for config tooling. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`.
- **V13 · `include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change.
- **V14 · `include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained writes before and after child removal during Include teardown. The first drain preserves an existing terminal write failure even if child removal schedules a later write. Missing-file initialization awaits the write and forces a fresh parse before mounting the initial entries. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures.
- **V15 · Configuration access across Core, Loader, Include and HMR**: rc.11 Core owns raw `Fiber.config`, `internal/config(fiber, next)`, `internal/commit` and awaited `internal/update(config, next)`. Root-fiber candidate resolution remains available without a plugin schema. Local `rawConfig`, `resolveConfig(candidate)` and `effectiveConfig` accessors retain the DSH consumer API. Fiber caches the object passed to activation or a successful update that keeps the instance; candidate resolution is pure, failed or superseded updates cannot replace the cache, and volatile updates retain references. Loader resolves only entry roots and excludes Group/Include tree carriers, so nested `!!js` expressions wait for their own injections. Include retains its tree-carrier marker, writer and patches, and HMR transfers raw config through the accessors. Covered by `scripts/cordis-config-access.spec.ts`, `scripts/cordis-core-lifecycle.spec.ts` and the boot/HMR suites.
- **V16 · `cordis/package.json` publishes `src`**: added `src` to the `files` list, joining the other eight vendored packages. Cordis declares `"./src/*": "./src/*"` in its exports, so a tarball without `src` publishes an export map pointing at absent files; the release change judgement also reads `files` to decide whether a diff reaches the payload, and a package whose only published paths are build output has no tracked path to match.
- **V17 · `@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).
- **V18 · Entry `disabled` interpolation in `loader/src/config/entry.ts`**: a `disabled: !!js` expression evaluates against the loader context at every mount decision; the raw node stays in the options, so write-back keeps the `!!js` form. `disabled` is the only interpolated metadata field. Covered by `packages/boot/app-boot/tests/user-patches.spec.ts` and `apps/cli/tests/windows-shell.spec.ts`.
- **V22 · Volatile config across `cosmokit`, `schemastery`, `cordis`, and `loader`**: Cosmokit supplies immutable references and framework-owned commits; `deepEqual` treats two references as equal, propagates strict comparison into arrays, compares every array slot with holes treated as undefined, handles cycles conservatively, compares URLs by normalized href, and compares other opaque objects by identity in strict mode. Schemastery adds `.volatile()`, optional/default inference, placement validation without eager lazy-schema expansion (a lazy inner is validated once when built), metadata-free serialized node compatibility, serialized metadata, reference-aware simplification, unsupported volatile values reported as `ValidationError` with their path, and `.default()` accepting the raw input type `Partial<S>` because a volatile field makes the output type carry references. Loader's save path calls `simplify` with its schema receiver (`unparse.call`), an independent fix for the unbound-`this` call. Cordis exports consumer types without changing Fiber lifecycle. Loader compares raw options strictly in `Entry.update`; for a config-only change on an active fiber it calls the pure `equalExceptVolatile` function in `config/diff.ts`, which narrows the Standard Schema to Schemastery's `Schema` type (a new type-only `@deepseek-ai/schemastery` dependency of `loader`) and recursively skips fixed volatile paths using schema metadata without executing config hooks or validation. Missing or null object nodes compare using their declared defaults, including nodes without volatile descendants; schema backedges retain raw comparison. Entry passes its actual schema and preserves ordinary context and custom-update lifecycles. For a volatile-only change, `Entry._commitVolatile` takes the canonical fiber `entry.fiber.ctx.fiber` (`Entry.fiber` holds the registry wrapper), re-parses the raw config through `Fiber.resolveConfig()`, compares ordinary effective values, commits references and emits the instance-local `loader/volatile-update` declared in `loader/src/index.ts`; a changed ordinary effective value falls back to the ordinary remount, and an invalid candidate is logged while the raw config stays retained for the next activation. Cosmokit provides reference enumeration and commits, and `simplify` is called with its schema receiver. The package READMEs document these additions; schema tests live in `scripts/volatile-config.spec.ts`, Loader runtime and real-file tests in `scripts/loader-volatile-update.spec.ts`, and code replacement tests in the HMR module suite.
- **V23 · `loader/src/config/entry.ts` module identity**: retains each entry's raw import result as `moduleNamespace`, before plugin export normalization. HMR matches that object to Node's cached module namespace and updates it after a successful reload. `EntryTree.import()` keeps its existing arguments and return value; `loader/README.md` documents the additive field.
- **V24 · `timer/locale/{en,zh}.json` display metadata**: local English and Chinese titles and descriptions for the Settings plugin inventory. `timer/package.json` exports and publishes these locale resources.
- **V25 · Loader lifecycle slice from Cordis v4.0.0-rc.11**: entry updates await context patches, Fiber updates and disabled-entry disposal; isolation uses `Impl.ctx` and maintains `Impl.isolate` during transfers; the `Entry.init()` observer handles both activation outcomes. Group and Include return their child-update work. The existing writer receives `internal/commit` with schema-bound simplification. A local dependency refresh before a combined context/config update prevents activation against stale isolation bindings. Loader, Include and HMR keep their manifest baselines apart from the slices recorded in this log. Failed fibers require an explicit update after correcting their input or dependencies. Core retains the effect hardening in V6; rc.11 owns dependency teardown ordering and superseded-activation handling.
### Resolver adaptations
+1 -1
View File
@@ -31,7 +31,7 @@ The comparison above checks the current vendored source against the release tag;
The identifiers below are plan labels, not GitHub issue numbers. Preconditions are merge requirements. Independent PRs can be developed and reviewed concurrently; a dependent PR can start against the proposed changes before its predecessors merge, but must validate the actual combined result before merging.
`Vn` below identifies item `n` in the [current local-modification log](README.md#local-modifications), not a PR. **Retire** means the upstream implementation replaces the local difference; **partial** means some behavior still needs a local adaptation; **retain** means the requirement remains; **prepare** does not retire a patch. These are proposed outcomes, confirmed only by the owning PR's tests. Track each item by its subject if later manifest edits renumber the log.
`Vn` below is the stable ID of an entry in the [local-modification log](README.md#local-modifications), not a PR; a retired ID no longer appears in the log. **Retire** means the upstream implementation replaces the local difference; **partial** means some behavior still needs a local adaptation; **retain** means the requirement remains; **prepare** does not retire a patch. These are proposed outcomes, confirmed only by the owning PR's tests.
| PR | Scope | Prerequisite PRs | Existing vendor modifications | Required evidence |
|---|---|---|---|---|
+6 -2
View File
@@ -106,6 +106,10 @@ Schemas may return `Volatile<T>` references, whose values are read through `.get
## Configuration access
`fiber.rawConfig` reads or retains the raw input for a later activation. Assigning it does not validate, update the running instance or trigger a restart. Registry wrappers route raw writes, updates and restarts through the same canonical fiber. `fiber.resolveConfig(candidate)` evaluates the fiber's config hooks and schema without publishing the result; any services used by those hooks must already be available. `fiber.effectiveConfig` returns the lifecycle-published object without re-parsing, preserving the identity of volatile references. It is undefined before the first resolution and stays readable after unload, so consumers that require a running instance also check the fiber state.
`fiber.rawConfig` reads or retains the raw input for a later activation. Assigning it does not validate, update the running instance or trigger a restart. Registry wrappers route raw writes, updates and restarts through the same canonical fiber. `fiber.resolveConfig(candidate)` evaluates the fiber's config hooks and schema without publishing the result; any services used by those hooks must already be available. `fiber.effectiveConfig` returns the object passed to activation or the latest successful update that retained the instance, without re-parsing. Rejected or superseded updates do not replace it. Volatile updates preserve its references. It is undefined before activation and stays readable after unload, so consumers that require a running instance also check the fiber state.
Use `fiber.update()` for an ordinary config change. Loader retains raw input separately when committing only volatile values into the running object.
Use `fiber.update()` for an ordinary config change and await its result for completion. The `internal/config(fiber, next)` waterfall resolves candidates, `internal/commit` requests persistence unless `noSave` is set, and `internal/update(config, next)` either retains the instance or awaits the restart returned by `next()`. Loader retains raw input separately when committing only volatile values into the running object.
## Lifecycle completion
Service providers retain their implementations until dependent fibers finish unloading. Errors from superseded activations are logged without failing the replacement state. A disposed instance cannot access its released injections. A failed fiber requires an explicit `update()` to recover, including after its dependencies change.
+1 -1
View File
@@ -21,7 +21,7 @@ export interface Context {
/** The root context of the application (every child context shares it). @experimental */
root: this
/** Base URL used to resolve relative plugin/module specifiers, if the runtime sets one. */
baseUrl?: string
baseUrl?: string | undefined
/** The event bus. Its methods are also mixed onto `ctx` (`ctx.on`, `ctx.emit`, ...). */
events: EventsService
/** The logging service. Call `ctx.logger(name)` for a named logger. */
+81 -62
View File
@@ -1,7 +1,6 @@
import { defineProperty } from '@deepseek-ai/cosmokit'
import type { Promisify } from '@deepseek-ai/cosmokit'
import { defineProperty, type Awaitable, type Promisify } from '@deepseek-ai/cosmokit'
import { Context } from './context.ts'
import { Fiber, FiberState } from './fiber.ts'
import { Fiber, FiberState, resolveConfig } from './fiber.ts'
import { DisposableList, symbols } from './utils.ts'
/**
@@ -129,7 +128,7 @@ export interface Hook extends EventOptions {
* dispatch and automatically disposes listeners with their owning fiber.
*/
export class EventsService {
_hooks: Record<keyof any, Hook[]> = {}
_hooks: Record<keyof any, Hook[]> = Object.create(null)
constructor(private ctx: Context) {
defineProperty(this, symbols.tracker, {
@@ -141,37 +140,46 @@ export class EventsService {
if (name === 'internal/update' && !options.global) {
const hooks = this.fiber._hooks['internal/update'] ??= new DisposableList()
const method = options.prepend ? 'unshift' : 'push'
return hooks[method](listener)
return this.fiber.effect(() => hooks[method](listener), 'ctx.on("internal/update")')
}
})
this.on('internal/update', function (config, noSave, next) {
this.on('internal/update', function (config, next) {
const cbs = [...this._hooks['internal/update'] || []]
const _next = () => {
const cb = cbs.shift() ?? next
return cb.call(this, config, noSave, _next)
return cb.call(this, config, _next)
}
return _next()
}, { global: true, prepend: true })
this.on('internal/config', (fiber, next) => {
const config = next()
return fiber.runtime ? resolveConfig(fiber.runtime, config) : config
})
}
/**
* Resolve listeners for one dispatch and apply context filtering.
*
* @param type — the dispatch mode, reported on `internal/dispatch`.
* @param args — the raw dispatch arguments; consumed up to the event name.
* @returns the matching listener callbacks, bound to the dispatch `this`.
*/
dispatch(type: string, args: any[]) {
private _resolve(type: string, args: any[]) {
const thisArg = typeof args[0] === 'object' || typeof args[0] === 'function' ? args.shift() : null
const name: string = args.shift()
if (!name.startsWith('internal/')) {
const name: string | symbol = args.shift()
if ((typeof name !== 'string' || !name.startsWith('internal/')) && this._hooks['internal/dispatch']?.length) {
this.emit('internal/dispatch', type, name, args, thisArg)
}
const filter = thisArg?.[Context.filter]
return (this._hooks[name] || [])
.filter(hook => hook.global || !filter || filter.call(thisArg, hook.ctx))
.map(hook => hook.callback.bind(thisArg))
return [thisArg, (this._hooks[name] || [])
.filter(hook => hook.global || !filter || filter.call(thisArg, hook.ctx)).map(hook => hook.callback)] as const
}
/**
* Resolve bound callbacks using the event's context filter.
* @param type Dispatch mode reported to observers.
* @param args Dispatch arguments, consumed through the event name.
* @returns Matching callbacks bound to the dispatch receiver.
* DSH retains callback enumeration for per-listener error containment.
*/
dispatch(type: string, args: any[]) {
const [thisArg, callbacks] = this._resolve(type, args)
return callbacks.map(callback => callback.bind(thisArg))
}
/**
@@ -181,7 +189,8 @@ export class EventsService {
* @returns a promise resolving once every listener has settled.
*/
async parallel(...args: any[]) {
const results = await Promise.allSettled(this.dispatch('emit', args).map(async cb => cb(...args)))
const [thisArg, callbacks] = this._resolve('parallel', args)
const results = await Promise.allSettled(callbacks.map(async callback => Reflect.apply(callback, thisArg, args)))
const errors = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
if (errors.length) throw new AggregateError(errors.map(error => error.reason))
}
@@ -192,7 +201,8 @@ export class EventsService {
* @param args — optional `this`, the event name, then listener arguments.
*/
emit(...args: any[]) {
this.dispatch('emit', args).map(cb => cb(...args))
const [thisArg, callbacks] = this._resolve('emit', args)
for (const callback of callbacks) Reflect.apply(callback, thisArg, args)
}
/**
@@ -202,8 +212,9 @@ export class EventsService {
* @returns the first bail value (see {@link isBailed}), if any.
*/
async serial(...args: any[]) {
for (const cb of this.dispatch('serial', args)) {
const result = await cb(...args)
const [thisArg, callbacks] = this._resolve('serial', args)
for (const callback of callbacks) {
const result = await Reflect.apply(callback, thisArg, args)
if (isBailed(result)) return result
}
}
@@ -215,8 +226,9 @@ export class EventsService {
* @returns the first bail value (see {@link isBailed}), if any.
*/
bail(...args: any[]) {
for (const cb of this.dispatch('bail', args)) {
const result = cb(...args)
const [thisArg, callbacks] = this._resolve('bail', args)
for (const callback of callbacks) {
const result = Reflect.apply(callback, thisArg, args)
if (isBailed(result)) return result
}
}
@@ -232,44 +244,38 @@ export class EventsService {
* @returns the outermost listener's return value.
*/
waterfall(...args: any[]) {
const cbs = this.dispatch('waterfall', args)
const [thisArg, callbacks] = this._resolve('waterfall', args)
const inner = args.pop()
const next = () => {
const cb = cbs.shift() ?? inner
return cb(...args)
const dispatch = () => {
const callback = callbacks.shift()
if (!callback) return inner()
let called = false
const next = () => {
if (called) throw new Error('next() called multiple times')
called = true
return dispatch()
}
return Reflect.apply(callback, thisArg, [...args, next])
}
args.push(next)
return next()
return dispatch()
}
/**
* Store a listener record as an effect on the current fiber.
*
* @param label — effect label shown in fiber diagnostics.
* @param hooks — the listener list for one event.
* @param callback — the listener to store.
* @param options — placement and filtering options.
* @returns a disposer that unregisters the listener.
*/
register(label: string, hooks: Hook[], callback: any, options: EventOptions): () => void {
private register(label: string, name: string | symbol, callback: any, options: EventOptions): () => void {
const method = options.prepend ? 'unshift' : 'push'
return this.ctx.fiber.effect(() => {
const hooks = this._hooks[name] ??= []
hooks[method]({ ctx: this.ctx, callback, ...options })
return () => this.unregister(hooks, callback)
return () => this.unregister(name, callback)
}, label)
}
/**
* Remove a stored listener record.
*
* @param hooks — the listener list for one event.
* @param callback — the listener to remove.
* @returns `true` if the listener was found and removed.
*/
unregister(hooks: Hook[], callback: any) {
private unregister(name: string | symbol, callback: any) {
const hooks = this._hooks[name]
if (!hooks) return
const index = hooks.findIndex(hook => hook.callback === callback)
if (index >= 0) {
hooks.splice(index, 1)
if (!hooks.length) delete this._hooks[name]
return true
}
}
@@ -296,9 +302,8 @@ export class EventsService {
const result = this.bail(this.ctx, 'internal/listener', name, listener, options)
if (result) return result
const hooks = this._hooks[name] ||= []
const label = `ctx.on(${typeof name === 'string' ? JSON.stringify(name) : name.toString()})`
return this.register(label, hooks, listener, options)
return this.register(label, name, listener, options)
}
/**
@@ -309,7 +314,7 @@ export class EventsService {
* @param options — listener options; a boolean is shorthand for `prepend`.
* @returns a disposer removing the listener; `true` if it was still registered.
*/
once(name: string, listener: (...args: any) => any, options?: boolean | EventOptions) {
once(name: string | symbol, listener: (...args: any) => any, options?: boolean | EventOptions) {
const dispose = this.on(name, function (...args: any[]) {
dispose()
return listener.apply(this, args)
@@ -327,20 +332,34 @@ export class EventsService {
* diagnostics before public events are delivered.
*/
export interface Events {
[key: symbol]: (...args: any[]) => any
/** A plugin fiber was created or its uid was cleared on disposal. */
'internal/plugin'(fiber: Fiber): void
/** A fiber changed lifecycle state; receives the fiber and its previous state. */
'internal/status'(fiber: Fiber, oldValue: FiberState): void
/** A service binding was refreshed in the matching isolation scope. */
'internal/service'(this: Context, name: string, value: any): void
/**
* Resolve raw plugin config after the fiber's injections become active.
* @param config - the raw config for this activation.
* Apply resolved config; omitting `next()` keeps the current instance.
* @param resolvedConfig - the evaluated and validated candidate.
* @param next - restarts the fiber and waits for its lifecycle work.
* @mode waterfall
*/
'internal/config'(this: Fiber, config: any, next: () => any): any
/** Interception hook for a service binding (no core producer). */
'internal/service'(this: Context, name: string, value: any): void
/** Waterfall: a fiber config update is being applied; skip `next()` to veto. */
'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void): void
'internal/update'(this: Fiber, resolvedConfig: any, next: () => Awaitable<void>): Awaitable<void>
/**
* Persist original config from a programmatic update, before its lifecycle transition.
* @param fiber - the updated fiber.
* @param originalConfig - the raw candidate; omitted when update uses noSave.
* @mode emit
*/
'internal/commit'(fiber: Fiber, originalConfig: any): void
/**
* Resolve plugin config after injections are active; candidate resolution does not publish it.
* @param fiber - the fiber whose candidate is being resolved.
* @param next - obtains the raw candidate from inner listeners.
* @mode waterfall
*/
'internal/config'(fiber: Fiber, next: () => any): any
/** Waterfall: a service is being read through the context proxy. */
'internal/get'(ctx: Context, name: string, error: Error, next: () => any): any
/** Waterfall: a service is being written through the context proxy. */
@@ -348,5 +367,5 @@ export interface Events {
/** Bail: a listener is being registered; a non-null result replaces registration. */
'internal/listener'(this: Context, name: string, listener: any, prepend: boolean): void
/** An event is being dispatched to listeners (fired for non-internal events only). */
'internal/dispatch'(mode: DispatchMode, name: string, args: any[], thisArg: any): void
'internal/dispatch'(mode: DispatchMode, name: string | symbol, args: any[], thisArg: any): void
}
+95 -58
View File
@@ -178,7 +178,7 @@ const INACTIVE = '__INACTIVE__'
/**
* Runtime instance of one plugin application.
*
* A fiber tracks dependency state, validated config, lifecycle effects, and
* A fiber tracks dependency state, raw and effective config, lifecycle effects, and
* cleanup for the plugin context returned by `ctx.plugin()`.
*/
export class Fiber {
@@ -186,16 +186,16 @@ export class Fiber {
public uid: number | null
/** The context this fiber's plugin runs in (extends the parent context). */
public readonly ctx: Context
/** The validated plugin config (updated by `update()`); read through {@link effectiveConfig}. */
/** Raw plugin input retained for the next activation. */
public config: any
/** The raw plugin config, re-resolved before activation; use {@link rawConfig} to read or retain it. */
public _config: any
/** Current lifecycle state; transitions emit `internal/status`. */
public state = FiberState.PENDING
/** Dispose this fiber: unload the plugin, then settle once cleanup finished. */
public readonly dispose: () => Promise<void>
/** Snapshot of required service implementations while loaded; `undefined` otherwise. */
public store: Dict<Impl> | undefined
/** Required implementations retained through dependent teardown. */
public committed: Readonly<Dict<Impl>> | undefined
/** Service implementations owned by this fiber, including isolated provisions. */
public readonly provisions: Set<Impl> = new Set()
/** The in-flight load/unload transition, if one is currently running. */
public inertia: Promise<void> | undefined
@@ -206,8 +206,12 @@ export class Fiber {
protected context: Context
private _error: any
private _revision = 0
private _configRevision = 0
private _effectiveConfig: unknown
private _runner: EffectRunner<string>
private _store: Dict<Impl> = Object.create(null)
private _release: (() => void) | undefined
/**
* Create a fiber. Plugin authors normally obtain fibers from `ctx.plugin()`
@@ -226,7 +230,7 @@ export class Fiber {
public runtime: Plugin.Runtime | null,
getOuterStack: () => string[],
) {
this._config = config
this.config = config
const collect = (dispose: Disposable) => {
this._disposables.push(dispose)
}
@@ -247,16 +251,18 @@ export class Fiber {
this._runner = {
epoch: INACTIVE,
getOuterStack,
execute: function () {
execute: function (this: Fiber) {
const config = this.resolveConfig(this.config)
this._effectiveConfig = config
if (isConstructor(runtime.callback)) {
// eslint-disable-next-line new-cap
const instance = new runtime.callback(this.ctx, this.config)
const instance = new runtime.callback(this.ctx, config)
for (const hook of instance?.[symbols.initHooks] ?? []) {
hook()
}
return instance?.[symbols.init]?.()
} else {
return runtime.callback(this.ctx, this.config)
return runtime.callback(this.ctx, config)
}
},
collect,
@@ -321,9 +327,9 @@ export class Fiber {
this.uid = 0
this.ctx = this.context = parent
this.state = FiberState.ACTIVE
this.store = Object.create(null)
this.committed = Object.create(null)
this._runner = {
epoch: '',
epoch: '0',
getOuterStack,
execute: () => {},
collect,
@@ -589,8 +595,8 @@ export class Fiber {
if (oldState !== FiberState.ACTIVE && this.state !== FiberState.ACTIVE) return
for (const key of Reflect.ownKeys(this.ctx.reflect.store)) {
const impl = this.ctx.reflect.store[key as symbol]
if (impl.fiber !== this) continue
this.ctx.reflect.notify([impl.name])
if (impl.ctx.fiber !== this) continue
this.ctx.isolate(impl.name, impl.isolate).reflect.notify([impl.name])
}
}
@@ -602,22 +608,21 @@ export class Fiber {
return delete this._store[name]
}
} catch (error) {
impl.fiber.ctx.logger.error(error)
impl.ctx.logger.error(error)
return delete this._store[name]
}
this._store[name] = impl
}
_refresh() {
let epoch: string | boolean = false
epoch = ''
let epoch = `${this._revision}`
for (const name of Object.keys(this.inject)) {
const impl = this._store[name]
if (!impl) {
epoch = INACTIVE
break
}
epoch += ':' + impl.fiber.uid
epoch += ':' + impl.uid
}
this._setEpoch(epoch)
}
@@ -625,6 +630,8 @@ export class Fiber {
private _setEpoch(epoch: string) {
const oldEpoch = this._runner.epoch
if (epoch === oldEpoch) return
// a failed fiber only recovers through update(), which clears _error
if (this._error) return
this._runner.epoch = epoch
if (this.inertia) return
this._updateState(() => {
@@ -640,20 +647,22 @@ export class Fiber {
/** Raw input retained for the next activation, without evaluating expressions or validating it. */
get rawConfig(): unknown {
return this.ctx.fiber._config
return this.ctx.fiber.config
}
/** Retain raw input without changing the running config or triggering an update. */
set rawConfig(config: unknown) {
this.ctx.fiber._config = config
this.ctx.fiber.config = config
this.ctx.fiber._configRevision++
}
/**
* The config object published by the existing activation/update lifecycle, without cloning or re-parsing.
* It is undefined before the first resolution and remains readable after unload; callers requiring a live instance check `state`.
* The object passed to plugin activation or the latest successful update that retained the instance.
* Candidate resolution does not publish it; volatile updates retain its references.
* Undefined before activation and readable after unload; callers requiring a live instance check `state`.
*/
get effectiveConfig(): unknown {
return this.ctx.fiber.config
return this.ctx.fiber._effectiveConfig
}
/**
@@ -666,28 +675,29 @@ export class Fiber {
*/
resolveConfig(config: unknown): unknown {
const fiber = this.ctx.fiber
config = fiber.context.waterfall(fiber, 'internal/config', config, () => config)
return fiber.runtime ? resolveConfig(fiber.runtime, config) : config
return fiber.context.waterfall('internal/config', fiber, () => config)
}
private async _reload() {
this.store = { ...this._store }
this.committed = { ...this._store }
for (const impl of Object.values(this.committed)) {
if (impl.ctx.fiber === this) continue
impl.dependents.add(this)
}
const oldEpoch = this._runner.epoch
try {
await Promise.resolve()
// A disposer queued before this checkpoint may already have invalidated
// the load. Do not run plugin code for a stale epoch; the state update
// below will drain any effects collected while the fiber was PENDING.
if (this._runner.epoch === oldEpoch) {
this.config = this.resolveConfig(this._config)
await this._execute(this._runner)
this._error = undefined
}
} catch (reason) {
// impl guarantees that the error is non-null (?)
this.ctx.logger.error(reason)
this._error = reason
this._runner.epoch = INACTIVE
// A superseded load still needs cleanup, but cannot fail the new target.
if (this._runner.epoch === oldEpoch) {
this._error = reason
this._runner.epoch = INACTIVE
}
}
this._updateState(() => {
if (this._runner.epoch === oldEpoch) {
@@ -700,6 +710,13 @@ export class Fiber {
}
private async _unload() {
// Dependents are notified when this fiber leaves ACTIVE, but may still
// access its services during their own teardown, so the effects of this
// fiber are only disposed after every dependent has unloaded.
while ([...this.provisions].some(impl => impl.dependents.size)) {
await new Promise<void>((resolve) => this._release = resolve)
}
this._release = undefined
await Promise.all(this._disposables.clear().map(async (dispose) => {
try {
await composeError(async (info) => {
@@ -711,7 +728,13 @@ export class Fiber {
this.ctx.logger.error(reason)
}
}))
this.store = undefined
// release the providers waiting for this fiber to unload
for (const impl of Object.values(this.committed ?? {})) {
if (!impl.dependents.delete(this) || impl.dependents.size) continue
impl.release?.()
impl.ctx.fiber._release?.()
}
this.committed = undefined
this._updateState(() => {
if (this._runner.epoch === INACTIVE) {
this.inertia = undefined
@@ -745,39 +768,53 @@ export class Fiber {
async restart() {
const fiber = this.ctx.fiber
fiber.assertActive()
fiber._setEpoch(INACTIVE)
fiber._revision++
fiber._refresh()
await fiber.await()
}
/**
* Validate and apply new config, then restart the plugin.
*
* Runs the `internal/update` waterfall first, so update hooks (and HMR)
* can veto or replace the restart.
*
* @param config — the new raw config; validated before anything restarts.
* @param noSave — hint for persistence hooks not to write the change back.
* @returns nothing; the restart runs behind the `internal/update` waterfall.
* @throws {ValidationError} when the new config fails validation.
* Resolve a candidate, retain its raw config and apply it through the update waterfall.
* @param config Raw config; pending fibers defer resolution until activation.
* @param noSave Suppress the persistence notification when true.
* @returns The awaited update/restart result, or undefined when a synchronous listener handles it.
* @throws {ValidationError} when an active fiber's candidate is invalid.
*/
update(config: any, noSave = false) {
update(config: any, noSave = false): Awaitable<void> {
const fiber = this.ctx.fiber
fiber.assertActive()
fiber._config = config
if (fiber.state !== FiberState.ACTIVE) {
// Config resolution may access injected services, so defer it until the
// fiber can activate.
fiber._error = undefined
fiber._setEpoch(INACTIVE)
fiber._refresh()
const loaded = fiber._runner.epoch !== INACTIVE
const resolved = loaded ? fiber.resolveConfig(config) : undefined
if (!noSave) fiber.context.emit('internal/commit', fiber, config)
fiber.config = config
fiber._error = undefined
const configRevision = ++fiber._configRevision
const epoch = fiber._runner.epoch
const committed = fiber.committed
let restarted = false
const restart = () => {
restarted = true
return fiber.restart()
}
const publish = () => {
if (!restarted && fiber.uid !== null && fiber._configRevision === configRevision
&& fiber._runner.epoch === epoch && fiber.committed === committed) {
fiber._effectiveConfig = resolved
}
}
const result = loaded
? fiber.context.waterfall(fiber, 'internal/update', resolved, restart)
: restart()
// a listener may veto the restart, in which case there is nothing to await
if (result === undefined) {
publish()
return
}
config = fiber.resolveConfig(config)
fiber.context.waterfall(fiber, 'internal/update', config, noSave, () => {
fiber.config = config
fiber._error = undefined
return fiber.restart()
})
const task = Promise.resolve(result).then(publish)
// the failure is already reported by the fiber, so mark it handled here:
// a caller that drops the result cannot turn it into an unhandled
// rejection, while `await update()` still observes it
task.catch(() => {})
return task
}
}
+10 -5
View File
@@ -21,8 +21,8 @@ export type Formatter = (value: any, exporter: Exporter, message: Message) => an
/** Numeric severity used when exporters decide whether to emit a message. */
export const enum LoggerLevel {
ERROR = 0,
INFO = 1,
WARN = 2,
WARN = 1,
INFO = 2,
DEBUG = 3,
}
@@ -214,8 +214,12 @@ export class LoggerService {
colors: 3,
export: (message) => {
self.buffer.push(message)
if (self.buffer.length > self.bufferSize) {
self.buffer = self.buffer.slice(-self.bufferSize)
// for better performance
const overflow = self.buffer.length - self.bufferSize
if (overflow === 1) {
self.buffer.shift()
} else if (overflow > 1) {
self.buffer.splice(0, overflow)
}
},
})
@@ -251,7 +255,8 @@ export class LoggerService {
[symbols.invoke](name?: string): Logger {
const config = this._resolveConfig()
const fiber = ((this.ctx as any)[symbols.shadow] ?? this.ctx).fiber
const caller = (this as any)[symbols.caller] as Context | undefined
const fiber = (caller ?? this.ctx).fiber
name ??= config.name
name ??= hyphenate(fiber.name)
return new Logger({
+37 -19
View File
@@ -114,14 +114,22 @@ export namespace Property {
/** Concrete service implementation record stored in the root reflect service. */
export interface Impl {
/** Unique identity of this provision. */
uid: number
/** The service name. */
name: string
/** The fiber that provided the service (owns its lifetime). */
fiber: Fiber
/** The providing context, including its service isolation. */
ctx: Context
/** Fibers retaining this implementation until their teardown finishes. */
dependents: Set<Fiber>
/** Wake a provision disposer after its final dependent unloads. */
release?: () => void
/** The current service value. */
value?: any
/** Optional availability predicate consulted before dependents may load. */
check?: () => boolean
// Actual isolation symbol; may differ from the provider context after a blocked move.
isolate: symbol
}
/**
@@ -149,12 +157,17 @@ export class ReflectService {
return def.get.call(ctx, ctx[symbols.receiver], error)
}
if (!ctx.fiber.runtime) return ctx.reflect.get(prop, false)
// a fiber-less def site cannot declare `inject` at all, so it keeps the unchecked root access.
const defSite = (ctx[symbols.shadow] as Context | undefined) ?? ctx
if (!defSite.fiber.runtime) return ctx.reflect.get(prop, false)
return ctx.events.waterfall('internal/get', ctx, prop, error, () => {
const key = target[symbols.isolate][prop]
let fiber = (ctx[symbols.shadow] as Context ?? ctx).fiber
const key = defSite[symbols.isolate][prop]
let fiber = defSite.fiber
while (true) {
const impl = fiber.store?.[prop]
let impl = fiber.committed?.[prop]
for (const provision of fiber.provisions) {
if (provision.name === prop && provision.ctx[symbols.isolate][prop] === key) impl = provision
}
if (impl) return getTraceable(ctx, impl.value)
if (prop in fiber.inject) {
error.message = `cannot get required service "${prop}" in inactive context`
@@ -209,6 +222,7 @@ export class ReflectService {
public store: Dict<Impl, symbol> = Object.create(null)
/** Declared context properties (services and accessors), by name. */
public props: Dict<Property> = Object.create(null)
private _implCounter = 0
constructor(public ctx: Context) {
defineProperty(this, symbols.tracker, {
@@ -238,7 +252,7 @@ export class ReflectService {
const key = this.ctx[symbols.isolate][name]
const impl = key && this.store[key]
if (!impl) return
if (strict && impl.fiber.state !== FiberState.ACTIVE) return
if (strict && impl.ctx.fiber.state !== FiberState.ACTIVE) return
return impl
}
@@ -257,7 +271,7 @@ export class ReflectService {
if (!impl) {
throw new Error(`cannot set property "${name}" without provide`)
}
if (impl.fiber !== this.ctx.fiber) {
if (impl.ctx.fiber !== this.ctx.fiber) {
throw new Error(`cannot set property "${name}" in multiple fibers`)
}
impl.value = value
@@ -285,21 +299,29 @@ export class ReflectService {
this.ctx.root[symbols.isolate][name] ??= Symbol(name)
const key = this.ctx[symbols.isolate][name]
const impl: Impl = { name, value, fiber: this.ctx.fiber, check }
const impl: Impl = { uid: ++this._implCounter, name, value, ctx: this.ctx, check, isolate: key, dependents: new Set() }
if (this.store[key]) {
throw new Error(`service "${name}" has been registered at <${this.store[key].fiber.name}>`)
throw new Error(`service "${name}" has been registered at <${this.store[key].ctx.fiber.name}>`)
}
this.store[key] = impl
this.ctx.fiber.store![name] = impl
this.ctx.fiber.provisions.add(impl)
if (this.ctx.fiber.state === FiberState.ACTIVE) {
this.notify([name])
}
return async () => {
delete this.store[key]
const fibers = this.notify([name])
await Promise.allSettled(fibers.map(fiber => fiber.await()))
if (this.store[impl.isolate] === impl) delete this.store[impl.isolate]
this.ctx.isolate(name, impl.isolate).reflect.notify([name])
// Retired bindings cannot gain new dependents. Wait only for their
// cleanup, even if a fiber has left the registry or starts a new load.
// Yield even without direct dependents so child cleanup can still
// access the inherited service before its provision is removed.
await new Promise<void>((resolve) => {
impl.release = resolve
if (!impl.dependents.size) resolve()
})
impl.release = undefined
// ensure self access before dependencies cleanup
delete this.ctx.fiber.store![name]
this.ctx.fiber.provisions.delete(impl)
}
}, `ctx.provide(${JSON.stringify(name)})`)
}
@@ -309,10 +331,8 @@ export class ReflectService {
*
* @param names — the service names that changed.
* @param filter — restricts notification to matching isolation scopes.
* @returns the fibers whose dependency state was refreshed.
*/
notify(names: string[], filter = (ctx: Context, name: string) => ctx[symbols.isolate][name] === this.ctx[symbols.isolate][name]) {
const fibers: Fiber[] = []
for (const runtime of this.ctx.registry.values()) {
for (const fiber of runtime.fibers) {
let hasUpdate = false
@@ -324,7 +344,6 @@ export class ReflectService {
}
if (!hasUpdate) continue
fiber._refresh()
fibers.push(fiber)
}
}
for (const name of names) {
@@ -332,7 +351,6 @@ export class ReflectService {
self[symbols.filter] = (target: Context) => filter(target, name)
this.ctx.events.emit(self, 'internal/service', name, this._getImpl(name, false)?.value)
}
return fibers
}
/**
+28 -21
View File
@@ -50,6 +50,7 @@ export interface Tracker {
export const symbols = {
// internal symbols
shadow: Symbol.for('cordis.shadow'),
caller: Symbol.for('cordis.caller'),
receiver: Symbol.for('cordis.receiver'),
original: Symbol.for('cordis.original'),
metadata: Symbol.for('cordis.metadata'),
@@ -146,11 +147,12 @@ function withProp(target: any, prop: string | symbol, value: any) {
}))
}
function createShadow(ctx: Context, target: any, property: string | undefined, receiver: any) {
function createShadow(useSite: Context, target: any, property: string | undefined, receiver: any) {
if (!property) return receiver
const origin = Reflect.getOwnPropertyDescriptor(target, property)?.value
if (!origin) return receiver
return withProp(receiver, property, ctx.extend({ [symbols.shadow]: origin }))
const value = getPropertyDescriptor(target, property)?.value
if (!value) return receiver
const defSite = (value[symbols.shadow] as Context | undefined) ?? value
return withProp(receiver, property, useSite.extend({ [symbols.shadow]: defSite }))
}
function createShadowMethod(ctx: Context, value: any, outer: any, shadow: {}) {
@@ -162,22 +164,23 @@ function createShadowMethod(ctx: Context, value: any, outer: any, shadow: {}) {
})
}
// `ctx` may be a shadow, i.e. a pair of:
// - def site: where the accessing code was defined, governs service resolution
// - use site: where the service is consumed, governs intercept, isolate and effects
function createTraceable(ctx: Context, value: any, tracker: Tracker) {
// noShadow services are identity-aware (e.g. logger uses the origin fiber to
// derive its name): keep the shadow ctx so they can read [symbols.shadow]
// and resolve the origin. Non-noShadow services strip — their side effects
// bind to caller, not origin.
if (ctx[symbols.shadow] && !tracker.noShadow) {
ctx = Object.getPrototypeOf(ctx)
}
const defSite = (ctx[symbols.shadow] as Context | undefined) ?? ctx
const useSite = ctx[symbols.shadow] ? Object.getPrototypeOf(ctx) as Context : ctx
const proxy = new Proxy(value, {
get: (target, prop, receiver) => {
if (prop === symbols.original) return target
if (prop === tracker.property) return ctx
if (prop === symbols.caller) return defSite
if (prop === tracker.property) return useSite
if (typeof prop === 'symbol') {
return Reflect.get(target, prop, receiver)
}
if (tracker.associate && ctx.reflect.props[`${tracker.associate}.${prop}`]) {
// `ctx` rather than `useSite`, so that `ctx.foo.bar` and `ctx['foo.bar']` are
// governed by the same def site
if (tracker.associate && useSite.reflect.props[`${tracker.associate}.${prop}`]) {
return Reflect.get(ctx, `${tracker.associate}.${prop}`, withProp(ctx, symbols.receiver, receiver))
}
let shadow: any, innerValue: any
@@ -185,33 +188,37 @@ function createTraceable(ctx: Context, value: any, tracker: Tracker) {
if (desc && 'value' in desc) {
innerValue = desc.value
} else {
shadow = createShadow(ctx, target, tracker.property, receiver)
shadow = createShadow(useSite, target, tracker.property, receiver)
innerValue = Reflect.get(target, prop, shadow)
}
const innerTracker = innerValue?.[symbols.tracker]
if (innerTracker) {
return createTraceable(ctx, innerValue, innerTracker)
return createTraceable(useSite, innerValue, innerTracker)
} else if (!tracker.noShadow && typeof innerValue === 'function') {
shadow ??= createShadow(ctx, target, tracker.property, receiver)
return createShadowMethod(ctx, innerValue, receiver, shadow)
shadow ??= createShadow(useSite, target, tracker.property, receiver)
return createShadowMethod(useSite, innerValue, receiver, shadow)
} else {
return innerValue
}
},
set: (target, prop, value, receiver) => {
if (prop === symbols.original) return false
if (prop === symbols.caller) return false
if (prop === tracker.property) return false
if (typeof prop === 'symbol') {
return Reflect.set(target, prop, value, receiver)
}
if (tracker.associate && ctx.reflect.props[`${tracker.associate}.${prop}`]) {
if (tracker.associate && useSite.reflect.props[`${tracker.associate}.${prop}`]) {
return Reflect.set(ctx, `${tracker.associate}.${prop}`, value, withProp(ctx, symbols.receiver, receiver))
}
const shadow = createShadow(ctx, target, tracker.property, receiver)
const shadow = createShadow(useSite, target, tracker.property, receiver)
return Reflect.set(target, prop, value, shadow)
},
apply: (target, thisArg, args) => {
return applyTraceable(proxy, target, thisArg, args)
const receiver = tracker.noShadow
? proxy
: createShadow(useSite, target, tracker.property, proxy)
return applyTraceable(receiver, target, thisArg, args)
},
})
return proxy
@@ -226,7 +233,7 @@ function applyTraceable(proxy: any, value: any, thisArg: any, args: any[]) {
export function createCallable(name: string, proto: {}, tracker: Tracker) {
const self = function (...args: any[]) {
const proxy = createTraceable(self['ctx'], self, tracker)
return applyTraceable(proxy, self, this, args)
return Reflect.apply(proxy, this, args)
}
defineProperty(self, 'name', name)
return Object.setPrototypeOf(self, proto)
+2 -6
View File
@@ -187,14 +187,10 @@ export class Include extends EntryTree {
this.readonly = !this.type
this.ctx.baseUrl = new URL('.', pathToFileURL(this.filename)).href
ctx.on('internal/update', (config, _, next) => {
ctx.on('internal/update', (config, next) => {
if (config.path !== this.config.path) return next()
// Veto the fiber restart (children update in place), but persist the new
// config ourselves — `Fiber.update` only assigns `this.config` behind
// `next()`, and a stale `this.config.patches` would make the next
// `refresh()` re-apply the old overlay.
this.config = config
this.root.update(this.applyPatches(this.data!, config.patches)).catch((error) => {
return this.root.update(this.applyPatches(this.data!, config.patches)).catch((error) => {
this.ctx.logger.warn('config update at %C failed', this.filename)
this.ctx.logger.warn(error)
})
+4
View File
@@ -61,3 +61,7 @@ Node module internals are classified by their available APIs, including the v1 A
## Volatile configuration
`Entry.update` compares raw entry options strictly; when only `config` changed on an active fiber in an unchanged context, the pure `equalExceptVolatile` function in `config/diff.ts` compares the raw configs using Schemastery metadata. It ignores volatile fields at fixed object paths without executing expressions, validators or `internal/config` hooks. Object nodes use their declared defaults for null or absent values, including objects without volatile fields; equivalent defaults skip updates. Other ordinary values, parent expressions and unknown fields retain raw comparison. Recursive schema backedges use raw comparison. Entry passes the actual plugin schema; a non-Schemastery schema compares raw. Context changes, other option changes and pending fibers use ordinary updates. A volatile-only change retains the raw Fiber config for later activation, then `_commitVolatile` parses it through the fiber's `internal/config` hook and schema, commits the new values into the running references when every ordinary effective value still matches, and emits `loader/volatile-update` to the owning fiber with the changed paths. A changed ordinary effective value uses the ordinary remount with a debug log naming the entry; because strict comparison treats class instances other than URL, Date and RegExp by identity, an ordinary field whose transform builds a fresh instance on every parse always takes that remount. An invalid candidate is logged and leaves the running references unchanged. A throwing `loader/volatile-update` listener is logged and does not fail the update; the values are already committed. Group/Include retain child dispatch, and programmatic Fiber updates preserve saving and `noSave`; `simplify` is called with its schema receiver so saved config contains plain values.
## Update completion
Entry updates await `loader/patch-context`, the Fiber update waterfall and disabled-entry disposal. Combined context/config changes refresh required bindings before restarting. Group and Include updates expose completion of their child updates; file persistence still follows Include’s debounced writer.
+13 -13
View File
@@ -98,12 +98,15 @@ export class Entry {
return evaluate(this.ctx, expr)
}
private _patchContext(diff: string[]) {
this.context.waterfall('loader/patch-context', this, () => {
private async _patchContext(diff: string[]) {
await this.context.waterfall('loader/patch-context', this, async () => {
Object.setPrototypeOf(this.ctx, this.parent.ctx)
if (this.fiber?.uid && (diff.includes('config') || this.options.group)) {
this.fiber.update(this.options.config, true)
// A simultaneous context/config edit must restart against the new dependencies.
const fiber = this.fiber.ctx.fiber
for (const name of Object.keys(fiber.inject)) fiber._checkImpl(name)
await fiber.update(this.options.config, true)
}
})
}
@@ -134,7 +137,7 @@ export class Entry {
// step 2: execute
if (this.disabled) {
this.fiber?.dispose()
await this.fiber?.dispose()
return
}
@@ -150,7 +153,7 @@ export class Entry {
const pending = volatileOnly && this._commitVolatile() ? [] : changes
if (!pending.length && !force) return
this.context.emit('loader/partial-dispose', this, legacy, true)
this._patchContext(pending)
await this._patchContext(pending)
} else {
await this.init()
}
@@ -162,7 +165,7 @@ export class Entry {
* @returns `false` when an ordinary effective value changed, so the caller applies the ordinary update lifecycle.
*/
private _commitVolatile(): boolean {
const fiber = this.fiber!
const fiber = this.fiber!.ctx.fiber
const refs = volatileEntries(fiber.effectiveConfig)
if (!refs.length) return true
const raw = this.options.config
@@ -213,11 +216,10 @@ export class Entry {
} finally {
this._initTask = undefined
}
const notify = () => {
this.fiber?.await().catch(() => {}).finally(() => {
if (this.loader.getTasks().length) return
this.ctx.reflect.notify(['loader'])
}
this.fiber?.await().then(notify, notify)
})
}
private async _init() {
@@ -227,13 +229,11 @@ export class Entry {
} catch (error) {
this.ctx.logger.error(error)
return
} finally {
this._initTask = undefined
}
const plugin = this.loader.unwrapExports(moduleNamespace)
this._patchContext([])
await this._patchContext([])
this.loader.showLog(this, 'apply')
this.fiber = this.ctx.registry.plugin(plugin, this.options.config, this.getOuterStack).ctx.fiber
this.fiber = this.ctx.registry.plugin(plugin, this.options.config, this.getOuterStack)
this.moduleNamespace = moduleNamespace
}
}
+1 -3
View File
@@ -78,9 +78,7 @@ export class Group extends EntryGroup {
constructor(public ctx: Context, public config: EntryOptions[]) {
super(ctx, ctx.fiber.entry!.parent.tree)
ctx.on('internal/update', (config) => {
this.update(config)
})
ctx.on('internal/update', config => this.update(config))
}
async* [Service.init]() {
+10 -7
View File
@@ -93,7 +93,7 @@ export default function isolate(ctx: Context) {
entry.ctx[Context.isolate] = Object.create(entry.ctx[Context.isolate])
})
ctx.on('loader/patch-context', (entry, next) => {
ctx.on('loader/patch-context', async (entry, next) => {
// step 1: generate new isolate map
const newMap: Dict<symbol> = Object.create(entry.parent.ctx[Context.isolate])
for (const name of Object.keys(entry.options.isolate ?? {})) {
@@ -110,12 +110,13 @@ export default function isolate(ctx: Context) {
for (const symbol of [oldMap[name], newMap[name]]) {
const impl = symbol && entry.ctx.reflect.store[symbol]
if (!impl) continue
if (!impl.fiber) {
const fiber = impl.ctx.fiber
if (!fiber) {
entry.ctx.logger.warn(new Error(`expected service ${name} to be implemented`))
continue
}
diff[name] = [oldMap[name], newMap[name], entry.ctx[delim], impl.fiber.ctx[delim]]
if (entry.ctx[delim] !== impl.fiber.ctx[delim]) break
diff[name] = [oldMap[name], newMap[name], entry.ctx[delim], fiber.ctx[delim]]
if (entry.ctx[delim] !== fiber.ctx[delim]) break
}
}
@@ -126,13 +127,15 @@ export default function isolate(ctx: Context) {
swap(entry.ctx[Context.intercept], entry.options.intercept)
// step 4: reload fiber
next()
await next()
// step 5: replace service impl
for (const [symbol1, symbol2, flag1, flag2] of Object.values(diff)) {
if (flag1 === flag2 && entry.ctx.reflect.store[symbol1] && !entry.ctx.reflect.store[symbol2]) {
entry.ctx.reflect.store[symbol2] = entry.ctx.reflect.store[symbol1]
const impl = entry.ctx.reflect.store[symbol1]
if (flag1 === flag2 && impl && !entry.ctx.reflect.store[symbol2]) {
entry.ctx.reflect.store[symbol2] = impl
delete entry.ctx.reflect.store[symbol1]
impl.isolate = symbol2
}
}
+17 -20
View File
@@ -1,5 +1,5 @@
import { Context, FiberState, Inject, Service, type Fiber } from '@deepseek-ai/cordis'
import { defineProperty, isNullable, type Dict } from '@deepseek-ai/cosmokit'
import { Context, FiberState, Inject, Service } from '@deepseek-ai/cordis'
import { defineProperty, isNullable, type Awaitable, type Dict } from '@deepseek-ai/cosmokit'
import { ModuleLoader } from './internal.ts'
import { Entry, type EntryOptions } from './config/entry.ts'
import { EntryGroup } from './config/group.ts'
@@ -38,7 +38,7 @@ declare module '@deepseek-ai/cordis' {
* @param next - continue context refresh and Loader's config update.
* @mode waterfall
*/
'loader/patch-context'(entry: Entry, next: () => void): void
'loader/patch-context'(entry: Entry, next: () => Awaitable<void>): Awaitable<void>
}
interface Context {
@@ -101,26 +101,23 @@ export class Loader extends EntryTree {
ctx.reflect.provide('loader', this, this[Service.check])
ctx.on('internal/config', function (this: Fiber, _config, next) {
ctx.on('internal/config', (fiber, next) => {
const config = next()
if (!this.entry || this.parent.fiber?.entry === this.entry) return config
// Tree carriers (Group, Include) keep their configs literal: their
// entry and patch lists hold other rows' configs, whose `!!js`
// expressions belong to those rows' own fibers.
const plugin = this.runtime?.callback as Record<PropertyKey, unknown> | undefined
if (plugin?.[EntryGroup.key]) return config
return interpolate(this.ctx, config)
}, { global: true })
if (!fiber.entry || fiber.parent.fiber?.entry === fiber.entry) return config
// Tree carriers preserve the expressions owned by their child entries.
if (fiber.runtime?.callback[EntryGroup.key]) return config
return interpolate(fiber.ctx, config)
})
ctx.on('internal/update', function (config, noSave, next) {
if (!this.entry || noSave || this.parent.fiber?.entry === this.entry) return next()
const unparse = this.runtime?.Config?.['simplify']
this.entry.options.config = unparse ? unparse.call(this.runtime!.Config, config) : config
this.entry.parent.tree.write()
return next()
}, { global: true, prepend: true })
ctx.on('internal/commit', (fiber, config) => {
if (!fiber.entry || fiber.parent.fiber?.entry === fiber.entry) return
const schema = fiber.runtime?.Config
const unparse = schema?.['simplify']
fiber.entry.options.config = unparse ? unparse.call(schema, config) : config
fiber.entry.parent.tree.write()
})
ctx.on('internal/update', function (config, _, next) {
ctx.on('internal/update', function (config, next) {
if (!this.entry || this.parent.fiber?.entry === this.entry) return next()
self.showLog(this.entry, 'reload')
return next()