From 4151e5754cf406272c7a8dec8c318f87faba7abd Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 29 Aug 2026 18:24:18 +0200 Subject: [PATCH] feat: simplify Chord service instance API Deliver observed services as ordinary typed proxies, expose optional instance-key lookup, and rename the deferred multi-instance capability to ServiceSpawner. --- packages/agent/docs/plugins.md | 29 ++++++--------- packages/agent/docs/rpc.md | 12 +++--- packages/chord/PLANNING.md | 2 +- packages/chord/src/facets/host.ts | 37 ++++++------------- packages/chord/src/facets/types.ts | 15 +++----- packages/chord/src/index.ts | 11 ++---- packages/chord/src/services/consumer.ts | 9 ++--- packages/chord/src/services/contracts.ts | 10 +---- packages/chord/src/services/instances.ts | 16 ++++---- packages/chord/src/services/metadata.ts | 10 +++++ packages/chord/test/facets.test.ts | 16 ++++---- packages/chord/test/services.test.ts | 9 +++-- .../src/experimental/services/connection.ts | 6 +-- .../test/experimental-remote-runtime.test.ts | 4 +- .../test/fixtures/faux-session-worker.ts | 2 +- 15 files changed, 78 insertions(+), 110 deletions(-) create mode 100644 packages/chord/src/services/metadata.ts diff --git a/packages/agent/docs/plugins.md b/packages/agent/docs/plugins.md index 0428453da..367422531 100644 --- a/packages/agent/docs/plugins.md +++ b/packages/agent/docs/plugins.md @@ -117,15 +117,15 @@ Facets communicate across processes through **services**. One token type gives a function defineService(id: string, options?: { local?: boolean }): Service; ``` -The declaration lives in the shared contract module and creates nothing. Services are remotely publishable by default; a process-local token declares `{ local: true }`. `provide(service, implementation)` adds one singleton to the host service graph. `provideMany(service)` registers ownership of a multi-instance service during facet setup and returns an owned collection whose later `add(key, implementation)` calls add instances. The host publishes every non-local provision across its process boundary. Consumers select the same modes with `use(service)` or `observe(service, handler)`. Within one facet generation, a token must stay in one mode: mixing `provide`/`use` with `provideMany`/`observe` is an assembly or protocol error. +The declaration lives in the shared contract module and creates nothing. Services are remotely publishable by default; a process-local token declares `{ local: true }`. `provide(service, implementation)` adds one singleton to the host service graph. `provideMany(service)` registers ownership of a multi-instance service during facet setup and returns a `ServiceSpawner` whose later `spawn(key, implementation)` calls publish instances. The host publishes every non-local provision across its process boundary. Consumers select the same modes with `use(service)` or `observe(service, handler)`. Within one facet generation, a token must stay in one mode: mixing `provide`/`use` with `provideMany`/`observe` is an assembly or protocol error. ```ts -interface ServiceInstances { - add(key: string, implementation: T): () => void; +interface ServiceSpawner { + spawn(key: string, implementation: T): () => void; } ``` -TypeScript types cannot produce runtime member metadata. Facet authors nevertheless declare no parallel member descriptor. When an exposed `provide()` or `ServiceInstances.add()` implementation reaches the remote-service boundary, the runtime classifies functions as remote methods and recognizes branded `ReplicatedState` values. It rejects unsupported members and announces the resulting member table over the transport. Process-local services may use arbitrary object contracts. +TypeScript types cannot produce runtime member metadata. Facet authors nevertheless declare no parallel member descriptor. When an exposed `provide()` implementation or `ServiceSpawner.spawn()` instance reaches the remote-service boundary, the runtime classifies functions as remote methods and recognizes Chord-created `ReplicatedState` values. It rejects unsupported members and announces the resulting member table over the transport. Process-local services may use arbitrary object contracts. `use()` on a singleton returns a stable lazy proxy synchronously, even before a remote provider is attached. Member access creates local method or state slots as they are used; attachment validates those slots against the provider-announced kinds. A mismatch is an assembly or protocol error. This runtime mechanism is implemented once by the host rather than repeated in every service declaration. @@ -149,7 +149,7 @@ env.observe(QuestionDialogs, handler) → @pi/question:tui requires pi.question-dialog/keyed ``` -The first `provide()`, `provideMany()`, `use()`, or `observe()` for a token must occur during facet setup. Commands, hooks, event handlers, and activation callbacks use handles acquired during setup; they cannot introduce an undeclared service dependency later. Dynamic instances use the setup-owned `ServiceInstances` handle, so adding and closing instances do not change the graph. +The first `provide()`, `provideMany()`, `use()`, or `observe()` for a token must occur during facet setup. Commands, hooks, event handlers, and activation callbacks use handles acquired during setup; they cannot introduce an undeclared service dependency later. Dynamic instances use the setup-owned `ServiceSpawner`, so spawning and closing instances do not change the graph. After every facet has registered, the host generates its outgoing catalogue from non-local provisions, obtains catalogues from its connections, resolves requirements to local or connected provisions, rejects missing providers, duplicate offers or singleton owners, singleton/keyed mismatches, invalid dependency cycles, and invalid remote service implementations, then records consumer-to-provider edges for lifecycle ordering. `use()` and `observe()` declare hard requirements; optional dependencies require a future distinct acquisition API rather than inference from call failure. The ledger and resulting graph are private kernel machinery, not a facet-facing plan or second declaration format. @@ -271,14 +271,14 @@ The TUI facet has no credentials, registry, or refresh logic: it calls a typed l ### Service semantics -A service has **one owner and many consumers**. In singleton mode, `providersBuiltinSessionFacet` provides `Models` and both model-selection commands consume it. In multi-instance mode, one owner may add instances `A` and `B`, and every observer sees the same two instances. +A service has **one owner and many consumers**. In singleton mode, `providersBuiltinSessionFacet` provides `Models` and both model-selection commands consume it. In multi-instance mode, one owner may spawn instances `A` and `B`, and every observer sees the same two instances. `use()` behaves differently by locality: - **Local:** `use()` returns a stable lazy proxy backed by a direct process-local implementation slot. During synchronous setup it is disconnected; after assembly it binds to the local implementation without requiring provider-before-consumer setup order. Reload unbinds and rebinds that same slot. - **Remote:** across a connection, `use()` returns the same kind of stable lazy proxy. Calls made while disconnected fail when invoked; state has no value until hydrated. Concurrent consumers of one token in one process share one proxy, one state replica, and one remote subscription. -Multi-instance services use `provideMany()` and `observe()`. The service is empty until its setup-owned `ServiceInstances` handle calls `add()`; observing it never creates an instance. `instances.add(key, implementation)` returns an idempotent close function, and the key must be unique among that service's live instances. Local observers use a direct process-local instance registry; non-local provisions additionally publish the same instance through RPC. `observe(service, handler)` reconciles current instances and then ordered additions, replacements, and removals. After an instance's initial state members hydrate, the host starts one handler task with a fresh `Context`. Closing the instance aborts that context, rejects new calls, and lets already-admitted calls return. Cancellation from the instance context is normal task cleanup; other handler failures follow host failure policy. Reusing a closed key creates a new host-owned generation, so stale proxies cannot address the replacement. +Multi-instance services use `provideMany()` and `observe()`. The service is empty until its setup-owned `ServiceSpawner` calls `spawn()`; observing it never creates an instance. `spawner.spawn(key, implementation)` returns an idempotent close function, and the key must be unique among that service's live instances. Local observers use a direct process-local instance registry; non-local provisions additionally publish the same instance through RPC. `observe(service, handler)` reconciles current instances and then ordered additions, replacements, and removals. The handler receives the same `T` proxy shape as `use()`; `getServiceInstanceKey(proxy)` exposes its keyed application identity when needed. After an instance's initial state members hydrate, the host starts one handler task with a fresh `Context`. Closing the instance aborts that context, rejects new calls, and lets already-admitted calls return. Cancellation from the instance context is normal task cleanup; other handler failures follow host failure policy. Reusing a closed key creates a new host-owned generation, so stale proxies cannot address the replacement. An added instance member has structural identity `(service, key, generation, member)`. Its `ReplicatedState` members therefore need no independent IDs. The instance directory is control-plane metadata, not a facet-visible `ReplicatedState` containing proxies. Switching sessions aborts all observed instance tasks before hydrating the selected session's current instances. @@ -314,19 +314,14 @@ const Tools = defineService("pi.local.tools", { local: **Presentation facets hold none of this.** A TUI or web facet never receives the raw Harness, Session, tree, tool registry, hooks, or credentials. It uses host-local presentation services plus the semantic services and replicated state deliberately exposed by a Session or server facet. ```ts -interface RemoteServiceInstance { - readonly key: string; - readonly service: T; -} - interface FacetEnvironment extends FacetLifecycle { use(service: Service): T; observe( service: Service, - handler: (instance: RemoteServiceInstance, context: Context) => void | Promise, + handler: (service: T, context: Context) => void | Promise, ): () => void; provide(service: Service, implementation: T): void; - provideMany(service: Service): ServiceInstances; + provideMany(service: Service): ServiceSpawner; replicatedState(initial: T): MutableReplicatedState; } @@ -504,7 +499,7 @@ interface IndexJob { } ``` -An `IndexService.start(root, context)` returning an `IndexJob` validates the root, creates its own `AbortController` and a detached telemetry root, and returns the job. The job crosses the wire as a private **remote object reference** (`rpc.md`) known only to that caller. If every attached presentation must discover a job, register a multi-instance service with `provideMany()` during setup and add an instance instead. Discovery is the distinction: returned references are passed explicitly; added instances appear in `observe()` hydration. Both make the cancellation domains concrete, and both need explicit lifetime cleanup. +An `IndexService.start(root, context)` returning an `IndexJob` validates the root, creates its own `AbortController` and a detached telemetry root, and returns the job. The job crosses the wire as a private **remote object reference** (`rpc.md`) known only to that caller. If every attached presentation must discover a job, register a multi-instance service with `provideMany()` during setup and spawn an instance instead. Discovery is the distinction: returned references are passed explicitly; spawned instances appear in `observe()` hydration. Both make the cancellation domains concrete, and both need explicit lifetime cleanup. ## The server: directory, management, and routing @@ -772,7 +767,7 @@ export const questionSessionFacet = defineFacet({ if (response === undefined) { const completion = Promise.withResolvers(); const request = env.replicatedState(params); - const close = dialogs.add(invocation.invocationId, { + const close = dialogs.spawn(invocation.invocationId, { request, async submitAnswer(candidate, _answerContext) { if (candidate.outcome === "selected" && params.options[candidate.index] === undefined) { @@ -807,7 +802,7 @@ export const questionSessionFacet = defineFacet({ }); ``` -`dialogs.add()` installs the instance before `execute()` waits. The returned close function is the single normal, cancellation, and error cleanup path. Concurrent submissions call `memoOnce()`, whose atomic first-writer rule prevents overwrite and returns the same durable winner. `completion.resolve(committed)` makes the local wait follow that durable operation: success resumes the tool, while failure rejects it and runs the same cleanup instead of leaving it suspended. Each service call also awaits its own `committed` promise, so it cannot report success before durability or leave an ignored rejection. Calls through a closed instance or an old generation fail as stale service calls. +`dialogs.spawn()` installs the instance before `execute()` waits. The returned close function is the single normal, cancellation, and error cleanup path. Concurrent submissions call `memoOnce()`, whose atomic first-writer rule prevents overwrite and returns the same durable winner. `completion.resolve(committed)` makes the local wait follow that durable operation: success resumes the tool, while failure rejects it and runs the same cleanup instead of leaving it suspended. Each service call also awaits its own `committed` promise, so it cannot report success before durability or leave an ignored rejection. Calls through a closed instance or an old generation fail as stale service calls. ### TUI and web facets: observe every dialog instance diff --git a/packages/agent/docs/rpc.md b/packages/agent/docs/rpc.md index 98c0c6672..56802dd9e 100644 --- a/packages/agent/docs/rpc.md +++ b/packages/agent/docs/rpc.md @@ -9,10 +9,10 @@ runtime rejection of unsupported values to the concrete serializer. ## Role -`provide()`/`use()` and `provideMany().add()`/`observe()` are the facet system's hidden RPC. Facets share TypeScript service contracts, while the transport carries service/member identifiers, strict JSON values, request/subscription correlation, keyed generations, and binding control messages. Hosts construct the typed local implementation or facade; TypeScript types and arbitrary objects never cross the wire. Independently loaded processes may temporarily run different source generations, so their service contracts must remain forward compatible across the supported skew window; version negotiation remains deferred. +`provide()`/`use()` and `provideMany().spawn()`/`observe()` are the facet system's hidden RPC. Facets share TypeScript service contracts, while the transport carries service/member identifiers, strict JSON values, request/subscription correlation, keyed generations, and binding control messages. Hosts construct the typed local implementation or facade; TypeScript types and arbitrary objects never cross the wire. Independently loaded processes may temporarily run different source generations, so their service contracts must remain forward compatible across the supported skew window; version negotiation remains deferred. ```text -session/server facet: provide() / provideMany().add() +session/server facet: provide() / provideMany().spawn() ↕ hidden service RPC presentation/session facet: use() / observe() ``` @@ -25,9 +25,9 @@ Do not serialize `Context`, `AbortSignal`, telemetry objects, callbacks, tools, ## Service contracts and typed facades -A service token is a shared TypeScript contract and stable service ID. It is not a generated descriptor and creates no provider. Tokens are remotely publishable by default; process-local tokens declare `{ local: true }`. `provide()` adds one singleton implementation to the host graph. `provideMany()` registers one keyed-service owner during facet setup and returns a `ServiceInstances` handle whose later `add()` calls add instances. The host automatically publishes every non-local provision. A token has one mode in one host service graph: mixing singleton and keyed use is an error. +A service token is a shared TypeScript contract and stable service ID. It is not a generated descriptor and creates no provider. Tokens are remotely publishable by default; process-local tokens declare `{ local: true }`. `provide()` adds one singleton implementation to the host graph. `provideMany()` registers one multi-instance service owner during facet setup and returns a `ServiceSpawner` whose later `spawn()` calls publish instances. The host automatically publishes every non-local provision. A token has one mode in one host service graph: mixing singleton and keyed use is an error. -The provider classifies every exposed implementation member as a method or branded `ReplicatedState` and publishes that member table in subscription snapshots. The consumer obtains member names from ordinary property access—for example, a JavaScript `Proxy` receives `"state"` for `models.state` and `"refresh"` for `models.refresh(context)`. Accessed slots are validated against the provider-announced kind when the facade binds. +The provider classifies every exposed implementation member as a method or Chord-created `ReplicatedState` and publishes that member table in subscription snapshots. The consumer obtains member names from ordinary property access—for example, a JavaScript `Proxy` receives `"state"` for `models.state` and `"refresh"` for `models.refresh(context)`. Accessed slots are validated against the provider-announced kind when the facade binds. Local and remote `use()` both return a stable, lazy typed facade shared by consumers of that token. During synchronous facet setup the facade is disconnected, so setup can capture it but cannot invoke methods, read state, or register member subscriptions. After assembly, a local facade resolves through a direct process-local implementation slot and a remote facade binds through the host's connected services. Reloading the providing facet temporarily marks that same facade unavailable, then swaps its target; RPC singletons clear readiness and install a complete replacement snapshot on their existing subscription so captured methods and member facades address the replacement. While no provider is bound, invoking a method fails and state remains unhydrated; no call is queued merely because it was made through a proxy. @@ -48,7 +48,7 @@ Type erasure does not hide service identity: every service token retains its sta Facets always call unqualified `env.use()` or `env.observe()`; routing is not encoded in the call. These operations return source-independent disconnected handles during setup. After setup, each connection returns its provider-generated service catalogue, and the host binds every requirement to its local provision or exactly one connected provider. The method supplies the mode and the token supplies the ID. The host therefore needs no reflection over the erased `T` and no handwritten parallel dependency list. -First acquisition or provision is permitted only during facet setup. Later commands, hooks, and activation callbacks use setup-acquired singleton facades, observer registrations, or `ServiceInstances` handles. In particular, dynamic instances are added through the handle returned by `provideMany()`; a late direct addition cannot introduce a previously undeclared provision. +First acquisition or provision is permitted only during facet setup. Later commands, hooks, and activation callbacks use setup-acquired singleton facades, observer registrations, or `ServiceSpawner` capabilities. In particular, dynamic instances are spawned through the capability returned by `provideMany()`; late spawning cannot introduce a previously undeclared provision. After setup, each host privately resolves the recorded requirements against local provisions and connection catalogues to reject missing providers, duplicate remote offers, and mode mismatches and to derive lifecycle edges. A selected-Session connection with no live attachment may provisionally accept unresolved requirements as unavailable; attachment validates them against the worker's generated catalogue and caches that catalogue for later detached generations. Connection bindings are generation-owned and include only selected requirements, so failed or retired generations release their subscriptions without disposing the underlying transport connection. This internal service graph is distinct from the module loader's source import graph; it is not a facet-authored or facet-visible plan. @@ -116,7 +116,7 @@ Three cancellation domains remain separate: aborting one RPC invocation; explici ## Security and lifecycle -Only loaded service tokens not marked local may be registered at the remote boundary. Only the owning `ServiceInstances` handle may add instances. Services marked `{ local: true }` are never discoverable remotely. Local services may use unrestricted object contracts, while remote providers validate member kinds and every JSON business value. Clients cannot forge control envelopes as ordinary values, choose instance generations, select a different Session route in a service call, or cancel another client's request. +Only loaded service tokens not marked local may be registered at the remote boundary. Only the owning `ServiceSpawner` may spawn instances. Services marked `{ local: true }` are never discoverable remotely. Local services may use unrestricted object contracts. Remote providers validate member kinds, while concrete serializers enforce JSON business values. Clients cannot forge control envelopes as ordinary values, choose instance generations, select a different Session route in a service call, or cancel another client's request. The server authenticates connections, authorizes attachment, and reconstructs client identity in the service `Context`. Ordinary business arguments never carry authority. Credentials, prompts, completions, tool data, filesystem contents, and other sensitive values require an explicit presentation-safe contract. diff --git a/packages/chord/PLANNING.md b/packages/chord/PLANNING.md index 818d79cc9..f429bf078 100644 --- a/packages/chord/PLANNING.md +++ b/packages/chord/PLANNING.md @@ -342,7 +342,7 @@ Required behavior: ### 7.5 Keyed services -`provideMany(token)` declares one keyed owner during setup and returns a generation-owned collection handle. While active, `add(key, implementation)`: +`provideMany(token)` declares one keyed owner during setup and returns a generation-owned `ServiceSpawner`. While active, `spawn(key, implementation)`: - requires a non-empty key unique among live instances; - creates a host-owned monotonically increasing generation for that key; diff --git a/packages/chord/src/facets/host.ts b/packages/chord/src/facets/host.ts index 47ce49d0d..49f333b2a 100644 --- a/packages/chord/src/facets/host.ts +++ b/packages/chord/src/facets/host.ts @@ -1,16 +1,10 @@ import { BACKGROUND_CONTEXT, type Context } from "../context.ts"; -import type { - RemoteServiceContract, - RemoteServiceInstance, - RemoteServices, - Service, - ServiceMode, -} from "../services/contracts.ts"; +import type { RemoteServiceContract, RemoteServices, Service, ServiceMode } from "../services/contracts.ts"; import { ServiceHandle } from "../services/handle.ts"; import { InstanceDirectory, type InstanceDirectoryEntry } from "../services/instances.ts"; import { RemoteServiceProvider, validateRemoteServiceImplementation } from "../services/provider.ts"; import { replicatedState } from "../state.ts"; -import type { Facet, FacetConnection, FacetEnvironment, FacetHost, FacetOptions, ServiceInstances } from "./types.ts"; +import type { Facet, FacetConnection, FacetEnvironment, FacetHost, FacetOptions, ServiceSpawner } from "./types.ts"; interface FacetServiceReference { readonly serviceId: string; @@ -121,10 +115,7 @@ interface StagedObservation { } interface KeyedServiceSource { - observe( - service: Service, - handler: (instance: RemoteServiceInstance, context: Context) => void | Promise, - ): () => void; + observe(service: Service, handler: (service: T, context: Context) => void | Promise): () => void; } interface LocalKeyedRegistration { @@ -182,10 +173,7 @@ class LocalKeyedServiceRegistry implements KeyedServiceSource { }; } - observe( - service: Service, - handler: (instance: RemoteServiceInstance, context: Context) => void | Promise, - ): () => void { + observe(service: Service, handler: (service: T, context: Context) => void | Promise): () => void { this.#assertActive(); return this.#registration(service.id).directory.observe(handler); } @@ -228,10 +216,7 @@ class FacetServiceHandles { return handle.proxy as T; } - observe( - service: Service, - handler: (instance: RemoteServiceInstance, context: Context) => void | Promise, - ): StagedObservation { + observe(service: Service, handler: (service: T, context: Context) => void | Promise): StagedObservation { let close: (() => void) | undefined; let closed = false; const stop = (): void => { @@ -281,7 +266,7 @@ class FacetServiceHandles { type ServiceInstanceInstaller = (key: string, implementation: T) => () => void; -class StagedServiceInstances implements ServiceInstances { +class StagedServiceSpawner implements ServiceSpawner { readonly #lifecycle: FacetLifecycle; readonly #installers: ServiceInstanceInstaller[] = []; @@ -293,8 +278,8 @@ class StagedServiceInstances implements ServiceInstances { this.#installers.push(installer); } - add(key: string, implementation: T): () => void { - this.#lifecycle.assertActive("add service instances"); + spawn(key: string, implementation: T): () => void { + this.#lifecycle.assertActive("spawn service instances"); if (this.#installers.length === 0) throw new Error("Facet service provider is not connected"); const closes: Array<() => void> = []; try { @@ -570,10 +555,10 @@ class FacetKernel { replace: (provider) => provider.replace(service, implementation as NoInfer>), }); }, - provideMany: (service: Service): ServiceInstances => { + provideMany: (service: Service): ServiceSpawner => { lifecycle.assertSettingUp("provide service instances"); recordServiceReference(runtime.provides, service, "keyed"); - const instances = new StagedServiceInstances(lifecycle); + const instances = new StagedServiceSpawner(lifecycle); provisions.push({ kind: "keyed", service, @@ -593,7 +578,7 @@ class FacetKernel { }, observe: ( service: Service, - handler: (instance: RemoteServiceInstance, context: Context) => void | Promise, + handler: (service: T, context: Context) => void | Promise, ): (() => void) => { lifecycle.assertSettingUp("observe services"); recordServiceReference(runtime.requires, service, "keyed"); diff --git a/packages/chord/src/facets/types.ts b/packages/chord/src/facets/types.ts index 685e7002f..108c0154c 100644 --- a/packages/chord/src/facets/types.ts +++ b/packages/chord/src/facets/types.ts @@ -1,25 +1,22 @@ import type { Context } from "../context.ts"; -import type { RemoteServiceInstance, RemoteServices, Service } from "../services/contracts.ts"; +import type { RemoteServices, Service } from "../services/contracts.ts"; import type { ServiceCatalogueEntry } from "../services/protocol.ts"; import type { RemoteServiceProvider } from "../services/provider.ts"; import type { MutableReplicatedState } from "../state.ts"; -export interface ServiceInstances { - add(key: string, implementation: T): () => void; +export interface ServiceSpawner { + spawn(key: string, implementation: T): () => void; } export interface FacetEnvironment { /** Declare a hard dependency on one singleton service and return its stable handle. */ use(service: Service): T; /** Declare a hard dependency on a keyed service and observe each live instance. */ - observe( - service: Service, - handler: (instance: RemoteServiceInstance, context: Context) => void | Promise, - ): () => void; + observe(service: Service, handler: (service: T, context: Context) => void | Promise): () => void; /** Declare and install this facet's singleton implementation of a service. */ provide(service: Service, implementation: NoInfer): void; - /** Declare ownership of a keyed service and return its live-instance collection. */ - provideMany(service: Service): ServiceInstances; + /** Declare ownership of a multi-instance service and return its deferred spawning capability. */ + provideMany(service: Service): ServiceSpawner; /** Create initialized mutable state suitable for exposing through a service implementation. */ replicatedState(initial: T): MutableReplicatedState; /** Give the facet ownership of a resource cleanup function. */ diff --git a/packages/chord/src/index.ts b/packages/chord/src/index.ts index 8bdb4f76d..29a22c1d1 100644 --- a/packages/chord/src/index.ts +++ b/packages/chord/src/index.ts @@ -18,7 +18,7 @@ export { type FacetEnvironment, type FacetHost, type FacetOptions, - type ServiceInstances, + type ServiceSpawner, } from "./facets/types.ts"; export type { JsonValue } from "./json.ts"; export { @@ -26,14 +26,9 @@ export { type RemoteServiceBinding, type RemoteServiceBindingOptions, } from "./services/consumer.ts"; -export { - defineService, - type RemoteServiceInstance, - type RemoteServices, - type Service, - type ServiceMode, -} from "./services/contracts.ts"; +export { defineService, type RemoteServices, type Service, type ServiceMode } from "./services/contracts.ts"; export { RemoteServiceError, type RemoteServiceErrorCode } from "./services/errors.ts"; +export { getServiceInstanceKey } from "./services/metadata.ts"; export type { RemoteServiceConnection, ServiceCall, diff --git a/packages/chord/src/services/consumer.ts b/packages/chord/src/services/consumer.ts index a783c0640..f21796c7d 100644 --- a/packages/chord/src/services/consumer.ts +++ b/packages/chord/src/services/consumer.ts @@ -1,7 +1,7 @@ import { awaitWithContext, BACKGROUND_CONTEXT, type Context } from "../context.ts"; import type { JsonValue } from "../json.ts"; import { freshDeliveryContext, ReplicatedStateReplica } from "../state.ts"; -import type { RemoteServiceInstance, RemoteServices, Service } from "./contracts.ts"; +import type { RemoteServices, Service } from "./contracts.ts"; import { RemoteServiceError } from "./errors.ts"; import { InstanceDirectory, type InstanceDirectoryEntry } from "./instances.ts"; import type { @@ -281,7 +281,7 @@ class KeyedBinding { this.#instances = new InstanceDirectory({ ready: false, onError: reportError }); } - observe(handler: (instance: RemoteServiceInstance, context: Context) => void | Promise): () => void { + observe(handler: (service: T, context: Context) => void | Promise): () => void { if (this.#closed) throw new Error("Remote keyed service binding is closed"); const stop = this.#instances.observe(handler); if (this.#bound && this.#starting === undefined) { @@ -466,10 +466,7 @@ class RemoteServiceBindingImpl implements RemoteServiceBinding { return binding.facade.proxy as T; } - observe( - service: Service, - handler: (instance: RemoteServiceInstance, context: Context) => void | Promise, - ): () => void { + observe(service: Service, handler: (service: T, context: Context) => void | Promise): () => void { this.#assertRemotable(service); this.#assertAvailable(service.id, "keyed"); let binding = this.#keyed.get(service.id) as KeyedBinding | undefined; diff --git a/packages/chord/src/services/contracts.ts b/packages/chord/src/services/contracts.ts index 04c2c16f3..083aa7ce8 100644 --- a/packages/chord/src/services/contracts.ts +++ b/packages/chord/src/services/contracts.ts @@ -65,17 +65,9 @@ export function defineService(id: string, options?: { readonly local?: boolean } return Object.freeze({ id, local: options?.local ?? false }); } -export interface RemoteServiceInstance { - readonly key: string; - readonly service: T; -} - export interface RemoteServices { use(service: Service): T; - observe( - service: Service, - handler: (instance: RemoteServiceInstance, context: Context) => void | Promise, - ): () => void; + observe(service: Service, handler: (service: T, context: Context) => void | Promise): () => void; /** Activate this service set and wait for every acquired service to become ready. */ activate(context: Context): Promise; /** Wait until every currently acquired service has installed its initial snapshot. */ diff --git a/packages/chord/src/services/instances.ts b/packages/chord/src/services/instances.ts index 6d49dca02..8fd81e63f 100644 --- a/packages/chord/src/services/instances.ts +++ b/packages/chord/src/services/instances.ts @@ -1,5 +1,5 @@ import { BACKGROUND_CONTEXT, type Context, withCancel } from "../context.ts"; -import type { RemoteServiceInstance } from "./contracts.ts"; +import { registerServiceInstance } from "./metadata.ts"; export interface InstanceDirectoryEntry { readonly key: string; @@ -9,7 +9,7 @@ export interface InstanceDirectoryEntry { } interface Observer { - readonly handler: (instance: RemoteServiceInstance, context: Context) => void | Promise; + readonly handler: (service: object, context: Context) => void | Promise; readonly tasks: Map void }>; closed: boolean; } @@ -41,6 +41,7 @@ export class InstanceDirectory { throw new Error(`Keyed service already has a live instance with key ${entry.key}`); } this.#entries.set(entry.key, entry); + registerServiceInstance(entry.service, entry.key); if (this.#ready) this.#startAll(entry); } @@ -54,6 +55,7 @@ export class InstanceDirectory { this.#remove(previous); } this.#entries.set(entry.key, entry); + registerServiceInstance(entry.service, entry.key); if (this.#ready) this.#startAll(entry); } @@ -75,7 +77,7 @@ export class InstanceDirectory { for (const entry of [...this.#entries.values()]) this.#remove(entry); } - observe(handler: (instance: RemoteServiceInstance, context: Context) => void | Promise): () => void { + observe(handler: (service: T, context: Context) => void | Promise): () => void { this.#assertActive(); const observer: Observer = { handler: handler as Observer["handler"], @@ -129,11 +131,9 @@ export class InstanceDirectory { const { context, cancel } = withCancel(BACKGROUND_CONTEXT); observer.tasks.set(entry, { cancel }); try { - void Promise.resolve(observer.handler({ key: entry.key, service: entry.service }, context)).catch( - (error: unknown) => { - if (!context.abortSignal?.aborted) this.#reportError(toError(error)); - }, - ); + void Promise.resolve(observer.handler(entry.service, context)).catch((error: unknown) => { + if (!context.abortSignal?.aborted) this.#reportError(toError(error)); + }); } catch (error) { if (!context.abortSignal?.aborted) this.#reportError(toError(error)); } diff --git a/packages/chord/src/services/metadata.ts b/packages/chord/src/services/metadata.ts new file mode 100644 index 000000000..d34c71aa4 --- /dev/null +++ b/packages/chord/src/services/metadata.ts @@ -0,0 +1,10 @@ +const instanceKeys = new WeakMap(); + +export function registerServiceInstance(service: object, key: string): void { + instanceKeys.set(service, key); +} + +/** Return the application key for a keyed service proxy, or undefined for a singleton. */ +export function getServiceInstanceKey(service: object): string | undefined { + return instanceKeys.get(service); +} diff --git a/packages/chord/test/facets.test.ts b/packages/chord/test/facets.test.ts index 2f4018e28..2b18e0a90 100644 --- a/packages/chord/test/facets.test.ts +++ b/packages/chord/test/facets.test.ts @@ -116,8 +116,8 @@ describe("facet host", () => { const observer = defineFacet({ id: "observer", setup(env) { - env.observe(KeyedValue, async (instance, context) => { - trace.push(`observe ${await instance.service.read(context)}`); + env.observe(KeyedValue, async (service, context) => { + trace.push(`observe ${await service.read(context)}`); }); env.onActivate(() => { trace.push("activate observer"); @@ -130,7 +130,7 @@ describe("facet host", () => { const values = env.provideMany(KeyedValue); env.onActivate(() => { trace.push("activate provider"); - values.add("one", { + values.spawn("one", { async read() { return "one"; }, @@ -147,8 +147,8 @@ describe("facet host", () => { connection: createLoopbackServiceConnection(host.services), }); const remoteValues: string[] = []; - remoteServices.observe(KeyedValue, async (instance, context) => { - remoteValues.push(await instance.service.read(context)); + remoteServices.observe(KeyedValue, async (service, context) => { + remoteValues.push(await service.read(context)); }); await remoteServices.ready(BACKGROUND_CONTEXT); await vi.waitFor(() => expect(remoteValues).toEqual(["one"])); @@ -162,8 +162,8 @@ describe("facet host", () => { const consumer = defineFacet({ id: "local-keyed-consumer", setup(env) { - env.observe(LocalKeyedValue, (instance, context) => { - observed.push({ service: instance.service, context }); + env.observe(LocalKeyedValue, (service, context) => { + observed.push({ service, context }); }); }, }); @@ -173,7 +173,7 @@ describe("facet host", () => { setup(env) { const values = env.provideMany(LocalKeyedValue); env.onActivate(() => { - values.add("current", { + values.spawn("current", { metadata: new Map([["value", value]]), read: () => value, }); diff --git a/packages/chord/test/services.test.ts b/packages/chord/test/services.test.ts index 9e8a39dc8..8ae74b890 100644 --- a/packages/chord/test/services.test.ts +++ b/packages/chord/test/services.test.ts @@ -5,6 +5,7 @@ import { createLoopbackServiceConnection, createRemoteServiceBinding, defineService, + getServiceInstanceKey, type JsonValue, type RemoteServiceConnection, RemoteServiceProvider, @@ -348,11 +349,11 @@ describe("remote services", () => { service: QuestionDialogs; context: Context; }[] = []; - const stop = namespace.observe(QuestionDialogs, (instance, context) => { + const stop = namespace.observe(QuestionDialogs, (service, context) => { observed.push({ - key: instance.key, - question: instance.service.request.value, - service: instance.service, + key: getServiceInstanceKey(service)!, + question: service.request.value, + service, context, }); }); diff --git a/packages/coding-agent/src/experimental/services/connection.ts b/packages/coding-agent/src/experimental/services/connection.ts index 59f4514b0..c171d2c01 100644 --- a/packages/coding-agent/src/experimental/services/connection.ts +++ b/packages/coding-agent/src/experimental/services/connection.ts @@ -8,7 +8,6 @@ import { type MutableReplicatedState, type RemoteServiceBinding, type RemoteServiceConnection, - type RemoteServiceInstance, type RemoteServices, type ReplicatedState, replicatedState, @@ -86,10 +85,7 @@ class RoutedServiceBinding implements RemoteServices { return this.#services.use(service); } - observe( - service: Service, - handler: (instance: RemoteServiceInstance, context: Context) => void | Promise, - ): () => void { + observe(service: Service, handler: (service: T, context: Context) => void | Promise): () => void { return this.#services.observe(service, handler); } diff --git a/packages/coding-agent/test/experimental-remote-runtime.test.ts b/packages/coding-agent/test/experimental-remote-runtime.test.ts index de5f9b7c8..b16cc90f2 100644 --- a/packages/coding-agent/test/experimental-remote-runtime.test.ts +++ b/packages/coding-agent/test/experimental-remote-runtime.test.ts @@ -553,8 +553,8 @@ describe("experimental durable server composition", () => { const consumer = defineFacet({ id: "@test/keyed-probe-consumer", setup(env) { - env.observe(KeyedProbe, (instance, context) => { - observed.push({ service: instance.service, context, value: instance.service.state.value?.value }); + env.observe(KeyedProbe, (service, context) => { + observed.push({ service, context, value: service.state.value?.value }); }); }, }); diff --git a/packages/coding-agent/test/fixtures/faux-session-worker.ts b/packages/coding-agent/test/fixtures/faux-session-worker.ts index 15e4b5458..4d590e9be 100644 --- a/packages/coding-agent/test/fixtures/faux-session-worker.ts +++ b/packages/coding-agent/test/fixtures/faux-session-worker.ts @@ -37,7 +37,7 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath( const spawn = (value: string): void => { const state = env.replicatedState({ value }); let close = (): void => {}; - close = probes.add("probe", { + close = probes.spawn("probe", { state, async replace(next) { close();