mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-11-09 20:07:43 +08:00
chore(cordis): upgrade Core to 4.0.0-rc.11
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,前提是运行时设置了该值。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: ee804c03c39e7806
|
||||
zh: 4353ba7817ec61ec
|
||||
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: 290f7f1bf1207adc
|
||||
zh: e666cfb53dea897a
|
||||
en: a4f711258eb70e47
|
||||
zh: fff4d880d820de10
|
||||
/fiber/effect:
|
||||
en: 007d5b214e8a8610
|
||||
zh: d74c26fbb1b8aeb6
|
||||
|
||||
+45
-39
@@ -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()`). */
|
||||
/** Raw plugin input retained for the next activation. */
|
||||
public config: any
|
||||
```
|
||||
|
||||
The validated plugin config (updated by `update()`).
|
||||
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#L763)
|
||||
[Source](../../vendor/cordis/src/fiber.ts#L783)
|
||||
|
||||
## Effect
|
||||
|
||||
|
||||
+45
-40
@@ -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()`). */
|
||||
/** Raw plugin input retained for the next activation. */
|
||||
public config: any
|
||||
```
|
||||
|
||||
经过校验的插件配置(由 `update()` 更新)。
|
||||
为下次激活保留的原始插件输入。
|
||||
|
||||
[源码](../../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
|
||||
|
||||
@@ -221,22 +232,22 @@ 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
|
||||
```
|
||||
|
||||
传给插件激活过程或最近一次成功保留实例的更新的对象。候选配置求值不发布结果;volatile 更新保留其中的引用。激活前为 undefined,卸载后仍可读取;需要运行中实例的调用方还应检查 `state`。
|
||||
|
||||
既有激活/更新生命周期发布的配置对象,不克隆或重新解析。首次解析前为 undefined,卸载后仍可读取;需要运行实例的调用方应检查 `state`。
|
||||
|
||||
[源码](../../vendor/cordis/src/fiber.ts#L655)
|
||||
[源码](../../vendor/cordis/src/fiber.ts#L664)
|
||||
|
||||
### fiber.resolveConfig(config)
|
||||
|
||||
@@ -259,7 +270,7 @@ resolveConfig(config: unknown): unknown
|
||||
|
||||
**返回**候选配置的解析结果,保留 schema 返回对象的身份。
|
||||
|
||||
[源码](../../vendor/cordis/src/fiber.ts#L667)
|
||||
[源码](../../vendor/cordis/src/fiber.ts#L676)
|
||||
|
||||
### fiber.await()
|
||||
|
||||
@@ -277,7 +288,7 @@ async await()
|
||||
|
||||
**返回**:进入稳定状态后的此 fiber。
|
||||
|
||||
[源码](../../vendor/cordis/src/fiber.ts#L731)
|
||||
[源码](../../vendor/cordis/src/fiber.ts#L754)
|
||||
|
||||
### fiber.restart()
|
||||
|
||||
@@ -295,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#L763)
|
||||
[源码](../../vendor/cordis/src/fiber.ts#L783)
|
||||
|
||||
## Effect
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -100,8 +100,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `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` | - | `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-pi-ai`](../packages/llm/llm-pi-ai), `product-analytics`, `speech-to-text` |
|
||||
| `slots/changed` | `ui-renderer` (`emit`) | - |
|
||||
<!-- END GENERATED event-producer-consumer:undeclared -->
|
||||
|
||||
@@ -102,8 +102,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` | - | `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-pi-ai`](../packages/llm/llm-pi-ai), `product-analytics`, `speech-to-text` |
|
||||
| `slots/changed` | `ui-renderer` (`emit`) | - |
|
||||
<!-- END GENERATED event-producer-consumer:undeclared -->
|
||||
|
||||
@@ -333,7 +333,7 @@ function invalidRemoteEventFrame(): never {
|
||||
}
|
||||
|
||||
function privateEvents(ctx: Context): PrivateEventContext {
|
||||
return ctx
|
||||
return ctx.events
|
||||
}
|
||||
|
||||
function toError(reason: unknown, message: string): Error {
|
||||
|
||||
@@ -848,7 +848,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(
|
||||
@@ -879,7 +879,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(
|
||||
@@ -925,7 +925,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
|
||||
|
||||
@@ -662,10 +662,12 @@ describe('sessions.fork', () => {
|
||||
const child = ctx.agents.get(response.value.sessionId)
|
||||
if (child === undefined) throw new Error('fork did not publish the child agent')
|
||||
expect(child.session.snapshotEvents().slice(0, child.session.inheritedEventCount)).toEqual(inherited)
|
||||
const assembly = await child.ctx.systemPrompt.assemble()
|
||||
expect(assembly.variables).toMatchObject({
|
||||
provider: 'inherited-provider',
|
||||
model: 'inherited-model',
|
||||
await child.ctx.inject(['systemPrompt'], async (scope) => {
|
||||
const assembly = await scope.systemPrompt.assemble()
|
||||
expect(assembly.variables).toMatchObject({
|
||||
provider: 'inherited-provider',
|
||||
model: 'inherited-model',
|
||||
})
|
||||
})
|
||||
const fallback: LlmCallConfig = { provider: 'default-provider', model: 'default-model' }
|
||||
await expect(agentEvents(child.ctx, child).waterfall(
|
||||
|
||||
@@ -388,10 +388,13 @@ async function recompose(
|
||||
const prepared = prepareProfilePatches(ctx, patches, parentURL, binName)
|
||||
applying = true
|
||||
const retried = [...new Set([...running, ...withResolution ? [...ctx.loader.entries()].filter(importFailed) : []])]
|
||||
const priorOptions = new Map(retried.map(row => [row, row.options]))
|
||||
try {
|
||||
await entry.update({ config: { ...includeConfig, patches: prepared } })
|
||||
} finally {
|
||||
await resume(ctx, retried)
|
||||
// Group replaces options when it attempts a row, including failed imports.
|
||||
// Only untouched rows still need an explicit retry after the awaited update.
|
||||
await resume(ctx, retried.filter(row => row.options === priorOptions.get(row)))
|
||||
}
|
||||
const results = await Promise.allSettled(previousFibers.map(({ fiber }) => fiber.await()))
|
||||
await ctx.loader.await()
|
||||
@@ -1221,11 +1224,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'
|
||||
|
||||
@@ -254,6 +254,8 @@ describe('Loader config interpolation', () => {
|
||||
await provider?.update({ config: { value: 'recovered' } })
|
||||
await provider?.update({ disabled: false })
|
||||
await ctx.loader.await()
|
||||
await expect(reader?.fiber?.await()).rejects.toThrow('rejected provider')
|
||||
await reader?.fiber?.update(reader.fiber.rawConfig, true)
|
||||
expect(ctx.get('readerResult')).toEqual({ value: 'recovered' })
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
@@ -360,7 +362,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 +381,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 +392,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 () => {
|
||||
|
||||
@@ -831,7 +831,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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -223,7 +223,7 @@ 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)
|
||||
await entry.fiber.update(entry.options.config)
|
||||
}
|
||||
} catch (error) {
|
||||
failures.push({ id: row.id, message: String(error) })
|
||||
|
||||
@@ -622,12 +622,14 @@ describe('model-relative reference budgets', () => {
|
||||
it('removes both listeners 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 }])
|
||||
expect(resolve).toHaveBeenLastCalledWith('seed', 'seed', undefined)
|
||||
expect(resolve).not.toHaveBeenCalled()
|
||||
await expect(resolver.prepare(agent, [], [{ sessionId: source.id }]))
|
||||
.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', { messages: [message], turn: 1, step: 1, signal: new AbortController().signal },
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -921,17 +921,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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -270,15 +270,16 @@ 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()
|
||||
await rejected
|
||||
withdrawal = owner.fiber!.dispose()
|
||||
} else if (change === 'catalog') {
|
||||
const config = owner.fiber!.effectiveConfig as ReturnType<typeof OfficialConfig>
|
||||
config.models = OfficialConfig({ models: [{ id: 'deepseek-v4-flash' }] }).models
|
||||
} else b.setConfigured(false)
|
||||
release.resolve(undefined)
|
||||
await rejected
|
||||
await withdrawal
|
||||
expect(b.requests).toEqual([])
|
||||
expect(b.session?.snapshotEvents()).toEqual([])
|
||||
})
|
||||
|
||||
@@ -289,7 +289,7 @@ describe('GoalService creation and replay', () => {
|
||||
await fiber.dispose()
|
||||
expect(ctx.get('goals')).toBeUndefined()
|
||||
expect(ctx.sessionProjections.stateOf(stub.session, 'goal')).toBeUndefined()
|
||||
expect(() => first.get(stub.agent)).toThrow('goal projection is not registered')
|
||||
expect(() => first.get(stub.agent)).toThrow('cannot get required service "agents" in inactive context')
|
||||
|
||||
await ctx.plugin(GoalService)
|
||||
expect(ctx.goals).not.toBe(first)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
@@ -61,14 +61,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.ctx.fiber.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()
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -1017,15 +1017,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' },
|
||||
|
||||
@@ -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
|
||||
@@ -481,9 +482,9 @@ it('logs an invalid candidate and keeps its references while other entries commi
|
||||
await invalid.entry!.update({ config: { title: 'invalid', nested: { count: -1 } } })
|
||||
await healthy.entry!.update({ config: { title: 'healthy' } })
|
||||
expect(warning).toHaveBeenCalledTimes(2)
|
||||
expect((invalid.config as ReturnType<typeof Config>).title.get()).toBe('hello')
|
||||
expect((invalid.effectiveConfig as ReturnType<typeof Config>).title.get()).toBe('hello')
|
||||
expect(invalid.rawConfig).toEqual({ title: 'invalid', nested: { count: -1 } })
|
||||
expect((healthy.config as ReturnType<typeof Config>).title.get()).toBe('healthy')
|
||||
expect((healthy.effectiveConfig as ReturnType<typeof Config>).title.get()).toBe('healthy')
|
||||
expect(events).toHaveBeenCalledExactlyOnceWith([['title']])
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
@@ -617,7 +618,7 @@ it('isolates a failing notification listener and dispatches with a Context recei
|
||||
expect(receivers).toEqual([fiber])
|
||||
expect(warning).toHaveBeenCalledWith(expect.objectContaining({ message: 'listener boom' }))
|
||||
await update(other, { title: 'unaffected' })
|
||||
expect((other.config as ReturnType<typeof Config>).title.get()).toBe('unaffected')
|
||||
expect((other.effectiveConfig as ReturnType<typeof Config>).title.get()).toBe('unaffected')
|
||||
})
|
||||
|
||||
it('remounts instead of committing when an ordinary field parses into a fresh class instance', async () => {
|
||||
|
||||
Vendored
+9
-7
@@ -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` |
|
||||
@@ -37,24 +37,24 @@ Keep this log exhaustive — every divergence from upstream must be listed.
|
||||
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 and Loader 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. 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.
|
||||
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, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup.
|
||||
7. **Cordis and Loader 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. The upstream `EventsService.dispatch()` deprecation annotation is omitted: DSH enumerates callbacks to contain each observer failure independently, which the typed dispatch methods do not provide. 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`.
|
||||
12. **Loader activation observer**: the rc.11 `Entry.init()` observer handles both activation outcomes, retiring the local observer workaround. Fiber errors remain available to explicit consumer audits.
|
||||
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 use the canonical fiber, including writes 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`.
|
||||
15. **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.
|
||||
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 shape detection**: `ModuleLoader.fromInternal()` classifies the internal loader by which module-job API it owns — `getOrCreateModuleJob` for v2, `getModuleJobForImport` for v1 — instead of by Node major version. Upstream tags every major `>= 24` as v2, but the v2 interface arrived in Node 24.12.0, so 24.0–24.11.1 report major 24 while still carrying the v1 loader; consumers then called `resolveSync` with reversed parameters and every call threw. `dsh web` served an empty client graph (`__DSH_BOOT__.entries: []`) and HMR partial reload resolved no entry URL, both behind swallowed or warn-level errors. Arity cannot discriminate the two shapes, because each reports `resolveSync.length === 2`. A loader owning neither API is left unclassified rather than guessed, so consumers take their documented no-internals path. Covered on the `node-compat` Node version matrix, which pins 24.9 for the mistagged range.
|
||||
|
||||
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`.
|
||||
20. **Loader fiber identity**: rc.11 Core canonicalizes `update()` and `restart()` through `ctx.fiber`, retiring the local unwrapping workaround. `Entry.fiber` retains the registry wrapper; volatile commits explicitly use the canonical fiber. HMR retains its existing replacement algorithm.
|
||||
|
||||
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`.
|
||||
21. **Logger exporter disposal**: the rc.11 implementation retains each registration id in its disposer, retiring the local exporter-removal fix.
|
||||
|
||||
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.
|
||||
|
||||
@@ -62,6 +62,8 @@ Keep this log exhaustive — every divergence from upstream must be listed.
|
||||
|
||||
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.
|
||||
|
||||
25. **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. 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 retain their manifest baselines until the remaining package changes are reconciled. Failed fibers require an explicit update after correcting their input or dependencies. Core retains the effect hardening in item 6; rc.11 owns dependency teardown ordering and superseded-activation handling.
|
||||
|
||||
## Sync procedure
|
||||
|
||||
To update a vendored package from upstream:
|
||||
|
||||
Vendored
+6
-2
@@ -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. `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. `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.
|
||||
|
||||
Vendored
+1
-1
@@ -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. */
|
||||
|
||||
Vendored
+81
-62
@@ -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
|
||||
}
|
||||
|
||||
Vendored
+101
-62
@@ -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()`). */
|
||||
/** Raw plugin input retained for the next activation. */
|
||||
public config: any
|
||||
/** The raw plugin config, re-resolved before each activation. */
|
||||
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
|
||||
@@ -743,39 +766,55 @@ export class Fiber {
|
||||
* @throws {CordisError} `INACTIVE_EFFECT` when the fiber is already disposed.
|
||||
*/
|
||||
async restart() {
|
||||
this.assertActive()
|
||||
this._setEpoch(INACTIVE)
|
||||
this._refresh()
|
||||
await this.await()
|
||||
const fiber = this.ctx.fiber
|
||||
fiber.assertActive()
|
||||
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) {
|
||||
this.assertActive()
|
||||
this._config = config
|
||||
if (this.state !== FiberState.ACTIVE) {
|
||||
// Config resolution may access injected services, so defer it until the
|
||||
// fiber can activate.
|
||||
this._error = undefined
|
||||
this._setEpoch(INACTIVE)
|
||||
this._refresh()
|
||||
update(config: any, noSave = false): Awaitable<void> {
|
||||
const fiber = this.ctx.fiber
|
||||
fiber.assertActive()
|
||||
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 = this.resolveConfig(config)
|
||||
this.context.waterfall(this, 'internal/update', config, noSave, () => {
|
||||
this.config = config
|
||||
this._error = undefined
|
||||
return this.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
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+10
-5
@@ -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({
|
||||
|
||||
Vendored
+37
-19
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Vendored
+28
-21
@@ -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)
|
||||
|
||||
Vendored
+2
-6
@@ -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)
|
||||
})
|
||||
|
||||
Vendored
+4
@@ -51,3 +51,7 @@ For file-backed trees, use `@cordisjs/plugin-include`.
|
||||
## 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.
|
||||
|
||||
Vendored
+13
-13
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
-3
@@ -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]() {
|
||||
|
||||
Vendored
+10
-7
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+17
-20
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user