feat: move facet services into Chord

Move service, replicated-state, facet host, and loader ownership into the standalone Chord package. Migrate consumers and leave runtime JSON enforcement to serializers.
This commit is contained in:
Armin Ronacher 2026-08-29 14:51:01 +02:00
parent 28b49a6b3b
commit 34dc9d055a
78 changed files with 1423 additions and 1558 deletions

4
package-lock.json generated
View file

@ -5368,6 +5368,7 @@
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "0.91.1", "@anthropic-ai/sdk": "0.91.1",
"@aws-sdk/client-bedrock-runtime": "3.1048.0", "@aws-sdk/client-bedrock-runtime": "3.1048.0",
"@earendil-works/chord": "^0.84.4",
"@earendil-works/pi-telemetry": "^0.84.4", "@earendil-works/pi-telemetry": "^0.84.4",
"@google/genai": "1.52.0", "@google/genai": "1.52.0",
"@smithy/node-http-handler": "4.7.3", "@smithy/node-http-handler": "4.7.3",
@ -5421,6 +5422,7 @@
"version": "0.84.4", "version": "0.84.4",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/chord": "^0.84.4",
"@earendil-works/pi-agent-core": "^0.84.4", "@earendil-works/pi-agent-core": "^0.84.4",
"@earendil-works/pi-ai": "^0.84.4", "@earendil-works/pi-ai": "^0.84.4",
"@earendil-works/pi-client": "^0.84.4", "@earendil-works/pi-client": "^0.84.4",
@ -5559,6 +5561,7 @@
"version": "0.84.4", "version": "0.84.4",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/chord": "^0.84.4",
"typebox": "1.3.7" "typebox": "1.3.7"
}, },
"devDependencies": { "devDependencies": {
@ -5574,6 +5577,7 @@
"version": "0.84.4", "version": "0.84.4",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/chord": "^0.84.4",
"@earendil-works/pi-agent-core": "^0.84.4", "@earendil-works/pi-agent-core": "^0.84.4",
"@earendil-works/pi-protocol": "^0.84.4" "@earendil-works/pi-protocol": "^0.84.4"
}, },

View file

@ -44,7 +44,7 @@ await agent.prompt("Hello!");
## Experimental facet services ## Experimental facet services
The package exports the transport-neutral facet-service primitives used by the experimental application hosts: `defineService()`, `replicatedState()`, `RemoteServiceProvider`, and `RemoteServiceNamespace`. Providers classify implementation methods and replicated-state members at runtime; consumers receive stable singleton facades through `use()` or lifecycle-scoped keyed instances through `observe()`. `ReplicatedState` values are borrowed immutable JSON: the runtime does not defensively clone them, so callers must not mutate or retain them. Wire framing and host routing remain outside this package. Transport-neutral facet-service primitives live in `@earendil-works/chord`. The agent core does not export the service runtime.
## Core Concepts ## Core Concepts

View file

@ -1,5 +1,9 @@
# Coding-Agent Application Hosts and Facets # Coding-Agent Application Hosts and Facets
The application-neutral facet, service, and replicated-state runtime is provided by
`@earendil-works/chord`. This document specifies how Pi composes that runtime with Pi-owned
service contracts, process roles, routing, and lifecycle policy.
> **Status:** Design specification for the experimental facet and service architecture. > **Status:** Design specification for the experimental facet and service architecture.
This document assumes you already understand `AgentHarness`, `AgentLane`, `Session`, `Branch`, `SessionRepo`, and invocation `Context`. Read `rpc.md` for service transport semantics and `telemetry.md` for the telemetry model. This document assumes you already understand `AgentHarness`, `AgentLane`, `Session`, `Branch`, `SessionRepo`, and invocation `Context`. Read `rpc.md` for service transport semantics and `telemetry.md` for the telemetry model.

View file

@ -1,5 +1,10 @@
# Facet Service RPC # Facet Service RPC
Chord owns the application-neutral service semantics and pluggable strict-JSON connection
boundary. Pi owns the concrete wire envelope, routing, attachment state, and error adapters
described here. The current implementation treats `JsonValue` as a static contract and defers
runtime rejection of unsupported values to the concrete serializer.
> **Status:** Design specification for experimental facet-service RPC semantics. > **Status:** Design specification for experimental facet-service RPC semantics.
## Role ## Role

View file

@ -1,4 +1,4 @@
import { type Context, createContextKey, withContextValue } from "@earendil-works/chord"; import { type Context, createContextKey, withContextValue } from "@earendil-works/chord/context";
import { NOOP_TELEMETRY_CONTEXT, type TelemetryContext } from "@earendil-works/pi-telemetry"; import { NOOP_TELEMETRY_CONTEXT, type TelemetryContext } from "@earendil-works/pi-telemetry";
export { export {
@ -12,7 +12,7 @@ export {
withCancel, withCancel,
withContextValue, withContextValue,
withoutAbortSignal, withoutAbortSignal,
} from "@earendil-works/chord"; } from "@earendil-works/chord/context";
const TELEMETRY_CONTEXT_KEY = createContextKey<TelemetryContext>("pi.telemetryContext"); const TELEMETRY_CONTEXT_KEY = createContextKey<TelemetryContext>("pi.telemetryContext");

View file

@ -1,3 +1,4 @@
import type { JsonValue } from "@earendil-works/chord/json";
import type { AssistantMessage, StopReason, Usage } from "@earendil-works/pi-ai"; import type { AssistantMessage, StopReason, Usage } from "@earendil-works/pi-ai";
import type { AgentMessage, QueueMode, ThinkingLevel } from "../../types.ts"; import type { AgentMessage, QueueMode, ThinkingLevel } from "../../types.ts";
import type { BranchPreparation } from "../compaction/branch-summarization.ts"; import type { BranchPreparation } from "../compaction/branch-summarization.ts";
@ -6,7 +7,7 @@ import type { Context } from "../context.ts";
import type { AgentHarnessStreamOptions } from "../types.ts"; import type { AgentHarnessStreamOptions } from "../types.ts";
import type { ListElement, ListReadOptions, ListWrite, StoredValue, Value, ValueList, ValueWrite } from "./values.ts"; import type { ListElement, ListReadOptions, ListWrite, StoredValue, Value, ValueList, ValueWrite } from "./values.ts";
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; export type { JsonValue } from "@earendil-works/chord/json";
export type SettledAssistantMessage = AssistantMessage & { export type SettledAssistantMessage = AssistantMessage & {
stopReason: Exclude<StopReason, "pending">; stopReason: Exclude<StopReason, "pending">;

View file

@ -137,7 +137,6 @@ export {
} from "./harness/types.ts"; } from "./harness/types.ts";
export * from "./harness/utils/shell-output.ts"; export * from "./harness/utils/shell-output.ts";
export * from "./harness/utils/truncate.ts"; export * from "./harness/utils/truncate.ts";
export * from "./plugins/services/index.ts";
export * from "./proxy.ts"; export * from "./proxy.ts";
export * from "./search/index.ts"; export * from "./search/index.ts";
export { setDefaultStreamFn } from "./stream-fn.ts"; export { setDefaultStreamFn } from "./stream-fn.ts";

View file

@ -1,44 +0,0 @@
export { RemoteServiceNamespace } from "./namespace.ts";
export {
createLoopbackServiceConnection,
RemoteServiceError,
type RemoteServiceErrorCode,
RemoteServiceProvider,
} from "./provider.ts";
export {
freshDeliveryContext,
getReplicatedStateInternals,
REPLICATED_STATE_INTERNALS,
type ReplicatedStateInternals,
type ReplicatedStateSource,
replicatedState,
} from "./replicated-state.ts";
export {
cloneJson,
defineService,
isJsonValue,
type MutableReplicatedState,
type RemoteJson,
type RemoteServiceConnection,
type RemoteServiceContract,
type RemoteServiceInstance,
type RemoteServiceNamespaceOptions,
type RemoteServiceSubscription,
type RemoteServices,
type ReplicatedState,
type Service,
type ServiceCall,
type ServiceCatalogueEntry,
type ServiceInstanceAddress,
type ServiceInstanceSnapshot,
type ServiceMemberDescription,
type ServiceMemberKind,
type ServiceMode,
type ServiceProviderDefinition,
type ServiceProviderSubscription,
type ServiceProviderUpdate,
ServiceSliceNotImplemented,
type ServiceStateSnapshot,
type ServiceSubscriptionSnapshot,
type ServiceType,
} from "./types.ts";

View file

@ -1,82 +0,0 @@
import { BACKGROUND_CONTEXT, type Context, withCancel } from "../../harness/context.ts";
import { isJsonValue, type MutableReplicatedState, type ReplicatedState } from "./types.ts";
export const REPLICATED_STATE_INTERNALS = Symbol("pi.replicatedState.internals");
export interface ReplicatedStateInternals<T = unknown> {
readonly sequence: number;
readonly value: T;
subscribe(listener: (value: T, sequence: number, context: Context) => void): () => void;
}
export interface ReplicatedStateSource<T = unknown> extends MutableReplicatedState<T> {
readonly [REPLICATED_STATE_INTERNALS]: ReplicatedStateInternals<T>;
}
class MutableReplicatedStateImpl<T> implements ReplicatedStateSource<T> {
readonly #listeners = new Set<(value: T, context: Context) => void>();
readonly #sourceListeners = new Set<(value: T, sequence: number, context: Context) => void>();
#value: T;
#sequence = 0;
constructor(initial: T) {
assertStateValue(initial);
this.#value = initial;
}
get value(): T {
return this.#value;
}
set(value: T, context: Context): void {
assertStateValue(value);
this.#value = value;
this.#sequence += 1;
for (const listener of this.#sourceListeners) listener(value, this.#sequence, context);
for (const listener of this.#listeners) listener(value, context);
}
subscribe(listener: (value: T, context: Context) => void): () => void {
this.#listeners.add(listener);
listener(this.value, freshDeliveryContext());
return () => this.#listeners.delete(listener);
}
get [REPLICATED_STATE_INTERNALS](): ReplicatedStateInternals<T> {
const source = this;
return {
get sequence() {
return source.#sequence;
},
get value() {
return source.value;
},
subscribe: (listener) => {
this.#sourceListeners.add(listener);
return () => this.#sourceListeners.delete(listener);
},
};
}
}
export function replicatedState<T>(initial: T): MutableReplicatedState<T> {
return new MutableReplicatedStateImpl(initial);
}
export function getReplicatedStateInternals(value: unknown): ReplicatedStateInternals | undefined {
if (typeof value !== "object" || value === null || !(REPLICATED_STATE_INTERNALS in value)) return undefined;
const candidate = value as { readonly [REPLICATED_STATE_INTERNALS]?: unknown };
const internals = candidate[REPLICATED_STATE_INTERNALS];
if (typeof internals !== "object" || internals === null) return undefined;
return internals as ReplicatedStateInternals;
}
export function freshDeliveryContext(): Context {
return withCancel(BACKGROUND_CONTEXT).context;
}
function assertStateValue(value: unknown): void {
if (!isJsonValue(value)) throw new TypeError("Remote service value must be strict JSON");
}
export type { ReplicatedState };

View file

@ -1,234 +0,0 @@
import type { Context } from "../../harness/context.ts";
import type { JsonValue } from "../../harness/session/types.ts";
const SERVICE_TYPE = Symbol("pi.service.type");
export type ServiceMode = "singleton" | "keyed";
export type ServiceMemberKind = "method" | "state";
export interface ServiceCatalogueEntry {
readonly serviceId: string;
readonly mode: ServiceMode;
}
export interface ServiceProviderDefinition {
readonly service: { readonly id: string };
readonly mode: ServiceMode;
}
export class ServiceSliceNotImplemented extends Error {
readonly code = "service_not_implemented" as const;
constructor(operation: string) {
super(`${operation} is not implemented until its later facet-service slice`);
this.name = "ServiceSliceNotImplemented";
}
}
export interface ReplicatedState<T> {
/** Borrowed immutable value, or undefined until hydration. Do not mutate or retain it. */
readonly value: T | undefined;
/** Listener values are borrowed and must not be mutated or retained. */
subscribe(listener: (value: T, context: Context) => void): () => void;
}
export interface MutableReplicatedState<T> extends ReplicatedState<T> {
readonly value: T;
/** Transfers the JSON value to the state; the caller must not subsequently mutate it. */
set(value: T, context: Context): void;
}
/** Marks an existing schema-validated wire DTO as JSON-safe for static service-contract checking. */
declare const REMOTE_JSON_TYPE: unique symbol;
export type RemoteJson<T> = T & { readonly [REMOTE_JSON_TYPE]?: true };
type JsonPrimitive = null | boolean | number | string;
type InvalidJsonPart<T> = typeof REMOTE_JSON_TYPE extends keyof T
? never
: T extends JsonPrimitive
? never
: T extends readonly (infer TItem)[]
? InvalidJsonPart<TItem>
: T extends (...args: never[]) => unknown
? T
: T extends object
? { [TKey in keyof T]-?: InvalidJsonPart<T[TKey]> }[keyof T]
: T;
type InvalidRemoteMember<T> = T extends ReplicatedState<infer TValue>
? InvalidJsonPart<TValue> extends never
? never
: "state value is not JSON"
: T extends (...args: [...infer TArgs, Context]) => Promise<infer TResult>
? InvalidJsonPart<TArgs[number]> extends never
? TResult extends void
? never
: InvalidJsonPart<TResult> extends never
? never
: "method result is not JSON or void"
: "method argument is not JSON"
: "member is not a remote method or ReplicatedState";
type InvalidRemoteMemberNames<T> = {
[TKey in keyof T]-?: InvalidRemoteMember<T[TKey]> extends never ? never : TKey;
}[keyof T];
export type RemoteServiceContract<T> = InvalidRemoteMemberNames<T> extends never ? T : never;
/** Stable identity for one shared TypeScript service contract. */
export interface Service<T> {
readonly id: string;
/** Process-local services accept unrestricted object contracts and are never published remotely. */
readonly local: boolean;
readonly [SERVICE_TYPE]?: (value: T) => T;
}
export type ServiceType<TService> = TService extends Service<infer T> ? T : never;
export function defineService<T>(id: string, options: { readonly local: true }): Service<T>;
export function defineService<T>(
id: string,
...options: [RemoteServiceContract<T>] extends [never]
? readonly [options: never]
: readonly [options?: { readonly local?: false }]
): Service<T>;
export function defineService(id: string, options?: { readonly local?: boolean }): Service<unknown> {
if (id.length === 0) throw new TypeError("Service ID must not be empty");
if (id.startsWith("$pi.")) throw new TypeError("Service IDs beginning with $pi. are reserved");
return Object.freeze({ id, local: options?.local ?? false });
}
export interface ServiceInstanceAddress {
readonly key: string;
readonly generation: number;
}
export interface ServiceMemberDescription {
readonly name: string;
readonly kind: ServiceMemberKind;
}
export interface ServiceStateSnapshot {
readonly sequence: number;
readonly value: JsonValue;
}
export interface ServiceInstanceSnapshot {
readonly instance?: ServiceInstanceAddress;
readonly members: readonly ServiceMemberDescription[];
readonly states: Readonly<Record<string, ServiceStateSnapshot>>;
}
export interface ServiceSubscriptionSnapshot {
readonly serviceId: string;
readonly mode: ServiceMode;
readonly instances: readonly ServiceInstanceSnapshot[];
}
export type ServiceProviderUpdate =
| {
readonly type: "state";
readonly instance?: ServiceInstanceAddress;
readonly member: string;
readonly sequence: number;
readonly value: JsonValue;
}
| { readonly type: "unavailable" }
| { readonly type: "replaced"; readonly snapshot: ServiceInstanceSnapshot }
| { readonly type: "spawned"; readonly instance: ServiceInstanceSnapshot }
| { readonly type: "closed"; readonly instance: ServiceInstanceAddress };
export interface ServiceCall {
readonly serviceId: string;
readonly instance?: ServiceInstanceAddress;
readonly member: string;
readonly args: readonly JsonValue[];
}
export interface ServiceProviderSubscription {
readonly snapshot: ServiceSubscriptionSnapshot;
activate(): void;
close(): void;
}
export interface RemoteServiceSubscription {
readonly snapshot: ServiceSubscriptionSnapshot;
activate(): void;
close(context: Context): void | Promise<void>;
}
/** Transport-neutral connection consumed by a remote service namespace. */
export interface RemoteServiceConnection {
invoke(call: ServiceCall, context: Context): Promise<JsonValue | undefined>;
subscribe(
serviceId: string,
mode: ServiceMode,
listener: (update: ServiceProviderUpdate, context: Context) => void,
context: Context,
): Promise<RemoteServiceSubscription>;
}
export interface RemoteServiceInstance<T> {
readonly key: string;
readonly service: T;
}
export interface RemoteServiceNamespaceOptions {
readonly services: readonly { readonly id: string }[];
readonly connection: RemoteServiceConnection;
readonly bound?: boolean;
readonly onError?: (error: Error) => void;
readonly assertAccess?: () => void;
}
export interface RemoteServices {
use<T>(service: Service<T>): T;
observe<T>(
service: Service<T>,
handler: (instance: RemoteServiceInstance<T>, context: Context) => void | Promise<void>,
): () => void;
/** Activate this service set and wait for every acquired service to become ready. */
activate(context: Context): Promise<void>;
/** Wait until every currently acquired service has installed its initial snapshot. */
ready(context: Context): Promise<void>;
/** Install a host lifecycle guard for proxy member access. */
setAccessGuard(assertAccess: () => void): void;
dispose(context: Context): Promise<void>;
}
export function isJsonValue(value: unknown): value is JsonValue {
return checkJsonValue(value, new Set<object>());
}
function checkJsonValue(value: unknown, ancestors: Set<object>): value is JsonValue {
if (value === null || typeof value === "boolean" || typeof value === "string") return true;
if (typeof value === "number") return Number.isFinite(value);
if (typeof value !== "object" || ancestors.has(value)) return false;
if (!Array.isArray(value)) {
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) return false;
if (
Object.getOwnPropertySymbols(value).some((symbol) => Object.prototype.propertyIsEnumerable.call(value, symbol))
) {
return false;
}
}
ancestors.add(value);
const valid = Array.isArray(value)
? checkJsonArray(value, ancestors)
: Object.values(value).every((item) => checkJsonValue(item, ancestors));
ancestors.delete(value);
return valid;
}
function checkJsonArray(value: unknown[], ancestors: Set<object>): value is JsonValue[] {
for (let index = 0; index < value.length; index++) {
if (!Object.hasOwn(value, index) || !checkJsonValue(value[index], ancestors)) return false;
}
return true;
}
export function cloneJson<T>(value: T): T {
if (!isJsonValue(value)) throw new TypeError("Remote service value must be strict JSON");
return structuredClone(value);
}

View file

@ -1,7 +1,6 @@
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
const chordSrcIndex = fileURLToPath(new URL("../chord/src/index.ts", import.meta.url));
const telemetrySrcIndex = fileURLToPath(new URL("../telemetry/src/index.ts", import.meta.url)); const telemetrySrcIndex = fileURLToPath(new URL("../telemetry/src/index.ts", import.meta.url));
const aiSrcIndex = fileURLToPath(new URL("../ai/src/index.ts", import.meta.url)); const aiSrcIndex = fileURLToPath(new URL("../ai/src/index.ts", import.meta.url));
const aiSrcCompat = fileURLToPath(new URL("../ai/src/compat.ts", import.meta.url)); const aiSrcCompat = fileURLToPath(new URL("../ai/src/compat.ts", import.meta.url));
@ -16,12 +15,13 @@ export default defineConfig({
silent: "passed-only", silent: "passed-only",
}, },
resolve: { resolve: {
conditions: ["source"],
alias: [ alias: [
{ find: /^@earendil-works\/chord$/, replacement: chordSrcIndex },
{ find: /^@earendil-works\/pi-telemetry$/, replacement: telemetrySrcIndex }, { find: /^@earendil-works\/pi-telemetry$/, replacement: telemetrySrcIndex },
{ find: /^@earendil-works\/pi-agent-core$/, replacement: agentSrcIndex }, { find: /^@earendil-works\/pi-agent-core$/, replacement: agentSrcIndex },
{ find: /^@earendil-works\/pi-ai$/, replacement: aiSrcIndex }, { find: /^@earendil-works\/pi-ai$/, replacement: aiSrcIndex },
{ find: /^@earendil-works\/pi-ai\/compat$/, replacement: aiSrcCompat }, { find: /^@earendil-works\/pi-ai\/compat$/, replacement: aiSrcCompat },
], ],
}, },
ssr: { resolve: { conditions: ["source"] } },
}); });

View file

@ -1,7 +1,6 @@
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
const chordSrcIndex = fileURLToPath(new URL("../chord/src/index.ts", import.meta.url));
const telemetrySrcIndex = fileURLToPath(new URL("../telemetry/src/index.ts", import.meta.url)); const telemetrySrcIndex = fileURLToPath(new URL("../telemetry/src/index.ts", import.meta.url));
const aiSrcIndex = fileURLToPath(new URL("../ai/src/index.ts", import.meta.url)); const aiSrcIndex = fileURLToPath(new URL("../ai/src/index.ts", import.meta.url));
const aiSrcCompat = fileURLToPath(new URL("../ai/src/compat.ts", import.meta.url)); const aiSrcCompat = fileURLToPath(new URL("../ai/src/compat.ts", import.meta.url));
@ -23,12 +22,13 @@ export default defineConfig({
}, },
}, },
resolve: { resolve: {
conditions: ["source"],
alias: [ alias: [
{ find: /^@earendil-works\/chord$/, replacement: chordSrcIndex },
{ find: /^@earendil-works\/pi-telemetry$/, replacement: telemetrySrcIndex }, { find: /^@earendil-works\/pi-telemetry$/, replacement: telemetrySrcIndex },
{ find: /^@earendil-works\/pi-agent-core$/, replacement: agentSrcIndex }, { find: /^@earendil-works\/pi-agent-core$/, replacement: agentSrcIndex },
{ find: /^@earendil-works\/pi-ai$/, replacement: aiSrcIndex }, { find: /^@earendil-works\/pi-ai$/, replacement: aiSrcIndex },
{ find: /^@earendil-works\/pi-ai\/compat$/, replacement: aiSrcCompat }, { find: /^@earendil-works\/pi-ai\/compat$/, replacement: aiSrcCompat },
], ],
}, },
ssr: { resolve: { conditions: ["source"] } },
}); });

View file

@ -65,6 +65,7 @@
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "0.91.1", "@anthropic-ai/sdk": "0.91.1",
"@earendil-works/chord": "^0.84.4",
"@aws-sdk/client-bedrock-runtime": "3.1048.0", "@aws-sdk/client-bedrock-runtime": "3.1048.0",
"@earendil-works/pi-telemetry": "^0.84.4", "@earendil-works/pi-telemetry": "^0.84.4",
"@google/genai": "1.52.0", "@google/genai": "1.52.0",

View file

@ -1,3 +1,4 @@
import type { JsonValue } from "@earendil-works/chord/json";
import type { TelemetryContext } from "@earendil-works/pi-telemetry"; import type { TelemetryContext } from "@earendil-works/pi-telemetry";
import type { AnthropicOptions } from "./api/anthropic-messages.ts"; import type { AnthropicOptions } from "./api/anthropic-messages.ts";
import type { AzureOpenAIResponsesOptions } from "./api/azure-openai-responses.ts"; import type { AzureOpenAIResponsesOptions } from "./api/azure-openai-responses.ts";
@ -404,7 +405,7 @@ export interface Usage {
export type StopReason = "pending" | "stop" | "length" | "toolUse" | "error" | "aborted" | "deferred"; export type StopReason = "pending" | "stop" | "length" | "toolUse" | "error" | "aborted" | "deferred";
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; export type { JsonValue } from "@earendil-works/chord/json";
export interface DeferredHandle { export interface DeferredHandle {
provider: string; provider: string;

View file

@ -1,6 +1,6 @@
# Chord implementation plan # Chord implementation plan
> **Status:** Planning document. This describes the intended scope and implementation sequence; it is not a public API contract yet. > **Status:** Active implementation plan. Context, strict JSON, replicated state, service publication/consumption, and the facet host/loader now live in Chord. Symmetric RPC, generation loading, and bundling remain planned. This is not a stable public API contract yet.
## 1. Goal ## 1. Goal
@ -90,13 +90,13 @@ Applications may define their own context keys. A Pi adapter can carry telemetry
A context object never crosses RPC as a business value. The calling peer sends cancellation control and, if configured, an opaque strict-JSON metadata carrier. The receiving adapter constructs a fresh local context. The adapter, not remote business arguments, installs authenticated local identity. A context object never crosses RPC as a business value. The calling peer sends cancellation control and, if configured, an opaque strict-JSON metadata carrier. The receiving adapter constructs a fresh local context. The adapter, not remote business arguments, installs authenticated local identity.
Chord will own a strict `JsonValue` type and runtime check. Remote arguments, results, errors, snapshots, updates, catalogues, and RPC envelopes must be finite strict JSON: Chord owns the static `JsonValue` contract. The current implementation deliberately performs no recursive runtime validation; concrete serializers are responsible for rejecting unsupported values. Remote arguments, results, errors, snapshots, updates, catalogues, and RPC envelopes are expected to be finite strict JSON:
- finite numbers only; - finite numbers only;
- no `undefined`, sparse arrays, symbols, prototypes, cycles, classes, functions, `Map`, or `Set`; and - no `undefined`, sparse arrays, symbols, prototypes, cycles, classes, functions, `Map`, or `Set`; and
- `null`, rather than `undefined`, for business-level absence. - `null`, rather than `undefined`, for business-level absence.
Application schema validation remains the application's responsibility. Chord validates generic envelopes and the strict-JSON boundary, not every application's DTO shape. Application schema validation remains the application's responsibility. Chord validates structural control envelopes, but runtime enforcement of the strict-JSON boundary is currently deferred to serializers.
## 5. Plugins and lifecycle ## 5. Plugins and lifecycle

View file

@ -1,10 +1,10 @@
# @earendil-works/chord # @earendil-works/chord
Chord is an application-composition runtime for systems assembled from Chord is an application-composition runtime for systems assembled from
plugins/extensions. It is built on services, replicated state, and automatic plugins/extensions. It provides facets, services, replicated state, and a
RPC. It is being developed as a standalone package in the Pi monorepo, but it pluggable remote-service boundary. It is developed as a standalone package in
is not a Pi package: it does not depend on any other Pi workspace package and is the Pi monorepo, but it is not a Pi package: it does not depend on any other Pi
built to also be usable by unrelated applications. workspace package and can be used by unrelated applications.
## What Chord is for ## What Chord is for
@ -36,17 +36,20 @@ The design has a few connected pieces:
ordered updates, and become unready on disconnect or replacement until they ordered updates, and become unready on disconnect or replacement until they
are rehydrated. are rehydrated.
- **RPC** connects two symmetric peers over an application-supplied duplex - **Remote connections** carry logical service calls and subscriptions through
transport. Either peer may provide and consume services. Remote calls, an application-supplied adapter. Chord requires strict-JSON arguments,
state snapshots, updates, catalogues, and control messages use strict JSON and results, snapshots, updates, and catalogues, but does not prescribe framing,
support cancellation. 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 - **Context** Chord provides a Go-like context system for cancellation and
invocation-scoped application values. Applications can carry permissions or invocation-scoped application values. Applications can carry permissions or
telemetry through those values without Chord depending on either. telemetry through those values without Chord depending on either.
Chord bundles application-declared plugin-module facets into independently The current runtime exports service tokens, singleton and keyed providers,
loadable Node ESM artifacts. remote bindings, replicated state, facet hosts, and facet loaders from
`@earendil-works/chord`. Chord-owned identifiers use the `chord.*` namespace and
its reserved service prefix is `$chord.*`.
See [PLANNING.md](PLANNING.md) for the architecture, implementation order, See [PLANNING.md](PLANNING.md) for the broader RPC, generation-loading, and
migration boundary, and conformance criteria. bundling architecture.

View file

@ -7,9 +7,20 @@
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"exports": { "exports": {
".": { ".": {
"source": "./src/index.ts",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
}, },
"./context": {
"source": "./src/context.ts",
"types": "./dist/context.d.ts",
"import": "./dist/context.js"
},
"./json": {
"source": "./src/json.ts",
"types": "./dist/json.d.ts",
"import": "./dist/json.js"
},
"./package.json": "./package.json" "./package.json": "./package.json"
}, },
"sideEffects": false, "sideEffects": false,

View file

@ -1,118 +1,33 @@
import { import { BACKGROUND_CONTEXT, type Context } from "../context.ts";
BACKGROUND_CONTEXT, import type {
type Context, RemoteServiceContract,
type MutableReplicatedState, RemoteServiceInstance,
type RemoteServiceContract, RemoteServices,
type RemoteServiceInstance, Service,
RemoteServiceProvider, ServiceMode,
type RemoteServices, } from "../services/contracts.ts";
replicatedState, import { ServiceHandle } from "../services/handle.ts";
type Service, import { InstanceDirectory, type InstanceDirectoryEntry } from "../services/instances.ts";
type ServiceCatalogueEntry, import { RemoteServiceProvider, validateRemoteServiceImplementation } from "../services/provider.ts";
type ServiceMode, import { replicatedState } from "../state.ts";
withCancel, import type { Facet, FacetConnection, FacetEnvironment, FacetHost, FacetOptions, ServiceInstances } from "./types.ts";
} from "@earendil-works/pi-agent-core";
export interface ServiceInstances<T> {
add(key: string, implementation: T): () => void;
}
interface FacetServiceReference { interface FacetServiceReference {
readonly serviceId: string; readonly serviceId: string;
readonly mode: ServiceMode; readonly mode: ServiceMode;
} }
interface FacetRecord { interface FacetShape {
readonly facetId: string; facetId: string;
readonly requires: readonly FacetServiceReference[]; requires: readonly FacetServiceReference[];
readonly provides: readonly FacetServiceReference[]; provides: readonly FacetServiceReference[];
} }
export interface FacetEnvironment { interface FacetRuntime extends FacetShape {
/** requires: FacetServiceReference[];
* Declare a hard dependency on one singleton service and return its stable handle. provides: FacetServiceReference[];
* readonly lifecycle: FacetLifecycle;
* Call this synchronously during setup so the host can validate and order the complete readonly provisions: FacetProvision[];
* dependency graph before activation. The handle cannot be accessed during setup. It
* binds when the host activates and follows later provider-facet replacements, whether
* the implementation is process-local or reached through RPC.
*/
use<T>(service: Service<T>): T;
/**
* Declare a hard dependency on a keyed service and observe each live instance.
*
* The host starts the handler after the instance and its replicated state have hydrated.
* The handler receives a context that is aborted when that instance closes or is replaced.
* The returned idempotent function stops this observation early; otherwise the facet owns
* it automatically. Use keyed services when instances appear and disappear at runtime.
*/
observe<T>(
service: Service<T>,
handler: (instance: RemoteServiceInstance<T>, context: Context) => void | Promise<void>,
): () => void;
/**
* Declare and install this facet's singleton implementation of a service.
*
* Call this synchronously during setup. The declaration lets the host reject duplicate
* providers and order consumers after this facet. Remotable tokens are published
* automatically; `{ local: true }` tokens remain process-local. Reloading this facet swaps
* the implementation behind existing consumer handles.
*/
provide<T>(service: Service<T>, implementation: NoInfer<T>): void;
/**
* Declare ownership of a keyed service and return the handle used to add live instances.
*
* Declaring ownership during setup makes the service discoverable even while it has no
* instances. Instances may be added only while the facet is active and are closed
* automatically when the facet deactivates.
*/
provideMany<T>(service: Service<T>): ServiceInstances<T>;
/**
* Create initialized mutable state suitable for exposing through a service implementation.
*
* Remote consumers receive a stable read-only replica that hydrates from this value and
* follows later updates. This is live projection state, not durable storage; the facet must
* be able to reconstruct it after reload or process restart.
*/
replicatedState<T>(initial: T): MutableReplicatedState<T>;
/**
* Give the facet ownership of a resource cleanup function.
*
* Cleanups run once in reverse registration order during deactivation and startup-failure
* cleanup. Register every subscription, timer, process, file, or contribution here so a
* failed or reloaded facet cannot leak resources into its replacement.
*/
own(disposal: () => void | Promise<void>): void;
/**
* Register asynchronous initialization to run after dependencies are bound and ready.
*
* Setup itself must stay synchronous because it declares the graph. Put I/O, hydration,
* and other effects here. Provider facets activate before facets that consume them.
*/
onActivate(callback: () => void | Promise<void>): void;
/**
* Register final facet teardown.
*
* The callback runs after resources acquired during activation have been released. Use it
* for shutdown work that is not naturally represented by an individual `own()` cleanup.
*/
onDeactivate(callback: () => void | Promise<void>): void;
}
export interface Facet {
readonly id: string;
setup(env: FacetEnvironment): void;
}
export function defineFacet(facet: Facet): Facet {
return facet;
} }
type LifecycleState = "setting_up" | "prepared" | "active" | "disposing" | "dead"; type LifecycleState = "setting_up" | "prepared" | "active" | "disposing" | "dead";
@ -200,88 +115,6 @@ class FacetLifecycle {
} }
} }
class FacetLedger {
readonly #facetId: string;
readonly #requires = new Map<string, FacetServiceReference>();
readonly #provides = new Map<string, FacetServiceReference>();
constructor(facetId: string) {
this.#facetId = facetId;
}
require(service: { readonly id: string }, mode: ServiceMode): void {
this.#record(this.#requires, service, mode);
}
provide(service: { readonly id: string }, mode: ServiceMode): void {
this.#record(this.#provides, service, mode);
}
record(): FacetRecord {
return Object.freeze({
facetId: this.#facetId,
requires: Object.freeze([...this.#requires.values()]),
provides: Object.freeze([...this.#provides.values()]),
});
}
#record(target: Map<string, FacetServiceReference>, service: { readonly id: string }, mode: ServiceMode): void {
const reference = Object.freeze({ serviceId: service.id, mode });
target.set(referenceKey(reference), reference);
}
}
class FacetServiceHandle {
readonly #serviceId: string;
readonly #assertAccess: () => void;
readonly #methods = new Map<PropertyKey, unknown>();
readonly proxy: object;
#implementation: object | undefined;
#remote = false;
constructor(serviceId: string, assertAccess: () => void) {
this.#serviceId = serviceId;
this.#assertAccess = assertAccess;
this.proxy = new Proxy(
{},
{
get: (_target, property) => {
this.#assertAccess();
if (this.#implementation === undefined) throw new Error(`Service ${this.#serviceId} is disconnected`);
const value: unknown = Reflect.get(this.#implementation, property, this.#implementation);
if (this.#remote || typeof value !== "function") return value;
let method = this.#methods.get(property);
if (method === undefined) {
method = (...args: unknown[]) => {
this.#assertAccess();
if (this.#implementation === undefined) {
throw new Error(`Service ${this.#serviceId} is disconnected`);
}
const current: unknown = Reflect.get(this.#implementation, property, this.#implementation);
if (typeof current !== "function") {
throw new TypeError(`Service ${this.#serviceId}.${String(property)} is not a method`);
}
return Reflect.apply(current, this.#implementation, args);
};
this.#methods.set(property, method);
}
return method;
},
},
);
}
bind(implementation: object, remote = false): void {
this.#implementation = implementation;
this.#remote = remote;
}
unbind(): void {
this.#implementation = undefined;
this.#remote = false;
}
}
interface StagedObservation { interface StagedObservation {
start(): () => void; start(): () => void;
stop(): void; stop(): void;
@ -294,29 +127,14 @@ interface KeyedServiceSource {
): () => void; ): () => void;
} }
interface LocalKeyedInstance {
readonly key: string;
readonly generation: number;
readonly handle: FacetServiceHandle;
}
interface LocalKeyedObserver {
readonly handler: (instance: RemoteServiceInstance<unknown>, context: Context) => void | Promise<void>;
readonly tasks: Map<string, { readonly cancel: (reason?: unknown) => void }>;
closed: boolean;
}
interface LocalKeyedRegistration { interface LocalKeyedRegistration {
readonly serviceId: string;
readonly generations: Map<string, number>; readonly generations: Map<string, number>;
readonly instances: Map<string, LocalKeyedInstance>; readonly directory: InstanceDirectory<InstanceDirectoryEntry>;
readonly observers: Set<LocalKeyedObserver>;
} }
class LocalKeyedServiceRegistry implements KeyedServiceSource { class LocalKeyedServiceRegistry implements KeyedServiceSource {
readonly #registrations = new Map<string, LocalKeyedRegistration>(); readonly #registrations = new Map<string, LocalKeyedRegistration>();
readonly #assertAccess: () => void; readonly #assertAccess: () => void;
readonly #onError: (error: Error) => void;
#disposed = false; #disposed = false;
constructor( constructor(
@ -328,14 +146,11 @@ class LocalKeyedServiceRegistry implements KeyedServiceSource {
if (new Set(ids).size !== ids.length) throw new TypeError("Local keyed service registry has duplicate IDs"); if (new Set(ids).size !== ids.length) throw new TypeError("Local keyed service registry has duplicate IDs");
for (const serviceId of ids) { for (const serviceId of ids) {
this.#registrations.set(serviceId, { this.#registrations.set(serviceId, {
serviceId,
generations: new Map(), generations: new Map(),
instances: new Map(), directory: new InstanceDirectory({ ready: true, onError }),
observers: new Set(),
}); });
} }
this.#assertAccess = assertAccess; this.#assertAccess = assertAccess;
this.#onError = onError;
} }
spawn<T>(service: Service<T>, key: string, implementation: T): () => void { spawn<T>(service: Service<T>, key: string, implementation: T): () => void {
@ -345,28 +160,25 @@ class LocalKeyedServiceRegistry implements KeyedServiceSource {
throw new TypeError(`Local service ${service.id} implementation must be an object`); throw new TypeError(`Local service ${service.id} implementation must be an object`);
} }
const registration = this.#registration(service.id); const registration = this.#registration(service.id);
if (registration.instances.has(key)) { if (registration.directory.get(key) !== undefined) {
throw new Error(`Local service ${service.id} already has a live instance with key ${key}`); throw new Error(`Local service ${service.id} already has a live instance with key ${key}`);
} }
const generation = (registration.generations.get(key) ?? 0) + 1; const generation = (registration.generations.get(key) ?? 0) + 1;
registration.generations.set(key, generation); registration.generations.set(key, generation);
const handle = new FacetServiceHandle(service.id, this.#assertAccess); const handle = new ServiceHandle(service.id, this.#assertAccess);
handle.bind(implementation); handle.bind(implementation);
const instance = { key, generation, handle }; const instance: InstanceDirectoryEntry = {
registration.instances.set(key, instance); key,
for (const observer of registration.observers) this.#startTask(observer, instance); generation,
service: handle.proxy,
deactivate: () => handle.unbind(),
};
registration.directory.insert(instance);
let closed = false; let closed = false;
return () => { return () => {
if (closed) return; if (closed) return;
closed = true; closed = true;
if (registration.instances.get(key) !== instance) return; registration.directory.remove(instance);
registration.instances.delete(key);
instance.handle.unbind();
const taskKey = localInstanceTaskKey(instance);
for (const observer of registration.observers) {
observer.tasks.get(taskKey)?.cancel();
observer.tasks.delete(taskKey);
}
}; };
} }
@ -375,56 +187,16 @@ class LocalKeyedServiceRegistry implements KeyedServiceSource {
handler: (instance: RemoteServiceInstance<T>, context: Context) => void | Promise<void>, handler: (instance: RemoteServiceInstance<T>, context: Context) => void | Promise<void>,
): () => void { ): () => void {
this.#assertActive(); this.#assertActive();
const registration = this.#registration(service.id); return this.#registration(service.id).directory.observe(handler);
const observer: LocalKeyedObserver = {
handler: handler as unknown as LocalKeyedObserver["handler"],
tasks: new Map(),
closed: false,
};
registration.observers.add(observer);
for (const instance of registration.instances.values()) this.#startTask(observer, instance);
return () => {
if (observer.closed) return;
observer.closed = true;
for (const task of observer.tasks.values()) task.cancel();
observer.tasks.clear();
registration.observers.delete(observer);
};
} }
dispose(): void { dispose(): void {
if (this.#disposed) return; if (this.#disposed) return;
this.#disposed = true; this.#disposed = true;
for (const registration of this.#registrations.values()) { for (const registration of this.#registrations.values()) registration.directory.dispose();
for (const observer of registration.observers) {
observer.closed = true;
for (const task of observer.tasks.values()) task.cancel();
observer.tasks.clear();
}
registration.observers.clear();
for (const instance of registration.instances.values()) instance.handle.unbind();
registration.instances.clear();
}
this.#registrations.clear(); this.#registrations.clear();
} }
#startTask(observer: LocalKeyedObserver, instance: LocalKeyedInstance): void {
if (observer.closed) return;
const taskKey = localInstanceTaskKey(instance);
if (observer.tasks.has(taskKey)) return;
const { context, cancel } = withCancel(BACKGROUND_CONTEXT);
observer.tasks.set(taskKey, { cancel });
try {
void Promise.resolve(observer.handler({ key: instance.key, service: instance.handle.proxy }, context)).catch(
(error: unknown) => {
if (!context.abortSignal?.aborted) this.#onError(toError(error));
},
);
} catch (error) {
if (!context.abortSignal?.aborted) this.#onError(toError(error));
}
}
#registration(serviceId: string): LocalKeyedRegistration { #registration(serviceId: string): LocalKeyedRegistration {
const registration = this.#registrations.get(serviceId); const registration = this.#registrations.get(serviceId);
if (registration === undefined) throw new Error(`Local keyed service ${serviceId} is not registered`); if (registration === undefined) throw new Error(`Local keyed service ${serviceId} is not registered`);
@ -438,7 +210,7 @@ class LocalKeyedServiceRegistry implements KeyedServiceSource {
class FacetServiceHandles { class FacetServiceHandles {
readonly #assertAccess: () => void; readonly #assertAccess: () => void;
readonly #singletons = new Map<string, FacetServiceHandle>(); readonly #singletons = new Map<string, ServiceHandle>();
readonly #singletonServices = new Map<string, Service<unknown>>(); readonly #singletonServices = new Map<string, Service<unknown>>();
readonly #keyedSources = new Map<string, KeyedServiceSource>(); readonly #keyedSources = new Map<string, KeyedServiceSource>();
@ -449,7 +221,7 @@ class FacetServiceHandles {
use<T>(service: Service<T>): T { use<T>(service: Service<T>): T {
let handle = this.#singletons.get(service.id); let handle = this.#singletons.get(service.id);
if (handle === undefined) { if (handle === undefined) {
handle = new FacetServiceHandle(service.id, this.#assertAccess); handle = new ServiceHandle(service.id, this.#assertAccess);
this.#singletons.set(service.id, handle); this.#singletons.set(service.id, handle);
this.#singletonServices.set(service.id, service as Service<unknown>); this.#singletonServices.set(service.id, service as Service<unknown>);
} }
@ -542,45 +314,21 @@ class StagedServiceInstances<T> implements ServiceInstances<T> {
} }
} }
interface SingletonProvision { type FacetProvision =
readonly facetId: string; | {
readonly service: { readonly id: string; readonly local: boolean }; readonly kind: "singleton";
readonly implementation: object; readonly service: { readonly id: string; readonly local: boolean };
install(provider: RemoteServiceProvider): void; readonly implementation: object;
withdraw(provider: RemoteServiceProvider): void; install(provider: RemoteServiceProvider): void;
replace(provider: RemoteServiceProvider): void; withdraw(provider: RemoteServiceProvider): void;
} replace(provider: RemoteServiceProvider): void;
}
interface KeyedProvision { | {
readonly facetId: string; readonly kind: "keyed";
readonly service: { readonly id: string; readonly local: boolean }; readonly service: { readonly id: string; readonly local: boolean };
connectLocal(registry: LocalKeyedServiceRegistry): void; connectLocal(registry: LocalKeyedServiceRegistry): void;
connectRemote(provider: RemoteServiceProvider): void; connectRemote(provider: RemoteServiceProvider): void;
} };
export interface FacetConnection {
/** Whether this currently unavailable route may provisionally own requirements absent from its catalogue. */
readonly acceptsUnavailableServices: boolean;
catalogue(context: Context): Promise<readonly ServiceCatalogueEntry[]>;
open(options: {
readonly services: readonly { readonly id: string }[];
assertAccess(): void;
onError(error: Error): void;
}): RemoteServices;
}
interface FacetOptions {
readonly facets: readonly Facet[];
readonly connections?: readonly FacetConnection[];
readonly onError?: (error: Error) => void;
}
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>;
}
/** Create an active host for one complete set of facets. */ /** Create an active host for one complete set of facets. */
export async function createFacetHost(options: FacetOptions): Promise<FacetHost> { export async function createFacetHost(options: FacetOptions): Promise<FacetHost> {
@ -595,13 +343,10 @@ export async function createFacetHost(options: FacetOptions): Promise<FacetHost>
/** Private lifecycle and dependency kernel behind the atomic host entry point. */ /** Private lifecycle and dependency kernel behind the atomic host entry point. */
class FacetKernel { class FacetKernel {
readonly #facets: readonly Facet[]; readonly #initialFacets: readonly Facet[];
readonly #connections: readonly FacetConnection[]; readonly #connections: readonly FacetConnection[];
readonly #onError: (error: Error) => void; readonly #onError: (error: Error) => void;
readonly #lifecycles = new Map<string, FacetLifecycle>(); readonly #facets = new Map<string, FacetRuntime>();
readonly #records = new Map<string, FacetRecord>();
readonly #singletons: SingletonProvision[] = [];
readonly #keyed: KeyedProvision[] = [];
readonly #handles: FacetServiceHandles; readonly #handles: FacetServiceHandles;
readonly #connectionBindings = new Map<FacetConnection, RemoteServices>(); readonly #connectionBindings = new Map<FacetConnection, RemoteServices>();
#activationOrder: readonly string[] = []; #activationOrder: readonly string[] = [];
@ -613,7 +358,7 @@ class FacetKernel {
const ids = options.facets.map((facet) => facet.id); const ids = options.facets.map((facet) => facet.id);
if (ids.some((id) => id.length === 0)) throw new Error("Facet ID must not be empty"); 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"); if (new Set(ids).size !== ids.length) throw new Error("Facet IDs must be unique within a generation");
this.#facets = options.facets; this.#initialFacets = options.facets;
this.#connections = options.connections ?? []; this.#connections = options.connections ?? [];
this.#onError = options.onError ?? (() => {}); this.#onError = options.onError ?? (() => {});
this.#handles = new FacetServiceHandles(() => this.#assertServiceAccess()); this.#handles = new FacetServiceHandles(() => this.#assertServiceAccess());
@ -624,22 +369,33 @@ class FacetKernel {
return this.#provider; return this.#provider;
} }
#createFacetRuntime(facetId: string): FacetRuntime {
return {
facetId,
requires: [],
provides: [],
lifecycle: new FacetLifecycle(facetId),
provisions: [],
};
}
#setupFacet(facet: Facet, record: FacetRuntime): void {
const result: unknown = facet.setup(this.#environment(record));
if (isPromiseLike(result)) {
void Promise.resolve(result).catch(() => {});
throw new Error(`Facet ${facet.id} setup must be synchronous`);
}
record.lifecycle.prepared();
}
async activate(): Promise<void> { async activate(): Promise<void> {
const records: FacetRecord[] = []; const records: FacetRuntime[] = [];
try { try {
for (const facet of this.#facets) { for (const facet of this.#initialFacets) {
const lifecycle = new FacetLifecycle(facet.id); const record = this.#createFacetRuntime(facet.id);
const ledger = new FacetLedger(facet.id); this.#facets.set(facet.id, record);
this.#lifecycles.set(facet.id, lifecycle); this.#setupFacet(facet, record);
const result: unknown = facet.setup(this.#environment(lifecycle, ledger));
if (isPromiseLike(result)) {
void Promise.resolve(result).catch(() => {});
throw new Error(`Facet ${facet.id} setup must be synchronous`);
}
lifecycle.prepared();
const record = ledger.record();
records.push(record); records.push(record);
this.#records.set(record.facetId, record);
} }
this.#phase = "assembling"; this.#phase = "assembling";
@ -654,7 +410,7 @@ class FacetKernel {
); );
this.#phase = "activating"; this.#phase = "activating";
for (const id of this.#activationOrder) await this.#lifecycles.get(id)!.activate(); for (const id of this.#activationOrder) await this.#facets.get(id)!.lifecycle.activate();
this.#phase = "active"; this.#phase = "active";
} catch (error) { } catch (error) {
const cleanupErrors = await this.#disposeLifecycles(); const cleanupErrors = await this.#disposeLifecycles();
@ -681,43 +437,29 @@ class FacetKernel {
if (ids.some((id) => id.length === 0)) throw new Error("Facet ID must not be empty"); 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"); if (new Set(ids).size !== ids.length) throw new Error("Reloaded facet IDs must be unique");
for (const id of ids) { for (const id of ids) {
if (!this.#records.has(id)) throw new Error(`Facet ${id} is not active`); if (!this.#facets.has(id)) throw new Error(`Facet ${id} is not active`);
} }
this.#phase = "reloading"; this.#phase = "reloading";
const stagedLifecycles: FacetLifecycle[] = []; const staged: FacetRuntime[] = [];
const candidates: Array<{ const candidates: FacetRuntime[] = [];
readonly lifecycle: FacetLifecycle;
readonly record: FacetRecord;
readonly singletons: SingletonProvision[];
readonly keyed: KeyedProvision[];
}> = [];
try { try {
for (const facet of facets) { for (const facet of facets) {
const lifecycle = new FacetLifecycle(facet.id); const record = this.#createFacetRuntime(facet.id);
stagedLifecycles.push(lifecycle); staged.push(record);
const ledger = new FacetLedger(facet.id); this.#setupFacet(facet, record);
const singletons: SingletonProvision[] = []; const previous = this.#facets.get(facet.id)!;
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)) { if (!sameFacetShape(previous, record)) {
throw new Error(`Reloaded facet ${facet.id} must preserve its service requirements and provisions`); throw new Error(`Reloaded facet ${facet.id} must preserve its service requirements and provisions`);
} }
this.#validateReplacementProvisions(singletons, keyed); this.#validateReplacementProvisions(record.provisions);
candidates.push({ lifecycle, record, singletons, keyed }); candidates.push(record);
} }
} catch (error) { } catch (error) {
const cleanupErrors: unknown[] = []; const cleanupErrors: unknown[] = [];
for (const lifecycle of stagedLifecycles.reverse()) { for (const record of staged.reverse()) {
try { try {
await lifecycle.dispose(); await record.lifecycle.dispose();
} catch (cleanupError) { } catch (cleanupError) {
cleanupErrors.push(cleanupError); cleanupErrors.push(cleanupError);
} }
@ -729,9 +471,10 @@ class FacetKernel {
throw error; throw error;
} }
const replacements = new Map(candidates.map((candidate) => [candidate.record.facetId, candidate])); const replacements = new Map(candidates.map((record) => [record.facetId, record]));
for (const candidate of candidates) { for (const candidate of candidates) {
for (const provision of candidate.singletons) { for (const provision of candidate.provisions) {
if (provision.kind !== "singleton") continue;
this.#handles.unbindSingleton(provision.service.id); this.#handles.unbindSingleton(provision.service.id);
if (!provision.service.local) provision.withdraw(this.provider); if (!provision.service.local) provision.withdraw(this.provider);
} }
@ -739,18 +482,18 @@ class FacetKernel {
const disposalErrors: unknown[] = []; const disposalErrors: unknown[] = [];
for (const id of [...this.#activationOrder].reverse()) { for (const id of [...this.#activationOrder].reverse()) {
if (!replacements.has(id)) continue; if (!replacements.has(id)) continue;
const lifecycle = this.#lifecycles.get(id)!; const record = this.#facets.get(id)!;
this.#lifecycles.delete(id); this.#facets.delete(id);
try { try {
await lifecycle.dispose(); await record.lifecycle.dispose();
} catch (error) { } catch (error) {
disposalErrors.push(error); disposalErrors.push(error);
} }
} }
if (disposalErrors.length > 0) { if (disposalErrors.length > 0) {
for (const { lifecycle } of candidates.reverse()) { for (const candidate of candidates.reverse()) {
try { try {
await lifecycle.dispose(); await candidate.lifecycle.dispose();
} catch (error) { } catch (error) {
disposalErrors.push(error); disposalErrors.push(error);
} }
@ -759,30 +502,22 @@ class FacetKernel {
throw new AggregateError(disposalErrors, "Failed to deactivate reloaded facets"); 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"; this.#phase = "activating";
try { try {
for (const candidate of candidates) { for (const candidate of candidates) {
for (const provision of candidate.keyed) { for (const provision of candidate.provisions) {
if (provision.kind !== "keyed") continue;
provision.connectLocal(this.#localKeyedRegistry); provision.connectLocal(this.#localKeyedRegistry);
if (!provision.service.local) provision.connectRemote(this.provider); if (!provision.service.local) provision.connectRemote(this.provider);
} }
this.#singletons.push(...candidate.singletons); this.#facets.set(candidate.facetId, candidate);
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) { for (const id of this.#activationOrder) {
const candidate = replacements.get(id); const candidate = replacements.get(id);
if (candidate === undefined) continue; if (candidate === undefined) continue;
await candidate.lifecycle.activate(); await candidate.lifecycle.activate();
for (const provision of candidate.singletons) { for (const provision of candidate.provisions) {
if (provision.kind !== "singleton") continue;
this.#handles.bindSingleton(provision.service.id, provision.implementation); this.#handles.bindSingleton(provision.service.id, provision.implementation);
if (!provision.service.local) provision.replace(this.provider); if (!provision.service.local) provision.replace(this.provider);
} }
@ -810,38 +545,24 @@ class FacetKernel {
if (errors.length > 1) throw new AggregateError(errors, "Failed to dispose facet generation"); if (errors.length > 1) throw new AggregateError(errors, "Failed to dispose facet generation");
} }
#validateReplacementProvisions(singletons: readonly SingletonProvision[], keyed: readonly KeyedProvision[]): void { #validateReplacementProvisions(provisions: readonly FacetProvision[]): void {
const definitions = [ for (const provision of provisions) {
...singletons if (provision.kind !== "singleton" || provision.service.local) continue;
.filter(({ service }) => !service.local) validateRemoteServiceImplementation(provision.service.id, provision.implementation);
.map(({ service }) => ({ service, mode: "singleton" as const })),
...keyed.filter(({ service }) => !service.local).map(({ service }) => ({ service, mode: "keyed" as const })),
];
const provider = new RemoteServiceProvider(definitions);
try {
for (const provision of singletons) {
if (!provision.service.local) provision.install(provider);
}
} finally {
provider.dispose();
} }
} }
#environment( #environment(runtime: FacetRuntime): FacetEnvironment {
lifecycle: FacetLifecycle, const { lifecycle, provisions } = runtime;
ledger: FacetLedger,
singletons: SingletonProvision[] = this.#singletons,
keyed: KeyedProvision[] = this.#keyed,
): FacetEnvironment {
return { return {
provide: <T>(service: Service<T>, implementation: NoInfer<T>): void => { provide: <T>(service: Service<T>, implementation: NoInfer<T>): void => {
lifecycle.assertSettingUp("provide services"); lifecycle.assertSettingUp("provide services");
if (typeof implementation !== "object" || implementation === null || Array.isArray(implementation)) { if (typeof implementation !== "object" || implementation === null || Array.isArray(implementation)) {
throw new TypeError(`Service ${service.id} implementation must be an object`); throw new TypeError(`Service ${service.id} implementation must be an object`);
} }
ledger.provide(service, "singleton"); recordServiceReference(runtime.provides, service, "singleton");
singletons.push({ provisions.push({
facetId: lifecycle.id, kind: "singleton",
service, service,
implementation, implementation,
install: (provider) => provider.provide(service, implementation as NoInfer<RemoteServiceContract<T>>), install: (provider) => provider.provide(service, implementation as NoInfer<RemoteServiceContract<T>>),
@ -851,10 +572,10 @@ class FacetKernel {
}, },
provideMany: <T>(service: Service<T>): ServiceInstances<T> => { provideMany: <T>(service: Service<T>): ServiceInstances<T> => {
lifecycle.assertSettingUp("provide service instances"); lifecycle.assertSettingUp("provide service instances");
ledger.provide(service, "keyed"); recordServiceReference(runtime.provides, service, "keyed");
const instances = new StagedServiceInstances<T>(lifecycle); const instances = new StagedServiceInstances<T>(lifecycle);
keyed.push({ provisions.push({
facetId: lifecycle.id, kind: "keyed",
service, service,
connectLocal: (registry) => connectLocal: (registry) =>
instances.connect((key, implementation) => registry.spawn(service, key, implementation)), instances.connect((key, implementation) => registry.spawn(service, key, implementation)),
@ -867,7 +588,7 @@ class FacetKernel {
}, },
use: <T>(service: Service<T>): T => { use: <T>(service: Service<T>): T => {
lifecycle.assertSettingUp("acquire services"); lifecycle.assertSettingUp("acquire services");
ledger.require(service, "singleton"); recordServiceReference(runtime.requires, service, "singleton");
return this.#handles.use(service); return this.#handles.use(service);
}, },
observe: <T>( observe: <T>(
@ -875,7 +596,7 @@ class FacetKernel {
handler: (instance: RemoteServiceInstance<T>, context: Context) => void | Promise<void>, handler: (instance: RemoteServiceInstance<T>, context: Context) => void | Promise<void>,
): (() => void) => { ): (() => void) => {
lifecycle.assertSettingUp("observe services"); lifecycle.assertSettingUp("observe services");
ledger.require(service, "keyed"); recordServiceReference(runtime.requires, service, "keyed");
const observation = this.#handles.observe(service, handler); const observation = this.#handles.observe(service, handler);
lifecycle.observe(() => observation.start()); lifecycle.observe(() => observation.start());
return observation.stop; return observation.stop;
@ -891,7 +612,7 @@ class FacetKernel {
} }
async #resolveExternalServices( async #resolveExternalServices(
records: readonly FacetRecord[], records: readonly FacetShape[],
): Promise<ReadonlyMap<string, { readonly mode: ServiceMode; readonly connection: FacetConnection }>> { ): Promise<ReadonlyMap<string, { readonly mode: ServiceMode; readonly connection: FacetConnection }>> {
const catalogues = await Promise.all( const catalogues = await Promise.all(
this.#connections.map(async (connection) => ({ this.#connections.map(async (connection) => ({
@ -950,39 +671,36 @@ class FacetKernel {
} }
#assembleProviders(): void { #assembleProviders(): void {
const remotelyPublishedServices = [ const provisions = this.#provisions();
...this.#singletons const provider = new RemoteServiceProvider(
.filter(({ service }) => !service.local) provisions.filter(({ service }) => !service.local).map(({ service, kind }) => ({ service, mode: kind })),
.map(({ service }) => ({ service, mode: "singleton" as const })), );
...this.#keyed
.filter(({ service }) => !service.local)
.map(({ service }) => ({ service, mode: "keyed" as const })),
];
const provider = new RemoteServiceProvider(remotelyPublishedServices);
const localKeyedServices = new LocalKeyedServiceRegistry( const localKeyedServices = new LocalKeyedServiceRegistry(
this.#keyed.map(({ service }) => service), provisions.flatMap((provision) => (provision.kind === "keyed" ? [provision.service] : [])),
() => this.#assertServiceAccess(), () => this.#assertServiceAccess(),
this.#onError, this.#onError,
); );
this.#provider = provider; this.#provider = provider;
this.#localKeyedServices = localKeyedServices; this.#localKeyedServices = localKeyedServices;
for (const provision of this.#singletons) { for (const provision of provisions) {
if (!provision.service.local) provision.install(provider); if (provision.kind === "singleton") {
} if (!provision.service.local) provision.install(provider);
for (const provision of this.#keyed) { } else {
provision.connectLocal(localKeyedServices); provision.connectLocal(localKeyedServices);
if (!provision.service.local) provision.connectRemote(provider); if (!provision.service.local) provision.connectRemote(provider);
}
} }
} }
#bindServices( #bindServices(
externalServices: ReadonlyMap<string, { readonly mode: ServiceMode; readonly connection: FacetConnection }>, externalServices: ReadonlyMap<string, { readonly mode: ServiceMode; readonly connection: FacetConnection }>,
): void { ): void {
for (const provision of this.#singletons) { for (const provision of this.#provisions()) {
this.#handles.bindSingleton(provision.service.id, provision.implementation); if (provision.kind === "singleton") {
} this.#handles.bindSingleton(provision.service.id, provision.implementation);
for (const provision of this.#keyed) { } else {
this.#handles.bindKeyed(provision.service.id, this.#localKeyedRegistry); this.#handles.bindKeyed(provision.service.id, this.#localKeyedRegistry);
}
} }
for (const [serviceId, { mode, connection }] of externalServices) { for (const [serviceId, { mode, connection }] of externalServices) {
const services = this.#connectionBindings.get(connection); const services = this.#connectionBindings.get(connection);
@ -992,6 +710,10 @@ class FacetKernel {
} }
} }
#provisions(): FacetProvision[] {
return [...this.#facets.values()].flatMap(({ provisions }) => provisions);
}
get #localKeyedRegistry(): LocalKeyedServiceRegistry { get #localKeyedRegistry(): LocalKeyedServiceRegistry {
if (this.#localKeyedServices === undefined) throw new Error("Facet keyed services are not assembled"); if (this.#localKeyedServices === undefined) throw new Error("Facet keyed services are not assembled");
return this.#localKeyedServices; return this.#localKeyedServices;
@ -1013,15 +735,13 @@ class FacetKernel {
async #disposeLifecycles(): Promise<unknown[]> { async #disposeLifecycles(): Promise<unknown[]> {
const errors: unknown[] = []; const errors: unknown[] = [];
const order = const order =
this.#activationOrder.length > 0 this.#activationOrder.length > 0 ? [...this.#activationOrder].reverse() : [...this.#facets.keys()].reverse();
? [...this.#activationOrder].reverse()
: [...this.#lifecycles.keys()].reverse();
for (const id of order) { for (const id of order) {
const lifecycle = this.#lifecycles.get(id); const record = this.#facets.get(id);
if (lifecycle === undefined) continue; if (record === undefined) continue;
this.#lifecycles.delete(id); this.#facets.delete(id);
try { try {
await lifecycle.dispose(); await record.lifecycle.dispose();
} catch (error) { } catch (error) {
errors.push(error); errors.push(error);
} }
@ -1031,7 +751,7 @@ class FacetKernel {
} }
function validateFacets( function validateFacets(
records: readonly FacetRecord[], records: readonly FacetShape[],
externalServices: ReadonlyMap<string, { readonly mode: ServiceMode }>, externalServices: ReadonlyMap<string, { readonly mode: ServiceMode }>,
): string[] { ): string[] {
const providers = new Map< const providers = new Map<
@ -1081,7 +801,7 @@ function validateFacets(
} }
function topologicalOrder( function topologicalOrder(
records: readonly FacetRecord[], records: readonly FacetShape[],
dependencies: ReadonlyMap<string, ReadonlySet<string>>, dependencies: ReadonlyMap<string, ReadonlySet<string>>,
dependents: ReadonlyMap<string, ReadonlySet<string>>, dependents: ReadonlyMap<string, ReadonlySet<string>>,
): string[] { ): string[] {
@ -1104,26 +824,26 @@ function topologicalOrder(
return order; return order;
} }
function sameFacetShape(left: FacetRecord, right: FacetRecord): boolean { function recordServiceReference(
target: FacetServiceReference[],
service: { readonly id: string },
mode: ServiceMode,
): void {
if (target.some((reference) => reference.serviceId === service.id && reference.mode === mode)) return;
target.push({ serviceId: service.id, mode });
}
function sameFacetShape(left: FacetShape, right: FacetShape): boolean {
return sameReferences(left.requires, right.requires) && sameReferences(left.provides, right.provides); return sameReferences(left.requires, right.requires) && sameReferences(left.provides, right.provides);
} }
function sameReferences(left: readonly FacetServiceReference[], right: readonly FacetServiceReference[]): boolean { function sameReferences(left: readonly FacetServiceReference[], right: readonly FacetServiceReference[]): boolean {
if (left.length !== right.length) return false; return (
const rightKeys = new Set(right.map(referenceKey)); left.length === right.length &&
return left.every((reference) => rightKeys.has(referenceKey(reference))); left.every((reference) =>
} right.some((other) => other.serviceId === reference.serviceId && other.mode === reference.mode),
)
function referenceKey(reference: FacetServiceReference): string { );
return `${reference.serviceId}\0${reference.mode}`;
}
function localInstanceTaskKey(instance: LocalKeyedInstance): string {
return `${instance.key}\0${instance.generation}`;
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
} }
function isPromiseLike(value: unknown): value is PromiseLike<unknown> { function isPromiseLike(value: unknown): value is PromiseLike<unknown> {

View file

@ -1,4 +1,4 @@
import type { Facet } from "./facets.ts"; import type { Facet } from "./types.ts";
export interface LoadedFacets { export interface LoadedFacets {
readonly facets: readonly Facet[]; readonly facets: readonly Facet[];

View file

@ -0,0 +1,64 @@
import type { Context } from "../context.ts";
import type { RemoteServiceInstance, RemoteServices, Service } from "../services/contracts.ts";
import type { ServiceCatalogueEntry } from "../services/protocol.ts";
import type { RemoteServiceProvider } from "../services/provider.ts";
import type { MutableReplicatedState } from "../state.ts";
export interface ServiceInstances<T> {
add(key: string, implementation: T): () => void;
}
export interface FacetEnvironment {
/** Declare a hard dependency on one singleton service and return its stable handle. */
use<T>(service: Service<T>): T;
/** Declare a hard dependency on a keyed service and observe each live instance. */
observe<T>(
service: Service<T>,
handler: (instance: RemoteServiceInstance<T>, context: Context) => void | Promise<void>,
): () => void;
/** Declare and install this facet's singleton implementation of a service. */
provide<T>(service: Service<T>, implementation: NoInfer<T>): void;
/** Declare ownership of a keyed service and return its live-instance collection. */
provideMany<T>(service: Service<T>): ServiceInstances<T>;
/** Create initialized mutable state suitable for exposing through a service implementation. */
replicatedState<T>(initial: T): MutableReplicatedState<T>;
/** Give the facet ownership of a resource cleanup function. */
own(disposal: () => void | Promise<void>): void;
/** Register asynchronous initialization after dependencies are bound and ready. */
onActivate(callback: () => void | Promise<void>): void;
/** Register final facet teardown. */
onDeactivate(callback: () => void | Promise<void>): void;
}
export interface Facet {
readonly id: string;
setup(env: FacetEnvironment): void;
}
export function defineFacet(facet: Facet): Facet {
return facet;
}
export interface FacetConnection {
/** Whether this currently unavailable route may provisionally own absent requirements. */
readonly acceptsUnavailableServices: boolean;
catalogue(context: Context): Promise<readonly ServiceCatalogueEntry[]>;
open(options: {
readonly services: readonly { readonly id: string }[];
assertAccess(): void;
onError(error: Error): void;
}): RemoteServices;
}
export interface FacetOptions {
readonly facets: readonly Facet[];
readonly connections?: readonly FacetConnection[];
readonly onError?: (error: Error) => void;
}
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>;
}

View file

@ -4,3 +4,46 @@
* Its public API is under development. * Its public API is under development.
*/ */
export * from "./context.ts"; export * from "./context.ts";
export { createFacetHost } from "./facets/host.ts";
export {
combineFacetLoaders,
createStaticFacetLoader,
type FacetLoader,
type LoadedFacets,
} from "./facets/loader.ts";
export {
defineFacet,
type Facet,
type FacetConnection,
type FacetEnvironment,
type FacetHost,
type FacetOptions,
type ServiceInstances,
} from "./facets/types.ts";
export type { JsonValue } from "./json.ts";
export {
createRemoteServiceBinding,
type RemoteServiceBinding,
type RemoteServiceBindingOptions,
} from "./services/consumer.ts";
export {
defineService,
type RemoteServiceInstance,
type RemoteServices,
type Service,
type ServiceMode,
} from "./services/contracts.ts";
export { RemoteServiceError, type RemoteServiceErrorCode } from "./services/errors.ts";
export type {
RemoteServiceConnection,
ServiceCall,
ServiceCatalogueEntry,
ServiceInstanceAddress,
ServiceInstanceSnapshot,
ServiceMemberSnapshot,
ServiceProviderUpdate,
ServiceSubscription,
ServiceSubscriptionSnapshot,
} from "./services/protocol.ts";
export { createLoopbackServiceConnection, RemoteServiceProvider } from "./services/provider.ts";
export { freshDeliveryContext, type MutableReplicatedState, type ReplicatedState, replicatedState } from "./state.ts";

View file

@ -0,0 +1 @@
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };

View file

@ -1,80 +1,32 @@
import { awaitWithContext, BACKGROUND_CONTEXT, type Context, withCancel } from "../../harness/context.ts"; import { awaitWithContext, BACKGROUND_CONTEXT, type Context } from "../context.ts";
import type { JsonValue } from "../../harness/session/types.ts"; import type { JsonValue } from "../json.ts";
import { RemoteServiceError } from "./provider.ts"; import { freshDeliveryContext, ReplicatedStateReplica } from "../state.ts";
import { freshDeliveryContext } from "./replicated-state.ts"; import type { RemoteServiceInstance, RemoteServices, Service } from "./contracts.ts";
import { import { RemoteServiceError } from "./errors.ts";
cloneJson, import { InstanceDirectory, type InstanceDirectoryEntry } from "./instances.ts";
isJsonValue, import type {
type RemoteServiceConnection, RemoteServiceConnection,
type RemoteServiceInstance, ServiceInstanceAddress,
type RemoteServiceNamespaceOptions, ServiceInstanceSnapshot,
type RemoteServiceSubscription, ServiceMemberSnapshot,
type RemoteServices, ServiceProviderUpdate,
type ReplicatedState, ServiceSubscription,
type Service, } from "./protocol.ts";
type ServiceInstanceAddress,
type ServiceInstanceSnapshot, export interface RemoteServiceBindingOptions {
type ServiceMemberDescription, readonly services: readonly { readonly id: string }[];
type ServiceMemberKind, readonly connection: RemoteServiceConnection;
type ServiceProviderUpdate, readonly bound?: boolean;
type ServiceStateSnapshot, readonly onError?: (error: Error) => void;
} from "./types.ts"; readonly assertAccess?: () => void;
}
export interface RemoteServiceBinding extends RemoteServices {
rebind(bound: boolean, context: Context): Promise<void>;
}
type ErrorReporter = (error: Error) => void; type ErrorReporter = (error: Error) => void;
type ServiceMemberKind = ServiceMemberSnapshot["kind"];
class ReplicatedStateReplica implements ReplicatedState<JsonValue> {
readonly #listeners = new Set<(value: JsonValue, context: Context) => void>();
readonly #reportError: ErrorReporter;
#value: JsonValue | undefined;
#sequence: number | undefined;
constructor(reportError: ErrorReporter) {
this.#reportError = reportError;
}
get value(): JsonValue | undefined {
return this.#value;
}
subscribe(listener: (value: JsonValue, context: Context) => void): () => void {
this.#listeners.add(listener);
if (this.#value !== undefined) this.#deliver(listener, this.#value, freshDeliveryContext());
return () => this.#listeners.delete(listener);
}
hydrate(snapshot: ServiceStateSnapshot, context: Context): void {
this.#sequence = snapshot.sequence;
this.#value = snapshot.value;
this.#deliverAll(context);
}
update(sequence: number, value: JsonValue, context: Context): void {
if (this.#sequence === undefined) throw new Error("Replicated state received an update before hydration");
if (sequence <= this.#sequence) return;
if (sequence !== this.#sequence + 1) throw new Error("Replicated state update sequence has a gap");
this.#sequence = sequence;
this.#value = value;
this.#deliverAll(context);
}
clear(): void {
this.#value = undefined;
this.#sequence = undefined;
}
#deliverAll(context: Context): void {
if (this.#value === undefined) return;
for (const listener of this.#listeners) this.#deliver(listener, this.#value, context);
}
#deliver(listener: (value: JsonValue, context: Context) => void, value: JsonValue, context: Context): void {
try {
listener(value, context);
} catch (error) {
this.#reportError(toError(error));
}
}
}
class MemberSlot { class MemberSlot {
readonly #serviceId: string; readonly #serviceId: string;
@ -131,9 +83,9 @@ class MemberSlot {
} }
} }
hydrate(snapshot: ServiceStateSnapshot, context: Context): void { hydrate(sequence: number, value: JsonValue, context: Context): void {
this.setDescription("state"); this.setDescription("state");
this.#state.hydrate(snapshot, context); this.#state.hydrate(sequence, value, context);
} }
update(sequence: number, value: JsonValue, context: Context): void { update(sequence: number, value: JsonValue, context: Context): void {
@ -187,19 +139,8 @@ class MemberSlot {
), ),
); );
} }
const businessArgs = args.slice(0, -1); const businessArgs = args.slice(0, -1) as JsonValue[];
if (!businessArgs.every(isJsonValue)) { return this.#invoke(businessArgs, context);
return Promise.reject(
new RemoteServiceError(
"service_invalid_value",
`Remote service method ${this.#serviceId}.${this.#member} arguments must be strict JSON`,
),
);
}
return this.#invoke(
businessArgs.map((value) => cloneJson(value)),
context,
);
} }
} }
@ -210,7 +151,6 @@ class ServiceFacade {
readonly #reportError: ErrorReporter; readonly #reportError: ErrorReporter;
readonly #slots = new Map<string, MemberSlot>(); readonly #slots = new Map<string, MemberSlot>();
readonly #descriptions = new Map<string, ServiceMemberKind>(); readonly #descriptions = new Map<string, ServiceMemberKind>();
readonly #stateSnapshots = new Map<string, ServiceStateSnapshot>();
readonly #isActive: () => boolean; readonly #isActive: () => boolean;
readonly #assertAccess: () => void; readonly #assertAccess: () => void;
readonly proxy: object; readonly proxy: object;
@ -240,29 +180,24 @@ class ServiceFacade {
install(snapshot: ServiceInstanceSnapshot, context: Context): void { install(snapshot: ServiceInstanceSnapshot, context: Context): void {
if (!sameAddress(snapshot.instance, this.#address)) if (!sameAddress(snapshot.instance, this.#address))
throw new Error("Remote service snapshot has the wrong address"); throw new Error("Remote service snapshot has the wrong address");
const descriptions = validateMemberDescriptions(snapshot.members); const members = validateMembers(snapshot.members);
for (const name of this.#slots.keys()) { for (const name of this.#slots.keys()) {
if (!descriptions.has(name)) { if (!members.has(name)) {
throw new RemoteServiceError( throw new RemoteServiceError(
"service_member_not_found", "service_member_not_found",
`Unknown remote service member ${this.#serviceId}.${name}`, `Unknown remote service member ${this.#serviceId}.${name}`,
); );
} }
} }
for (const [name, kind] of descriptions) {
if ((kind === "state") !== Object.hasOwn(snapshot.states, name)) {
throw new Error(`Remote service snapshot has invalid state metadata for ${this.#serviceId}.${name}`);
}
}
this.#descriptions.clear(); this.#descriptions.clear();
for (const [name, kind] of descriptions) { for (const [name, member] of members) this.#descriptions.set(name, member.kind);
this.#descriptions.set(name, kind); for (const member of members.values()) {
this.#slots.get(name)?.setDescription(kind); const slot = this.#slots.get(member.name);
} if (member.kind === "state") {
this.#stateSnapshots.clear(); (slot ?? this.#slot(member.name)).hydrate(member.sequence, member.value, context);
for (const [name, state] of Object.entries(snapshot.states)) { } else {
this.#stateSnapshots.set(name, state); slot?.setDescription(member.kind);
this.#slot(name).hydrate(state, context); }
} }
} }
@ -270,14 +205,11 @@ class ServiceFacade {
if (this.#descriptions.get(member) !== "state") { if (this.#descriptions.get(member) !== "state") {
throw new Error(`Remote service update targets non-state member ${this.#serviceId}.${member}`); throw new Error(`Remote service update targets non-state member ${this.#serviceId}.${member}`);
} }
const snapshot = { sequence, value };
this.#stateSnapshots.set(member, snapshot);
this.#slot(member).update(sequence, value, context); this.#slot(member).update(sequence, value, context);
} }
clear(): void { clear(): void {
for (const slot of this.#slots.values()) slot.clear(); for (const slot of this.#slots.values()) slot.clear();
this.#stateSnapshots.clear();
} }
#slot(member: string): MemberSlot { #slot(member: string): MemberSlot {
@ -302,8 +234,6 @@ class ServiceFacade {
); );
const kind = this.#descriptions.get(member); const kind = this.#descriptions.get(member);
if (kind !== undefined) slot.setDescription(kind); if (kind !== undefined) slot.setDescription(kind);
const state = this.#stateSnapshots.get(member);
if (state !== undefined) slot.hydrate(state, freshDeliveryContext());
this.#slots.set(member, slot); this.#slots.set(member, slot);
return slot; return slot;
} }
@ -311,26 +241,14 @@ class ServiceFacade {
interface SingletonBinding { interface SingletonBinding {
facade: ServiceFacade; facade: ServiceFacade;
subscription?: RemoteServiceSubscription; subscription?: ServiceSubscription;
starting?: Promise<void>; starting?: Promise<void>;
active: boolean; active: boolean;
revision: number; revision: number;
} }
interface KeyedInstance { interface KeyedInstance extends InstanceDirectoryEntry {
readonly address: ServiceInstanceAddress; readonly facade: ServiceFacade;
facade: ServiceFacade;
active: boolean;
}
interface ObserverTask {
readonly cancel: (reason?: unknown) => void;
}
interface ObserverRegistration<T> {
readonly handler: (instance: RemoteServiceInstance<T>, context: Context) => void | Promise<void>;
readonly tasks: Map<string, ObserverTask>;
closed: boolean;
} }
class KeyedBinding<T> { class KeyedBinding<T> {
@ -339,13 +257,11 @@ class KeyedBinding<T> {
readonly #reportError: ErrorReporter; readonly #reportError: ErrorReporter;
readonly #assertAccess: () => void; readonly #assertAccess: () => void;
readonly #onEmpty: () => void; readonly #onEmpty: () => void;
readonly #instances = new Map<string, KeyedInstance>(); readonly #instances: InstanceDirectory<KeyedInstance>;
readonly #observers = new Set<ObserverRegistration<T>>(); #subscription: ServiceSubscription | undefined;
#subscription: RemoteServiceSubscription | undefined;
#starting: Promise<void> | undefined; #starting: Promise<void> | undefined;
#closed = false; #closed = false;
#bound: boolean; #bound: boolean;
#hydrated = false;
#revision = 0; #revision = 0;
constructor( constructor(
@ -362,15 +278,12 @@ class KeyedBinding<T> {
this.#assertAccess = assertAccess; this.#assertAccess = assertAccess;
this.#onEmpty = onEmpty; this.#onEmpty = onEmpty;
this.#bound = bound; this.#bound = bound;
this.#instances = new InstanceDirectory({ ready: false, onError: reportError });
} }
observe(handler: ObserverRegistration<T>["handler"]): () => void { observe(handler: (instance: RemoteServiceInstance<T>, context: Context) => void | Promise<void>): () => void {
if (this.#closed) throw new Error("Remote keyed service binding is closed"); if (this.#closed) throw new Error("Remote keyed service binding is closed");
const observer: ObserverRegistration<T> = { handler, tasks: new Map(), closed: false }; const stop = this.#instances.observe(handler);
this.#observers.add(observer);
if (this.#hydrated) {
for (const instance of this.#instances.values()) this.#startTask(observer, instance);
}
if (this.#bound && this.#starting === undefined) { if (this.#bound && this.#starting === undefined) {
const revision = this.#revision; const revision = this.#revision;
const starting = this.#start(revision); const starting = this.#start(revision);
@ -379,13 +292,12 @@ class KeyedBinding<T> {
if (!this.#closed && this.#revision === revision && this.#bound) this.#reportError(toError(error)); if (!this.#closed && this.#revision === revision && this.#bound) this.#reportError(toError(error));
}); });
} }
let stopped = false;
return () => { return () => {
if (observer.closed) return; if (stopped) return;
observer.closed = true; stopped = true;
for (const task of observer.tasks.values()) task.cancel(); stop();
observer.tasks.clear(); if (this.#instances.observerCount === 0) this.#onEmpty();
this.#observers.delete(observer);
if (this.#observers.size === 0) this.#onEmpty();
}; };
} }
@ -395,7 +307,7 @@ class KeyedBinding<T> {
const revision = ++this.#revision; const revision = ++this.#revision;
await this.#reset(context, false); await this.#reset(context, false);
if (this.#closed || this.#revision !== revision || this.#bound !== bound) return; if (this.#closed || this.#revision !== revision || this.#bound !== bound) return;
if (bound && this.#observers.size > 0) { if (bound && this.#instances.observerCount > 0) {
const starting = this.#start(revision); const starting = this.#start(revision);
this.#starting = starting; this.#starting = starting;
await starting; await starting;
@ -411,21 +323,11 @@ class KeyedBinding<T> {
this.#closed = true; this.#closed = true;
this.#revision += 1; this.#revision += 1;
await this.#reset(context, true); await this.#reset(context, true);
for (const observer of this.#observers) observer.closed = true; this.#instances.dispose();
this.#observers.clear();
} }
async #reset(context: Context, waitForStarting: boolean): Promise<void> { async #reset(context: Context, waitForStarting: boolean): Promise<void> {
for (const observer of this.#observers) { this.#instances.reset();
for (const task of observer.tasks.values()) task.cancel();
observer.tasks.clear();
}
for (const instance of this.#instances.values()) {
instance.active = false;
instance.facade.clear();
}
this.#instances.clear();
this.#hydrated = false;
const starting = this.#starting; const starting = this.#starting;
this.#starting = undefined; this.#starting = undefined;
const subscription = this.#subscription; const subscription = this.#subscription;
@ -453,11 +355,8 @@ class KeyedBinding<T> {
if (subscription.snapshot.mode !== "keyed" || subscription.snapshot.serviceId !== this.#service.id) { if (subscription.snapshot.mode !== "keyed" || subscription.snapshot.serviceId !== this.#service.id) {
throw new Error(`Remote service ${this.#service.id} returned the wrong keyed snapshot`); throw new Error(`Remote service ${this.#service.id} returned the wrong keyed snapshot`);
} }
for (const snapshot of subscription.snapshot.instances) this.#spawn(snapshot, freshDeliveryContext(), false); for (const snapshot of subscription.snapshot.instances) this.#spawn(snapshot, freshDeliveryContext());
this.#hydrated = true; this.#instances.ready();
for (const observer of this.#observers) {
for (const instance of this.#instances.values()) this.#startTask(observer, instance);
}
subscription.activate(); subscription.activate();
} }
@ -469,15 +368,17 @@ class KeyedBinding<T> {
case "replaced": case "replaced":
throw new Error("Keyed service received a singleton lifecycle update"); throw new Error("Keyed service received a singleton lifecycle update");
case "spawned": case "spawned":
this.#spawn(update.instance, context, true); this.#spawn(update.instance, context);
break; break;
case "closed": case "closed": {
this.#closeInstance(update.instance); const instance = this.#instances.get(update.instance.key);
if (instance?.generation === update.instance.generation) this.#instances.remove(instance);
break; break;
}
case "state": { case "state": {
if (update.instance === undefined) throw new Error("Keyed state update has no instance address"); if (update.instance === undefined) throw new Error("Keyed state update has no instance address");
const instance = this.#instances.get(update.instance.key); const instance = this.#instances.get(update.instance.key);
if (instance?.address.generation !== update.instance.generation) return; if (instance?.generation !== update.instance.generation) return;
instance.facade.update(update.member, update.sequence, update.value, context); instance.facade.update(update.member, update.sequence, update.value, context);
break; break;
} }
@ -487,66 +388,33 @@ class KeyedBinding<T> {
} }
} }
#spawn(snapshot: ServiceInstanceSnapshot, context: Context, startTasks: boolean): void { #spawn(snapshot: ServiceInstanceSnapshot, context: Context): void {
const address = snapshot.instance; const address = snapshot.instance;
if (address === undefined) throw new Error("Keyed service instance snapshot has no address"); if (address === undefined) throw new Error("Keyed service instance snapshot has no address");
const previous = this.#instances.get(address.key); let active = true;
if (previous !== undefined) { const facade = new ServiceFacade(
if (previous.address.generation === address.generation)
throw new Error("Keyed service repeated a live generation");
this.#closeInstance(previous.address);
}
const instance: KeyedInstance = {
address,
active: true,
facade: undefined as unknown as ServiceFacade,
};
instance.facade = new ServiceFacade(
this.#service.id, this.#service.id,
address, address,
this.#connection, this.#connection,
() => instance.active && !this.#closed, () => active && !this.#closed,
this.#assertAccess, this.#assertAccess,
this.#reportError, this.#reportError,
); );
instance.facade.install(snapshot, context); facade.install(snapshot, context);
this.#instances.set(address.key, instance); this.#instances.replace({
if (startTasks && this.#hydrated) { key: address.key,
for (const observer of this.#observers) this.#startTask(observer, instance); generation: address.generation,
} service: facade.proxy,
} facade,
deactivate: () => {
#closeInstance(address: ServiceInstanceAddress): void { active = false;
const instance = this.#instances.get(address.key); facade.clear();
if (instance?.address.generation !== address.generation) return; },
instance.active = false; });
instance.facade.clear();
this.#instances.delete(address.key);
const taskKey = instanceTaskKey(address);
for (const observer of this.#observers) {
observer.tasks.get(taskKey)?.cancel();
observer.tasks.delete(taskKey);
}
}
#startTask(observer: ObserverRegistration<T>, instance: KeyedInstance): void {
if (observer.closed || !instance.active) return;
const key = instanceTaskKey(instance.address);
if (observer.tasks.has(key)) return;
const { context, cancel } = withCancel(BACKGROUND_CONTEXT);
observer.tasks.set(key, { cancel });
const exposed = { key: instance.address.key, service: instance.facade.proxy as T };
try {
void Promise.resolve(observer.handler(exposed, context)).catch((error: unknown) => {
if (!context.abortSignal?.aborted) this.#reportError(toError(error));
});
} catch (error) {
if (!context.abortSignal?.aborted) this.#reportError(toError(error));
}
} }
} }
export class RemoteServiceNamespace implements RemoteServices { class RemoteServiceBindingImpl implements RemoteServiceBinding {
readonly #connection: RemoteServiceConnection; readonly #connection: RemoteServiceConnection;
readonly #allowlist = new Set<string>(); readonly #allowlist = new Set<string>();
readonly #reportError: ErrorReporter; readonly #reportError: ErrorReporter;
@ -559,10 +427,10 @@ export class RemoteServiceNamespace implements RemoteServices {
#bindingTransition = Promise.resolve(); #bindingTransition = Promise.resolve();
#disposed = false; #disposed = false;
constructor(options: RemoteServiceNamespaceOptions) { constructor(options: RemoteServiceBindingOptions) {
this.#connection = options.connection; this.#connection = options.connection;
const ids = options.services.map(({ id }) => id); const ids = options.services.map(({ id }) => id);
if (new Set(ids).size !== ids.length) throw new TypeError("Remote service namespace has duplicate service IDs"); 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); for (const id of ids) this.#allowlist.add(id);
this.#reportError = options.onError ?? (() => {}); this.#reportError = options.onError ?? (() => {});
this.#assertAccess = options.assertAccess ?? (() => {}); this.#assertAccess = options.assertAccess ?? (() => {});
@ -634,7 +502,7 @@ export class RemoteServiceNamespace implements RemoteServices {
} }
async ready(context: Context): Promise<void> { async ready(context: Context): Promise<void> {
if (this.#disposed) throw new Error("Remote service namespace is disposed"); if (this.#disposed) throw new Error("Remote service binding is disposed");
while (true) { while (true) {
const revision = this.#readinessRevision; const revision = this.#readinessRevision;
const starts = [ const starts = [
@ -648,13 +516,13 @@ export class RemoteServiceNamespace implements RemoteServices {
Promise.all(starts).then(() => undefined), Promise.all(starts).then(() => undefined),
context, context,
); );
if (this.#disposed) throw new Error("Remote service namespace is disposed"); if (this.#disposed) throw new Error("Remote service binding is disposed");
if (revision === this.#readinessRevision) return; if (revision === this.#readinessRevision) return;
} }
} }
async rebind(bound: boolean, context: Context): Promise<void> { async rebind(bound: boolean, context: Context): Promise<void> {
if (this.#disposed) throw new Error("Remote service namespace is disposed"); if (this.#disposed) throw new Error("Remote service binding is disposed");
this.#bound = bound; this.#bound = bound;
this.#readinessRevision += 1; this.#readinessRevision += 1;
const transitions: Promise<void>[] = []; const transitions: Promise<void>[] = [];
@ -748,7 +616,7 @@ export class RemoteServiceNamespace implements RemoteServices {
} }
#assertAvailable(serviceId: string, mode: "singleton" | "keyed"): void { #assertAvailable(serviceId: string, mode: "singleton" | "keyed"): void {
if (this.#disposed) throw new Error("Remote service namespace is disposed"); if (this.#disposed) throw new Error("Remote service binding is disposed");
if (!this.#allowlist.has(serviceId)) { if (!this.#allowlist.has(serviceId)) {
throw new RemoteServiceError("service_not_allowed", `Remote service ${serviceId} is not allowlisted`); throw new RemoteServiceError("service_not_allowed", `Remote service ${serviceId} is not allowlisted`);
} }
@ -763,13 +631,17 @@ export class RemoteServiceNamespace implements RemoteServices {
} }
} }
function validateMemberDescriptions( export function createRemoteServiceBinding(options: RemoteServiceBindingOptions): RemoteServiceBinding {
descriptions: readonly ServiceMemberDescription[], return new RemoteServiceBindingImpl(options);
): ReadonlyMap<string, ServiceMemberKind> { }
const result = new Map<string, ServiceMemberKind>();
for (const { name, kind } of descriptions) { function validateMembers(members: readonly ServiceMemberSnapshot[]): ReadonlyMap<string, ServiceMemberSnapshot> {
if (name.length === 0 || result.has(name)) throw new Error("Remote service has invalid member descriptions"); const result = new Map<string, ServiceMemberSnapshot>();
result.set(name, kind); for (const member of members) {
if (member.name.length === 0 || result.has(member.name)) {
throw new Error("Remote service has invalid member descriptions");
}
result.set(member.name, member);
} }
return result; return result;
} }
@ -779,10 +651,6 @@ function sameAddress(left: ServiceInstanceAddress | undefined, right: ServiceIns
return left.key === right.key && left.generation === right.generation; return left.key === right.key && left.generation === right.generation;
} }
function instanceTaskKey(address: ServiceInstanceAddress): string {
return `${address.key}\0${address.generation}`;
}
function isContext(value: unknown): value is Context { function isContext(value: unknown): value is Context {
if (typeof value !== "object" || value === null) return false; if (typeof value !== "object" || value === null) return false;
const candidate = value as Partial<Context>; const candidate = value as Partial<Context>;

View file

@ -0,0 +1,88 @@
import type { Context } from "../context.ts";
import type { JsonValue } from "../json.ts";
import type { MutableReplicatedState, ReplicatedState } from "../state.ts";
const SERVICE_TYPE = Symbol("chord.service.type");
export type ServiceMode = "singleton" | "keyed";
type InvalidJsonPart<T> = [T] extends [JsonValue]
? [JsonValue] extends [T]
? never
: InvalidJsonStructure<T>
: InvalidJsonStructure<T>;
type InvalidJsonStructure<T> = T extends null | boolean | number | string
? never
: T extends readonly (infer TItem)[]
? InvalidJsonPart<TItem>
: T extends (...args: never[]) => unknown
? T
: T extends object
? { [TKey in keyof T]-?: InvalidJsonPart<T[TKey]> }[keyof T]
: T;
type InvalidRemoteMember<T> = T extends ReplicatedState<infer TValue>
? InvalidJsonPart<TValue> extends never
? never
: "state value is not JSON"
: T extends (...args: [...infer TArgs, Context]) => Promise<infer TResult>
? InvalidJsonPart<TArgs[number]> extends never
? TResult extends void
? never
: InvalidJsonPart<TResult> extends never
? never
: "method result is not JSON or void"
: "method argument is not JSON"
: "member is not a remote method or ReplicatedState";
type InvalidRemoteMemberNames<T> = {
[TKey in keyof T]-?: InvalidRemoteMember<T[TKey]> extends never ? never : TKey;
}[keyof T];
export type RemoteServiceContract<T> = InvalidRemoteMemberNames<T> extends never ? T : never;
/** Stable identity for one shared TypeScript service contract. */
export interface Service<T> {
// TODO: check if this should be part of Chord.
readonly id: string;
/** Process-local services accept unrestricted object contracts and are never published remotely. */
readonly local: boolean;
readonly [SERVICE_TYPE]?: (value: T) => T;
}
export function defineService<T>(id: string, options: { readonly local: true }): Service<T>;
export function defineService<T>(
id: string,
...options: [RemoteServiceContract<T>] extends [never]
? readonly [options: never]
: readonly [options?: { readonly local?: false }]
): Service<T>;
export function defineService(id: string, options?: { readonly local?: boolean }): Service<unknown> {
if (id.length === 0) throw new TypeError("Service ID must not be empty");
// TODO: check if this should be part of Chord.
if (id.startsWith("$chord.")) throw new TypeError("Service IDs beginning with $chord. are reserved");
return Object.freeze({ id, local: options?.local ?? false });
}
export interface RemoteServiceInstance<T> {
readonly key: string;
readonly service: T;
}
export interface RemoteServices {
use<T>(service: Service<T>): T;
observe<T>(
service: Service<T>,
handler: (instance: RemoteServiceInstance<T>, context: Context) => void | Promise<void>,
): () => void;
/** Activate this service set and wait for every acquired service to become ready. */
activate(context: Context): Promise<void>;
/** Wait until every currently acquired service has installed its initial snapshot. */
ready(context: Context): Promise<void>;
/** Install a host lifecycle guard for proxy member access. */
setAccessGuard(assertAccess: () => void): void;
dispose(context: Context): Promise<void>;
}
export type { MutableReplicatedState, ReplicatedState };

View file

@ -0,0 +1,19 @@
export type RemoteServiceErrorCode =
| "service_not_allowed"
| "service_not_found"
| "service_mode_mismatch"
| "service_member_not_found"
| "service_member_mismatch"
| "service_instance_not_found"
| "service_stale_instance"
| "service_invalid_value";
export class RemoteServiceError extends Error {
readonly code: RemoteServiceErrorCode;
constructor(code: RemoteServiceErrorCode, message: string) {
super(message);
this.name = "RemoteServiceError";
this.code = code;
}
}

View file

@ -0,0 +1,50 @@
export class ServiceHandle {
readonly #serviceId: string;
readonly #assertAccess: () => void;
readonly #methods = new Map<PropertyKey, unknown>();
readonly proxy: object;
#implementation: object | undefined;
#remote = false;
constructor(serviceId: string, assertAccess: () => void) {
this.#serviceId = serviceId;
this.#assertAccess = assertAccess;
this.proxy = new Proxy(
{},
{
get: (_target, property) => {
this.#assertAccess();
if (this.#implementation === undefined) throw new Error(`Service ${this.#serviceId} is disconnected`);
const value: unknown = Reflect.get(this.#implementation, property, this.#implementation);
if (this.#remote || typeof value !== "function") return value;
let method = this.#methods.get(property);
if (method === undefined) {
method = (...args: unknown[]) => {
this.#assertAccess();
if (this.#implementation === undefined) {
throw new Error(`Service ${this.#serviceId} is disconnected`);
}
const current: unknown = Reflect.get(this.#implementation, property, this.#implementation);
if (typeof current !== "function") {
throw new TypeError(`Service ${this.#serviceId}.${String(property)} is not a method`);
}
return Reflect.apply(current, this.#implementation, args);
};
this.#methods.set(property, method);
}
return method;
},
},
);
}
bind(implementation: object, remote = false): void {
this.#implementation = implementation;
this.#remote = remote;
}
unbind(): void {
this.#implementation = undefined;
this.#remote = false;
}
}

View file

@ -0,0 +1,149 @@
import { BACKGROUND_CONTEXT, type Context, withCancel } from "../context.ts";
import type { RemoteServiceInstance } from "./contracts.ts";
export interface InstanceDirectoryEntry {
readonly key: string;
readonly generation: number;
readonly service: object;
deactivate(): void;
}
interface Observer {
readonly handler: (instance: RemoteServiceInstance<unknown>, context: Context) => void | Promise<void>;
readonly tasks: Map<InstanceDirectoryEntry, { readonly cancel: (reason?: unknown) => void }>;
closed: boolean;
}
/** Owns keyed instance lifetime and the cancellable tasks observing those instances. */
export class InstanceDirectory<TEntry extends InstanceDirectoryEntry> {
readonly #entries = new Map<string, TEntry>();
readonly #observers = new Set<Observer>();
readonly #reportError: (error: Error) => void;
#ready: boolean;
#disposed = false;
constructor(options: { readonly ready: boolean; readonly onError: (error: Error) => void }) {
this.#ready = options.ready;
this.#reportError = options.onError;
}
get observerCount(): number {
return this.#observers.size;
}
get(key: string): TEntry | undefined {
return this.#entries.get(key);
}
insert(entry: TEntry): void {
this.#assertActive();
if (this.#entries.has(entry.key)) {
throw new Error(`Keyed service already has a live instance with key ${entry.key}`);
}
this.#entries.set(entry.key, entry);
if (this.#ready) this.#startAll(entry);
}
replace(entry: TEntry): void {
this.#assertActive();
const previous = this.#entries.get(entry.key);
if (previous !== undefined) {
if (previous.generation === entry.generation) {
throw new Error("Keyed service repeated a live generation");
}
this.#remove(previous);
}
this.#entries.set(entry.key, entry);
if (this.#ready) this.#startAll(entry);
}
remove(entry: TEntry): void {
if (this.#entries.get(entry.key) !== entry) return;
this.#remove(entry);
}
ready(): void {
this.#assertActive();
if (this.#ready) return;
this.#ready = true;
for (const entry of this.#entries.values()) this.#startAll(entry);
}
reset(): void {
if (this.#disposed) return;
this.#ready = false;
for (const entry of [...this.#entries.values()]) this.#remove(entry);
}
observe<T>(handler: (instance: RemoteServiceInstance<T>, context: Context) => void | Promise<void>): () => void {
this.#assertActive();
const observer: Observer = {
handler: handler as Observer["handler"],
tasks: new Map(),
closed: false,
};
this.#observers.add(observer);
if (this.#ready) {
for (const entry of this.#entries.values()) this.#start(observer, entry);
}
return () => {
if (observer.closed) return;
observer.closed = true;
for (const task of observer.tasks.values()) task.cancel();
observer.tasks.clear();
this.#observers.delete(observer);
};
}
dispose(): void {
if (this.#disposed) return;
this.#disposed = true;
for (const observer of this.#observers) {
observer.closed = true;
for (const task of observer.tasks.values()) task.cancel();
observer.tasks.clear();
}
this.#observers.clear();
for (const entry of [...this.#entries.values()]) {
entry.deactivate();
}
this.#entries.clear();
}
#remove(entry: TEntry): void {
if (this.#entries.get(entry.key) !== entry) return;
this.#entries.delete(entry.key);
entry.deactivate();
for (const observer of this.#observers) {
observer.tasks.get(entry)?.cancel();
observer.tasks.delete(entry);
}
}
#startAll(entry: TEntry): void {
for (const observer of this.#observers) this.#start(observer, entry);
}
#start(observer: Observer, entry: TEntry): void {
if (observer.closed || observer.tasks.has(entry)) return;
const { context, cancel } = withCancel(BACKGROUND_CONTEXT);
observer.tasks.set(entry, { cancel });
try {
void Promise.resolve(observer.handler({ key: entry.key, service: entry.service }, context)).catch(
(error: unknown) => {
if (!context.abortSignal?.aborted) this.#reportError(toError(error));
},
);
} catch (error) {
if (!context.abortSignal?.aborted) this.#reportError(toError(error));
}
}
#assertActive(): void {
if (this.#disposed) throw new Error("Keyed service directory is disposed");
}
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}

View file

@ -0,0 +1,73 @@
import type { Context } from "../context.ts";
import type { JsonValue } from "../json.ts";
import type { ServiceMode } from "./contracts.ts";
// TODO: check if this should be part of Chord.
export type ServiceCatalogueEntry = {
readonly serviceId: string;
readonly mode: ServiceMode;
};
export type ServiceInstanceAddress = {
readonly key: string;
readonly generation: number;
};
export type ServiceMemberSnapshot =
| { readonly name: string; readonly kind: "method" }
| { readonly name: string; readonly kind: "state"; readonly sequence: number; readonly value: JsonValue };
export type ServiceInstanceSnapshot = {
readonly instance?: ServiceInstanceAddress;
readonly members: readonly ServiceMemberSnapshot[];
};
export type ServiceSubscriptionSnapshot = {
readonly serviceId: string;
readonly mode: ServiceMode;
readonly instances: readonly ServiceInstanceSnapshot[];
};
export type ServiceProviderUpdate =
| {
readonly type: "state";
readonly instance?: ServiceInstanceAddress;
readonly member: string;
readonly sequence: number;
readonly value: JsonValue;
}
| { readonly type: "unavailable" }
| { readonly type: "replaced"; readonly snapshot: ServiceInstanceSnapshot }
| { readonly type: "spawned"; readonly instance: ServiceInstanceSnapshot }
| { readonly type: "closed"; readonly instance: ServiceInstanceAddress };
export type ServiceCall = {
readonly serviceId: string;
readonly instance?: ServiceInstanceAddress;
readonly member: string;
/** Borrowed immutable values. Chord validates but does not clone them. */
readonly args: readonly JsonValue[];
};
export interface ServiceSubscription {
readonly snapshot: ServiceSubscriptionSnapshot;
activate(): void;
close(context?: Context): void | Promise<void>;
}
/**
* Pluggable wire boundary consumed by a remote service binding.
*
* Implementations choose transport, framing, routing, and envelope encoding. Values crossing this
* 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.
*/
export interface RemoteServiceConnection {
invoke(call: ServiceCall, context: Context): Promise<JsonValue | undefined>;
subscribe(
serviceId: string,
mode: ServiceMode,
listener: (update: ServiceProviderUpdate, context: Context) => void,
context: Context,
): Promise<ServiceSubscription>;
}

View file

@ -1,48 +1,20 @@
import type { Context } from "../../harness/context.ts"; import type { Context } from "../context.ts";
import type { JsonValue } from "../../harness/session/types.ts"; import type { JsonValue } from "../json.ts";
import { import { freshDeliveryContext } from "../state.ts";
freshDeliveryContext, import { getReplicatedStateInternals, type ReplicatedStateInternals } from "../state-internals.ts";
getReplicatedStateInternals, import type { RemoteServiceContract, Service, ServiceMode } from "./contracts.ts";
type ReplicatedStateInternals, import { RemoteServiceError } from "./errors.ts";
} from "./replicated-state.ts"; import type {
import { RemoteServiceConnection,
cloneJson, ServiceCall,
isJsonValue, ServiceCatalogueEntry,
type RemoteServiceConnection, ServiceInstanceAddress,
type RemoteServiceContract, ServiceInstanceSnapshot,
type Service, ServiceMemberSnapshot,
type ServiceCall, ServiceProviderUpdate,
type ServiceCatalogueEntry, ServiceSubscription,
type ServiceInstanceAddress, ServiceSubscriptionSnapshot,
type ServiceInstanceSnapshot, } from "./protocol.ts";
type ServiceMemberDescription,
type ServiceMode,
type ServiceProviderDefinition,
type ServiceProviderSubscription,
type ServiceProviderUpdate,
type ServiceSubscriptionSnapshot,
} from "./types.ts";
export type RemoteServiceErrorCode =
| "service_not_allowed"
| "service_not_found"
| "service_mode_mismatch"
| "service_member_not_found"
| "service_member_mismatch"
| "service_instance_not_found"
| "service_stale_instance"
| "service_invalid_value"
| "service_not_implemented";
export class RemoteServiceError extends Error {
readonly code: RemoteServiceErrorCode;
constructor(code: RemoteServiceErrorCode, message: string) {
super(message);
this.name = "RemoteServiceError";
this.code = code;
}
}
type RemoteMethod = (...args: unknown[]) => unknown; type RemoteMethod = (...args: unknown[]) => unknown;
@ -65,6 +37,11 @@ interface ProviderSubscriber {
closed: boolean; closed: boolean;
} }
interface ServiceProviderDefinition {
readonly service: { readonly id: string; readonly local?: boolean };
readonly mode: ServiceMode;
}
interface ServiceRegistration { interface ServiceRegistration {
readonly serviceId: string; readonly serviceId: string;
readonly mode: ServiceMode; readonly mode: ServiceMode;
@ -75,7 +52,6 @@ interface ServiceRegistration {
} }
export class RemoteServiceProvider { export class RemoteServiceProvider {
readonly #allowlist: ReadonlySet<string>;
readonly #catalogue: readonly ServiceCatalogueEntry[]; readonly #catalogue: readonly ServiceCatalogueEntry[];
readonly #registrations = new Map<string, ServiceRegistration>(); readonly #registrations = new Map<string, ServiceRegistration>();
#disposed = false; #disposed = false;
@ -91,7 +67,6 @@ export class RemoteServiceProvider {
} }
const ids = definitions.map(({ service }) => service.id); const ids = definitions.map(({ service }) => service.id);
if (new Set(ids).size !== ids.length) throw new TypeError("Remote service catalogue contains duplicate IDs"); if (new Set(ids).size !== ids.length) throw new TypeError("Remote service catalogue contains duplicate IDs");
this.#allowlist = new Set(ids);
this.#catalogue = Object.freeze( this.#catalogue = Object.freeze(
definitions.map(({ service, mode }) => Object.freeze({ serviceId: service.id, mode })), definitions.map(({ service, mode }) => Object.freeze({ serviceId: service.id, mode })),
); );
@ -114,7 +89,7 @@ export class RemoteServiceProvider {
this.#assertActive(); this.#assertActive();
this.#assertRemotable(service); this.#assertRemotable(service);
this.#assertAllowed(service.id); this.#assertAllowed(service.id);
const registration = this.#registration(service.id, "singleton", true); const registration = this.#registration(service.id, "singleton");
if (registration.singleton !== undefined) { if (registration.singleton !== undefined) {
throw new RemoteServiceError("service_mode_mismatch", `Remote service ${service.id} already has a provider`); throw new RemoteServiceError("service_mode_mismatch", `Remote service ${service.id} already has a provider`);
} }
@ -127,7 +102,7 @@ export class RemoteServiceProvider {
this.#assertActive(); this.#assertActive();
this.#assertRemotable(service); this.#assertRemotable(service);
this.#assertAllowed(service.id); this.#assertAllowed(service.id);
const registration = this.#registration(service.id, "singleton", false); const registration = this.#registration(service.id, "singleton");
const previous = registration.singleton; const previous = registration.singleton;
if (previous === undefined) return; if (previous === undefined) return;
previous.active = false; previous.active = false;
@ -141,7 +116,7 @@ export class RemoteServiceProvider {
this.#assertActive(); this.#assertActive();
this.#assertRemotable(service); this.#assertRemotable(service);
this.#assertAllowed(service.id); this.#assertAllowed(service.id);
const registration = this.#registration(service.id, "singleton", false); const registration = this.#registration(service.id, "singleton");
const replacement = this.#classifyInstance(registration, implementation, undefined); const replacement = this.#classifyInstance(registration, implementation, undefined);
const previous = registration.singleton; const previous = registration.singleton;
if (previous !== undefined) { if (previous !== undefined) {
@ -168,7 +143,7 @@ export class RemoteServiceProvider {
this.#assertRemotable(service); this.#assertRemotable(service);
this.#assertAllowed(service.id); this.#assertAllowed(service.id);
if (key.length === 0) throw new TypeError("Remote service instance key must not be empty"); if (key.length === 0) throw new TypeError("Remote service instance key must not be empty");
const registration = this.#registration(service.id, "keyed", true); const registration = this.#registration(service.id, "keyed");
if (registration.instances.has(key)) { if (registration.instances.has(key)) {
throw new RemoteServiceError( throw new RemoteServiceError(
"service_mode_mismatch", "service_mode_mismatch",
@ -196,9 +171,6 @@ export class RemoteServiceProvider {
async invoke(call: ServiceCall, context: Context): Promise<JsonValue | undefined> { async invoke(call: ServiceCall, context: Context): Promise<JsonValue | undefined> {
this.#assertActive(); this.#assertActive();
this.#assertAllowed(call.serviceId); this.#assertAllowed(call.serviceId);
if (!call.args.every(isJsonValue)) {
throw new RemoteServiceError("service_invalid_value", "Remote service arguments must be strict JSON");
}
const registration = this.#registrations.get(call.serviceId); const registration = this.#registrations.get(call.serviceId);
if (registration === undefined) { if (registration === undefined) {
throw new RemoteServiceError("service_not_found", `Unknown remote service ${call.serviceId}`); throw new RemoteServiceError("service_not_found", `Unknown remote service ${call.serviceId}`);
@ -217,23 +189,18 @@ export class RemoteServiceProvider {
`Remote service member ${call.serviceId}.${call.member} is not a method`, `Remote service member ${call.serviceId}.${call.member} is not a method`,
); );
} }
const args = call.args.map((value) => cloneJson(value)); const result: unknown = await Reflect.apply(member.method, instance.implementation, [...call.args, context]);
const result: unknown = await Reflect.apply(member.method, instance.implementation, [...args, context]); return result as JsonValue | undefined;
if (result === undefined) return undefined;
if (!isJsonValue(result)) {
throw new RemoteServiceError("service_invalid_value", "Remote service result must be strict JSON or void");
}
return cloneJson(result);
} }
subscribe( subscribe(
serviceId: string, serviceId: string,
mode: ServiceMode, mode: ServiceMode,
listener: (update: ServiceProviderUpdate, context: Context) => void, listener: (update: ServiceProviderUpdate, context: Context) => void,
): ServiceProviderSubscription { ): ServiceSubscription {
this.#assertActive(); this.#assertActive();
this.#assertAllowed(serviceId); this.#assertAllowed(serviceId);
const registration = this.#registration(serviceId, mode, mode === "keyed"); const registration = this.#registration(serviceId, mode);
if (registration.mode === "singleton" && registration.singleton === undefined) { if (registration.mode === "singleton" && registration.singleton === undefined) {
throw new RemoteServiceError("service_not_found", `Remote service ${serviceId} has no provider`); throw new RemoteServiceError("service_not_found", `Remote service ${serviceId} has no provider`);
} }
@ -277,26 +244,17 @@ export class RemoteServiceProvider {
this.#registrations.clear(); this.#registrations.clear();
} }
#registration(serviceId: string, mode: ServiceMode, create: boolean): ServiceRegistration { #registration(serviceId: string, mode: ServiceMode): ServiceRegistration {
const existing = this.#registrations.get(serviceId); const registration = this.#registrations.get(serviceId);
if (existing !== undefined) { if (registration === undefined) {
if (existing.mode !== mode) { throw new RemoteServiceError("service_not_found", `Unknown remote service ${serviceId}`);
throw new RemoteServiceError( }
"service_mode_mismatch", if (registration.mode !== mode) {
`Remote service ${serviceId} is ${existing.mode}, not ${mode}`, throw new RemoteServiceError(
); "service_mode_mismatch",
} `Remote service ${serviceId} is ${registration.mode}, not ${mode}`,
return existing; );
} }
if (!create) throw new RemoteServiceError("service_not_found", `Unknown remote service ${serviceId}`);
const registration: ServiceRegistration = {
serviceId,
mode,
instances: new Map(),
generations: new Map(),
subscribers: new Set(),
};
this.#registrations.set(serviceId, registration);
return registration; return registration;
} }
@ -305,57 +263,34 @@ export class RemoteServiceProvider {
implementation: unknown, implementation: unknown,
address: ServiceInstanceAddress | undefined, address: ServiceInstanceAddress | undefined,
): ProviderInstance { ): ProviderInstance {
if (typeof implementation !== "object" || implementation === null || Array.isArray(implementation)) { const classified = classifyRemoteServiceImplementation(registration.serviceId, implementation);
throw new TypeError(`Remote service ${registration.serviceId} implementation must be an object`);
}
const members = new Map<string, InstanceMember>();
const removeMemberListeners: (() => void)[] = []; const removeMemberListeners: (() => void)[] = [];
const instance: ProviderInstance = { const instance: ProviderInstance = {
...(address === undefined ? {} : { address }), ...(address === undefined ? {} : { address }),
implementation, implementation: classified.implementation,
members, members: classified.members,
removeMemberListeners, removeMemberListeners,
active: true, active: true,
}; };
for (const name of Object.keys(implementation).sort()) { for (const [name, member] of classified.members) {
const descriptor = Object.getOwnPropertyDescriptor(implementation, name); if (member.kind !== "state") continue;
if (descriptor === undefined || !("value" in descriptor)) { removeMemberListeners.push(
throw new TypeError(`Remote service member ${registration.serviceId}.${name} must be a data property`); member.state.subscribe((value, sequence, context) => {
} if (!instance.active) return;
if (typeof descriptor.value === "function") { this.#emit(
members.set(name, { kind: "method", method: descriptor.value as RemoteMethod }); registration,
continue; {
} type: "state",
const state = getReplicatedStateInternals(descriptor.value); ...(address === undefined ? {} : { instance: address }),
if (state !== undefined) { member: name,
members.set(name, { kind: "state", state }); sequence,
removeMemberListeners.push( value: value as JsonValue,
state.subscribe((value, sequence, context) => { },
if (!instance.active) return; context,
if (!isJsonValue(value)) { );
throw new RemoteServiceError( }),
"service_invalid_value", );
"Replicated state update must be strict JSON",
);
}
this.#emit(
registration,
{
type: "state",
...(address === undefined ? {} : { instance: address }),
member: name,
sequence,
value,
},
context,
);
}),
);
continue;
}
throw new TypeError(`Remote service member ${registration.serviceId}.${name} is not remotely exposable`);
} }
if (members.size === 0) throw new TypeError(`Remote service ${registration.serviceId} has no members`);
return instance; return instance;
} }
@ -407,21 +342,22 @@ export class RemoteServiceProvider {
} }
#snapshotInstance(instance: ProviderInstance): ServiceInstanceSnapshot { #snapshotInstance(instance: ProviderInstance): ServiceInstanceSnapshot {
const members: ServiceMemberDescription[] = []; const members: ServiceMemberSnapshot[] = [];
const states: Record<string, { sequence: number; value: JsonValue }> = {};
for (const [name, member] of instance.members) { for (const [name, member] of instance.members) {
members.push({ name, kind: member.kind }); if (member.kind === "method") {
if (member.kind === "state") { members.push({ name, kind: "method" });
if (!isJsonValue(member.state.value)) { } else {
throw new RemoteServiceError("service_invalid_value", "Replicated state snapshot must be strict JSON"); members.push({
} name,
states[name] = { sequence: member.state.sequence, value: member.state.value }; kind: "state",
sequence: member.state.sequence,
value: member.state.value as JsonValue,
});
} }
} }
return { return {
...(instance.address === undefined ? {} : { instance: instance.address }), ...(instance.address === undefined ? {} : { instance: instance.address }),
members, members,
states,
}; };
} }
@ -441,7 +377,7 @@ export class RemoteServiceProvider {
} }
#assertAllowed(serviceId: string): void { #assertAllowed(serviceId: string): void {
if (!this.#allowlist.has(serviceId)) { if (!this.#registrations.has(serviceId)) {
throw new RemoteServiceError("service_not_allowed", `Remote service ${serviceId} is not allowlisted`); throw new RemoteServiceError("service_not_allowed", `Remote service ${serviceId} is not allowlisted`);
} }
} }
@ -451,6 +387,38 @@ export class RemoteServiceProvider {
} }
} }
export function validateRemoteServiceImplementation(serviceId: string, implementation: unknown): void {
void classifyRemoteServiceImplementation(serviceId, implementation);
}
function classifyRemoteServiceImplementation(
serviceId: string,
implementation: unknown,
): { readonly implementation: object; readonly members: Map<string, InstanceMember> } {
if (typeof implementation !== "object" || implementation === null || Array.isArray(implementation)) {
throw new TypeError(`Remote service ${serviceId} implementation must be an object`);
}
const members = new Map<string, InstanceMember>();
for (const name of Object.keys(implementation).sort()) {
const descriptor = Object.getOwnPropertyDescriptor(implementation, name);
if (descriptor === undefined || !("value" in descriptor)) {
throw new TypeError(`Remote service member ${serviceId}.${name} must be a data property`);
}
if (typeof descriptor.value === "function") {
members.set(name, { kind: "method", method: descriptor.value as RemoteMethod });
continue;
}
const state = getReplicatedStateInternals(descriptor.value);
if (state !== undefined) {
members.set(name, { kind: "state", state });
continue;
}
throw new TypeError(`Remote service member ${serviceId}.${name} is not remotely exposable`);
}
if (members.size === 0) throw new TypeError(`Remote service ${serviceId} has no members`);
return { implementation, members };
}
export function createLoopbackServiceConnection(provider: RemoteServiceProvider): RemoteServiceConnection { export function createLoopbackServiceConnection(provider: RemoteServiceProvider): RemoteServiceConnection {
return { return {
invoke: (call, context) => provider.invoke(call, context), invoke: (call, context) => provider.invoke(call, context),

View file

@ -0,0 +1,18 @@
import type { Context } from "./context.ts";
export interface ReplicatedStateInternals {
readonly sequence: number;
readonly value: unknown;
subscribe(listener: (value: unknown, sequence: number, context: Context) => void): () => void;
}
const sources = new WeakMap<object, ReplicatedStateInternals>();
export function registerReplicatedStateInternals(value: object, internals: ReplicatedStateInternals): void {
sources.set(value, internals);
}
export function getReplicatedStateInternals(value: unknown): ReplicatedStateInternals | undefined {
if (typeof value !== "object" || value === null) return undefined;
return sources.get(value);
}

127
packages/chord/src/state.ts Normal file
View file

@ -0,0 +1,127 @@
import { BACKGROUND_CONTEXT, type Context, withCancel } from "./context.ts";
import type { JsonValue } from "./json.ts";
import { registerReplicatedStateInternals } from "./state-internals.ts";
export interface ReplicatedState<T> {
/** Borrowed immutable value, or undefined until hydration. Do not mutate or retain it. */
readonly value: T | undefined;
/** Listener values are borrowed and must not be mutated or retained. */
subscribe(listener: (value: T, context: Context) => void): () => void;
}
export interface MutableReplicatedState<T> extends ReplicatedState<T> {
readonly value: T;
/** Transfers the JSON value to the state; the caller must not subsequently mutate it. */
set(value: T, context: Context): void;
}
class MutableReplicatedStateImpl<T> implements MutableReplicatedState<T> {
readonly #listeners = new Set<(value: T, context: Context) => void>();
readonly #sourceListeners = new Set<(value: T, sequence: number, context: Context) => void>();
#value: T;
#sequence = 0;
constructor(initial: T) {
this.#value = initial;
const thisSource = this;
registerReplicatedStateInternals(this, {
get sequence() {
return thisSource.#sequence;
},
get value() {
return thisSource.#value;
},
subscribe: (listener) => {
const sourceListener = (value: T, sequence: number, context: Context): void => {
listener(value, sequence, context);
};
thisSource.#sourceListeners.add(sourceListener);
return () => thisSource.#sourceListeners.delete(sourceListener);
},
});
}
get value(): T {
return this.#value;
}
set(value: T, context: Context): void {
this.#value = value;
this.#sequence += 1;
for (const listener of this.#sourceListeners) listener(value, this.#sequence, context);
for (const listener of this.#listeners) listener(value, context);
}
subscribe(listener: (value: T, context: Context) => void): () => void {
this.#listeners.add(listener);
listener(this.value, freshDeliveryContext());
return () => this.#listeners.delete(listener);
}
}
/** A cold read-only state used by service consumers until a complete snapshot arrives. */
export class ReplicatedStateReplica<T extends JsonValue = JsonValue> implements ReplicatedState<T> {
readonly #listeners = new Set<(value: T, context: Context) => void>();
readonly #reportError: (error: Error) => void;
#value: T | undefined;
#sequence: number | undefined;
constructor(reportError: (error: Error) => void) {
this.#reportError = reportError;
}
get value(): T | undefined {
return this.#value;
}
subscribe(listener: (value: T, context: Context) => void): () => void {
this.#listeners.add(listener);
if (this.#value !== undefined) this.#deliver(listener, this.#value, freshDeliveryContext());
return () => this.#listeners.delete(listener);
}
hydrate(sequence: number, value: T, context: Context): void {
this.#sequence = sequence;
this.#value = value;
this.#deliverAll(context);
}
update(sequence: number, value: T, context: Context): void {
if (this.#sequence === undefined) throw new Error("Replicated state received an update before hydration");
if (sequence <= this.#sequence) return;
if (sequence !== this.#sequence + 1) throw new Error("Replicated state update sequence has a gap");
this.#sequence = sequence;
this.#value = value;
this.#deliverAll(context);
}
clear(): void {
this.#value = undefined;
this.#sequence = undefined;
}
#deliverAll(context: Context): void {
if (this.#value === undefined) return;
for (const listener of this.#listeners) this.#deliver(listener, this.#value, context);
}
#deliver(listener: (value: T, context: Context) => void, value: T, context: Context): void {
try {
listener(value, context);
} catch (error) {
this.#reportError(toError(error));
}
}
}
export function replicatedState<T>(initial: T): MutableReplicatedState<T> {
return new MutableReplicatedStateImpl(initial);
}
export function freshDeliveryContext(): Context {
return withCancel(BACKGROUND_CONTEXT).context;
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}

View file

@ -0,0 +1,34 @@
import { readdir, readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, test } from "vitest";
const packageDirectory = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const sourceDirectory = resolve(packageDirectory, "src");
const IMPORT_SPECIFIER = /(?:import|export)\s+(?:type\s+)?(?:[^;]*?\sfrom\s*)?["']([^"']+)["']/gu;
describe("package boundary", () => {
test("does not depend on Pi packages or files outside Chord", async () => {
const manifest = JSON.parse(await readFile(resolve(packageDirectory, "package.json"), "utf8")) as {
dependencies?: Record<string, string>;
};
expect(Object.keys(manifest.dependencies ?? {}).filter((name) => name.startsWith("@earendil-works/pi-"))).toEqual(
[],
);
const paths = (await readdir(sourceDirectory, { recursive: true })).filter((path) => path.endsWith(".ts")).sort();
const violations: string[] = [];
for (const path of paths) {
const file = resolve(sourceDirectory, path);
const source = await readFile(file, "utf8");
for (const match of source.matchAll(IMPORT_SPECIFIER)) {
const specifier = match[1]!;
if (specifier.startsWith("@earendil-works/pi-")) violations.push(`${path}: ${specifier}`);
if (specifier.startsWith(".") && !resolve(dirname(file), specifier).startsWith(`${sourceDirectory}/`)) {
violations.push(`${path}: ${specifier}`);
}
}
}
expect(violations).toEqual([]);
});
});

View file

@ -1,13 +1,16 @@
import { describe, expect, test, vi } from "vitest";
import { import {
BACKGROUND_CONTEXT, BACKGROUND_CONTEXT,
type Context, type Context,
combineFacetLoaders,
createFacetHost,
createLoopbackServiceConnection, createLoopbackServiceConnection,
createRemoteServiceBinding,
createStaticFacetLoader,
defineFacet,
defineService, defineService,
RemoteServiceNamespace, type FacetLoader,
} from "@earendil-works/pi-agent-core"; } from "../src/index.ts";
import { describe, expect, test, vi } from "vitest";
import { combineFacetLoaders, createStaticFacetLoader, type FacetLoader } from "../src/experimental/facet-loader.ts";
import { createFacetHost, defineFacet } from "../src/experimental/facets.ts";
interface GenerationValue { interface GenerationValue {
read(context: Context): Promise<string>; read(context: Context): Promise<string>;
@ -21,7 +24,7 @@ const RemoteGenerationValue = defineService<GenerationValue>("test.experimental.
const firstFacet = defineFacet({ id: "first", setup() {} }); const firstFacet = defineFacet({ id: "first", setup() {} });
const secondFacet = defineFacet({ id: "second", setup() {} }); const secondFacet = defineFacet({ id: "second", setup() {} });
describe("experimental facet loader", () => { describe("facet loader", () => {
test("combines loaded facets in loader order and disposes generations in reverse", async () => { test("combines loaded facets in loader order and disposes generations in reverse", async () => {
const trace: string[] = []; const trace: string[] = [];
const first: FacetLoader = { const first: FacetLoader = {
@ -138,7 +141,7 @@ describe("experimental facet loader", () => {
const host = await createFacetHost({ facets: [consumer, ...loadedA.facets] }); const host = await createFacetHost({ facets: [consumer, ...loadedA.facets] });
const originalLocalHandle = localValue!; const originalLocalHandle = localValue!;
const localRead = originalLocalHandle.read; const localRead = originalLocalHandle.read;
const remoteServices = new RemoteServiceNamespace({ const remoteServices = createRemoteServiceBinding({
services: [RemoteGenerationValue], services: [RemoteGenerationValue],
connection: createLoopbackServiceConnection(host.services), connection: createLoopbackServiceConnection(host.services),
}); });

View file

@ -1,15 +1,17 @@
import { describe, expect, test, vi } from "vitest";
import { import {
BACKGROUND_CONTEXT, BACKGROUND_CONTEXT,
type Context, type Context,
createFacetHost,
createLoopbackServiceConnection, createLoopbackServiceConnection,
createRemoteServiceBinding,
defineFacet,
defineService, defineService,
type FacetConnection,
type MutableReplicatedState, type MutableReplicatedState,
RemoteServiceNamespace,
RemoteServiceProvider, RemoteServiceProvider,
type ReplicatedState, type ReplicatedState,
} from "@earendil-works/pi-agent-core"; } from "../src/index.ts";
import { describe, expect, test, vi } from "vitest";
import { createFacetHost, defineFacet, type FacetConnection } from "../src/experimental/facets.ts";
interface Source { interface Source {
read(context: Context): Promise<string>; read(context: Context): Promise<string>;
@ -59,7 +61,7 @@ const LeftValue = defineService<LeftValue>("test.experimental.left-value");
const RightValue = defineService<RightValue>("test.experimental.right-value"); const RightValue = defineService<RightValue>("test.experimental.right-value");
const CombinedValue = defineService<CombinedValue>("test.experimental.combined-value"); const CombinedValue = defineService<CombinedValue>("test.experimental.combined-value");
describe("experimental facet host", () => { describe("facet host", () => {
test("discovers setup dependencies before connecting stable service handles", async () => { test("discovers setup dependencies before connecting stable service handles", async () => {
const trace: string[] = []; const trace: string[] = [];
let sourceHandle: Source | undefined; let sourceHandle: Source | undefined;
@ -140,7 +142,7 @@ describe("experimental facet host", () => {
await vi.waitFor(() => expect(trace).toContain("observe one")); await vi.waitFor(() => expect(trace).toContain("observe one"));
expect(trace).toEqual(["activate provider", "activate observer", "observe one"]); expect(trace).toEqual(["activate provider", "activate observer", "observe one"]);
const remoteServices = new RemoteServiceNamespace({ const remoteServices = createRemoteServiceBinding({
services: [KeyedValue], services: [KeyedValue],
connection: createLoopbackServiceConnection(host.services), connection: createLoopbackServiceConnection(host.services),
}); });
@ -268,12 +270,12 @@ describe("experimental facet host", () => {
return "right"; return "right";
}, },
}); });
const leftNamespace = new RemoteServiceNamespace({ const leftNamespace = createRemoteServiceBinding({
services: [LeftValue], services: [LeftValue],
connection: createLoopbackServiceConnection(leftProvider), connection: createLoopbackServiceConnection(leftProvider),
bound: false, bound: false,
}); });
const rightNamespace = new RemoteServiceNamespace({ const rightNamespace = createRemoteServiceBinding({
services: [RightValue], services: [RightValue],
connection: createLoopbackServiceConnection(rightProvider), connection: createLoopbackServiceConnection(rightProvider),
bound: false, bound: false,
@ -362,7 +364,7 @@ describe("experimental facet host", () => {
}, },
open(options) { open(options) {
opened += 1; opened += 1;
const namespace = new RemoteServiceNamespace({ const namespace = createRemoteServiceBinding({
services: options.services, services: options.services,
connection: createLoopbackServiceConnection(currentProvider), connection: createLoopbackServiceConnection(currentProvider),
bound: false, bound: false,

View file

@ -1,15 +1,16 @@
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import type { Context } from "../../src/harness/context.ts";
import { import {
BACKGROUND_CONTEXT, BACKGROUND_CONTEXT,
type Context,
createLoopbackServiceConnection, createLoopbackServiceConnection,
createRemoteServiceBinding,
defineService, defineService,
type JsonValue,
type RemoteServiceConnection, type RemoteServiceConnection,
RemoteServiceNamespace,
RemoteServiceProvider, RemoteServiceProvider,
type ReplicatedState, type ReplicatedState,
replicatedState, replicatedState,
} from "../../src/index.ts"; } from "../src/index.ts";
type ModelRef = { provider: string; modelId: string }; type ModelRef = { provider: string; modelId: string };
type ModelsState = { type ModelsState = {
@ -31,11 +32,51 @@ interface QuestionDialogs {
const QuestionDialogs = defineService<QuestionDialogs>("test.question-dialog"); const QuestionDialogs = defineService<QuestionDialogs>("test.question-dialog");
describe("plugin remote services", () => { type EchoPayload = { value: string };
test("marks services remotable by default and permits explicit local services", () => { interface Echo {
echo(payload: EchoPayload, context: Context): Promise<EchoPayload>;
}
const Echo = defineService<Echo>("test.echo");
interface JsonPassthrough {
call(value: JsonValue, context: Context): Promise<JsonValue>;
}
interface NonJsonArgument {
call(value: Date, context: Context): Promise<void>;
}
interface NonJsonResult {
call(context: Context): Promise<bigint>;
}
interface NonJsonState {
readonly state: ReplicatedState<{ value: undefined }>;
}
describe("remote services", () => {
test("checks remote JSON contracts only at compile time", () => {
expect(defineService<JsonPassthrough>("test.json-passthrough").local).toBe(false);
const defineInvalidContracts = (): void => {
// @ts-expect-error Remote service arguments must be JSON-compatible.
defineService<NonJsonArgument>("test.non-json-argument");
// @ts-expect-error Remote service results must be JSON-compatible.
defineService<NonJsonResult>("test.non-json-result");
// @ts-expect-error Replicated state must be JSON-compatible.
defineService<NonJsonState>("test.non-json-state");
};
expect(defineInvalidContracts).not.toThrow();
expect(defineService<NonJsonArgument>("test.local-non-json", { local: true }).local).toBe(true);
});
test("marks services remotable by default and reserves Chord service IDs", () => {
const local = defineService<{ readonly value: string }>("test.local", { local: true }); const local = defineService<{ readonly value: string }>("test.local", { local: true });
expect(Models.local).toBe(false); expect(Models.local).toBe(false);
expect(local.local).toBe(true); expect(local.local).toBe(true);
expect(() => defineService("$chord.internal", { local: true })).toThrow(
"Service IDs beginning with $chord. are reserved",
);
expect(() => new RemoteServiceProvider([local])).toThrow("cannot be published remotely"); expect(() => new RemoteServiceProvider([local])).toThrow("cannot be published remotely");
}); });
@ -56,6 +97,31 @@ describe("plugin remote services", () => {
unsubscribe(); unsubscribe();
}); });
test("does not defensively clone method arguments or results", async () => {
const provider = new RemoteServiceProvider([Echo]);
let received: EchoPayload | undefined;
const response: EchoPayload = { value: "response" };
provider.provide(Echo, {
async echo(payload) {
received = payload;
return response;
},
});
const namespace = createRemoteServiceBinding({
services: [Echo],
connection: createLoopbackServiceConnection(provider),
});
const echo = namespace.use(Echo);
await namespace.ready(BACKGROUND_CONTEXT);
const request: EchoPayload = { value: "request" };
await expect(echo.echo(request, BACKGROUND_CONTEXT)).resolves.toBe(response);
expect(received).toBe(request);
await namespace.dispose(BACKGROUND_CONTEXT);
provider.dispose();
});
test("provides and consumes one singleton with replicated state", async () => { test("provides and consumes one singleton with replicated state", async () => {
const provider = new RemoteServiceProvider([Models]); const provider = new RemoteServiceProvider([Models]);
expect(provider.catalogue).toEqual([{ serviceId: Models.id, mode: "singleton" }]); expect(provider.catalogue).toEqual([{ serviceId: Models.id, mode: "singleton" }]);
@ -70,7 +136,7 @@ describe("plugin remote services", () => {
}, },
}); });
const errors: Error[] = []; const errors: Error[] = [];
const namespace = new RemoteServiceNamespace({ const namespace = createRemoteServiceBinding({
services: [Models], services: [Models],
connection: createLoopbackServiceConnection(provider), connection: createLoopbackServiceConnection(provider),
onError: (error) => errors.push(error), onError: (error) => errors.push(error),
@ -97,7 +163,7 @@ describe("plugin remote services", () => {
]); ]);
expect(errors).toEqual([]); expect(errors).toEqual([]);
const lateNamespace = new RemoteServiceNamespace({ const lateNamespace = createRemoteServiceBinding({
services: [Models], services: [Models],
connection: createLoopbackServiceConnection(provider), connection: createLoopbackServiceConnection(provider),
}); });
@ -116,7 +182,7 @@ describe("plugin remote services", () => {
state: replicatedState<ModelsState>({ selected: null, revision: 1 }), state: replicatedState<ModelsState>({ selected: null, revision: 1 }),
async select() {}, async select() {},
}); });
const namespace = new RemoteServiceNamespace({ const namespace = createRemoteServiceBinding({
services: [Models], services: [Models],
connection: createLoopbackServiceConnection(provider), connection: createLoopbackServiceConnection(provider),
}); });
@ -155,7 +221,7 @@ describe("plugin remote services", () => {
async select() {}, async select() {},
}); });
let active = false; let active = false;
const namespace = new RemoteServiceNamespace({ const namespace = createRemoteServiceBinding({
services: [Models], services: [Models],
connection: createLoopbackServiceConnection(provider), connection: createLoopbackServiceConnection(provider),
bound: false, bound: false,
@ -183,7 +249,7 @@ describe("plugin remote services", () => {
test("rejects namespace readiness when initial hydration fails", async () => { test("rejects namespace readiness when initial hydration fails", async () => {
const failure = new Error("initial hydration failed"); const failure = new Error("initial hydration failed");
const errors: Error[] = []; const errors: Error[] = [];
const namespace = new RemoteServiceNamespace({ const namespace = createRemoteServiceBinding({
services: [Models], services: [Models],
connection: { connection: {
invoke: () => Promise.reject(new Error("unexpected invocation")), invoke: () => Promise.reject(new Error("unexpected invocation")),
@ -218,7 +284,7 @@ describe("plugin remote services", () => {
}; };
}, },
}; };
const namespace = new RemoteServiceNamespace({ services: [Models], connection }); const namespace = createRemoteServiceBinding({ services: [Models], connection });
const models = namespace.use(Models); const models = namespace.use(Models);
const revisions: number[] = []; const revisions: number[] = [];
models.state.subscribe((value) => revisions.push(value.revision)); models.state.subscribe((value) => revisions.push(value.revision));
@ -236,7 +302,7 @@ describe("plugin remote services", () => {
state, state,
async select() {}, async select() {},
}); });
const namespace = new RemoteServiceNamespace({ const namespace = createRemoteServiceBinding({
services: [Models], services: [Models],
connection: createLoopbackServiceConnection(provider), connection: createLoopbackServiceConnection(provider),
bound: false, bound: false,
@ -271,7 +337,7 @@ describe("plugin remote services", () => {
expect(provider.catalogue).toEqual([{ serviceId: QuestionDialogs.id, mode: "keyed" }]); expect(provider.catalogue).toEqual([{ serviceId: QuestionDialogs.id, mode: "keyed" }]);
const connection = createLoopbackServiceConnection(provider); const connection = createLoopbackServiceConnection(provider);
const errors: Error[] = []; const errors: Error[] = [];
const namespace = new RemoteServiceNamespace({ const namespace = createRemoteServiceBinding({
services: [QuestionDialogs], services: [QuestionDialogs],
connection, connection,
onError: (error) => errors.push(error), onError: (error) => errors.push(error),
@ -331,7 +397,7 @@ describe("plugin remote services", () => {
provider.dispose(); provider.dispose();
}); });
test("rejects mode mixing, unsupported members, and non-JSON values", async () => { test("rejects mode mixing and unsupported members", () => {
const provider = new RemoteServiceProvider([Models, { service: QuestionDialogs, mode: "keyed" }]); const provider = new RemoteServiceProvider([Models, { service: QuestionDialogs, mode: "keyed" }]);
provider.provide(Models, { provider.provide(Models, {
state: replicatedState<ModelsState>({ selected: null, revision: 0 }), state: replicatedState<ModelsState>({ selected: null, revision: 0 }),
@ -346,16 +412,6 @@ describe("plugin remote services", () => {
}, },
}), }),
).toThrow(/not remotely exposable/); ).toThrow(/not remotely exposable/);
await expect(
provider.invoke(
{
serviceId: "test.models",
member: "select",
args: [Number.NaN as unknown as number],
},
BACKGROUND_CONTEXT,
),
).rejects.toMatchObject({ code: "service_invalid_value" });
provider.dispose(); provider.dispose();
}); });
}); });

View file

@ -371,7 +371,7 @@ describe("Client service operations", () => {
type: "request", type: "request",
target, target,
call: { call: {
serviceId: "$pi.service", serviceId: "$chord.service",
member: "subscribe", member: "subscribe",
args: ["service-1", "pi.models", "singleton"], args: ["service-1", "pi.models", "singleton"],
}, },
@ -392,22 +392,23 @@ describe("Client service operations", () => {
mode: "singleton", mode: "singleton",
instances: [ instances: [
{ {
members: [{ name: "state", kind: "state" }], members: [{ name: "state", kind: "state", sequence: 0, value: { revision: 0 } }],
states: { state: { sequence: 0, value: { revision: 0 } } },
}, },
], ],
}, },
}); });
const subscription = await opening; const subscription = await opening;
expect(updates).toEqual([]); expect(updates).toEqual([]);
expect(subscription.snapshot.instances[0]?.states.state?.value).toEqual({ revision: 0 }); expect(subscription.snapshot.instances[0]?.members).toEqual([
{ name: "state", kind: "state", sequence: 0, value: { revision: 0 } },
]);
subscription.start(); subscription.start();
await vi.waitFor(() => expect(updates).toEqual(["state"])); await vi.waitFor(() => expect(updates).toEqual(["state"]));
const disposing = subscription.dispose(); const disposing = subscription.dispose();
await server.waitForMessages(4); await server.waitForMessages(4);
expect(server.messages[3]).toMatchObject({ expect(server.messages[3]).toMatchObject({
call: { serviceId: "$pi.service", member: "unsubscribe", args: ["service-1"] }, call: { serviceId: "$chord.service", member: "unsubscribe", args: ["service-1"] },
}); });
server.send({ type: "response", id: "request-3", ok: true }); server.send({ type: "response", id: "request-3", ok: true });
await disposing; await disposing;

View file

@ -480,6 +480,7 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "0.91.1", "@anthropic-ai/sdk": "0.91.1",
"@earendil-works/chord": "^0.84.4",
"@aws-sdk/client-bedrock-runtime": "3.1048.0", "@aws-sdk/client-bedrock-runtime": "3.1048.0",
"@earendil-works/pi-telemetry": "^0.84.4", "@earendil-works/pi-telemetry": "^0.84.4",
"@google/genai": "1.52.0", "@google/genai": "1.52.0",
@ -513,6 +514,7 @@
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.84.4.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.84.4.tgz",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/chord": "^0.84.4",
"@earendil-works/pi-agent-core": "^0.84.4", "@earendil-works/pi-agent-core": "^0.84.4",
"@earendil-works/pi-ai": "^0.84.4", "@earendil-works/pi-ai": "^0.84.4",
"@earendil-works/pi-client": "^0.84.4", "@earendil-works/pi-client": "^0.84.4",
@ -549,6 +551,7 @@
"resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.4.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.4.tgz",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/chord": "^0.84.4",
"typebox": "1.3.7" "typebox": "1.3.7"
}, },
"engines": { "engines": {

View file

@ -9,6 +9,7 @@
"version": "0.84.4", "version": "0.84.4",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/chord": "^0.84.4",
"@earendil-works/pi-agent-core": "^0.84.4", "@earendil-works/pi-agent-core": "^0.84.4",
"@earendil-works/pi-ai": "^0.84.4", "@earendil-works/pi-ai": "^0.84.4",
"@earendil-works/pi-client": "^0.84.4", "@earendil-works/pi-client": "^0.84.4",
@ -506,6 +507,7 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "0.91.1", "@anthropic-ai/sdk": "0.91.1",
"@earendil-works/chord": "^0.84.4",
"@aws-sdk/client-bedrock-runtime": "3.1048.0", "@aws-sdk/client-bedrock-runtime": "3.1048.0",
"@earendil-works/pi-telemetry": "^0.84.4", "@earendil-works/pi-telemetry": "^0.84.4",
"@google/genai": "1.52.0", "@google/genai": "1.52.0",
@ -539,6 +541,7 @@
"resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.4.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.4.tgz",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/chord": "^0.84.4",
"typebox": "1.3.7" "typebox": "1.3.7"
}, },
"engines": { "engines": {

View file

@ -44,6 +44,7 @@
"prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap"
}, },
"dependencies": { "dependencies": {
"@earendil-works/chord": "^0.84.4",
"@earendil-works/pi-agent-core": "^0.84.4", "@earendil-works/pi-agent-core": "^0.84.4",
"@earendil-works/pi-ai": "^0.84.4", "@earendil-works/pi-ai": "^0.84.4",
"@earendil-works/pi-client": "^0.84.4", "@earendil-works/pi-client": "^0.84.4",

View file

@ -1,5 +1,5 @@
import { basename } from "node:path"; import { basename } from "node:path";
import { BACKGROUND_CONTEXT } from "@earendil-works/pi-agent-core"; import { BACKGROUND_CONTEXT } from "@earendil-works/chord";
import { Client } from "@earendil-works/pi-client"; import { Client } from "@earendil-works/pi-client";
import { createUnixTransportFactory, discoverUnixServers, type UnixServerRoute } from "@earendil-works/pi-client/unix"; import { createUnixTransportFactory, discoverUnixServers, type UnixServerRoute } from "@earendil-works/pi-client/unix";
import { isServerId, type ServerId } from "@earendil-works/pi-protocol"; import { isServerId, type ServerId } from "@earendil-works/pi-protocol";

View file

@ -1,4 +1,13 @@
import { BACKGROUND_CONTEXT } from "@earendil-works/pi-agent-core"; import {
BACKGROUND_CONTEXT,
combineFacetLoaders,
createFacetHost,
createStaticFacetLoader,
defineFacet,
type FacetHost,
type FacetLoader,
type LoadedFacets,
} from "@earendil-works/chord";
import type { SessionSummary } from "@earendil-works/pi-protocol"; import type { SessionSummary } from "@earendil-works/pi-protocol";
import { import {
CombinedAutocompleteProvider, CombinedAutocompleteProvider,
@ -22,8 +31,6 @@ import { InteractiveThemeController } from "../modes/interactive/theme/theme-con
import { createInteractiveTui } from "../modes/interactive/tui-renderer.ts"; import { createInteractiveTui } from "../modes/interactive/tui-renderer.ts";
import { type OpenClientRuntimeOptions, openClientRuntime } from "./client-runtime.ts"; import { type OpenClientRuntimeOptions, openClientRuntime } from "./client-runtime.ts";
import { ExperimentalChatView } from "./client-tui-chat.ts"; import { ExperimentalChatView } from "./client-tui-chat.ts";
import { combineFacetLoaders, createStaticFacetLoader, type FacetLoader, type LoadedFacets } from "./facet-loader.ts";
import { createFacetHost, defineFacet, type FacetHost } from "./facets.ts";
import { type LaneReplica, type LaneWatchSource, openLaneReplica } from "./lane-replica.ts"; import { type LaneReplica, type LaneWatchSource, openLaneReplica } from "./lane-replica.ts";
import helloPluginFacet from "./plugins/hello.ts"; import helloPluginFacet from "./plugins/hello.ts";
import { AgentController, type AgentOperationResponse, type AgentQueueResponse } from "./services/agent-controller.ts"; import { AgentController, type AgentOperationResponse, type AgentQueueResponse } from "./services/agent-controller.ts";

View file

@ -22,21 +22,19 @@ import type {
Usage, Usage,
UserMessage, UserMessage,
} from "@earendil-works/pi-ai"; } from "@earendil-works/pi-ai";
import { import type {
type AssistantMessageFrame, AssistantMessageFrame,
type JsonValue, JsonValue,
JsonValueSchema, LaneEntry,
type LaneEntry, LaneEvent,
type LaneEvent, LaneSnapshot,
type LaneSnapshot, OperationResultRecord,
type OperationResultRecord, PromptArguments,
type PromptArguments, PromptImage,
type PromptImage, PromptMessage,
type PromptMessage, RunResult,
type RunResult, ToolOutput,
type ToolOutput,
} from "@earendil-works/pi-protocol"; } from "@earendil-works/pi-protocol";
import { Check } from "typebox/value";
type HarnessPromptArguments = type HarnessPromptArguments =
| [text: string] | [text: string]
@ -272,9 +270,7 @@ function toWireOperationResult(value: NonNullable<HarnessLaneSnapshot["lastResul
error: { error: {
code: value.error.code, code: value.error.code,
message: value.error.message, message: value.error.message,
...(value.error.details === undefined ...(value.error.details === undefined ? {} : { details: toWireJsonValue(value.error.details) }),
? {}
: { details: toWireJsonValue(value.error.details, "operation error details") }),
}, },
}; };
} }
@ -326,10 +322,7 @@ function toWireAssistantMessage(message: AssistantMessage): WireAssistantMessage
id: content.id, id: content.id,
name: content.name, name: content.name,
arguments: Object.fromEntries( arguments: Object.fromEntries(
Object.entries(content.arguments).map(([key, value]) => [ Object.entries(content.arguments).map(([key, value]) => [key, toWireJsonValue(value)]),
key,
toWireJsonValue(value, `tool argument ${key}`),
]),
), ),
...(content.thoughtSignature === undefined ? {} : { thoughtSignature: content.thoughtSignature }), ...(content.thoughtSignature === undefined ? {} : { thoughtSignature: content.thoughtSignature }),
...(content.namespace === undefined ? {} : { namespace: content.namespace }), ...(content.namespace === undefined ? {} : { namespace: content.namespace }),
@ -363,10 +356,7 @@ function toWireAssistantMessage(message: AssistantMessage): WireAssistantMessage
? {} ? {}
: { : {
details: Object.fromEntries( details: Object.fromEntries(
Object.entries(diagnostic.details).map(([key, value]) => [ Object.entries(diagnostic.details).map(([key, value]) => [key, toWireJsonValue(value)]),
key,
toWireJsonValue(value, `diagnostic detail ${key}`),
]),
), ),
}), }),
})), })),
@ -389,13 +379,12 @@ function toWireDeferred(deferred: DeferredHandle): WireAssistantMessage["deferre
id: deferred.id, id: deferred.id,
...(deferred.expiresAt === undefined ? {} : { expiresAt: deferred.expiresAt }), ...(deferred.expiresAt === undefined ? {} : { expiresAt: deferred.expiresAt }),
...(deferred.pollAfterMs === undefined ? {} : { pollAfterMs: deferred.pollAfterMs }), ...(deferred.pollAfterMs === undefined ? {} : { pollAfterMs: deferred.pollAfterMs }),
...(deferred.data === undefined ? {} : { data: toWireJsonValue(deferred.data, "deferred data") }), ...(deferred.data === undefined ? {} : { data: toWireJsonValue(deferred.data) }),
}; };
} }
function toWireJsonValue(value: unknown, field: string): JsonValue { function toWireJsonValue(value: unknown): JsonValue {
if (!Check(JsonValueSchema, value)) throw new TypeError(`Harness ${field} is not JSON-serializable`); return value as JsonValue;
return value;
} }
function toWireMessage(message: AgentMessage): PromptMessage { function toWireMessage(message: AgentMessage): PromptMessage {
@ -435,9 +424,7 @@ function toWireMessage(message: AgentMessage): PromptMessage {
} }
: { type: "image" as const, data: content.data, mimeType: content.mimeType }, : { type: "image" as const, data: content.data, mimeType: content.mimeType },
), ),
...(message.details === undefined ...(message.details === undefined ? {} : { details: toWireJsonValue(message.details) }),
? {}
: { details: toWireJsonValue(message.details, "tool result details") }),
...(message.usage === undefined ? {} : { usage: toHarnessUsage(message.usage) }), ...(message.usage === undefined ? {} : { usage: toHarnessUsage(message.usage) }),
...(message.addedToolNames === undefined ? {} : { addedToolNames: [...message.addedToolNames] }), ...(message.addedToolNames === undefined ? {} : { addedToolNames: [...message.addedToolNames] }),
isError: message.isError, isError: message.isError,
@ -474,9 +461,7 @@ function toWireMessage(message: AgentMessage): PromptMessage {
: { type: "image" as const, data: content.data, mimeType: content.mimeType }, : { type: "image" as const, data: content.data, mimeType: content.mimeType },
), ),
display: message.display, display: message.display,
...(message.details === undefined ...(message.details === undefined ? {} : { details: toWireJsonValue(message.details) }),
? {}
: { details: toWireJsonValue(message.details, "custom message details") }),
timestamp: message.timestamp, timestamp: message.timestamp,
}; };
case "branchSummary": case "branchSummary":
@ -552,7 +537,7 @@ function toWireToolOutput(result: AgentToolResult<unknown>): ToolOutput {
} }
: { type: "image" as const, data: content.data, mimeType: content.mimeType }, : { type: "image" as const, data: content.data, mimeType: content.mimeType },
), ),
...(result.details === undefined ? {} : { details: toWireJsonValue(result.details, "tool output details") }), ...(result.details === undefined ? {} : { details: toWireJsonValue(result.details) }),
...(result.usage === undefined ? {} : { usage: toHarnessUsage(result.usage) }), ...(result.usage === undefined ? {} : { usage: toHarnessUsage(result.usage) }),
}; };
} }
@ -581,10 +566,7 @@ function toWireFrame(frame: AiAssistantMessageFrame): AssistantMessageFrame {
toolCall: { toolCall: {
...frame.toolCall, ...frame.toolCall,
arguments: Object.fromEntries( arguments: Object.fromEntries(
Object.entries(frame.toolCall.arguments).map(([key, value]) => [ Object.entries(frame.toolCall.arguments).map(([key, value]) => [key, toWireJsonValue(value)]),
key,
toWireJsonValue(value, `tool argument ${key}`),
]),
), ),
}, },
}; };
@ -592,10 +574,7 @@ function toWireFrame(frame: AiAssistantMessageFrame): AssistantMessageFrame {
return { return {
...frame, ...frame,
arguments: Object.fromEntries( arguments: Object.fromEntries(
Object.entries(frame.arguments).map(([key, value]) => [ Object.entries(frame.arguments).map(([key, value]) => [key, toWireJsonValue(value)]),
key,
toWireJsonValue(value, `tool argument ${key}`),
]),
), ),
}; };
default: default:
@ -650,7 +629,7 @@ export function toWireLaneSnapshot(snapshot: HarnessLaneSnapshot): LaneSnapshot
runningTools: snapshot.operation.runningTools.map((tool) => ({ runningTools: snapshot.operation.runningTools.map((tool) => ({
toolCallId: tool.toolCallId, toolCallId: tool.toolCallId,
toolName: tool.toolName, toolName: tool.toolName,
args: toWireJsonValue(tool.args, "running tool arguments"), args: toWireJsonValue(tool.args),
...(tool.partialResult === undefined ...(tool.partialResult === undefined
? {} ? {}
: { partialResult: toWireToolOutput(tool.partialResult) }), : { partialResult: toWireToolOutput(tool.partialResult) }),
@ -667,7 +646,7 @@ function toWireOperationError(error: Extract<HarnessEvent, { type: "run_end" }>[
return { return {
code: error.code, code: error.code,
message: error.message, message: error.message,
...(error.details === undefined ? {} : { details: toWireJsonValue(error.details, "operation error details") }), ...(error.details === undefined ? {} : { details: toWireJsonValue(error.details) }),
}; };
} }
@ -720,7 +699,7 @@ export function toWireLaneEvent(event: HarnessEvent): LaneEvent | undefined {
case "tool_start": case "tool_start":
return { return {
...event, ...event,
args: toWireJsonValue(event.args, "tool arguments"), args: toWireJsonValue(event.args),
...base, ...base,
lane: event.lane, lane: event.lane,
}; };
@ -750,9 +729,7 @@ export function toWireLaneEvent(event: HarnessEvent): LaneEvent | undefined {
row: { row: {
...event.row, ...event.row,
usage: toHarnessUsage(event.row.usage), usage: toHarnessUsage(event.row.usage),
...(event.row.details === undefined ...(event.row.details === undefined ? {} : { details: toWireJsonValue(event.row.details) }),
? {}
: { details: toWireJsonValue(event.row.details, "usage details") }),
}, },
totals: toHarnessUsage(event.totals), totals: toHarnessUsage(event.totals),
}; };
@ -767,8 +744,8 @@ export function toWireLaneEvent(event: HarnessEvent): LaneEvent | undefined {
return { return {
type: "config_update", type: "config_update",
property: event.property, property: event.property,
value: toWireJsonValue(event.value, `config ${event.property}`), value: toWireJsonValue(event.value),
previous: toWireJsonValue(event.previous, `previous config ${event.property}`), previous: toWireJsonValue(event.previous),
...base, ...base,
lane: event.lane, lane: event.lane,
}; };
@ -780,8 +757,8 @@ export function toWireLaneEvent(event: HarnessEvent): LaneEvent | undefined {
return { return {
type: "config_update", type: "config_update",
property: event.property, property: event.property,
value: toWireJsonValue(event.value, `config ${event.property}`), value: toWireJsonValue(event.value),
previous: toWireJsonValue(event.previous, `previous config ${event.property}`), previous: toWireJsonValue(event.previous),
}; };
default: default:
return assertNever(event); return assertNever(event);

View file

@ -1,4 +1,4 @@
import { defineFacet } from "../facets.ts"; import { defineFacet } from "@earendil-works/chord";
import { SlashCommands } from "../services/slash-commands.ts"; import { SlashCommands } from "../services/slash-commands.ts";
/** Example presentation facet. A future plugin build emits this facet as one standalone bundle. */ /** Example presentation facet. A future plugin build emits this facet as one standalone bundle. */

View file

@ -20,4 +20,4 @@ The Session worker loads all built-in and configured Session facets through one
A host may reload selected facets when their declared service shape is unchanged; retained consumers keep the same service facades while replacement implementations and singleton snapshots are installed behind them. Synchronous setup-time `env.provide()`, `env.provideMany()`, `env.use()`, and `env.observe()` calls produce the internal dependency graph; setup does not repeat a declarative dependency list, and service handles remain disconnected until the complete graph validates. Providers activate before consumers, observations connect with their consuming facet, and facet replacement or host shutdown disposes affected lifecycles in reverse dependency order. Server and Session service tokens are non-local and automatically published by their providing host. Presentation-only hookpoints such as `SlashCommands` are explicitly local and never enter an RPC catalogue. A host may reload selected facets when their declared service shape is unchanged; retained consumers keep the same service facades while replacement implementations and singleton snapshots are installed behind them. Synchronous setup-time `env.provide()`, `env.provideMany()`, `env.use()`, and `env.observe()` calls produce the internal dependency graph; setup does not repeat a declarative dependency list, and service handles remain disconnected until the complete graph validates. Providers activate before consumers, observations connect with their consuming facet, and facet replacement or host shutdown disposes affected lifecycles in reverse dependency order. Server and Session service tokens are non-local and automatically published by their providing host. Presentation-only hookpoints such as `SlashCommands` are explicitly local and never enter an RPC catalogue.
A facet always calls unqualified `env.use()` or `env.observe()`. The host resolves each token across facet-provided and connected services. The example `/hello` plugin is one presentation-facet module with a default facet export and is loaded through a `FacetLoader`; a future plugin build may emit that module as one self-contained bundle without changing the runtime facet contract. Other host facets from the same plugin will be separate bundle entries rather than one aggregate plugin object. Transport namespaces remain internal binding machinery rather than part of the facet environment. `ExperimentalClientTui` currently owns terminal rendering, state subscriptions, navigation, and action dispatch directly. The server provider remains directly assembled until its complete host environment exists. The question dialog, diff review, Git, indexing-job, canvas, and rich local TUI service examples in `packages/agent/docs/plugins.md` are extension patterns, not built-in coding-agent services. Private references, trace carriers, and flow control remain protocol/host infrastructure slices rather than presentation service tokens. A facet always calls unqualified `env.use()` or `env.observe()`. The host resolves each token across facet-provided and connected services. The example `/hello` plugin is one presentation-facet module with a default facet export and is loaded through a `FacetLoader`; a future plugin build may emit that module as one self-contained bundle without changing the runtime facet contract. Other host facets from the same plugin will be separate bundle entries rather than one aggregate plugin object. Transport bindings remain internal machinery rather than part of the facet environment. `ExperimentalClientTui` currently owns terminal rendering, state subscriptions, navigation, and action dispatch directly. The server provider remains directly assembled until its complete host environment exists. The question dialog, diff review, Git, indexing-job, canvas, and rich local TUI service examples in `packages/agent/docs/plugins.md` are extension patterns, not built-in coding-agent services. Private references, trace carriers, and flow control remain protocol/host infrastructure slices rather than presentation service tokens.

View file

@ -1,4 +1,4 @@
import { type Context, defineService, type ReplicatedState } from "@earendil-works/pi-agent-core"; import { type Context, defineService, type ReplicatedState } from "@earendil-works/chord";
export interface AccountSummary { export interface AccountSummary {
provider: string; provider: string;

View file

@ -1,4 +1,4 @@
import { type Context, defineService } from "@earendil-works/pi-agent-core"; import { type Context, defineService } from "@earendil-works/chord";
import type { PromptImage } from "@earendil-works/pi-protocol"; import type { PromptImage } from "@earendil-works/pi-protocol";
export interface AgentPromptRequest { export interface AgentPromptRequest {

View file

@ -1,16 +1,19 @@
import { import {
BACKGROUND_CONTEXT, BACKGROUND_CONTEXT,
type Context, type Context,
type ServiceProviderUpdate as CoreServiceProviderUpdate, createRemoteServiceBinding,
type FacetConnection,
freshDeliveryContext, freshDeliveryContext,
isJsonValue, type JsonValue,
type MutableReplicatedState, type MutableReplicatedState,
type RemoteServiceBinding,
type RemoteServiceConnection, type RemoteServiceConnection,
RemoteServiceNamespace, type RemoteServiceInstance,
type RemoteServices, type RemoteServices,
type ReplicatedState, type ReplicatedState,
replicatedState, replicatedState,
} from "@earendil-works/pi-agent-core"; type Service,
} from "@earendil-works/chord";
import type { Client } from "@earendil-works/pi-client"; import type { Client } from "@earendil-works/pi-client";
import type { import type {
ProtocolRpcCall, ProtocolRpcCall,
@ -19,7 +22,6 @@ import type {
ServiceProviderUpdate, ServiceProviderUpdate,
SessionTarget, SessionTarget,
} from "@earendil-works/pi-protocol"; } from "@earendil-works/pi-protocol";
import type { FacetConnection } from "../facets.ts";
export type ServerConnectionState = export type ServerConnectionState =
| { status: "connecting"; attempt: number } | { status: "connecting"; attempt: number }
@ -48,11 +50,12 @@ export interface ServiceConnectionOptions {
readonly onError?: (error: Error) => void; readonly onError?: (error: Error) => void;
} }
export interface ServiceNamespaceOptions extends ServiceConnectionOptions { export interface ServiceBindingOptions extends ServiceConnectionOptions {
readonly services: readonly { readonly id: string }[]; readonly services: readonly { readonly id: string }[];
} }
class RoutedServiceBinding extends RemoteServiceNamespace { class RoutedServiceBinding implements RemoteServices {
readonly #services: RemoteServiceBinding;
readonly #getBound: () => boolean; readonly #getBound: () => boolean;
readonly #onActivate: (context: Context) => Promise<void>; readonly #onActivate: (context: Context) => Promise<void>;
readonly #remove: () => void; readonly #remove: () => void;
@ -67,7 +70,7 @@ class RoutedServiceBinding extends RemoteServiceNamespace {
readonly onActivate?: (context: Context) => Promise<void>; readonly onActivate?: (context: Context) => Promise<void>;
readonly remove: () => void; readonly remove: () => void;
}) { }) {
super({ this.#services = createRemoteServiceBinding({
services: options.services, services: options.services,
connection: options.connection, connection: options.connection,
bound: false, bound: false,
@ -79,22 +82,41 @@ class RoutedServiceBinding extends RemoteServiceNamespace {
this.#remove = options.remove; this.#remove = options.remove;
} }
override async activate(context: Context): Promise<void> { use<T>(service: Service<T>): T {
return this.#services.use(service);
}
observe<T>(
service: Service<T>,
handler: (instance: RemoteServiceInstance<T>, context: Context) => void | Promise<void>,
): () => void {
return this.#services.observe(service, handler);
}
setAccessGuard(assertAccess: () => void): void {
this.#services.setAccessGuard(assertAccess);
}
async activate(context: Context): Promise<void> {
if (!this.#activated) { if (!this.#activated) {
this.#activated = true; this.#activated = true;
await this.rebind(this.#getBound(), context); await this.#services.rebind(this.#getBound(), context);
} }
await this.ready(context); await this.#services.ready(context);
await this.#onActivate(context); await this.#onActivate(context);
} }
updateBound(bound: boolean, context: Context): Promise<void> { ready(context: Context): Promise<void> {
return this.#activated ? this.rebind(bound, context) : Promise.resolve(); return this.#services.ready(context);
} }
override async dispose(context: Context): Promise<void> { updateBound(bound: boolean, context: Context): Promise<void> {
return this.#activated ? this.#services.rebind(bound, context) : Promise.resolve();
}
async dispose(context: Context): Promise<void> {
this.#remove(); this.#remove();
await super.dispose(context); await this.#services.dispose(context);
} }
} }
@ -333,10 +355,10 @@ class SessionServiceConnectionImpl implements SessionServiceConnection {
} }
} }
/** Create an explicitly bound server-service namespace outside a facet host. */ /** Create an explicitly bound server-service binding outside a facet host. */
export function createServerServiceNamespace( export function createServerServiceBinding(
client: Client, client: Client,
options: ServiceNamespaceOptions, options: ServiceBindingOptions,
): ServerServiceConnection & RemoteServices { ): ServerServiceConnection & RemoteServices {
const connection = createServerServiceConnection(client, options); const connection = createServerServiceConnection(client, options);
const services = connection.open({ const services = connection.open({
@ -362,15 +384,15 @@ export function createServerServiceNamespace(
setAccessGuard: (assertAccess) => services.setAccessGuard(assertAccess), setAccessGuard: (assertAccess) => services.setAccessGuard(assertAccess),
async dispose(context) { async dispose(context) {
const results = await Promise.allSettled([services.dispose(context), connection.dispose(context)]); const results = await Promise.allSettled([services.dispose(context), connection.dispose(context)]);
throwFailures(results, "Failed to dispose server service namespace"); throwFailures(results, "Failed to dispose server service binding");
}, },
}; };
} }
/** Create an explicitly bound selected-Session namespace outside a facet host. */ /** Create an explicitly bound selected-Session service binding outside a facet host. */
export function createSessionServiceNamespace( export function createSessionServiceBinding(
client: Client, client: Client,
options: ServiceNamespaceOptions, options: ServiceBindingOptions,
): SessionServiceConnection & RemoteServices { ): SessionServiceConnection & RemoteServices {
const connection = createSessionServiceConnection(client, options); const connection = createSessionServiceConnection(client, options);
const services = connection.open({ const services = connection.open({
@ -402,7 +424,7 @@ export function createSessionServiceNamespace(
whenDetached: (context) => connection.whenDetached(context), whenDetached: (context) => connection.whenDetached(context),
async dispose(context) { async dispose(context) {
const results = await Promise.allSettled([services.dispose(context), connection.dispose(context)]); const results = await Promise.allSettled([services.dispose(context), connection.dispose(context)]);
throwFailures(results, "Failed to dispose Session service namespace"); throwFailures(results, "Failed to dispose Session service binding");
}, },
}; };
} }
@ -437,9 +459,7 @@ function createRemoteServiceConnection(
member: call.member, member: call.member,
args: [...call.args], args: [...call.args],
}; };
const result = await client.request(target, wireCall, context.abortSignal); return client.request(target, wireCall, context.abortSignal) as Promise<JsonValue | undefined>;
if (result !== undefined && !isJsonValue(result)) throw new Error("Service returned a non-JSON result");
return result;
}, },
subscribe: async (serviceId, mode, listener, context) => { subscribe: async (serviceId, mode, listener, context) => {
const target = getTarget(); const target = getTarget();
@ -448,8 +468,7 @@ function createRemoteServiceConnection(
target, target,
serviceId, serviceId,
mode, mode,
(update: ServiceProviderUpdate) => (update: ServiceProviderUpdate) => listener(update, freshDeliveryContext()),
listener(update as unknown as CoreServiceProviderUpdate, freshDeliveryContext()),
context.abortSignal, context.abortSignal,
); );
return { return {

View file

@ -1,14 +1,14 @@
import { import {
type AgentLane,
BACKGROUND_CONTEXT, BACKGROUND_CONTEXT,
type Context, type Context,
defineFacet,
type Facet,
type MutableReplicatedState, type MutableReplicatedState,
type ThinkingLevel, } from "@earendil-works/chord";
} from "@earendil-works/pi-agent-core"; import type { AgentLane, ThinkingLevel } from "@earendil-works/pi-agent-core";
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai"; import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
import type { ModelRuntime } from "../../core/model-runtime.ts"; import type { ModelRuntime } from "../../core/model-runtime.ts";
import type { SettingsManager } from "../../core/settings-manager.ts"; import type { SettingsManager } from "../../core/settings-manager.ts";
import { defineFacet, type Facet } from "../facets.ts";
import { Models, type Models as ModelsService, type ModelsState } from "./models.ts"; import { Models, type Models as ModelsService, type ModelsState } from "./models.ts";
export interface ModelsServiceRuntime { export interface ModelsServiceRuntime {

View file

@ -1,4 +1,5 @@
import { type Context, defineService, type ReplicatedState, type ThinkingLevel } from "@earendil-works/pi-agent-core"; import { type Context, defineService, type ReplicatedState } from "@earendil-works/chord";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
export interface ModelRef { export interface ModelRef {
provider: string; provider: string;

View file

@ -4,12 +4,11 @@ import {
type ServiceProviderUpdate as CoreServiceProviderUpdate, type ServiceProviderUpdate as CoreServiceProviderUpdate,
RemoteServiceProvider, RemoteServiceProvider,
replicatedState, replicatedState,
type ServiceProviderSubscription, type ServiceSubscription,
} from "@earendil-works/pi-agent-core"; } from "@earendil-works/chord";
import { import {
decodeServiceControlCall, decodeServiceControlCall,
type JsonValue, type JsonValue,
JsonValueSchema,
type ServiceProviderUpdate as ProtocolServiceProviderUpdate, type ServiceProviderUpdate as ProtocolServiceProviderUpdate,
ServiceProviderUpdateSchema, ServiceProviderUpdateSchema,
type SessionCreateOptions, type SessionCreateOptions,
@ -105,7 +104,7 @@ function createProviderAttachment(
provider: RemoteServiceProvider, provider: RemoteServiceProvider,
onRelease: () => void, onRelease: () => void,
): RoutedServerServiceAttachment { ): RoutedServerServiceAttachment {
const subscriptions = new Map<string, ServiceProviderSubscription>(); const subscriptions = new Map<string, ServiceSubscription>();
let released = false; let released = false;
return { return {
async invokeService(call, publish, context) { async invokeService(call, publish, context) {
@ -146,8 +145,7 @@ function createProviderAttachment(
} }
function toProtocolJson(value: unknown): JsonValue { function toProtocolJson(value: unknown): JsonValue {
if (!Check(JsonValueSchema, value)) throw new Error("Service control value is not strict JSON"); return value as JsonValue;
return value;
} }
function toProtocolServiceUpdate(update: CoreServiceProviderUpdate): ProtocolServiceProviderUpdate { function toProtocolServiceUpdate(update: CoreServiceProviderUpdate): ProtocolServiceProviderUpdate {

View file

@ -1,4 +1,4 @@
import { type Context, defineService, type ReplicatedState } from "@earendil-works/pi-agent-core"; import { type Context, defineService, type ReplicatedState } from "@earendil-works/chord";
import type { SessionCreateOptions, SessionSummary } from "@earendil-works/pi-protocol"; import type { SessionCreateOptions, SessionSummary } from "@earendil-works/pi-protocol";
export interface SessionDirectoryState { export interface SessionDirectoryState {

View file

@ -1,5 +1,5 @@
import { defineFacet, type Facet } from "@earendil-works/chord";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { defineFacet, type Facet } from "../facets.ts";
import { AgentController, type AgentOperationResponse } from "./agent-controller.ts"; import { AgentController, type AgentOperationResponse } from "./agent-controller.ts";
import { type ModelSummary, Models, type Models as ModelsService } from "./models.ts"; import { type ModelSummary, Models, type Models as ModelsService } from "./models.ts";
import { type SlashCommandContribution, type SlashCommandExecutionContext, SlashCommands } from "./slash-commands.ts"; import { type SlashCommandContribution, type SlashCommandExecutionContext, SlashCommands } from "./slash-commands.ts";

View file

@ -1,4 +1,4 @@
import { type Context, defineService } from "@earendil-works/pi-agent-core"; import { type Context, defineService } from "@earendil-works/chord";
export interface SlashCommandCompletion { export interface SlashCommandCompletion {
readonly value: string; readonly value: string;

View file

@ -1,8 +1,16 @@
import { ServiceSliceNotImplemented } from "@earendil-works/pi-agent-core"; import { defineFacet } from "@earendil-works/chord";
import { defineFacet } from "../facets.ts";
import { Accounts, type AccountsState } from "./accounts.ts"; import { Accounts, type AccountsState } from "./accounts.ts";
import { Transcript } from "./transcript.ts"; import { Transcript } from "./transcript.ts";
export class ServiceSliceNotImplemented extends Error {
readonly code = "service_not_implemented" as const;
constructor(operation: string) {
super(`${operation} is not implemented until its later facet-service slice`);
this.name = "ServiceSliceNotImplemented";
}
}
/** Documented built-in surfaces whose implementations belong to later service slices. */ /** Documented built-in surfaces whose implementations belong to later service slices. */
export const accountsServiceFacet = defineFacet({ export const accountsServiceFacet = defineFacet({
id: "@pi/accounts", id: "@pi/accounts",

View file

@ -1,9 +1,9 @@
import { type Context, defineService, type RemoteJson } from "@earendil-works/pi-agent-core"; import { type Context, defineService } from "@earendil-works/chord";
import type { LaneSnapshot } from "@earendil-works/pi-protocol"; import type { LaneSnapshot } from "@earendil-works/pi-protocol";
export interface TranscriptSnapshot { export interface TranscriptSnapshot {
revision: number; revision: number;
lane: RemoteJson<LaneSnapshot>; lane: LaneSnapshot;
} }
export interface Transcript { export interface Transcript {

View file

@ -1,11 +1,17 @@
import type { import {
AgentHarness, type Context,
AgentLane, type ServiceProviderUpdate as CoreServiceProviderUpdate,
Context, combineFacetLoaders,
ServiceProviderUpdate as CoreServiceProviderUpdate, createFacetHost,
ServiceCatalogueEntry, createStaticFacetLoader,
ServiceProviderSubscription, defineFacet,
} from "@earendil-works/pi-agent-core"; type Facet,
type FacetHost,
type FacetLoader,
type ServiceCatalogueEntry,
type ServiceSubscription,
} from "@earendil-works/chord";
import type { AgentHarness, AgentLane } from "@earendil-works/pi-agent-core";
import { import {
decodeServiceControlCall, decodeServiceControlCall,
type JsonValue, type JsonValue,
@ -18,8 +24,6 @@ import Type, { type Static } from "typebox";
import { Check } from "typebox/value"; import { Check } from "typebox/value";
import type { ModelRuntime } from "../../core/model-runtime.ts"; import type { ModelRuntime } from "../../core/model-runtime.ts";
import type { SettingsManager } from "../../core/settings-manager.ts"; import type { SettingsManager } from "../../core/settings-manager.ts";
import { combineFacetLoaders, createStaticFacetLoader, type FacetLoader } from "../facet-loader.ts";
import { createFacetHost, defineFacet, type Facet, type FacetHost } from "../facets.ts";
import { AgentController } from "./agent-controller.ts"; import { AgentController } from "./agent-controller.ts";
import { createAgentController } from "./agent-controller-provider.ts"; import { createAgentController } from "./agent-controller-provider.ts";
import { createModelsServiceFacet } from "./models-provider.ts"; import { createModelsServiceFacet } from "./models-provider.ts";
@ -46,7 +50,7 @@ export interface WorkerServiceScope {
interface WorkerServiceSubscription { interface WorkerServiceSubscription {
readonly scope: WorkerServiceScope; readonly scope: WorkerServiceScope;
readonly subscription: ServiceProviderSubscription; readonly subscription: ServiceSubscription;
} }
export interface SessionWorkerServices { export interface SessionWorkerServices {
@ -159,8 +163,7 @@ function scopedSubscriptionKey(scope: WorkerServiceScope, subscriptionId: string
} }
function toProtocolJson(value: unknown): JsonValue { function toProtocolJson(value: unknown): JsonValue {
if (!Check(JsonValueSchema, value)) throw new Error("Service control value is not strict JSON"); return value as JsonValue;
return value;
} }
function toProtocolServiceUpdate(update: CoreServiceProviderUpdate): ProtocolServiceProviderUpdate { function toProtocolServiceUpdate(update: CoreServiceProviderUpdate): ProtocolServiceProviderUpdate {

View file

@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { createConnection, type Socket } from "node:net"; import { createConnection, type Socket } from "node:net";
import { isAbsolute } from "node:path"; import { isAbsolute } from "node:path";
import { RemoteServiceError } from "@earendil-works/chord";
import { import {
AgentHarness, AgentHarness,
type AgentHarness as AgentHarnessInstance, type AgentHarness as AgentHarnessInstance,
@ -12,8 +13,6 @@ import {
createWriteTool, createWriteTool,
type JsonlSessionMetadata, type JsonlSessionMetadata,
JsonlSessionRepo, JsonlSessionRepo,
RemoteServiceError,
ServiceSliceNotImplemented,
type Session, type Session,
TODO_CONTEXT, TODO_CONTEXT,
withCancel, withCancel,
@ -739,8 +738,13 @@ async function run(options: SessionWorkerOptions, createHarness: CreateSessionWo
response: { type: "operation_result", requestId: request.requestId, scope: request.scope, result }, response: { type: "operation_result", requestId: request.requestId, scope: request.scope, result },
}); });
} catch (error) { } catch (error) {
const code: ServiceErrorCode | undefined = let code: ServiceErrorCode | undefined;
error instanceof RemoteServiceError || error instanceof ServiceSliceNotImplemented ? error.code : undefined; if (error instanceof RemoteServiceError) {
code = error.code;
} else if (error instanceof Error && "code" in error) {
const candidate = error.code;
if (Check(ServiceErrorCodeSchema, candidate)) code = candidate;
}
await control.send({ await control.send({
type: "operation_response", type: "operation_response",
token, token,

View file

@ -1,3 +1,4 @@
import { createFacetHost, defineFacet } from "@earendil-works/chord";
import { import {
type AgentLane, type AgentLane,
BACKGROUND_CONTEXT, BACKGROUND_CONTEXT,
@ -9,7 +10,6 @@ import {
UnknownTemplate, UnknownTemplate,
} from "@earendil-works/pi-agent-core"; } from "@earendil-works/pi-agent-core";
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { createFacetHost, defineFacet } from "../src/experimental/facets.ts";
import { AgentController } from "../src/experimental/services/agent-controller.ts"; import { AgentController } from "../src/experimental/services/agent-controller.ts";
import { createAgentController } from "../src/experimental/services/agent-controller-provider.ts"; import { createAgentController } from "../src/experimental/services/agent-controller-provider.ts";

View file

@ -1,17 +1,17 @@
import { import {
type AgentLane,
BACKGROUND_CONTEXT, BACKGROUND_CONTEXT,
createLoopbackServiceConnection, createLoopbackServiceConnection,
RemoteServiceNamespace, createRemoteServiceBinding,
defineFacet,
type FacetLoader,
RemoteServiceProvider, RemoteServiceProvider,
replicatedState, replicatedState,
} from "@earendil-works/pi-agent-core"; } from "@earendil-works/chord";
import type { AgentLane } from "@earendil-works/pi-agent-core";
import type { LaneEvent, LaneSnapshot, SessionSummary } from "@earendil-works/pi-protocol"; import type { LaneEvent, LaneSnapshot, SessionSummary } from "@earendil-works/pi-protocol";
import { ProcessTerminal, TuiMainScreen } from "@earendil-works/pi-tui"; import { ProcessTerminal, TuiMainScreen } from "@earendil-works/pi-tui";
import { beforeAll, describe, expect, test, vi } from "vitest"; import { beforeAll, describe, expect, test, vi } from "vitest";
import { type ClientTuiServer, ExperimentalClientTui } from "../src/experimental/client-tui.ts"; import { type ClientTuiServer, ExperimentalClientTui } from "../src/experimental/client-tui.ts";
import type { FacetLoader } from "../src/experimental/facet-loader.ts";
import { defineFacet } from "../src/experimental/facets.ts";
import { AgentController } from "../src/experimental/services/agent-controller.ts"; import { AgentController } from "../src/experimental/services/agent-controller.ts";
import { createAgentController } from "../src/experimental/services/agent-controller-provider.ts"; import { createAgentController } from "../src/experimental/services/agent-controller-provider.ts";
import type { import type {
@ -191,7 +191,7 @@ describe("experimental client TUI", () => {
}); });
sessionProvider.provide(AgentController, createAgentController({ prompt } as unknown as AgentLane)); sessionProvider.provide(AgentController, createAgentController({ prompt } as unknown as AgentLane));
const serverNamespace = new RemoteServiceNamespace({ const serverNamespace = createRemoteServiceBinding({
services: [SessionDirectory, SessionManagement], services: [SessionDirectory, SessionManagement],
connection: createLoopbackServiceConnection(serverProvider), connection: createLoopbackServiceConnection(serverProvider),
bound: false, bound: false,
@ -210,7 +210,7 @@ describe("experimental client TUI", () => {
await serverNamespace.ready(BACKGROUND_CONTEXT); await serverNamespace.ready(BACKGROUND_CONTEXT);
}, },
}); });
const sessionNamespace = new RemoteServiceNamespace({ const sessionNamespace = createRemoteServiceBinding({
services: [Models, AgentController], services: [Models, AgentController],
connection: createLoopbackServiceConnection(sessionProvider), connection: createLoopbackServiceConnection(sessionProvider),
bound: false, bound: false,

View file

@ -10,7 +10,6 @@ import {
} from "@earendil-works/pi-agent-core"; } from "@earendil-works/pi-agent-core";
import type { AssistantMessage } from "@earendil-works/pi-ai"; import type { AssistantMessage } from "@earendil-works/pi-ai";
import { import {
type JsonValue,
LaneEventSchema, LaneEventSchema,
LaneSnapshotSchema, LaneSnapshotSchema,
type PromptArguments, type PromptArguments,
@ -255,23 +254,4 @@ describe("Harness wire adapter", () => {
frame: { type: "text_delta", contentIndex: 0, delta: "swer" }, frame: { type: "text_delta", contentIndex: 0, delta: "swer" },
}); });
}); });
test("rejects non-JSON values in Harness output", () => {
const details = { when: null } satisfies JsonValue;
Object.defineProperty(details, "when", { value: new Date(0) });
const result: HarnessRunResult = {
ok: true,
value: {
operationId: "run-1",
kind: "run",
status: "failed",
error: { code: "provider", message: "failed", details },
fromTipId: null,
tipId: "leaf-1",
startedAt: 1,
endedAt: 2,
},
};
expect(() => toWireRunResult(result)).toThrow(/not JSON-serializable/);
});
}); });

View file

@ -1,18 +1,17 @@
import { lstat, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; import { lstat, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path"; import { join, resolve } from "node:path";
import { BACKGROUND_CONTEXT, type Context, withCancel } from "@earendil-works/pi-agent-core"; import { BACKGROUND_CONTEXT, type Context, createFacetHost, defineFacet, withCancel } from "@earendil-works/chord";
import { Client } from "@earendil-works/pi-client"; import { Client } from "@earendil-works/pi-client";
import { createUnixTransportFactory } from "@earendil-works/pi-client/unix"; import { createUnixTransportFactory } from "@earendil-works/pi-client/unix";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { runClient } from "../src/experimental/client.ts"; import { runClient } from "../src/experimental/client.ts";
import { activateBuiltinClientServices, openClientRuntime } from "../src/experimental/client-runtime.ts"; import { activateBuiltinClientServices, openClientRuntime } from "../src/experimental/client-runtime.ts";
import { createFacetHost, defineFacet } from "../src/experimental/facets.ts";
import * as processRuntime from "../src/experimental/process.ts"; import * as processRuntime from "../src/experimental/process.ts";
import { activateServer, type RunningServer, startServer } from "../src/experimental/server.ts"; import { activateServer, type RunningServer, startServer } from "../src/experimental/server.ts";
import { import {
createServerServiceNamespace, createServerServiceBinding,
createSessionServiceBinding,
createSessionServiceConnection, createSessionServiceConnection,
createSessionServiceNamespace,
type SessionAttachmentState, type SessionAttachmentState,
} from "../src/experimental/services/connection.ts"; } from "../src/experimental/services/connection.ts";
import { Models } from "../src/experimental/services/models.ts"; import { Models } from "../src/experimental/services/models.ts";
@ -299,11 +298,11 @@ describe("experimental durable server composition", () => {
clients.add(firstClient); clients.add(firstClient);
clients.add(secondClient); clients.add(secondClient);
const errors: Error[] = []; const errors: Error[] = [];
const firstServices = createServerServiceNamespace(firstClient, { const firstServices = createServerServiceBinding(firstClient, {
services: [SessionDirectory, SessionManagement], services: [SessionDirectory, SessionManagement],
onError: (error) => errors.push(error), onError: (error) => errors.push(error),
}); });
const secondServices = createServerServiceNamespace(secondClient, { const secondServices = createServerServiceBinding(secondClient, {
services: [SessionDirectory, SessionManagement], services: [SessionDirectory, SessionManagement],
onError: (error) => errors.push(error), onError: (error) => errors.push(error),
}); });
@ -370,11 +369,11 @@ describe("experimental durable server composition", () => {
const firstClient = await attachClient(runtime, "demo-1"); const firstClient = await attachClient(runtime, "demo-1");
const secondClient = await attachClient(runtime, "demo-1"); const secondClient = await attachClient(runtime, "demo-1");
const errors: Error[] = []; const errors: Error[] = [];
const firstServices = createSessionServiceNamespace(firstClient, { const firstServices = createSessionServiceBinding(firstClient, {
services: [Models], services: [Models],
onError: (error) => errors.push(error), onError: (error) => errors.push(error),
}); });
const secondServices = createSessionServiceNamespace(secondClient, { const secondServices = createSessionServiceBinding(secondClient, {
services: [Models], services: [Models],
onError: (error) => errors.push(error), onError: (error) => errors.push(error),
}); });
@ -418,7 +417,7 @@ describe("experimental durable server composition", () => {
const runtime = await startServer({ directory }); const runtime = await startServer({ directory });
servers.add(runtime); servers.add(runtime);
const firstClient = await attachClient(runtime, "demo-1"); const firstClient = await attachClient(runtime, "demo-1");
const firstServices = createSessionServiceNamespace(firstClient, { services: [Models] }); const firstServices = createSessionServiceBinding(firstClient, { services: [Models] });
const firstModels = firstServices.use(Models); const firstModels = firstServices.use(Models);
await firstServices.ready(BACKGROUND_CONTEXT); await firstServices.ready(BACKGROUND_CONTEXT);
await firstModels.select({ provider: "anthropic", modelId: "claude-opus-4-6" }, BACKGROUND_CONTEXT); await firstModels.select({ provider: "anthropic", modelId: "claude-opus-4-6" }, BACKGROUND_CONTEXT);
@ -428,7 +427,7 @@ describe("experimental durable server composition", () => {
await expect.poll(() => runtime.workerPids.has("demo-1")).toBe(false); await expect.poll(() => runtime.workerPids.has("demo-1")).toBe(false);
const secondClient = await attachClient(runtime, "demo-2"); const secondClient = await attachClient(runtime, "demo-2");
const secondServices = createSessionServiceNamespace(secondClient, { services: [Models] }); const secondServices = createSessionServiceBinding(secondClient, { services: [Models] });
const secondModels = secondServices.use(Models); const secondModels = secondServices.use(Models);
await secondServices.ready(BACKGROUND_CONTEXT); await secondServices.ready(BACKGROUND_CONTEXT);
expect(secondModels.state.value?.configuration.model).toEqual({ expect(secondModels.state.value?.configuration.model).toEqual({
@ -466,7 +465,7 @@ describe("experimental durable server composition", () => {
}); });
clients.add(client); clients.add(client);
const errors: Error[] = []; const errors: Error[] = [];
const services = createSessionServiceNamespace(client, { const services = createSessionServiceBinding(client, {
services: [Models], services: [Models],
onError: (error) => errors.push(error), onError: (error) => errors.push(error),
}); });
@ -517,7 +516,7 @@ describe("experimental durable server composition", () => {
test("routes explicit later-slice errors across the framed service boundary", async () => { test("routes explicit later-slice errors across the framed service boundary", async () => {
const { runtime } = await makeServer(); const { runtime } = await makeServer();
const client = await attachClient(runtime, "demo-1"); const client = await attachClient(runtime, "demo-1");
const services = createSessionServiceNamespace(client, { services: [Transcript] }); const services = createSessionServiceBinding(client, { services: [Transcript] });
const transcript = services.use(Transcript); const transcript = services.use(Transcript);
await expect(transcript.snapshot(BACKGROUND_CONTEXT)).rejects.toMatchObject({ await expect(transcript.snapshot(BACKGROUND_CONTEXT)).rejects.toMatchObject({

View file

@ -1,14 +1,22 @@
import { BACKGROUND_CONTEXT, ServiceSliceNotImplemented } from "@earendil-works/pi-agent-core"; import { BACKGROUND_CONTEXT, createFacetHost } from "@earendil-works/chord";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { createFacetHost } from "../src/experimental/facets.ts"; import {
import { accountsServiceFacet, transcriptServiceFacet } from "../src/experimental/services/stubs-provider.ts"; accountsServiceFacet,
ServiceSliceNotImplemented,
transcriptServiceFacet,
} from "../src/experimental/services/stubs-provider.ts";
describe("experimental built-in service surface", () => { describe("experimental built-in service surface", () => {
test("exposes later singleton slices as explicit unimplemented providers", async () => { test("exposes later singleton slices as explicit unimplemented providers", async () => {
const host = await createFacetHost({ facets: [accountsServiceFacet, transcriptServiceFacet] }); const host = await createFacetHost({ facets: [accountsServiceFacet, transcriptServiceFacet] });
try { try {
const accounts = host.services.subscribe("pi.accounts", "singleton", () => {}).snapshot; const accounts = host.services.subscribe("pi.accounts", "singleton", () => {}).snapshot;
expect(accounts.instances[0]?.states.state?.value).toEqual({ providers: [] }); expect(accounts.instances[0]?.members).toContainEqual({
name: "state",
kind: "state",
sequence: 0,
value: { providers: [] },
});
await expect( await expect(
host.services.invoke( host.services.invoke(
{ serviceId: "pi.accounts", member: "remove", args: ["anthropic"] }, { serviceId: "pi.accounts", member: "remove", args: ["anthropic"] },

View file

@ -1,5 +1,5 @@
import { createFacetHost, defineFacet } from "@earendil-works/chord";
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { createFacetHost, defineFacet } from "../src/experimental/facets.ts";
import helloPluginFacet from "../src/experimental/plugins/hello.ts"; import helloPluginFacet from "../src/experimental/plugins/hello.ts";
import { SlashCommands } from "../src/experimental/services/slash-commands.ts"; import { SlashCommands } from "../src/experimental/services/slash-commands.ts";
import { import {

View file

@ -1,9 +1,8 @@
import { resolve } from "node:path"; import { resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { createStaticFacetLoader, defineFacet } from "@earendil-works/chord";
import { AgentHarness, BACKGROUND_CONTEXT } from "@earendil-works/pi-agent-core"; import { AgentHarness, BACKGROUND_CONTEXT } from "@earendil-works/pi-agent-core";
import { createModels, fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai"; import { createModels, fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai";
import { createStaticFacetLoader } from "../../src/experimental/facet-loader.ts";
import { defineFacet } from "../../src/experimental/facets.ts";
import { consumeInternalProcessRole } from "../../src/experimental/process.ts"; import { consumeInternalProcessRole } from "../../src/experimental/process.ts";
import { runSessionWorkerWithHarness } from "../../src/experimental/session-worker.ts"; import { runSessionWorkerWithHarness } from "../../src/experimental/session-worker.ts";
import { KeyedProbe } from "./keyed-service.ts"; import { KeyedProbe } from "./keyed-service.ts";

View file

@ -1,4 +1,4 @@
import { type Context, defineService, type ReplicatedState } from "@earendil-works/pi-agent-core"; import { type Context, defineService, type ReplicatedState } from "@earendil-works/chord";
export interface KeyedProbe { export interface KeyedProbe {
readonly state: ReplicatedState<{ value: string }>; readonly state: ReplicatedState<{ value: string }>;

View file

@ -3,6 +3,7 @@
"compilerOptions": { "compilerOptions": {
"outDir": "./dist", "outDir": "./dist",
"paths": { "paths": {
"@earendil-works/chord": ["../chord/dist/index.d.ts"],
"@earendil-works/pi-agent-core": ["../agent/dist/index.d.ts"], "@earendil-works/pi-agent-core": ["../agent/dist/index.d.ts"],
"@earendil-works/pi-agent-core/*": ["../agent/dist/*.d.ts"], "@earendil-works/pi-agent-core/*": ["../agent/dist/*.d.ts"],
"@earendil-works/pi-ai": ["../ai/dist/index.d.ts"], "@earendil-works/pi-ai": ["../ai/dist/index.d.ts"],

View file

@ -39,6 +39,7 @@
"node": ">=22.19.0" "node": ">=22.19.0"
}, },
"dependencies": { "dependencies": {
"@earendil-works/chord": "^0.84.4",
"typebox": "1.3.7" "typebox": "1.3.7"
}, },
"devDependencies": { "devDependencies": {

View file

@ -1,45 +1,7 @@
import Type, { type Static } from "typebox"; import type { JsonValue } from "@earendil-works/chord/json";
import Type from "typebox";
type JsonValueShape = null | boolean | number | string | JsonValueShape[] | { [key: string]: JsonValueShape }; /** Static JSON contract. Concrete serializers are responsible for rejecting unsupported runtime values. */
export const JsonValueSchema = Type.Unsafe<JsonValue>(Type.Unknown());
// TypeBox cannot express plain-object checks or cycle-safe recursion. The static annotation and runtime refinement are export type { JsonValue } from "@earendil-works/chord/json";
// deliberately paired here; all hand-written Harness and service DTOs remain schema-derived.
export const JsonValueSchema = Type.Refine(
Type.Unsafe<JsonValueShape>(Type.Unknown()),
(value) => isJsonValue(value),
() => "Expected recursively plain JSON data",
);
export type JsonValue = Static<typeof JsonValueSchema>;
/** Return whether a value is recursively finite JSON data without cycles. */
export function isJsonValue(value: unknown): value is JsonValue {
return checkJsonValue(value, new Set<object>());
}
function checkJsonValue(value: unknown, ancestors: Set<object>): value is JsonValue {
if (value === null || typeof value === "boolean" || typeof value === "string") return true;
if (typeof value === "number") return Number.isFinite(value);
if (typeof value !== "object" || ancestors.has(value)) return false;
if (!Array.isArray(value)) {
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) return false;
if (
Object.getOwnPropertySymbols(value).some((symbol) => Object.prototype.propertyIsEnumerable.call(value, symbol))
) {
return false;
}
}
ancestors.add(value);
const valid = Array.isArray(value)
? checkJsonArray(value, ancestors)
: Object.values(value).every((item) => checkJsonValue(item, ancestors));
ancestors.delete(value);
return valid;
}
function checkJsonArray(value: unknown[], ancestors: Set<object>): value is JsonValue[] {
for (let index = 0; index < value.length; index++) {
if (!Object.hasOwn(value, index) || !checkJsonValue(value[index], ancestors)) return false;
}
return true;
}

View file

@ -121,22 +121,23 @@ export const ProtocolRpcCallSchema = StrictObject({
export type ProtocolRpcCall = Static<typeof ProtocolRpcCallSchema>; export type ProtocolRpcCall = Static<typeof ProtocolRpcCallSchema>;
export type ProtocolRpcResult = JsonValue | undefined; export type ProtocolRpcResult = JsonValue | undefined;
export const ServiceMemberDescriptionSchema = StrictObject({ export const ServiceMemberSnapshotSchema = Type.Union([
name: IdSchema, StrictObject({
kind: Type.Union([Type.Literal("method"), Type.Literal("state")]), name: IdSchema,
}); kind: Type.Literal("method"),
export type ServiceMemberDescription = Static<typeof ServiceMemberDescriptionSchema>; }),
StrictObject({
export const ServiceStateSnapshotSchema = StrictObject({ name: IdSchema,
sequence: Type.Integer({ minimum: 0 }), kind: Type.Literal("state"),
value: JsonValueSchema, sequence: Type.Integer({ minimum: 0 }),
}); value: JsonValueSchema,
export type ServiceStateSnapshot = Static<typeof ServiceStateSnapshotSchema>; }),
]);
export type ServiceMemberSnapshot = Static<typeof ServiceMemberSnapshotSchema>;
export const ServiceInstanceSnapshotSchema = StrictObject({ export const ServiceInstanceSnapshotSchema = StrictObject({
instance: Type.Optional(ServiceInstanceAddressSchema), instance: Type.Optional(ServiceInstanceAddressSchema),
members: Type.Array(ServiceMemberDescriptionSchema), members: Type.Array(ServiceMemberSnapshotSchema),
states: Type.Record(Type.String(), ServiceStateSnapshotSchema),
}); });
export type ServiceInstanceSnapshot = Static<typeof ServiceInstanceSnapshotSchema>; export type ServiceInstanceSnapshot = Static<typeof ServiceInstanceSnapshotSchema>;
@ -180,7 +181,8 @@ export const ServiceProviderUpdateSchema = Type.Union([
]); ]);
export type ServiceProviderUpdate = Static<typeof ServiceProviderUpdateSchema>; export type ServiceProviderUpdate = Static<typeof ServiceProviderUpdateSchema>;
export const SERVICE_CONTROL_ID = "$pi.service"; // TODO: check if this should be part of Chord.
export const SERVICE_CONTROL_ID = "$chord.service";
export const SERVICE_CATALOGUE_MEMBER = "catalogue"; export const SERVICE_CATALOGUE_MEMBER = "catalogue";
export const SERVICE_SUBSCRIBE_MEMBER = "subscribe"; export const SERVICE_SUBSCRIBE_MEMBER = "subscribe";
export const SERVICE_UNSUBSCRIBE_MEMBER = "unsubscribe"; export const SERVICE_UNSUBSCRIBE_MEMBER = "unsubscribe";

View file

@ -20,8 +20,6 @@ import {
encodeServiceRpcCall, encodeServiceRpcCall,
FrameDecoder, FrameDecoder,
isSupportedProtocolVersion, isSupportedProtocolVersion,
type JsonValue,
JsonValueSchema,
type LaneSnapshot, type LaneSnapshot,
PROTOCOL_VERSION, PROTOCOL_VERSION,
PromptArgumentsSchema, PromptArgumentsSchema,
@ -274,8 +272,7 @@ describe("protocol validation", () => {
mode: "singleton", mode: "singleton",
instances: [ instances: [
{ {
members: [{ name: "state", kind: "state" }], members: [{ name: "state", kind: "state", sequence: 0, value: { revision: 1 } }],
states: { state: { sequence: 0, value: { revision: 1 } } },
}, },
], ],
}), }),
@ -284,8 +281,7 @@ describe("protocol validation", () => {
mode: "singleton", mode: "singleton",
instances: [ instances: [
{ {
members: [{ name: "state", kind: "state" }], members: [{ name: "state", kind: "state", sequence: 0, value: { revision: 1 } }],
states: { state: { sequence: 0, value: { revision: 1 } } },
}, },
], ],
}); });
@ -314,7 +310,7 @@ describe("protocol validation", () => {
subscriptionId: "subscription-1", subscriptionId: "subscription-1",
update: { update: {
type: "replaced", type: "replaced",
snapshot: { members: [{ name: "select", kind: "method" }], states: {} }, snapshot: { members: [{ name: "select", kind: "method" }] },
}, },
}), }),
).toMatchObject({ update: { type: "replaced", snapshot: { members: [{ name: "select" }] } } }); ).toMatchObject({ update: { type: "replaced", snapshot: { members: [{ name: "select" }] } } });
@ -397,38 +393,6 @@ describe("protocol validation", () => {
expect(() => parseClientMessage({ ...cancel, extra: true })).toThrow(ProtocolValidationError); expect(() => parseClientMessage({ ...cancel, extra: true })).toThrow(ProtocolValidationError);
}); });
test("validates recursively nested JSON values", () => {
const values = [
null,
true,
1,
"value",
[1, { nested: [false] }],
{ nested: { value: "ok" } },
] satisfies JsonValue[];
for (const value of values) expect(Check(JsonValueSchema, value)).toBe(true);
});
test.each([undefined, 1n, Symbol("value"), () => {}])("rejects non-JSON value %s", (value) => {
expect(Check(JsonValueSchema, value)).toBe(false);
});
test.each([
["CBOR byte array", decodeCbor(encodeCbor(new Uint8Array([1, 2, 3])))],
["date", new Date(0)],
["map", new Map([["key", "value"]])],
["class instance", new (class JsonValueTestClass {})()],
["nested class instance", { nested: new (class JsonValueTestClass {})() }],
] as const)("rejects non-plain JSON value: %s", (_label, value) => {
expect(Check(JsonValueSchema, value)).toBe(false);
});
test("rejects cyclic JSON values", () => {
const value: { self?: unknown } = {};
value.self = value;
expect(Check(JsonValueSchema, value)).toBe(false);
});
test.each([ test.each([
["text", ["Hello"]], ["text", ["Hello"]],
["text and images", ["Describe", [{ type: "image", data: "aW1hZ2U=", mimeType: "image/png" }]]], ["text and images", ["Describe", [{ type: "image", data: "aW1hZ2U=", mimeType: "image/png" }]]],
@ -622,28 +586,6 @@ describe("protocol validation", () => {
expect(Check(RunResultSchema, result)).toBe(false); expect(Check(RunResultSchema, result)).toBe(false);
}); });
test.each([
["bigint", 1n],
["byte array", new Uint8Array([1, 2, 3])],
["date", new Date(0)],
] as const)("rejects non-protocol %s nested in Harness DTOs", (_label, details) => {
expect(
Check(RunResultSchema, {
ok: true,
value: {
operationId: "run-1",
kind: "run",
status: "failed",
fromTipId: null,
tipId: "leaf-1",
startedAt: 1,
endedAt: 2,
error: { code: "provider", message: "failed", details },
},
}),
).toBe(false);
});
test.each([ test.each([
[ [
"empty request id", "empty request id",

View file

@ -47,6 +47,7 @@
"node": ">=22.19.0" "node": ">=22.19.0"
}, },
"dependencies": { "dependencies": {
"@earendil-works/chord": "^0.84.4",
"@earendil-works/pi-agent-core": "^0.84.4", "@earendil-works/pi-agent-core": "^0.84.4",
"@earendil-works/pi-protocol": "^0.84.4" "@earendil-works/pi-protocol": "^0.84.4"
}, },

View file

@ -1,11 +1,5 @@
import { import { RemoteServiceError } from "@earendil-works/chord";
BACKGROUND_CONTEXT, import { BACKGROUND_CONTEXT, type SessionMetadata, TODO_CONTEXT, withAbortSignal } from "@earendil-works/pi-agent-core";
RemoteServiceError,
ServiceSliceNotImplemented,
type SessionMetadata,
TODO_CONTEXT,
withAbortSignal,
} from "@earendil-works/pi-agent-core";
import { import {
type CancelEnvelope, type CancelEnvelope,
type ClientHello, type ClientHello,
@ -467,11 +461,10 @@ export class Server<TMetadata extends SessionMetadata = SessionMetadata> {
} }
private toProtocolError(error: unknown): ProtocolError { private toProtocolError(error: unknown): ProtocolError {
if ( if (error instanceof ServerError || error instanceof RemoteServiceError) {
error instanceof ServerError || return { code: error.code, message: error.message };
error instanceof RemoteServiceError || }
error instanceof ServiceSliceNotImplemented if (error instanceof Error && "code" in error && error.code === "service_not_implemented") {
) {
return { code: error.code, message: error.message }; return { code: error.code, message: error.message };
} }
if (error instanceof ProtocolValidationError) { if (error instanceof ProtocolValidationError) {

View file

@ -3,7 +3,7 @@ import { createConnection, type Socket } from "node:net";
import { import {
type ClientMessage, type ClientMessage,
encodeClientMessage, encodeClientMessage,
isJsonValue, type JsonValue,
PROTOCOL_VERSION, PROTOCOL_VERSION,
type ProtocolRpcCall, type ProtocolRpcCall,
type ResponseEnvelope, type ResponseEnvelope,
@ -15,7 +15,7 @@ import { Deferred } from "./host.ts";
interface TestRpcCall { interface TestRpcCall {
readonly method: string; readonly method: string;
readonly args: unknown; readonly args: JsonValue[];
} }
interface MessageWaiter { interface MessageWaiter {
@ -88,9 +88,6 @@ export class ProtocolTestClient {
} }
private routeLegacyCall(serverId: string, call: TestRpcCall): { target: RpcTarget; call: ProtocolRpcCall } { private routeLegacyCall(serverId: string, call: TestRpcCall): { target: RpcTarget; call: ProtocolRpcCall } {
if (!Array.isArray(call.args) || !call.args.every(isJsonValue)) {
throw new Error(`Test call ${call.method} requires JSON array arguments`);
}
const sessionOperation = ["prompt", "watch", "startWatch", "resnapshotWatch", "stopWatch"].includes(call.method); const sessionOperation = ["prompt", "watch", "startWatch", "resnapshotWatch", "stopWatch"].includes(call.method);
let target: RpcTarget = { serverId }; let target: RpcTarget = { serverId };
let args = call.args; let args = call.args;

View file

@ -14,8 +14,8 @@ export default defineConfig({
reporters: process.env.GITHUB_ACTIONS ? ["dot", "github-actions"] : ["dot"], reporters: process.env.GITHUB_ACTIONS ? ["dot", "github-actions"] : ["dot"],
}, },
resolve: { resolve: {
conditions: ["source"],
alias: [ alias: [
{ find: /^@earendil-works\/chord$/, replacement: src("../chord/src/index.ts") },
{ find: /^@earendil-works\/pi-agent-core$/, replacement: src("../agent/src/index.ts") }, { find: /^@earendil-works\/pi-agent-core$/, replacement: src("../agent/src/index.ts") },
{ find: /^@earendil-works\/pi-agent-core\/(.+)$/, replacement: `${src("../agent/src/")}$1.ts` }, { find: /^@earendil-works\/pi-agent-core\/(.+)$/, replacement: `${src("../agent/src/")}$1.ts` },
{ find: /^@earendil-works\/pi-ai$/, replacement: src("../ai/src/index.ts") }, { find: /^@earendil-works\/pi-ai$/, replacement: src("../ai/src/index.ts") },
@ -24,4 +24,5 @@ export default defineConfig({
{ find: /^@earendil-works\/pi-protocol$/, replacement: src("../protocol/src/index.ts") }, { find: /^@earendil-works\/pi-protocol$/, replacement: src("../protocol/src/index.ts") },
], ],
}, },
ssr: { resolve: { conditions: ["source"] } },
}); });

View file

@ -1,7 +1,6 @@
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
const chordIndex = fileURLToPath(new URL("../../chord/src/index.ts", import.meta.url));
const telemetryIndex = fileURLToPath(new URL("../../telemetry/src/index.ts", import.meta.url)); const telemetryIndex = fileURLToPath(new URL("../../telemetry/src/index.ts", import.meta.url));
const aiIndex = fileURLToPath(new URL("../../ai/src/index.ts", import.meta.url)); const aiIndex = fileURLToPath(new URL("../../ai/src/index.ts", import.meta.url));
const agentIndex = fileURLToPath(new URL("../../agent/src/index.ts", import.meta.url)); const agentIndex = fileURLToPath(new URL("../../agent/src/index.ts", import.meta.url));
@ -18,12 +17,13 @@ export default defineConfig({
}, },
}, },
resolve: { resolve: {
conditions: ["source"],
alias: [ alias: [
{ find: /^@earendil-works\/chord$/, replacement: chordIndex },
{ find: /^@earendil-works\/pi-telemetry$/, replacement: telemetryIndex }, { find: /^@earendil-works\/pi-telemetry$/, replacement: telemetryIndex },
{ find: /^@earendil-works\/pi-agent-core\/session\/testing$/, replacement: agentSessionTesting }, { find: /^@earendil-works\/pi-agent-core\/session\/testing$/, replacement: agentSessionTesting },
{ find: /^@earendil-works\/pi-agent-core$/, replacement: agentIndex }, { find: /^@earendil-works\/pi-agent-core$/, replacement: agentIndex },
{ find: /^@earendil-works\/pi-ai$/, replacement: aiIndex }, { find: /^@earendil-works\/pi-ai$/, replacement: aiIndex },
], ],
}, },
ssr: { resolve: { conditions: ["source"] } },
}); });

View file

@ -1,7 +1,6 @@
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
const chordIndex = fileURLToPath(new URL("../../chord/src/index.ts", import.meta.url));
const telemetryIndex = fileURLToPath(new URL("../../telemetry/src/index.ts", import.meta.url)); const telemetryIndex = fileURLToPath(new URL("../../telemetry/src/index.ts", import.meta.url));
const aiIndex = fileURLToPath(new URL("../../ai/src/index.ts", import.meta.url)); const aiIndex = fileURLToPath(new URL("../../ai/src/index.ts", import.meta.url));
const agentIndex = fileURLToPath(new URL("../../agent/src/index.ts", import.meta.url)); const agentIndex = fileURLToPath(new URL("../../agent/src/index.ts", import.meta.url));
@ -25,8 +24,8 @@ export default defineConfig({
}, },
}, },
resolve: { resolve: {
conditions: ["source"],
alias: [ alias: [
{ find: /^@earendil-works\/chord$/, replacement: chordIndex },
{ find: /^@earendil-works\/pi-telemetry$/, replacement: telemetryIndex }, { find: /^@earendil-works\/pi-telemetry$/, replacement: telemetryIndex },
{ find: /^@earendil-works\/pi-agent-core\/node$/, replacement: agentNode }, { find: /^@earendil-works\/pi-agent-core\/node$/, replacement: agentNode },
{ find: /^@earendil-works\/pi-agent-core\/session\/testing$/, replacement: agentSessionTesting }, { find: /^@earendil-works\/pi-agent-core\/session\/testing$/, replacement: agentSessionTesting },
@ -34,4 +33,5 @@ export default defineConfig({
{ find: /^@earendil-works\/pi-ai$/, replacement: aiIndex }, { find: /^@earendil-works\/pi-ai$/, replacement: aiIndex },
], ],
}, },
ssr: { resolve: { conditions: ["source"] } },
}); });

View file

@ -7,6 +7,8 @@
"paths": { "paths": {
"*": ["./*"], "*": ["./*"],
"@earendil-works/chord": ["./packages/chord/src/index.ts"], "@earendil-works/chord": ["./packages/chord/src/index.ts"],
"@earendil-works/chord/context": ["./packages/chord/src/context.ts"],
"@earendil-works/chord/json": ["./packages/chord/src/json.ts"],
"@earendil-works/pi-telemetry": ["./packages/telemetry/src/index.ts"], "@earendil-works/pi-telemetry": ["./packages/telemetry/src/index.ts"],
"@earendil-works/pi-telemetry/testing": ["./packages/telemetry/src/testing/index.ts"], "@earendil-works/pi-telemetry/testing": ["./packages/telemetry/src/testing/index.ts"],
"@earendil-works/pi-ai": ["./packages/ai/src/index.ts"], "@earendil-works/pi-ai": ["./packages/ai/src/index.ts"],

View file

@ -2,7 +2,6 @@ import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
export const workspaceSourcePaths = { export const workspaceSourcePaths = {
chordIndex: fileURLToPath(new URL("./packages/chord/src/index.ts", import.meta.url)),
telemetryIndex: fileURLToPath(new URL("./packages/telemetry/src/index.ts", import.meta.url)), telemetryIndex: fileURLToPath(new URL("./packages/telemetry/src/index.ts", import.meta.url)),
telemetryTesting: fileURLToPath(new URL("./packages/telemetry/src/testing/index.ts", import.meta.url)), telemetryTesting: fileURLToPath(new URL("./packages/telemetry/src/testing/index.ts", import.meta.url)),
aiIndex: fileURLToPath(new URL("./packages/ai/src/index.ts", import.meta.url)), aiIndex: fileURLToPath(new URL("./packages/ai/src/index.ts", import.meta.url)),
@ -22,8 +21,8 @@ export const workspaceSourcePaths = {
export default defineConfig({ export default defineConfig({
resolve: { resolve: {
conditions: ["source"],
alias: [ alias: [
{ find: /^@earendil-works\/chord$/, replacement: workspaceSourcePaths.chordIndex },
{ find: /^@earendil-works\/pi-telemetry$/, replacement: workspaceSourcePaths.telemetryIndex }, { find: /^@earendil-works\/pi-telemetry$/, replacement: workspaceSourcePaths.telemetryIndex },
{ find: /^@earendil-works\/pi-telemetry\/testing$/, replacement: workspaceSourcePaths.telemetryTesting }, { find: /^@earendil-works\/pi-telemetry\/testing$/, replacement: workspaceSourcePaths.telemetryTesting },
{ find: /^@earendil-works\/pi-ai$/, replacement: workspaceSourcePaths.aiIndex }, { find: /^@earendil-works\/pi-ai$/, replacement: workspaceSourcePaths.aiIndex },
@ -43,4 +42,5 @@ export default defineConfig({
{ find: /^@earendil-works\/pi-tui$/, replacement: workspaceSourcePaths.tuiIndex }, { find: /^@earendil-works\/pi-tui$/, replacement: workspaceSourcePaths.tuiIndex },
], ],
}, },
ssr: { resolve: { conditions: ["source"] } },
}); });