refactor(core): simplify scoped agent lifecycles
This commit is contained in:
@@ -4,15 +4,14 @@ 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 | undefined): Scoped<T>` Build the dispatch `thisArg` for a scope-filtered event: capture and compose `base`'s own `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). The captured base filter and the exposed composed filter are invoked through captured JavaScript primordials, and the composed filter's frozen invocation surface cannot be replaced or tampered with. The carrier uses a dedicated surrogate proxy target; ordinary property access, writes, own-key visibility, methods, invocation, and construction delegate to `base`, and callable carriers match the base's constructable/non-constructable shape. For non-overlay base-owned properties, descriptor queries preserve values and flags except that configurable is normalized to `true`, as required to report those properties through an extensible surrogate; defining through the carrier is therefore supported only with an explicit `configurable: true` descriptor, while an omitted or false flag is rejected before the base is touched. 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 that snapshots the requested service list before activation, fails loud with stable missing-service diagnostics, and whose shared `dispose()` waits for both the host fiber and every minted scope, including a child already tearing down through `rawDispose`.
|
||||
|
||||
## Design contract
|
||||
|
||||
|
||||
@@ -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,476 +8,109 @@
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import { Context as CordisContext } from 'cordis'
|
||||
|
||||
// Capture the invocation primordials once. A carrier holder can reach the
|
||||
// composed Context.filter function, so neither that function's mutable
|
||||
// property surface nor a base filter's own `.call` may choose how listener-
|
||||
// selection predicates are invoked.
|
||||
const reflectApply = Reflect.apply
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const functionCall = Function.prototype.call
|
||||
|
||||
/**
|
||||
* 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
|
||||
* dependency surface; 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]
|
||||
}
|
||||
|
||||
/** Whether a callable has JavaScript's internal construction capability. */
|
||||
function isConstructable(value: (...args: unknown[]) => unknown): boolean {
|
||||
try {
|
||||
// A Proxy has [[Construct]] iff its target does. Its trap returns before
|
||||
// the engine invokes `value` or reads `value.prototype`, so a hostile but
|
||||
// constructable callable cannot be mistaken for a non-constructor.
|
||||
Reflect.construct(new Proxy(value, { construct: () => ({}) }), [])
|
||||
return true
|
||||
} catch {
|
||||
// The harmless outer trap leaves lack of [[Construct]] as the only failure.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 listener-filter check) also admits
|
||||
* it. Both the captured base filter and the composed filter are invoked
|
||||
* through captured JavaScript primordials, so mutating either function's
|
||||
* public `.call` property cannot bypass either predicate. 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. Defining an ordinary property through
|
||||
* the carrier is supported only when its descriptor explicitly says
|
||||
* `configurable: true`; an omitted or false flag is rejected before touching
|
||||
* `base`, because the extensible surrogate cannot truthfully report a new
|
||||
* non-configurable base property.
|
||||
* @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: unknown = (base as { [CordisContext.filter]?: unknown })[CordisContext.filter]
|
||||
if (baseFilter !== undefined && typeof baseFilter !== 'function') {
|
||||
throw new TypeError('scope target Context.filter must be a function when present')
|
||||
const baseFilter = (base as { [CordisContext.filter]?: (ctx: Context) => boolean })[CordisContext.filter]
|
||||
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
|
||||
},
|
||||
}
|
||||
const filter = (ctx: Context): boolean => {
|
||||
if (baseFilter && !reflectApply(functionCall, baseFilter, [base, ctx])) return false
|
||||
const tag = scopeOf(ctx)
|
||||
return tag === undefined || tag === key
|
||||
}
|
||||
// Cordis invokes a dispatch filter as `filter.call(thisArg, listenerCtx)`.
|
||||
// Pin that property to the captured primordial, then freeze the callable so
|
||||
// a carrier holder cannot replace it with an always-true scope bypass.
|
||||
Object.defineProperty(filter, 'call', {
|
||||
value: functionCall,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
})
|
||||
Object.freeze(filter)
|
||||
const overlay: Record<string | symbol, unknown> = {
|
||||
[CordisContext.filter]: filter,
|
||||
[kCarrier]: Object.freeze({ key }),
|
||||
}
|
||||
// Use a dedicated extensible proxy TARGET, never `base` itself. Proxy get
|
||||
// invariants force a trap to return a base's non-configurable/non-writable
|
||||
// own value verbatim; if a caller pinned Context.filter during or after
|
||||
// construction, a base-target proxy would therefore silently replace the
|
||||
// composed scope predicate with the caller's filter. The surrogate owns the
|
||||
// two immutable overlay slots, so later descriptor changes on `base` cannot
|
||||
// affect listener selection. It shares the base prototype and delegates ordinary
|
||||
// reads/writes/keys to preserve the supported transparent shape. Callable
|
||||
// targets use native bound built-ins so V8 contributes no user-code surface;
|
||||
// the chosen built-in matches whether `base` has [[Construct]], and the traps
|
||||
// below delegate the actual call/construction to `base`.
|
||||
const callableBase = typeof base === 'function'
|
||||
? base as unknown as (...args: unknown[]) => unknown
|
||||
: undefined
|
||||
const constructable = callableBase !== undefined && isConstructable(callableBase)
|
||||
const target: object = callableBase === undefined
|
||||
? {}
|
||||
: constructable
|
||||
? Object.bind(undefined)
|
||||
: Math.max.bind(undefined)
|
||||
Reflect.setPrototypeOf(target, Reflect.getPrototypeOf(base))
|
||||
Object.defineProperties(target, {
|
||||
[CordisContext.filter]: {
|
||||
value: filter,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
},
|
||||
[kCarrier]: {
|
||||
value: overlay[kCarrier],
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
},
|
||||
})
|
||||
const carrier = new Proxy(target, {
|
||||
get(target, prop) {
|
||||
// The callable surrogate has engine-owned pinned properties (`prototype`,
|
||||
// `caller`, …); honor those target invariants. For object carriers the
|
||||
// only pinned target properties are the exact overlay values above.
|
||||
const own = Reflect.getOwnPropertyDescriptor(target, prop)
|
||||
const pinned = own !== undefined && own.configurable === false
|
||||
&& own.get === undefined && own.writable !== true
|
||||
if (pinned) {
|
||||
const value: unknown = Reflect.get(target, prop, target)
|
||||
return value
|
||||
}
|
||||
const value: unknown = Reflect.get(base, prop, base)
|
||||
if (typeof value !== 'function') 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(base) as unknown
|
||||
},
|
||||
set(_target, prop, value) {
|
||||
if (Object.hasOwn(overlay, prop)) return false
|
||||
return Reflect.set(base, prop, value, base)
|
||||
},
|
||||
has(_target, prop) {
|
||||
// A Proxy may not hide a non-configurable target key. Configurable
|
||||
// surrogate-only keys (bound-function name/length) are omitted; the
|
||||
// base's own/inherited surface remains authoritative.
|
||||
const own = Reflect.getOwnPropertyDescriptor(target, prop)
|
||||
return own?.configurable === false || Reflect.has(base, prop)
|
||||
},
|
||||
ownKeys(target) {
|
||||
const requiredTargetKeys = Reflect.ownKeys(target).filter((prop) => {
|
||||
return Reflect.getOwnPropertyDescriptor(target, prop)?.configurable === false
|
||||
})
|
||||
return [...new Set([...requiredTargetKeys, ...Reflect.ownKeys(base)])]
|
||||
},
|
||||
getOwnPropertyDescriptor(target, prop) {
|
||||
const targetDescriptor = Reflect.getOwnPropertyDescriptor(target, prop)
|
||||
if (targetDescriptor?.configurable === false) return targetDescriptor
|
||||
const baseDescriptor = Reflect.getOwnPropertyDescriptor(base, prop)
|
||||
if (baseDescriptor !== undefined) return { ...baseDescriptor, configurable: true }
|
||||
// Configurable surrogate-only function metadata is intentionally hidden.
|
||||
return undefined
|
||||
},
|
||||
defineProperty(_target, prop, attributes) {
|
||||
if (Object.hasOwn(overlay, prop) || attributes.configurable !== true) return false
|
||||
return Reflect.defineProperty(base, prop, attributes)
|
||||
},
|
||||
deleteProperty(_target, prop) {
|
||||
if (Object.hasOwn(overlay, prop)) return false
|
||||
return Reflect.deleteProperty(base, prop)
|
||||
},
|
||||
preventExtensions() {
|
||||
// Keeping the surrogate extensible is required for ownKeys to report
|
||||
// caller-owned base fields that may change over the carrier's lifetime.
|
||||
return false
|
||||
},
|
||||
setPrototypeOf() {
|
||||
// The carrier prototype and base delegation must not be split.
|
||||
return false
|
||||
},
|
||||
apply(_target, thisArg, args) {
|
||||
const callable = callableBase as (...values: unknown[]) => unknown
|
||||
const result: unknown = Reflect.apply(callable, thisArg, args)
|
||||
return result
|
||||
},
|
||||
construct(_target, args, newTarget) {
|
||||
const constructor = callableBase as unknown as new (...values: unknown[]) => object
|
||||
const result: unknown = Reflect.construct(
|
||||
constructor,
|
||||
args,
|
||||
newTarget === carrier ? constructor : newTarget,
|
||||
)
|
||||
return result as object
|
||||
},
|
||||
})
|
||||
return carrier 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' && typeof value !== 'function') || value === null) return false
|
||||
// A property read checks the immutable marker owned by the surrogate target.
|
||||
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.
|
||||
* The service list is copied before plugin activation so caller mutation
|
||||
* across the await cannot change dependency resolution or diagnostics.
|
||||
* @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> {
|
||||
// The inject list crosses an await before missing-service diagnostics run.
|
||||
// Detach it now so caller mutation cannot change either Cordis dependency
|
||||
// resolution or the names reported by this helper.
|
||||
const requiredServices = [...services]
|
||||
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: requiredServices }))
|
||||
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 = requiredServices.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,569 +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}`))
|
||||
|
||||
// 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')
|
||||
expect(heard).toEqual([])
|
||||
|
||||
// A base whose filter accepts delegates to the scope predicate, with the
|
||||
// real base preserved as its `this` receiver.
|
||||
let baseReceiverWasOpen = false
|
||||
const openBase = {
|
||||
scope.ctx.on('scope-test/ping', value => void heard.push(`A:${value}`))
|
||||
let receiverMatches = false
|
||||
const base = {
|
||||
[Context.filter](this: object): boolean {
|
||||
baseReceiverWasOpen = this === openBase
|
||||
return true
|
||||
receiverMatches = this === base
|
||||
return false
|
||||
},
|
||||
}
|
||||
ctx.emit(scopeTarget(openBase, keyA), 'scope-test/ping', 'open')
|
||||
expect(heard).toEqual(['global:open', 'A:open'])
|
||||
expect(baseReceiverWasOpen).toBe(true)
|
||||
|
||||
// A function's public `.call` property is not its invocation semantics.
|
||||
// An always-true replacement must not override the base predicate's veto.
|
||||
const tamperedVeto = (): boolean => false
|
||||
Object.defineProperty(tamperedVeto, 'call', { value: () => true })
|
||||
ctx.emit(scopeTarget({ [Context.filter]: tamperedVeto }, keyA), 'scope-test/ping', 'tampered-veto')
|
||||
expect(heard).toEqual(['global:open', 'A:open'])
|
||||
ctx.emit(scopeTarget(base, key), 'scope-test/ping', 'vetoed')
|
||||
expect(heard).toEqual([])
|
||||
expect(receiverMatches).toBe(true)
|
||||
await scope.dispose()
|
||||
})
|
||||
|
||||
it('pins the exposed composed filter invocation so a carrier holder cannot bypass isolation', async () => {
|
||||
it('{ global: true } listeners retain Cordis global-listener semantics', 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 scope = await mintScope(ctx, { name: 'A' })
|
||||
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}`))
|
||||
|
||||
const carrier = scopeTarget(ctx, keyA)
|
||||
const exposedFilter: unknown = Reflect.get(carrier, Context.filter)
|
||||
expect(typeof exposedFilter).toBe('function')
|
||||
const filter = exposedFilter as ((ctx: Context) => boolean) & { call: (...args: unknown[]) => unknown }
|
||||
const primordialCall: unknown = Reflect.get(Function.prototype, 'call')
|
||||
expect(Object.getOwnPropertyDescriptor(filter, 'call')).toMatchObject({
|
||||
value: primordialCall,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
})
|
||||
expect(Object.isFrozen(filter)).toBe(true)
|
||||
expect(Reflect.set(filter, 'call', () => true)).toBe(false)
|
||||
expect(Reflect.defineProperty(filter, 'call', { value: () => true })).toBe(false)
|
||||
|
||||
ctx.emit(carrier, 'scope-test/ping', 'still-A-only')
|
||||
expect(heard).toEqual(['global:still-A-only', 'A:still-A-only'])
|
||||
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('keeps listener `this` base-shaped through the carrier (waterfall)', 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')
|
||||
})
|
||||
|
||||
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 the ordinary reflective surface while keeping overlays immutable', () => {
|
||||
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
|
||||
// The surrogate target frees reads from the base property's proxy
|
||||
// invariant, so even a frozen own method can be safely bound to the base.
|
||||
expect(carrier.pinned).not.toBe(frozenFn)
|
||||
expect(carrier.pinned()).toBe('frozen')
|
||||
// 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')
|
||||
expect('mutable' in carrier).toBe(true)
|
||||
expect(Object.hasOwn(carrier, 'mutable')).toBe(true)
|
||||
expect(Object.keys(carrier)).toEqual(['mutable', 'pinned', 'toString'])
|
||||
Object.defineProperty(carrier, 'extra', { value: 1, configurable: true })
|
||||
expect((base as typeof base & { extra?: number }).extra).toBe(1)
|
||||
expect(delete (carrier as typeof carrier & { extra?: number }).extra).toBe(true)
|
||||
|
||||
// A non-configurable property cannot be reflected truthfully through the
|
||||
// extensible surrogate. Reject before mutating the delegated base; an
|
||||
// omitted `configurable` has JavaScript's false default and is rejected too.
|
||||
expect(Reflect.defineProperty(carrier, 'sealed', { value: 1, configurable: false })).toBe(false)
|
||||
expect(Object.hasOwn(base, 'sealed')).toBe(false)
|
||||
expect(Reflect.defineProperty(carrier, 'default-sealed', { value: 2 })).toBe(false)
|
||||
expect(Object.hasOwn(base, 'default-sealed')).toBe(false)
|
||||
expect(Reflect.preventExtensions(carrier)).toBe(false)
|
||||
expect(Reflect.setPrototypeOf(carrier, null)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps isolation when the base filter is pinned before, during, or after construction', 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}`))
|
||||
const pinnedFilter = (): boolean => true
|
||||
|
||||
const pinnedData = {}
|
||||
Object.defineProperty(pinnedData, Context.filter, {
|
||||
value: pinnedFilter,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
})
|
||||
const pinnedCarrier = scopeTarget(pinnedData, keyA)
|
||||
ctx.emit(pinnedCarrier, 'scope-test/ping', 'before')
|
||||
|
||||
const duringRead = {}
|
||||
Object.defineProperty(duringRead, Context.filter, {
|
||||
configurable: true,
|
||||
get() {
|
||||
Object.defineProperty(duringRead, Context.filter, {
|
||||
value: pinnedFilter,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
})
|
||||
return pinnedFilter
|
||||
},
|
||||
})
|
||||
ctx.emit(scopeTarget(duringRead, keyA), 'scope-test/ping', 'during')
|
||||
|
||||
const pinnedAfter = { [Context.filter]: pinnedFilter }
|
||||
const afterCarrier = scopeTarget(pinnedAfter, keyA)
|
||||
Object.defineProperty(pinnedAfter, Context.filter, {
|
||||
value: pinnedFilter,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
})
|
||||
ctx.emit(afterCarrier, 'scope-test/ping', 'after')
|
||||
|
||||
const pinnedGetterless = {}
|
||||
Object.defineProperty(pinnedGetterless, Context.filter, { set(_value: unknown) {}, configurable: false })
|
||||
ctx.emit(scopeTarget(pinnedGetterless, keyA), 'scope-test/ping', 'getterless')
|
||||
|
||||
expect(heard).toEqual([
|
||||
'global:before', 'A:before',
|
||||
'global:during', 'A:during',
|
||||
'global:after', 'A:after',
|
||||
'global:getterless', 'A:getterless',
|
||||
])
|
||||
expect((pinnedCarrier as Record<symbol, unknown>)[Context.filter]).not.toBe(pinnedFilter)
|
||||
expect(Reflect.set(pinnedCarrier, Context.filter, pinnedFilter)).toBe(false)
|
||||
expect(Reflect.defineProperty(pinnedCarrier, Context.filter, { value: pinnedFilter })).toBe(false)
|
||||
expect(Reflect.deleteProperty(pinnedCarrier, Context.filter)).toBe(false)
|
||||
|
||||
expect(() => scopeTarget({ [Context.filter]: 1 }, { name: 'A' })).toThrow(
|
||||
/Context\.filter must be a function/,
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves callable and constructable bases', () => {
|
||||
function Subject(this: { value?: number }, value: number): number {
|
||||
if (new.target) {
|
||||
this.value = value
|
||||
return value
|
||||
}
|
||||
return value * 2
|
||||
}
|
||||
const carrier = scopeTarget(Subject as typeof Subject & (new (value: number) => { value: number }), {
|
||||
name: 'callable',
|
||||
})
|
||||
|
||||
const called: unknown = Reflect.apply(carrier, { value: 0 }, [3])
|
||||
expect(called).toBe(6)
|
||||
const instance = new carrier(4)
|
||||
expect(instance).toBeInstanceOf(Subject)
|
||||
expect(instance.value).toBe(4)
|
||||
const prototypeDescriptor = Object.getOwnPropertyDescriptor(carrier, 'prototype')
|
||||
const subjectPrototype: unknown = Reflect.get(Subject, 'prototype')
|
||||
expect(prototypeDescriptor?.configurable).toBe(true)
|
||||
expect(prototypeDescriptor?.value).toBe(subjectPrototype)
|
||||
class Derived extends carrier {}
|
||||
const derived = new Derived(5)
|
||||
expect(derived).toBeInstanceOf(Derived)
|
||||
expect(derived).toBeInstanceOf(Subject)
|
||||
expect(derived.value).toBe(5)
|
||||
expect(isScopeCarrier(carrier)).toBe(true)
|
||||
})
|
||||
|
||||
it('matches non-constructable and bound-constructor function shapes', () => {
|
||||
const arrow = (value: number): number => value + 1
|
||||
const arrowCarrier = scopeTarget(arrow, { name: 'arrow' })
|
||||
const arrowResult: unknown = Reflect.apply(arrowCarrier, undefined, [2])
|
||||
expect(arrowResult).toBe(3)
|
||||
expect('prototype' in arrowCarrier).toBe(false)
|
||||
expect(Object.getOwnPropertyDescriptor(arrowCarrier, 'prototype')).toBeUndefined()
|
||||
expect(() => { Reflect.construct(arrowCarrier, []) }).toThrow(TypeError)
|
||||
|
||||
class Subject {
|
||||
constructor(readonly value: number) {}
|
||||
}
|
||||
const bound = Subject.bind(undefined, 7)
|
||||
const boundCarrier = scopeTarget(bound, { name: 'bound-constructor' })
|
||||
expect('prototype' in boundCarrier).toBe(false)
|
||||
expect(Object.getOwnPropertyDescriptor(boundCarrier, 'prototype')).toBeUndefined()
|
||||
const instance = new boundCarrier()
|
||||
expect(instance).toBeInstanceOf(Subject)
|
||||
expect(instance.value).toBe(7)
|
||||
})
|
||||
|
||||
it('detects construction without reading a hostile base prototype', () => {
|
||||
class Subject {
|
||||
constructor(readonly value: number) {}
|
||||
}
|
||||
let prototypeReads = 0
|
||||
const hostile = new Proxy(Subject, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === 'prototype') {
|
||||
prototypeReads += 1
|
||||
throw new Error('hostile prototype getter')
|
||||
}
|
||||
return Reflect.get(target, prop, receiver) as unknown
|
||||
},
|
||||
})
|
||||
|
||||
const carrier = scopeTarget(hostile, { name: 'hostile-constructor' })
|
||||
expect(prototypeReads).toBe(0)
|
||||
const instance: unknown = Reflect.construct(carrier, [9], Subject)
|
||||
expect(instance).toBeInstanceOf(Subject)
|
||||
expect(instance).toMatchObject({ value: 9 })
|
||||
expect(prototypeReads).toBe(0)
|
||||
})
|
||||
|
||||
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('snapshots missing-service diagnostics across the host activation await', async () => {
|
||||
const ctx = new Context()
|
||||
const services = ['tools', 'systemPrompt']
|
||||
const pending = scopeHost(ctx, services)
|
||||
services.splice(0)
|
||||
|
||||
await expect(pending)
|
||||
.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