diff --git a/packages/agent/docs/plugins.md b/packages/agent/docs/plugins.md index 84f2afd6f..97debfebd 100644 --- a/packages/agent/docs/plugins.md +++ b/packages/agent/docs/plugins.md @@ -110,10 +110,10 @@ Out of scope: arbitrary undeclared object remoting, serialized functions/classes Facets communicate across processes through **services**. One token type gives a service contract its identity: ```ts -function defineService(id: string, options?: { rpc?: boolean }): Service; +function defineService(id: string, options?: { local?: boolean }): Service; ``` -The declaration lives in the shared contract module and creates nothing. Services are RPC-capable by default; a process-local token declares `{ rpc: false }`. `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 automatically publishes provided RPC-capable services 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 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. ```ts interface ServiceInstances { @@ -147,7 +147,7 @@ env.observe(QuestionDialogs, handler) 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. -After every facet has registered, the host generates its outgoing catalogue from RPC-capable 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 RPC 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. +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. Only dependencies acquired through `env.use()` or `env.observe()` belong to this lifecycle graph. Importing another extension's live implementation bypasses ownership and is unsupported. The module loader separately owns the ordinary source import graph. Reload therefore needs both loaded-source ownership and the generated service graph; see [Reloading facets](#reloading-facets). @@ -271,10 +271,10 @@ A service has **one owner and many consumers**. In singleton mode, `providersBui `use()` behaves differently by locality: -- **Local:** `use()` returns a stable lazy proxy backed by an in-process connection. During synchronous setup it is disconnected; after assembly it binds to the local implementation without requiring provider-before-consumer setup order. +- **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. `observe(service, handler)` reconciles a snapshot of 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 `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. 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. @@ -300,9 +300,9 @@ interface AgentFacetScope { lane(name: string, context: Context): Promise; } -const Agent = defineService("pi.local.agent", { rpc: false }); -const Providers = defineService("pi.local.providers", { rpc: false }); -const Tools = defineService("pi.local.tools", { rpc: false }); +const Agent = defineService("pi.local.agent", { local: true }); +const Providers = defineService("pi.local.providers", { local: true }); +const Tools = defineService("pi.local.tools", { local: true }); ``` "Local" and "unrestricted" are separate decisions. The scope narrows authority for lifecycle and composition — hooks and event subscriptions registered through it are automatically owned by the facet and disposed with it. `AgentLaneFacetView` exposes Branch methods directly alongside agent operations. `ScopedSessionData` exposes purpose-bounded durable operations. The host keeps the unrestricted concrete instances and reserves: `AgentHarness.close()` and `Session.close()`; raw `Session.mutate()`, `beginMutation()`, and `SessionMutator` (unless a narrowly trusted durability extension explicitly owns them); `idGenerator` and backend/storage objects; Branch creation; whole-registry setters such as `setTools()`; unscoped hook/event registration; transport exposure and remote-reference registration. This is a composition and lifecycle boundary, not a security sandbox: session facets are trusted code in the authoritative process. A future extension policy may explicitly grant broader local capability, but built-ins should receive no implicit bypass. @@ -348,7 +348,7 @@ interface TuiHost { select(title: string, items: SelectItem[], options: { signal: AbortSignal }): Promise; } -const Tui = defineService("pi.local.tui", { rpc: false }); +const Tui = defineService("pi.local.tui", { local: true }); ``` `acquireModal()` waits in one presentation-owned queue and holds the modal slot across a multi-step interaction. Its signal removes a queued request or dismisses an active one, and `close()` is idempotent. `select()` is the one-step acquire/select/close convenience. Both return selected values directly, so feature code never recovers identity from a display label. @@ -374,10 +374,10 @@ Its TUI facet consumes `AgentController` through `env.use()` exactly as the mode ## Local services and narrow remote facades -Not every dependency should be remotely reachable. A **local service** is a token confined to its providing process. It may hold functions, native objects, credentials, or filesystem handles; remote `use()` cannot resolve it, and local services are never discoverable remotely. The pattern for sensitive state is a local full service plus a narrow remote facade: +Not every dependency should be remotely reachable. A **local service** is a token declared with `{ local: true }` and confined to its providing process. It may use synchronous methods and hold functions, classes, native objects, credentials, filesystem handles, or other non-JSON values. Remote `use()` cannot resolve it, and local services are never discoverable remotely. Local and non-local provisions share dependency ordering, stable handles, keyed generations, activation, disposal, and provider-facet reload; non-local services only add validation, replication, and RPC publication. The pattern for sensitive state is a local full service plus a narrow remote facade: ```ts -const Credentials = defineService("credentials", { rpc: false }); // get/set provider secrets +const Credentials = defineService("credentials", { local: true }); // get/set provider secrets interface Accounts { readonly state: ReplicatedState<{ providers: Array<{ provider: string; configured: boolean }> }>; @@ -512,7 +512,7 @@ interface FleetFacetScope { readonly attachments: AttachmentsView; // bind/unbind a client's selected session } -const Fleet = defineService("pi.local.fleet", { rpc: false }); +const Fleet = defineService("pi.local.fleet", { local: true }); ``` The raw `SessionRepo`, storage handles, unrestricted process-kill authority, routing map, and routing machinery stay with the server application: @@ -936,7 +936,7 @@ Errors cross the wire as a JSON envelope `{ code, message }` with stable protoco Boundary rules: -- RPC-capable service IDs come from trusted loaded service tokens; the remote boundary accepts only implementation functions and branded replicated-state members, instance generations are host-owned, and `{ rpc: false }` services are never discoverable remotely; +- remotely publishable service IDs come from trusted loaded service tokens; the remote boundary accepts only implementation functions and branded replicated-state members, instance generations are host-owned, and `{ local: true }` services are never discoverable remotely; - business arguments, results, and state are validated as JSON; protocol envelopes cannot be forged as ordinary values; - clients cannot choose context position, instance generations, selected-Session routing fields, or cancellation targets other than their own requests; and - credentials, prompts, completions, tool arguments/results, and filesystem contents are not exposed unless an explicit contract permits them. @@ -1037,7 +1037,7 @@ The client never supplies a patch, author, or review ID. The session computes a ### Narrow local durability capabilities -Unlike a question, this interaction has no invocation memo. The Session facet uses three process-local capabilities: a diff source that snapshots the working tree, a review store that serializes record mutations, and a prompt queue with idempotent `enqueueOnce()`. One durable review record contains the immutable patch, revisioned comments, status, and an optional frozen `{ submissionId, prompt }`. These local capabilities are ordinary `{ rpc: false }` services; their repository APIs are not part of the extension's shared contract. +Unlike a question, this interaction has no invocation memo. The Session facet uses three process-local capabilities: a diff source that snapshots the working tree, a review store that serializes record mutations, and a prompt queue with idempotent `enqueueOnce()`. One durable review record contains the immutable patch, revisioned comments, status, and an optional frozen `{ submissionId, prompt }`. These local capabilities are ordinary `{ local: true }` services; their repository APIs are not part of the extension's shared contract. `DiffReviewRecords` serializes mutations per review. `addComment()` validates the anchor against the stored patch, stamps the authenticated author, deduplicates `commentId`, commits, and then returns the new revision. `freezeForSubmission()` atomically excludes later comments and stores a stable submission ID plus a prompt containing the immutable patch and that exact comment snapshot. If submission was already frozen, it returns the same record. `PromptQueue.enqueueOnce()` returns only after that logical prompt is durably accepted; retrying its submission ID cannot enqueue a second prompt. diff --git a/packages/agent/docs/rpc.md b/packages/agent/docs/rpc.md index c2d2412bf..8c6193e8c 100644 --- a/packages/agent/docs/rpc.md +++ b/packages/agent/docs/rpc.md @@ -20,16 +20,18 @@ 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 RPC-capable by default; process-local tokens declare `{ rpc: false }`. `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 provided RPC-capable service. 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 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. 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. -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 to its facet-provided implementation 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. +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. Remote methods return promises and accept and return strict JSON apart from their declared `Context`; `void` is a successful response without a result field. Private returned references are not supported. The client removes the context before transport and the receiving host constructs a fresh local context. The contract position is host-controlled and must be consistent; the examples use one required trailing `Context`. Business absence is JSON `null` or an options object, never transported `undefined`. Use static assertions and runtime validation. Static checks constrain remote methods and replicated-state members; runtime boundaries reject unsupported members and non-JSON arguments, results, and state values. TypeScript supplies typed facades but does not authenticate a peer or create runtime metadata. +`{ local: true }` removes only remote publication and its wire-contract restrictions. Local and non-local provisions otherwise use the same dependency ledger, activation order, stable singleton slots, keyed-instance generations, observer cancellation, disposal, and provider-facet reload. Local singleton slots and the local keyed registry hold arbitrary object contracts directly; non-local provisions additionally install their implementations in the remote provider. + ## Dependency ledger Type erasure does not hide service identity: every service token retains its stable ID at runtime. Facet environments are created by the host with a non-forgeable owner identity, and their setup-time service methods append to a generation-scoped ledger: @@ -109,7 +111,7 @@ Three cancellation domains remain separate: aborting one RPC invocation; explici ## Security and lifecycle -Only loaded service tokens marked RPC-capable may be registered at the remote boundary. Only the owning `ServiceInstances` handle may add instances. Services marked `{ rpc: false }` are never discoverable remotely. 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 `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. 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. @@ -119,7 +121,7 @@ Facet environments own registrations, added service instances, observations, and The facet-facing semantics are tested over loopback and framed transports: -- setup-time dependency-ledger ownership, rejection of late acquisition, local and remote `use()`, keyed-provider ownership, singleton/keyed mode validation, token-driven RPC publication, lazy member access, stable local and RPC singleton facades across provider-facet replacement, and `{ rpc: false }` services remaining unreachable remotely; +- setup-time dependency-ledger ownership, rejection of late acquisition, local and remote `use()`, keyed-provider ownership, singleton/keyed mode validation, token-driven RPC publication, lazy member access, stable local and RPC singleton facades across provider-facet replacement, and `{ local: true }` services remaining unreachable remotely; - strict JSON boundaries, method context reconstruction, and request cancellation isolation without serializing context values; - server/Session facet isolation, selected-Session switching, stale-frame rejection, and worker-side per-client request correlation; - cold and hydrated `ReplicatedState`, snapshot/update race freedom, fresh delivery contexts, and clearing/re-hydration on disconnect, reconnect, and provider replacement; diff --git a/packages/agent/src/plugins/services/namespace.ts b/packages/agent/src/plugins/services/namespace.ts index dacfe5ee5..de8a38c4d 100644 --- a/packages/agent/src/plugins/services/namespace.ts +++ b/packages/agent/src/plugins/services/namespace.ts @@ -570,6 +570,7 @@ export class RemoteServiceNamespace implements RemoteServices { } use(service: Service): T { + this.#assertRemotable(service); this.#assertAvailable(service.id, "singleton"); let binding = this.#singletons.get(service.id); if (binding !== undefined) return binding.facade.proxy as T; @@ -601,6 +602,7 @@ export class RemoteServiceNamespace implements RemoteServices { service: Service, handler: (instance: RemoteServiceInstance, context: Context) => void | Promise, ): () => void { + this.#assertRemotable(service); this.#assertAvailable(service.id, "keyed"); let binding = this.#keyed.get(service.id) as KeyedBinding | undefined; if (binding === undefined) { @@ -741,6 +743,10 @@ export class RemoteServiceNamespace implements RemoteServices { subscription.activate(); } + #assertRemotable(service: { readonly id: string; readonly local: boolean }): void { + if (service.local) throw new RemoteServiceError("service_not_allowed", `Service ${service.id} is process-local`); + } + #assertAvailable(serviceId: string, mode: "singleton" | "keyed"): void { if (this.#disposed) throw new Error("Remote service namespace is disposed"); if (!this.#allowlist.has(serviceId)) { diff --git a/packages/agent/src/plugins/services/provider.ts b/packages/agent/src/plugins/services/provider.ts index 9ef643d52..186ac9f50 100644 --- a/packages/agent/src/plugins/services/provider.ts +++ b/packages/agent/src/plugins/services/provider.ts @@ -84,6 +84,11 @@ export class RemoteServiceProvider { const definitions = entries.map( (entry): ServiceProviderDefinition => ("service" in entry ? entry : { service: entry, mode: "singleton" }), ); + for (const { service } of definitions) { + if ("local" in service && service.local === true) { + throw new TypeError(`Local service ${service.id} cannot be published remotely`); + } + } const ids = definitions.map(({ service }) => service.id); if (new Set(ids).size !== ids.length) throw new TypeError("Remote service catalogue contains duplicate IDs"); this.#allowlist = new Set(ids); @@ -107,6 +112,7 @@ export class RemoteServiceProvider { provide(service: Service, implementation: NoInfer>): void { this.#assertActive(); + this.#assertRemotable(service); this.#assertAllowed(service.id); const registration = this.#registration(service.id, "singleton", true); if (registration.singleton !== undefined) { @@ -119,6 +125,7 @@ export class RemoteServiceProvider { /** Disconnect one singleton while preserving its active subscriptions and remote facades. */ withdraw(service: Service): void { this.#assertActive(); + this.#assertRemotable(service); this.#assertAllowed(service.id); const registration = this.#registration(service.id, "singleton", false); const previous = registration.singleton; @@ -132,6 +139,7 @@ export class RemoteServiceProvider { /** Replace one singleton while preserving its active subscriptions and remote facades. */ replace(service: Service, implementation: NoInfer>): void { this.#assertActive(); + this.#assertRemotable(service); this.#assertAllowed(service.id); const registration = this.#registration(service.id, "singleton", false); const replacement = this.#classifyInstance(registration, implementation, undefined); @@ -146,6 +154,7 @@ export class RemoteServiceProvider { use(service: Service): T { this.#assertActive(); + this.#assertRemotable(service); this.#assertAllowed(service.id); const registration = this.#registrations.get(service.id); if (registration?.mode !== "singleton" || registration.singleton === undefined) { @@ -156,6 +165,7 @@ export class RemoteServiceProvider { spawn(service: Service, key: string, implementation: NoInfer>): () => void { this.#assertActive(); + this.#assertRemotable(service); this.#assertAllowed(service.id); if (key.length === 0) throw new TypeError("Remote service instance key must not be empty"); const registration = this.#registration(service.id, "keyed", true); @@ -426,6 +436,10 @@ export class RemoteServiceProvider { } } + #assertRemotable(service: { readonly id: string; readonly local: boolean }): void { + if (service.local) throw new RemoteServiceError("service_not_allowed", `Service ${service.id} is process-local`); + } + #assertAllowed(serviceId: string): void { if (!this.#allowlist.has(serviceId)) { throw new RemoteServiceError("service_not_allowed", `Remote service ${serviceId} is not allowlisted`); diff --git a/packages/agent/src/plugins/services/types.ts b/packages/agent/src/plugins/services/types.ts index 256ceb5d5..c0840b608 100644 --- a/packages/agent/src/plugins/services/types.ts +++ b/packages/agent/src/plugins/services/types.ts @@ -78,23 +78,24 @@ export type RemoteServiceContract = InvalidRemoteMemberNames extends never /** Stable identity for one shared TypeScript service contract. */ export interface Service { readonly id: string; - readonly rpc: boolean; + /** Process-local services accept unrestricted object contracts and are never published remotely. */ + readonly local: boolean; readonly [SERVICE_TYPE]?: (value: T) => T; } export type ServiceType = TService extends Service ? T : never; -export function defineService(id: string, options: { readonly rpc: false }): Service; +export function defineService(id: string, options: { readonly local: true }): Service; export function defineService( id: string, ...options: [RemoteServiceContract] extends [never] ? readonly [options: never] - : readonly [options?: { readonly rpc?: true }] + : readonly [options?: { readonly local?: false }] ): Service; -export function defineService(id: string, options?: { readonly rpc?: boolean }): Service { +export function defineService(id: string, options?: { readonly local?: boolean }): Service { if (id.length === 0) throw new TypeError("Service ID must not be empty"); if (id.startsWith("$pi.")) throw new TypeError("Service IDs beginning with $pi. are reserved"); - return Object.freeze({ id, rpc: options?.rpc ?? true }); + return Object.freeze({ id, local: options?.local ?? false }); } export interface ServiceInstanceAddress { diff --git a/packages/agent/test/plugins/services.test.ts b/packages/agent/test/plugins/services.test.ts index 1ddc31b82..3e67dbc38 100644 --- a/packages/agent/test/plugins/services.test.ts +++ b/packages/agent/test/plugins/services.test.ts @@ -32,10 +32,11 @@ interface QuestionDialogs { const QuestionDialogs = defineService("test.question-dialog"); describe("plugin remote services", () => { - test("marks services RPC-capable by default and permits explicit local services", () => { - const local = defineService<{ readonly value: string }>("test.local", { rpc: false }); - expect(Models.rpc).toBe(true); - expect(local.rpc).toBe(false); + test("marks services remotable by default and permits explicit local services", () => { + const local = defineService<{ readonly value: string }>("test.local", { local: true }); + expect(Models.local).toBe(false); + expect(local.local).toBe(true); + expect(() => new RemoteServiceProvider([local])).toThrow("cannot be published remotely"); }); test("does not defensively clone borrowed state values", () => { diff --git a/packages/coding-agent/src/experimental/facets.ts b/packages/coding-agent/src/experimental/facets.ts index 7082c4653..bd09e2b49 100644 --- a/packages/coding-agent/src/experimental/facets.ts +++ b/packages/coding-agent/src/experimental/facets.ts @@ -1,21 +1,20 @@ import { BACKGROUND_CONTEXT, type Context, - createLoopbackServiceConnection, type MutableReplicatedState, type RemoteServiceContract, type RemoteServiceInstance, - RemoteServiceNamespace, RemoteServiceProvider, type RemoteServices, replicatedState, type Service, type ServiceCatalogueEntry, type ServiceMode, + withCancel, } from "@earendil-works/pi-agent-core"; export interface ServiceInstances { - add(key: string, implementation: RemoteServiceContract): () => void; + add(key: string, implementation: T): () => void; } interface FacetServiceReference { @@ -57,8 +56,8 @@ export interface FacetEnvironment { * Declare and install this facet's singleton implementation of a service. * * Call this synchronously during setup. The declaration lets the host reject duplicate - * providers and order consumers after this facet. RPC-capable tokens are published - * automatically; `{ rpc: false }` tokens remain process-local. Reloading this facet swaps + * providers and order consumers after this facet. Remotable tokens are published + * automatically; `{ local: true }` tokens remain process-local. Reloading this facet swaps * the implementation behind existing consumer handles. */ provide(service: Service, implementation: NoInfer): void; @@ -232,11 +231,55 @@ class FacetLedger { } } -interface SingletonServiceHandle { - bind(implementation: object): void; - unbind(): void; - bindRemote(services: RemoteServices): void; +class FacetServiceHandle { + readonly #serviceId: string; + readonly #assertAccess: () => void; + readonly #methods = new Map(); readonly proxy: object; + #implementation: object | undefined; + #remote = false; + + constructor(serviceId: string, assertAccess: () => void) { + this.#serviceId = serviceId; + this.#assertAccess = assertAccess; + this.proxy = new Proxy( + {}, + { + get: (_target, property) => { + this.#assertAccess(); + if (this.#implementation === undefined) throw new Error(`Service ${this.#serviceId} is disconnected`); + const value: unknown = Reflect.get(this.#implementation, property, this.#implementation); + if (this.#remote || typeof value !== "function") return value; + let method = this.#methods.get(property); + if (method === undefined) { + method = (...args: unknown[]) => { + this.#assertAccess(); + if (this.#implementation === undefined) { + throw new Error(`Service ${this.#serviceId} is disconnected`); + } + const current: unknown = Reflect.get(this.#implementation, property, this.#implementation); + if (typeof current !== "function") { + throw new TypeError(`Service ${this.#serviceId}.${String(property)} is not a method`); + } + return Reflect.apply(current, this.#implementation, args); + }; + this.#methods.set(property, method); + } + return method; + }, + }, + ); + } + + bind(implementation: object, remote = false): void { + this.#implementation = implementation; + this.#remote = remote; + } + + unbind(): void { + this.#implementation = undefined; + this.#remote = false; + } } interface StagedObservation { @@ -244,10 +287,160 @@ interface StagedObservation { stop(): void; } +interface KeyedServiceSource { + observe( + service: Service, + handler: (instance: RemoteServiceInstance, context: Context) => void | Promise, + ): () => void; +} + +interface LocalKeyedInstance { + readonly key: string; + readonly generation: number; + readonly handle: FacetServiceHandle; +} + +interface LocalKeyedObserver { + readonly handler: (instance: RemoteServiceInstance, context: Context) => void | Promise; + readonly tasks: Map void }>; + closed: boolean; +} + +interface LocalKeyedRegistration { + readonly serviceId: string; + readonly generations: Map; + readonly instances: Map; + readonly observers: Set; +} + +class LocalKeyedServiceRegistry implements KeyedServiceSource { + readonly #registrations = new Map(); + readonly #assertAccess: () => void; + readonly #onError: (error: Error) => void; + #disposed = false; + + constructor( + services: readonly { readonly id: string }[], + assertAccess: () => void, + onError: (error: Error) => void, + ) { + const ids = services.map(({ id }) => id); + if (new Set(ids).size !== ids.length) throw new TypeError("Local keyed service registry has duplicate IDs"); + for (const serviceId of ids) { + this.#registrations.set(serviceId, { + serviceId, + generations: new Map(), + instances: new Map(), + observers: new Set(), + }); + } + this.#assertAccess = assertAccess; + this.#onError = onError; + } + + spawn(service: Service, key: string, implementation: T): () => void { + this.#assertActive(); + if (key.length === 0) throw new TypeError("Local service instance key must not be empty"); + if (typeof implementation !== "object" || implementation === null || Array.isArray(implementation)) { + throw new TypeError(`Local service ${service.id} implementation must be an object`); + } + const registration = this.#registration(service.id); + if (registration.instances.has(key)) { + throw new Error(`Local service ${service.id} already has a live instance with key ${key}`); + } + const generation = (registration.generations.get(key) ?? 0) + 1; + registration.generations.set(key, generation); + const handle = new FacetServiceHandle(service.id, this.#assertAccess); + handle.bind(implementation); + const instance = { key, generation, handle }; + registration.instances.set(key, instance); + for (const observer of registration.observers) this.#startTask(observer, instance); + let closed = false; + return () => { + if (closed) return; + closed = true; + if (registration.instances.get(key) !== instance) return; + registration.instances.delete(key); + instance.handle.unbind(); + const taskKey = localInstanceTaskKey(instance); + for (const observer of registration.observers) { + observer.tasks.get(taskKey)?.cancel(); + observer.tasks.delete(taskKey); + } + }; + } + + observe( + service: Service, + handler: (instance: RemoteServiceInstance, context: Context) => void | Promise, + ): () => void { + this.#assertActive(); + const registration = this.#registration(service.id); + const observer: LocalKeyedObserver = { + handler: handler as unknown as LocalKeyedObserver["handler"], + tasks: new Map(), + closed: false, + }; + registration.observers.add(observer); + for (const instance of registration.instances.values()) this.#startTask(observer, instance); + return () => { + if (observer.closed) return; + observer.closed = true; + for (const task of observer.tasks.values()) task.cancel(); + observer.tasks.clear(); + registration.observers.delete(observer); + }; + } + + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + for (const registration of this.#registrations.values()) { + for (const observer of registration.observers) { + observer.closed = true; + for (const task of observer.tasks.values()) task.cancel(); + observer.tasks.clear(); + } + registration.observers.clear(); + for (const instance of registration.instances.values()) instance.handle.unbind(); + registration.instances.clear(); + } + this.#registrations.clear(); + } + + #startTask(observer: LocalKeyedObserver, instance: LocalKeyedInstance): void { + if (observer.closed) return; + const taskKey = localInstanceTaskKey(instance); + if (observer.tasks.has(taskKey)) return; + const { context, cancel } = withCancel(BACKGROUND_CONTEXT); + observer.tasks.set(taskKey, { cancel }); + try { + void Promise.resolve(observer.handler({ key: instance.key, service: instance.handle.proxy }, context)).catch( + (error: unknown) => { + if (!context.abortSignal?.aborted) this.#onError(toError(error)); + }, + ); + } catch (error) { + if (!context.abortSignal?.aborted) this.#onError(toError(error)); + } + } + + #registration(serviceId: string): LocalKeyedRegistration { + const registration = this.#registrations.get(serviceId); + if (registration === undefined) throw new Error(`Local keyed service ${serviceId} is not registered`); + return registration; + } + + #assertActive(): void { + if (this.#disposed) throw new Error("Local keyed service registry is disposed"); + } +} + class FacetServiceHandles { readonly #assertAccess: () => void; - readonly #singletons = new Map(); - readonly #keyedSources = new Map(); + readonly #singletons = new Map(); + readonly #singletonServices = new Map>(); + readonly #keyedSources = new Map(); constructor(assertAccess: () => void) { this.#assertAccess = assertAccess; @@ -256,50 +449,9 @@ class FacetServiceHandles { use(service: Service): T { let handle = this.#singletons.get(service.id); if (handle === undefined) { - let implementation: object | undefined; - let remote = false; - const methods = new Map(); - const proxy = new Proxy( - {}, - { - get: (_target, property) => { - this.#assertAccess(); - if (implementation === undefined) throw new Error(`Service ${service.id} is disconnected`); - const value: unknown = Reflect.get(implementation, property, implementation); - if (remote || typeof value !== "function") return value; - let method = methods.get(property); - if (method === undefined) { - method = (...args: unknown[]) => { - this.#assertAccess(); - if (implementation === undefined) throw new Error(`Service ${service.id} is disconnected`); - const current: unknown = Reflect.get(implementation, property, implementation); - if (typeof current !== "function") { - throw new TypeError(`Service ${service.id}.${String(property)} is not a method`); - } - return Reflect.apply(current, implementation, args); - }; - methods.set(property, method); - } - return method; - }, - }, - ); - handle = { - proxy, - bind: (target) => { - implementation = target; - remote = false; - }, - unbind: () => { - implementation = undefined; - remote = false; - }, - bindRemote: (services) => { - implementation = services.use(service) as object; - remote = true; - }, - }; + handle = new FacetServiceHandle(service.id, this.#assertAccess); this.#singletons.set(service.id, handle); + this.#singletonServices.set(service.id, service as Service); } return handle.proxy as T; } @@ -337,40 +489,44 @@ class FacetServiceHandles { bindRemoteSingleton(serviceId: string, services: RemoteServices): void { const handle = this.#singletons.get(serviceId); - if (handle === undefined) throw new Error(`Service ${serviceId} has no singleton handle`); - handle.bindRemote(services); + const service = this.#singletonServices.get(serviceId); + if (handle === undefined || service === undefined) + throw new Error(`Service ${serviceId} has no singleton handle`); + handle.bind(services.use(service) as object, true); } - bindKeyed(serviceId: string, services: RemoteServices): void { + bindKeyed(serviceId: string, services: KeyedServiceSource): void { this.#keyedSources.set(serviceId, services); } dispose(): void { + for (const handle of this.#singletons.values()) handle.unbind(); this.#singletons.clear(); + this.#singletonServices.clear(); this.#keyedSources.clear(); } } -class StagedServiceInstances implements ServiceInstances { - readonly #service: Service; - readonly #lifecycle: FacetLifecycle; - readonly #providers: RemoteServiceProvider[] = []; +type ServiceInstanceInstaller = (key: string, implementation: T) => () => void; - constructor(service: Service, lifecycle: FacetLifecycle) { - this.#service = service; +class StagedServiceInstances implements ServiceInstances { + readonly #lifecycle: FacetLifecycle; + readonly #installers: ServiceInstanceInstaller[] = []; + + constructor(lifecycle: FacetLifecycle) { this.#lifecycle = lifecycle; } - connect(provider: RemoteServiceProvider): void { - this.#providers.push(provider); + connect(installer: ServiceInstanceInstaller): void { + this.#installers.push(installer); } - add(key: string, implementation: RemoteServiceContract): () => void { + add(key: string, implementation: T): () => void { this.#lifecycle.assertActive("add service instances"); - if (this.#providers.length === 0) throw new Error("Facet service provider is not connected"); + if (this.#installers.length === 0) throw new Error("Facet service provider is not connected"); const closes: Array<() => void> = []; try { - for (const provider of this.#providers) closes.push(provider.spawn(this.#service, key, implementation)); + for (const install of this.#installers) closes.push(install(key, implementation)); } catch (error) { for (const close of closes.reverse()) close(); throw error; @@ -388,7 +544,7 @@ class StagedServiceInstances implements ServiceInstances { interface SingletonProvision { readonly facetId: string; - readonly service: { readonly id: string; readonly rpc: boolean }; + readonly service: { readonly id: string; readonly local: boolean }; readonly implementation: object; install(provider: RemoteServiceProvider): void; withdraw(provider: RemoteServiceProvider): void; @@ -397,8 +553,9 @@ interface SingletonProvision { interface KeyedProvision { readonly facetId: string; - readonly service: { readonly id: string; readonly rpc: boolean }; - connect(provider: RemoteServiceProvider): void; + readonly service: { readonly id: string; readonly local: boolean }; + connectLocal(registry: LocalKeyedServiceRegistry): void; + connectRemote(provider: RemoteServiceProvider): void; } export interface FacetConnection { @@ -449,8 +606,7 @@ class FacetKernel { readonly #connectionBindings = new Map(); #activationOrder: readonly string[] = []; #provider: RemoteServiceProvider | undefined; - #keyedProvider: RemoteServiceProvider | undefined; - #localKeyedServices: RemoteServiceNamespace | undefined; + #localKeyedServices: LocalKeyedServiceRegistry | undefined; #phase: GenerationPhase = "setup"; constructor(options: FacetOptions) { @@ -493,10 +649,9 @@ class FacetKernel { this.#bindServices(externalServices); this.#phase = "connecting"; - await Promise.all([ - this.#localKeyedNamespace.activate(BACKGROUND_CONTEXT), - ...[...this.#connectionBindings.values()].map((services) => services.activate(BACKGROUND_CONTEXT)), - ]); + await Promise.all( + [...this.#connectionBindings.values()].map((services) => services.activate(BACKGROUND_CONTEXT)), + ); this.#phase = "activating"; for (const id of this.#activationOrder) await this.#lifecycles.get(id)!.activate(); @@ -505,13 +660,12 @@ class FacetKernel { const cleanupErrors = await this.#disposeLifecycles(); this.#phase = "disposing"; try { - await this.#localKeyedServices?.dispose(BACKGROUND_CONTEXT); + await this.#localKeyedServices?.dispose(); } catch (cleanupError) { cleanupErrors.push(cleanupError); } cleanupErrors.push(...(await this.#disposeConnectionBindings())); this.#handles.dispose(); - this.#keyedProvider?.dispose(); this.#provider?.dispose(); this.#phase = "dead"; if (cleanupErrors.length > 0) { @@ -579,7 +733,7 @@ class FacetKernel { for (const candidate of candidates) { for (const provision of candidate.singletons) { this.#handles.unbindSingleton(provision.service.id); - if (provision.service.rpc) provision.withdraw(this.provider); + if (!provision.service.local) provision.withdraw(this.provider); } } const disposalErrors: unknown[] = []; @@ -616,8 +770,8 @@ class FacetKernel { try { for (const candidate of candidates) { for (const provision of candidate.keyed) { - provision.connect(this.#keyedProvider!); - if (provision.service.rpc) provision.connect(this.provider); + provision.connectLocal(this.#localKeyedRegistry); + if (!provision.service.local) provision.connectRemote(this.provider); } this.#singletons.push(...candidate.singletons); this.#keyed.push(...candidate.keyed); @@ -630,7 +784,7 @@ class FacetKernel { await candidate.lifecycle.activate(); for (const provision of candidate.singletons) { this.#handles.bindSingleton(provision.service.id, provision.implementation); - if (provision.service.rpc) provision.replace(this.provider); + if (!provision.service.local) provision.replace(this.provider); } } } finally { @@ -644,13 +798,12 @@ class FacetKernel { const errors = await this.#disposeLifecycles(); this.#phase = "disposing"; try { - await this.#localKeyedServices?.dispose(BACKGROUND_CONTEXT); + await this.#localKeyedServices?.dispose(); } catch (error) { errors.push(error); } errors.push(...(await this.#disposeConnectionBindings())); this.#handles.dispose(); - this.#keyedProvider?.dispose(); this.#provider?.dispose(); this.#phase = "dead"; if (errors.length === 1) throw errors[0]; @@ -660,14 +813,14 @@ class FacetKernel { #validateReplacementProvisions(singletons: readonly SingletonProvision[], keyed: readonly KeyedProvision[]): void { const definitions = [ ...singletons - .filter(({ service }) => service.rpc) + .filter(({ service }) => !service.local) .map(({ service }) => ({ service, mode: "singleton" as const })), - ...keyed.filter(({ service }) => service.rpc).map(({ service }) => ({ service, mode: "keyed" as const })), + ...keyed.filter(({ service }) => !service.local).map(({ service }) => ({ service, mode: "keyed" as const })), ]; const provider = new RemoteServiceProvider(definitions); try { for (const provision of singletons) { - if (provision.service.rpc) provision.install(provider); + if (!provision.service.local) provision.install(provider); } } finally { provider.dispose(); @@ -699,8 +852,17 @@ class FacetKernel { provideMany: (service: Service): ServiceInstances => { lifecycle.assertSettingUp("provide service instances"); ledger.provide(service, "keyed"); - const instances = new StagedServiceInstances(service, lifecycle); - keyed.push({ facetId: lifecycle.id, service, connect: (target) => instances.connect(target) }); + const instances = new StagedServiceInstances(lifecycle); + keyed.push({ + facetId: lifecycle.id, + service, + connectLocal: (registry) => + instances.connect((key, implementation) => registry.spawn(service, key, implementation)), + connectRemote: (provider) => + instances.connect((key, implementation) => + provider.spawn(service, key, implementation as NoInfer>), + ), + }); return instances; }, use: (service: Service): T => { @@ -788,30 +950,28 @@ class FacetKernel { } #assembleProviders(): void { - const rpcServices = [ + const remotelyPublishedServices = [ ...this.#singletons - .filter(({ service }) => service.rpc) + .filter(({ service }) => !service.local) .map(({ service }) => ({ service, mode: "singleton" as const })), ...this.#keyed - .filter(({ service }) => service.rpc) + .filter(({ service }) => !service.local) .map(({ service }) => ({ service, mode: "keyed" as const })), ]; - const provider = new RemoteServiceProvider(rpcServices); - const keyedProvider = new RemoteServiceProvider(this.#keyed.map(({ service }) => ({ service, mode: "keyed" }))); + const provider = new RemoteServiceProvider(remotelyPublishedServices); + const localKeyedServices = new LocalKeyedServiceRegistry( + this.#keyed.map(({ service }) => service), + () => this.#assertServiceAccess(), + this.#onError, + ); this.#provider = provider; - this.#keyedProvider = keyedProvider; - this.#localKeyedServices = new RemoteServiceNamespace({ - services: this.#keyed.map(({ service }) => service), - connection: createLoopbackServiceConnection(keyedProvider), - assertAccess: () => this.#assertServiceAccess(), - onError: this.#onError, - }); + this.#localKeyedServices = localKeyedServices; for (const provision of this.#singletons) { - if (provision.service.rpc) provision.install(provider); + if (!provision.service.local) provision.install(provider); } for (const provision of this.#keyed) { - provision.connect(keyedProvider); - if (provision.service.rpc) provision.connect(provider); + provision.connectLocal(localKeyedServices); + if (!provision.service.local) provision.connectRemote(provider); } } @@ -822,7 +982,7 @@ class FacetKernel { this.#handles.bindSingleton(provision.service.id, provision.implementation); } for (const provision of this.#keyed) { - this.#handles.bindKeyed(provision.service.id, this.#localKeyedNamespace); + this.#handles.bindKeyed(provision.service.id, this.#localKeyedRegistry); } for (const [serviceId, { mode, connection }] of externalServices) { const services = this.#connectionBindings.get(connection); @@ -832,7 +992,7 @@ class FacetKernel { } } - get #localKeyedNamespace(): RemoteServiceNamespace { + get #localKeyedRegistry(): LocalKeyedServiceRegistry { if (this.#localKeyedServices === undefined) throw new Error("Facet keyed services are not assembled"); return this.#localKeyedServices; } @@ -958,6 +1118,14 @@ function referenceKey(reference: FacetServiceReference): string { return `${reference.serviceId}\0${reference.mode}`; } +function localInstanceTaskKey(instance: LocalKeyedInstance): string { + return `${instance.key}\0${instance.generation}`; +} + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + function isPromiseLike(value: unknown): value is PromiseLike { return typeof value === "object" && value !== null && "then" in value && typeof value.then === "function"; } diff --git a/packages/coding-agent/src/experimental/services/README.md b/packages/coding-agent/src/experimental/services/README.md index ac8cedf08..17ece6b14 100644 --- a/packages/coding-agent/src/experimental/services/README.md +++ b/packages/coding-agent/src/experimental/services/README.md @@ -1,6 +1,6 @@ # Experimental client/server service slices -Facet setup generates each host's RPC service catalogue from its provided RPC-capable tokens. Connections exchange those catalogues and bind only services required by consuming facets; there is no handwritten built-in service inventory. With no selected Session, its deferred connection admits unresolved requirements as unavailable and keeps their handles disconnected. Attachment validates them against the worker's generated catalogue, which is cached for later detached generations. A built-in token is either implemented end to end or registered with an explicit `ServiceSliceNotImplemented` provider. Framed callers receive the stable `service_not_implemented` code. Keyed services hydrate as an empty directory until their owning feature spawns an instance. +Facet setup generates each host's RPC service catalogue from its provided non-local tokens. Connections exchange those catalogues and bind only services required by consuming facets; there is no handwritten built-in service inventory. With no selected Session, its deferred connection admits unresolved requirements as unavailable and keeps their handles disconnected. Attachment validates them against the worker's generated catalogue, which is cached for later detached generations. A built-in token is either implemented end to end or registered with an explicit `ServiceSliceNotImplemented` provider. Framed callers receive the stable `service_not_implemented` code. Keyed services hydrate as an empty directory until their owning feature spawns an instance. | Scope | Service | Current slice | Continuation point | |---|---|---|---| @@ -17,6 +17,6 @@ With `PI_EXPERIMENTAL=1`, an interactive `pi client` creates and attaches a Sess The Session worker loads all built-in and configured Session facets through one `FacetLoader`, then creates one active `FacetHost`. Host-created implementation dependencies such as `AgentLane`, `ModelRuntime`, and `SettingsManager` are passed directly to built-in facet factories; they are not exposed as services. The TUI loads configured presentation facets and adds one private bridge facet that consumes the connected server and selected-Session services for `ExperimentalClientTui`. There is no local TUI service until the presentation host has a real command, modal, and renderer contribution API worth exposing to independently loaded facets. -A host may reload selected facets when their declared service shape is unchanged; retained consumers keep the same RPC service facades while replacement implementations and singleton snapshots are installed behind them. Synchronous setup-time `env.provide()`, `env.provideMany()`, `env.use()`, and `env.observe()` calls produce the internal dependency graph; setup does not repeat a declarative dependency list, and service handles remain disconnected until the complete graph validates. Providers activate before consumers, observations connect with their consuming facet, and facet replacement or host shutdown disposes affected lifecycles in reverse dependency order. All currently implemented experimental service tokens are RPC-capable and automatically published by their providing host. +A host may reload selected facets when their declared service shape is unchanged; retained consumers keep the same service facades while replacement implementations and singleton snapshots are installed behind them. Synchronous setup-time `env.provide()`, `env.provideMany()`, `env.use()`, and `env.observe()` calls produce the internal dependency graph; setup does not repeat a declarative dependency list, and service handles remain disconnected until the complete graph validates. Providers activate before consumers, observations connect with their consuming facet, and facet replacement or host shutdown disposes affected lifecycles in reverse dependency order. All currently implemented experimental service tokens are non-local and automatically published by their providing host. A facet always calls unqualified `env.use()` or `env.observe()`. The host resolves each token across facet-provided and connected services. Transport namespaces remain internal binding machinery rather than part of the facet environment. `ExperimentalClientTui` currently owns terminal rendering, state subscriptions, navigation, and action dispatch directly. The server provider remains directly assembled until its complete host environment exists. The question dialog, diff review, Git, indexing-job, canvas, and rich local TUI service examples in `packages/agent/docs/plugins.md` are extension patterns, not built-in coding-agent services. Private references, trace carriers, and flow control remain protocol/host infrastructure slices rather than presentation service tokens. diff --git a/packages/coding-agent/test/experimental-facet-loader.test.ts b/packages/coding-agent/test/experimental-facet-loader.test.ts index 754846faf..3748f2573 100644 --- a/packages/coding-agent/test/experimental-facet-loader.test.ts +++ b/packages/coding-agent/test/experimental-facet-loader.test.ts @@ -14,7 +14,7 @@ interface GenerationValue { } const LocalGenerationValue = defineService("test.experimental.local-generation-value", { - rpc: false, + local: true, }); const RemoteGenerationValue = defineService("test.experimental.remote-generation-value"); diff --git a/packages/coding-agent/test/experimental-facets.test.ts b/packages/coding-agent/test/experimental-facets.test.ts index 3f35fabc2..13a357486 100644 --- a/packages/coding-agent/test/experimental-facets.test.ts +++ b/packages/coding-agent/test/experimental-facets.test.ts @@ -32,6 +32,11 @@ interface HostValues { readonly use: string; } +interface LocalKeyedValue { + readonly metadata: Map; + read(): string; +} + interface LeftValue { read(context: Context): Promise; } @@ -48,7 +53,8 @@ const Source = defineService("test.experimental.source"); const Projection = defineService("test.experimental.projection"); const KeyedValue = defineService("test.experimental.keyed-value"); const Watched = defineService("test.experimental.watched"); -const HostValues = defineService("test.experimental.host-values", { rpc: false }); +const HostValues = defineService("test.experimental.host-values", { local: true }); +const LocalKeyedValue = defineService("test.experimental.local-keyed-value", { local: true }); const LeftValue = defineService("test.experimental.left-value"); const RightValue = defineService("test.experimental.right-value"); const CombinedValue = defineService("test.experimental.combined-value"); @@ -134,9 +140,63 @@ describe("experimental facet host", () => { await vi.waitFor(() => expect(trace).toContain("observe one")); expect(trace).toEqual(["activate provider", "activate observer", "observe one"]); + const remoteServices = new RemoteServiceNamespace({ + services: [KeyedValue], + connection: createLoopbackServiceConnection(host.services), + }); + const remoteValues: string[] = []; + remoteServices.observe(KeyedValue, async (instance, context) => { + remoteValues.push(await instance.service.read(context)); + }); + await remoteServices.ready(BACKGROUND_CONTEXT); + await vi.waitFor(() => expect(remoteValues).toEqual(["one"])); + + await remoteServices.dispose(BACKGROUND_CONTEXT); await host.dispose(); }); + test("keeps unrestricted local keyed services process-local across provider reloads", async () => { + const observed: Array<{ service: LocalKeyedValue; context: Context }> = []; + const consumer = defineFacet({ + id: "local-keyed-consumer", + setup(env) { + env.observe(LocalKeyedValue, (instance, context) => { + observed.push({ service: instance.service, context }); + }); + }, + }); + const provider = (value: string) => + defineFacet({ + id: "local-keyed-provider", + setup(env) { + const values = env.provideMany(LocalKeyedValue); + env.onActivate(() => { + values.add("current", { + metadata: new Map([["value", value]]), + read: () => value, + }); + }); + }, + }); + const host = await createFacetHost({ facets: [consumer, provider("A")] }); + await vi.waitFor(() => expect(observed).toHaveLength(1)); + const first = observed[0]!; + expect(first.service.read()).toBe("A"); + expect(first.service.metadata.get("value")).toBe("A"); + expect(host.services.catalogue).not.toContainEqual({ serviceId: LocalKeyedValue.id, mode: "keyed" }); + expect(() => host.services.use(LocalKeyedValue)).toThrow("process-local"); + + await host.reload([provider("B")]); + await vi.waitFor(() => expect(observed).toHaveLength(2)); + expect(first.context.abortSignal?.aborted).toBe(true); + expect(() => first.service.read()).toThrow(`Service ${LocalKeyedValue.id} is disconnected`); + expect(observed[1]!.service.read()).toBe("B"); + expect(observed[1]!.service.metadata.get("value")).toBe("B"); + + await host.dispose(); + expect(observed[1]!.context.abortSignal?.aborted).toBe(true); + }); + test("owns resources registered during activation", async () => { let state: MutableReplicatedState<{ value: number }> | undefined; let deliveries = 0; @@ -191,9 +251,7 @@ describe("experimental facet host", () => { }, }); const host = await createFacetHost({ facets: [consumer, provider] }); - expect(() => host.services.use(HostValues)).toThrow( - "Remote service test.experimental.host-values is not allowlisted", - ); + expect(() => host.services.use(HostValues)).toThrow("Service test.experimental.host-values is process-local"); await host.dispose(); });