Merge remote-tracking branch 'origin/worktree-agent-scope-design' into codex/package-readme-limitations-audit-20260712
# Conflicts: # packages/core/scope/README.md # packages/session-persistence/session-persistence-jsonl/README.md # packages/session-persistence/session-persistence-sqlite/README.md # packages/subagent/subagent-acp/README.md # packages/subagent/subagent-fork/README.md # packages/subagent/subagent-inprocess/README.md # packages/subagent/subagent/README.md # packages/subagent/tool-subagent/README.md # packages/support/invariants/README.md # packages/support/subagent-mock/README.md # packages/workflow/workflow-workerthread/README.md # packages/workflow/workflow/README.md
This commit is contained in:
@@ -4,25 +4,23 @@ Scoped-context registration primitive. `createScope(ctx, key)` mints a Cordis co
|
||||
|
||||
## Public API
|
||||
|
||||
- `createScope(ctx: Context, key: ScopeKey): Scope` Mint a scope under `ctx`'s fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). Throws on a primitive key, or when `ctx`'s fiber is disposing (`INACTIVE_EFFECT`).
|
||||
- `createScope(ctx: Context, key: ScopeKey): Scope` Mint a scope under `ctx`'s fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). The typed, same-process key is trusted; an inactive minting context still fails through Cordis (`INACTIVE_EFFECT`).
|
||||
- `Scope.ctx` The tagged context: registrations through it are scope-visible AND scope-lifetime. Derived contexts (an `extend`, a fiber mounted under it) inherit the tag; nested scopes shadow (nearest tag wins).
|
||||
- `Scope.rawDispose` The EXACT Cordis disposer for the backing fiber — a composite (generator) effect yields THIS function to nest the scope's teardown at that yield position (Cordis dedupes nested effects by function identity; yielding a wrapper leaves the scope disposing as a concurrent sibling).
|
||||
- `Scope.dispose(): Promise<void>` Idempotent, shared quiescence boundary for every registration made through the scope. Racing/repeat calls await the same teardown, including when `rawDispose` invoked the underlying single-shot Cordis disposer first.
|
||||
- `scopeOf(ctx: Context): ScopeKey | undefined` The tag a context (or any context derived from it) carries; `undefined` = context-global.
|
||||
- `scopeTarget(base: T, key?: ScopeKey): Scoped<T>` Build the dispatch `thisArg` for a scope-filtered event: composes `base`'s own `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). Listener `this` stays `base`-shaped. `{ global: true }` listeners bypass filtering (Cordis semantics).
|
||||
- `Scoped<T>` The compile-time carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error.
|
||||
- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped<T>` Build the opaque dispatch `thisArg` for a scope-filtered event. It composes `base`'s existing `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). The carrier contains routing state only; the real subject is carried by the event arguments. `{ global: true }` listeners bypass filtering (Cordis semantics).
|
||||
- `Scoped<T>` The compile-time opaque carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error. The type parameter records the subject type but does not expose its properties.
|
||||
- `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name.
|
||||
- `scopeHost(ctx, services)` Test/tooling host whose shared `dispose()` waits for both the host fiber and every minted scope, including a child already tearing down through `rawDispose`.
|
||||
|
||||
## Design contract
|
||||
|
||||
Ownership and visibility derive from ONE fact — which context a registration went through. An explicit `{ scope }` registration parameter could express "visible to X, disposed with Y", which is almost always a bug; the scoped context makes it unrepresentable. Rationale and alternatives: [the agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md).
|
||||
Ownership and visibility derive from ONE fact — which context a registration went through. An explicit `{ scope }` registration parameter could express "visible to X, disposed with Y", which is almost always a bug; the scoped context makes it unrepresentable. This is trusted registration and listener routing, not sandboxing or an authority hierarchy: a same-process plugin is not confined, and a child scope need not be a subset of its parent's view. Rationale, alternatives, and the security non-goal: [the agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals).
|
||||
|
||||
Handing out a scoped context hands out the minting plugin's service-resolution capability (resolution walks the minting fiber's dependency chain, not the holder's) — mint scopes from a plugin whose `inject` surface is what scope holders should reach.
|
||||
Handing out a scoped context hands out the minting plugin's service-resolution surface (resolution walks the minting fiber's dependency chain, not the holder's) — mint it from the plugin whose dependencies the scoped registrations need to resolve.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Only scope-aware surfaces isolate state** — registries must file by `scopeOf()` and events must dispatch through `scopeTarget()`; an arbitrary Cordis service remains context-global merely because it is called through a scoped context.
|
||||
- **A context carries one nearest scope key** — nested scopes shadow their parent's tag rather than forming hierarchical or multi-membership policy sets.
|
||||
- **Dispatch carriers preserve behavior, not identity** — listener `this` can call the subject's methods, but `this !== subject` and a method read returns a newly bound function.
|
||||
- **Service reachability comes from the scope minter** — handing out `Scope.ctx` also hands out the minting plugin's injected service surface, so a broader minter cannot later be narrowed by the holder.
|
||||
|
||||
@@ -1,23 +1,6 @@
|
||||
/**
|
||||
* Scoped-context primitive: mint a Cordis context that TAGS everything
|
||||
* registered through it with an opaque {@link ScopeKey}, and dispatch events so
|
||||
* listeners registered through such a context fire only for their key's
|
||||
* subject. Scope-aware registries (`ctx.tools`, `ctx.systemPrompt`) read the
|
||||
* tag via {@link scopeOf} to file a registration in the right layer; the agent
|
||||
* loop is the one scope MINTER today (one scope per live agent, key = the
|
||||
* `Agent` object — see `Agent.ctx` in `@deepseek-ai/dsh-agent`), but the
|
||||
* mechanism is key-agnostic by design so packages below the agent layer
|
||||
* (`dsh-session`, `dsh-system-prompt`) can depend on it without a dependency
|
||||
* cycle.
|
||||
*
|
||||
* Ownership and visibility derive from ONE fact — which context a registration
|
||||
* went through: the scope's fiber owns the disposal (a `ctx.effect()`/
|
||||
* `ctx.on()`/registry call through the scoped context unwinds on
|
||||
* {@link Scope.dispose}, because Cordis routes a service method's `this.ctx`
|
||||
* to the ACCESSING context), and the tag decides who sees it. Splitting those
|
||||
* two — an explicit `{ scope }` registration parameter — would let a caller
|
||||
* express "visible to X, disposed with Y", which is almost always a bug; the
|
||||
* scoped context makes it unrepresentable.
|
||||
* Scoped-context primitive: mint a Cordis context that tags registrations with
|
||||
* an opaque identity and build routing-only event carriers for that identity.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-scope
|
||||
*/
|
||||
@@ -25,354 +8,109 @@
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import { Context as CordisContext } from 'cordis'
|
||||
|
||||
/**
|
||||
* The identity a scope is keyed by. Opaque and compared by object identity —
|
||||
* never inspected. The harness convention: a live `Agent` is the key of its
|
||||
* own scope, so seam vocabularies that already carry the agent
|
||||
* (`ToolExecution.agent`, `AssembleContext.scope`) name the layer directly.
|
||||
*/
|
||||
/** An opaque, identity-compared scope key. */
|
||||
export type ScopeKey = object
|
||||
|
||||
/** The context tag {@link createScope} writes and {@link scopeOf} reads (module-private). */
|
||||
/** Context tag written by {@link createScope}. */
|
||||
const kScope = Symbol('dsh.scope')
|
||||
|
||||
/** The carrier mark {@link scopeTarget} writes and {@link carrierKeyOf} reads (module-private). */
|
||||
const kCarrier = Symbol('dsh.scope.carrier')
|
||||
|
||||
declare const ScopedBrand: unique symbol
|
||||
|
||||
/**
|
||||
* A dispatch carrier built by {@link scopeTarget}: structurally the `base` it
|
||||
* overlays, branded so scope-filtered events can DEMAND a carrier as their
|
||||
* `this` type — passing a bare subject where a `Scoped<T>` is required is a
|
||||
* compile error, which is what makes "forgot the carrier" unrepresentable at
|
||||
* dispatch sites. The brand is compile-time only; {@link isScopeCarrier} is
|
||||
* the runtime counterpart (used by the dev invariants).
|
||||
* A routing-only event receiver built by {@link scopeTarget}. The type
|
||||
* parameter records the subject type for dispatch checking; the carrier does
|
||||
* not expose the subject's properties. Event payloads carry the real subject.
|
||||
*/
|
||||
export type Scoped<T> = T & { readonly [ScopedBrand]: 'dsh.scope.carrier' }
|
||||
export type Scoped<T extends object> = object & { readonly [ScopedBrand]: T }
|
||||
|
||||
/**
|
||||
* A minted scope: the tagged context to register through, plus the disposers
|
||||
* that unwind every registration made through it.
|
||||
*/
|
||||
/** The key associated with each carrier. Presence distinguishes an unkeyed carrier from a non-carrier. */
|
||||
const carrierKeys = new WeakMap<object, ScopeKey | undefined>()
|
||||
|
||||
/** A minted registration scope and its quiescent disposal boundaries. */
|
||||
export interface Scope {
|
||||
/**
|
||||
* The scoped context. Registrations through it are tagged with the scope's
|
||||
* key (scope-aware registries file them in that key's layer; `ctx.on`
|
||||
* listeners fire only for dispatches targeted at that key) and owned by the
|
||||
* scope's fiber (disposed together on {@link dispose}). Contexts DERIVED
|
||||
* from it — an `extend`, a fiber mounted under it — inherit the tag through
|
||||
* the prototype chain.
|
||||
*/
|
||||
/** Context through which scope-owned registrations are made. */
|
||||
ctx: Context
|
||||
/**
|
||||
* The EXACT disposer Cordis registered on the minting fiber for the scope's
|
||||
* backing fiber. A composite (generator) effect that owns the scope's
|
||||
* position in an ordered teardown must yield THIS function: Cordis dedupes a
|
||||
* nested effect out of the parent's concurrent disposal list by function
|
||||
* identity, so yielding a wrapper would leave the scope disposing as an
|
||||
* unordered sibling. Callers outside a composite effect use {@link dispose}.
|
||||
* @returns the backing fiber's teardown promise (undefined on a repeat call
|
||||
* — Cordis effect disposers are single-shot).
|
||||
*/
|
||||
/** Exact Cordis disposer, used when nesting this scope in an ordered composite effect. */
|
||||
rawDispose: () => Promise<void> | void
|
||||
/**
|
||||
* Unwind the scope: dispose the backing fiber, running every collected
|
||||
* registration disposer. Idempotent and always awaitable: repeat and racing
|
||||
* calls share one completion even though the underlying Cordis disposer is
|
||||
* single-shot and returns undefined after its first invocation.
|
||||
* After disposal the scoped context is inert — a further registration
|
||||
* through it throws Cordis's INACTIVE_EFFECT.
|
||||
* @returns for the call that initiates teardown: resolves when every
|
||||
* registration's disposer has settled. Every repeat/racing call awaits
|
||||
* that same quiescence boundary, including when {@link rawDispose} claimed
|
||||
* the underlying single-shot Cordis disposer first.
|
||||
*/
|
||||
/** Dispose every scope-owned registration; racing calls await the same completion. */
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose a Cordis fiber and await its lifecycle inertia even when some other
|
||||
* caller claimed the single-shot raw disposer first. `Fiber.dispose()` returns
|
||||
* `undefined` on a repeat call, but the fiber's `inertia` remains the
|
||||
* authoritative promise while its async unload is running.
|
||||
*/
|
||||
/** Follow a Cordis fiber through asynchronous teardown even if its raw disposer was already claimed. */
|
||||
async function quiesceFiber(fiber: Fiber): Promise<void> {
|
||||
await Promise.resolve(fiber.dispose())
|
||||
while (fiber.inertia !== undefined) await fiber.inertia
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared no-op plugin every scope fiber mounts: named so diagnostics read
|
||||
* `scope` and shared so all scopes join ONE plugin runtime (Cordis deletes the
|
||||
* runtime record when its last fiber disposes, so idle deployments carry no
|
||||
* residue).
|
||||
*/
|
||||
/** Shared no-op plugin used as the backing scope fiber. */
|
||||
function scope(): void {}
|
||||
|
||||
/**
|
||||
* Mint a registration scope for `key` under `ctx`.
|
||||
*
|
||||
* Mounts a runtime fiber (`ctx.plugin`) and tags a child of its context with
|
||||
* `key`. The fiber is usable synchronously — Cordis activates it on a
|
||||
* microtask, but effect collection is uid-gated (not state-gated) and service
|
||||
* resolution falls through the pending fiber to the MINTING plugin's
|
||||
* dependency surface, so a caller may register through {@link Scope.ctx} the
|
||||
* moment this returns.
|
||||
*
|
||||
* Service resolution through the scoped context flows through the minting
|
||||
* plugin's dependency chain (the fiber walk), regardless of what the eventual
|
||||
* holder's own fiber injected — handing out the scoped context hands out that
|
||||
* capability; see `Agent.ctx` in `@deepseek-ai/dsh-agent` for the harness's
|
||||
* contract.
|
||||
* @param ctx - the context to mount the scope under; its fiber must be active
|
||||
* (a disposing owner throws Cordis's INACTIVE_EFFECT), and its plugin's
|
||||
* `inject` surface is what the scoped context resolves services against.
|
||||
* @param key - the scope's identity ({@link ScopeKey}); must be an object
|
||||
* (identity-compared), else this throws.
|
||||
* @returns the tagged context plus its disposers ({@link Scope}).
|
||||
* Mint a scope under `ctx`. The scoped context inherits the minting plugin's
|
||||
* dependency surface and owns every registration made through it.
|
||||
* @param ctx - active context whose dependency surface the scope inherits.
|
||||
* @param key - opaque identity used for listener routing.
|
||||
* @returns the scoped context and exact/shared disposal boundaries.
|
||||
*/
|
||||
export function createScope(ctx: Context, key: ScopeKey): Scope {
|
||||
// Runtime guard behind the ScopeKey type: callers outside the typechecker
|
||||
// (yml-configured plugins, JS consumers) can still pass a primitive.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if ((typeof key !== 'object' && typeof key !== 'function') || key === null) {
|
||||
throw new TypeError('createScope: key must be a non-null object or function (scope keys are identity-compared)')
|
||||
}
|
||||
const fiber = ctx.plugin(scope)
|
||||
const scoped: Context = fiber.ctx.extend({ [kScope]: key })
|
||||
let disposing: Promise<void> | undefined
|
||||
return {
|
||||
ctx: scoped,
|
||||
// fiber.dispose IS the disposer Cordis pushed onto the minting fiber's
|
||||
// disposable list — the identity a composite effect must yield (see
|
||||
// Scope.rawDispose).
|
||||
rawDispose: fiber.dispose,
|
||||
// Memoize the public boundary and explicitly follow fiber inertia: the raw
|
||||
// disposer must remain the exact Cordis function for ordered composition,
|
||||
// so it cannot itself be wrapped to record a raw-first invocation.
|
||||
dispose: () => (disposing ??= quiesceFiber(fiber)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the scope key a context is tagged with, or `undefined` for an untagged
|
||||
* (context-global) context. Walks the prototype chain, so any context DERIVED
|
||||
* from a scoped context — service shadows, `extend`s, fibers mounted under it
|
||||
* — reads as that scope; with nested scopes the nearest tag wins.
|
||||
* @param ctx - the context to inspect (typically a registry method's
|
||||
* `this.ctx`, i.e. the ACCESSING context).
|
||||
* @returns the key given to {@link createScope}, or `undefined` when the
|
||||
* context is not derived from any scope.
|
||||
* Read the nearest scope tag inherited by a context.
|
||||
* @param ctx - context to inspect.
|
||||
* @returns its scope key, or `undefined` for an unscoped context.
|
||||
*/
|
||||
export function scopeOf(ctx: Context): ScopeKey | undefined {
|
||||
// A plain (possibly proxied) property read: symbols bypass the Cordis
|
||||
// context proxy's service resolution, and Reflect walks the prototype chain.
|
||||
return (ctx as Context & { [kScope]?: ScopeKey })[kScope]
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the dispatch carrier for a scope-filtered event: `base` overlaid with
|
||||
* a `Context.filter` that admits a listener iff
|
||||
* Build the routing receiver for a scope-filtered event. Untagged listeners
|
||||
* remain global; tagged listeners run only when their key matches. A base
|
||||
* Cordis filter is composed before the scope predicate.
|
||||
*
|
||||
* - its registering context is UNTAGGED (a context-global listener — the
|
||||
* compatibility default: plain plugin listeners see every subject), or
|
||||
* - its tag IS `key` (a scoped listener seeing exactly its own subject),
|
||||
*
|
||||
* AND `base`'s own filter (a Cordis `Service`'s isolation check) also admits
|
||||
* it. Dispatching with `key === undefined` — a subject-less dispatch, e.g. a
|
||||
* tool call with no calling agent or a bare (agent-less) session's events —
|
||||
* admits only untagged listeners: a scoped listener never fires for someone
|
||||
* else's (or nobody's) subject. Listeners registered `{ global: true }`
|
||||
* bypass all filtering (Cordis semantics).
|
||||
*
|
||||
* Use it as the `thisArg` of the dispatch:
|
||||
* `ctx.waterfall(scopeTarget(this, exec.agent), 'tools/pre-execute', …)`. The
|
||||
* carrier is a TRANSPARENT proxy over `base`: reads delegate with `base` as
|
||||
* the receiver and retrieved methods are bound to `base`, so a listener may
|
||||
* call subject methods through its `this` (`this.send(…)` on a
|
||||
* `Scoped<Agent>`) even when the subject uses native `#private` fields — a
|
||||
* bare proxy receiver would throw on those. Identity is still not
|
||||
* transparent: `this !== subject` and method identity varies per read; the
|
||||
* subject always travels in the event's arguments. The returned carrier is
|
||||
* branded {@link Scoped} and runtime-marked ({@link isScopeCarrier} /
|
||||
* {@link carrierKeyOf}) so both the type system and the dev invariants can
|
||||
* tell a carrier from a bare subject.
|
||||
* @param base - the object the event is dispatched on behalf of (the owning
|
||||
* service, or the subject agent itself); its own `Context.filter` is
|
||||
* preserved and composed.
|
||||
* @param key - the subject's scope key, or `undefined` for a subject-less
|
||||
* dispatch.
|
||||
* @returns the carrier to pass as the dispatch `thisArg`.
|
||||
* The receiver is deliberately opaque: listener code obtains the real subject
|
||||
* from event arguments, never from `this`.
|
||||
* @param base - subject or service whose existing Cordis filter is preserved.
|
||||
* @param key - routed scope identity, or `undefined` for an unscoped subject.
|
||||
* @returns an opaque dispatch carrier.
|
||||
*/
|
||||
export function scopeTarget<T extends object>(base: T, key: ScopeKey | undefined): Scoped<T> {
|
||||
const baseFilter = (base as { [CordisContext.filter]?: (ctx: Context) => boolean })[CordisContext.filter]
|
||||
const filter = (ctx: Context): boolean => {
|
||||
if (baseFilter && !baseFilter.call(base, ctx)) return false
|
||||
const tag = scopeOf(ctx)
|
||||
return tag === undefined || tag === key
|
||||
}
|
||||
const overlay: Record<string | symbol, unknown> = {
|
||||
[CordisContext.filter]: filter,
|
||||
[kCarrier]: { key },
|
||||
}
|
||||
// A hand-rolled proxy, NOT cordis withProps: withProps delegates gets with
|
||||
// the PROXY as receiver, so a getter on `base` runs with proxy `this` and a
|
||||
// method call through the carrier gets a proxy receiver — either one throws
|
||||
// on a native `#private` field of the subject (TypeError: private member
|
||||
// not declared). Cordis hands the carrier to listeners as `this`, and the
|
||||
// event declarations type it `Scoped<Agent>` — so subject method calls
|
||||
// through it are a SUPPORTED shape and must reach the real object: gets
|
||||
// delegate with `base` as receiver, functions come back bound to `base`,
|
||||
// and sets land on `base` directly.
|
||||
return new Proxy(base, {
|
||||
get(target, prop) {
|
||||
// Proxy get invariants pin what this trap may report for a
|
||||
// non-configurable OWN property of the base: a non-writable data prop
|
||||
// must be reported AS-IS (neither overlaid nor bound), a getterless
|
||||
// accessor as undefined — checked FIRST so even an overlay key
|
||||
// colliding with a frozen own prop of a (pathological) base yields the
|
||||
// base's value instead of an engine TypeError. Such a base forgoes
|
||||
// scope filtering; no production base freezes these keys.
|
||||
const own = Reflect.getOwnPropertyDescriptor(target, prop)
|
||||
const pinned = own !== undefined && own.configurable === false
|
||||
&& own.get === undefined && own.writable !== true
|
||||
// hasOwn, not `in`: the overlay literal inherits Object.prototype, so
|
||||
// `in` would claim `toString`/`constructor` and shadow the subject's.
|
||||
if (!pinned && Object.hasOwn(overlay, prop)) return overlay[prop]
|
||||
const value: unknown = Reflect.get(target, prop, target)
|
||||
if (typeof value !== 'function' || pinned) return value
|
||||
// `constructor` is looked up, never invoked as a subject method — keep
|
||||
// the real one (withProps special-cases it the same way), so
|
||||
// `carrier.constructor` still identifies the subject's class.
|
||||
if (prop === 'constructor') return value
|
||||
// `Function.prototype.bind` types as `any`; the value is structurally
|
||||
// T[prop] and the trap's contract is untyped (`any`), so unknown is the
|
||||
// honest safe return.
|
||||
return value.bind(target) as unknown
|
||||
const carrier = {
|
||||
[CordisContext.filter](ctx: Context): boolean {
|
||||
if (baseFilter !== undefined && !baseFilter.call(base, ctx)) return false
|
||||
const tag = scopeOf(ctx)
|
||||
return tag === undefined || tag === key
|
||||
},
|
||||
set(target, prop, value) {
|
||||
return Reflect.set(target, prop, value, target)
|
||||
},
|
||||
}) as Scoped<T>
|
||||
}
|
||||
carrierKeys.set(carrier, key)
|
||||
return carrier as unknown as Scoped<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `value` is a carrier built by {@link scopeTarget} — the runtime
|
||||
* counterpart of the {@link Scoped} brand, used by the dev invariants to
|
||||
* assert that a scope-filtered event was dispatched with a carrier and not a
|
||||
* bare subject.
|
||||
* @param value - the dispatch `thisArg` to test.
|
||||
* @returns true iff `value` came from {@link scopeTarget}.
|
||||
* Test whether a value is a scope carrier.
|
||||
* @param value - dispatch receiver to inspect.
|
||||
* @returns whether {@link scopeTarget} created it.
|
||||
*/
|
||||
export function isScopeCarrier(value: unknown): value is Scoped<object> {
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
// A property READ, not an `in` check: the carrier overlays its marks in the
|
||||
// get trap only (no `has` trap), so `kCarrier in carrier` would fall
|
||||
// through to the wrapped base and always answer false.
|
||||
return (value as { [kCarrier]?: { key: ScopeKey | undefined } })[kCarrier] !== undefined
|
||||
return typeof value === 'object' && value !== null && carrierKeys.has(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* The scope key a carrier was built for — `undefined` for a subject-less
|
||||
* carrier, and also `undefined` for a non-carrier (pair with
|
||||
* {@link isScopeCarrier} when the distinction matters). The dev invariants
|
||||
* use it to assert the carrier's key IS the subject the event's arguments
|
||||
* name.
|
||||
* @param value - the dispatch `thisArg` to read.
|
||||
* @returns the `key` given to {@link scopeTarget}, or `undefined`.
|
||||
* Read a carrier's routing key.
|
||||
* @param value - dispatch receiver to inspect.
|
||||
* @returns the carrier key, or `undefined` for an unkeyed/non-carrier value.
|
||||
*/
|
||||
export function carrierKeyOf(value: unknown): ScopeKey | undefined {
|
||||
if (!isScopeCarrier(value)) return undefined
|
||||
// Optional-prop cast: the guard proves the mark is present at runtime, but
|
||||
// the Scoped<> brand carries no structural kCarrier member to narrow from.
|
||||
return (value as { [kCarrier]?: { key: ScopeKey | undefined } })[kCarrier]?.key
|
||||
}
|
||||
|
||||
/**
|
||||
* A test/tooling host for minting scopes: one mounted plugin whose `inject`
|
||||
* list is the service surface every scope minted through it can reach.
|
||||
*/
|
||||
export interface ScopeHost {
|
||||
/**
|
||||
* Mint a scope under the host (see {@link createScope}); the scoped context
|
||||
* resolves exactly the host's injected services.
|
||||
* @param key - the scope's identity ({@link ScopeKey}).
|
||||
* @returns the minted scope.
|
||||
*/
|
||||
mint(key: ScopeKey): Scope
|
||||
/**
|
||||
* Dispose the host fiber and with it every scope minted through it.
|
||||
* Every racing/repeat caller observes the same completion, including when a
|
||||
* child's raw disposer started before host disposal.
|
||||
* @returns resolves when the host and every minted scope have reached
|
||||
* quiescence.
|
||||
*/
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a scope-minting host plugin that injects `services`, THE sanctioned
|
||||
* way to mint scopes in tests (production scopes are minted by the agent
|
||||
* loop). Exists because the naive spelling fails confusingly twice over:
|
||||
* a plugin with no `inject` mints scopes whose service reads throw Cordis's
|
||||
* cryptic `cannot get property … without inject`, and a plugin whose inject
|
||||
* can never be satisfied RESOLVES its fiber await without ever running the
|
||||
* callback — a silent no-op host. This helper fails LOUD instead: when the
|
||||
* callback did not run, it names the absent services and disposes the host.
|
||||
* @param ctx - the context to mount the host under.
|
||||
* @param services - the service names scopes minted through this host reach
|
||||
* (the host plugin's `inject` list).
|
||||
* @returns the host (mint scopes, dispose them all at once).
|
||||
* @throws when any of `services` is not available on `ctx` — named, not the
|
||||
* Cordis dead end.
|
||||
*/
|
||||
export async function scopeHost(ctx: Context, services: string[]): Promise<ScopeHost> {
|
||||
let hostCtx: Context | undefined
|
||||
// A named function statement (not Object.assign({name}) — Function.name is
|
||||
// read-only) so diagnostics read `scopeHost`.
|
||||
function scopeHostPlugin(inner: Context): void { hostCtx = inner }
|
||||
const fiber = ctx.plugin(Object.assign(scopeHostPlugin, { inject: services }))
|
||||
await fiber
|
||||
if (hostCtx === undefined) {
|
||||
// Dependency-pending: cordis resolves the await without running the
|
||||
// callback. Name the absentees and unwind the pending fiber.
|
||||
const missing = services.filter(name => ctx.get(name) === undefined)
|
||||
await fiber.dispose()
|
||||
/* v8 ignore next -- the '(unknown)' fallback is defensive: a pending
|
||||
* fiber with zero absent services cannot occur (an all-present inject
|
||||
* list runs the callback) */
|
||||
const named = missing.map(name => `"${name}"`).join(', ') || '(unknown)'
|
||||
throw new Error(`scopeHost: service${missing.length === 1 ? '' : 's'} ${named} not available on this context — load the providing plugin(s) before minting scopes`)
|
||||
}
|
||||
const host = hostCtx
|
||||
const scopes = new Set<Scope>()
|
||||
let disposing: Promise<void> | undefined
|
||||
const dispose = async (): Promise<void> => {
|
||||
// Start every boundary before awaiting any one of them. A child whose raw
|
||||
// disposer already ran is still followed through Scope.dispose(); a child
|
||||
// the host unload claims first is followed through the same fiber inertia.
|
||||
const tasks = [quiesceFiber(fiber), ...[...scopes].map(scope => scope.dispose())]
|
||||
const results = await Promise.allSettled(tasks)
|
||||
scopes.clear()
|
||||
const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason as unknown] : [])
|
||||
if (errors.length === 1) throw errors[0]
|
||||
if (errors.length > 1) throw new AggregateError(errors, 'scopeHost: disposal failed')
|
||||
}
|
||||
return {
|
||||
mint: (key: ScopeKey) => {
|
||||
const minted = createScope(host, key)
|
||||
let disposing: Promise<void> | undefined
|
||||
const tracked: Scope = {
|
||||
ctx: minted.ctx,
|
||||
// Preserve the exact Cordis identity: only the public shared boundary
|
||||
// is wrapped to retire this child from the host's tracking set.
|
||||
rawDispose: minted.rawDispose,
|
||||
dispose: () => (disposing ??= minted.dispose().finally(() => { scopes.delete(tracked) })),
|
||||
}
|
||||
scopes.add(tracked)
|
||||
return tracked
|
||||
},
|
||||
dispose: () => (disposing ??= dispose()),
|
||||
}
|
||||
return carrierKeys.get(value)
|
||||
}
|
||||
|
||||
@@ -1,374 +1,155 @@
|
||||
import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { carrierKeyOf, createScope, isScopeCarrier, scopeHost, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope, ScopeKey, Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import { carrierKeyOf, createScope, isScopeCarrier, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope, Scoped } from '@deepseek-ai/dsh-scope'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* Test-only event for exercising scope-filtered dispatch.
|
||||
* Test-only event for scope-filtered dispatch.
|
||||
* @param value - opaque payload recorded by listeners.
|
||||
* @mode emit
|
||||
*/
|
||||
'scope-test/ping'(value: string): void
|
||||
/**
|
||||
* Test-only waterfall for exercising carrier `this` shape.
|
||||
* @param value - seed value listeners may wrap.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'scope-test/echo'(value: string, next: () => string): string
|
||||
}
|
||||
}
|
||||
|
||||
/** Mount a host plugin and mint a scope inside it, returning both. */
|
||||
/** Mount a host plugin and mint a scope inside it. */
|
||||
async function mintScope(ctx: Context, key: object): Promise<Scope> {
|
||||
let scope!: Scope
|
||||
await ctx.plugin((inner: Context) => {
|
||||
scope = createScope(inner, key)
|
||||
})
|
||||
await ctx.plugin((inner: Context) => { scope = createScope(inner, key) })
|
||||
return scope
|
||||
}
|
||||
|
||||
describe('createScope', () => {
|
||||
it('rejects primitive keys but accepts callable objects (matching ScopeKey)', async () => {
|
||||
it('tags contexts and derived contexts, with the nearest tag winning', async () => {
|
||||
const ctx = new Context()
|
||||
// Typed through `unknown` so the ScopeKey type cannot argue the assertion
|
||||
// away: this test exercises exactly the callers the typechecker misses.
|
||||
const badKeys: unknown[] = ['k', null]
|
||||
for (const bad of badKeys) {
|
||||
expect(() => createScope(ctx, bad as ScopeKey)).toThrow(/must be a non-null object or function/)
|
||||
}
|
||||
const outerKey = { name: 'outer' }
|
||||
const innerKey = { name: 'inner' }
|
||||
const outer = await mintScope(ctx, outerKey)
|
||||
const inner = createScope(outer.ctx, innerKey)
|
||||
|
||||
const callable = Object.assign(() => {}, { nameForTest: 'callable-key' })
|
||||
const scope = await mintScope(ctx, callable)
|
||||
expect(scopeOf(scope.ctx)).toBe(callable)
|
||||
await scope.dispose()
|
||||
})
|
||||
|
||||
it('tags the scoped context, readable through derivations (nearest tag wins)', async () => {
|
||||
const ctx = new Context()
|
||||
const key = { name: 'a' }
|
||||
const inner = { name: 'a.inner' }
|
||||
const scope = await mintScope(ctx, key)
|
||||
|
||||
expect(scopeOf(scope.ctx)).toBe(key)
|
||||
// An extend of the scoped context inherits the tag through the prototype chain.
|
||||
expect(scopeOf(scope.ctx.extend({}))).toBe(key)
|
||||
// A plain context carries no tag.
|
||||
expect(scopeOf(ctx)).toBeUndefined()
|
||||
// A fiber mounted UNDER the scoped context reads as that scope…
|
||||
let mountedCtx!: Context
|
||||
await scope.ctx.plugin((c: Context) => { mountedCtx = c })
|
||||
expect(scopeOf(mountedCtx)).toBe(key)
|
||||
// …and a nested scope shadows the outer tag (nearest wins).
|
||||
const nested = createScope(scope.ctx, inner)
|
||||
expect(scopeOf(nested.ctx)).toBe(inner)
|
||||
expect(scopeOf(outer.ctx)).toBe(outerKey)
|
||||
expect(scopeOf(outer.ctx.extend({}))).toBe(outerKey)
|
||||
expect(scopeOf(inner.ctx)).toBe(innerKey)
|
||||
|
||||
await inner.dispose()
|
||||
await outer.dispose()
|
||||
})
|
||||
|
||||
it('is usable synchronously: registrations land before the fiber activates', async () => {
|
||||
it('is usable synchronously before the backing fiber activates', async () => {
|
||||
const ctx = new Context()
|
||||
const events: string[] = []
|
||||
let scope!: Scope
|
||||
await ctx.plugin((inner: Context) => {
|
||||
const scope = createScope(inner, { name: 'sync' })
|
||||
// Same tick as createScope — no await between mint and use.
|
||||
scope.ctx.effect(() => () => void events.push('effect-disposed'))
|
||||
scope.ctx.on('scope-test/ping', value => void events.push(`heard:${value}`))
|
||||
scope = createScope(inner, { name: 'sync' })
|
||||
scope.ctx.effect(() => () => void events.push('disposed'))
|
||||
events.push('registered')
|
||||
})
|
||||
ctx.emit(scopeTarget(ctx, undefined), 'scope-test/ping', 'nobody')
|
||||
expect(events).toEqual(['registered'])
|
||||
})
|
||||
|
||||
it('dispose() unwinds registrations, is idempotent, and inerts the context', async () => {
|
||||
const ctx = new Context()
|
||||
const scope = await mintScope(ctx, { name: 'd' })
|
||||
const order: string[] = []
|
||||
scope.ctx.effect(() => () => void order.push('a'))
|
||||
scope.ctx.effect(() => () => void order.push('b'))
|
||||
|
||||
await scope.dispose()
|
||||
expect(order).toEqual(['b', 'a']) // LIFO within the scope fiber
|
||||
|
||||
// Repeat dispose: the underlying cordis disposer returns undefined; the
|
||||
// wrapper still resolves.
|
||||
await expect(scope.dispose()).resolves.toBeUndefined()
|
||||
// Registration through a disposed scope throws INACTIVE_EFFECT.
|
||||
expect(() => scope.ctx.effect(() => () => {})).toThrow(/inactive context/)
|
||||
expect(events).toEqual(['registered', 'disposed'])
|
||||
})
|
||||
|
||||
it('dispose() follows a rawDispose-first race through async quiescence', async () => {
|
||||
it('shares quiescence across repeat and raw-disposer-first calls', async () => {
|
||||
const ctx = new Context()
|
||||
const scope = await mintScope(ctx, { name: 'raw-first' })
|
||||
const scope = await mintScope(ctx, { name: 'quiescence' })
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
let cleanupFinished = false
|
||||
let finished = false
|
||||
scope.ctx.effect(() => async () => {
|
||||
await gate.promise
|
||||
cleanupFinished = true
|
||||
finished = true
|
||||
})
|
||||
|
||||
const raw = Promise.resolve(scope.rawDispose())
|
||||
let publicSettled = false
|
||||
const publicDispose = scope.dispose().then(() => { publicSettled = true })
|
||||
const publicDispose = scope.dispose()
|
||||
await Promise.resolve()
|
||||
expect(publicSettled).toBe(false)
|
||||
expect(cleanupFinished).toBe(false)
|
||||
|
||||
expect(finished).toBe(false)
|
||||
gate.resolve(undefined)
|
||||
await Promise.all([raw, publicDispose])
|
||||
expect(cleanupFinished).toBe(true)
|
||||
await expect(scope.dispose()).resolves.toBeUndefined()
|
||||
await Promise.all([raw, publicDispose, scope.dispose()])
|
||||
expect(finished).toBe(true)
|
||||
})
|
||||
|
||||
it('rawDispose is the exact cordis disposer: yielding it nests the scope at its position', async () => {
|
||||
it('exposes the exact raw disposer for ordered composite teardown', async () => {
|
||||
const ctx = new Context()
|
||||
const order: string[] = []
|
||||
let composite!: () => Promise<void> | void
|
||||
let dispose!: () => Promise<void> | void
|
||||
await ctx.plugin((inner: Context) => {
|
||||
composite = inner.effect(function* () {
|
||||
yield () => void order.push('outermost') // disposed LAST
|
||||
dispose = inner.effect(function* () {
|
||||
yield () => void order.push('outer')
|
||||
const scope = createScope(inner, { name: 'nested' })
|
||||
scope.ctx.effect(() => () => void order.push('scope-registration'))
|
||||
yield scope.rawDispose // disposed SECOND — nested by identity
|
||||
yield () => void order.push('innermost') // disposed FIRST
|
||||
scope.ctx.effect(() => () => void order.push('scope'))
|
||||
yield scope.rawDispose
|
||||
yield () => void order.push('inner')
|
||||
})
|
||||
})
|
||||
await composite()
|
||||
// The scope disposed exactly at its yield position (between the two
|
||||
// neighbours), not as a concurrent sibling of the composite.
|
||||
expect(order).toEqual(['innermost', 'scope-registration', 'outermost'])
|
||||
await dispose()
|
||||
expect(order).toEqual(['inner', 'scope', 'outer'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('scopeTarget dispatch filtering', () => {
|
||||
it('scoped listeners hear only their key; untagged listeners hear everything', async () => {
|
||||
describe('scopeTarget', () => {
|
||||
it('routes scoped listeners by key while untagged listeners remain global', async () => {
|
||||
const ctx = new Context()
|
||||
const keyA = { name: 'A' }
|
||||
const keyB = { name: 'B' }
|
||||
const scopeA = await mintScope(ctx, keyA)
|
||||
const scopeB = await mintScope(ctx, keyB)
|
||||
|
||||
const heard: string[] = []
|
||||
ctx.on('scope-test/ping', value => void heard.push(`global:${value}`))
|
||||
scopeA.ctx.on('scope-test/ping', value => void heard.push(`A:${value}`))
|
||||
scopeB.ctx.on('scope-test/ping', value => void heard.push(`B:${value}`))
|
||||
|
||||
ctx.emit(scopeTarget(ctx, keyA), 'scope-test/ping', 'to-A')
|
||||
ctx.emit(scopeTarget(ctx, keyB), 'scope-test/ping', 'to-B')
|
||||
ctx.emit(scopeTarget(ctx, undefined), 'scope-test/ping', 'to-nobody')
|
||||
ctx.emit(scopeTarget(ctx, keyA), 'scope-test/ping', 'a')
|
||||
ctx.emit(scopeTarget(ctx, keyB), 'scope-test/ping', 'b')
|
||||
ctx.emit(scopeTarget(ctx, undefined), 'scope-test/ping', 'none')
|
||||
|
||||
expect(heard).toEqual([
|
||||
'global:to-A', 'A:to-A',
|
||||
'global:to-B', 'B:to-B',
|
||||
'global:to-nobody',
|
||||
])
|
||||
expect(heard).toEqual(['global:a', 'A:a', 'global:b', 'B:b', 'global:none'])
|
||||
await Promise.all([scopeA.dispose(), scopeB.dispose()])
|
||||
})
|
||||
|
||||
it('{ global: true } listeners bypass scope filtering entirely', async () => {
|
||||
it('preserves a base Cordis filter and its receiver', async () => {
|
||||
const ctx = new Context()
|
||||
const keyA = { name: 'A' }
|
||||
const scopeA = await mintScope(ctx, keyA)
|
||||
const heard: string[] = []
|
||||
scopeA.ctx.on('scope-test/ping', value => void heard.push(`escape:${value}`), { global: true })
|
||||
|
||||
ctx.emit(scopeTarget(ctx, { name: 'other' }), 'scope-test/ping', 'foreign')
|
||||
ctx.emit(scopeTarget(ctx, undefined), 'scope-test/ping', 'nobody')
|
||||
expect(heard).toEqual(['escape:foreign', 'escape:nobody'])
|
||||
})
|
||||
|
||||
it("composes the base's own Context.filter (a rejecting base filter wins)", async () => {
|
||||
const ctx = new Context()
|
||||
const keyA = { name: 'A' }
|
||||
const scopeA = await mintScope(ctx, keyA)
|
||||
const key = { name: 'A' }
|
||||
const scope = await mintScope(ctx, key)
|
||||
const heard: string[] = []
|
||||
ctx.on('scope-test/ping', value => void heard.push(`global:${value}`))
|
||||
scopeA.ctx.on('scope-test/ping', value => void heard.push(`A:${value}`))
|
||||
scope.ctx.on('scope-test/ping', value => void heard.push(`A:${value}`))
|
||||
let receiverMatches = false
|
||||
const base = {
|
||||
[Context.filter](this: object): boolean {
|
||||
receiverMatches = this === base
|
||||
return false
|
||||
},
|
||||
}
|
||||
|
||||
// A base whose own filter rejects every listener context: nothing fires,
|
||||
// scoped or not — the scope predicate never overrides the base's veto.
|
||||
const vetoBase = { [Context.filter]: () => false }
|
||||
ctx.emit(scopeTarget(vetoBase, keyA), 'scope-test/ping', 'vetoed')
|
||||
ctx.emit(scopeTarget(base, key), 'scope-test/ping', 'vetoed')
|
||||
expect(heard).toEqual([])
|
||||
|
||||
// A base whose filter accepts delegates to the scope predicate.
|
||||
const openBase = { [Context.filter]: () => true }
|
||||
ctx.emit(scopeTarget(openBase, keyA), 'scope-test/ping', 'open')
|
||||
expect(heard).toEqual(['global:open', 'A:open'])
|
||||
expect(receiverMatches).toBe(true)
|
||||
await scope.dispose()
|
||||
})
|
||||
|
||||
it('keeps listener `this` base-shaped through the carrier (waterfall)', async () => {
|
||||
it('{ global: true } listeners retain Cordis global-listener semantics', async () => {
|
||||
const ctx = new Context()
|
||||
const base = { label: 'the-base' }
|
||||
let seenLabel: string | undefined
|
||||
ctx.on('scope-test/echo', function (this: { label: string }, value, next) {
|
||||
seenLabel = this.label
|
||||
return `${next()}+${value}`
|
||||
})
|
||||
const result = ctx.waterfall(scopeTarget(base, undefined), 'scope-test/echo', 'v', () => 'seed')
|
||||
expect(result).toBe('seed+v')
|
||||
expect(seenLabel).toBe('the-base')
|
||||
const scope = await mintScope(ctx, { name: 'A' })
|
||||
const heard: string[] = []
|
||||
scope.ctx.on('scope-test/ping', value => void heard.push(value), { global: true })
|
||||
ctx.emit(scopeTarget(ctx, { name: 'other' }), 'scope-test/ping', 'foreign')
|
||||
ctx.emit(scopeTarget(ctx, undefined), 'scope-test/ping', 'none')
|
||||
expect(heard).toEqual(['foreign', 'none'])
|
||||
await scope.dispose()
|
||||
})
|
||||
|
||||
it('is transparent for subjects with native #private fields: methods and getters through the carrier reach the real object', () => {
|
||||
// The ds-review-bot regression: cordis hands the carrier to listeners as
|
||||
// `this` (typed Scoped<Agent>), so subject method calls through it are a
|
||||
// supported shape. A proxy that delegates with the PROXY as receiver
|
||||
// (cordis withProps) throws TypeError on any native #private the method
|
||||
// or getter touches; the carrier must delegate with the BASE as receiver
|
||||
// and bind retrieved methods to it.
|
||||
class Subject {
|
||||
#count = 0
|
||||
bump(): number { return ++this.#count }
|
||||
get count(): number { return this.#count }
|
||||
}
|
||||
const subject = new Subject()
|
||||
const carrier = scopeTarget(subject, subject)
|
||||
expect(carrier.bump()).toBe(1) // method call: bound to the base
|
||||
expect(subject.count).toBe(1) // ...and it mutated the REAL object
|
||||
expect(carrier.count).toBe(1) // getter: runs with the base as receiver
|
||||
// The get trap returns the method already bound to the base;
|
||||
// detachability IS the assertion.
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const detached = carrier.bump
|
||||
expect(detached()).toBe(2)
|
||||
})
|
||||
|
||||
it('delegates sets to the base and leaves frozen own function props unbound (proxy invariant)', () => {
|
||||
const frozenFn = (): string => 'frozen'
|
||||
const base: { mutable: number; pinned: () => string; toString: () => string } = {
|
||||
mutable: 0,
|
||||
pinned: frozenFn,
|
||||
toString: () => 'base-str',
|
||||
}
|
||||
Object.defineProperty(base, 'pinned', { value: frozenFn, writable: false, configurable: false })
|
||||
const carrier = scopeTarget(base, undefined)
|
||||
carrier.mutable = 7
|
||||
expect(base.mutable).toBe(7) // sets land on the base, not a detached overlay
|
||||
// A non-configurable, non-writable own data prop must be reported
|
||||
// unchanged (binding it would violate the proxy get invariant).
|
||||
expect(carrier.pinned).toBe(frozenFn)
|
||||
// The overlay literal inherits Object.prototype; hasOwn (not `in`) keeps
|
||||
// it from shadowing the subject's own prototype-surface members.
|
||||
expect(String(carrier)).toBe('base-str')
|
||||
})
|
||||
|
||||
it('honors the get invariant even when an overlay key collides with a frozen own prop of the base', () => {
|
||||
// Pathological but engine-enforced: a base whose own [Context.filter] is
|
||||
// a non-configurable, non-writable data prop pins what any proxy over it
|
||||
// may report for that key. The carrier must yield the base's value (an
|
||||
// overlay there would be a runtime TypeError from the engine, not a
|
||||
// filtering choice). Such a base forgoes scope filtering by construction.
|
||||
const pinnedFilter = (): boolean => true
|
||||
const base = {}
|
||||
Object.defineProperty(base, Context.filter, { value: pinnedFilter, writable: false, configurable: false })
|
||||
const carrier = scopeTarget(base, { name: 'key' })
|
||||
expect((carrier as Record<symbol, unknown>)[Context.filter]).toBe(pinnedFilter)
|
||||
})
|
||||
|
||||
it('keeps the real constructor: class identity survives the carrier', () => {
|
||||
class Subject { work(): string { return 'w' } }
|
||||
const subject = new Subject()
|
||||
const carrier = scopeTarget(subject, subject)
|
||||
// `constructor` is looked up, never invoked as a subject method — binding
|
||||
// it would break `carrier.constructor === Subject` for no benefit.
|
||||
expect(carrier.constructor).toBe(Subject)
|
||||
})
|
||||
})
|
||||
|
||||
describe('carrier marks', () => {
|
||||
it('isScopeCarrier / carrierKeyOf distinguish carriers, keys, and bare subjects', () => {
|
||||
const base = { name: 'base' }
|
||||
it('uses an opaque branded carrier with a separately tracked key', () => {
|
||||
const key = { name: 'key' }
|
||||
const keyed = scopeTarget(base, key)
|
||||
const subjectless = scopeTarget(base, undefined)
|
||||
|
||||
expect(isScopeCarrier(keyed)).toBe(true)
|
||||
expect(carrierKeyOf(keyed)).toBe(key)
|
||||
expect(isScopeCarrier(subjectless)).toBe(true)
|
||||
expect(carrierKeyOf(subjectless)).toBeUndefined()
|
||||
|
||||
expect(isScopeCarrier(base)).toBe(false)
|
||||
expect(carrierKeyOf(base)).toBeUndefined()
|
||||
expect(isScopeCarrier(null)).toBe(false)
|
||||
expect(isScopeCarrier('x')).toBe(false)
|
||||
})
|
||||
|
||||
it('brands the carrier type (compile-time)', () => {
|
||||
const base = { name: 'base' }
|
||||
const carrier = scopeTarget(base, undefined)
|
||||
expectTypeOf(carrier).toExtend<Scoped<{ name: string }>>()
|
||||
// A bare subject is NOT assignable where a carrier is demanded.
|
||||
expectTypeOf(base).not.toExtend<Scoped<{ name: string }>>()
|
||||
})
|
||||
})
|
||||
|
||||
describe('scopeHost', () => {
|
||||
it('mints scopes that reach the injected services; dispose unwinds them all', async () => {
|
||||
const ctx = new Context()
|
||||
ctx.provide('answers', { value: 42 })
|
||||
const host = await scopeHost(ctx, ['answers'])
|
||||
const scope = host.mint({ name: 'a' })
|
||||
expect((scope.ctx as Context & { answers: { value: number } }).answers.value).toBe(42)
|
||||
const order: string[] = []
|
||||
scope.ctx.effect(() => () => void order.push('scoped-disposed'))
|
||||
await host.dispose()
|
||||
expect(order).toEqual(['scoped-disposed'])
|
||||
expect(() => scope.ctx.effect(() => () => {})).toThrow(/inactive context/)
|
||||
})
|
||||
|
||||
it('dispose waits for a child whose raw disposer won the race', async () => {
|
||||
const ctx = new Context()
|
||||
ctx.provide('answers', { value: 42 })
|
||||
const host = await scopeHost(ctx, ['answers'])
|
||||
const scope = host.mint({ name: 'raw-first-child' })
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
let cleanupFinished = false
|
||||
scope.ctx.effect(() => async () => {
|
||||
await gate.promise
|
||||
cleanupFinished = true
|
||||
})
|
||||
|
||||
const raw = Promise.resolve(scope.rawDispose())
|
||||
let hostSettled = false
|
||||
const hostDispose = host.dispose().then(() => { hostSettled = true })
|
||||
await Promise.resolve()
|
||||
expect(hostSettled).toBe(false)
|
||||
|
||||
gate.resolve(undefined)
|
||||
await Promise.all([raw, hostDispose])
|
||||
expect(cleanupFinished).toBe(true)
|
||||
await expect(host.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('reaches every child before surfacing one or multiple disposal failures', async () => {
|
||||
const oneCtx = new Context()
|
||||
oneCtx.provide('answers', { value: 42 })
|
||||
const oneHost = await scopeHost(oneCtx, ['answers'])
|
||||
const one = oneHost.mint({ name: 'one' })
|
||||
one.dispose = () => Promise.reject(new Error('one failed'))
|
||||
await expect(oneHost.dispose()).rejects.toThrow('one failed')
|
||||
|
||||
const manyCtx = new Context()
|
||||
manyCtx.provide('answers', { value: 42 })
|
||||
const manyHost = await scopeHost(manyCtx, ['answers'])
|
||||
const a = manyHost.mint({ name: 'a' })
|
||||
const b = manyHost.mint({ name: 'b' })
|
||||
a.dispose = () => Promise.reject(new Error('a failed'))
|
||||
b.dispose = () => Promise.reject(new Error('b failed'))
|
||||
await expect(manyHost.dispose()).rejects.toMatchObject({
|
||||
name: 'AggregateError',
|
||||
message: 'scopeHost: disposal failed',
|
||||
errors: [expect.objectContaining({ message: 'a failed' }), expect.objectContaining({ message: 'b failed' })],
|
||||
})
|
||||
})
|
||||
|
||||
it('fails LOUD naming absent services instead of resolving as a silent no-op host', async () => {
|
||||
const ctx = new Context()
|
||||
await expect(scopeHost(ctx, ['tools', 'systemPrompt']))
|
||||
.rejects.toThrow('scopeHost: services "tools", "systemPrompt" not available')
|
||||
})
|
||||
|
||||
it('names a single absent service in the singular', async () => {
|
||||
const ctx = new Context()
|
||||
await expect(scopeHost(ctx, ['tools'])).rejects.toThrow('scopeHost: service "tools" not available')
|
||||
const subject = { value: 1 }
|
||||
const carrier = scopeTarget(subject, key)
|
||||
expect(isScopeCarrier(carrier)).toBe(true)
|
||||
expect(carrierKeyOf(carrier)).toBe(key)
|
||||
expect(isScopeCarrier(subject)).toBe(false)
|
||||
expect(carrierKeyOf(subject)).toBeUndefined()
|
||||
expect('value' in carrier).toBe(false)
|
||||
expectTypeOf(carrier).toEqualTypeOf<Scoped<typeof subject>>()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user