feat(coding-agent): preserve handles across facet reloads

This commit is contained in:
Armin Ronacher 2026-08-26 12:13:38 +02:00
parent 75223072b8
commit 6f0ea81e8b
12 changed files with 496 additions and 63 deletions

View file

@ -6,25 +6,25 @@ Read `plugins.md` for host facets and service contracts, `rpc.md` for binding an
## Decision
A source reload replaces one complete application manifest generation across every host facet. It does not replace one service implementation, one registration, or one facet independently.
A source reload replaces the affected facet environments while preserving service facades held by unaffected consumers. When a replacement facet declares the same requirements, provisions, and modes, the host stages it, temporarily disconnects its singleton provisions, deactivates the old facet, activates the replacement, rebinds those provisions, and only then unloads the old module generation. A singleton returned earlier by `use()` retains its identity and resolves the replacement implementation.
One reload targets:
One reload may target changed facets in:
- the server facet set in the server host;
- the session facet set in every live session worker;
- the presentation facet set in every connected TUI or web host; and
- the same facet set for hosts started after the reload.
- the server host;
- every live session worker;
- every connected TUI or web host; and
- the facet sources used by hosts started after the reload.
The hosts do not switch at one atomic instant. The server records one desired manifest generation and every host converges on it. A session worker or presentation may temporarily run a different generation while it stops, restarts, reconnects, or fails. Plugin contracts must remain forward compatible across this window.
A structural change to requirements, provisions, modes, or manifest membership requires graph reassembly rather than an in-place facet swap. Replacing Session ownership or other process authority may still require an ordinary worker or host restart. Hosts do not switch at one atomic instant: the server records one desired source generation and every host converges on it, so plugin contracts remain forward compatible across the skew window.
Reload uses ordinary host shutdown and startup. It has no stronger safety guarantee, no rollback guarantee, and no test that arbitrary in-process work is safe to restart.
Shape-preserving reload uses ordinary facet deactivation and activation; structural or authority replacement uses ordinary host shutdown and startup. Neither has a stronger safety or rollback guarantee, and arbitrary in-process work is not assumed safe to restart.
## Terms
These operations are different:
- **Rebuild** replays active contributions over a fresh draft and commits a new registry value. Settings, configuration, availability, or source data changed, but plugin module bytes did not.
- **Reload** selects a new manifest generation after plugin source or the selected plugin set changed. Every facet is replaced.
- **Reload** selects new source for changed facets. Shape-preserving facets swap in place; structural graph or process-authority changes require broader host reassembly or restart.
- **Restart** stops and recreates a concrete server, session worker, or presentation runtime while implementing a reload.
- **Rebind** moves connected services from one live provider binding to another and hydrates fresh snapshots.
- **Resume** continues durable Harness work from the Session repository after a worker restart.
@ -38,8 +38,8 @@ Pi has a stronger durability boundary than an in-process plugin framework. The a
A reload divides state into three layers:
1. **Durable authority** survives reload. It includes Session records, durable Harness control, accepted operation IDs, plugin-owned durable records, and persisted server intentions.
2. **Host runtime state** is reconstructed. It includes Harness instances, extension runners, service providers, contribution drafts, listeners, timers, caches, open files, and subprocess handles.
3. **Presentation replicas** are rebound. They include `ReplicatedState` values, event listeners, keyed observer tasks, widgets, and attachment routes.
2. **Affected runtime state** is reconstructed. It includes replaced facet environments, contributions, listeners, timers, caches, open files, and subprocess handles; broader restart also reconstructs Harness instances and host infrastructure.
3. **Affected presentation replicas** are rebound. Shape-preserving singleton replacement keeps member facades while installing a complete snapshot; route or worker replacement also rebuilds keyed observer tasks, widgets, and attachment routes.
Reload never reverses committed durable state. Cordis-style inverse tracking is useful for orderly release of process-local resources, but it is not a transaction over Session history, filesystem emissions, subprocess effects, or network effects. A plugin that needs durable recovery must express it through the Session durability model rather than rely on a disposer.
@ -67,11 +67,11 @@ The server owns the desired manifest generation. The server directory contains t
The exact file layout is not part of this design. Transient request IDs, subscription IDs, attachment IDs, and process IDs are not durable plugin state.
One generation names one resolved manifest across all facet kinds. Hosts resolve only their own entry points, but they report and converge on the same generation. A newly opened session starts directly on the desired generation. A live host leaves its previous generation by ordinary shutdown and then starts the desired one.
One generation names one resolved manifest across all facet kinds. Hosts resolve only their own entry points, but they report and converge on the same generation. A newly opened session starts directly on the desired generation. A live host may converge by swapping affected facets in place, structurally reassembling its graph, or restarting when process authority requires it.
Temporary cross-generation communication is expected. The system does not attempt a distributed lock-step commit. Shared service contracts, durable records, and control messages must tolerate the supported forward-compatibility window. Runtime member-kind validation remains useful but does not replace contract compatibility.
Each process receives one `FacetLoader` configured for its process kind. Calling `load()` returns the complete ordered facet set for that process plus a disposer for the loaded module or sandbox generation. The current loader may return hardcoded built-ins; a manifest-backed loader resolves the same `./server`, `./session`, `./tui`, or `./web` entry points without changing host startup. Reload creates a fresh loader result, replaces the process's complete facet host, then disposes the retired loader result. The loader collects code; facet setup collects service requirements and providers.
Each process receives `FacetLoader` instances configured for its process kind and independently disposable source bundles. Calling `load()` returns an ordered facet set plus a disposer for that loaded module or sandbox generation. The current loader may return hardcoded built-ins; a manifest-backed loader resolves the same `./server`, `./session`, `./tui`, or `./web` entry points. For a shape-preserving reload, the coordinator loads the replacement bundle, asks the existing host to reload its matching facet IDs, then disposes the retired bundle. A combined loader can still assemble initial startup or a structural replacement. The loader collects code; facet setup collects service requirements and providers.
## Dependency discovery and manifest validation
@ -92,9 +92,9 @@ First acquisition and provision are setup-only operations. Commands, hooks, even
After setup, the host privately resolves its recorded requirements and provisions. Connections return provider-generated catalogues containing service IDs and modes; the host matches unresolved requirements to exactly one connection, opens generation-owned bindings only for selected services, and rejects missing providers, duplicate offers, mode mismatches, and invalid dependency cycles. The record is kernel machinery, not a plugin-facing plan or a second declaration format. Transport bindings must become ready before consuming facets activate. `use()` and `observe()` are hard requirements; optional dependencies need a separate explicit acquisition operation if introduced later. Directly importing another plugin's live implementation bypasses this ownership model and is unsupported.
Removing one plugin constructs a candidate manifest without that plugin and runs the same assembly. If retained facets have unresolved hard requirements, the candidate is rejected before becoming the desired generation. If it is valid, removal still uses a complete-generation reload and simply brings back fewer facets. The removed plugin's durable namespace remains stored.
Removing one plugin constructs a candidate graph without that plugin. If retained facets have unresolved hard requirements, the candidate is rejected before becoming desired. Valid removal is structural: the host reassembles the affected graph and disconnects removed services while retaining the plugin's durable namespace.
The service graph does not replace the module loader's source import graph. Partial source invalidation would need both graphs: one for runtime service dependents and one for modules importing changed bytes. Complete-generation reload deliberately avoids that additional mechanism. A future partial unload would also require per-plugin scopes, per-service withdrawal and rebinding, local stale-reference fencing, and dependent deactivation ordering; the graph is necessary for that design but does not by itself provide it.
The service graph does not replace the module loader's source import graph. A facet may swap in place only when its source bundle can be unloaded independently and its runtime service shape is unchanged. If another active facet imports changed module bytes, that facet is affected too. The reload coordinator therefore combines source ownership with the generated service graph rather than treating either graph as sufficient by itself.
## Reload sequence
@ -102,16 +102,16 @@ At the architectural level, a reload proceeds as follows:
```text
user invokes /reload
→ server records desired manifest generation N+1
→ application enters reloading presentation state
→ every live host stops its complete generation-N facet set
→ server/session/presentation runtimes restart on N+1
→ connections and Session attachments are re-established
→ remote service bindings hydrate fresh snapshots
→ normal application interaction resumes where required hosts are ready
→ server records desired source generation N+1
→ each host loads and validates its affected replacement facets
→ shape-preserving facets disconnect old provisions and deactivate in dependency order
→ replacements activate and stable local facades resolve the new implementations
→ stable RPC singleton facades install complete replacement snapshots
→ structural or authority changes restart the affected host or Session worker
→ normal application interaction resumes where required services are ready
```
Module resolution and syntax may be checked before tearing down the old generation. Such preflight reduces avoidable failures but does not make reload transactional. Module evaluation may itself be defective, facet setup may fail only with host capabilities, and startup may fail after the old runtime has stopped.
Module resolution, evaluation, facet setup, and unchanged service shape are checked before deactivating the old facets where possible. Such preflight reduces avoidable failures but does not make reload transactional. Deactivation or replacement activation can still fail after the old facet has stopped.
All facet-owned resources close through the ordinary facet environment before graceful process exit. If graceful shutdown fails, the host may terminate the process according to its normal shutdown policy. The operating system then reclaims process resources, but it cannot undo external emissions or make an unsafe tool restartable.
@ -144,10 +144,10 @@ These are ordinary shutdown hazards. Reload does not claim to repair them. Tools
Reload adds no RPC transaction or automatic replay.
When a host begins shutdown:
When a providing facet or host begins replacement:
- it stops accepting new work through the retiring binding;
- active invocation contexts, watches, subscriptions, and observations close through normal shutdown;
- affected services become unavailable through their stable facades and do not queue new work;
- active invocation contexts, watches, subscriptions, and observations owned by the retiring lifecycle close normally;
- interrupted calls fail with cancellation, disconnect, reloading, or unavailable behavior appropriate to the boundary;
- calls are never queued merely because a replacement is starting; and
- clients never blindly replay a mutation whose outcome is uncertain.
@ -182,21 +182,20 @@ The global suspended reload experience is separate from attachment state. Attach
## Service lifetime
A remote service implementation belongs to its providing host generation. Services are not independently hot-swapped in the initial design.
A service facade belongs to its consumer, independently of the current providing facet environment. Shape-preserving provider reload swaps the implementation behind that facade.
This matches the basic implementation:
The implementation follows these rules:
- `RemoteServiceProvider.provide()` installs one singleton for the provider's lifetime;
- plugin-facing `provideMany()` registers keyed ownership during setup, while its `ServiceInstances.add()` handle delegates dynamic instances to the provider and preserves generation fencing when a key is reused;
- disposing a provider removes its member listeners and subscriptions;
- a server connection or Session attachment change makes connected services close old subscriptions and hydrate new ones; and
- `RemoteServiceProvider.provide()` installs the initial singleton, `withdraw()` reports its temporary unavailability, and `replace()` publishes a complete singleton replacement snapshot to existing subscriptions;
- local singleton proxies dispatch captured method functions through the current implementation slot rather than closing over the original method;
- RPC member facades retain identity while replacement state hydrates from the complete snapshot;
- plugin-facing `provideMany()` registers keyed ownership during setup, while its `ServiceInstances.add()` handle preserves generation fencing when a key is reused;
- a server connection or Session attachment change still closes route-bound subscriptions and hydrates the selected provider; and
- binding revisions and attachment IDs fence delayed frames.
There is therefore no need for a singleton-provider replacement frame or public singleton generation merely to reload plugins. Replacing the server connection or Session attachment replaces the provider binding as a whole. A stable remote facade may remain a local object, but it becomes unready and receives a complete snapshot from the replacement before it is usable again.
Changing service tokens or modes is structural. The host must reassemble catalogues and dependency bindings before switching that graph. During temporary version skew, a service absent from one side fails normally; forward-compatible plugin bundles must not assume every host has switched already.
Changing the manifest may add or remove service tokens. Each provider catalogue is regenerated from the new facet generation's RPC-capable provisions, and each consumer generation resolves only requirements recorded by its own setup. During temporary version skew, a service absent from one side fails normally; forward-compatible plugin bundles must not assume every host has switched already.
Local service implementations are replaced with their host generation as well. Captured local implementation objects from the retired generation are stale and must not be retained by host-global state.
Captured implementation objects from a retired provider are stale and must not be retained. Consumers retain only the facade returned by `use()` or member facades obtained from it.
## Current core services
@ -229,7 +228,7 @@ Keyed services are live projections. If an instance must return after worker rep
## Contribution registries
Contribution registries are rebuilt, not individually undone. The new host generation collects the complete ordered contribution set, replays it over fresh drafts, finalizes each registry, and publishes the resulting state.
Contribution registries are rebuilt, not individually undone. After affected facets swap, the host collects the complete current contribution set, replays it over fresh drafts, finalizes each registry, and publishes the resulting state.
This is preferable to asking plugins to reverse mutations to a shared tool, provider, command, or renderer registry. Lifecycle disposal still closes process-local effects, but registry correctness comes from reconstruction.
@ -239,7 +238,7 @@ A configuration or settings change that leaves module bytes and manifest members
Reload has no runtime rollback guarantee.
If generation N stops and N+1 fails, the affected host remains unavailable or degraded. The system records and reports the failure. It does not silently restore N. The user fixes the plugin or configuration and invokes reload or restart again.
If a facet at source generation N stops and its N+1 replacement fails, the affected services or host remain unavailable or degraded. The system records and reports the failure. It does not silently restore N. The user fixes the plugin or configuration and invokes reload or restart again.
Durable records remain as committed. Plugin state remains retained. Other sessions or hosts that already reached N+1 remain there; the desired generation does not move backward merely because one host failed.
@ -250,7 +249,7 @@ The following claims are explicitly out of scope:
- rollback of durable writes or external effects;
- preservation of arbitrary plugin-local memory;
- transparent replay of interrupted RPC methods;
- per-service hot module replacement; and
- changing a facet's service contract in place without graph reassembly; and
- automatic durable schema migration.
## Relationship to Cordis
@ -260,14 +259,14 @@ Cordis identifies two useful concerns: resources created by a component need lif
- facet environments own registrations, listeners, subscriptions, and process-local effects; and
- connected services become unavailable and rehydrate against replacement providers.
Pi does not adopt Cordis's in-process component reload as its primary mechanism. Durable Session authority, process-per-session isolation, server replacement, and snapshot-based service rebinding make host-generation replacement the simpler boundary. Inverse disposal remains cleanup, not rollback.
Pi uses in-process facet replacement for shape-preserving source changes, while durable Session authority, process-per-session isolation, and server replacement still require broader lifecycle boundaries when ownership or graph structure changes. Inverse disposal remains cleanup, not rollback.
This also follows Cordis's system-boundary caveat: acquiring and releasing a process resource may be reversible, while bytes already written, commands already executed, and durable records already committed are not.
## Architectural footguns
- Implementing `/reload` inside the plugin or service runtime it destroys.
- Loading only one facet of a multi-facet plugin on new source bytes.
- Reloading one facet while another active facet still imports the same changed source bytes.
- Opening the same Session in old and replacement workers concurrently.
- Treating `ReplicatedState` as the durable source rather than a reconstructible projection.
- Depending on `RemoteEvents` to repair missed state after restart.
@ -286,11 +285,12 @@ This also follows Cordis's system-boundary caveat: acquiring and releasing a pro
A later implementation handoff should cover at least:
- one `/reload` selects one new manifest generation for server, session, and presentation facets;
- each process's facet loader returns its complete ordered facet set and disposes failed or retired module generations;
- one `/reload` selects one desired source generation for affected server, session, and presentation facets;
- independently loaded facet bundles dispose only after their active facets retire;
- retained local and RPC consumers keep the same singleton facade across provider-facet replacement and RPC state installs a complete replacement snapshot;
- setup-time service acquisition produces the complete internal dependency graph without reflecting on TypeScript types;
- missing providers, duplicate singleton providers, mode mismatches, and late dependency acquisition are rejected;
- removing a plugin rejects an invalid dependent manifest or performs a complete reload with fewer facets while retaining its durable state;
- removing a plugin rejects an invalid dependent graph or structurally reassembles the host with fewer facets while retaining its durable state;
- configuration-only changes rebuild without loading a new module generation;
- a worker releases Session ownership before its replacement opens the Session;
- reload during active non-restartable work follows ordinary shutdown behavior and makes no false recovery guarantee;

View file

@ -519,7 +519,7 @@ Required behavior:
3. **Hydration** installs a complete snapshot atomically before updates flow. Subscribing before hydration is valid, and updates emitted concurrently with the snapshot are buffered, so the listener observes snapshot then updates with no gap.
4. Once hydrated, `.value` is synchronously readable and `subscribe()` immediately reports the current value, then future updates. The first snapshot callback uses a fresh **hydration context** parented to the subscription; an immediate callback for an already-present value uses a fresh local delivery context because the write that produced it may be long settled.
5. State values are borrowed immutable JSON. The state runtime does not defensively clone reads, writes, snapshots, or listener deliveries. Callers transfer ownership to `set()` and must not mutate or retain values returned by `.value` or passed to listeners; copy explicitly when ownership is required. Process and transport serialization may naturally produce a detached value, but callers must not depend on object identity or detachment.
6. Disconnect may retain the last value as stale display data alongside connection or attachment state. Reconnecting to the same provider replaces it with a fresh snapshot. Switching to a different provider or host generation clears readiness and `.value` becomes `undefined` until that provider hydrates; service-bound presentation resources are disposed as part of the switch.
6. Disconnect may retain the last value as stale display data alongside connection or attachment state. Reconnecting to the same provider replaces it with a fresh snapshot. Reloading a singleton provider installs its complete replacement snapshot in the existing member facade. Switching routes to a different provider clears readiness and `.value` becomes `undefined` until that provider hydrates; route-bound presentation resources are disposed as part of the switch.
7. `set(value, context)` carries source trace metadata; each live delivery invokes listeners with a reconstructed delivery `Context`. Background updates use an intentional background or lifecycle context, never a retained caller context.
Anything a consumer must recover after reconnect is exposed as replicated state or pulled through a remote method. Replicated state is latest-value replication, not by itself durable session storage; the providing facet must reconstruct its authoritative value after a worker restart.
@ -983,9 +983,9 @@ The memo has the existing invocation lifetime. Staging the tool result as `outco
Every facet-owned resource — singleton registrations, still-open service instances, contributions, state subscriptions, watchers, timers, in-flight RPCs, subprocesses, overlays — is owned by the facet environment and disposed when that environment closes, in reverse dependency order. Host capabilities enforce this automatically for hooks and event subscriptions; `onDeactivate` handles the rest. Contribution registries rebuild from the remaining ordered contributions instead of asking a plugin to reverse shared mutations.
Source reload does not replace one environment or service in place. One user-triggered `/reload` selects a new complete manifest generation for server, Session, and presentation hosts. Each host closes its old facet set through ordinary shutdown, reconstructs runtime state from durable authority, and rebinds services from fresh snapshots. Hosts may temporarily run different generations; contracts must remain forward compatible during convergence.
Source reload replaces the affected facet environments while retaining unaffected consumers. The host stages each replacement against the existing dependency graph, disconnects its old provisions, deactivates the old facet, activates the replacement, and rebinds its provisions before unloading the old module generation. A singleton facade returned earlier by `use()` keeps its identity: local calls resolve the current implementation, and RPC consumers receive a complete replacement snapshot on the existing subscription. Reloads that change service requirements, provisions, modes, or manifest membership require graph reassembly before they can switch.
Reload has no safety or rollback guarantee beyond normal shutdown and restart. Committed durable state survives, removed plugin data is retained, interrupted calls are not automatically replayed, and non-restartable process effects may fail. See [`plugin-reloading.md`](plugin-reloading.md) for the complete design.
Reload has no safety or rollback guarantee beyond ordinary facet shutdown and activation. Committed durable state survives, removed plugin data is retained, interrupted calls are not automatically replayed, and non-restartable process effects may fail. Hosts may temporarily run different source generations, so contracts remain forward compatible during convergence. See [`plugin-reloading.md`](plugin-reloading.md) for the complete design.
## Connection loss, errors, and security
@ -1039,7 +1039,7 @@ The handoff should include a reusable two-transport test matrix (loopback plus a
- **Registries:** ordered provider contributions rebuild deterministically; tool wrappers compose once and rebuild correctly after removal.
- **Presentation:** typed selections return values rather than labels; modal leases do not interleave multi-step dialogs; closing a queued instance removes it before display; closing an active instance aborts and dismisses only that dialog; non-cancellation observer failures reach host failure policy.
- **Routing:** attach authorizes at the server and rejects cross-workspace targets; attach/switch closes previous routed requests and instance tasks, then hydrates the selected session; a routed singleton or instance call reconstructs `Context` at the session worker, with cancellation and trace carriers traversing the server; stale instance generations are rejected; per-client keying prevents request-ID collisions; the server routes services whose contracts it does not load; session-worker failure leaves server services healthy and updates directory status; presentation disconnect cleanup reaches the session; summaries contain no `ownerId` or `cwd`.
- **Reload:** one `/reload` selects one desired manifest generation for every facet kind; old Session ownership closes before replacement; logical selection survives a route gap and receives a fresh attachment ID; old calls and frames are fenced; state rehydrates from authoritative snapshots; events are not replayed; interrupted mutations are not retried; removed plugin data is retained; temporary generation skew remains compatible; and failed replacement stays degraded without rollback.
- **Reload:** changed facet implementations replace their matching active facets while stable service facades rebind to new local implementations or RPC singleton snapshots; structural graph changes reassemble the affected host; old Session ownership closes before worker replacement; logical selection survives a route gap and receives a fresh attachment ID; old calls and frames are fenced; events are not replayed; interrupted mutations are not retried; removed plugin data is retained; temporary generation skew remains compatible; and failed replacement stays degraded without rollback.
- **Boundaries:** scoped agent and fleet capabilities cannot reach host ownership or whole-registry mutation; raw Harness/Session/SessionRepo capabilities unreachable from any client connection; job `wait` cancellation distinct from `job.cancel()`; stale/closed reference rejection; concurrent question invocations add distinct services visible in simultaneous TUI and web clients, survive having no connected presentation while the worker is live, disappear on worker loss, reappear under new generations when safe replay finds no answer memo, accept only one durable answer each, reject the tool wait rather than hang when the memo write fails, return committed answers without adding another instance on replay, clean up with durable tool-result staging, and close in every presentation; invocation cancellation writes no durable cancellation state.
## Collaborative diff review: a durable shared sidebar

View file

@ -24,7 +24,7 @@ A service token is a shared TypeScript contract and stable service ID. It is not
The provider must make enough control-plane information available for the host to validate an accessed member as a method, `ReplicatedState`, or `RemoteEvents`. The consumer must be able to obtain the member name from ordinary property access—for example, a JavaScript `Proxy` receives `"state"` for `models.state` and `"refresh"` for `models.refresh(context)`. How a disconnected lazy proxy represents an unresolved member, and the exact provider-kind validation exchange, are implementation details.
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. While disconnected, 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 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.
Remote methods return promises and accept and return strict JSON apart from their declared `Context`; `void` is a successful response without a result field. A private returned reference is a separately validated control envelope, not a business JSON value. 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 current examples use one required trailing `Context`. Business absence is JSON `null` or an options object, never transported `undefined`.
@ -121,7 +121,7 @@ Facet environments own registrations, added service instances, subscriptions, ob
Test the plugin-facing semantics over loopback and a real framed transport:
- 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, 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 `{ rpc: false }` services remaining unreachable remotely;
- strict JSON boundaries, method context reconstruction, request cancellation isolation, and trace propagation without serializing context values;
- server/Session facet isolation, authorized attach, selected-Session switching, stale-frame rejection, and worker-side per-client request correlation;
- cold and hydrated `ReplicatedState`, snapshot/update race freedom, delivery contexts, stale display on reconnect, and clearing on provider switch;
@ -132,7 +132,7 @@ Test the plugin-facing semantics over loopback and a real framed transport:
## Open protocol mechanics
The following are intentionally not specified here: exact control-frame schemas; member-kind discovery and lazy-proxy implementation; subscription sequencing, acknowledgements, buffering, and flow control; provider replacement and reference collection; and how a future multi-pane presentation represents more than one selected session. They must preserve the semantics above without changing the plugin contract.
The following are intentionally not specified here: most exact control-frame schemas; member-kind discovery and lazy-proxy implementation; subscription sequencing, acknowledgements, buffering, and flow control; reference collection; and how a future multi-pane presentation represents more than one selected session. Singleton provider replacement requires a complete replacement snapshot on the existing subscription so method, state, and event member slots retain identity. The remaining mechanics must preserve the semantics above without changing the plugin contract.
## Example: directory and selected session

View file

@ -563,6 +563,9 @@ class KeyedBinding<T> {
if (this.#closed) return;
try {
switch (update.type) {
case "unavailable":
case "replaced":
throw new Error("Keyed service received a singleton lifecycle update");
case "spawned":
this.#spawn(update.instance, context, true);
break;
@ -812,11 +815,18 @@ export class RemoteServiceNamespace implements RemoteServices {
serviceId,
"singleton",
(update, context) => {
if (!binding.active || binding.revision !== revision || update.instance !== undefined) return;
if (!binding.active || binding.revision !== revision) return;
try {
if (update.type === "state") {
if (update.type === "unavailable") {
binding.facade.clear();
} else if (update.type === "replaced") {
if (update.snapshot.instance !== undefined) {
throw new Error("Singleton replacement has an instance address");
}
binding.facade.install(update.snapshot, context);
} else if (update.type === "state" && update.instance === undefined) {
binding.facade.update(update.member, update.sequence, update.value, context);
} else if (update.type === "event") {
} else if (update.type === "event" && update.instance === undefined) {
binding.facade.deliverEvent(update.member, update.event, context);
}
} catch (error) {

View file

@ -114,6 +114,34 @@ export class RemoteServiceProvider {
registration.singleton = instance;
}
/** Disconnect one singleton while preserving its active subscriptions and remote facades. */
withdraw<T>(service: Service<T>): void {
this.#assertActive();
this.#assertAllowed(service.id);
const registration = this.#registration(service.id, "singleton", false);
const previous = registration.singleton;
if (previous === undefined) return;
previous.active = false;
for (const remove of previous.removeMemberListeners) remove();
delete registration.singleton;
this.#emit(registration, { type: "unavailable" });
}
/** Replace one singleton while preserving its active subscriptions and remote facades. */
replace<T>(service: Service<T>, implementation: NoInfer<RemoteServiceContract<T>>): void {
this.#assertActive();
this.#assertAllowed(service.id);
const registration = this.#registration(service.id, "singleton", false);
const replacement = this.#classifyInstance(registration, implementation, undefined);
const previous = registration.singleton;
if (previous !== undefined) {
previous.active = false;
for (const remove of previous.removeMemberListeners) remove();
}
registration.singleton = replacement;
this.#emit(registration, { type: "replaced", snapshot: this.#snapshotInstance(replacement) });
}
use<T>(service: Service<T>): T {
this.#assertActive();
this.#assertAllowed(service.id);

View file

@ -158,6 +158,8 @@ export type ServiceProviderUpdate =
readonly member: string;
readonly event: JsonValue;
}
| { readonly type: "unavailable" }
| { readonly type: "replaced"; readonly snapshot: ServiceInstanceSnapshot }
| { readonly type: "spawned"; readonly instance: ServiceInstanceSnapshot }
| { readonly type: "closed"; readonly instance: ServiceInstanceAddress };

View file

@ -118,6 +118,44 @@ describe("plugin remote services", () => {
provider.dispose();
});
test("keeps singleton facades stable when their provider is replaced", async () => {
const provider = new RemoteServiceProvider([Models]);
provider.provide(Models, {
state: remoteState<ModelsState>({ selected: null, revision: 1 }),
async select() {},
});
const namespace = new RemoteServiceNamespace({
services: [Models],
connection: createLoopbackServiceConnection(provider),
});
const models = namespace.use(Models);
const state = models.state;
const select = models.select;
await namespace.ready(BACKGROUND_CONTEXT);
expect(state.value?.revision).toBe(1);
provider.withdraw(Models);
expect(state.value).toBeUndefined();
await expect(select({ provider: "test", modelId: "unavailable" }, BACKGROUND_CONTEXT)).rejects.toMatchObject({
code: "service_not_found",
});
const replacementSelect = vi.fn(async () => {});
provider.replace(Models, {
state: remoteState<ModelsState>({ selected: null, revision: 2 }),
select: replacementSelect,
});
expect(namespace.use(Models)).toBe(models);
expect(models.state).toBe(state);
expect(state.value?.revision).toBe(2);
await select({ provider: "test", modelId: "replacement" }, BACKGROUND_CONTEXT);
expect(replacementSelect).toHaveBeenCalledOnce();
await namespace.dispose(BACKGROUND_CONTEXT);
provider.dispose();
});
test("keeps deferred service handles inaccessible until host activation", async () => {
const provider = new RemoteServiceProvider([Models]);
provider.provide(Models, {

View file

@ -56,7 +56,15 @@ export function defineFacet(facet: Facet): Facet {
}
type LifecycleState = "setting_up" | "prepared" | "active" | "disposing" | "dead";
type GenerationPhase = "setup" | "assembling" | "connecting" | "activating" | "active" | "disposing" | "dead";
type GenerationPhase =
| "setup"
| "assembling"
| "connecting"
| "activating"
| "active"
| "reloading"
| "disposing"
| "dead";
type Disposal = () => void | Promise<void>;
class FacetLifecycle {
@ -165,6 +173,7 @@ class FacetLedger {
interface SingletonServiceHandle {
bind(implementation: object): void;
unbind(): void;
bindRemote(services: RemoteServices): void;
readonly proxy: object;
}
@ -199,7 +208,15 @@ class FacetServiceHandles {
if (remote || typeof value !== "function") return value;
let method = methods.get(property);
if (method === undefined) {
method = (...args: unknown[]) => Reflect.apply(value, implementation, args);
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;
@ -212,6 +229,10 @@ class FacetServiceHandles {
implementation = target;
remote = false;
},
unbind: () => {
implementation = undefined;
remote = false;
},
bindRemote: (services) => {
implementation = services.use(service) as object;
remote = true;
@ -249,6 +270,10 @@ class FacetServiceHandles {
this.#singletons.get(serviceId)?.bind(implementation);
}
unbindSingleton(serviceId: string): void {
this.#singletons.get(serviceId)?.unbind();
}
bindRemoteSingleton(serviceId: string, services: RemoteServices): void {
const handle = this.#singletons.get(serviceId);
if (handle === undefined) throw new Error(`Service ${serviceId} has no singleton handle`);
@ -301,12 +326,16 @@ class StagedServiceInstances<T> implements ServiceInstances<T> {
}
interface SingletonProvision {
readonly facetId: string;
readonly service: { readonly id: string; readonly rpc: boolean };
readonly implementation: object;
install(provider: RemoteServiceProvider): void;
withdraw(provider: RemoteServiceProvider): void;
replace(provider: RemoteServiceProvider): void;
}
interface KeyedProvision {
readonly facetId: string;
readonly service: { readonly id: string; readonly rpc: boolean };
connect(provider: RemoteServiceProvider): void;
}
@ -330,6 +359,8 @@ interface FacetOptions {
export interface FacetHost {
readonly services: RemoteServiceProvider;
/** Replace active facets with matching IDs while preserving consumer service handles. */
reload(facets: readonly Facet[]): Promise<void>;
dispose(): Promise<void>;
}
@ -339,6 +370,7 @@ export async function createFacetHost(options: FacetOptions): Promise<FacetHost>
await kernel.activate();
return Object.freeze({
services: kernel.provider,
reload: (facets: readonly Facet[]) => kernel.reload(facets),
dispose: () => kernel.dispose(),
});
}
@ -349,6 +381,7 @@ class FacetKernel {
readonly #connections: readonly FacetConnection[];
readonly #onError: (error: Error) => void;
readonly #lifecycles = new Map<string, FacetLifecycle>();
readonly #records = new Map<string, FacetRecord>();
readonly #singletons: SingletonProvision[] = [];
readonly #keyed: KeyedProvision[] = [];
readonly #handles: FacetServiceHandles;
@ -387,7 +420,9 @@ class FacetKernel {
throw new Error(`Facet ${facet.id} setup must be synchronous`);
}
lifecycle.prepared();
records.push(ledger.record());
const record = ledger.record();
records.push(record);
this.#records.set(record.facetId, record);
}
this.#phase = "assembling";
@ -425,8 +460,126 @@ class FacetKernel {
}
}
async reload(facets: readonly Facet[]): Promise<void> {
if (this.#phase !== "active") throw new Error(`Facet host cannot reload while ${this.#phase}`);
const ids = facets.map(({ id }) => id);
if (ids.some((id) => id.length === 0)) throw new Error("Facet ID must not be empty");
if (new Set(ids).size !== ids.length) throw new Error("Reloaded facet IDs must be unique");
for (const id of ids) {
if (!this.#records.has(id)) throw new Error(`Facet ${id} is not active`);
}
this.#phase = "reloading";
const stagedLifecycles: FacetLifecycle[] = [];
const candidates: Array<{
readonly lifecycle: FacetLifecycle;
readonly record: FacetRecord;
readonly singletons: SingletonProvision[];
readonly keyed: KeyedProvision[];
}> = [];
try {
for (const facet of facets) {
const lifecycle = new FacetLifecycle(facet.id);
stagedLifecycles.push(lifecycle);
const ledger = new FacetLedger(facet.id);
const singletons: SingletonProvision[] = [];
const keyed: KeyedProvision[] = [];
const result: unknown = facet.setup(this.#environment(lifecycle, ledger, singletons, keyed));
if (isPromiseLike(result)) {
void Promise.resolve(result).catch(() => {});
throw new Error(`Facet ${facet.id} setup must be synchronous`);
}
lifecycle.prepared();
const record = ledger.record();
const previous = this.#records.get(facet.id)!;
if (!sameFacetShape(previous, record)) {
throw new Error(`Reloaded facet ${facet.id} must preserve its service requirements and provisions`);
}
this.#validateReplacementProvisions(singletons, keyed);
candidates.push({ lifecycle, record, singletons, keyed });
}
} catch (error) {
const cleanupErrors: unknown[] = [];
for (const lifecycle of stagedLifecycles.reverse()) {
try {
await lifecycle.dispose();
} catch (cleanupError) {
cleanupErrors.push(cleanupError);
}
}
this.#phase = "active";
if (cleanupErrors.length > 0) {
throw new AggregateError([error, ...cleanupErrors], "Facet reload setup and cleanup failed");
}
throw error;
}
const replacements = new Map(candidates.map((candidate) => [candidate.record.facetId, candidate]));
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);
}
}
const disposalErrors: unknown[] = [];
for (const id of [...this.#activationOrder].reverse()) {
if (!replacements.has(id)) continue;
const lifecycle = this.#lifecycles.get(id)!;
this.#lifecycles.delete(id);
try {
await lifecycle.dispose();
} catch (error) {
disposalErrors.push(error);
}
}
if (disposalErrors.length > 0) {
for (const { lifecycle } of candidates.reverse()) {
try {
await lifecycle.dispose();
} catch (error) {
disposalErrors.push(error);
}
}
this.#phase = "active";
throw new AggregateError(disposalErrors, "Failed to deactivate reloaded facets");
}
for (let index = this.#singletons.length - 1; index >= 0; index--) {
if (replacements.has(this.#singletons[index]!.facetId)) this.#singletons.splice(index, 1);
}
for (let index = this.#keyed.length - 1; index >= 0; index--) {
if (replacements.has(this.#keyed[index]!.facetId)) this.#keyed.splice(index, 1);
}
this.#phase = "activating";
try {
for (const candidate of candidates) {
for (const provision of candidate.keyed) {
provision.connect(this.#keyedProvider!);
if (provision.service.rpc) provision.connect(this.provider);
}
this.#singletons.push(...candidate.singletons);
this.#keyed.push(...candidate.keyed);
this.#lifecycles.set(candidate.record.facetId, candidate.lifecycle);
this.#records.set(candidate.record.facetId, candidate.record);
}
for (const id of this.#activationOrder) {
const candidate = replacements.get(id);
if (candidate === undefined) continue;
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);
}
}
} finally {
this.#phase = "active";
}
}
async dispose(): Promise<void> {
if (this.#phase === "dead") return;
if (this.#phase === "reloading") throw new Error("Facet host cannot be disposed while reloading");
const errors = await this.#disposeLifecycles();
this.#phase = "disposing";
try {
@ -443,7 +596,29 @@ class FacetKernel {
if (errors.length > 1) throw new AggregateError(errors, "Failed to dispose facet generation");
}
#environment(lifecycle: FacetLifecycle, ledger: FacetLedger): FacetEnvironment {
#validateReplacementProvisions(singletons: readonly SingletonProvision[], keyed: readonly KeyedProvision[]): void {
const definitions = [
...singletons
.filter(({ service }) => service.rpc)
.map(({ service }) => ({ service, mode: "singleton" as const })),
...keyed.filter(({ service }) => service.rpc).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);
}
} finally {
provider.dispose();
}
}
#environment(
lifecycle: FacetLifecycle,
ledger: FacetLedger,
singletons: SingletonProvision[] = this.#singletons,
keyed: KeyedProvision[] = this.#keyed,
): FacetEnvironment {
return {
provide: <T>(service: Service<T>, implementation: NoInfer<T>): void => {
lifecycle.assertSettingUp("provide services");
@ -451,17 +626,20 @@ class FacetKernel {
throw new TypeError(`Service ${service.id} implementation must be an object`);
}
ledger.provide(service, "singleton");
this.#singletons.push({
singletons.push({
facetId: lifecycle.id,
service,
implementation,
install: (provider) => provider.provide(service, implementation as NoInfer<RemoteServiceContract<T>>),
withdraw: (provider) => provider.withdraw(service),
replace: (provider) => provider.replace(service, implementation as NoInfer<RemoteServiceContract<T>>),
});
},
provideMany: <T>(service: Service<T>): ServiceInstances<T> => {
lifecycle.assertSettingUp("provide service instances");
ledger.provide(service, "keyed");
const instances = new StagedServiceInstances(service, lifecycle);
this.#keyed.push({ service, connect: (target) => instances.connect(target) });
keyed.push({ facetId: lifecycle.id, service, connect: (target) => instances.connect(target) });
return instances;
},
use: <T>(service: Service<T>): T => {
@ -610,7 +788,7 @@ class FacetKernel {
}
#assertServiceAccess(): void {
if (this.#phase !== "activating" && this.#phase !== "active") {
if (this.#phase !== "activating" && this.#phase !== "active" && this.#phase !== "reloading") {
throw new Error(`Facet service handles cannot be used during ${this.#phase}`);
}
}
@ -709,6 +887,16 @@ function topologicalOrder(
return order;
}
function sameFacetShape(left: FacetRecord, right: FacetRecord): boolean {
return sameReferences(left.requires, right.requires) && sameReferences(left.provides, right.provides);
}
function sameReferences(left: readonly FacetServiceReference[], right: readonly FacetServiceReference[]): boolean {
if (left.length !== right.length) return false;
const rightKeys = new Set(right.map(referenceKey));
return left.every((reference) => rightKeys.has(referenceKey(reference)));
}
function referenceKey(reference: FacetServiceReference): string {
return `${reference.serviceId}\0${reference.mode}`;
}

View file

@ -15,6 +15,6 @@ Facet setup generates each host's RPC service catalogue from its provided RPC-ca
With `PI_EXPERIMENTAL=1`, an interactive `pi client` opens the service-only TUI. It lists the repository-backed `SessionDirectory`, creates or switches Sessions through `SessionManagement`, and selects from the attached worker's `Models` state without invoking Harness drive.
The Session worker loads all built-in and configured Session facets through one `FacetLoader`, then creates one active `FacetHost`. The service-only TUI likewise loads all TUI facets once and creates one host that combines its local `Tui` service with connected server and selected-Session services. Runtime facets provide host-created services such as `Harness`, `ModelsRuntime`, and `Tui` through the same `env.provide()` operation used by every other facet. 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 disposal runs in reverse dependency order for the complete generation. Services are RPC-capable by default; process-local tokens such as `Harness`, `ModelsRuntime`, and `Tui` declare `{ rpc: false }`. The host automatically publishes every provided RPC-capable service.
The Session worker loads all built-in and configured Session facets through one `FacetLoader`, then creates one active `FacetHost`. The service-only TUI likewise loads all TUI facets once and creates one host that combines its local `Tui` service with connected server and selected-Session services. A host may reload selected facets when their declared service shape is unchanged; retained consumers keep the same local and RPC service facades while replacement implementations and singleton snapshots are installed behind them. Runtime facets provide host-created services such as `Harness`, `ModelsRuntime`, and `Tui` through the same `env.provide()` operation used by every other facet. 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. Services are RPC-capable by default; process-local tokens such as `Harness`, `ModelsRuntime`, and `Tui` declare `{ rpc: false }`. The host automatically publishes every provided RPC-capable service.
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. TUI facets own subscriptions to their remote replicated state and events; `ExperimentalClientTui` retains only terminal rendering, navigation, and action dispatch. The server provider remains directly assembled until its complete host environment exists. The question dialog, diff review, Git, indexing-job, and canvas 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.

View file

@ -1,6 +1,22 @@
import {
BACKGROUND_CONTEXT,
type Context,
createLoopbackServiceConnection,
defineService,
RemoteServiceNamespace,
} from "@earendil-works/pi-agent-core";
import { describe, expect, test, vi } from "vitest";
import { combineFacetLoaders, createStaticFacetLoader, type FacetLoader } from "../src/experimental/facet-loader.ts";
import { defineFacet } from "../src/experimental/facets.ts";
import { createFacetHost, defineFacet } from "../src/experimental/facets.ts";
interface GenerationValue {
read(context: Context): Promise<string>;
}
const LocalGenerationValue = defineService<GenerationValue>("test.experimental.local-generation-value", {
rpc: false,
});
const RemoteGenerationValue = defineService<GenerationValue>("test.experimental.remote-generation-value");
const firstFacet = defineFacet({ id: "first", setup() {} });
const secondFacet = defineFacet({ id: "second", setup() {} });
@ -56,6 +72,133 @@ describe("experimental facet loader", () => {
expect(dispose).toHaveBeenCalledOnce();
});
test("keeps local and RPC service handles stable when their provider facet reloads", async () => {
const trace: string[] = [];
let localValue: GenerationValue | undefined;
let generation = 0;
let reportReplacementStarted!: () => void;
const replacementStarted = new Promise<void>((resolve) => {
reportReplacementStarted = resolve;
});
let continueReplacement!: () => void;
const replacementCanContinue = new Promise<void>((resolve) => {
continueReplacement = resolve;
});
const consumer = defineFacet({
id: "consumer",
setup(env) {
trace.push("setup consumer");
localValue = env.use(LocalGenerationValue);
env.onActivate(async () => {
trace.push(`activate consumer:${await localValue!.read(BACKGROUND_CONTEXT)}`);
});
env.onDeactivate(() => {
trace.push("deactivate consumer");
});
},
});
const loader: FacetLoader = {
async load() {
const name = generation++ === 0 ? "A" : "B";
trace.push(`load ${name}`);
return {
facets: [
defineFacet({
id: "provider",
setup(env) {
trace.push(`setup provider ${name}`);
const implementation: GenerationValue = {
async read() {
return name;
},
};
env.provide(LocalGenerationValue, implementation);
env.provide(RemoteGenerationValue, implementation);
env.onActivate(async () => {
trace.push(`activate provider ${name}`);
if (name === "B") {
reportReplacementStarted();
await replacementCanContinue;
}
});
env.onDeactivate(() => {
trace.push(`deactivate provider ${name}`);
});
},
}),
],
async dispose() {
trace.push(`unload ${name}`);
},
};
},
};
const loadedA = await loader.load();
const host = await createFacetHost({ facets: [consumer, ...loadedA.facets] });
const originalLocalHandle = localValue!;
const localRead = originalLocalHandle.read;
const remoteServices = new RemoteServiceNamespace({
services: [RemoteGenerationValue],
connection: createLoopbackServiceConnection(host.services),
});
const originalRemoteHandle = remoteServices.use(RemoteGenerationValue);
const remoteRead = originalRemoteHandle.read;
await remoteServices.ready(BACKGROUND_CONTEXT);
await expect(localRead(BACKGROUND_CONTEXT)).resolves.toBe("A");
await expect(remoteRead(BACKGROUND_CONTEXT)).resolves.toBe("A");
await expect(
host.reload([
defineFacet({
id: "provider",
setup(env) {
env.provide(LocalGenerationValue, {
async read() {
return "invalid";
},
});
},
}),
]),
).rejects.toThrow("Reloaded facet provider must preserve its service requirements and provisions");
await expect(localRead(BACKGROUND_CONTEXT)).resolves.toBe("A");
await expect(remoteRead(BACKGROUND_CONTEXT)).resolves.toBe("A");
const loadedB = await loader.load();
const reload = host.reload(loadedB.facets);
await replacementStarted;
expect(() => localRead(BACKGROUND_CONTEXT)).toThrow(`Service ${LocalGenerationValue.id} is disconnected`);
await expect(remoteRead(BACKGROUND_CONTEXT)).rejects.toMatchObject({ code: "service_not_found" });
continueReplacement();
await reload;
await loadedA.dispose();
expect(localValue).toBe(originalLocalHandle);
expect(remoteServices.use(RemoteGenerationValue)).toBe(originalRemoteHandle);
await expect(localRead(BACKGROUND_CONTEXT)).resolves.toBe("B");
await expect(remoteRead(BACKGROUND_CONTEXT)).resolves.toBe("B");
await remoteServices.dispose(BACKGROUND_CONTEXT);
await host.dispose();
await loadedB.dispose();
expect(trace).toEqual([
"load A",
"setup consumer",
"setup provider A",
"activate provider A",
"activate consumer:A",
"load B",
"setup provider B",
"deactivate provider A",
"activate provider B",
"unload A",
"deactivate consumer",
"deactivate provider B",
"unload B",
]);
});
test("creates a reusable static loader", async () => {
const loader = createStaticFacetLoader([firstFacet]);
const first = await loader.load();

View file

@ -168,6 +168,13 @@ export const ServiceProviderUpdateSchema = Type.Union([
member: IdSchema,
event: JsonValueSchema,
}),
StrictObject({
type: Type.Literal("unavailable"),
}),
StrictObject({
type: Type.Literal("replaced"),
snapshot: ServiceInstanceSnapshotSchema,
}),
StrictObject({
type: Type.Literal("spawned"),
instance: ServiceInstanceSnapshotSchema,

View file

@ -290,6 +290,23 @@ describe("protocol validation", () => {
},
}),
).toMatchObject({ update: { type: "event", member: "events" } });
expect(
parseServerMessage({
type: "service_event",
subscriptionId: "subscription-1",
update: { type: "unavailable" },
}),
).toMatchObject({ update: { type: "unavailable" } });
expect(
parseServerMessage({
type: "service_event",
subscriptionId: "subscription-1",
update: {
type: "replaced",
snapshot: { members: [{ name: "select", kind: "method" }], states: {} },
},
}),
).toMatchObject({ update: { type: "replaced", snapshot: { members: [{ name: "select" }] } } });
const keyed: ClientMessage = {
type: "request",
id: "request-1",