feat: clarify Chord remote service APIs

This commit is contained in:
Armin Ronacher 2026-08-29 21:43:01 +02:00
parent b1e9b9ea8f
commit 024439d151
19 changed files with 188 additions and 214 deletions

View file

@ -12,7 +12,7 @@ This document assumes you already understand `AgentHarness`, `AgentLane`, `Sessi
The coding agent is assembled independently in several processes. Three layers:
1. The **facet kernel** owns service-aware lifecycle mechanics: synchronous setup, dependency assembly, local and connected service binding, activation, scoped resource ownership, setup-failure cleanup, reload, and reverse-order disposal. It knows about services and connections, but not about Harness, tools, TUI components, or coding-agent policy.
1. The **facet kernel** owns service-aware lifecycle mechanics: synchronous setup, dependency assembly, local and connected service binding, activation, scoped resource ownership, setup-failure cleanup, reload, and reverse-order disposal. It knows about services and remote service sources, but not about Harness, tools, TUI components, or coding-agent policy.
2. An **application host** owns one concrete runtime and contributes runtime facets that provide its concrete services. The **session host** normally runs in a dedicated session worker and owns session authority — the real Harness. A **presentation host** (TUI today, web later) owns a user interface. A **server host** owns server-wide authority: session records (`SessionRepo`), session-worker management, authentication, attachment, and routing between presentations and session workers.
3. An **extension** may distribute independent host-specific bundles containing **facets**. No aggregate extension object is loaded into all processes. Each host loads only facets built for that process and those facets can use only services available in that host graph.
@ -103,7 +103,7 @@ Each host receives one or more static, combined, or extension-backed loaders. A
An extension resolver may add identity, ordering, version selection, package isolation, and process-specific source resolution. Its output remains independent `FacetLoader` inputs for each process rather than one cross-process extension object.
After transport setup, a host gives the kernel its loaded facets, runtime facets that provide concrete local services, and any host-selected service connections. The kernel runs every `setup()` in loader order, validates the complete service graph, binds dependencies, and then activates providers before consumers. Setup failure and normal shutdown dispose resources in reverse dependency order.
After transport setup, a host gives the kernel its loaded facets, runtime facets that provide concrete local services, and any host-selected remote service sources. The kernel runs every `setup()` in loader order, validates the complete service graph, binds dependencies, and then activates providers before consumers. Setup failure and normal shutdown dispose resources in reverse dependency order.
Exactly one process owns a Session's authority at a time. Worker replacement must close the old owner before a new process opens the same durable Session. Each presentation and Session worker uses one multiplexed connection to its server; facets never open private sockets or handle request IDs, cancellation frames, routing namespaces, or reconnect buffering.
@ -151,7 +151,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 `ServiceSpawner`, so spawning and closing instances do not change the graph.
After every facet has registered, the host generates its outgoing catalogue from non-local provisions, obtains catalogues from its connections, resolves requirements to local or connected provisions, rejects missing providers, duplicate offers or singleton owners, singleton/keyed mismatches, invalid dependency cycles, and invalid remote service implementations, then records consumer-to-provider edges for lifecycle ordering. `use()` and `observe()` declare hard requirements; optional dependencies require a future distinct acquisition API rather than inference from call failure. The ledger and resulting graph are private kernel machinery, not a facet-facing plan or second declaration format.
After every facet has registered, the host generates its outgoing catalogue from non-local provisions, obtains catalogues from its remote service sources, 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).

View file

@ -135,9 +135,9 @@ The host records setup calls in a private generation ledger. Plugin authors do n
After every plugin has completed setup, the host must:
1. collect local provisions and catalogues from configured connections;
1. collect local provisions and catalogues from configured remote service sources;
2. resolve every hard requirement to exactly one local or connected provision;
3. reject missing providers, duplicate providers, ambiguous connection offers, and singleton/keyed mismatches;
3. reject missing providers, duplicate providers, ambiguous source offers, and singleton/keyed mismatches;
4. reject duplicate plugin IDs;
5. derive provider-to-consumer lifecycle edges;
6. reject dependency cycles;

View file

@ -36,11 +36,13 @@ The design has a few connected pieces:
ordered updates, and become unready on disconnect or replacement until they
are rehydrated.
- **Remote connections** carry logical service calls and subscriptions through
an application-supplied adapter. Chord requires strict-JSON arguments,
results, snapshots, updates, and catalogues, but does not prescribe framing,
routing, transport, or an application wire envelope. Symmetric RPC peers are
planned as one optional implementation of this boundary.
- **Remote service sources** advertise services available outside a facet host
and open bindings for the services its facets require. Bindings carry logical
calls and subscriptions through an application-supplied adapter. Chord
requires strict-JSON arguments, results, snapshots, updates, and catalogues,
but does not prescribe framing, routing, transport, or an application wire
envelope. Symmetric RPC peers are planned as one optional implementation of
this boundary.
- **Context** Chord provides a Go-like context system for cancellation and
invocation-scoped application values. Applications can carry permissions or

View file

@ -81,7 +81,6 @@ export function defineService(id: string, options?: { readonly local?: boolean }
return Object.freeze({ id, local: options?.local ?? false });
}
/** @publicApiReview Consider hiding this low-level remote binding factory behind facet connections. */
export function createRemoteServiceBinding(options: RemoteServiceBindingOptions): RemoteServiceBinding {
return new RemoteServiceBindingImpl(options);
}

View file

@ -6,10 +6,10 @@ import { MutableReplicatedStateImpl } from "../services/state.ts";
import type {
Context,
Facet,
FacetConnection,
FacetEnvironment,
FacetOptions,
RemoteServiceContract,
RemoteServiceSource,
RemoteServices,
Service,
ServiceMode,
@ -25,7 +25,7 @@ interface FacetServiceReference {
interface ExternalService {
readonly service: Service<unknown>;
readonly mode: ServiceMode;
readonly connection: FacetConnection;
readonly source: RemoteServiceSource;
}
interface FacetShape {
@ -305,11 +305,11 @@ type FacetProvision =
/** Private lifecycle and dependency kernel behind the atomic host entry point. */
export class FacetKernel {
readonly #initialFacets: readonly Facet[];
readonly #connections: readonly FacetConnection[];
readonly #serviceSources: readonly RemoteServiceSource[];
readonly #onError: (error: Error) => void;
readonly #facets = new Map<string, FacetRuntime>();
readonly #serviceSlots: HostServiceSlots;
readonly #connectionBindings = new Map<FacetConnection, RemoteServices>();
readonly #sourceBindings = new Map<RemoteServiceSource, RemoteServices>();
#activationOrder: readonly string[] = [];
#provider: RemoteServiceProvider | undefined;
#localKeyedServices: LocalKeyedServiceRegistry | undefined;
@ -320,7 +320,7 @@ export class FacetKernel {
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("Facet IDs must be unique within a generation");
this.#initialFacets = options.facets;
this.#connections = options.connections ?? [];
this.#serviceSources = options.serviceSources ?? [];
this.#onError = options.onError ?? (() => {});
this.#serviceSlots = new HostServiceSlots(() => this.#assertServiceAccess());
}
@ -366,9 +366,7 @@ export class FacetKernel {
this.#bindServices(externalServices);
this.#phase = "connecting";
await Promise.all(
[...this.#connectionBindings.values()].map((services) => services.ready(BACKGROUND_CONTEXT)),
);
await Promise.all([...this.#sourceBindings.values()].map((services) => services.ready(BACKGROUND_CONTEXT)));
this.#phase = "activating";
for (const id of this.#activationOrder) await this.#facets.get(id)!.lifecycle.activate();
@ -381,7 +379,7 @@ export class FacetKernel {
} catch (cleanupError) {
cleanupErrors.push(cleanupError);
}
cleanupErrors.push(...(await this.#disposeConnectionBindings()));
cleanupErrors.push(...(await this.#disposeSourceBindings()));
this.#serviceSlots.dispose();
this.#provider?.dispose();
this.#phase = "dead";
@ -498,7 +496,7 @@ export class FacetKernel {
} catch (error) {
errors.push(error);
}
errors.push(...(await this.#disposeConnectionBindings()));
errors.push(...(await this.#disposeSourceBindings()));
this.#serviceSlots.dispose();
this.#provider?.dispose();
this.#phase = "dead";
@ -569,18 +567,18 @@ export class FacetKernel {
async #resolveExternalServices(records: readonly FacetShape[]): Promise<ReadonlyMap<string, ExternalService>> {
const catalogues = await Promise.all(
this.#connections.map(async (connection) => ({
connection,
entries: await connection.catalogue(BACKGROUND_CONTEXT),
this.#serviceSources.map(async (source) => ({
source,
entries: await source.catalogue(BACKGROUND_CONTEXT),
})),
);
const offered = new Map<string, { readonly mode: ServiceMode; readonly connection: FacetConnection }>();
for (const { connection, entries } of catalogues) {
const offered = new Map<string, { readonly mode: ServiceMode; readonly source: RemoteServiceSource }>();
for (const { source, entries } of catalogues) {
for (const { serviceId, mode } of entries) {
if (offered.has(serviceId)) {
throw new Error(`Facet host service ${serviceId} is offered by more than one connection`);
throw new Error(`Facet host service ${serviceId} is offered by more than one source`);
}
offered.set(serviceId, { mode, connection });
offered.set(serviceId, { mode, source });
}
}
@ -589,34 +587,34 @@ export class FacetKernel {
for (const { requires } of records) {
for (const requirement of requires) {
if (local.has(requirement.serviceId) || external.has(requirement.serviceId)) continue;
let provider = offered.get(requirement.serviceId);
if (provider === undefined) {
const deferred = this.#connections.filter(
let source = offered.get(requirement.serviceId);
if (source === undefined) {
const deferred = this.#serviceSources.filter(
({ acceptsUnavailableServices }) => acceptsUnavailableServices,
);
if (deferred.length > 1) {
throw new Error(`Facet host service ${requirement.serviceId} has more than one deferred connection`);
throw new Error(`Facet host service ${requirement.serviceId} has more than one deferred source`);
}
if (deferred.length === 1) provider = { mode: requirement.mode, connection: deferred[0]! };
if (deferred.length === 1) source = { mode: requirement.mode, source: deferred[0]! };
}
if (provider !== undefined) {
external.set(requirement.serviceId, { ...provider, service: requirement.service });
if (source !== undefined) {
external.set(requirement.serviceId, { ...source, service: requirement.service });
}
}
}
const serviceIdsByConnection = new Map<FacetConnection, string[]>();
for (const [serviceId, { connection }] of external) {
let serviceIds = serviceIdsByConnection.get(connection);
const serviceIdsBySource = new Map<RemoteServiceSource, string[]>();
for (const [serviceId, { source }] of external) {
let serviceIds = serviceIdsBySource.get(source);
if (serviceIds === undefined) {
serviceIds = [];
serviceIdsByConnection.set(connection, serviceIds);
serviceIdsBySource.set(source, serviceIds);
}
serviceIds.push(serviceId);
}
for (const [connection, serviceIds] of serviceIdsByConnection) {
this.#connectionBindings.set(
connection,
connection.open({
for (const [source, serviceIds] of serviceIdsBySource) {
this.#sourceBindings.set(
source,
source.open({
services: serviceIds.map((id) => ({ id })),
assertAccess: () => this.#assertServiceAccess(),
onError: this.#onError,
@ -656,9 +654,9 @@ export class FacetKernel {
this.#serviceSlots.bindKeyed(provision.service.id, this.#localKeyedRegistry);
}
}
for (const [serviceId, { service, mode, connection }] of externalServices) {
const services = this.#connectionBindings.get(connection);
if (services === undefined) throw new Error(`Service connection for ${serviceId} is not open`);
for (const [serviceId, { service, mode, source }] of externalServices) {
const services = this.#sourceBindings.get(source);
if (services === undefined) throw new Error(`Service source for ${serviceId} is not open`);
if (mode === "singleton") {
this.#serviceSlots.bindSingleton(serviceId, services.use(service) as object);
} else {
@ -676,9 +674,9 @@ export class FacetKernel {
return this.#localKeyedServices;
}
async #disposeConnectionBindings(): Promise<unknown[]> {
const bindings = [...this.#connectionBindings.values()];
this.#connectionBindings.clear();
async #disposeSourceBindings(): Promise<unknown[]> {
const bindings = [...this.#sourceBindings.values()];
this.#sourceBindings.clear();
const results = await Promise.allSettled(bindings.map((services) => services.dispose(BACKGROUND_CONTEXT)));
return results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
}

View file

@ -16,7 +16,6 @@ export type {
Context,
ContextKey,
Facet,
FacetConnection,
FacetEnvironment,
FacetHost,
FacetLoader,
@ -26,10 +25,10 @@ export type {
MutableReplicatedState,
RemoteServiceBinding,
RemoteServiceBindingOptions,
RemoteServiceConnection,
RemoteServiceContract,
RemoteServiceErrorCode,
RemoteServiceSource,
RemoteServices,
RemoteServiceTransport,
ReplicatedState,
Service,
ServiceCall,

View file

@ -4,7 +4,7 @@ import type {
JsonValue,
RemoteServiceBinding,
RemoteServiceBindingOptions,
RemoteServiceConnection,
RemoteServiceTransport,
Service,
ServiceInstanceAddress,
ServiceInstanceSnapshot,
@ -138,7 +138,7 @@ class MemberSlot {
class ServiceFacade {
readonly #serviceId: string;
readonly #address: ServiceInstanceAddress | undefined;
readonly #connection: RemoteServiceConnection;
readonly #transport: RemoteServiceTransport;
readonly #reportError: ErrorReporter;
readonly #slots = new Map<string, MemberSlot>();
readonly #descriptions = new Map<string, ServiceMemberKind>();
@ -149,14 +149,14 @@ class ServiceFacade {
constructor(
serviceId: string,
address: ServiceInstanceAddress | undefined,
connection: RemoteServiceConnection,
transport: RemoteServiceTransport,
isActive: () => boolean,
assertAccess: () => void,
reportError: ErrorReporter,
) {
this.#serviceId = serviceId;
this.#address = address;
this.#connection = connection;
this.#transport = transport;
this.#isActive = isActive;
this.#assertAccess = assertAccess;
this.#reportError = reportError;
@ -210,7 +210,7 @@ class ServiceFacade {
this.#serviceId,
member,
(args, context) =>
this.#connection.invoke(
this.#transport.invoke(
{
serviceId: this.#serviceId,
...(this.#address === undefined ? {} : { instance: this.#address }),
@ -244,7 +244,7 @@ interface KeyedInstance extends InstanceDirectoryEntry {
class KeyedBinding<T> {
readonly #service: Service<T>;
readonly #connection: RemoteServiceConnection;
readonly #transport: RemoteServiceTransport;
readonly #reportError: ErrorReporter;
readonly #assertAccess: () => void;
readonly #onEmpty: () => void;
@ -257,14 +257,14 @@ class KeyedBinding<T> {
constructor(
service: Service<T>,
connection: RemoteServiceConnection,
transport: RemoteServiceTransport,
reportError: ErrorReporter,
assertAccess: () => void,
onEmpty: () => void,
bound: boolean,
) {
this.#service = service;
this.#connection = connection;
this.#transport = transport;
this.#reportError = reportError;
this.#assertAccess = assertAccess;
this.#onEmpty = onEmpty;
@ -330,7 +330,7 @@ class KeyedBinding<T> {
}
async #start(revision: number): Promise<void> {
const subscription = await this.#connection.subscribe(
const subscription = await this.#transport.subscribe(
this.#service.id,
"keyed",
(update, context) => {
@ -386,7 +386,7 @@ class KeyedBinding<T> {
const facade = new ServiceFacade(
this.#service.id,
address,
this.#connection,
this.#transport,
() => active && !this.#closed,
this.#assertAccess,
this.#reportError,
@ -406,7 +406,7 @@ class KeyedBinding<T> {
}
export class RemoteServiceBindingImpl implements RemoteServiceBinding {
readonly #connection: RemoteServiceConnection;
readonly #transport: RemoteServiceTransport;
readonly #allowlist = new Set<string>();
readonly #reportError: ErrorReporter;
readonly #modes = new Map<string, "singleton" | "keyed">();
@ -419,7 +419,7 @@ export class RemoteServiceBindingImpl implements RemoteServiceBinding {
#disposed = false;
constructor(options: RemoteServiceBindingOptions) {
this.#connection = options.connection;
this.#transport = options.transport;
const ids = options.services.map(({ id }) => id);
if (new Set(ids).size !== ids.length) throw new TypeError("Remote service binding has duplicate service IDs");
for (const id of ids) this.#allowlist.add(id);
@ -437,7 +437,7 @@ export class RemoteServiceBindingImpl implements RemoteServiceBinding {
binding.facade = new ServiceFacade(
service.id,
undefined,
this.#connection,
this.#transport,
() => binding!.active && !this.#disposed && this.#bound,
() => this.#assertAccess(),
this.#reportError,
@ -464,7 +464,7 @@ export class RemoteServiceBindingImpl implements RemoteServiceBinding {
if (binding === undefined) {
binding = new KeyedBinding(
service,
this.#connection,
this.#transport,
this.#reportError,
() => this.#assertAccess(),
() => {
@ -556,7 +556,7 @@ export class RemoteServiceBindingImpl implements RemoteServiceBinding {
}
async #startSingleton(serviceId: string, binding: SingletonBinding, revision: number): Promise<void> {
const subscription = await this.#connection.subscribe(
const subscription = await this.#transport.subscribe(
serviceId,
"singleton",
(update, context) => {

View file

@ -1,5 +1,5 @@
export type {
RemoteServiceConnection,
RemoteServiceTransport,
ServiceCall,
ServiceCatalogueEntry,
ServiceInstanceAddress,

View file

@ -38,7 +38,6 @@ export type ServiceMode = "singleton" | "keyed";
/** Stable identity for one shared TypeScript service contract. */
export interface Service<T> {
/** @publicApiReview Decide whether Chord should expose and own service identifiers. */
readonly id: string;
/** Process-local services accept unrestricted object contracts and are never published remotely. */
readonly local: boolean;
@ -79,17 +78,12 @@ type InvalidRemoteMemberNames<T> = {
[TKey in keyof T]-?: InvalidRemoteMember<T[TKey]> extends never ? never : TKey;
}[keyof T];
/** @publicApiReview Consider keeping this contract-validation helper internal. */
export type RemoteServiceContract<T> = InvalidRemoteMemberNames<T> extends never ? T : never;
export interface ServiceSpawner<T> {
spawn(key: string, implementation: T): () => void;
}
/**
* @publicApiReview This host-facing binding interface may be better hidden behind
* higher-level facet connection APIs.
*/
export interface RemoteServices {
use<T>(service: Service<T>): T;
observe<T>(service: Service<T>, handler: (service: T, context: Context) => void | Promise<void>): () => void;
@ -98,37 +92,31 @@ export interface RemoteServices {
dispose(context: Context): Promise<void>;
}
/** @publicApiReview Consider hiding this low-level transport type behind the connection API. */
export type ServiceCatalogueEntry = {
readonly serviceId: string;
readonly mode: ServiceMode;
};
/** @publicApiReview Consider hiding this low-level transport type behind the connection API. */
export type ServiceInstanceAddress = {
readonly key: string;
readonly generation: number;
};
/** @publicApiReview Consider hiding this low-level transport type behind the connection API. */
export type ServiceMemberSnapshot =
| { readonly name: string; readonly kind: "method" }
| { readonly name: string; readonly kind: "state"; readonly sequence: number; readonly value: JsonValue };
/** @publicApiReview Consider hiding this low-level transport type behind the connection API. */
export type ServiceInstanceSnapshot = {
readonly instance?: ServiceInstanceAddress;
readonly members: readonly ServiceMemberSnapshot[];
};
/** @publicApiReview Consider hiding this low-level transport type behind the connection API. */
export type ServiceSubscriptionSnapshot = {
readonly serviceId: string;
readonly mode: ServiceMode;
readonly instances: readonly ServiceInstanceSnapshot[];
};
/** @publicApiReview Consider hiding this low-level transport type behind the connection API. */
export type ServiceProviderUpdate =
| {
readonly type: "state";
@ -142,7 +130,6 @@ export type ServiceProviderUpdate =
| { readonly type: "spawned"; readonly instance: ServiceInstanceSnapshot }
| { readonly type: "closed"; readonly instance: ServiceInstanceAddress };
/** @publicApiReview Consider hiding this low-level transport type behind the connection API. */
export type ServiceCall = {
readonly serviceId: string;
readonly instance?: ServiceInstanceAddress;
@ -151,7 +138,6 @@ export type ServiceCall = {
readonly args: readonly JsonValue[];
};
/** @publicApiReview Consider hiding this low-level transport type behind the connection API. */
export interface ServiceSubscription {
readonly snapshot: ServiceSubscriptionSnapshot;
activate(): void;
@ -165,9 +151,8 @@ export interface ServiceSubscription {
* boundary must remain strict JSON. Chord does not clone values or require a particular application wire protocol;
* adapters own serialization and any isolation copies they require.
*
* @publicApiReview Consider replacing this low-level transport boundary with a higher-level adapter API.
*/
export interface RemoteServiceConnection {
export interface RemoteServiceTransport {
invoke(call: ServiceCall, context: Context): Promise<JsonValue | undefined>;
subscribe(
serviceId: string,
@ -179,7 +164,7 @@ export interface RemoteServiceConnection {
export interface RemoteServiceBindingOptions {
readonly services: readonly { readonly id: string }[];
readonly connection: RemoteServiceConnection;
readonly transport: RemoteServiceTransport;
readonly bound?: boolean;
readonly onError?: (error: Error) => void;
readonly assertAccess?: () => void;
@ -223,8 +208,8 @@ export interface Facet {
setup(env: FacetEnvironment): void;
}
export interface FacetConnection {
/** Whether this currently unavailable route may provisionally own absent requirements. */
export interface RemoteServiceSource {
/** Whether this currently unavailable source may provisionally own absent requirements. */
readonly acceptsUnavailableServices: boolean;
catalogue(context: Context): Promise<readonly ServiceCatalogueEntry[]>;
open(options: {
@ -236,7 +221,7 @@ export interface FacetConnection {
export interface FacetOptions {
readonly facets: readonly Facet[];
readonly connections?: readonly FacetConnection[];
readonly serviceSources?: readonly RemoteServiceSource[];
readonly onError?: (error: Error) => void;
}

View file

@ -10,7 +10,7 @@ import {
defineService,
type FacetLoader,
} from "../src/index.ts";
import { createLoopbackServiceConnection } from "./helpers.ts";
import { createLoopbackServiceTransport } from "./helpers.ts";
interface GenerationValue {
read(context: Context): Promise<string>;
@ -143,7 +143,7 @@ describe("facet loader", () => {
const localRead = originalLocalHandle.read;
const remoteServices = createRemoteServiceBinding({
services: [RemoteGenerationValue],
connection: createLoopbackServiceConnection(host.services),
transport: createLoopbackServiceTransport(host.services),
});
const originalRemoteHandle = remoteServices.use(RemoteGenerationValue);
const remoteRead = originalRemoteHandle.read;

View file

@ -6,12 +6,12 @@ import {
createRemoteServiceBinding,
defineFacet,
defineService,
type FacetConnection,
type MutableReplicatedState,
RemoteServiceProvider,
type RemoteServiceSource,
type ReplicatedState,
} from "../src/index.ts";
import { createLoopbackServiceConnection } from "./helpers.ts";
import { createLoopbackServiceTransport } from "./helpers.ts";
interface Source {
read(context: Context): Promise<string>;
@ -144,7 +144,7 @@ describe("facet host", () => {
expect(trace).toEqual(["activate provider", "activate observer", "observe one"]);
const remoteServices = createRemoteServiceBinding({
services: [KeyedValue],
connection: createLoopbackServiceConnection(host.services),
transport: createLoopbackServiceTransport(host.services),
});
const remoteValues: string[] = [];
remoteServices.observe(KeyedValue, async (service, context) => {
@ -272,15 +272,15 @@ describe("facet host", () => {
});
const leftNamespace = createRemoteServiceBinding({
services: [LeftValue],
connection: createLoopbackServiceConnection(leftProvider),
transport: createLoopbackServiceTransport(leftProvider),
bound: false,
});
const rightNamespace = createRemoteServiceBinding({
services: [RightValue],
connection: createLoopbackServiceConnection(rightProvider),
transport: createLoopbackServiceTransport(rightProvider),
bound: false,
});
const connections = [
const serviceSources = [
{ namespace: leftNamespace, provider: leftProvider },
{ namespace: rightNamespace, provider: rightProvider },
].map(({ namespace, provider }) => {
@ -312,7 +312,7 @@ describe("facet host", () => {
},
});
const host = await createFacetHost({ facets: [facet], connections });
const host = await createFacetHost({ facets: [facet], serviceSources });
await expect(host.services.use(CombinedValue).read(BACKGROUND_CONTEXT)).resolves.toBe("left right");
await host.dispose();
@ -321,28 +321,28 @@ describe("facet host", () => {
rightProvider.dispose();
});
test("rejects a service offered by multiple connections", async () => {
test("rejects a service offered by multiple sources", async () => {
const duplicate = {
acceptsUnavailableServices: false,
async catalogue() {
return [{ serviceId: LeftValue.id, mode: "singleton" as const }];
},
open() {
throw new Error("Ambiguous connections must not open");
throw new Error("Ambiguous sources must not open");
},
} satisfies FacetConnection;
} satisfies RemoteServiceSource;
const consumer = defineFacet({
id: "duplicate-consumer",
setup(env) {
env.use(LeftValue);
},
});
await expect(createFacetHost({ facets: [consumer], connections: [duplicate, duplicate] })).rejects.toThrow(
`Facet host service ${LeftValue.id} is offered by more than one connection`,
await expect(createFacetHost({ facets: [consumer], serviceSources: [duplicate, duplicate] })).rejects.toThrow(
`Facet host service ${LeftValue.id} is offered by more than one source`,
);
});
test("reopens connection bindings from changed catalogues for a replacement generation", async () => {
test("reopens source bindings from changed catalogues for a replacement generation", async () => {
const leftProvider = new RemoteServiceProvider([LeftValue]);
leftProvider.provide(LeftValue, {
async read() {
@ -358,7 +358,7 @@ describe("facet host", () => {
let currentProvider = leftProvider;
let opened = 0;
let disposed = 0;
const connection = {
const source = {
acceptsUnavailableServices: false,
async catalogue() {
return currentProvider.catalogue;
@ -367,7 +367,7 @@ describe("facet host", () => {
opened += 1;
const namespace = createRemoteServiceBinding({
services: options.services,
connection: createLoopbackServiceConnection(currentProvider),
transport: createLoopbackServiceTransport(currentProvider),
bound: false,
assertAccess: options.assertAccess,
onError: options.onError,
@ -385,7 +385,7 @@ describe("facet host", () => {
},
});
},
} satisfies FacetConnection;
} satisfies RemoteServiceSource;
const values: string[] = [];
const leftConsumer = defineFacet({
id: "left-consumer",
@ -396,7 +396,7 @@ describe("facet host", () => {
});
},
});
const first = await createFacetHost({ facets: [leftConsumer], connections: [connection] });
const first = await createFacetHost({ facets: [leftConsumer], serviceSources: [source] });
await first.dispose();
currentProvider = rightProvider;
@ -409,7 +409,7 @@ describe("facet host", () => {
});
},
});
const second = await createFacetHost({ facets: [rightConsumer], connections: [connection] });
const second = await createFacetHost({ facets: [rightConsumer], serviceSources: [source] });
await second.dispose();
expect(values).toEqual(["left", "right"]);

View file

@ -1,8 +1,8 @@
import type { RemoteServiceConnection } from "../src/index.ts";
import type { RemoteServiceTransport } from "../src/index.ts";
import type { RemoteServiceProvider } from "../src/services/provider.ts";
import { serviceDeliveryContext } from "../src/services/state.ts";
export function createLoopbackServiceConnection(provider: RemoteServiceProvider): RemoteServiceConnection {
export function createLoopbackServiceTransport(provider: RemoteServiceProvider): RemoteServiceTransport {
return {
invoke: (call, context) => provider.invoke(call, context),
subscribe: async (serviceId, mode, listener) => {

View file

@ -5,12 +5,12 @@ import {
createRemoteServiceBinding,
defineService,
type JsonValue,
type RemoteServiceConnection,
RemoteServiceProvider,
type RemoteServiceTransport,
type ReplicatedState,
replicatedState,
} from "../src/index.ts";
import { createLoopbackServiceConnection } from "./helpers.ts";
import { createLoopbackServiceTransport } from "./helpers.ts";
type ModelRef = { provider: string; modelId: string };
type ModelsState = {
@ -109,7 +109,7 @@ describe("remote services", () => {
});
const namespace = createRemoteServiceBinding({
services: [Echo],
connection: createLoopbackServiceConnection(provider),
transport: createLoopbackServiceTransport(provider),
});
const echo = namespace.use(Echo);
await namespace.ready(BACKGROUND_CONTEXT);
@ -138,7 +138,7 @@ describe("remote services", () => {
const errors: Error[] = [];
const namespace = createRemoteServiceBinding({
services: [Models],
connection: createLoopbackServiceConnection(provider),
transport: createLoopbackServiceTransport(provider),
onError: (error) => errors.push(error),
});
@ -165,7 +165,7 @@ describe("remote services", () => {
const lateNamespace = createRemoteServiceBinding({
services: [Models],
connection: createLoopbackServiceConnection(provider),
transport: createLoopbackServiceTransport(provider),
});
const lateModels = lateNamespace.use(Models);
await lateNamespace.ready(BACKGROUND_CONTEXT);
@ -184,7 +184,7 @@ describe("remote services", () => {
});
const namespace = createRemoteServiceBinding({
services: [Models],
connection: createLoopbackServiceConnection(provider),
transport: createLoopbackServiceTransport(provider),
});
const models = namespace.use(Models);
const state = models.state;
@ -223,7 +223,7 @@ describe("remote services", () => {
let active = false;
const namespace = createRemoteServiceBinding({
services: [Models],
connection: createLoopbackServiceConnection(provider),
transport: createLoopbackServiceTransport(provider),
bound: false,
assertAccess() {
if (!active) throw new Error("Service handles are not active");
@ -251,7 +251,7 @@ describe("remote services", () => {
const errors: Error[] = [];
const namespace = createRemoteServiceBinding({
services: [Models],
connection: {
transport: {
invoke: () => Promise.reject(new Error("unexpected invocation")),
subscribe: () => Promise.reject(failure),
},
@ -272,7 +272,7 @@ describe("remote services", () => {
state,
async select() {},
});
const connection: RemoteServiceConnection = {
const transport: RemoteServiceTransport = {
invoke: (call, context) => provider.invoke(call, context),
subscribe: async (serviceId, mode, listener) => {
const subscription = provider.subscribe(serviceId, mode, listener);
@ -284,7 +284,7 @@ describe("remote services", () => {
};
},
};
const namespace = createRemoteServiceBinding({ services: [Models], connection });
const namespace = createRemoteServiceBinding({ services: [Models], transport });
const models = namespace.use(Models);
const revisions: number[] = [];
models.state.subscribe((value) => revisions.push(value.revision));
@ -304,7 +304,7 @@ describe("remote services", () => {
});
const namespace = createRemoteServiceBinding({
services: [Models],
connection: createLoopbackServiceConnection(provider),
transport: createLoopbackServiceTransport(provider),
bound: false,
});
const models = namespace.use(Models);
@ -335,11 +335,11 @@ describe("remote services", () => {
test("hydrates keyed state before observe handlers and fences reused keys", async () => {
const provider = new RemoteServiceProvider([{ service: QuestionDialogs, mode: "keyed" }]);
expect(provider.catalogue).toEqual([{ serviceId: QuestionDialogs.id, mode: "keyed" }]);
const connection = createLoopbackServiceConnection(provider);
const transport = createLoopbackServiceTransport(provider);
const errors: Error[] = [];
const namespace = createRemoteServiceBinding({
services: [QuestionDialogs],
connection,
transport,
onError: (error) => errors.push(error),
});
const observed: {

View file

@ -9,10 +9,10 @@ import { createRadiusClientTransportFactory, RadiusClientReconnect } from "./rad
import { activateServer, ENV_SERVER_ID, resolveServerDirectory, resolveSessionDirectory } from "./server.ts";
import { AgentController } from "./services/agent-controller.ts";
import {
createServerServiceConnection,
createSessionServiceConnection,
type ServerServiceConnection,
type SessionServiceConnection,
createServerServiceSource,
createSessionServiceSource,
type ServerServiceSource,
type SessionServiceSource,
} from "./services/connection.ts";
import { Models } from "./services/models.ts";
import { SessionDirectory, SessionManagement } from "./services/sessions.ts";
@ -24,8 +24,8 @@ export type ClientRuntimeRoute =
export interface ClientRuntimeServer {
readonly route: ClientRuntimeRoute;
readonly client: Client;
readonly server: ServerServiceConnection;
readonly session: SessionServiceConnection;
readonly server: ServerServiceSource;
readonly session: SessionServiceSource;
}
export interface ActivatedClientRuntimeServer extends ClientRuntimeServer {
@ -88,18 +88,18 @@ export async function openClientRuntime(
const clients: Client[] = [];
const reconnectors: RadiusClientReconnect[] = [];
const serviceConnections: Array<ServerServiceConnection | SessionServiceConnection> = [];
const serviceSources: Array<ServerServiceSource | SessionServiceSource> = [];
const servers: ClientRuntimeServer[] = [];
let disposed = false;
const dispose = async (): Promise<void> => {
if (disposed) return;
disposed = true;
const reconnectResults = await Promise.allSettled(reconnectors.map((reconnector) => reconnector.dispose()));
const connectionResults = await Promise.allSettled(
serviceConnections.map((connection) => connection.dispose(BACKGROUND_CONTEXT)),
const sourceResults = await Promise.allSettled(
serviceSources.map((source) => source.dispose(BACKGROUND_CONTEXT)),
);
const clientResults = await Promise.allSettled(clients.map((client) => client.dispose()));
const errors = [...reconnectResults, ...connectionResults, ...clientResults].flatMap((result) =>
const errors = [...reconnectResults, ...sourceResults, ...clientResults].flatMap((result) =>
result.status === "rejected" ? [result.reason] : [],
);
if (errors.length === 1) throw errors[0];
@ -123,9 +123,9 @@ export async function openClientRuntime(
activatedClient = undefined;
clients.push(client);
if (route.transport === "radius") reconnectors.push(new RadiusClientReconnect(client));
const server = createServerServiceConnection(client);
const session = createSessionServiceConnection(client);
serviceConnections.push(server, session);
const server = createServerServiceSource(client);
const session = createSessionServiceSource(client);
serviceSources.push(server, session);
servers.push({ route, client, server, session });
}
return { servers, dispose };

View file

@ -36,9 +36,9 @@ import helloPluginFacet from "./plugins/hello.ts";
import { AgentController, type AgentOperationResponse, type AgentQueueResponse } from "./services/agent-controller.ts";
import type {
ServerConnectionState,
ServerServiceConnection,
ServerServiceSource,
SessionAttachmentState,
SessionServiceConnection,
SessionServiceSource,
} from "./services/connection.ts";
import { SessionDirectory, SessionManagement } from "./services/sessions.ts";
import { type SlashCommandExecutionContext, SlashCommands } from "./services/slash-commands.ts";
@ -55,15 +55,15 @@ export interface ClientTuiServer {
readonly serverId: string;
readonly radius: boolean;
readonly laneWatches: LaneWatchSource;
readonly server: ServerServiceConnection;
readonly session: SessionServiceConnection;
readonly server: ServerServiceSource;
readonly session: SessionServiceSource;
}
interface SessionFeature {
readonly serverId: string;
readonly directory: SessionDirectory;
readonly management: SessionManagement;
readonly session: SessionServiceConnection;
readonly session: SessionServiceSource;
readonly laneWatches: LaneWatchSource;
}
@ -284,7 +284,7 @@ export class ExperimentalClientTui implements Component {
createBuiltInSlashCommandsFacet(),
...this.#loadedFacets.facets,
],
connections: [server.server, server.session],
serviceSources: [server.server, server.session],
});
this.#facetHosts.push(facetHost);
}

View file

@ -1,6 +1,6 @@
# Experimental client/server service slices
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.
Facet setup generates each host's RPC service catalogue from its provided non-local tokens. Remote service sources obtain those catalogues and bind only services required by consuming facets; there is no handwritten built-in service inventory. With no selected Session, its deferred source 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 |
|---|---|---|---|
@ -12,7 +12,7 @@ Facet setup generates each host's RPC service catalogue from its provided non-lo
| session | `Transcript` | authoritative snapshot declared but throws | implement snapshot projection and remove the compatibility lane watch |
| presentation | `SlashCommands` | process-local contribution registry with model, thinking, compact, and example hello commands | add more presentation hookpoints only as concrete plugin slices require them |
`ServerServiceConnection.connection` and `SessionServiceConnection.attachment` are implemented local control states.
`ServerServiceSource.connection` and `SessionServiceSource.attachment` are implemented local control states.
With `PI_EXPERIMENTAL=1`, an interactive `pi client` creates and attaches a Session before opening the service-only chat TUI. `pi client -c` and `pi client -r` attach the newest existing Session instead, preserving its durable model and thinking configuration. Model selection is available on demand through `/model`; it is never a startup screen. The presentation always uses the stable coding agent's alternate-screen renderer and shared transcript/dock viewport. It loads configured theme resources and uses the stable terminal light/dark detection and appearance-change notifications. Its replicated state feeds the stable editor, message, tool, status, theme, and tool-renderer components. The presentation drives the worker-owned main lane through `AgentController` and renders a coherent lane-watch replica with the Harness reducer while controller calls are pending. `Transcript` remains reserved for a later service-owned live-delta replacement of that compatibility watch.

View file

@ -1,12 +1,12 @@
import {
type Context,
createRemoteServiceBinding,
type FacetConnection,
type JsonValue,
type MutableReplicatedState,
type RemoteServiceBinding,
type RemoteServiceConnection,
type RemoteServiceSource,
type RemoteServices,
type RemoteServiceTransport,
type ReplicatedState,
replicatedState,
type Service,
@ -30,12 +30,12 @@ export type SessionAttachmentState =
| { status: "detached" }
| { status: "attaching" | "attached" | "degraded"; sessionId: string };
export interface ServerServiceConnection extends FacetConnection {
export interface ServerServiceSource extends RemoteServiceSource {
readonly connection: ReplicatedState<ServerConnectionState>;
dispose(context: Context): Promise<void>;
}
export interface SessionServiceConnection extends FacetConnection {
export interface SessionServiceSource extends RemoteServiceSource {
readonly attachment: ReplicatedState<SessionAttachmentState>;
/** Wait for the exact current attachment generation to finish hydrating. */
whenAttached(sessionId: string, context: Context): Promise<void>;
@ -44,11 +44,11 @@ export interface SessionServiceConnection extends FacetConnection {
dispose(context: Context): Promise<void>;
}
export interface ServiceConnectionOptions {
export interface ServiceSourceOptions {
readonly onError?: (error: Error) => void;
}
export interface ServiceBindingOptions extends ServiceConnectionOptions {
export interface ServiceBindingOptions extends ServiceSourceOptions {
readonly services: readonly { readonly id: string }[];
}
@ -62,7 +62,7 @@ class RoutedServiceBinding implements RemoteServices {
constructor(options: {
readonly services: readonly { readonly id: string }[];
readonly connection: RemoteServiceConnection;
readonly transport: RemoteServiceTransport;
readonly getBound: () => boolean;
readonly assertAccess: () => void;
readonly onError: (error: Error) => void;
@ -71,7 +71,7 @@ class RoutedServiceBinding implements RemoteServices {
}) {
this.#services = createRemoteServiceBinding({
services: options.services,
connection: options.connection,
transport: options.transport,
bound: false,
assertAccess: options.assertAccess,
onError: options.onError,
@ -115,11 +115,11 @@ class RoutedServiceBinding implements RemoteServices {
}
}
class ServerServiceConnectionImpl implements ServerServiceConnection {
class ServerServiceSourceImpl implements ServerServiceSource {
readonly acceptsUnavailableServices = false;
readonly connection: ReplicatedState<ServerConnectionState>;
readonly #client: Client;
readonly #remoteConnection: RemoteServiceConnection;
readonly #transport: RemoteServiceTransport;
readonly #bindings = new Set<RoutedServiceBinding>();
readonly #removeConnectionListener: () => void;
readonly #onError: (error: Error) => void;
@ -127,9 +127,9 @@ class ServerServiceConnectionImpl implements ServerServiceConnection {
#connectionAttempt: number;
#disposed = false;
constructor(client: Client, options: ServiceConnectionOptions) {
constructor(client: Client, options: ServiceSourceOptions) {
this.#client = client;
this.#remoteConnection = createRemoteServiceConnection(client, () => ({ serverId: client.serverId }));
this.#transport = createRemoteServiceTransport(client, () => ({ serverId: client.serverId }));
this.#onError = options.onError ?? (() => {});
this.#connectionAttempt = client.connectionState === "connecting" ? 1 : 0;
const connectionState = replicatedState<ServerConnectionState>(
@ -162,11 +162,11 @@ class ServerServiceConnectionImpl implements ServerServiceConnection {
assertAccess(): void;
onError(error: Error): void;
}): RemoteServices {
if (this.#disposed) throw new Error("Server service connection is disposed");
if (this.#disposed) throw new Error("Server service source is disposed");
let binding!: RoutedServiceBinding;
binding = new RoutedServiceBinding({
services: options.services,
connection: this.#remoteConnection,
transport: this.#transport,
getBound: () => this.#client.connected,
assertAccess: options.assertAccess,
onError: options.onError,
@ -184,14 +184,14 @@ class ServerServiceConnectionImpl implements ServerServiceConnection {
const bindings = [...this.#bindings];
this.#bindings.clear();
const results = await Promise.allSettled(bindings.map((binding) => binding.dispose(context)));
throwFailures(results, "Failed to dispose server service connection");
throwFailures(results, "Failed to dispose server service source");
}
}
class SessionServiceConnectionImpl implements SessionServiceConnection {
class SessionServiceSourceImpl implements SessionServiceSource {
readonly attachment: ReplicatedState<SessionAttachmentState>;
readonly #client: Client;
readonly #remoteConnection: RemoteServiceConnection;
readonly #transport: RemoteServiceTransport;
readonly #attachmentState: MutableReplicatedState<SessionAttachmentState>;
readonly #bindings = new Set<RoutedServiceBinding>();
readonly #removeAttachmentListener: () => void;
@ -205,9 +205,9 @@ class SessionServiceConnectionImpl implements SessionServiceConnection {
return this.#client.attachment === undefined && this.#catalogue === undefined;
}
constructor(client: Client, options: ServiceConnectionOptions) {
constructor(client: Client, options: ServiceSourceOptions) {
this.#client = client;
this.#remoteConnection = createRemoteServiceConnection(client, () => client.attachment);
this.#transport = createRemoteServiceTransport(client, () => client.attachment);
this.#onError = options.onError ?? (() => {});
this.#attachmentState = replicatedState<SessionAttachmentState>(
client.attachment === undefined
@ -271,11 +271,11 @@ class SessionServiceConnectionImpl implements SessionServiceConnection {
assertAccess(): void;
onError(error: Error): void;
}): RemoteServices {
if (this.#disposed) throw new Error("Session service connection is disposed");
if (this.#disposed) throw new Error("Session service source is disposed");
let binding!: RoutedServiceBinding;
binding = new RoutedServiceBinding({
services: options.services,
connection: this.#remoteConnection,
transport: this.#transport,
getBound: () => this.#client.attachment !== undefined,
onActivate: async (context) => {
const attachment = this.#client.attachment;
@ -311,7 +311,7 @@ class SessionServiceConnectionImpl implements SessionServiceConnection {
const bindings = [...this.#bindings];
this.#bindings.clear();
const results = await Promise.allSettled(bindings.map((binding) => binding.dispose(context)));
throwFailures(results, "Failed to dispose Session service connection");
throwFailures(results, "Failed to dispose Session service source");
}
async #rebind(bound: boolean, context: Context): Promise<void> {
@ -354,9 +354,9 @@ class SessionServiceConnectionImpl implements SessionServiceConnection {
export function createServerServiceBinding(
client: Client,
options: ServiceBindingOptions,
): ServerServiceConnection & RemoteServices {
const connection = createServerServiceConnection(client, options);
const services = connection.open({
): ServerServiceSource & RemoteServices {
const source = createServerServiceSource(client, options);
const services = source.open({
services: options.services,
assertAccess() {},
onError: options.onError ?? (() => {}),
@ -368,15 +368,15 @@ export function createServerServiceBinding(
await services.ready(context);
};
return {
acceptsUnavailableServices: connection.acceptsUnavailableServices,
connection: connection.connection,
catalogue: (context) => connection.catalogue(context),
open: (openOptions) => connection.open(openOptions),
acceptsUnavailableServices: source.acceptsUnavailableServices,
connection: source.connection,
catalogue: (context) => source.catalogue(context),
open: (openOptions) => source.open(openOptions),
use: (service) => services.use(service),
observe: (service, handler) => services.observe(service, handler),
ready,
async dispose(context) {
const results = await Promise.allSettled([services.dispose(context), connection.dispose(context)]);
const results = await Promise.allSettled([services.dispose(context), source.dispose(context)]);
throwFailures(results, "Failed to dispose server service binding");
},
};
@ -386,9 +386,9 @@ export function createServerServiceBinding(
export function createSessionServiceBinding(
client: Client,
options: ServiceBindingOptions,
): SessionServiceConnection & RemoteServices {
const connection = createSessionServiceConnection(client, options);
const services = connection.open({
): SessionServiceSource & RemoteServices {
const source = createSessionServiceSource(client, options);
const services = source.open({
services: options.services,
assertAccess() {},
onError: options.onError ?? (() => {}),
@ -397,49 +397,40 @@ export function createSessionServiceBinding(
void activation.catch(options.onError ?? (() => {}));
const ready = async (context: Context): Promise<void> => {
await activation;
const attachment = connection.attachment.value;
if (attachment?.status === "detached") await connection.whenDetached(context);
else if (attachment !== undefined) await connection.whenAttached(attachment.sessionId, context);
const attachment = source.attachment.value;
if (attachment?.status === "detached") await source.whenDetached(context);
else if (attachment !== undefined) await source.whenAttached(attachment.sessionId, context);
};
return {
get acceptsUnavailableServices() {
return connection.acceptsUnavailableServices;
return source.acceptsUnavailableServices;
},
attachment: connection.attachment,
catalogue: (context) => connection.catalogue(context),
open: (openOptions) => connection.open(openOptions),
attachment: source.attachment,
catalogue: (context) => source.catalogue(context),
open: (openOptions) => source.open(openOptions),
use: (service) => services.use(service),
observe: (service, handler) => services.observe(service, handler),
ready,
whenAttached: (sessionId, context) => connection.whenAttached(sessionId, context),
whenDetached: (context) => connection.whenDetached(context),
whenAttached: (sessionId, context) => source.whenAttached(sessionId, context),
whenDetached: (context) => source.whenDetached(context),
async dispose(context) {
const results = await Promise.allSettled([services.dispose(context), connection.dispose(context)]);
const results = await Promise.allSettled([services.dispose(context), source.dispose(context)]);
throwFailures(results, "Failed to dispose Session service binding");
},
};
}
/** Create the server-scoped service connection for one presentation client. */
export function createServerServiceConnection(
client: Client,
options: ServiceConnectionOptions = {},
): ServerServiceConnection {
return new ServerServiceConnectionImpl(client, options);
/** Create the server-scoped remote service source for one presentation client. */
export function createServerServiceSource(client: Client, options: ServiceSourceOptions = {}): ServerServiceSource {
return new ServerServiceSourceImpl(client, options);
}
/** Create the selected-Session service connection for one presentation client. */
export function createSessionServiceConnection(
client: Client,
options: ServiceConnectionOptions = {},
): SessionServiceConnection {
return new SessionServiceConnectionImpl(client, options);
/** Create the selected-Session remote service source for one presentation client. */
export function createSessionServiceSource(client: Client, options: ServiceSourceOptions = {}): SessionServiceSource {
return new SessionServiceSourceImpl(client, options);
}
function createRemoteServiceConnection(
client: Client,
getTarget: () => RpcTarget | undefined,
): RemoteServiceConnection {
function createRemoteServiceTransport(client: Client, getTarget: () => RpcTarget | undefined): RemoteServiceTransport {
return {
invoke: async (call, context) => {
const target = getTarget();

View file

@ -2,8 +2,8 @@ import {
createRemoteServiceBinding,
defineFacet,
type FacetLoader,
type RemoteServiceConnection,
RemoteServiceProvider,
type RemoteServiceTransport,
replicatedState,
} from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
@ -16,9 +16,9 @@ import { AgentController } from "../src/experimental/services/agent-controller.t
import { createAgentController } from "../src/experimental/services/agent-controller-provider.ts";
import type {
ServerConnectionState,
ServerServiceConnection,
ServerServiceSource,
SessionAttachmentState,
SessionServiceConnection,
SessionServiceSource,
} from "../src/experimental/services/connection.ts";
import { Models, type ModelsState } from "../src/experimental/services/models.ts";
import {
@ -34,7 +34,7 @@ function session(sessionId: string, createdAt: number): SessionSummary {
return { serverId, sessionId, createdAt };
}
function createLoopbackServiceConnection(provider: RemoteServiceProvider): RemoteServiceConnection {
function createLoopbackServiceTransport(provider: RemoteServiceProvider): RemoteServiceTransport {
return {
invoke: (call, context) => provider.invoke(call, context),
subscribe: async (serviceId, mode, listener) => {
@ -207,11 +207,11 @@ describe("experimental client TUI", () => {
const serverNamespace = createRemoteServiceBinding({
services: [SessionDirectory, SessionManagement],
connection: createLoopbackServiceConnection(serverProvider),
transport: createLoopbackServiceTransport(serverProvider),
bound: false,
});
const serverNamespaceReady = serverNamespace.ready.bind(serverNamespace);
const serverServices: ServerServiceConnection = Object.assign(serverNamespace, {
const serverServices: ServerServiceSource = Object.assign(serverNamespace, {
acceptsUnavailableServices: false,
connection: connectionState,
async catalogue() {
@ -227,10 +227,10 @@ describe("experimental client TUI", () => {
});
const sessionNamespace = createRemoteServiceBinding({
services: [Models, AgentController],
connection: createLoopbackServiceConnection(sessionProvider),
transport: createLoopbackServiceTransport(sessionProvider),
bound: false,
});
const sessionServices: SessionServiceConnection = Object.assign(sessionNamespace, {
const sessionServices: SessionServiceSource = Object.assign(sessionNamespace, {
acceptsUnavailableServices: true,
attachment,
async catalogue() {

View file

@ -12,7 +12,7 @@ import { activateServer, type RunningServer, startServer } from "../src/experime
import {
createServerServiceBinding,
createSessionServiceBinding,
createSessionServiceConnection,
createSessionServiceSource,
type SessionAttachmentState,
} from "../src/experimental/services/connection.ts";
import { Models } from "../src/experimental/services/models.ts";
@ -543,7 +543,7 @@ describe("experimental durable server composition", () => {
const { runtime } = await makeServer();
const client = await attachClient(runtime, "demo-1");
const errors: Error[] = [];
const services = createSessionServiceConnection(client, {
const services = createSessionServiceSource(client, {
onError: (error) => errors.push(error),
});
await expect(services.catalogue(BACKGROUND_CONTEXT)).resolves.toContainEqual({
@ -559,7 +559,7 @@ describe("experimental durable server composition", () => {
});
},
});
const facetHost = await createFacetHost({ facets: [consumer], connections: [services] });
const facetHost = await createFacetHost({ facets: [consumer], serviceSources: [services] });
expect(services.attachment.value).toEqual({ status: "attached", sessionId: "demo-1" });
await vi.waitFor(() => expect(observed).toHaveLength(1));