From a91a827546727057a4e0d05b7fa59ff5c5138be1 Mon Sep 17 00:00:00 2001 From: Shigma Date: Wed, 14 Oct 2026 16:45:55 +0800 Subject: [PATCH 001/131] chore(cordis): upgrade Core to 4.0.0-rc.11 --- docs/cordis-api/context.md | 2 +- docs/cordis-api/context.zh.md | 2 +- docs/cordis-api/events.i18n.yaml | 36 ++-- docs/cordis-api/events.md | 18 +- docs/cordis-api/events.zh.md | 18 +- docs/cordis-api/fiber.i18n.yaml | 69 ++++--- docs/cordis-api/fiber.md | 84 ++++---- docs/cordis-api/fiber.zh.md | 85 ++++---- docs/cordis-api/inherited.md | 19 +- docs/event-producer-consumer.md | 3 +- docs/event-producer-consumer.zh.md | 3 +- .../api/gateway/src/client/remote-events.ts | 2 +- .../api/gateway/tests/gateway.client.spec.ts | 6 +- .../api/gateway/tests/gateway.host.spec.ts | 4 +- .../tests/session-fork.host.spec.ts | 10 +- packages/boot/app-boot/src/index.ts | 10 +- .../boot/app-boot/tests/user-patches.spec.ts | 20 +- packages/boot/hmr/src/index.ts | 2 +- packages/boot/hmr/tests/coordination.spec.ts | 2 +- packages/client/modules/src/client/entries.ts | 2 +- .../tests/session-reference.spec.ts | 6 +- .../claude-code-mods/README.i18n.yaml | 4 +- .../experimental/claude-code-mods/README.md | 2 + .../claude-code-mods/README.zh.md | 2 + .../claude-code-mods/src/index.ts | 6 +- .../claude-code-mods/tests/bridge.spec.ts | 10 +- .../tests/cordis-collector.host.spec.ts | 10 +- .../translator/tests/deepseek.spec.ts | 5 +- packages/goal/goal/tests/goal.spec.ts | 2 +- packages/llm/llm-pi-ai/src/index.ts | 4 +- packages/lsp/lsp-stdio/tests/provider.spec.ts | 4 +- .../preset/agent-preset-registry/src/mount.ts | 4 +- .../schedule/schedule/tests/recovery.spec.ts | 2 +- packages/settings/settings/src/index.ts | 4 +- scripts/cordis-config-access.spec.ts | 9 +- scripts/cordis-core-lifecycle.spec.ts | 188 ++++++++++++++++++ scripts/gen-cordis-catalog.ts | 19 +- scripts/loader-volatile-update.spec.ts | 35 ++-- vendor/README.md | 16 +- vendor/cordis/README.md | 8 +- vendor/cordis/src/context.ts | 2 +- vendor/cordis/src/events.ts | 143 +++++++------ vendor/cordis/src/fiber.ts | 163 +++++++++------ vendor/cordis/src/logger.ts | 15 +- vendor/cordis/src/reflect.ts | 56 ++++-- vendor/cordis/src/utils.ts | 49 +++-- vendor/include/src/index.ts | 8 +- vendor/loader/README.md | 4 + vendor/loader/src/config/entry.ts | 26 +-- vendor/loader/src/config/group.ts | 4 +- vendor/loader/src/config/isolate.ts | 17 +- vendor/loader/src/index.ts | 37 ++-- 52 files changed, 789 insertions(+), 472 deletions(-) create mode 100644 scripts/cordis-core-lifecycle.spec.ts diff --git a/docs/cordis-api/context.md b/docs/cordis-api/context.md index 320b23de1c..453f607768 100644 --- a/docs/cordis-api/context.md +++ b/docs/cordis-api/context.md @@ -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. diff --git a/docs/cordis-api/context.zh.md b/docs/cordis-api/context.zh.md index b97fe51eb8..a5c7e36b4b 100644 --- a/docs/cordis-api/context.zh.md +++ b/docs/cordis-api/context.zh.md @@ -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,前提是运行时设置了该值。 diff --git a/docs/cordis-api/events.i18n.yaml b/docs/cordis-api/events.i18n.yaml index 68421a5f2c..f423afad17 100644 --- a/docs/cordis-api/events.i18n.yaml +++ b/docs/cordis-api/events.i18n.yaml @@ -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 diff --git a/docs/cordis-api/events.md b/docs/cordis-api/events.md index 8b8eb2358e..1916532331 100644 --- a/docs/cordis-api/events.md +++ b/docs/cordis-api/events.md @@ -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) diff --git a/docs/cordis-api/events.zh.md b/docs/cordis-api/events.zh.md index 60b0580496..b66a1fc4d3 100644 --- a/docs/cordis-api/events.zh.md +++ b/docs/cordis-api/events.zh.md @@ -28,7 +28,7 @@ parallel(thisArg: NoInfer>, name: K, **返回值**:一个 Promise,在所有监听器均已完成后兑现。 -[源码](../../vendor/cordis/src/events.ts#L44) +[源码](../../vendor/cordis/src/events.ts#L43) ### ctx.emit(name, ...args) @@ -48,7 +48,7 @@ emit(thisArg: NoInfer>, 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(thisArg: NoInfer>, 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(thisArg: NoInfer>, 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(thisArg: NoInfer>, name: K **返回值**:最外层监听器的返回值。 -[源码](../../vendor/cordis/src/events.ts#L86) +[源码](../../vendor/cordis/src/events.ts#L85) ### ctx.on(name, listener, options?) @@ -146,7 +146,7 @@ on(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(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) diff --git a/docs/cordis-api/fiber.i18n.yaml b/docs/cordis-api/fiber.i18n.yaml index 6c1d8987ce..aeba613660 100644 --- a/docs/cordis-api/fiber.i18n.yaml +++ b/docs/cordis-api/fiber.i18n.yaml @@ -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 diff --git a/docs/cordis-api/fiber.md b/docs/cordis-api/fiber.md index 366c457d4f..3bfc3a00e9 100644 --- a/docs/cordis-api/fiber.md +++ b/docs/cordis-api/fiber.md @@ -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 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 | undefined +/** Required implementations retained through dependent teardown. */ +public committed: Readonly> | 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 +``` + +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 ``` -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 diff --git a/docs/cordis-api/fiber.zh.md b/docs/cordis-api/fiber.zh.md index 05ee483bdd..88d253452d 100644 --- a/docs/cordis-api/fiber.zh.md +++ b/docs/cordis-api/fiber.zh.md @@ -36,7 +36,7 @@ effect(execute: () => Effect, label?: string): AsyncDisposable> **返回**一个用于撤销该作用的清理函数,并在清理完成后结算。 -[源码](../../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 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 | undefined +/** Required implementations retained through dependent teardown. */ +public committed: Readonly> | 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 +``` + +此 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> **返回**一个用于撤销该作用的清理函数,并在清理完成后结算。 -[源码](../../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 ``` -校验并应用新配置,然后重新启动插件。 +求值候选配置,保留原始输入,并通过更新 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 diff --git a/docs/cordis-api/inherited.md b/docs/cordis-api/inherited.md index cc62683cb5..c414eaedd5 100644 --- a/docs/cordis-api/inherited.md +++ b/docs/cordis-api/inherited.md @@ -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)) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e18409d8ab..b11cc58620 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -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`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index aed9a07616..36f52f47e5 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -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`) | - | diff --git a/packages/api/gateway/src/client/remote-events.ts b/packages/api/gateway/src/client/remote-events.ts index eabda54e37..8e5b1c22ca 100644 --- a/packages/api/gateway/src/client/remote-events.ts +++ b/packages/api/gateway/src/client/remote-events.ts @@ -333,7 +333,7 @@ function invalidRemoteEventFrame(): never { } function privateEvents(ctx: Context): PrivateEventContext { - return ctx + return ctx.events } function toError(reason: unknown, message: string): Error { diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index b0c7d4f390..a2ade6d8c0 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -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() diff --git a/packages/api/gateway/tests/gateway.host.spec.ts b/packages/api/gateway/tests/gateway.host.spec.ts index 8f3f6fddc4..689960d9f7 100644 --- a/packages/api/gateway/tests/gateway.host.spec.ts +++ b/packages/api/gateway/tests/gateway.host.spec.ts @@ -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 diff --git a/packages/api/session-controller/tests/session-fork.host.spec.ts b/packages/api/session-controller/tests/session-fork.host.spec.ts index e6ba8321ce..a2f251371a 100644 --- a/packages/api/session-controller/tests/session-fork.host.spec.ts +++ b/packages/api/session-controller/tests/session-fork.host.spec.ts @@ -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( diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index fded02c9c8..a0019b1617 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -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' diff --git a/packages/boot/app-boot/tests/user-patches.spec.ts b/packages/boot/app-boot/tests/user-patches.spec.ts index 63e0af5ea6..359fe9a487 100644 --- a/packages/boot/app-boot/tests/user-patches.spec.ts +++ b/packages/boot/app-boot/tests/user-patches.spec.ts @@ -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() const release = Promise.withResolvers() @@ -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() + 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 () => { diff --git a/packages/boot/hmr/src/index.ts b/packages/boot/hmr/src/index.ts index 5c31921549..164fa6ddb8 100644 --- a/packages/boot/hmr/src/index.ts +++ b/packages/boot/hmr/src/index.ts @@ -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 } diff --git a/packages/boot/hmr/tests/coordination.spec.ts b/packages/boot/hmr/tests/coordination.spec.ts index b3660828e9..e0dceccb99 100644 --- a/packages/boot/hmr/tests/coordination.spec.ts +++ b/packages/boot/hmr/tests/coordination.spec.ts @@ -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() }) diff --git a/packages/client/modules/src/client/entries.ts b/packages/client/modules/src/client/entries.ts index b3c096dbaa..eb0a123e06 100644 --- a/packages/client/modules/src/client/entries.ts +++ b/packages/client/modules/src/client/entries.ts @@ -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) }) diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index efc8447e90..90aee1120a 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -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 }, diff --git a/packages/experimental/claude-code-mods/README.i18n.yaml b/packages/experimental/claude-code-mods/README.i18n.yaml index aa0a5e6116..b824f0c6a1 100644 --- a/packages/experimental/claude-code-mods/README.i18n.yaml +++ b/packages/experimental/claude-code-mods/README.i18n.yaml @@ -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 diff --git a/packages/experimental/claude-code-mods/README.md b/packages/experimental/claude-code-mods/README.md index 4fd02d3ded..1a89bb14df 100644 --- a/packages/experimental/claude-code-mods/README.md +++ b/packages/experimental/claude-code-mods/README.md @@ -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`). diff --git a/packages/experimental/claude-code-mods/README.zh.md b/packages/experimental/claude-code-mods/README.zh.md index 1a2f909626..fba92a214b 100644 --- a/packages/experimental/claude-code-mods/README.zh.md +++ b/packages/experimental/claude-code-mods/README.zh.md @@ -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。 diff --git a/packages/experimental/claude-code-mods/src/index.ts b/packages/experimental/claude-code-mods/src/index.ts index 1c97870b73..7f0f95c1d5 100644 --- a/packages/experimental/claude-code-mods/src/index.ts +++ b/packages/experimental/claude-code-mods/src/index.ts @@ -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() diff --git a/packages/experimental/claude-code-mods/tests/bridge.spec.ts b/packages/experimental/claude-code-mods/tests/bridge.spec.ts index 4ee329ce99..d76153180c 100644 --- a/packages/experimental/claude-code-mods/tests/bridge.spec.ts +++ b/packages/experimental/claude-code-mods/tests/bridge.spec.ts @@ -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') }) diff --git a/packages/experimental/inspector/tests/cordis-collector.host.spec.ts b/packages/experimental/inspector/tests/cordis-collector.host.spec.ts index 77a09f5343..902febcf75 100644 --- a/packages/experimental/inspector/tests/cordis-collector.host.spec.ts +++ b/packages/experimental/inspector/tests/cordis-collector.host.spec.ts @@ -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) diff --git a/packages/experimental/translator/tests/deepseek.spec.ts b/packages/experimental/translator/tests/deepseek.spec.ts index 7f45aecd9f..76f723e0e2 100644 --- a/packages/experimental/translator/tests/deepseek.spec.ts +++ b/packages/experimental/translator/tests/deepseek.spec.ts @@ -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 | 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 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([]) }) diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 5bcdcf1fcb..1eaff01666 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -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) diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index a7b00faa79..7b05531258 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -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, diff --git a/packages/lsp/lsp-stdio/tests/provider.spec.ts b/packages/lsp/lsp-stdio/tests/provider.spec.ts index 4f2ac651ea..f1bc5e0293 100644 --- a/packages/lsp/lsp-stdio/tests/provider.spec.ts +++ b/packages/lsp/lsp-stdio/tests/provider.spec.ts @@ -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() diff --git a/packages/preset/agent-preset-registry/src/mount.ts b/packages/preset/agent-preset-registry/src/mount.ts index 37eb64a91e..23eec09cdf 100644 --- a/packages/preset/agent-preset-registry/src/mount.ts +++ b/packages/preset/agent-preset-registry/src/mount.ts @@ -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( /* 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 } diff --git a/packages/schedule/schedule/tests/recovery.spec.ts b/packages/schedule/schedule/tests/recovery.spec.ts index ed1c37876c..2b2a8ec142 100644 --- a/packages/schedule/schedule/tests/recovery.spec.ts +++ b/packages/schedule/schedule/tests/recovery.spec.ts @@ -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) diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index bd439b2301..42fbea9d30 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -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 }) diff --git a/scripts/cordis-config-access.spec.ts b/scripts/cordis-config-access.spec.ts index 16457afdec..435c5d8399 100644 --- a/scripts/cordis-config-access.spec.ts +++ b/scripts/cordis-config-access.spec.ts @@ -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() diff --git a/scripts/cordis-core-lifecycle.spec.ts b/scripts/cordis-core-lifecycle.spec.ts new file mode 100644 index 0000000000..20f456db81 --- /dev/null +++ b/scripts/cordis-core-lifecycle.spec.ts @@ -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() + 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() + 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 => 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') + }) +}) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index d734035f5a..640377fc51 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -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' }, diff --git a/scripts/loader-volatile-update.spec.ts b/scripts/loader-volatile-update.spec.ts index c23486cc66..bdcb8917d3 100644 --- a/scripts/loader-volatile-update.spec.ts +++ b/scripts/loader-volatile-update.spec.ts @@ -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) => next()) ctx.on('internal/update', globalHook, { global: true }) const fiber = await mountEntry(ctx, { Config, apply(owner: Context) { owner.on('internal/update', (config: ReturnType) => { @@ -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).title.get()).toBe('hello') - entry.fiber!.update({ title: 'ephemeral', fixed: 'ordinary' }, true) + expect((entry.fiber!.effectiveConfig as ReturnType).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 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) => 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 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) => 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).title.get()).toBe('hello') + expect((invalid.effectiveConfig as ReturnType).title.get()).toBe('hello') expect(invalid.rawConfig).toEqual({ title: 'invalid', nested: { count: -1 } }) - expect((healthy.config as ReturnType).title.get()).toBe('healthy') + expect((healthy.effectiveConfig as ReturnType).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).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).title.get()).toBe('unaffected') + expect((other.effectiveConfig as ReturnType).title.get()).toBe('unaffected') }) it('remounts instead of committing when an ordinary field parses into a fresh class instance', async () => { diff --git a/vendor/README.md b/vendor/README.md index deff0e6500..5e0e7a02fe 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -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` 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: diff --git a/vendor/cordis/README.md b/vendor/cordis/README.md index 2f7385dcf4..e32016ab45 100644 --- a/vendor/cordis/README.md +++ b/vendor/cordis/README.md @@ -106,6 +106,10 @@ Schemas may return `Volatile` 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. diff --git a/vendor/cordis/src/context.ts b/vendor/cordis/src/context.ts index 0488423088..b7dcf44ff6 100644 --- a/vendor/cordis/src/context.ts +++ b/vendor/cordis/src/context.ts @@ -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. */ diff --git a/vendor/cordis/src/events.ts b/vendor/cordis/src/events.ts index c7bf42632e..9e7b6a6633 100644 --- a/vendor/cordis/src/events.ts +++ b/vendor/cordis/src/events.ts @@ -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 = {} + _hooks: Record = 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): Awaitable + /** + * 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 } diff --git a/vendor/cordis/src/fiber.ts b/vendor/cordis/src/fiber.ts index a21f18510d..6da19b4767 100644 --- a/vendor/cordis/src/fiber.ts +++ b/vendor/cordis/src/fiber.ts @@ -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 - /** Snapshot of required service implementations while loaded; `undefined` otherwise. */ - public store: Dict | undefined + /** Required implementations retained through dependent teardown. */ + public committed: Readonly> | undefined + /** Service implementations owned by this fiber, including isolated provisions. */ + public readonly provisions: Set = new Set() /** The in-flight load/unload transition, if one is currently running. */ public inertia: Promise | 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 private _store: Dict = 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((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 { + 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 } } diff --git a/vendor/cordis/src/logger.ts b/vendor/cordis/src/logger.ts index 7038ecc202..0dc97c16b3 100644 --- a/vendor/cordis/src/logger.ts +++ b/vendor/cordis/src/logger.ts @@ -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({ diff --git a/vendor/cordis/src/reflect.ts b/vendor/cordis/src/reflect.ts index 7bbe1ae991..89123616a0 100644 --- a/vendor/cordis/src/reflect.ts +++ b/vendor/cordis/src/reflect.ts @@ -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 + /** 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 = Object.create(null) /** Declared context properties (services and accessors), by name. */ public props: Dict = 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((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 } /** diff --git a/vendor/cordis/src/utils.ts b/vendor/cordis/src/utils.ts index 024d21f5e0..07f52e6477 100644 --- a/vendor/cordis/src/utils.ts +++ b/vendor/cordis/src/utils.ts @@ -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) diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index ee4dd53fb8..075252dfff 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -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) }) diff --git a/vendor/loader/README.md b/vendor/loader/README.md index ee9a0994a1..80ca7158b9 100644 --- a/vendor/loader/README.md +++ b/vendor/loader/README.md @@ -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. diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index 5f819367ee..b014ed6497 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -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 } } diff --git a/vendor/loader/src/config/group.ts b/vendor/loader/src/config/group.ts index 15d4d49e4b..ba797a44ee 100644 --- a/vendor/loader/src/config/group.ts +++ b/vendor/loader/src/config/group.ts @@ -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]() { diff --git a/vendor/loader/src/config/isolate.ts b/vendor/loader/src/config/isolate.ts index e2fd539157..43747992ce 100644 --- a/vendor/loader/src/config/isolate.ts +++ b/vendor/loader/src/config/isolate.ts @@ -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 = 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 } } diff --git a/vendor/loader/src/index.ts b/vendor/loader/src/index.ts index 51d36260a8..f9d9baff0a 100644 --- a/vendor/loader/src/index.ts +++ b/vendor/loader/src/index.ts @@ -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): Awaitable } 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 | 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() From e34f45723e56c59fdea10b83be2a8b6a8452b8be Mon Sep 17 00:00:00 2001 From: Shigma Date: Wed, 14 Oct 2026 18:03:05 +0800 Subject: [PATCH 002/131] fix(cordis): complete canonical Fiber consumer migration --- packages/api/remotes/tests/built-lib.e2e.ts | 2 +- packages/boot/app-boot/src/index.ts | 9 ++------- packages/experimental/translator/src/deepseek.ts | 4 ++-- packages/experimental/translator/tests/deepseek.spec.ts | 2 ++ packages/goal/goal/src/index.ts | 4 ++-- 5 files changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts index 5b8200c657..3e7c6073fc 100644 --- a/packages/api/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -182,7 +182,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { await client.plugin({ inject: plugin.inject, apply: plugin.apply }) } client.typert.contexts.registerClient('agent', { - identity: candidate => candidate.builtAgentId, + identity: candidate => 'builtAgentId' in candidate ? candidate.builtAgentId : undefined, }) const invalidResult = await client.remote.goals.create(rootAgent.id, { objective: 1 }) diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index a0019b1617..530da71ce7 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -342,9 +342,7 @@ async function recompose( diagnostic: failure.entry.fiber?.uid ? reloadDiagnostic(failure) : undefined, })) // Removed entries leave the Loader store before their async disposers finish. - const previousFibers = [...ctx.loader.entries()].flatMap(row => row.fiber === undefined ? [] : [{ - fiber: row.fiber, failed: row.fiber.state === FIBER_FAILED || row.fiber.state === FIBER_DISPOSED, - }]) + const previousFibers = [...ctx.loader.entries()].flatMap(row => row.fiber === undefined ? [] : [row.fiber]) const { patches: previous = [], ...includeConfig } = entry.options.config as Include.Config const current = withResolution ? ctx.get('pluginPackages')?.resolution : undefined // A plain-data resolution has no inputs to recompute from. @@ -396,7 +394,7 @@ async function recompose( // 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 Promise.allSettled(previousFibers.map(fiber => fiber.await())) await ctx.loader.await() const failures = await inactiveEntries(ctx) const introduced = failures.filter((failure) => { @@ -408,9 +406,6 @@ async function recompose( return !previousFailures.some(previous => sameFailure(previous, failure)) }) if (introduced.length > 0) throw new Error(activationDiagnostic(binName, introduced, false).trimEnd()) - for (const [index, result] of results.entries()) { - if (result.status === 'rejected' && !previousFibers[index]?.failed) throw result.reason - } ctx.emit('app-boot/config-reload') return { warnings: failures.map(reloadDiagnostic) } } finally { diff --git a/packages/experimental/translator/src/deepseek.ts b/packages/experimental/translator/src/deepseek.ts index afdba4de20..600810e017 100644 --- a/packages/experimental/translator/src/deepseek.ts +++ b/packages/experimental/translator/src/deepseek.ts @@ -72,8 +72,8 @@ function nativeOwner(ctx: Context, provider: PaidTranslationProvider): NativeOwn const entry = [...loader.entries()].find(row => row.options.id === directory.settingsNs && row.options.name === OWNER_PACKAGES[provider]) if (entry?.fiber?.state !== FiberState.ACTIVE) return undefined return provider === 'deepseek-account' - ? { llm, fiber: entry.fiber, provider, config: entry.fiber.effectiveConfig as AccountNativeConfig } - : { llm, fiber: entry.fiber, provider, config: entry.fiber.effectiveConfig as OfficialNativeConfig } + ? { llm, fiber: entry.fiber.ctx.fiber, provider, config: entry.fiber.effectiveConfig as AccountNativeConfig } + : { llm, fiber: entry.fiber.ctx.fiber, provider, config: entry.fiber.effectiveConfig as OfficialNativeConfig } } function unchanged(ctx: Context, provider: PaidTranslationProvider, previous: EligibleOwner): NativeOwner | undefined { diff --git a/packages/experimental/translator/tests/deepseek.spec.ts b/packages/experimental/translator/tests/deepseek.spec.ts index 76f723e0e2..8d40b492c0 100644 --- a/packages/experimental/translator/tests/deepseek.spec.ts +++ b/packages/experimental/translator/tests/deepseek.spec.ts @@ -261,6 +261,7 @@ describe('native Flash translation', () => { it.each(['withdrawn', 'catalog', 'credential'])('rejects a native route %s during async preparation before audit or dispatch', async (change) => { const b = await paidFixture(cleanups), entered = Promise.withResolvers(), release = Promise.withResolvers() + cleanups.push(async () => { release.resolve(undefined) }) const original = b.ctx.llm.prepareCall.bind(b.ctx.llm) vi.spyOn(b.ctx.llm, 'prepareCall').mockImplementationOnce(async (config, signal) => { const call = await original(config, signal) @@ -273,6 +274,7 @@ describe('native Flash translation', () => { let withdrawal: Promise | undefined if (change === 'withdrawn') { withdrawal = owner.fiber!.dispose() + await rejected } else if (change === 'catalog') { const config = owner.fiber!.effectiveConfig as ReturnType config.models = OfficialConfig({ models: [{ id: 'deepseek-v4-flash' }] }).models diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 48a02e485c..df383e8f22 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -474,8 +474,8 @@ export class GoalService extends TypertRemoteService { /** Read the current durable projection maintained by the registry. */ private state(session: Session): GoalProjection | null { - const state = this.ctx.sessionProjections.stateOf(session, 'goal') - if (state === undefined) throw new Error('goal projection is not registered') + // The projection registration is owned by this live service instance. + const state = this.ctx.sessionProjections.stateOf(session, 'goal') as GoalProjectionState if (state.failure !== null) throw new Error(state.failure) return state.current } From 5186f36a68afe087f05604d2f45b97e4e4506392 Mon Sep 17 00:00:00 2001 From: Shigma Date: Wed, 14 Oct 2026 18:19:36 +0800 Subject: [PATCH 003/131] fix(cordis): preserve built Remote context and teardown snapshots --- packages/typert/protocol/src/index.ts | 2 +- snapshots/session/bash-startup-timeout/session.v4.jsonl | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/typert/protocol/src/index.ts b/packages/typert/protocol/src/index.ts index 42b6de0ecd..e2af8932b6 100644 --- a/packages/typert/protocol/src/index.ts +++ b/packages/typert/protocol/src/index.ts @@ -238,7 +238,7 @@ export function bindTypertRemote( const namespace = options.namespace ?? serviceKey validateName('namespace', namespace) const ctx: unknown = Reflect.get(service, 'ctx') - if (ctx instanceof Context) provideInvocationAccessor(ctx) + if (Context.is(ctx)) provideInvocationAccessor(ctx) return Object.freeze({ service, serviceKey, namespace }) } diff --git a/snapshots/session/bash-startup-timeout/session.v4.jsonl b/snapshots/session/bash-startup-timeout/session.v4.jsonl index d981ad7881..13598429fc 100644 --- a/snapshots/session/bash-startup-timeout/session.v4.jsonl +++ b/snapshots/session/bash-startup-timeout/session.v4.jsonl @@ -12,12 +12,13 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Use the bash tool to","messageSeqs":[8],"source":{"kind":"fallback"}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-startup-timeout","name":"bash","arguments":"{\"command\":\"sleep 30\",\"timeoutMs\":1,\"description\":\"Cancel a command during startup\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":1,"outputTokens":1},"stream":[{"type":"chunk","time":1791526663346,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791526663346,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-startup-timeout","name":"bash","arguments":"{\"command\":\"sleep 30\",\"timeoutMs\":1,\"description\":\"Cancel a command during startup\"}"}}},{"type":"chunk","time":1791526663346,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}},{"type":"chunk","time":1791526663346,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-startup-timeout","name":"bash","arguments":"{\"command\":\"sleep 30\",\"timeoutMs\":1,\"description\":\"Cancel a command during startup\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":1,"outputTokens":1},"stream":[{"type":"chunk","time":1791973104265,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791973104265,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-startup-timeout","name":"bash","arguments":"{\"command\":\"sleep 30\",\"timeoutMs\":1,\"description\":\"Cancel a command during startup\"}"}}},{"type":"chunk","time":1791973104266,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}},{"type":"chunk","time":1791973104266,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"bash-startup-timeout","name":"bash","arguments":"{\"command\":\"sleep 30\",\"timeoutMs\":1,\"description\":\"Cancel a command during startup\"}"}} -{"type":"job/registered","data":{"jobId":"{{id:1}}","owner":"{{session:1}}","kind":"bash","label":"sleep 30","startedAt":1791526663354,"toolCall":{"callId":"bash-startup-timeout","rootCallId":"bash-startup-timeout"}}} +{"type":"job/registered","data":{"jobId":"{{id:1}}","owner":"{{session:1}}","kind":"bash","label":"sleep 30","startedAt":1791973104275,"toolCall":{"callId":"bash-startup-timeout","rootCallId":"bash-startup-timeout"}}} {"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"bash-startup-timeout"},"toolCallId":"bash-startup-timeout","content":[{"type":"text","text":"[still running after 1ms; moved to background job {{id:1}}]\nThe command keeps running in the background. You will be notified when it finishes; read newer output with job_output, stop it with job_kill."}],"isError":false,"id":"{{message:5}}"},"meta":{"cwd":"{{cwd}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":1,"outputTokens":1},"stream":[{"type":"chunk","time":1791526663368,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1791526663368,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1791526663368,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}},{"type":"chunk","time":1791526663368,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":1,"outputTokens":1},"stream":[{"type":"chunk","time":1791973104295,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1791973104295,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1791973104295,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}},{"type":"chunk","time":1791973104295,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"job/settled","data":{"jobId":"{{id:1}}","owner":"{{session:1}}","kind":"bash","label":"sleep 30","startedAt":1791973104275,"toolCall":{"callId":"bash-startup-timeout","rootCallId":"bash-startup-timeout"},"finishedAt":1791973104307,"status":"killed","cause":"teardown","producerReported":true,"detail":"signal: SIGTERM"}} From 788bae3674a55ac082b6b51a2acc0eb3ccea9598 Mon Sep 17 00:00:00 2001 From: Saafo <46736350+Saafo@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:41:40 +0800 Subject: [PATCH 004/131] feat(client): adapt sidebar navigation to mobile drawers --- .../tests/expected/sidebar-drawer.expected.md | 22 + apps/web/tests/model-input-layout.ts | 6 + apps/web/tests/sidebar-drawer.e2e.ts | 489 ++++++++++++++++++ apps/web/tests/support.ts | 9 +- apps/web/tsconfig.json | 1 + packages/client/ui-layout/README.i18n.yaml | 8 +- packages/client/ui-layout/README.md | 8 +- packages/client/ui-layout/README.zh.md | 8 +- .../ui-layout/src/client/AppFrame.module.css | 97 +++- .../client/ui-layout/src/client/AppFrame.tsx | 89 +++- .../client/ui-layout/src/client/columns.ts | 9 +- packages/client/ui-layout/src/client/index.ts | 17 +- .../client/ui-layout/src/client/stores.ts | 6 +- .../ui-layout/tests/app-frame.client.spec.tsx | 147 +++++- .../ui-layout/tests/columns.client.spec.ts | 5 + .../tests/layout-store.client.spec.ts | 18 + .../client/ui-sidebar-right/README.i18n.yaml | 4 +- packages/client/ui-sidebar-right/README.md | 2 + packages/client/ui-sidebar-right/README.zh.md | 2 + .../src/client/shell/SidebarRight.module.css | 13 +- packages/client/ui-sidebar/README.i18n.yaml | 8 +- packages/client/ui-sidebar/README.md | 4 +- packages/client/ui-sidebar/README.zh.md | 4 +- .../client/HeaderLeadingControls.module.css | 6 +- .../src/client/SidebarRoot.module.css | 67 ++- .../ui-sidebar/src/client/SidebarRoot.tsx | 55 +- .../client/ui-sidebar/src/client/locales.ts | 2 + .../ui-sidebar/tests/apply.client.spec.tsx | 2 +- .../tests/panel-list.client.spec.tsx | 2 +- .../tests/pointer-scrollbars.client.spec.tsx | 1 + .../tests/sidebar-root.client.spec.tsx | 140 ++++- .../tests/sidebar-snapshot.client.spec.tsx | 14 +- packages/client/ui-workspace/README.i18n.yaml | 8 +- packages/client/ui-workspace/README.md | 5 + packages/client/ui-workspace/README.zh.md | 5 + .../src/client/rows/Rows.module.css | 36 +- .../ui-workspace/src/client/rows/Rows.tsx | 24 +- .../client/rows/WorkspaceBrowser.module.css | 45 +- .../src/client/rows/WorkspaceBrowser.tsx | 8 +- .../ui-workspace/tests/rows.client.spec.tsx | 30 ++ .../src/client/slot-catalog.ts | 18 +- .../client-runtime/README.i18n.yaml | 4 +- .../test-support/client-runtime/README.md | 2 +- .../test-support/client-runtime/README.zh.md | 2 +- .../src/assembly/test-client.ts | 7 +- .../tests/assembly-test-client.client.spec.ts | 11 +- tsconfig.host.json | 1 + 47 files changed, 1307 insertions(+), 164 deletions(-) create mode 100644 apps/web/tests/expected/sidebar-drawer.expected.md create mode 100644 apps/web/tests/sidebar-drawer.e2e.ts diff --git a/apps/web/tests/expected/sidebar-drawer.expected.md b/apps/web/tests/expected/sidebar-drawer.expected.md new file mode 100644 index 0000000000..cc8c6d2b7c --- /dev/null +++ b/apps/web/tests/expected/sidebar-drawer.expected.md @@ -0,0 +1,22 @@ +390px: closed sidebar has no rail; main width 390px +open: main geometry retained; background inert; no resize handle +close: Session navigation, Plugins navigation, mask and Escape +retained: sidebar DOM and opener focus +768px: 56px rail; main width 712px; drawer focus transfers to the rail toggle +breakpoint motion: collapsed rail hides immediately and returns compact without crossfades; expanded sidebar and explicit toggles retain their slides +767px: hidden drawer; main width 767px; no page overflow +global panels: browser top inset 48px; macOS keeps its existing clearance +tooltips: sidebar and workspace controls escape the drawer and fit the viewport +search input: 16px on touch; 13px with a fine pointer +simulated keyboard: homepage, drawer and Settings retain 844px layout height without following the 360px visual viewport or adding safe-area padding to Settings +keyboard close: layout height stays 844px; sidebar search retained; Settings close remains reachable +safe areas: drawer backing and mask cover 844px; content retains 56px top and 34px bottom insets +rotation: 844x390 then 390x844 restores shell height and sidebar breakpoint +folded landscape: 951px frame splits at 475.5px without a resize handle; unfolding restores 280px and search focus; portrait retains the drawer +fold transitions: sidebar controls follow the divider in both directions; reduced motion changes width without animation +drawer mask: opacity transitions from 0 to 1 on open and 1 to 0 on close +reduced motion: drawer and mask settle without transitions +touch selected Session: More remains visible; archive and pin stay in its menu; pin marker retained +touch Workspace: actions appear only while expanded, including after folding the focused row +fine pointer: folded Workspace actions remain available on hover +fine pointer: selected Session actions stay hidden until hover, including at 390px diff --git a/apps/web/tests/model-input-layout.ts b/apps/web/tests/model-input-layout.ts index e911bb3812..5cdacbfabd 100644 --- a/apps/web/tests/model-input-layout.ts +++ b/apps/web/tests/model-input-layout.ts @@ -27,5 +27,11 @@ export async function assertModelInputLayout(page: Page, editor: Locator): Promi } } finally { await page.setViewportSize(original) + // The frame reports its own width to the layout store from a rAF-throttled + // ResizeObserver, so the restored viewport is only in force once the shell + // has left drawer mode; a capture taken in between reads the narrow frame. + if (await page.locator('[data-sidebar-drawer]').count() > 0) { + await expect.poll(async () => page.locator('[data-sidebar-drawer]').count(), { timeout: 5_000 }).toBe(0) + } } } diff --git a/apps/web/tests/sidebar-drawer.e2e.ts b/apps/web/tests/sidebar-drawer.e2e.ts new file mode 100644 index 0000000000..e935fa0944 --- /dev/null +++ b/apps/web/tests/sidebar-drawer.e2e.ts @@ -0,0 +1,489 @@ +/** Narrow sidebar geometry, navigation, focus and retained content in the shipped profile. */ +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import type { Locator } from 'playwright' +import { chromium } from 'playwright' +import { expect, it } from 'vitest' +import { compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode } from './scaffold.ts' + +/** A returning rail uses its compact layout without wide-content or icon-entry animations. */ +async function assertCompactRail(sidebar: Locator): Promise { + expect(await sidebar.getByRole('button', { name: 'New session', exact: true }).count()).toBe(1) + expect(await sidebar.evaluate((el) => { + const root = el.firstElementChild! + return { + fits: root.getBoundingClientRect().width <= el.parentElement!.getBoundingClientRect().width, + animations: root.getAnimations({ subtree: true }) + .filter((animation): animation is CSSAnimation => + animation instanceof CSSAnimation && /wide-in|rail-in|rail-fade-in/.test(animation.animationName)) + .map(animation => animation.animationName), + } + })).toEqual({ fits: true, animations: [] }) +} + +it('hides a collapsed rail immediately when it becomes a drawer', async () => { + const scaffold = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), + }) + try { + const browser = await chromium.launch() + try { + const page = await browser.newPage({ viewport: { width: 768, height: 844 }, locale: 'en-US' }) + await page.goto(scaffold.authenticatedUrl) + await page.getByRole('button', { name: 'Open sidebar', exact: true }).waitFor() + const sidebar = page.locator('[data-slot="sidebar"]') + const sidebarColumn = sidebar.locator('..') + await expect.poll(async () => (await sidebarColumn.boundingBox())?.width).toBe(56) + await sidebarColumn.evaluate((el) => { + el.parentElement!.style.setProperty('--ds-transition-duration-slow', '100s') + }) + await page.setViewportSize({ width: 767, height: 844 }) + await page.locator('[data-sidebar-drawer]').waitFor() + const state = await sidebarColumn.evaluate(el => ({ + visibility: getComputedStyle(el).visibility, + transitions: el.getAnimations().filter(animation => animation instanceof CSSTransition) + .map(animation => animation.transitionProperty), + })) + expect(state).toEqual({ visibility: 'hidden', transitions: [] }) + await page.setViewportSize({ width: 768, height: 844 }) + await page.locator('[data-sidebar-drawer]').waitFor({ state: 'detached' }) + await assertCompactRail(sidebar) + await page.setViewportSize({ width: 767, height: 844 }) + await page.locator('[data-sidebar-drawer]').waitFor() + await page.getByRole('button', { name: 'Open sidebar', exact: true }).click() + const drawer = page.getByRole('dialog', { name: 'Navigation', exact: true }) + await drawer.waitFor() + expect(await sidebarColumn.evaluate(el => el.getAnimations().some(animation => + animation instanceof CSSTransition && animation.transitionProperty === 'transform'))).toBe(true) + await sidebarColumn.evaluate((el) => { + for (const animation of el.parentElement!.getAnimations({ subtree: true })) animation.finish() + }) + await drawer.getByRole('button', { name: 'Collapse sidebar', exact: true }).click() + expect(await sidebarColumn.evaluate(el => el.getAnimations().some(animation => + animation instanceof CSSTransition && animation.transitionProperty === 'transform'))).toBe(true) + await sidebarColumn.evaluate((el) => { + for (const animation of el.parentElement!.getAnimations({ subtree: true })) animation.finish() + }) + await page.setViewportSize({ width: 768, height: 844 }) + await page.locator('[data-sidebar-drawer]').waitFor({ state: 'detached' }) + await assertCompactRail(sidebar) + } finally { + await browser.close() + } + } finally { + await scaffold.close() + } +}) + +it('retains the closing slide when an expanded sidebar becomes a collapsed drawer', async () => { + const scaffold = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), + }) + try { + const browser = await chromium.launch() + try { + const page = await browser.newPage({ viewport: { width: 1024, height: 844 }, locale: 'en-US' }) + await page.goto(scaffold.authenticatedUrl) + await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).waitFor() + const sidebarColumn = page.locator('[data-slot="sidebar"]').locator('..') + await expect.poll(async () => (await sidebarColumn.boundingBox())?.width).toBe(280) + await sidebarColumn.evaluate((el) => { + el.parentElement!.style.setProperty('--ds-transition-duration-slow', '100s') + }) + await page.setViewportSize({ width: 767, height: 844 }) + await page.locator('[data-sidebar-drawer][data-sidebar-collapsed]').waitFor() + expect(await sidebarColumn.evaluate(el => el.getAnimations().some(animation => + animation instanceof CSSTransition && animation.transitionProperty === 'transform'))).toBe(true) + } finally { + await browser.close() + } + } finally { + await scaffold.close() + } +}) + +/** Sample the CSS fade at its midpoint without depending on the host's frame rate. */ +async function assertMaskFade(frame: Locator, from: number, to: number): Promise { + const sample = await frame.locator('[data-sidebar-mask]').evaluate((el) => { + const fade = el.getAnimations().find(animation => animation instanceof CSSTransition && animation.transitionProperty === 'opacity') + if (fade === undefined || !(fade.effect instanceof KeyframeEffect)) throw new Error('Drawer mask opacity transition is missing') + fade.pause() + fade.currentTime = Number(fade.effect.getComputedTiming().duration) / 2 + return { + endpoints: fade.effect.getKeyframes().map(keyframe => Number(keyframe.opacity)), + opacity: Number(getComputedStyle(el).opacity), + } + }) + expect(sample.endpoints).toEqual([from, to]) + expect(sample.opacity).toBeGreaterThan(0) + expect(sample.opacity).toBeLessThan(1) + await frame.evaluate((el) => { + for (const animation of el.getAnimations({ subtree: true })) { + if (animation instanceof CSSTransition) animation.finish() + } + el.style.removeProperty('--ds-transition-duration-slow') + }) +} + +/** Sample both width transitions on the same clock to detect detached sidebar controls. */ +async function assertFoldResize(toggle: Locator, from: number, to: number): Promise { + await expect.poll(() => toggle.evaluate((button, target) => { + const frame = button.closest('[data-animating]') + return frame !== null && parseFloat(frame.style.gridTemplateColumns) === target && frame.getAnimations().some(animation => + animation instanceof CSSTransition && animation.transitionProperty === 'grid-template-columns') + }, to)).toBe(true) + const sample = await toggle.evaluate((button) => { + const frame = button.closest('[data-animating]')! + const transitions = frame.getAnimations({ subtree: true }).filter((animation): animation is CSSTransition => + animation instanceof CSSTransition && ['grid-template-columns', 'width'].includes(animation.transitionProperty)) + const grid = transitions.find(animation => animation.transitionProperty === 'grid-template-columns') + if (grid === undefined) throw new Error('Fold width transition is missing') + const at = (progress: number) => { + for (const transition of transitions) { + transition.pause() + transition.currentTime = Number(transition.effect!.getComputedTiming().duration) * progress + } + const width = parseFloat(getComputedStyle(frame).gridTemplateColumns) + return { width, gap: frame.getBoundingClientRect().left + width - button.getBoundingClientRect().right } + } + const start = at(0) + const middle = at(0.5) + const end = at(1) + for (const transition of transitions) transition.finish() + frame.style.removeProperty('--ds-transition-duration-slow') + return { endpoints: [start.width, end.width], start, middle } + }) + expect(sample.endpoints).toEqual([from, to]) + expect(sample.middle.width).toBeGreaterThan(Math.min(from, to)) + expect(sample.middle.width).toBeLessThan(Math.max(from, to)) + expect(sample.middle.gap).toBeCloseTo(sample.start.gap, 1) +} + +it('overlays the conversation and closes after navigation without losing its sidebar content', async () => { + const scaffold = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), + }) + try { + const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd) + const seed = await readFile(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url), 'utf8') + const sessionId = await seedSession(scaffold, seed, 'session-c4421b66-252e-44c0-80ed-6e320abeba25') + await workspace.attachSession(sessionId) + await scaffold.ctx.sessionController.rename({ sessionId, title: 'Drawer navigation' }) + const browser = await chromium.launch() + try { + const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true, locale: 'en-US' }) + const console = watchConsole(page) + await page.goto(scaffold.authenticatedUrl) + const opener = page.getByRole('button', { name: 'Open sidebar', exact: true }) + await opener.waitFor() + const frame = page.locator('[data-sidebar-drawer]') + await expect.poll(() => frame.getAttribute('data-sidebar-collapsed')).toBe('true') + const sidebar = page.locator('[data-slot="sidebar"]') + const original = await sidebar.elementHandle() + const main = page.locator('[data-slot="main"]').locator('..') + const closed = await main.boundingBox() + // A long test-owned duration keeps the transition available until its + // animation clock is sampled and finished explicitly. + await frame.evaluate((el) => { el.style.setProperty('--ds-transition-duration-slow', '100s') }) + await opener.tap() + const drawer = page.getByRole('dialog', { name: 'Navigation', exact: true }) + await drawer.waitFor() + await assertMaskFade(frame, 0, 1) + await expect.poll(() => main.boundingBox()).toEqual(closed) + expect(closed?.width).toBe(390) + expect(await main.evaluate(el => el.closest('[inert]') !== null)).toBe(true) + expect(await page.locator('[data-side="sidebar"]').count()).toBe(0) + const workspaceRow = drawer.locator('[data-row-key^="workspace:"]').first() + const workspaceButtons = workspaceRow.getByRole('button', { includeHidden: true }) + expect(await workspaceRow.getAttribute('aria-expanded')).toBe('true') + expect(await workspaceButtons.count()).toBe(2) + for (const button of await workspaceButtons.all()) await button.waitFor() + await workspaceRow.tap() + expect(await workspaceRow.getAttribute('aria-expanded')).toBe('false') + for (const button of await workspaceButtons.all()) expect(await button.isVisible()).toBe(false) + expect(await workspaceRow.evaluate(el => el === document.activeElement)).toBe(true) + await workspaceRow.tap() + for (const button of await workspaceButtons.all()) await button.waitFor() + await drawer.getByRole('button', { name: 'Search sessions', exact: true }).tap() + const search = drawer.getByPlaceholder('Search session names') + expect(await search.evaluate(el => getComputedStyle(el).fontSize)).toBe('16px') + await drawer.getByRole('button', { name: 'Clear search', exact: true }).tap() + await frame.evaluate((el) => { el.style.setProperty('--ds-transition-duration-slow', '100s') }) + await page.getByRole('treeitem').filter({ has: page.getByText('Drawer navigation', { exact: true }) }).tap() + await assertMaskFade(frame, 1, 0) + await drawer.waitFor({ state: 'hidden' }) + await page.getByRole('navigation', { name: 'Session hierarchy' }).getByText('Drawer navigation', { exact: true }).waitFor() + await opener.click() + const selectedRow = drawer.getByRole('treeitem', { selected: true }) + const more = selectedRow.getByRole('button', { name: 'Session actions for Drawer navigation', exact: true }) + await more.waitFor() + expect(await selectedRow.getByRole('button', { name: 'Archive session', exact: true }).isVisible()).toBe(false) + expect(await selectedRow.getByRole('button', { name: 'Pin session', exact: true }).isVisible()).toBe(false) + expect(await selectedRow.getByRole('button').count()).toBe(1) + await more.tap() + const menu = page.getByRole('menu') + await menu.getByRole('menuitem', { name: 'Archive session', exact: true }).waitFor() + await menu.getByRole('menuitem', { name: 'Pin session', exact: true }).tap() + await menu.waitFor({ state: 'hidden' }) + await selectedRow.getByLabel('Pinned', { exact: true }).waitFor() + await more.tap() + await menu.getByRole('menuitem', { name: 'Unpin session', exact: true }).tap() + await menu.waitFor({ state: 'hidden' }) + await expect.poll(() => selectedRow.getByLabel('Pinned', { exact: true }).count()).toBe(0) + await drawer.getByRole('button', { name: 'Plugins', exact: true }).tap() + await drawer.waitFor({ state: 'hidden' }) + await page.locator('[data-plugin-panel]').waitFor() + await opener.click() + await drawer.waitFor() + await page.touchscreen.tap(370, 420) + await drawer.waitFor({ state: 'hidden' }) + await opener.click() + await page.keyboard.press('Escape') + await drawer.waitFor({ state: 'hidden' }) + expect(await opener.evaluate(el => el === document.activeElement)).toBe(true) + expect(await sidebar.evaluate((el, previous) => el === previous, original)).toBe(true) + await original?.dispose() + + await page.setViewportSize({ width: 768, height: 844 }) + await expect.poll(() => frame.count()).toBe(0) + await expect.poll(async () => Math.round((await main.boundingBox())!.width)).toBe(712) + await page.setViewportSize({ width: 767, height: 844 }) + await expect.poll(() => frame.count()).toBe(1) + + await page.emulateMedia({ reducedMotion: 'reduce' }) + await opener.tap() + const mask = frame.locator('[data-sidebar-mask]') + expect(await mask.evaluate(el => getComputedStyle(el).opacity)).toBe('1') + expect(await mask.evaluate(el => el.getAnimations().length)).toBe(0) + await drawer.getByRole('button', { name: 'Collapse sidebar', exact: true }).tap() + expect(await mask.evaluate(el => getComputedStyle(el).opacity)).toBe('0') + await page.emulateMedia({ reducedMotion: 'no-preference' }) + await expect.poll(async () => Math.round((await main.boundingBox())!.width)).toBe(767) + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(767) + await page.evaluate(() => { document.documentElement.dataset.platform = 'darwin' }) + await expect.poll(() => main.evaluate(el => getComputedStyle(el).paddingTop)).toBe('0px') + await page.evaluate(() => { delete document.documentElement.dataset.platform }) + await expect.poll(() => main.evaluate(el => getComputedStyle(el).paddingTop)).toBe('48px') + + await page.setViewportSize({ width: 390, height: 844 }) + await drawer.waitFor({ state: 'hidden' }) + await opener.tap() + await drawer.getByRole('button', { name: 'New session', exact: true }).last().tap() + await drawer.waitFor({ state: 'hidden' }) + const home = page.locator('[data-phase="hero"]') + await home.waitFor() + const editor = home.locator('[contenteditable="true"]') + await editor.tap() + const homeEditorBox = await editor.boundingBox() + // Chromium cannot raise a phone keyboard; simulate document panning + // before the visual viewport publishes the matching offset. + const restoreViewport = await page.evaluateHandle(() => { + const viewport = window.visualViewport! + const scrollY = Object.getOwnPropertyDescriptor(window, 'scrollY')! + Object.defineProperty(window, 'scrollY', { configurable: true, value: 80 }) + Object.defineProperties(viewport, { + height: { configurable: true, value: 360 }, + offsetTop: { configurable: true, value: 0 }, + }) + viewport.dispatchEvent(new Event('resize')) + viewport.dispatchEvent(new Event('scroll')) + window.dispatchEvent(new Event('scroll')) + return () => { + Object.defineProperty(window, 'scrollY', scrollY) + Reflect.deleteProperty(viewport, 'height') + Reflect.deleteProperty(viewport, 'offsetTop') + window.dispatchEvent(new Event('scroll')) + viewport.dispatchEvent(new Event('resize')) + } + }) + const root = page.locator('#root') + await expect.poll(() => root.boundingBox()).toEqual({ x: 0, y: 0, width: 390, height: 844 }) + expect(await editor.boundingBox()).toEqual(homeEditorBox) + await page.evaluate(() => { + Object.defineProperty(window.visualViewport!, 'offsetTop', { configurable: true, value: 80 }) + window.visualViewport!.dispatchEvent(new Event('scroll')) + }) + await expect.poll(() => root.boundingBox()).toEqual({ x: 0, y: 0, width: 390, height: 844 }) + await expect.poll(async () => (await main.boundingBox())?.height).toBe(844) + await opener.tap() + await expect.poll(async () => (await drawer.boundingBox())?.height).toBe(844) + const searchWithKeyboard = drawer.getByPlaceholder('Search session names') + await drawer.getByRole('button', { name: 'Search sessions', exact: true }).tap() + await searchWithKeyboard.tap() + await searchWithKeyboard.fill('retained search') + await drawer.getByRole('button', { name: 'Account menu', exact: true }).tap() + await page.getByRole('menuitem', { name: 'Settings', exact: true }).tap() + const settings = page.getByRole('dialog', { name: 'Settings', exact: true }) + await settings.waitFor() + await expect.poll(() => settings.boundingBox()).toEqual({ x: 0, y: 0, width: 390, height: 844 }) + expect(await settings.locator('..').evaluate(el => getComputedStyle(el).padding)).toBe('0px') + const closeSettings = settings.getByRole('button', { name: 'Close', exact: true }) + const closeBox = (await closeSettings.boundingBox())! + expect(closeBox.y).toBeGreaterThanOrEqual(0) + expect(closeBox.y + closeBox.height).toBeLessThanOrEqual(844) + await closeSettings.tap() + await settings.waitFor({ state: 'hidden' }) + await expect.poll(() => searchWithKeyboard.inputValue()).toBe('retained search') + await restoreViewport.evaluate((restore) => { restore() }) + await restoreViewport.dispose() + await expect.poll(() => root.boundingBox()).toEqual({ x: 0, y: 0, width: 390, height: 844 }) + await expect.poll(async () => (await drawer.boundingBox())?.height).toBe(844) + // Desktop Chromium supplies zero safe-area insets; exercise the shell's + // inset properties with the same geometry as a phone's protected edges. + await root.evaluate((el) => { + el.style.setProperty('--dsh-safe-area-top', '56px') + el.style.setProperty('--dsh-safe-area-bottom', '34px') + }) + await frame.evaluate((el) => { el.scrollLeft = 10 }) + expect(await frame.evaluate(el => el.scrollLeft)).toBe(0) + await expect.poll(() => mask.boundingBox()).toEqual({ x: 0, y: 0, width: 390, height: 844 }) + await expect.poll(() => drawer.evaluate((el) => { + const frame = el.closest('[data-sidebar-drawer]')! + let column = el + while (column.parentElement !== frame) column = column.parentElement! + const { y, height } = column.getBoundingClientRect() + return { y, height } + })).toEqual({ y: 0, height: 844 }) + await expect.poll(async () => { + const box = await drawer.boundingBox() + return { y: box?.y, height: box?.height } + }).toEqual({ y: 56, height: 754 }) + await root.evaluate((el) => { + el.style.removeProperty('--dsh-safe-area-top') + el.style.removeProperty('--dsh-safe-area-bottom') + }) + await expect.poll(async () => (await drawer.boundingBox())?.height).toBe(844) + await page.setViewportSize({ width: 844, height: 390 }) + await expect.poll(async () => (await root.boundingBox())?.height).toBe(390) + await expect.poll(() => frame.count()).toBe(0) + await page.setViewportSize({ width: 390, height: 844 }) + await expect.poll(async () => (await root.boundingBox())?.height).toBe(844) + await expect.poll(() => frame.count()).toBe(1) + + const posture = await page.context().newCDPSession(page) + try { + await page.setViewportSize({ width: 951, height: 669 }) + await expect.poll(() => frame.count()).toBe(0) + await posture.send('Emulation.setDevicePostureOverride', { posture: { type: 'folded' } }) + await opener.tap() + await expect.poll(() => main.boundingBox()).toEqual({ x: 475.5, y: 0, width: 475.5, height: 669 }) + expect(await page.locator('[data-side="sidebar"]').count()).toBe(0) + const foldSearch = page.getByPlaceholder('Search session names') + if (!(await foldSearch.isVisible())) await page.getByRole('button', { name: 'Search sessions', exact: true }).tap() + await foldSearch.fill('fold search') + const wideFrame = page.locator('[data-rightbar-collapsed]') + const collapseSidebar = page.getByRole('button', { name: 'Collapse sidebar', exact: true }) + await wideFrame.evaluate((el) => { el.style.setProperty('--ds-transition-duration-slow', '100s') }) + await posture.send('Emulation.clearDevicePostureOverride') + await assertFoldResize(collapseSidebar, 475.5, 280) + await expect.poll(() => main.boundingBox()).toEqual({ x: 280, y: 0, width: 671, height: 669 }) + expect(await page.locator('[data-side="sidebar"]').count()).toBe(1) + expect(await foldSearch.evaluate(el => el === document.activeElement)).toBe(true) + expect(await foldSearch.inputValue()).toBe('fold search') + await wideFrame.evaluate((el) => { el.style.setProperty('--ds-transition-duration-slow', '100s') }) + await posture.send('Emulation.setDevicePostureOverride', { posture: { type: 'folded' } }) + await assertFoldResize(collapseSidebar, 280, 475.5) + await expect.poll(async () => (await main.boundingBox())?.x).toBe(475.5) + expect(await foldSearch.evaluate(el => el === document.activeElement)).toBe(true) + await page.emulateMedia({ reducedMotion: 'reduce' }) + await posture.send('Emulation.clearDevicePostureOverride') + await expect.poll(async () => (await main.boundingBox())?.x).toBe(280) + expect(await wideFrame.evaluate(el => el.getAnimations({ subtree: true }).length)).toBe(0) + await posture.send('Emulation.setDevicePostureOverride', { posture: { type: 'folded' } }) + await expect.poll(async () => (await main.boundingBox())?.x).toBe(475.5) + await page.emulateMedia({ reducedMotion: 'no-preference' }) + await page.setViewportSize({ width: 669, height: 951 }) + await expect.poll(() => frame.getAttribute('data-sidebar-collapsed')).toBe('true') + await drawer.waitFor({ state: 'hidden' }) + await opener.tap() + await expect.poll(async () => (await drawer.boundingBox())?.width).toBe(280) + expect(await main.boundingBox()).toEqual({ x: 0, y: 0, width: 669, height: 951 }) + } finally { + await posture.detach() + } + + const pointerPage = await browser.newPage({ viewport: { width: 390, height: 844 }, locale: 'en-US' }) + try { + await pointerPage.goto(scaffold.authenticatedUrl) + await pointerPage.getByRole('button', { name: 'Open sidebar', exact: true }).click() + expect(await pointerPage.getByPlaceholder('Search session names').evaluate(el => getComputedStyle(el).fontSize)).toBe('13px') + const pointerWorkspace = pointerPage.locator('[data-row-key^="workspace:"]').first() + const pointerWorkspaceButtons = pointerWorkspace.getByRole('button', { includeHidden: true }) + await pointerWorkspace.click() + expect(await pointerWorkspace.getAttribute('aria-expanded')).toBe('false') + const collapseSidebar = pointerPage.getByRole('button', { name: 'Collapse sidebar', exact: true }) + await collapseSidebar.focus() + await collapseSidebar.hover() + for (const button of await pointerWorkspaceButtons.all()) expect(await button.isVisible()).toBe(false) + await pointerWorkspace.hover() + for (const button of await pointerWorkspaceButtons.all()) await button.waitFor() + await pointerWorkspace.click() + for (const label of ['Collapse sidebar', 'View options']) { + await pointerPage.getByRole('button', { name: label, exact: true }).hover() + const tooltip = pointerPage.getByRole('tooltip').filter({ hasText: label }) + await tooltip.waitFor() + expect(await tooltip.evaluate(el => el.closest('[data-slot="sidebar"]'))).toBeNull() + const box = (await tooltip.boundingBox())! + expect(box.x).toBeGreaterThanOrEqual(12) + expect(box.x + box.width).toBeLessThanOrEqual(378) + } + const pointerRow = pointerPage.getByRole('treeitem').filter({ has: pointerPage.getByText('Drawer navigation', { exact: true }) }) + await pointerRow.click() + await pointerPage.getByRole('dialog', { name: 'Navigation', exact: true }).waitFor({ state: 'hidden' }) + await pointerPage.getByRole('button', { name: 'Open sidebar', exact: true }).click() + await pointerPage.getByRole('button', { name: 'Collapse sidebar', exact: true }).hover() + await expect.poll(() => pointerRow.getByRole('button', { name: 'Session actions for Drawer navigation', exact: true }).isVisible()).toBe(false) + await pointerRow.hover() + for (const label of ['Session actions for Drawer navigation', 'Archive session', 'Pin session']) { + await pointerRow.getByRole('button', { name: label, exact: true }).waitFor() + } + await pointerPage.getByRole('button', { name: 'Collapse sidebar', exact: true }).click() + const pointerDrawer = pointerPage.getByRole('dialog', { name: 'Navigation', exact: true }) + await pointerDrawer.waitFor({ state: 'hidden' }) + await pointerPage.setViewportSize({ width: 767, height: 844 }) + await pointerPage.getByRole('button', { name: 'Open sidebar', exact: true }).press('Enter') + await pointerDrawer.waitFor() + expect(await pointerDrawer.evaluate(el => el.contains(document.activeElement))).toBe(true) + await pointerPage.setViewportSize({ width: 768, height: 844 }) + await pointerPage.locator('[data-sidebar-drawer]').waitFor({ state: 'detached' }) + const pointerSidebar = pointerPage.locator('[data-slot="sidebar"]') + await expect.poll(() => pointerSidebar.getByRole('button', { name: 'New session', exact: true }).count()).toBe(1) + const railToggle = pointerSidebar.getByRole('button', { name: 'Open sidebar', exact: true }) + expect(await railToggle.evaluate(el => el === document.activeElement)).toBe(true) + } finally { + await pointerPage.close() + } + expect(console.pageErrors).toEqual([]) + expect(console.warnings).toEqual([]) + const result = [ + '390px: closed sidebar has no rail; main width 390px', + 'open: main geometry retained; background inert; no resize handle', + 'close: Session navigation, Plugins navigation, mask and Escape', + 'retained: sidebar DOM and opener focus', + '768px: 56px rail; main width 712px; drawer focus transfers to the rail toggle', + 'breakpoint motion: collapsed rail hides immediately and returns compact without crossfades; expanded sidebar and explicit toggles retain their slides', + '767px: hidden drawer; main width 767px; no page overflow', + 'global panels: browser top inset 48px; macOS keeps its existing clearance', + 'tooltips: sidebar and workspace controls escape the drawer and fit the viewport', + 'search input: 16px on touch; 13px with a fine pointer', + 'simulated keyboard: homepage, drawer and Settings retain 844px layout height without following the 360px visual viewport or adding safe-area padding to Settings', + 'keyboard close: layout height stays 844px; sidebar search retained; Settings close remains reachable', + 'safe areas: drawer backing and mask cover 844px; content retains 56px top and 34px bottom insets', + 'rotation: 844x390 then 390x844 restores shell height and sidebar breakpoint', + 'folded landscape: 951px frame splits at 475.5px without a resize handle; unfolding restores 280px and search focus; portrait retains the drawer', + 'fold transitions: sidebar controls follow the divider in both directions; reduced motion changes width without animation', + 'drawer mask: opacity transitions from 0 to 1 on open and 1 to 0 on close', + 'reduced motion: drawer and mask settle without transitions', + 'touch selected Session: More remains visible; archive and pin stay in its menu; pin marker retained', + 'touch Workspace: actions appear only while expanded, including after folding the focused row', + 'fine pointer: folded Workspace actions remain available on hover', + 'fine pointer: selected Session actions stay hidden until hover, including at 390px', + ].join('\n') + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-drawer.expected.md', import.meta.url)), result, webSnapshotMode()) + } finally { + await browser.close() + } + } finally { + await scaffold.close() + } +}) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 33cfdeb374..42b5260536 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -334,6 +334,13 @@ export function conversationContextKey(kind: string, id: string): string { * @param locale - current UI language. */ export async function openSettings(page: Page, locale: 'en' | 'zh'): Promise { - await page.getByRole('button', { name: locale === 'zh' ? '账号菜单' : 'Account menu', exact: true }).click() + const trigger = page.getByRole('button', { + name: locale === 'zh' ? '账号菜单' : 'Account menu', exact: true, includeHidden: true, + }) + await trigger.waitFor({ state: 'attached' }) + if (!await trigger.isVisible()) { + await page.getByRole('button', { name: locale === 'zh' ? '打开侧边栏' : 'Open sidebar', exact: true }).click() + } + await trigger.click() await page.getByRole('menuitem', { name: locale === 'zh' ? '设置' : 'Settings', exact: true }).click() } diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index db02a233ec..40221e6122 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -22,6 +22,7 @@ // this client-registered project must not also hold them (one program // cannot see both sides of the cordis Context merges). "exclude": [ + "tests/sidebar-drawer.e2e.ts", "tests/collapse-timing.e2e.ts", "tests/notice-wake-tail.e2e.ts", "tests/fold-blank-matrix.e2e.ts", diff --git a/packages/client/ui-layout/README.i18n.yaml b/packages/client/ui-layout/README.i18n.yaml index 4ad7481ef7..159ff8de14 100644 --- a/packages/client/ui-layout/README.i18n.yaml +++ b/packages/client/ui-layout/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-layout/use-this-package: - en: cc6e9021ba3001bc - zh: 97444857762b4e8e + en: df0222b3e0ae1df8 + zh: 77d2968b75f166a2 /deepseek-ai-dsh-client-ui-layout/use-this-package/window-chrome-seat: en: 7eac53b38d0777fe zh: 7f9422dff671ba50 @@ -36,8 +36,8 @@ en: ca75c51c89c2c9b0 zh: de54f3467ef3b9be /deepseek-ai-dsh-client-ui-layout/known-limitations-and-deferred-work: - en: da92b97ac60d989f - zh: 5816b861728f1a04 + en: 834ae31817179718 + zh: d2cff416fd0f643a /deepseek-ai-dsh-client-ui-layout/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index 43c61b89d1..8d9b8cd3e6 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -27,7 +27,11 @@ This package provides the Web GUI's three-column AppFrame, edge-column widths, a Desktop Mod+B toggles the left sidebar through the same layout action used by its controls. The shortcut command is unavailable behind modal dialogs and yields to terminal input. -The root slot composes the sidebar, main content, and right column. The sidebar spans 264–420px, defaults to 280px, and retains a 56px rail when collapsed; below 1024px it collapses automatically, and opening the right panel collapses a manually expanded sidebar. The right panel first opens at 45% of the viewport, then retains the user's pixel preference, capped at 70%. To protect 400px for the center, the frame first reduces the right panel to 300px, then reports insufficient room so its occupant closes it, and only then compresses the center further. Dragging has no transition delay; the right handle is absent while closed or fullscreen. +The root slot composes the sidebar, main content, and right column. The sidebar spans 264–420px, defaults to 280px, and retains a 56px rail when collapsed at widths of 768px and above. Below 768px, without a native Windows caption, it uses a modal drawer without reserving a column; the header retains the reopen controls, and navigation closes the drawer. Below 1024px it collapses automatically, and opening the right panel collapses a manually expanded sidebar. The right panel first opens at 45% of the viewport, then retains the user's pixel preference, capped at 70%. To protect 400px for the center, the frame first reduces the right panel to 300px, then reports insufficient room so its occupant closes it, and only then compresses the center further. Dragging has no transition delay; the right handle is absent while closed or fullscreen. + +When the browser reports a folded device in landscape and the frame is at least 768px wide, the expanded sidebar occupies half the frame, aligning its divider with the center fold. Its resize handle is hidden; unfolding restores the saved width. Fold changes at a stable viewport width use the existing track transition; reduced motion disables it. Browsers without the device-posture media feature keep the ordinary layout. + +The drawer mask fades with the panel’s transition duration and easing on both open and close; reduced motion disables both transitions. Entering drawer mode from an already collapsed sidebar hides the drawer immediately; an expanded sidebar keeps its closing slide. The drawer has no edge divider, and its frame does not scroll. The frame fills the [shell’s layout viewport](../web/README.md#viewport-sizing), retaining its height while the keyboard opens. Drawer backing and mask cover its full height, including the top and bottom safe areas and `shell.bottom`; frame and drawer content retain the safe-area insets once. Global panels occupy the root-scoped `main` keyed slot; `conversation` is the reserved key for the Conversation. `ctx.layout.selectPanel(id)` selects a registered panel, and `null` selects the Conversation without changing the current Session. The Plugins page contributes one of the shipped global panels. @@ -92,7 +96,7 @@ None; this package neither assembles nor sends a provider request. These limits define the current layout behavior. They are current package constraints, not a general window-manager comparison or a task backlog. - **Panel geometry is transient** — reload restores the sidebar default and the right panel hidden; each dragged width is one frame-wide preference, not a per-Session fact. -- **Extremely narrow windows** — after the right panel closes, the center may still fall below 400px; the left 56px rail remains. +- **Extremely narrow windows** — after the right panel closes, the center may still fall below 400px; browser drawers reserve no sidebar width below 768px. - **Track and panel travel on one shared curve only while animating** — during a discrete open/close the frame sets `data-animating` and its track transition and the occupant's slide read the same duration and easing variables; an occupant that used its own would detach the panel's edge from the conversation's while squeezing. Drags and instant presentation switches run transition-free, so the curve does not cover them. - **No scroll anchoring during squeeze reflow** — layout changes may move the reader's viewport. diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md index 9b426dcdef..211d590846 100644 --- a/packages/client/ui-layout/README.zh.md +++ b/packages/client/ui-layout/README.zh.md @@ -27,7 +27,11 @@ kind: "package-reference" 桌面 Mod+B 通过与控件共用的布局操作切换左侧栏。模态弹窗打开时此快捷键不可用;终端输入优先。 -本插件在 root slot 中组合侧边栏、主内容和右栏。侧边栏宽度为 264~420px,默认为 280px,收起后保留 56px 控制栏;窗口宽度低于 1024px 时自动收起,打开右侧面板也会收起手动展开的侧边栏。右侧面板首次打开时使用视口宽度的 45%,之后保留用户的像素宽度偏好,上限为 70%。为给中栏保留 400px,框架先将右侧面板缩减至 300px,再报告空间不足,使占用方将其关闭,最后才进一步压缩中栏。拖动没有过渡延迟;右侧手柄在关闭或全屏时不显示。 +本插件在 root slot 中组合侧边栏、主内容和右栏。侧边栏宽度为 264~420px,默认为 280px,在宽度不低于 768px 时,收起后保留 56px 控制栏。低于 768px 且没有原生 Windows 顶栏时,采用模态抽屉,不占据列宽;标题区域保留重新打开的控件,导航后关闭抽屉。窗口宽度低于 1024px 时自动收起,打开右侧面板也会收起手动展开的侧边栏。右侧面板首次打开时使用视口宽度的 45%,之后保留用户的像素宽度偏好,上限为 70%。为给中栏保留 400px,框架先将右侧面板缩减至 300px,再报告空间不足,使占用方将其关闭,最后才进一步压缩中栏。拖动没有过渡延迟;右侧手柄在关闭或全屏时不显示。 + +当浏览器报告设备处于横屏半折叠状态,且框架宽度不低于 768px 时,展开的侧边栏占据一半宽度,分隔线与屏幕中央的折痕对齐。此时隐藏拖拽手柄;完全展开后恢复保存的宽度。视口宽度不变时,折叠状态切换复用现有的列宽过渡;减少动态效果时禁用该过渡。不支持 device-posture 媒体特性的浏览器继续使用常规布局。 + +抽屉遮罩使用与面板相同的时长和缓动,在打开与关闭时渐变透明度;减少动态效果时两者均不播放动画。从已收起的侧栏切换到抽屉布局时,抽屉立即隐藏;已展开的侧栏保留收起动画。抽屉边缘不绘制分隔线,外层框架不滚动。框架铺满[外壳的布局视口](../web/README.zh.md#viewport-sizing),在键盘抬起时保留原有高度。抽屉背景和遮罩覆盖完整高度,包括上下安全区与 `shell.bottom`;框架和抽屉的内容各保留一次安全区边距。 全局面板占据 root 作用域的 `main` keyed slot;`conversation` 是为会话界面保留的 key。`ctx.layout.selectPanel(id)` 选中已注册面板,`null` 则选中会话界面,但不改变当前会话。Plugins 页面是默认组合中的一个全局面板。 @@ -92,7 +96,7 @@ Windows Electron 的 `data-windows-titlebar` 标记在所有列上方预留顶 这些限制界定了当前布局行为。它们是当前包约束,不是通用窗口管理器对比或任务积压。 - **面板几何是瞬时状态**——重新加载会恢复侧栏默认值并隐藏右侧面板;拖动设置的宽度是整个框架共用的一份偏好,而非每个会话各自的属性。 -- **极窄窗口**——右侧面板关闭后,中栏仍可能小于 400px;左侧 56px 控制栏仍会保留。 +- **极窄窗口**——右侧面板关闭后,中栏仍可能小于 400px;低于 768px 时,浏览器抽屉不占据侧栏宽度。 - **轨道与面板仅在动画期间沿同一条曲线运动**——离散开合时框架设置 `data-animating`,其轨道过渡和占用方的滑入读取同一组时长与缓动变量;占用方若自用一套,挤压时面板边缘就会与会话界面的边缘脱开。拖拽和即时呈现切换不带过渡,因此该曲线不覆盖它们。 - **挤压重排期间无滚动锚定**——布局变化可能移动读者的视口。 diff --git a/packages/client/ui-layout/src/client/AppFrame.module.css b/packages/client/ui-layout/src/client/AppFrame.module.css index 5b71ebecfd..bfe09fc483 100644 --- a/packages/client/ui-layout/src/client/AppFrame.module.css +++ b/packages/client/ui-layout/src/client/AppFrame.module.css @@ -3,11 +3,13 @@ display: grid; grid-template-rows: minmax(0, 1fr) auto; height: 100%; + box-sizing: border-box; + padding-block: var(--dsh-safe-area-top, 0px) var(--dsh-safe-area-bottom, 0px); overflow: hidden; background: var(--dsw-alias-bg-base); } -/* Only a discrete collapse/expand toggle eases the tracks (the TSX holds +/* Only discrete collapse/expand and fold changes ease the tracks (the TSX holds data-animating across the toggle on the deepsuite sider curve). Steady-state tracks move only with the viewport, and those updates must land instantly: an eased track chases the live window edge and the centre column visibly @@ -56,6 +58,8 @@ } .sidebarCol { + grid-column: 1; + grid-row: 1; min-width: 0; overflow: hidden; background: var(--dsw-specific-sidebar-fill); @@ -63,12 +67,22 @@ } .centerCol { + grid-column: 2; + grid-row: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; } +.mainPanel { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; +} + /* macOS desktop: the frame goes transparent so window vibrancy reaches the sidebar column, which keeps only a translucent tint; the centre paints the base background itself to stay opaque. The sidebar/centre separator moves @@ -265,11 +279,92 @@ * where it is. The occupant draws its own left border. */ .rightbarCol { + grid-column: 3; + grid-row: 1; position: relative; min-width: 0; overflow: visible; } +.frame[data-sidebar-drawer] { + --dsh-frame-leading-clearance: 84px; + overflow: clip; +} + +.frame[data-sidebar-drawer] .mainPanel[data-global-panel] { + padding-top: max(0px, calc(48px - var(--dsh-frame-top-clearance, 0px))); +} + +.frame[data-sidebar-drawer] .sidebarCol { + position: absolute; + grid-area: auto; + inset: 0 auto 0 0; + z-index: 31; + box-sizing: border-box; + border-right: none; + padding-block: var(--dsh-safe-area-top, 0px) var(--dsh-safe-area-bottom, 0px); + transform: translateX(0); + visibility: visible; + transition: transform var(--ds-transition-duration-slow) var(--ds-ease-in-out); +} + +.frame[data-sidebar-drawer][data-sidebar-collapsed] .sidebarCol { + transform: translateX(-100%); + visibility: hidden; + transition: + transform var(--ds-transition-duration-slow) var(--ds-ease-in-out), + visibility 0s linear var(--ds-transition-duration-slow); +} + +.frame[data-sidebar-drawer][data-sidebar-instant] .sidebarCol { + transition: none; +} + +.drawerMask { + position: absolute; + inset: var(--dsh-frame-chrome-top, 0px) 0 0; + z-index: 30; + background: var(--dsw-alias-bg-mask-1); + backdrop-filter: var(--dsw-mask-blur); + opacity: 0; + pointer-events: none; + transition: opacity var(--ds-transition-duration-slow) var(--ds-ease-in-out); +} + +.frame[data-sidebar-drawer]:not([data-sidebar-collapsed]) .drawerMask { + opacity: 1; + pointer-events: auto; +} + +.frame[data-sidebar-drawer] .leadingSeat { + top: calc(11px + var(--dsh-safe-area-top, 0px)); + left: 12px; +} + +:global([data-platform='darwin']) .frame[data-sidebar-drawer] { + --dsh-frame-leading-clearance: 160px; +} + +:global([data-platform='darwin']) .frame[data-sidebar-drawer] .leadingSeat { + left: 88px; +} + +:global([data-platform='darwin'][data-fullscreen]) .frame[data-sidebar-drawer] { + --dsh-frame-leading-clearance: 84px; +} + +:global([data-platform='darwin'][data-fullscreen]) .frame[data-sidebar-drawer] .leadingSeat { + left: 12px; +} + +@media (prefers-reduced-motion: reduce) { + .frame[data-sidebar-drawer] .sidebarCol, + .frame[data-sidebar-drawer][data-sidebar-collapsed] .sidebarCol, + .drawerMask { + transition: none; + } +} + .bottomRow { grid-column: 1 / -1; min-width: 0; diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx index 5614947447..5d0e310b59 100644 --- a/packages/client/ui-layout/src/client/AppFrame.tsx +++ b/packages/client/ui-layout/src/client/AppFrame.tsx @@ -19,7 +19,7 @@ import type { ReactNode } from 'react' import type { PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, } from '@deepseek-ai/dsh-client-ui-slots' -import { CENTER_MIN, clampWidth, computeColumns, RIGHTBAR_DEFAULT_RATIO, RIGHTBAR_MAX_RATIO, RIGHTBAR_MIN, SIDEBAR_AUTO_COLLAPSE, SIDEBAR_COLLAPSED, SIDEBAR_DEFAULT } from './columns.ts' +import { CENTER_MIN, clampWidth, computeColumns, RIGHTBAR_DEFAULT_RATIO, RIGHTBAR_MAX_RATIO, RIGHTBAR_MIN, SIDEBAR_AUTO_COLLAPSE, SIDEBAR_COLLAPSED, SIDEBAR_DEFAULT, SIDEBAR_DRAWER_BREAKPOINT } from './columns.ts' import { DocumentTitle } from './DocumentTitle.tsx' import type { createLayoutStore } from './stores.ts' import css from './AppFrame.module.css' @@ -32,14 +32,16 @@ export type AppFrameProps = & PropsLocale<'common'> /** Center column grid item (session-body building block). */ -function CenterColumn(props: { children?: ReactNode }) { - return
{props.children}
+function CenterColumn(props: { children?: ReactNode; inert: boolean }) { + return
{props.children}
} /** Subscribe to the main key without subscribing the column frame to each panel id. */ function MainPanel({ usePanelInfo, renderSlot }: Pick, 'usePanelInfo'> & PropsRenderSlots<'main'>) { const panelId = usePanelInfo(info => info.activePanelId) - return renderSlot('main', {}, { entryKey: panelId ?? 'conversation' }) + return
+ {renderSlot('main', {}, { entryKey: panelId ?? 'conversation' })} +
} /** @@ -47,8 +49,8 @@ function MainPanel({ usePanelInfo, renderSlot }: Pick, 'use * occupant's panel is positioned against the column's right edge, which never * moves, so it can hang over the centre when there is no track. */ -function RightbarColumn(props: { children?: ReactNode }) { - return
{props.children}
+function RightbarColumn(props: { children?: ReactNode; inert: boolean }) { + return
{props.children}
} /** @@ -129,6 +131,15 @@ export function AppFrame({ const layoutInfo = useStore(state => state.layoutInfo) const frameRef = useRef(null) const viewport = layoutInfo.viewportWidth + const [foldedLandscape, setFoldedLandscape] = useState(false) + + useLayoutEffect(() => { + const query = window.matchMedia('(device-posture: folded) and (orientation: landscape)') + const update = () => { setFoldedLandscape(query.matches) } + update() + query.addEventListener('change', update) + return () => { query.removeEventListener('change', update) } + }, []) // Track the frame's own box (not the window): rAF-throttled ResizeObserver. useLayoutEffect(() => { @@ -158,7 +169,16 @@ export function AppFrame({ }, [actions]) const narrow = viewport < SIDEBAR_AUTO_COLLAPSE + const windowsTitlebar = document.documentElement.hasAttribute('data-windows-titlebar') + const drawer = viewport < SIDEBAR_DRAWER_BREAKPOINT && !windowsTitlebar + const centeredSidebar = foldedLandscape && viewport >= SIDEBAR_DRAWER_BREAKPOINT const sidebarCollapsed = narrow ? !layoutInfo.narrowExpanded : layoutInfo.sidebar === 0 + const drawerOpen = drawer && !sidebarCollapsed + // Entering drawer mode from a collapsed rail hides the backing immediately. + const [sidebarTransition, setSidebarTransition] = useState({ drawer, collapsed: sidebarCollapsed, instant: true }) + if (sidebarTransition.drawer !== drawer || sidebarTransition.collapsed !== sidebarCollapsed) { + setSidebarTransition({ drawer, collapsed: sidebarCollapsed, instant: drawer && sidebarCollapsed && sidebarTransition.collapsed }) + } const sidebarPreference = sidebarCollapsed ? 0 : layoutInfo.sidebar === 0 ? SIDEBAR_DEFAULT : layoutInfo.sidebar @@ -166,12 +186,15 @@ export function AppFrame({ // Desktop reopen controls occupy the frame's shell.leading seat (macOS) or // the Windows caption row; neither platform keeps an icon rail. const darwin = document.documentElement.dataset.platform === 'darwin' - const collapsedWidth = darwin - || document.documentElement.hasAttribute('data-windows-titlebar') ? 0 : SIDEBAR_COLLAPSED + const collapsedWidth = drawer || darwin || windowsTitlebar ? 0 : SIDEBAR_COLLAPSED // Opening on a narrow frame collapses the left sidebar. Eligibility must // include that space before the occupant's first shown report arrives. - const normal = computeColumns(viewport, !layoutInfo.rightbarShown && narrow ? 0 : sidebarPreference, rightbarPreference, collapsedWidth) - const cols = computeColumns(viewport, sidebarPreference, layoutInfo.rightbarTrack ? rightbarPreference : 0, collapsedWidth) + const normal = computeColumns( + viewport, !layoutInfo.rightbarShown && narrow ? 0 : sidebarPreference, rightbarPreference, collapsedWidth, centeredSidebar, + ) + const cols = computeColumns( + viewport, drawer ? 0 : sidebarPreference, layoutInfo.rightbarTrack ? rightbarPreference : 0, collapsedWidth, centeredSidebar, + ) const colsRef = useRef(cols) colsRef.current = cols const rightbarWidth = useRef(normal.rightbar) @@ -185,8 +208,8 @@ export function AppFrame({ // Track-level transitions pause for the whole gesture: eased tracks would // detach the column edge from the pointer (AppFrame.module.css). const [dragging, setDragging] = useState(false) - // Track easing is scoped to a discrete open/close toggle: data-animating - // goes up when the collapse state or the rightbar track flips and comes down + // Track easing is scoped to discrete open/close and fold changes: data-animating + // goes up when the sidebar presentation or the rightbar track flips and comes down // at transition end (timeout as the reduced-motion/covered-frame fallback). // Steady-state viewport updates stay instant (AppFrame.module.css), and so // does a toggle arriving together with a viewport change — that is the @@ -194,15 +217,21 @@ export function AppFrame({ // chase the live window edge. The counter restarts the settle window when a // re-toggle interrupts a running transition. const [animating, setAnimating] = useState(0) - const trackToggle = `${sidebarCollapsed}:${layoutInfo.rightbarTrack}` + const trackToggle = `${sidebarCollapsed}:${layoutInfo.rightbarTrack}:${centeredSidebar}` const previousToggle = useRef(trackToggle) const previousViewport = useRef(viewport) + // Arm the transition before child layout effects measure the new column widths. + const animateTracks = previousViewport.current === viewport && (animating > 0 || previousToggle.current !== trackToggle) useLayoutEffect(() => { const viewportChanged = previousViewport.current !== viewport previousViewport.current = viewport + if (viewportChanged) { + previousToggle.current = trackToggle + setAnimating(0) + return + } if (previousToggle.current === trackToggle) return previousToggle.current = trackToggle - if (viewportChanged) return setAnimating(token => token + 1) }, [trackToggle, viewport]) useEffect(() => { @@ -239,10 +268,14 @@ export function AppFrame({ // whole and was corrected two frames later — visible jitter. cols keeps only // the discrete decisions (track present, collapse state) and the drag base. const rightbarMax = cols.rightbar === 0 ? 0 : clampWidth(rightbarPreference, RIGHTBAR_MIN, viewport * RIGHTBAR_MAX_RATIO) + const sidebarWidth = drawer + ? Math.min(layoutInfo.sidebar || SIDEBAR_DEFAULT, Math.max(0, viewport - 48)) + : cols.sidebar const sidebar = useMemo(() => renderSlot('sidebar', { collapsed: sidebarCollapsed, - width: cols.sidebar, - }), [renderSlot, sidebarCollapsed, cols.sidebar]) + width: sidebarWidth, + drawer, + }), [renderSlot, sidebarCollapsed, sidebarWidth, drawer]) const main = useMemo(() => ( ), [usePanelInfo, renderSlot]) @@ -253,52 +286,58 @@ export function AppFrame({ // row (ui-sidebar). AppFrame.module.css publishes the matching // --dsh-frame-leading-clearance under the same collapsed condition. const leading = useMemo(() => renderSlot('shell.leading', {}), [renderSlot]) - const leadingMounted = darwin && sidebarCollapsed + const leadingMounted = drawer || darwin && sidebarCollapsed return (
0 || undefined} + data-animating={animateTracks || undefined} > -
+ {drawer &&