feat: isolate Chord context API

This commit is contained in:
Armin Ronacher 2026-08-29 20:01:32 +02:00
parent 6ec57e6e5f
commit e6dc574d1b
27 changed files with 88 additions and 155 deletions

View file

@ -1,19 +1,28 @@
import type { Context, ContextKey } from "@earendil-works/chord";
import {
awaitWithContext,
BACKGROUND_CONTEXT,
type Context,
type ContextKey,
INTERNAL_CONTEXT_OPERATIONS,
createContextKey,
TODO_CONTEXT,
withAbortSignal,
withCancel,
withContextValue,
withoutAbortSignal,
} from "@earendil-works/chord/context";
import { NOOP_TELEMETRY_CONTEXT, type TelemetryContext } from "@earendil-works/pi-telemetry";
export { BACKGROUND_CONTEXT, type Context, type ContextKey, TODO_CONTEXT };
export const awaitWithContext = INTERNAL_CONTEXT_OPERATIONS.awaitWithContext;
export const createContextKey = INTERNAL_CONTEXT_OPERATIONS.createContextKey;
export const withAbortSignal = INTERNAL_CONTEXT_OPERATIONS.withAbortSignal;
export const withCancel = INTERNAL_CONTEXT_OPERATIONS.withCancel;
export const withContextValue = INTERNAL_CONTEXT_OPERATIONS.withContextValue;
export const withoutAbortSignal = INTERNAL_CONTEXT_OPERATIONS.withoutAbortSignal;
export {
awaitWithContext,
BACKGROUND_CONTEXT,
type Context,
type ContextKey,
createContextKey,
TODO_CONTEXT,
withAbortSignal,
withCancel,
withContextValue,
withoutAbortSignal,
};
const TELEMETRY_CONTEXT_KEY = createContextKey<TelemetryContext>("pi.telemetryContext");

View file

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

View file

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

View file

@ -583,10 +583,10 @@ This is a planning aid, not a requirement to create all files immediately.
packages/chord/
src/
index.ts platform-neutral public API
api.ts public functions
types.ts public types, including strict JSON values
api.ts root-exported functions
types.ts root-exported types, including strict JSON values
context/
index.ts invocation context internals and compatibility entry
index.ts invocation context constants and functions
errors.ts lifecycle, service, and RPC errors
services/
types.ts tokens, contracts, modes, snapshots

View file

@ -48,9 +48,10 @@ The design has a few connected pieces:
The current runtime exports service tokens, singleton and keyed providers,
remote bindings, replicated state, facet hosts, and facet loaders from
`@earendil-works/chord`. All public functions are collected in
`@earendil-works/chord/api`, while all public types are collected in
`@earendil-works/chord/types`; both are re-exported from the main entry point.
`@earendil-works/chord`. Import public types and general runtime APIs from the
package root. Context constants and functions live in
`@earendil-works/chord/context` because their generic names should not pollute
the root API.
Chord-owned identifiers use the `chord.*` namespace and its reserved service
prefix is `$chord.*`.

View file

@ -11,21 +11,11 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./api": {
"source": "./src/api.ts",
"types": "./dist/api.d.ts",
"import": "./dist/api.js"
},
"./context": {
"source": "./src/context/index.ts",
"types": "./dist/context/index.d.ts",
"import": "./dist/context/index.js"
},
"./types": {
"source": "./src/types.ts",
"types": "./dist/types.d.ts",
"import": "./dist/types.js"
},
"./package.json": "./package.json"
},
"sideEffects": false,

View file

@ -1,19 +1,10 @@
import {
deriveCancellableContext,
deriveContextValue,
deriveContextWithAbortSignal,
deriveContextWithoutAbortSignal,
waitWithContext,
} from "./context/index.ts";
import { FacetKernel } from "./facets/host.ts";
import { disposeLoadedFacets } from "./facets/loader.ts";
import { RemoteServiceBindingImpl } from "./services/consumer.ts";
import { lookupServiceInstanceKey } from "./services/instances.ts";
import type { RemoteServiceProvider } from "./services/provider.ts";
import { createDeliveryContext, MutableReplicatedStateImpl } from "./services/state.ts";
import { MutableReplicatedStateImpl, serviceDeliveryContext } from "./services/state.ts";
import type {
Context,
ContextKey,
Facet,
FacetHost,
FacetLoader,
@ -27,44 +18,6 @@ import type {
Service,
} from "./types.ts";
export function createContextKey<T>(description: string): ContextKey<T> {
return Object.freeze({ token: Symbol(description) });
}
/** Derive a context containing one additional or replaced value. */
export function withContextValue<T>(key: ContextKey<T>, value: T, parent: Context): Context {
return deriveContextValue(key, value, parent);
}
/**
* Derive a context cancelled by either the parent signal or the supplied signal.
* The parent context remains unchanged.
*/
export function withAbortSignal(signal: AbortSignal, context: Context): Context {
return deriveContextWithAbortSignal(signal, context);
}
/** Derive a context retaining all values except caller cancellation. Intended for mandatory cleanup only. */
export function withoutAbortSignal(context: Context): Context {
return deriveContextWithoutAbortSignal(context);
}
/** Derive an independently cancellable child context. */
export function withCancel(context: Context): {
readonly context: Context;
readonly cancel: (reason?: unknown) => void;
} {
return deriveCancellableContext(context);
}
/**
* Observe a promise until it settles or the invocation is cancelled.
* Cancellation rejects only this waiter; it does not cancel the underlying promise.
*/
export function awaitWithContext<T>(promise: Promise<T>, context: Context): Promise<T> {
return waitWithContext(promise, context);
}
/** Create an active host for one complete set of facets. */
export async function createFacetHost(options: FacetOptions): Promise<FacetHost> {
const kernel = new FacetKernel(options);
@ -151,7 +104,7 @@ export function createLoopbackServiceConnection(provider: RemoteServiceProvider)
invoke: (call, context) => provider.invoke(call, context),
subscribe: async (serviceId, mode, listener) => {
const subscription = provider.subscribe(serviceId, mode, (update) =>
listener(update, createDeliveryContext()),
listener(update, serviceDeliveryContext()),
);
return {
snapshot: subscription.snapshot,
@ -165,8 +118,3 @@ export function createLoopbackServiceConnection(provider: RemoteServiceProvider)
export function replicatedState<T>(initial: T): MutableReplicatedState<T> {
return new MutableReplicatedStateImpl(initial);
}
/** @publicApiReview Delivery contexts may only be an internal service-replication concern. */
export function freshDeliveryContext(): Context {
return createDeliveryContext();
}

View file

@ -1,8 +1,6 @@
import type { Context, ContextKey } from "../types.ts";
export type { Context, ContextKey } from "../types.ts";
export const ABORT_SIGNAL_CONTEXT_KEY: ContextKey<AbortSignal | undefined> = Object.freeze({
const ABORT_SIGNAL_CONTEXT_KEY: ContextKey<AbortSignal | undefined> = Object.freeze({
token: Symbol("chord.abortSignal"),
});
@ -57,36 +55,47 @@ class ContextValue<T> extends BaseContext {
export const BACKGROUND_CONTEXT: Context = new EmptyContext("[Context BACKGROUND_CONTEXT]");
export const TODO_CONTEXT: Context = new EmptyContext("[Context TODO_CONTEXT]");
function createContextKey<T>(description: string): ContextKey<T> {
export function createContextKey<T>(description: string): ContextKey<T> {
return Object.freeze({ token: Symbol(description) });
}
export function deriveContextValue<T>(key: ContextKey<T>, value: T, parent: Context): Context {
/** Derive a context containing one additional or replaced value. */
export function withContextValue<T>(key: ContextKey<T>, value: T, parent: Context): Context {
return new ContextValue(parent, key, value);
}
export function deriveContextWithAbortSignal(signal: AbortSignal, context: Context): Context {
/**
* Derive a context cancelled by either the parent signal or the supplied signal.
* The parent context remains unchanged.
*/
export function withAbortSignal(signal: AbortSignal, context: Context): Context {
const parentSignal = context.abortSignal;
const combined = parentSignal === undefined ? signal : AbortSignal.any([parentSignal, signal]);
return deriveContextValue(ABORT_SIGNAL_CONTEXT_KEY, combined, context);
return withContextValue(ABORT_SIGNAL_CONTEXT_KEY, combined, context);
}
export function deriveContextWithoutAbortSignal(context: Context): Context {
return deriveContextValue(ABORT_SIGNAL_CONTEXT_KEY, undefined, context);
/** Derive a context retaining all values except caller cancellation. Intended for mandatory cleanup only. */
export function withoutAbortSignal(context: Context): Context {
return withContextValue(ABORT_SIGNAL_CONTEXT_KEY, undefined, context);
}
export function deriveCancellableContext(context: Context): {
/** Derive an independently cancellable child context. */
export function withCancel(context: Context): {
readonly context: Context;
readonly cancel: (reason?: unknown) => void;
} {
const controller = new AbortController();
return {
context: deriveContextWithAbortSignal(controller.signal, context),
context: withAbortSignal(controller.signal, context),
cancel: (reason?: unknown) => controller.abort(reason),
};
}
export function waitWithContext<T>(promise: Promise<T>, context: Context): Promise<T> {
/**
* Observe a promise until it settles or the invocation is cancelled.
* Cancellation rejects only this waiter; it does not cancel the underlying promise.
*/
export function awaitWithContext<T>(promise: Promise<T>, context: Context): Promise<T> {
const signal = context.abortSignal;
if (signal === undefined) return promise;
if (signal.aborted) return Promise.reject(abortError(signal));
@ -110,13 +119,3 @@ function abortError(signal: AbortSignal): Error {
const reason: unknown = signal.reason;
return reason instanceof Error ? reason : new DOMException("The operation was aborted", "AbortError");
}
/** @internal Narrow compatibility surface for the legacy context entry point. */
export const INTERNAL_CONTEXT_OPERATIONS = Object.freeze({
awaitWithContext: waitWithContext,
createContextKey,
withAbortSignal: deriveContextWithAbortSignal,
withCancel: deriveCancellableContext,
withContextValue: deriveContextValue,
withoutAbortSignal: deriveContextWithoutAbortSignal,
});

View file

@ -1,27 +1,17 @@
/**
* Chord is a standalone application-composition runtime.
*
* Its public API is under development.
* Chord is a standalone application-composition runtime for agentic applications.
*/
export {
awaitWithContext,
combineFacetLoaders,
createContextKey,
createFacetHost,
createLoopbackServiceConnection,
createRemoteServiceBinding,
createStaticFacetLoader,
defineFacet,
defineService,
freshDeliveryContext,
getServiceInstanceKey,
replicatedState,
withAbortSignal,
withCancel,
withContextValue,
withoutAbortSignal,
} from "./api.ts";
export { BACKGROUND_CONTEXT, TODO_CONTEXT } from "./context/index.ts";
export { RemoteServiceError } from "./services/errors.ts";
export { RemoteServiceProvider } from "./services/provider.ts";
export type {

View file

@ -1,4 +1,4 @@
import { BACKGROUND_CONTEXT, waitWithContext } from "../context/index.ts";
import { awaitWithContext, BACKGROUND_CONTEXT } from "../context/index.ts";
import type {
Context,
JsonValue,
@ -14,7 +14,7 @@ import type {
} from "../types.ts";
import { RemoteServiceError } from "./errors.ts";
import { InstanceDirectory, type InstanceDirectoryEntry } from "./instances.ts";
import { createDeliveryContext, ReplicatedStateReplica } from "./state.ts";
import { ReplicatedStateReplica, serviceDeliveryContext } from "./state.ts";
type ErrorReporter = (error: Error) => void;
type ServiceMemberKind = ServiceMemberSnapshot["kind"];
@ -346,7 +346,7 @@ class KeyedBinding<T> {
if (subscription.snapshot.mode !== "keyed" || subscription.snapshot.serviceId !== this.#service.id) {
throw new Error(`Remote service ${this.#service.id} returned the wrong keyed snapshot`);
}
for (const snapshot of subscription.snapshot.instances) this.#spawn(snapshot, createDeliveryContext());
for (const snapshot of subscription.snapshot.instances) this.#spawn(snapshot, serviceDeliveryContext());
this.#instances.ready();
subscription.activate();
}
@ -500,7 +500,7 @@ export class RemoteServiceBindingImpl implements RemoteServiceBinding {
),
...[...this.#keyed.values()].map((binding) => binding.ready()),
];
await waitWithContext(
await awaitWithContext(
Promise.all(starts).then(() => undefined),
context,
);
@ -595,7 +595,7 @@ export class RemoteServiceBindingImpl implements RemoteServiceBinding {
if (snapshot.mode !== "singleton" || snapshot.serviceId !== serviceId || snapshot.instances.length !== 1) {
throw new Error(`Remote service ${serviceId} returned an invalid singleton snapshot`);
}
binding.facade.install(snapshot.instances[0]!, createDeliveryContext());
binding.facade.install(snapshot.instances[0]!, serviceDeliveryContext());
subscription.activate();
}

View file

@ -1,4 +1,4 @@
import { BACKGROUND_CONTEXT, deriveCancellableContext } from "../context/index.ts";
import { BACKGROUND_CONTEXT, withCancel } from "../context/index.ts";
import type { Context } from "../types.ts";
const instanceKeys = new WeakMap<object, string>();
@ -134,7 +134,7 @@ export class InstanceDirectory<TEntry extends InstanceDirectoryEntry> {
#start(observer: Observer, entry: TEntry): void {
if (observer.closed || observer.tasks.has(entry)) return;
const { context, cancel } = deriveCancellableContext(BACKGROUND_CONTEXT);
const { context, cancel } = withCancel(BACKGROUND_CONTEXT);
observer.tasks.set(entry, { cancel });
try {
void Promise.resolve(observer.handler(entry.service, context)).catch((error: unknown) => {

View file

@ -14,7 +14,7 @@ import type {
ServiceSubscriptionSnapshot,
} from "../types.ts";
import { RemoteServiceError } from "./errors.ts";
import { createDeliveryContext } from "./state.ts";
import { serviceDeliveryContext } from "./state.ts";
import { getReplicatedStateInternals, type ReplicatedStateInternals } from "./state-internals.ts";
type RemoteMethod = (...args: unknown[]) => unknown;
@ -364,7 +364,7 @@ export class RemoteServiceProvider {
#emit(registration: ServiceRegistration, update: ServiceProviderUpdate, context?: Context): void {
if (registration.subscribers.size === 0) return;
const deliveryContext = context ?? createDeliveryContext();
const deliveryContext = context ?? serviceDeliveryContext();
for (const subscriber of registration.subscribers) {
if (subscriber.closed) continue;
const entry = { update, context: deliveryContext };

View file

@ -1,4 +1,4 @@
import { BACKGROUND_CONTEXT, deriveCancellableContext } from "../context/index.ts";
import { BACKGROUND_CONTEXT } from "../context/index.ts";
import type { Context, JsonValue, MutableReplicatedState, ReplicatedState } from "../types.ts";
import { registerReplicatedStateInternals } from "./state-internals.ts";
@ -41,7 +41,7 @@ export class MutableReplicatedStateImpl<T> implements MutableReplicatedState<T>
subscribe(listener: (value: T, context: Context) => void): () => void {
this.#listeners.add(listener);
listener(this.value, createDeliveryContext());
listener(this.value, serviceDeliveryContext());
return () => this.#listeners.delete(listener);
}
}
@ -63,7 +63,7 @@ export class ReplicatedStateReplica<T extends JsonValue = JsonValue> implements
subscribe(listener: (value: T, context: Context) => void): () => void {
this.#listeners.add(listener);
if (this.#value !== undefined) this.#deliver(listener, this.#value, createDeliveryContext());
if (this.#value !== undefined) this.#deliver(listener, this.#value, serviceDeliveryContext());
return () => this.#listeners.delete(listener);
}
@ -101,8 +101,10 @@ export class ReplicatedStateReplica<T extends JsonValue = JsonValue> implements
}
}
export function createDeliveryContext(): Context {
return deriveCancellableContext(BACKGROUND_CONTEXT).context;
/** @internal Context for synthetic service deliveries without a caller. */
export function serviceDeliveryContext(): Context {
// TODO: Add delivery-scoped cancellation or metadata if deliveries gain an owned lifecycle.
return BACKGROUND_CONTEXT;
}
function toError(error: unknown): Error {

View file

@ -8,7 +8,7 @@ import {
withCancel,
withContextValue,
withoutAbortSignal,
} from "../src/index.ts";
} from "../src/context/index.ts";
describe("Context", () => {
it("provides distinct empty root contexts", () => {

View file

@ -1,6 +1,6 @@
import { describe, expect, test, vi } from "vitest";
import { BACKGROUND_CONTEXT } from "../src/context/index.ts";
import {
BACKGROUND_CONTEXT,
type Context,
combineFacetLoaders,
createFacetHost,

View file

@ -1,6 +1,6 @@
import { describe, expect, test, vi } from "vitest";
import { BACKGROUND_CONTEXT } from "../src/context/index.ts";
import {
BACKGROUND_CONTEXT,
type Context,
createFacetHost,
createLoopbackServiceConnection,

View file

@ -1,6 +1,6 @@
import { describe, expect, test, vi } from "vitest";
import { BACKGROUND_CONTEXT } from "../src/context/index.ts";
import {
BACKGROUND_CONTEXT,
type Context,
createLoopbackServiceConnection,
createRemoteServiceBinding,

View file

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

View file

@ -1,5 +1,4 @@
import {
BACKGROUND_CONTEXT,
combineFacetLoaders,
createFacetHost,
createStaticFacetLoader,
@ -8,6 +7,7 @@ import {
type FacetLoader,
type LoadedFacets,
} from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import type { SessionSummary } from "@earendil-works/pi-protocol";
import {
CombinedAutocompleteProvider,

View file

@ -1,9 +1,7 @@
import {
BACKGROUND_CONTEXT,
type Context,
createRemoteServiceBinding,
type FacetConnection,
freshDeliveryContext,
type JsonValue,
type MutableReplicatedState,
type RemoteServiceBinding,
@ -13,6 +11,7 @@ import {
replicatedState,
type Service,
} from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import type { Client } from "@earendil-works/pi-client";
import type {
ProtocolRpcCall,
@ -464,7 +463,7 @@ function createRemoteServiceConnection(
target,
serviceId,
mode,
(update: ServiceProviderUpdate) => listener(update, freshDeliveryContext()),
(update: ServiceProviderUpdate) => listener(update, BACKGROUND_CONTEXT),
context.abortSignal,
);
return {

View file

@ -1,10 +1,5 @@
import {
BACKGROUND_CONTEXT,
type Context,
defineFacet,
type Facet,
type MutableReplicatedState,
} from "@earendil-works/chord";
import { type Context, defineFacet, type Facet, type MutableReplicatedState } from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import type { AgentLane, ThinkingLevel } from "@earendil-works/pi-agent-core";
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
import type { ModelRuntime } from "../../core/model-runtime.ts";

View file

@ -1,11 +1,11 @@
import {
BACKGROUND_CONTEXT,
type Context,
type ServiceProviderUpdate as CoreServiceProviderUpdate,
RemoteServiceProvider,
replicatedState,
type ServiceSubscription,
} from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import {
decodeServiceControlCall,
type JsonValue,

View file

@ -1,5 +1,4 @@
import {
BACKGROUND_CONTEXT,
createLoopbackServiceConnection,
createRemoteServiceBinding,
defineFacet,
@ -7,6 +6,7 @@ import {
RemoteServiceProvider,
replicatedState,
} from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import type { AgentLane } from "@earendil-works/pi-agent-core";
import type { LaneEvent, LaneSnapshot, SessionSummary } from "@earendil-works/pi-protocol";
import { ProcessTerminal, TuiMainScreen } from "@earendil-works/pi-tui";

View file

@ -1,6 +1,7 @@
import { lstat, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { BACKGROUND_CONTEXT, type Context, createFacetHost, defineFacet, withCancel } from "@earendil-works/chord";
import { type Context, createFacetHost, defineFacet } from "@earendil-works/chord";
import { BACKGROUND_CONTEXT, withCancel } from "@earendil-works/chord/context";
import { Client } from "@earendil-works/pi-client";
import { createUnixTransportFactory } from "@earendil-works/pi-client/unix";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";

View file

@ -1,4 +1,5 @@
import { BACKGROUND_CONTEXT, createFacetHost } from "@earendil-works/chord";
import { createFacetHost } from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { describe, expect, test } from "vitest";
import {
accountsServiceFacet,

View file

@ -1,7 +1,7 @@
import type { JsonValue } from "@earendil-works/chord/types";
import type { JsonValue } from "@earendil-works/chord";
import Type from "typebox";
/** Static JSON contract. Concrete serializers are responsible for rejecting unsupported runtime values. */
export const JsonValueSchema = Type.Unsafe<JsonValue>(Type.Unknown());
export type { JsonValue } from "@earendil-works/chord/types";
export type { JsonValue } from "@earendil-works/chord";

View file

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