mirror of
https://github.com/earendil-works/pi.git
synced 2026-10-02 00:35:27 +08:00
feat: add delta-backed replicated state
Track mutable provider state and publish immutable operation batches. Keep path codecs isolated per client subscription and state member.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import type { HarnessEvent, LaneSnapshot, LaneWatchEvent } from "../agent-harness.ts";
|
||||
import type { OperationResultRecord } from "../session/types.ts";
|
||||
|
||||
export type LaneSnapshotReduction = LaneSnapshot | { rebase: true };
|
||||
export type LaneSnapshotReduction = "rebase" | undefined;
|
||||
|
||||
function matchingOperation(
|
||||
snapshot: LaneSnapshot,
|
||||
@@ -10,145 +10,153 @@ function matchingOperation(
|
||||
return snapshot.operation?.id === operationId ? snapshot.operation : undefined;
|
||||
}
|
||||
|
||||
/** Apply one harness event to a replicated lane snapshot. Navigation completion requires a fresh snapshot. */
|
||||
/** Apply one harness event to a mutable lane snapshot. Navigation completion requires a fresh snapshot. */
|
||||
export function reduceLaneSnapshot(
|
||||
snapshot: LaneSnapshot,
|
||||
event: HarnessEvent | LaneWatchEvent,
|
||||
): LaneSnapshotReduction {
|
||||
if ("lane" in event && event.lane !== undefined && event.lane !== snapshot.lane && event.type !== "usage") {
|
||||
return snapshot;
|
||||
}
|
||||
const next = structuredClone(snapshot);
|
||||
if ("lane" in event && event.lane !== undefined && event.lane !== snapshot.lane && event.type !== "usage") return;
|
||||
switch (event.type) {
|
||||
case "run_start":
|
||||
next.operation = {
|
||||
snapshot.operation = {
|
||||
id: event.runId,
|
||||
kind: "run",
|
||||
startedAt: event.startedAt,
|
||||
fromTipId: next.tipId,
|
||||
fromTipId: snapshot.tipId,
|
||||
status: "open",
|
||||
runningTools: [],
|
||||
};
|
||||
return next;
|
||||
return;
|
||||
case "compaction_start":
|
||||
if (next.operation !== null) return next;
|
||||
next.operation = {
|
||||
if (snapshot.operation !== null) return;
|
||||
snapshot.operation = {
|
||||
id: event.runId,
|
||||
kind: "compaction",
|
||||
startedAt: event.startedAt,
|
||||
fromTipId: next.tipId,
|
||||
fromTipId: snapshot.tipId,
|
||||
status: "open",
|
||||
runningTools: [],
|
||||
};
|
||||
return next;
|
||||
return;
|
||||
case "navigation_start":
|
||||
next.operation = {
|
||||
snapshot.operation = {
|
||||
id: event.runId,
|
||||
kind: "navigation",
|
||||
startedAt: event.startedAt,
|
||||
fromTipId: next.tipId,
|
||||
fromTipId: snapshot.tipId,
|
||||
status: "open",
|
||||
runningTools: [],
|
||||
};
|
||||
return next;
|
||||
case "operation_abort":
|
||||
if (next.operation?.id === event.operationId) next.operation.status = "aborting";
|
||||
return next;
|
||||
case "run_resume":
|
||||
if (next.operation?.id === event.runId) delete next.operation.deferred;
|
||||
return next;
|
||||
case "run_suspend":
|
||||
if (next.operation?.id === event.runId) {
|
||||
next.operation.deferred = { handle: event.deferred, poll: event.poll };
|
||||
delete next.operation.streamingMessage;
|
||||
}
|
||||
return next;
|
||||
case "retry_scheduled":
|
||||
if (next.operation?.id === event.runId) {
|
||||
next.operation.retry = {
|
||||
return;
|
||||
case "operation_abort": {
|
||||
const operation = matchingOperation(snapshot, event.operationId);
|
||||
if (operation !== undefined) operation.status = "aborting";
|
||||
return;
|
||||
}
|
||||
case "run_resume": {
|
||||
const operation = matchingOperation(snapshot, event.runId);
|
||||
if (operation !== undefined) delete operation.deferred;
|
||||
return;
|
||||
}
|
||||
case "run_suspend": {
|
||||
const operation = matchingOperation(snapshot, event.runId);
|
||||
if (operation === undefined) return;
|
||||
delete operation.streamingMessage;
|
||||
operation.deferred = { handle: event.deferred, poll: event.poll };
|
||||
return;
|
||||
}
|
||||
case "retry_scheduled": {
|
||||
const operation = matchingOperation(snapshot, event.runId);
|
||||
if (operation !== undefined) {
|
||||
operation.retry = {
|
||||
attempt: event.attempt,
|
||||
maxAttempts: event.maxAttempts,
|
||||
nextAttemptAt: event.notBefore,
|
||||
};
|
||||
}
|
||||
return next;
|
||||
return;
|
||||
}
|
||||
case "retry_start":
|
||||
case "retry_end":
|
||||
if (next.operation?.id === event.runId) delete next.operation.retry;
|
||||
return next;
|
||||
case "message_start":
|
||||
case "retry_end": {
|
||||
const operation = matchingOperation(snapshot, event.runId);
|
||||
if (operation !== undefined) delete operation.retry;
|
||||
return;
|
||||
}
|
||||
case "message_start": {
|
||||
if (
|
||||
event.runId !== undefined &&
|
||||
next.operation?.id === event.runId &&
|
||||
event.message.role === "assistant" &&
|
||||
event.message.stopReason === "pending"
|
||||
event.runId === undefined ||
|
||||
event.message.role !== "assistant" ||
|
||||
event.message.stopReason !== "pending"
|
||||
) {
|
||||
next.operation.streamingMessage = event.message;
|
||||
return;
|
||||
}
|
||||
return next;
|
||||
case "message_update":
|
||||
if (next.operation?.id === event.runId && event.message.role === "assistant") {
|
||||
next.operation.streamingMessage = event.message;
|
||||
}
|
||||
return next;
|
||||
case "message_end":
|
||||
if (event.runId !== undefined && next.operation?.id === event.runId) {
|
||||
delete next.operation.streamingMessage;
|
||||
}
|
||||
return next;
|
||||
case "tool_start":
|
||||
if (next.operation?.id === event.runId) {
|
||||
next.operation.runningTools.push({
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
args: event.args,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
const operation = matchingOperation(snapshot, event.runId);
|
||||
if (operation !== undefined) operation.streamingMessage = event.message;
|
||||
return;
|
||||
}
|
||||
case "message_update": {
|
||||
if (event.message.role !== "assistant") return;
|
||||
const operation = matchingOperation(snapshot, event.runId);
|
||||
if (operation !== undefined) operation.streamingMessage = event.message;
|
||||
return;
|
||||
}
|
||||
case "message_end": {
|
||||
if (event.runId === undefined) return;
|
||||
const operation = matchingOperation(snapshot, event.runId);
|
||||
if (operation !== undefined) delete operation.streamingMessage;
|
||||
return;
|
||||
}
|
||||
case "tool_start": {
|
||||
const operation = matchingOperation(snapshot, event.runId);
|
||||
operation?.runningTools.push({
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
args: event.args,
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "tool_update": {
|
||||
const tool = next.operation?.runningTools.find((candidate) => candidate.toolCallId === event.toolCallId);
|
||||
const tool = snapshot.operation?.runningTools.find((candidate) => candidate.toolCallId === event.toolCallId);
|
||||
if (tool !== undefined) tool.partialResult = event.partialResult;
|
||||
return next;
|
||||
return;
|
||||
}
|
||||
case "tool_end":
|
||||
if (next.operation?.id === event.runId) {
|
||||
next.operation.runningTools = next.operation.runningTools.filter(
|
||||
(candidate) => candidate.toolCallId !== event.toolCallId,
|
||||
);
|
||||
}
|
||||
return next;
|
||||
case "entry_added": {
|
||||
const alreadyPresent = next.transcript.some((entry) => entry.id === event.entry.id);
|
||||
if (!alreadyPresent) {
|
||||
next.transcript = event.entry.type === "compaction" ? [event.entry] : [...next.transcript, event.entry];
|
||||
next.tipId = event.entry.id;
|
||||
if (event.entry.type === "message") next.stats.messageCount++;
|
||||
}
|
||||
return next;
|
||||
case "tool_end": {
|
||||
const operation = matchingOperation(snapshot, event.runId);
|
||||
const index =
|
||||
operation?.runningTools.findIndex((candidate) => candidate.toolCallId === event.toolCallId) ?? -1;
|
||||
if (operation !== undefined && index !== -1) operation.runningTools.splice(index, 1);
|
||||
return;
|
||||
}
|
||||
case "entry_added":
|
||||
if (snapshot.transcript.some((entry) => entry.id === event.entry.id)) return;
|
||||
if (event.entry.type === "compaction") snapshot.transcript.splice(0, snapshot.transcript.length, event.entry);
|
||||
else snapshot.transcript.push(event.entry);
|
||||
snapshot.tipId = event.entry.id;
|
||||
if (event.entry.type === "message") snapshot.stats.messageCount += 1;
|
||||
return;
|
||||
case "queue_update":
|
||||
next.queues = event.queues;
|
||||
return next;
|
||||
snapshot.queues = event.queues;
|
||||
return;
|
||||
case "usage":
|
||||
next.stats.usage = event.totals;
|
||||
return next;
|
||||
snapshot.stats.usage = event.totals;
|
||||
return;
|
||||
case "config_update":
|
||||
if (!("lane" in event) || event.lane !== next.lane) return next;
|
||||
if (!("lane" in event) || event.lane !== snapshot.lane) return;
|
||||
switch (event.property) {
|
||||
case "model":
|
||||
next.configuration.model = event.value;
|
||||
break;
|
||||
snapshot.configuration.model = event.value;
|
||||
return;
|
||||
case "thinkingLevel":
|
||||
next.configuration.thinkingLevel = event.value;
|
||||
break;
|
||||
snapshot.configuration.thinkingLevel = event.value;
|
||||
return;
|
||||
case "activeTools":
|
||||
next.configuration.activeToolNames = event.value;
|
||||
break;
|
||||
snapshot.configuration.activeToolNames = event.value;
|
||||
return;
|
||||
}
|
||||
return next;
|
||||
return;
|
||||
case "run_end": {
|
||||
const operation = matchingOperation(next, event.runId);
|
||||
if (operation?.kind !== "run") return next;
|
||||
const operation = matchingOperation(snapshot, event.runId);
|
||||
if (operation?.kind !== "run") return;
|
||||
const record: OperationResultRecord = {
|
||||
operationId: event.runId,
|
||||
kind: "run",
|
||||
@@ -159,38 +167,38 @@ export function reduceLaneSnapshot(
|
||||
startedAt: operation.startedAt,
|
||||
endedAt: event.endedAt,
|
||||
};
|
||||
next.lastResult = record;
|
||||
next.operation = null;
|
||||
next.tipId = event.tipId;
|
||||
return next;
|
||||
snapshot.lastResult = record;
|
||||
snapshot.operation = null;
|
||||
snapshot.tipId = event.tipId;
|
||||
return;
|
||||
}
|
||||
case "compaction_end": {
|
||||
const operation = matchingOperation(next, event.runId);
|
||||
if (operation?.kind !== "compaction") return next;
|
||||
const operation = matchingOperation(snapshot, event.runId);
|
||||
if (operation?.kind !== "compaction") return;
|
||||
const record: OperationResultRecord = {
|
||||
operationId: event.runId,
|
||||
kind: "compaction",
|
||||
status: event.status,
|
||||
...(event.status === "failed" ? { error: event.error } : {}),
|
||||
fromTipId: operation.fromTipId,
|
||||
tipId: next.tipId,
|
||||
tipId: snapshot.tipId,
|
||||
startedAt: operation.startedAt,
|
||||
endedAt: event.endedAt,
|
||||
};
|
||||
next.lastResult = record;
|
||||
next.operation = null;
|
||||
return next;
|
||||
snapshot.lastResult = record;
|
||||
snapshot.operation = null;
|
||||
return;
|
||||
}
|
||||
case "navigation_end":
|
||||
return { rebase: true };
|
||||
return "rebase";
|
||||
case "fault":
|
||||
next.faulted = true;
|
||||
return next;
|
||||
snapshot.faulted = true;
|
||||
return;
|
||||
case "handler_error":
|
||||
case "turn_start":
|
||||
case "turn_end":
|
||||
case "value_update":
|
||||
case "lane_created":
|
||||
return next;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,13 +36,10 @@ async function settleEvents(): Promise<void> {
|
||||
}
|
||||
|
||||
function fold(snapshot: LaneSnapshot, events: HarnessEvent[]): LaneSnapshot {
|
||||
let current = snapshot;
|
||||
for (const event of events) {
|
||||
const reduced = reduceLaneSnapshot(current, event);
|
||||
if ("rebase" in reduced) throw new Error(`Unexpected rebase for ${event.type}`);
|
||||
current = reduced;
|
||||
if (reduceLaneSnapshot(snapshot, event) === "rebase") throw new Error(`Unexpected rebase for ${event.type}`);
|
||||
}
|
||||
return current;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -156,23 +153,26 @@ describe("lane snapshot reducer", () => {
|
||||
runningTools: [],
|
||||
},
|
||||
};
|
||||
const started = reduceLaneSnapshot(running, {
|
||||
type: "compaction_start",
|
||||
lane: "main",
|
||||
runId: "run",
|
||||
reason: "threshold",
|
||||
startedAt: 2,
|
||||
});
|
||||
if ("rebase" in started) throw new Error("Unexpected segment rebase");
|
||||
const ended = reduceLaneSnapshot(started, {
|
||||
type: "compaction_end",
|
||||
lane: "main",
|
||||
runId: "run",
|
||||
reason: "threshold",
|
||||
status: "declined",
|
||||
endedAt: 3,
|
||||
});
|
||||
expect(ended).toMatchObject({ operation: { id: "run", kind: "run" } });
|
||||
expect(
|
||||
reduceLaneSnapshot(running, {
|
||||
type: "compaction_start",
|
||||
lane: "main",
|
||||
runId: "run",
|
||||
reason: "threshold",
|
||||
startedAt: 2,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
reduceLaneSnapshot(running, {
|
||||
type: "compaction_end",
|
||||
lane: "main",
|
||||
runId: "run",
|
||||
reason: "threshold",
|
||||
status: "declined",
|
||||
endedAt: 3,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(running).toMatchObject({ operation: { id: "run", kind: "run" } });
|
||||
});
|
||||
|
||||
it("marks navigation completion for rebase", async () => {
|
||||
@@ -200,6 +200,6 @@ describe("lane snapshot reducer", () => {
|
||||
endedAt: 2,
|
||||
},
|
||||
);
|
||||
expect(reduced).toEqual({ rebase: true });
|
||||
expect(reduced).toBe("rebase");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,9 +91,9 @@ export function fauxAssistantMessage(
|
||||
model: DEFAULT_MODEL_ID,
|
||||
usage: DEFAULT_USAGE,
|
||||
stopReason: options.stopReason ?? "stop",
|
||||
deferred: options.deferred,
|
||||
errorMessage: options.errorMessage,
|
||||
responseId: options.responseId,
|
||||
...(options.deferred === undefined ? {} : { deferred: options.deferred }),
|
||||
...(options.errorMessage === undefined ? {} : { errorMessage: options.errorMessage }),
|
||||
...(options.responseId === undefined ? {} : { responseId: options.responseId }),
|
||||
timestamp: options.timestamp ?? Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -203,7 +203,8 @@ export async function retryAssistantCall(
|
||||
} catch (error) {
|
||||
await callbacks?.onRetryFinished?.(false, attempt, lastRetry.errorMessage);
|
||||
if (error instanceof RetrySleepAbortError) {
|
||||
return { ...response, stopReason: "aborted", errorMessage: undefined };
|
||||
const { errorMessage: _errorMessage, ...rest } = response;
|
||||
return { ...rest, stopReason: "aborted" };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ 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.
|
||||
|
||||
Chord owns the static `JsonValue` contract and provides `JsonRepresentation<T>`, `isJsonValue()`, and `cloneJsonValue()` for adapter boundaries. The service runtime deliberately performs no automatic recursive validation; concrete serializers remain responsible for rejecting or normalizing unsupported values. Remote arguments, results, errors, snapshots, updates, catalogues, and RPC envelopes are expected to be finite strict JSON:
|
||||
Chord owns the static `JsonValue` contract and provides `JsonRepresentation<T>` plus `isJsonValue()` for adapter boundaries. The service runtime deliberately performs no automatic recursive validation; concrete serializers remain 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;
|
||||
- no `undefined`, sparse arrays, symbols, prototypes, cycles, classes, functions, `Map`, or `Set`; and
|
||||
@@ -379,12 +379,12 @@ Required semantics:
|
||||
4. Subscribing to cold state registers without immediate delivery.
|
||||
5. Subscription establishment installs update capture before taking the snapshot.
|
||||
6. Updates racing the snapshot are buffered and delivered after the snapshot with no gap.
|
||||
7. Updates carry a provider-binding-local sequence and are applied only in order.
|
||||
7. The source API exposes a tracked mutable state and explicit publication. Each publication flushes one decoded operation batch; connection adapters encode it independently per client/state stream, and replicas apply batches only in sequence order.
|
||||
8. A sequence gap clears readiness and triggers complete resubscription or reports a terminal binding error; stale state must not continue as current silently.
|
||||
9. Disconnect, provider withdrawal, route change, and replacement clear replica readiness.
|
||||
10. Reconnect or replacement installs a complete fresh snapshot in the existing state facade before later updates.
|
||||
11. Listener exceptions are isolated and reported through host policy.
|
||||
12. Values are borrowed immutable data. Chord does not defensively clone local reads, local writes, or local listener delivery. Wire encoding naturally detaches remote data, but callers must not depend on identity.
|
||||
12. Values are immutable data. Chord does not defensively clone local reads, local writes, or local listener delivery. Delta application preserves prior values and may structurally share unchanged data, but callers must not depend on identity.
|
||||
|
||||
State identity is structural:
|
||||
|
||||
@@ -398,13 +398,12 @@ Explicit non-goals:
|
||||
|
||||
- durability or reconstruction after process restart;
|
||||
- event history;
|
||||
- patch or delta replication in the initial API;
|
||||
- CRDT merging or multiple writers;
|
||||
- offline mutation replay;
|
||||
- automatic unchanged-value suppression; and
|
||||
- high-frequency stream transport.
|
||||
|
||||
Chord now exposes a standalone intent-preserving JSON delta primitive for durable output and other explicit consumers. Replicated service state still uses complete snapshots and ordered latest-value updates; adopting deltas there requires a separate snapshot/delta/gap-recovery design.
|
||||
Chord exposes an intent-preserving JSON delta primitive and uses its operation batches internally for remote replicated state. Initial hydration and reconnection carry a complete root replacement; producers mutate tracked state and flush compact operations on publication. Replicated-state sources do not select reducers or interact with path encoders. Every client/state pairing owns an independent encoder, and sequence handling rejects gaps before a later operation can be applied.
|
||||
|
||||
## 9. Symmetric RPC plumbing
|
||||
|
||||
@@ -830,7 +829,7 @@ Race tests should control exact points rather than use timing: subscription capt
|
||||
- network listeners, socket framing, reconnect loops, routing, or authentication;
|
||||
- server/client roles or fixed process topologies;
|
||||
- durable state, database integration, migrations, or transactional application writes;
|
||||
- CRDTs, offline writes, mutation replay, or generic delta replication;
|
||||
- CRDTs, offline writes, or mutation replay;
|
||||
- arbitrary object remoting, callbacks, remote references, or garbage collection of references;
|
||||
- serialized UI trees, remote tools, or remote hooks;
|
||||
- generic contribution registries; applications can expose these as process-local services;
|
||||
|
||||
+24
-11
@@ -32,9 +32,11 @@ The design has a few connected pieces:
|
||||
stable facade while a provider disconnects or is replaced.
|
||||
|
||||
- **Replicated state** exposes authoritative state to local and remote
|
||||
connected consumers. Replicas become ready from a complete snapshot, apply
|
||||
ordered updates, and become unready on disconnect or replacement until they
|
||||
are rehydrated.
|
||||
connected consumers. Producers mutate the tracked `state` proxy and call
|
||||
`publish(context)`; consumers receive complete immutable values. Chord flushes
|
||||
one decoded operation batch per publication, while each remote client/state
|
||||
stream owns independent path-codec state. Replicas become unready on disconnect
|
||||
or replacement until they are rehydrated.
|
||||
|
||||
- **Delta tracking** derives compact operations from tracked plain JSON at
|
||||
flush time. It preserves string append/front-truncation and array-append
|
||||
@@ -47,9 +49,8 @@ The design has a few connected pieces:
|
||||
requires strict-JSON arguments, results, snapshots, updates, and catalogues,
|
||||
but does not prescribe framing, routing, transport, or an application wire
|
||||
envelope. `JsonRepresentation<T>` derives a wire-safe type for application data
|
||||
with unknown payloads; `isJsonValue()` validates received values, while
|
||||
`cloneJsonValue()` validates, normalizes optional fields, and detaches outbound
|
||||
values at an adapter boundary. Symmetric RPC peers are planned as one optional
|
||||
with unknown payloads, while `isJsonValue()` validates received values at an
|
||||
adapter boundary. Symmetric RPC peers are planned as one optional
|
||||
implementation of this boundary.
|
||||
|
||||
- **Context** Chord provides a Go-like context system for cancellation and
|
||||
@@ -82,11 +83,23 @@ const replica = apply({ output: "", count: 0 }, ops);
|
||||
```
|
||||
|
||||
The first flush is always a complete base batch. Later flushes contain path-based
|
||||
changes. String assignments preserve pure appends and rolling-window movement as
|
||||
append and front-truncate operations; unrelated rewrites fall back to a set.
|
||||
Values inserted into tracked state become tracker-owned and must subsequently be
|
||||
mutated only through `state`. See the [Delta guide](src/delta/README.md) for
|
||||
mutation, array, lifecycle, and consumer-ownership rules.
|
||||
changes. `applyImmutable()` applies those batches while preserving prior replica
|
||||
revisions. `replicatedState(initial)` uses tracking directly:
|
||||
|
||||
```ts
|
||||
const status = env.replicatedState({ output: "", count: 0 });
|
||||
status.state.output += "done\n";
|
||||
status.state.count += 1;
|
||||
status.publish(context);
|
||||
```
|
||||
|
||||
`publish()` flushes once; remote connection plumbing encodes that operation batch
|
||||
independently for every client/state pairing. String assignments preserve pure
|
||||
appends and rolling-window movement as append and front-truncate operations;
|
||||
unrelated rewrites fall back to a set. Values inserted into tracked state become
|
||||
tracker-owned and must subsequently be mutated only through `state`. See the
|
||||
[Delta guide](src/delta/README.md) for mutation, array, lifecycle, and
|
||||
consumer-ownership rules.
|
||||
|
||||
## Bundling and loading facets
|
||||
|
||||
|
||||
@@ -85,6 +85,6 @@ export function createRemoteServiceBinding(options: RemoteServiceBindingOptions)
|
||||
return new RemoteServiceBindingImpl(options);
|
||||
}
|
||||
|
||||
export function replicatedState<T>(initial: T): MutableReplicatedState<T> {
|
||||
export function replicatedState<T extends object>(initial: T): MutableReplicatedState<T> {
|
||||
return new MutableReplicatedStateImpl(initial);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# Chord Delta
|
||||
|
||||
Chord Delta synchronizes one mutable JSON object or array from an authoritative
|
||||
producer to an ordered replica. It is available from `@earendil-works/chord/delta`.
|
||||
Chord Delta synchronizes JSON values from an authoritative producer to an
|
||||
ordered replica. It is available from `@earendil-works/chord/delta`.
|
||||
|
||||
A change is represented by an `Op`: a JSON tuple for replacing, setting,
|
||||
deleting, updating a string, or splicing an array. `flush()` returns `Op[]`;
|
||||
`apply()` replays an `Op[]` on a replica.
|
||||
deleting, updating a string, or splicing an array. Producers use `track()`;
|
||||
replicas use `apply()` or `applyImmutable()`.
|
||||
|
||||
```ts
|
||||
import { apply, track } from "@earendil-works/chord/delta";
|
||||
@@ -22,6 +22,11 @@ The first `flush()` returns one operation containing the complete value. Each
|
||||
later flush returns the operations needed to transform the previously published
|
||||
value into the current value. It returns `[]` when the value has not changed.
|
||||
|
||||
`applyImmutable()` copies only containers along changed paths and shares
|
||||
unchanged subtrees. It does not mutate, clone, or freeze either complete input.
|
||||
Chord's replicated-state producers mutate a tracked proxy and publish operation
|
||||
batches; consumers still observe complete immutable values.
|
||||
|
||||
## Sending or storing changes
|
||||
|
||||
`flush()` produces decoded `Op[]` with complete paths. This is convenient for
|
||||
@@ -55,8 +60,10 @@ encoder assigns numeric IDs to paths used across batches; the decoder remembers
|
||||
the corresponding definitions. A complete-value operation resets both path
|
||||
dictionaries, so replay can begin at that batch with a fresh decoder.
|
||||
|
||||
Path omission is local to one batch. Numeric path IDs may span batches. A second
|
||||
consumer needs its own stream and encoder state.
|
||||
Path omission is local to one batch. Numeric path IDs may span batches. Each
|
||||
independently hydrated replicated-state stream needs its own encoder and decoder.
|
||||
Do not share a pair between state members or subscriptions, even when their
|
||||
batches use the same ordered transport connection.
|
||||
|
||||
## Operation vocabulary
|
||||
|
||||
@@ -213,9 +220,8 @@ flush the structural change before editing elements at their new indices.
|
||||
Sparse arrays are unsupported. Writing beyond the next index throws. Increasing
|
||||
`length` creates explicit `null` elements; decreasing it removes elements.
|
||||
|
||||
`fill(object)` copies the object independently into each affected position.
|
||||
`copyWithin()` likewise separates duplicated objects. This keeps tracked state a
|
||||
tree rather than creating shared mutable array elements.
|
||||
`fill()` and `copyWithin()` keep normal JavaScript reference semantics. Do not use
|
||||
them to place one mutable object at multiple live paths.
|
||||
|
||||
### Optional properties
|
||||
|
||||
@@ -241,7 +247,8 @@ The object passed to `track()` becomes tracker-owned. The same applies to object
|
||||
later assigned into state or inserted into arrays.
|
||||
|
||||
After insertion, a retained reference may be read but must not be mutated or
|
||||
inserted at another live location:
|
||||
inserted at another live location. The tracker relies on this ownership rule; it
|
||||
does not recursively validate values or detect aliases:
|
||||
|
||||
```ts
|
||||
const item = { status: "new" };
|
||||
@@ -260,9 +267,8 @@ tracker.state.items.push(item); // unsupported alias
|
||||
```
|
||||
|
||||
Use distinct objects when values must appear at multiple paths. Perform
|
||||
mutations through `tracker.state`. Do not put a proxy read from `tracker.state`
|
||||
back into tracked state; mutate it in place or construct a replacement from
|
||||
plain data.
|
||||
mutations through `tracker.state`; do not put a proxy read from `tracker.state`
|
||||
back into tracked state.
|
||||
|
||||
Tracked state must be a mutable JSON tree:
|
||||
|
||||
@@ -288,20 +294,22 @@ replicas. Use it only when those replicas do not need the discarded changes.
|
||||
|
||||
`apply()` adopts object and array payloads from its input batch. Do not freeze a
|
||||
batch before applying it, and do not apply one in-memory batch to multiple
|
||||
replicas unless each replica receives its own clone. A serialized and decoded
|
||||
batch is already detached.
|
||||
mutable replicas unless each replica owns that batch. A serialized and decoded
|
||||
batch is already detached. `applyImmutable()` instead treats its previous value
|
||||
and operation payloads as immutable, so one batch can safely fan out in-process.
|
||||
|
||||
A `decode()` or `apply()` error terminates that stream. Discard its decoder and
|
||||
replica, then recover from a later base batch. `apply()` is not transactional;
|
||||
operations before the failing operation may already have changed the replica.
|
||||
A `decode()`, `apply()`, or `applyImmutable()` error terminates that stream.
|
||||
Discard its decoder and replica, then recover from a later base batch. `apply()`
|
||||
is not transactional; operations before the failing operation may already have
|
||||
changed the replica.
|
||||
|
||||
## Limits
|
||||
|
||||
- Delta assumes one authoritative writer and ordered delivery. Sequence numbers,
|
||||
gap detection, retries, and persistence policy belong to the surrounding
|
||||
protocol or storage format.
|
||||
- Object identity is not replicated. State must have tree structure rather than
|
||||
aliases or cycles.
|
||||
- Object identity is not replicated. Tracked mutable state must be a tree;
|
||||
immutable inputs may share references, but replicas need not preserve them.
|
||||
- Object key insertion order is not replicated. Do not compare or hash replicas
|
||||
using serialized key order.
|
||||
- Array operations that change indices may publish a wider array region, as
|
||||
|
||||
@@ -116,6 +116,8 @@ export interface Tracker<T extends object> {
|
||||
* must not mutate them outside this proxy.
|
||||
*/
|
||||
state: T;
|
||||
/** The untracked current value. Mutating it bypasses change tracking. */
|
||||
readonly target: T;
|
||||
flush(): Op[];
|
||||
/** Make the next flush a complete base batch without changing the value. */
|
||||
rebase(): void;
|
||||
@@ -147,7 +149,6 @@ const INDEX = /^(?:0|[1-9]\d*)$/;
|
||||
const norm = (target: object, key: string | symbol): Seg | symbol =>
|
||||
typeof key === "symbol" ? key : Array.isArray(target) && INDEX.test(key) ? Number(key) : key;
|
||||
const MUTATORS = new Set(["push", "pop", "shift", "unshift", "splice", "sort", "reverse", "fill", "copyWithin"]);
|
||||
const TRACKED_PROXIES = new WeakSet<object>();
|
||||
const MISSING = Symbol("missing");
|
||||
type MaybeJson = JsonValue | typeof MISSING;
|
||||
type ArrayDirty = { kind: "append"; start: number } | { kind: "diff" } | { kind: "replace" };
|
||||
@@ -257,7 +258,7 @@ function diffObject(
|
||||
scan: number,
|
||||
out: Op[],
|
||||
): void {
|
||||
if (path.length > 0 && [...Object.keys(before), ...Object.keys(after)].some((key) => RESERVED_SEGMENTS.has(key))) {
|
||||
if ([...Object.keys(before), ...Object.keys(after)].some((key) => RESERVED_SEGMENTS.has(key))) {
|
||||
emitSet(path, after, out);
|
||||
return;
|
||||
}
|
||||
@@ -432,7 +433,6 @@ const cloneOp = (op: Op): Op => {
|
||||
};
|
||||
|
||||
export function track<T extends object>(root: T, options: TrackerOptions = {}): Tracker<T> {
|
||||
validateJsonValue(root);
|
||||
const scan = options.maxOverlapScan ?? 65_536;
|
||||
let pending = dirtyNode();
|
||||
let hasPending = false;
|
||||
@@ -509,25 +509,7 @@ export function track<T extends object>(root: T, options: TrackerOptions = {}):
|
||||
return segment;
|
||||
};
|
||||
|
||||
const assertNoTrackedProxy = (value: unknown, seen = new Set<object>()): void => {
|
||||
if (!isObj(value) || seen.has(value)) return;
|
||||
if (TRACKED_PROXIES.has(value)) {
|
||||
throw new TypeError(
|
||||
"value contains tracked state; build replacements from plain data, not from state proxies",
|
||||
);
|
||||
}
|
||||
seen.add(value);
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (descriptor !== undefined && "value" in descriptor) assertNoTrackedProxy(descriptor.value, seen);
|
||||
}
|
||||
};
|
||||
|
||||
const adoptItems = (values: readonly unknown[]): JsonValue[] => {
|
||||
assertNoTrackedProxy(values);
|
||||
assertJsonValue(values);
|
||||
return values as JsonValue[];
|
||||
};
|
||||
const adoptItems = (values: readonly unknown[]): JsonValue[] => values as JsonValue[];
|
||||
|
||||
const integer = (value: unknown): number => {
|
||||
const number = Number(value);
|
||||
@@ -596,28 +578,9 @@ export function track<T extends object>(root: T, options: TrackerOptions = {}):
|
||||
result = spliceItems(target, index, remove, items);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
let methodArgs = args;
|
||||
let fillValue: JsonValue | undefined;
|
||||
if (key === "fill") {
|
||||
assertNoTrackedProxy(args[0]);
|
||||
assertJsonValue(args[0]);
|
||||
fillValue = cloneJson(args[0]);
|
||||
methodArgs = [fillValue, ...args.slice(1)];
|
||||
}
|
||||
default:
|
||||
markArrayDiff(path);
|
||||
result = Reflect.apply(Array.prototype[key as "sort"], target, methodArgs);
|
||||
if (isObj(fillValue)) {
|
||||
for (let index = 0; index < target.length; index++) {
|
||||
if (target[index] === fillValue) target[index] = cloneJson(fillValue as JsonValue);
|
||||
}
|
||||
} else if (key === "copyWithin") {
|
||||
for (let index = 0; index < target.length; index++) {
|
||||
if (isObj(target[index])) target[index] = cloneJson(target[index] as JsonValue);
|
||||
}
|
||||
}
|
||||
assertJsonValue(target);
|
||||
}
|
||||
result = Reflect.apply(Array.prototype[key as "sort"], target, args);
|
||||
}
|
||||
if (key === "pop") childProxies.delete(String(before - 1));
|
||||
else if (key !== "push") childProxies.clear();
|
||||
@@ -689,8 +652,6 @@ export function track<T extends object>(root: T, options: TrackerOptions = {}):
|
||||
if (previous === value) return true;
|
||||
const cached = childProxies.get(key);
|
||||
if (cached !== undefined && cached.target === previous && cached.proxy === value) return true;
|
||||
assertNoTrackedProxy(value);
|
||||
assertJsonValue(value);
|
||||
if (Array.isArray(target)) {
|
||||
const index = segment as number;
|
||||
if (index === target.length) markArrayAppend(path, target.length);
|
||||
@@ -726,7 +687,6 @@ export function track<T extends object>(root: T, options: TrackerOptions = {}):
|
||||
},
|
||||
});
|
||||
|
||||
TRACKED_PROXIES.add(proxy);
|
||||
return proxy as V;
|
||||
};
|
||||
|
||||
@@ -736,15 +696,15 @@ export function track<T extends object>(root: T, options: TrackerOptions = {}):
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
get target() {
|
||||
return root;
|
||||
},
|
||||
set state(next: T) {
|
||||
if (next === state) {
|
||||
clearPending();
|
||||
forceBase = true;
|
||||
return;
|
||||
}
|
||||
if (TRACKED_PROXIES.has(next)) throw new TypeError("tracker state cannot be replaced with tracked state");
|
||||
assertNoTrackedProxy(next);
|
||||
validateJsonValue(next);
|
||||
clearPending();
|
||||
root = next;
|
||||
state = wrap(root, []);
|
||||
@@ -814,63 +774,6 @@ export class UnsafePathError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects anything JSON.stringify would not round-trip and object aliases that
|
||||
* would lose identity when serialized. Tracked state is a JSON tree, not an
|
||||
* arbitrary object graph.
|
||||
*/
|
||||
export function assertJsonValue(value: unknown): asserts value is JsonValue {
|
||||
const visit = (candidate: unknown, seen: Set<unknown>): void => {
|
||||
if (candidate === null) return;
|
||||
const type = typeof candidate;
|
||||
if (type === "string" || type === "boolean") return;
|
||||
if (type === "number") {
|
||||
if (!Number.isFinite(candidate as number)) throw new TypeError("non-finite number is not JSON");
|
||||
return;
|
||||
}
|
||||
if (type !== "object") throw new TypeError(`${type} is not a JsonValue`);
|
||||
if (seen.has(candidate)) throw new TypeError("shared or cyclic object is not JSON state");
|
||||
seen.add(candidate);
|
||||
if (!Object.isExtensible(candidate)) throw new TypeError("non-extensible object is not mutable JSON state");
|
||||
if (Object.getOwnPropertySymbols(candidate).length > 0) throw new TypeError("symbol keys are not JSON");
|
||||
|
||||
const visitProperty = (key: string): void => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(candidate, key);
|
||||
if (descriptor === undefined) throw new TypeError("sparse arrays are not JSON state");
|
||||
if (!("value" in descriptor)) throw new TypeError("accessor properties are not JSON state");
|
||||
if (!descriptor.enumerable || !descriptor.writable || !descriptor.configurable) {
|
||||
throw new TypeError("JSON state properties must be enumerable, writable, and configurable");
|
||||
}
|
||||
visit(descriptor.value, seen);
|
||||
};
|
||||
|
||||
if (Array.isArray(candidate)) {
|
||||
if (Object.getOwnPropertyDescriptor(candidate, "length")?.writable !== true) {
|
||||
throw new TypeError("array length must be writable");
|
||||
}
|
||||
for (const key of Object.getOwnPropertyNames(candidate)) {
|
||||
if (key === "length") continue;
|
||||
if (!INDEX.test(key) || Number(key) >= candidate.length) {
|
||||
throw new TypeError("arrays cannot have non-index properties");
|
||||
}
|
||||
}
|
||||
for (let index = 0; index < candidate.length; index++) visitProperty(String(index));
|
||||
return;
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(candidate);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new TypeError("non-plain object is not a JsonValue");
|
||||
}
|
||||
for (const key of Object.getOwnPropertyNames(candidate)) visitProperty(key);
|
||||
};
|
||||
visit(value, new Set());
|
||||
}
|
||||
|
||||
function validateJsonValue(value: unknown): void {
|
||||
assertJsonValue(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verb, arity and payload shape for a **decoded** op: paths inline, no `#`, no
|
||||
* short forms. `apply` uses this.
|
||||
@@ -884,12 +787,10 @@ export function assertValidOp(op: unknown): asserts op is Op {
|
||||
switch (op[0]) {
|
||||
case "r":
|
||||
if (op.length !== 2) throw new TypeError("r arity");
|
||||
assertJsonValue(op[1]);
|
||||
return;
|
||||
case "s":
|
||||
if (op.length !== 3) throw new TypeError("s arity");
|
||||
assertPathArg(op[1], true);
|
||||
assertJsonValue(op[2]);
|
||||
return;
|
||||
case "d":
|
||||
if (op.length !== 2) throw new TypeError("d arity");
|
||||
@@ -909,7 +810,6 @@ export function assertValidOp(op: unknown): asserts op is Op {
|
||||
if (!Number.isInteger(op[2]) || op[2] < 0) throw new TypeError("p index");
|
||||
if (!Number.isInteger(op[3]) || op[3] < 0) throw new TypeError("p remove");
|
||||
if (!Array.isArray(op[4])) throw new TypeError("p items");
|
||||
for (const v of op[4]) assertJsonValue(v);
|
||||
return;
|
||||
}
|
||||
// Silently skipping an unknown verb is how a newer producer's op vanishes.
|
||||
@@ -941,14 +841,10 @@ export function assertValidWireOp(op: unknown): asserts op is WireOp {
|
||||
switch (verb) {
|
||||
case "r":
|
||||
if (op.length !== 2) throw new TypeError("r arity");
|
||||
assertJsonValue(op[1]);
|
||||
return;
|
||||
case "s":
|
||||
if (op.length === 3) {
|
||||
okRef(op[1]);
|
||||
assertJsonValue(op[2]);
|
||||
} else if (op.length === 2) assertJsonValue(op[1]);
|
||||
else throw new TypeError("s arity");
|
||||
if (op.length === 3) okRef(op[1]);
|
||||
else if (op.length !== 2) throw new TypeError("s arity");
|
||||
return;
|
||||
case "d":
|
||||
if (op.length === 2) okRef(op[1]);
|
||||
@@ -977,7 +873,6 @@ export function assertValidWireOp(op: unknown): asserts op is WireOp {
|
||||
if (!Number.isInteger(i) || (i as number) < 0) throw new TypeError("p index");
|
||||
if (!Number.isInteger(r) || (r as number) < 0) throw new TypeError("p remove");
|
||||
if (!Array.isArray(items)) throw new TypeError("p items");
|
||||
for (const v of items) assertJsonValue(v);
|
||||
return;
|
||||
}
|
||||
case "#": {
|
||||
@@ -1041,11 +936,14 @@ export class PathError extends Error {
|
||||
* `decode` first if the ops came from a boundary.
|
||||
*/
|
||||
export function apply<T>(target: T | undefined, ops: readonly Op[]): T {
|
||||
return applyOps(target, ops);
|
||||
}
|
||||
|
||||
function applyOps<T>(target: T | undefined, ops: readonly Op[]): T {
|
||||
let root = target as unknown as JsonValue;
|
||||
|
||||
for (const op of ops) {
|
||||
assertValidOp(op);
|
||||
|
||||
if (op[0] === "r") {
|
||||
// Adopted, not copied. The consumer owns the batch it was handed.
|
||||
//
|
||||
@@ -1112,6 +1010,59 @@ export function apply<T>(target: T | undefined, ops: readonly Op[]): T {
|
||||
return root as unknown as T;
|
||||
}
|
||||
|
||||
/** Apply decoded operations without mutating the previous immutable value. */
|
||||
export function applyImmutable<T>(target: T | undefined, ops: readonly Op[]): T {
|
||||
let root = target as unknown as JsonValue;
|
||||
for (const op of ops) {
|
||||
if (op[0] === "r") {
|
||||
assertValidOp(op);
|
||||
root = op[1];
|
||||
continue;
|
||||
}
|
||||
root = copyContainers(root, op[0] === "p" ? op[1] : op[1].slice(0, -1));
|
||||
root = applyOps(root, [op]);
|
||||
}
|
||||
return root as unknown as T;
|
||||
}
|
||||
|
||||
function copyContainers(root: JsonValue, path: Path): JsonValue {
|
||||
const copy = (value: JsonValue): JsonValue[] | Record<string, JsonValue> => {
|
||||
if (Array.isArray(value)) return value.slice();
|
||||
if (!isObj(value)) throw new PathError(path);
|
||||
const result = Object.create(Object.getPrototypeOf(value) === null ? null : Object.prototype) as Record<
|
||||
string,
|
||||
JsonValue
|
||||
>;
|
||||
for (const key of Object.keys(value)) {
|
||||
Object.defineProperty(result, key, {
|
||||
value: (value as Record<string, JsonValue>)[key],
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const copiedRoot = copy(root);
|
||||
let source = root;
|
||||
let destination = copiedRoot;
|
||||
for (const segment of path) {
|
||||
if (!isObj(source) || !Object.hasOwn(source, segment)) throw new PathError(path);
|
||||
if (Array.isArray(source) && typeof segment !== "number") throw new UnsafePathError(segment);
|
||||
const child = (source as Record<Seg, JsonValue>)[segment]!;
|
||||
const copiedChild = copy(child);
|
||||
Object.defineProperty(destination, segment, {
|
||||
value: copiedChild,
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
source = child;
|
||||
destination = copiedChild;
|
||||
}
|
||||
return copiedRoot;
|
||||
}
|
||||
|
||||
function resolveValue(root: JsonValue, path: Path): JsonValue {
|
||||
let node: JsonValue = root;
|
||||
for (const seg of path) {
|
||||
@@ -1136,12 +1087,10 @@ function resolve(root: JsonValue, path: Path): JsonValue {
|
||||
// Path interning and arity omission live between the tracker and a boundary;
|
||||
// `Op` and `apply` know nothing about them.
|
||||
//
|
||||
// ONE PAIR PER STREAM. The table spans an entire subscription or file: a path
|
||||
// interned in batch 3 is referenced in batch 40. A second consumer that
|
||||
// subscribes at batch 40 has never seen the definition, so it needs its own
|
||||
// encoder — sharing one across consumers hands the late subscriber ids it cannot
|
||||
// resolve. A base batch does not rescue it, because `["r", value]` carries no
|
||||
// path refs and leaves the table empty.
|
||||
// ONE PAIR PER INDEPENDENT STATE STREAM. Every decoder must observe exactly the
|
||||
// batches encoded by its matching encoder, beginning with that state's base.
|
||||
// Sharing a transport connection does not make separately hydrated states one
|
||||
// stream.
|
||||
|
||||
const pathKey = (path: Path): string => JSON.stringify(path);
|
||||
|
||||
|
||||
@@ -574,7 +574,7 @@ export class FacetKernel {
|
||||
this.#serviceSlots.observe(service, () => lifecycle.assertServiceAccess(), handler),
|
||||
);
|
||||
},
|
||||
replicatedState: <T>(initial: T) => {
|
||||
replicatedState: <T extends object>(initial: T) => {
|
||||
lifecycle.assertRunning("create replicated state");
|
||||
return new MutableReplicatedStateImpl(initial);
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ export {
|
||||
defineService,
|
||||
replicatedState,
|
||||
} from "./api.ts";
|
||||
export { cloneJsonValue, isJsonValue } from "./json.ts";
|
||||
export { isJsonValue } from "./json.ts";
|
||||
export { RemoteServiceError } from "./services/errors.ts";
|
||||
export { RemoteServiceProvider } from "./services/provider.ts";
|
||||
export type {
|
||||
@@ -32,6 +32,7 @@ export type {
|
||||
RemoteServices,
|
||||
RemoteServiceTransport,
|
||||
ReplicatedState,
|
||||
ReplicatedStateDelivery,
|
||||
Service,
|
||||
ServiceCall,
|
||||
ServiceCatalogueEntry,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { JsonRepresentation, JsonValue } from "./types.ts";
|
||||
import type { JsonValue } from "./types.ts";
|
||||
|
||||
/** Return whether a value is finite strict JSON with plain objects and no cycles. */
|
||||
export function isJsonValue(value: unknown): value is JsonValue {
|
||||
@@ -48,63 +48,3 @@ function check(value: unknown, ancestors: Set<object>, depth: number): boolean {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Clone one finite plain JavaScript value as strict JSON, omitting undefined object properties. */
|
||||
export function cloneJsonValue<T>(value: T): JsonRepresentation<T> {
|
||||
return clone(value, new Set<object>()) as JsonRepresentation<T>;
|
||||
}
|
||||
|
||||
function clone(value: unknown, ancestors: Set<object>): JsonValue {
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) throw new TypeError("JSON values must contain only finite numbers");
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const keys = Reflect.ownKeys(value);
|
||||
if (keys.length !== value.length + 1 || keys.some((key) => typeof key !== "string")) {
|
||||
throw new TypeError("JSON arrays must not contain extra properties");
|
||||
}
|
||||
if (ancestors.has(value)) throw new TypeError("JSON values must not contain cycles");
|
||||
ancestors.add(value);
|
||||
try {
|
||||
const result: JsonValue[] = [];
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
||||
if (descriptor === undefined) throw new TypeError("JSON arrays must not contain empty slots");
|
||||
if (!descriptor.enumerable || !("value" in descriptor)) {
|
||||
throw new TypeError("JSON arrays must contain only data properties");
|
||||
}
|
||||
if (descriptor.value === undefined) throw new TypeError("JSON arrays must not contain undefined values");
|
||||
result.push(clone(descriptor.value, ancestors));
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new TypeError("JSON values must contain only plain objects");
|
||||
}
|
||||
if (Object.getOwnPropertySymbols(value).length > 0) {
|
||||
throw new TypeError("JSON objects must not contain symbol properties");
|
||||
}
|
||||
if (ancestors.has(value)) throw new TypeError("JSON values must not contain cycles");
|
||||
ancestors.add(value);
|
||||
try {
|
||||
const result: { [key: string]: JsonValue } = {};
|
||||
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
|
||||
if (!descriptor.enumerable || !("value" in descriptor)) {
|
||||
throw new TypeError("JSON objects must contain only enumerable data properties");
|
||||
}
|
||||
if (descriptor.value !== undefined) result[key] = clone(descriptor.value, ancestors);
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
}
|
||||
throw new TypeError(`Unsupported JSON value: ${typeof value}`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { awaitWithContext, BACKGROUND_CONTEXT } from "../context/index.ts";
|
||||
import type { Op } from "../delta/index.ts";
|
||||
import type {
|
||||
Context,
|
||||
JsonValue,
|
||||
@@ -75,14 +76,14 @@ class MemberSlot {
|
||||
}
|
||||
}
|
||||
|
||||
hydrate(sequence: number, value: JsonValue, context: Context): void {
|
||||
hydrate(sequence: number, ops: readonly Op[], context: Context): void {
|
||||
this.setDescription("state");
|
||||
this.#state.hydrate(sequence, value, context);
|
||||
this.#state.hydrate(sequence, ops, context);
|
||||
}
|
||||
|
||||
update(sequence: number, value: JsonValue, context: Context): void {
|
||||
update(sequence: number, ops: readonly Op[], context: Context): void {
|
||||
this.setDescription("state");
|
||||
this.#state.update(sequence, value, context);
|
||||
this.#state.update(sequence, ops, context);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
@@ -186,18 +187,18 @@ class ServiceFacade {
|
||||
for (const member of members.values()) {
|
||||
const slot = this.#slots.get(member.name);
|
||||
if (member.kind === "state") {
|
||||
(slot ?? this.#slot(member.name)).hydrate(member.sequence, member.value, context);
|
||||
(slot ?? this.#slot(member.name)).hydrate(member.sequence, member.ops, context);
|
||||
} else {
|
||||
slot?.setDescription(member.kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
update(member: string, sequence: number, value: JsonValue, context: Context): void {
|
||||
update(member: string, sequence: number, ops: readonly Op[], context: Context): void {
|
||||
if (this.#descriptions.get(member) !== "state") {
|
||||
throw new Error(`Remote service update targets non-state member ${this.#serviceId}.${member}`);
|
||||
}
|
||||
this.#slot(member).update(sequence, value, context);
|
||||
this.#slot(member).update(sequence, ops, context);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
@@ -386,7 +387,7 @@ class KeyedBinding<T> {
|
||||
if (update.instance === undefined) throw new Error("Keyed state update has no instance address");
|
||||
const instance = this.#instances.get(update.instance.key);
|
||||
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.ops, context);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -586,7 +587,7 @@ export class RemoteServiceBindingImpl implements RemoteServiceBinding {
|
||||
}
|
||||
binding.facade.install(update.snapshot, context);
|
||||
} else if (update.type === "state" && update.instance === undefined) {
|
||||
binding.facade.update(update.member, update.sequence, update.value, context);
|
||||
binding.facade.update(update.member, update.sequence, update.ops, context);
|
||||
}
|
||||
} catch (error) {
|
||||
this.#reportError(toError(error));
|
||||
|
||||
@@ -239,6 +239,7 @@ export class RemoteServiceProvider {
|
||||
terminated: false,
|
||||
closed: false,
|
||||
};
|
||||
this.#publishPending(registration);
|
||||
registration.subscribers.add(subscriber);
|
||||
const snapshot = this.#snapshot(registration);
|
||||
return {
|
||||
@@ -332,7 +333,7 @@ export class RemoteServiceProvider {
|
||||
for (const [name, member] of classified.members) {
|
||||
if (member.kind !== "state") continue;
|
||||
removeMemberListeners.push(
|
||||
member.state.subscribe((value, sequence, context) => {
|
||||
member.state.subscribe((ops, sequence, context) => {
|
||||
if (!instance.active) return;
|
||||
this.#emit(
|
||||
registration,
|
||||
@@ -341,7 +342,7 @@ export class RemoteServiceProvider {
|
||||
...(address === undefined ? {} : { instance: address }),
|
||||
member: name,
|
||||
sequence,
|
||||
value: value as JsonValue,
|
||||
ops,
|
||||
},
|
||||
context,
|
||||
);
|
||||
@@ -395,6 +396,21 @@ export class RemoteServiceProvider {
|
||||
return instance;
|
||||
}
|
||||
|
||||
#publishPending(registration: ServiceRegistration): void {
|
||||
const context = serviceDeliveryContext();
|
||||
const instances =
|
||||
registration.mode === "singleton"
|
||||
? registration.singleton === undefined
|
||||
? []
|
||||
: [registration.singleton]
|
||||
: registration.instances.values();
|
||||
for (const instance of instances) {
|
||||
for (const member of instance.members.values()) {
|
||||
if (member.kind === "state") member.state.publish(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#snapshot(registration: ServiceRegistration): ServiceSubscriptionSnapshot {
|
||||
const instances =
|
||||
registration.mode === "singleton"
|
||||
@@ -417,7 +433,7 @@ export class RemoteServiceProvider {
|
||||
name,
|
||||
kind: "state",
|
||||
sequence: member.state.sequence,
|
||||
value: member.state.value as JsonValue,
|
||||
ops: [["r", member.state.value as JsonValue]],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { Op } from "../delta/index.ts";
|
||||
import type { Context } from "../types.ts";
|
||||
|
||||
export interface ReplicatedStateInternals {
|
||||
readonly sequence: number;
|
||||
readonly value: unknown;
|
||||
subscribe(listener: (value: unknown, sequence: number, context: Context) => void): () => void;
|
||||
publish(context: Context): void;
|
||||
subscribe(listener: (ops: readonly Op[], sequence: number, context: Context) => void): () => void;
|
||||
}
|
||||
|
||||
const sources = new WeakMap<object, ReplicatedStateInternals>();
|
||||
|
||||
@@ -1,54 +1,64 @@
|
||||
import { BACKGROUND_CONTEXT } from "../context/index.ts";
|
||||
import type { Context, JsonValue, MutableReplicatedState, ReplicatedState } from "../types.ts";
|
||||
import { applyImmutable, isBase, type Op, type Tracker, track } from "../delta/index.ts";
|
||||
import type { Context, JsonValue, MutableReplicatedState, ReplicatedState, ReplicatedStateDelivery } from "../types.ts";
|
||||
import { registerReplicatedStateInternals } from "./state-internals.ts";
|
||||
|
||||
export 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;
|
||||
export class MutableReplicatedStateImpl<T extends object> implements MutableReplicatedState<T> {
|
||||
readonly #listeners = new Set<(value: T, context: Context, delivery: ReplicatedStateDelivery) => void>();
|
||||
readonly #sourceListeners = new Set<(ops: readonly Op[], sequence: number, context: Context) => void>();
|
||||
readonly #tracker: Tracker<T>;
|
||||
#publishedValue: T;
|
||||
#sequence = 0;
|
||||
|
||||
constructor(initial: T) {
|
||||
this.#value = initial;
|
||||
this.#tracker = track(initial);
|
||||
this.#publishedValue = applyImmutable(undefined, this.#tracker.flush()) as unknown as T;
|
||||
const thisSource = this;
|
||||
registerReplicatedStateInternals(this, {
|
||||
get sequence() {
|
||||
return thisSource.#sequence;
|
||||
},
|
||||
get value() {
|
||||
return thisSource.#value;
|
||||
return thisSource.#publishedValue;
|
||||
},
|
||||
publish: (context) => thisSource.publish(context),
|
||||
subscribe: (listener) => {
|
||||
const sourceListener = (value: T, sequence: number, context: Context): void => {
|
||||
listener(value, sequence, context);
|
||||
};
|
||||
thisSource.#sourceListeners.add(sourceListener);
|
||||
return () => thisSource.#sourceListeners.delete(sourceListener);
|
||||
thisSource.#sourceListeners.add(listener);
|
||||
return () => thisSource.#sourceListeners.delete(listener);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get value(): T {
|
||||
return this.#value;
|
||||
return this.#publishedValue;
|
||||
}
|
||||
|
||||
set(value: T, context: Context): void {
|
||||
this.#value = value;
|
||||
get state(): T {
|
||||
return this.#tracker.state;
|
||||
}
|
||||
|
||||
publish(context: Context): void {
|
||||
const ops = this.#tracker.flush();
|
||||
if (ops.length === 0) return;
|
||||
this.#sequence += 1;
|
||||
for (const listener of this.#sourceListeners) listener(value, this.#sequence, context);
|
||||
for (const listener of this.#listeners) listener(value, context);
|
||||
this.#publishedValue = applyImmutable(this.#publishedValue as unknown as JsonValue, ops) as unknown as T;
|
||||
for (const listener of [...this.#sourceListeners]) listener(ops, this.#sequence, context);
|
||||
const delivery = { kind: "update", sequence: this.#sequence } as const;
|
||||
for (const listener of [...this.#listeners]) listener(this.#publishedValue, context, delivery);
|
||||
}
|
||||
|
||||
subscribe(listener: (value: T, context: Context) => void): () => void {
|
||||
subscribe(listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => void): () => void {
|
||||
const context = serviceDeliveryContext();
|
||||
this.publish(context);
|
||||
this.#listeners.add(listener);
|
||||
listener(this.value, serviceDeliveryContext());
|
||||
listener(this.#publishedValue, context, { kind: "hydrate", sequence: this.#sequence });
|
||||
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 #listeners = new Set<(value: T, context: Context, delivery: ReplicatedStateDelivery) => void>();
|
||||
readonly #reportError: (error: Error) => void;
|
||||
#value: T | undefined;
|
||||
#sequence: number | undefined;
|
||||
@@ -61,25 +71,37 @@ export class ReplicatedStateReplica<T extends JsonValue = JsonValue> implements
|
||||
return this.#value;
|
||||
}
|
||||
|
||||
subscribe(listener: (value: T, context: Context) => void): () => void {
|
||||
subscribe(listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => void): () => void {
|
||||
this.#listeners.add(listener);
|
||||
if (this.#value !== undefined) this.#deliver(listener, this.#value, serviceDeliveryContext());
|
||||
if (this.#value !== undefined) {
|
||||
this.#deliver(listener, this.#value, serviceDeliveryContext(), {
|
||||
kind: "hydrate",
|
||||
sequence: this.#sequence!,
|
||||
});
|
||||
}
|
||||
return () => this.#listeners.delete(listener);
|
||||
}
|
||||
|
||||
hydrate(sequence: number, value: T, context: Context): void {
|
||||
hydrate(sequence: number, ops: readonly Op[], context: Context): void {
|
||||
if (!isBase(ops)) throw new Error("Replicated state snapshot is not a base operation batch");
|
||||
const value = applyImmutable<T>(undefined, ops);
|
||||
this.#sequence = sequence;
|
||||
this.#value = value;
|
||||
this.#deliverAll(context);
|
||||
this.#deliverAll(context, { kind: "hydrate", sequence });
|
||||
}
|
||||
|
||||
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");
|
||||
update(sequence: number, ops: readonly Op[], context: Context): void {
|
||||
if (this.#sequence === undefined || this.#value === undefined) {
|
||||
throw new Error("Replicated state received an update before hydration");
|
||||
}
|
||||
if (sequence !== this.#sequence + 1) {
|
||||
this.clear();
|
||||
throw new Error("Replicated state update sequence has a gap");
|
||||
}
|
||||
const value = applyImmutable(this.#value, ops);
|
||||
this.#sequence = sequence;
|
||||
this.#value = value;
|
||||
this.#deliverAll(context);
|
||||
this.#deliverAll(context, { kind: "update", sequence });
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
@@ -87,14 +109,19 @@ export class ReplicatedStateReplica<T extends JsonValue = JsonValue> implements
|
||||
this.#sequence = undefined;
|
||||
}
|
||||
|
||||
#deliverAll(context: Context): void {
|
||||
#deliverAll(context: Context, delivery: ReplicatedStateDelivery): void {
|
||||
if (this.#value === undefined) return;
|
||||
for (const listener of this.#listeners) this.#deliver(listener, this.#value, context);
|
||||
for (const listener of this.#listeners) this.#deliver(listener, this.#value, context, delivery);
|
||||
}
|
||||
|
||||
#deliver(listener: (value: T, context: Context) => void, value: T, context: Context): void {
|
||||
#deliver(
|
||||
listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => void,
|
||||
value: T,
|
||||
context: Context,
|
||||
delivery: ReplicatedStateDelivery,
|
||||
): void {
|
||||
try {
|
||||
listener(value, context);
|
||||
listener(value, context, delivery);
|
||||
} catch (error) {
|
||||
this.#reportError(toError(error));
|
||||
}
|
||||
|
||||
+19
-11
@@ -1,3 +1,4 @@
|
||||
import type { Op } from "./delta/index.ts";
|
||||
import type { RemoteServiceProvider } from "./services/provider.ts";
|
||||
|
||||
export type { RemoteServiceError } from "./services/errors.ts";
|
||||
@@ -34,17 +35,24 @@ export type JsonRepresentation<T> = IsAny<T> extends true
|
||||
? { [TKey in keyof T]: JsonRepresentation<T[TKey]> }
|
||||
: never;
|
||||
|
||||
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 ReplicatedStateDelivery {
|
||||
readonly kind: "hydrate" | "update";
|
||||
readonly sequence: number;
|
||||
}
|
||||
|
||||
export interface MutableReplicatedState<T> extends ReplicatedState<T> {
|
||||
export interface ReplicatedState<T> {
|
||||
/** Immutable value, or undefined until hydration. Later updates do not mutate previously returned values. */
|
||||
readonly value: T | undefined;
|
||||
/** Listener values are immutable and may structurally share unchanged data with other revisions. */
|
||||
subscribe(listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => void): () => void;
|
||||
}
|
||||
|
||||
export interface MutableReplicatedState<T extends object> 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;
|
||||
/** Mutable tracked state. All writes must go through this proxy. */
|
||||
readonly state: T;
|
||||
/** Publish the changes made through {@link state} since the previous publication. */
|
||||
publish(context: Context): void;
|
||||
}
|
||||
|
||||
declare const SERVICE_TYPE: unique symbol;
|
||||
@@ -125,7 +133,7 @@ export type ServiceInstanceAddress = {
|
||||
|
||||
export type ServiceMemberSnapshot =
|
||||
| { readonly name: string; readonly kind: "method" }
|
||||
| { readonly name: string; readonly kind: "state"; readonly sequence: number; readonly value: JsonValue };
|
||||
| { readonly name: string; readonly kind: "state"; readonly sequence: number; readonly ops: readonly Op[] };
|
||||
|
||||
export type ServiceInstanceSnapshot = {
|
||||
readonly instance?: ServiceInstanceAddress;
|
||||
@@ -144,7 +152,7 @@ export type ServiceProviderUpdate =
|
||||
readonly instance?: ServiceInstanceAddress;
|
||||
readonly member: string;
|
||||
readonly sequence: number;
|
||||
readonly value: JsonValue;
|
||||
readonly ops: readonly Op[];
|
||||
}
|
||||
| { readonly type: "unavailable" }
|
||||
| { readonly type: "replaced"; readonly snapshot: ServiceInstanceSnapshot }
|
||||
@@ -215,7 +223,7 @@ export interface FacetEnvironment {
|
||||
/** Declare ownership of a multi-instance service and return its deferred spawning capability. */
|
||||
provideMany<T>(service: Service<T>): ServiceSpawner<T>;
|
||||
/** Create initialized mutable state suitable for exposing through a service implementation. */
|
||||
replicatedState<T>(initial: T): MutableReplicatedState<T>;
|
||||
replicatedState<T extends object>(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. */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
apply,
|
||||
assertJsonValue,
|
||||
applyImmutable,
|
||||
assertValidOp,
|
||||
assertValidWireOp,
|
||||
decoder,
|
||||
@@ -306,26 +306,6 @@ describe("tracker: root ops", () => {
|
||||
expect(apply(structuredClone(initial), t.flush())).toEqual(t.state);
|
||||
});
|
||||
|
||||
it("detaches objects supplied to fill", () => {
|
||||
const supplied = { value: 1 };
|
||||
const t = track({ xs: [{ value: 0 }, { value: 0 }] });
|
||||
t.flush();
|
||||
t.state.xs.fill(supplied);
|
||||
supplied.value = 2;
|
||||
expect(t.state.xs).toEqual([{ value: 1 }, { value: 1 }]);
|
||||
expect(t.state.xs[0]).not.toBe(t.state.xs[1]);
|
||||
expect(apply({ xs: [{ value: 0 }, { value: 0 }] }, t.flush())).toEqual(t.state);
|
||||
});
|
||||
|
||||
it("keeps copyWithin results as a JSON tree", () => {
|
||||
const t = track({ xs: [{ value: 1 }, { value: 2 }] });
|
||||
t.flush();
|
||||
t.state.xs.copyWithin(1, 0, 1);
|
||||
expect(t.state.xs).toEqual([{ value: 1 }, { value: 1 }]);
|
||||
expect(t.state.xs[0]).not.toBe(t.state.xs[1]);
|
||||
expect(apply({ xs: [{ value: 1 }, { value: 2 }] }, t.flush())).toEqual(t.state);
|
||||
});
|
||||
|
||||
it("combines retained element edits with one append", () => {
|
||||
const initial = { messages: [{ text: "a" }, { text: "b" }], flag: 0 };
|
||||
const t = track(structuredClone(initial));
|
||||
@@ -423,14 +403,6 @@ describe("replacing the whole value", () => {
|
||||
expect(t.flush()).toEqual([["s", ["value"], 2]]);
|
||||
});
|
||||
|
||||
it("rejects replacing state with a nested tracked proxy", () => {
|
||||
const t = track<{ nested: Record<string, number> }>({ nested: { value: 1 } });
|
||||
const nested = t.state.nested;
|
||||
expect(() => {
|
||||
t.state = nested as unknown as { nested: Record<string, number> };
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("allows a tracked child self-assignment as a no-op", () => {
|
||||
const t = track({ child: { value: 1 } });
|
||||
t.flush();
|
||||
@@ -439,28 +411,6 @@ describe("replacing the whole value", () => {
|
||||
expect(t.flush()).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects assigning a nested tracked proxy into state", () => {
|
||||
const t = track<{
|
||||
left: Record<string, number>;
|
||||
right?: Record<string, number>;
|
||||
wrapped?: { inner: Record<string, number> };
|
||||
items?: Record<string, number>[];
|
||||
}>({ left: { value: 1 } });
|
||||
t.flush();
|
||||
expect(() => {
|
||||
t.state.right = t.state.left;
|
||||
}).toThrow(/state prox/);
|
||||
expect(() => {
|
||||
t.state.wrapped = { inner: t.state.left };
|
||||
}).toThrow(/state prox/);
|
||||
t.state.items = [];
|
||||
expect(() => t.state.items?.push(t.state.left)).toThrow(/state prox/);
|
||||
const other = track({ value: 2 });
|
||||
expect(() => {
|
||||
t.state.wrapped = { inner: other.state as unknown as Record<string, number> };
|
||||
}).toThrow(/state prox/);
|
||||
});
|
||||
|
||||
it("assigning to state discards ops recorded before it", () => {
|
||||
// They describe a value that no longer exists.
|
||||
const t = track<Record<string, unknown>>({ p: 1, q: PAD });
|
||||
@@ -543,6 +493,18 @@ describe("the first flush", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("immutable operation application", () => {
|
||||
it("does not mutate a replacement payload targeted by a later operation", () => {
|
||||
const replacement = { nested: { value: 1 } };
|
||||
const next = applyImmutable<{ nested: { value: number } }>(undefined, [
|
||||
["r", replacement],
|
||||
["s", ["nested", "value"], 2],
|
||||
]);
|
||||
expect(replacement.nested.value).toBe(1);
|
||||
expect(next.nested.value).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("apply and fan-out", () => {
|
||||
it("adopts an `r` payload rather than copying it", () => {
|
||||
// The consumer owns the batch it was handed. Copying every base batch
|
||||
@@ -880,83 +842,7 @@ describe("safety: op structure", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("safety: what a facet can put on the wire", () => {
|
||||
// A facet cannot forge an op — it mutates plain objects and the tracker builds
|
||||
// the tuples. Values and keys are the leak, and structuredClone is NOT a JSON
|
||||
// check: it clones a Map happily, and the op then carries {} while the producer
|
||||
// keeps a real Map.
|
||||
const rejects = (name: string, mutate: (s: Record<string, unknown>) => void) =>
|
||||
it(`rejects ${name}`, () => {
|
||||
const t = track<Record<string, unknown>>({ pad: PAD });
|
||||
t.flush(); // drain the base batch
|
||||
expect(() => mutate(t.state)).toThrow();
|
||||
});
|
||||
|
||||
rejects("a function", (s) => {
|
||||
s.f = () => {};
|
||||
});
|
||||
rejects("a Map", (s) => {
|
||||
s.m = new Map([[1, 2]]);
|
||||
});
|
||||
rejects("a Date", (s) => {
|
||||
s.d = new Date(0);
|
||||
});
|
||||
rejects("a BigInt", (s) => {
|
||||
s.b = 1n;
|
||||
});
|
||||
rejects("a cycle", (s) => {
|
||||
const a: Record<string, unknown> = {};
|
||||
a.self = a;
|
||||
s.c = a;
|
||||
});
|
||||
rejects("a symbol key", (s) => {
|
||||
s[Symbol("k") as unknown as string] = 1;
|
||||
});
|
||||
|
||||
it("rejects invalid array items before mutation", () => {
|
||||
const t = track<{ xs: JsonValue[] }>({ xs: [] });
|
||||
t.flush();
|
||||
expect(() => t.state.xs.push(new Map() as unknown as JsonValue)).toThrow();
|
||||
expect(t.state.xs).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects duplicate objects inserted in one array call", () => {
|
||||
const t = track<{ xs: JsonValue[] }>({ xs: [] });
|
||||
t.flush();
|
||||
const shared = { value: 1 };
|
||||
expect(() => t.state.xs.push(shared, shared)).toThrow(/shared/);
|
||||
expect(t.state.xs).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects accessors without invoking them", () => {
|
||||
const t = track<Record<string, unknown>>({});
|
||||
t.flush();
|
||||
let reads = 0;
|
||||
const supplied = Object.defineProperty({}, "value", {
|
||||
get() {
|
||||
reads++;
|
||||
return 1;
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
expect(() => {
|
||||
t.state.value = supplied;
|
||||
}).toThrow(/accessor/);
|
||||
expect(reads).toBe(0);
|
||||
expect(t.state).toEqual({});
|
||||
});
|
||||
|
||||
it("rejects frozen and otherwise non-extensible values", () => {
|
||||
const t = track<Record<string, unknown>>({});
|
||||
t.flush();
|
||||
expect(() => {
|
||||
t.state.frozen = Object.freeze({ nested: { value: 1 } });
|
||||
}).toThrow(/non-extensible/);
|
||||
expect(() => track(Object.freeze({ value: 1 }))).toThrow(/non-extensible/);
|
||||
expect(t.state).toEqual({});
|
||||
});
|
||||
|
||||
describe("tracker proxy boundaries", () => {
|
||||
it("rejects descriptor and object-shape bypasses", () => {
|
||||
const t = track<Record<string, unknown>>({ pad: PAD });
|
||||
t.flush();
|
||||
@@ -968,34 +854,14 @@ describe("safety: what a facet can put on the wire", () => {
|
||||
});
|
||||
|
||||
it("accepts a value that merely looks like an op", () => {
|
||||
// Nested inside ["s", path, value]; nothing flattens, so it cannot be read
|
||||
// as a top-level op.
|
||||
const t = track<Record<string, unknown>>({ pad: PAD });
|
||||
t.flush(); // drain the base batch
|
||||
t.flush();
|
||||
t.state.x = ["r", { evil: true }];
|
||||
expect(t.flush()).toEqual([["s", ["x"], ["r", { evil: true }]]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertions", () => {
|
||||
it("assertJsonValue rejects non-JSON", () => {
|
||||
const shared = { value: 1 };
|
||||
for (const bad of [
|
||||
new Map(),
|
||||
new Set(),
|
||||
new Date(),
|
||||
/re/,
|
||||
() => {},
|
||||
Number.NaN,
|
||||
Infinity,
|
||||
{ left: shared, right: shared },
|
||||
]) {
|
||||
expect(() => assertJsonValue(bad)).toThrow();
|
||||
}
|
||||
for (const good of [null, true, 1, "s", [1, "a"], { a: { b: [null] } }]) {
|
||||
expect(() => assertJsonValue(good)).not.toThrow();
|
||||
}
|
||||
});
|
||||
it("assertValidOp accepts decoded ops and rejects wire forms", () => {
|
||||
// Validating Op against the wire grammar would be laxer than the type: a
|
||||
// two-element ["s", value] would pass, and apply would read the value as a
|
||||
@@ -1023,6 +889,11 @@ describe("assertions", () => {
|
||||
expect(() => assertValidWireOp(wireOnly)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not recursively inspect operation payloads", () => {
|
||||
expect(() => assertValidOp(["s", ["value"], new Map()] as never)).not.toThrow();
|
||||
expect(() => assertValidWireOp(["r", new Date()] as never)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("codec: path interning and arity omission", () => {
|
||||
|
||||
@@ -269,7 +269,8 @@ describe("facet host", () => {
|
||||
expect(watched!.state).toBe(retainedState);
|
||||
expect(retainedState.value).toEqual({ value: 2 });
|
||||
expect(revisions).toEqual([1, 2]);
|
||||
sources[0]!.set({ value: 3 }, BACKGROUND_CONTEXT);
|
||||
sources[0]!.state.value = 3;
|
||||
sources[0]!.publish(BACKGROUND_CONTEXT);
|
||||
expect(retainedState.value).toEqual({ value: 2 });
|
||||
expect(revisions).toEqual([1, 2]);
|
||||
|
||||
@@ -363,11 +364,13 @@ describe("facet host", () => {
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer, provider] });
|
||||
expect(deliveries).toBe(1);
|
||||
state!.set({ value: 1 }, BACKGROUND_CONTEXT);
|
||||
state!.state.value = 1;
|
||||
state!.publish(BACKGROUND_CONTEXT);
|
||||
expect(deliveries).toBe(2);
|
||||
|
||||
await host.dispose();
|
||||
state!.set({ value: 2 }, BACKGROUND_CONTEXT);
|
||||
state!.state.value = 2;
|
||||
state!.publish(BACKGROUND_CONTEXT);
|
||||
expect(deliveries).toBe(2);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,28 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { cloneJsonValue, isJsonValue } from "../src/index.ts";
|
||||
|
||||
describe("cloneJsonValue", () => {
|
||||
test("clones strict JSON and omits undefined object properties", () => {
|
||||
const source = { nested: { value: 1 }, omitted: undefined, values: [true, null, "value"] };
|
||||
const cloned = cloneJsonValue(source);
|
||||
expect(cloned).toEqual({ nested: { value: 1 }, values: [true, null, "value"] });
|
||||
expect(cloned).not.toBe(source);
|
||||
expect(cloned.nested).not.toBe(source.nested);
|
||||
});
|
||||
|
||||
test.each([[new Date()], [{ value: Number.NaN }], [[undefined]], [Array(1)], [1n]])(
|
||||
"rejects non-JSON values",
|
||||
(value) => {
|
||||
expect(() => cloneJsonValue(value)).toThrow(TypeError);
|
||||
},
|
||||
);
|
||||
|
||||
test("rejects cycles", () => {
|
||||
const value: { self?: unknown } = {};
|
||||
value.self = value;
|
||||
expect(() => cloneJsonValue(value)).toThrow(/cycles/);
|
||||
});
|
||||
import { isJsonValue } from "../src/index.ts";
|
||||
|
||||
describe("isJsonValue", () => {
|
||||
test("checks strict JSON without normalizing it", () => {
|
||||
expect(isJsonValue({ nested: [1, true, null] })).toBe(true);
|
||||
expect(isJsonValue({ omitted: undefined })).toBe(false);
|
||||
|
||||
@@ -39,6 +39,12 @@ interface Echo {
|
||||
|
||||
const Echo = defineService<Echo>("test.echo");
|
||||
|
||||
interface Timeline {
|
||||
readonly state: ReplicatedState<{ entries: { id: string }[]; retained: { value: number } }>;
|
||||
}
|
||||
|
||||
const Timeline = defineService<Timeline>("test.timeline");
|
||||
|
||||
interface JsonPassthrough {
|
||||
call(value: JsonValue, context: Context): Promise<JsonValue>;
|
||||
}
|
||||
@@ -80,23 +86,44 @@ describe("remote services", () => {
|
||||
expect(() => new RemoteServiceProvider([local])).toThrow("cannot be published remotely");
|
||||
});
|
||||
|
||||
test("does not defensively clone borrowed state values", () => {
|
||||
test("tracks mutable source state while publishing immutable revisions", () => {
|
||||
const initial: ModelsState = { selected: null, revision: 0 };
|
||||
const state = replicatedState(initial);
|
||||
let delivered: ModelsState | undefined;
|
||||
const unsubscribe = state.subscribe((value) => {
|
||||
const deliveries: string[] = [];
|
||||
const unsubscribe = state.subscribe((value, _context, delivery) => {
|
||||
delivered = value;
|
||||
deliveries.push(delivery.kind);
|
||||
});
|
||||
expect(state.value).toBe(initial);
|
||||
expect(delivered).toBe(initial);
|
||||
expect(state.value).toEqual(initial);
|
||||
expect(state.value).not.toBe(initial);
|
||||
expect(delivered).toBe(state.value);
|
||||
const hydrated = delivered;
|
||||
|
||||
const next: ModelsState = { selected: { provider: "test", modelId: "one" }, revision: 1 };
|
||||
state.set(next, BACKGROUND_CONTEXT);
|
||||
expect(state.value).toBe(next);
|
||||
expect(delivered).toBe(next);
|
||||
state.state.selected = { provider: "test", modelId: "one" };
|
||||
state.state.revision = 1;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
expect(state.value).toEqual({ selected: { provider: "test", modelId: "one" }, revision: 1 });
|
||||
expect(state.value).not.toBe(initial);
|
||||
expect(delivered).toBe(state.value);
|
||||
expect(hydrated).toEqual({ selected: null, revision: 0 });
|
||||
expect(deliveries).toEqual(["hydrate", "update"]);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("flushes pending mutations before hydrating a new state subscriber", () => {
|
||||
const state = replicatedState({ entries: [{ id: "one" }] });
|
||||
const first: { entries: { id: string }[] }[] = [];
|
||||
state.subscribe((value) => first.push(value));
|
||||
state.state.entries.push({ id: "two" });
|
||||
|
||||
const second: { entries: { id: string }[] }[] = [];
|
||||
state.subscribe((value) => second.push(value));
|
||||
|
||||
expect(first).toEqual([{ entries: [{ id: "one" }] }, { entries: [{ id: "one" }, { id: "two" }] }]);
|
||||
expect(second).toEqual([{ entries: [{ id: "one" }, { id: "two" }] }]);
|
||||
});
|
||||
|
||||
test("does not defensively clone method arguments or results", async () => {
|
||||
const provider = new RemoteServiceProvider([Echo]);
|
||||
let received: EchoPayload | undefined;
|
||||
@@ -131,8 +158,10 @@ describe("remote services", () => {
|
||||
provider.provide(Models, {
|
||||
state,
|
||||
async select(model, context) {
|
||||
publishedState = { selected: model, revision: state.value.revision + 1 };
|
||||
state.set(publishedState, context);
|
||||
state.state.selected = model;
|
||||
state.state.revision += 1;
|
||||
state.publish(context);
|
||||
publishedState = state.value;
|
||||
},
|
||||
});
|
||||
const errors: Error[] = [];
|
||||
@@ -147,12 +176,12 @@ describe("remote services", () => {
|
||||
expect(first).toBe(second);
|
||||
expect(first.state.value).toBeUndefined();
|
||||
await namespace.ready(BACKGROUND_CONTEXT);
|
||||
expect(first.state.value).toBe(initialState);
|
||||
expect(first.state.value).toEqual(initialState);
|
||||
|
||||
const updates: ModelsState[] = [];
|
||||
const unsubscribe = second.state.subscribe((value) => updates.push(value));
|
||||
await first.select({ provider: "test", modelId: "one" }, BACKGROUND_CONTEXT);
|
||||
expect(first.state.value).toBe(publishedState);
|
||||
expect(first.state.value).toEqual(publishedState);
|
||||
expect(first.state.value).toEqual({
|
||||
selected: { provider: "test", modelId: "one" },
|
||||
revision: 1,
|
||||
@@ -176,6 +205,60 @@ describe("remote services", () => {
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("publishes compact tracked operations through the remote provider", async () => {
|
||||
const provider = new RemoteServiceProvider([Timeline]);
|
||||
const initial = { entries: [{ id: "one" }], retained: { value: 1 } };
|
||||
const source = replicatedState(initial);
|
||||
provider.provide(Timeline, { state: source });
|
||||
const updates: Array<Parameters<Parameters<typeof provider.subscribe>[2]>[0]> = [];
|
||||
const raw = provider.subscribe(Timeline.id, "singleton", (update) => updates.push(update));
|
||||
expect(raw.snapshot.instances[0]?.members).toEqual([
|
||||
{ name: "state", kind: "state", sequence: 0, ops: [["r", initial]] },
|
||||
]);
|
||||
raw.activate();
|
||||
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Timeline],
|
||||
transport: createLoopbackServiceTransport(provider),
|
||||
});
|
||||
const timeline = namespace.use(Timeline);
|
||||
await namespace.ready(BACKGROUND_CONTEXT);
|
||||
const previous = timeline.state.value;
|
||||
const next = { entries: [{ id: "one" }, { id: "two" }], retained: initial.retained };
|
||||
source.state.entries.push({ id: "two" });
|
||||
source.publish(BACKGROUND_CONTEXT);
|
||||
|
||||
expect(updates).toContainEqual({
|
||||
type: "state",
|
||||
member: "state",
|
||||
sequence: 1,
|
||||
ops: [["p", ["entries"], 1, 0, [{ id: "two" }]]],
|
||||
});
|
||||
expect(previous).toEqual({ entries: [{ id: "one" }], retained: { value: 1 } });
|
||||
expect(timeline.state.value).toEqual(next);
|
||||
|
||||
source.state.entries.push({ id: "three" });
|
||||
const late = provider.subscribe(Timeline.id, "singleton", () => {});
|
||||
expect(updates.at(-1)).toEqual({
|
||||
type: "state",
|
||||
member: "state",
|
||||
sequence: 2,
|
||||
ops: [["p", ["entries"], 2, 0, [{ id: "three" }]]],
|
||||
});
|
||||
expect(late.snapshot.instances[0]?.members).toEqual([
|
||||
{
|
||||
name: "state",
|
||||
kind: "state",
|
||||
sequence: 2,
|
||||
ops: [["r", { entries: [{ id: "one" }, { id: "two" }, { id: "three" }], retained: { value: 1 } }]],
|
||||
},
|
||||
]);
|
||||
late.close();
|
||||
raw.close();
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("keeps singleton facades stable when their provider is replaced", async () => {
|
||||
const provider = new RemoteServiceProvider([Models]);
|
||||
provider.provide(Models, {
|
||||
@@ -343,7 +426,8 @@ describe("remote services", () => {
|
||||
invoke: (call, context) => provider.invoke(call, context),
|
||||
subscribe: async (serviceId, mode, listener) => {
|
||||
const subscription = provider.subscribe(serviceId, mode, listener);
|
||||
state.set({ selected: null, revision: 1 }, BACKGROUND_CONTEXT);
|
||||
state.state.revision = 1;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
return {
|
||||
snapshot: subscription.snapshot,
|
||||
activate: () => subscription.activate(),
|
||||
@@ -362,6 +446,59 @@ describe("remote services", () => {
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["duplicate", 0],
|
||||
["gap", 2],
|
||||
] as const)("clears replicated state after a %s operation sequence", async (_kind, sequence) => {
|
||||
let sendUpdate: ((update: Parameters<Parameters<RemoteServiceTransport["subscribe"]>[2]>[0]) => void) | undefined;
|
||||
const errors: Error[] = [];
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Models],
|
||||
transport: {
|
||||
invoke: () => Promise.reject(new Error("unexpected invocation")),
|
||||
async subscribe(_serviceId, _mode, listener) {
|
||||
sendUpdate = (update) => listener(update, BACKGROUND_CONTEXT);
|
||||
return {
|
||||
snapshot: {
|
||||
serviceId: Models.id,
|
||||
mode: "singleton",
|
||||
instances: [
|
||||
{
|
||||
members: [
|
||||
{ name: "select", kind: "method" },
|
||||
{
|
||||
name: "state",
|
||||
kind: "state",
|
||||
sequence: 0,
|
||||
ops: [["r", { selected: null, revision: 0 }]],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
activate() {},
|
||||
close() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
const models = namespace.use(Models);
|
||||
await namespace.ready(BACKGROUND_CONTEXT);
|
||||
expect(models.state.value?.revision).toBe(0);
|
||||
|
||||
sendUpdate?.({
|
||||
type: "state",
|
||||
member: "state",
|
||||
sequence,
|
||||
ops: [["r", { selected: null, revision: sequence }]],
|
||||
});
|
||||
expect(models.state.value).toBeUndefined();
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]?.message).toContain("sequence has a gap");
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
});
|
||||
|
||||
test("hydrates cold ReplicatedState replicas and replaces them across rebinds", async () => {
|
||||
const provider = new RemoteServiceProvider([Models]);
|
||||
const state = replicatedState<ModelsState>({ selected: null, revision: 0 });
|
||||
@@ -380,16 +517,19 @@ describe("remote services", () => {
|
||||
expect(models.state.value).toBeUndefined();
|
||||
expect(revisions).toEqual([]);
|
||||
|
||||
state.set({ selected: null, revision: 1 }, BACKGROUND_CONTEXT);
|
||||
state.state.revision = 1;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
await namespace.rebind(true, BACKGROUND_CONTEXT);
|
||||
expect(models.state.value?.revision).toBe(1);
|
||||
expect(revisions).toEqual([1]);
|
||||
state.set({ selected: null, revision: 2 }, BACKGROUND_CONTEXT);
|
||||
state.state.revision = 2;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
expect(revisions).toEqual([1, 2]);
|
||||
|
||||
await namespace.rebind(false, BACKGROUND_CONTEXT);
|
||||
expect(models.state.value).toBeUndefined();
|
||||
state.set({ selected: null, revision: 3 }, BACKGROUND_CONTEXT);
|
||||
state.state.revision = 3;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
expect(revisions).toEqual([1, 2]);
|
||||
await namespace.rebind(true, BACKGROUND_CONTEXT);
|
||||
expect(models.state.value?.revision).toBe(3);
|
||||
@@ -433,7 +573,8 @@ describe("remote services", () => {
|
||||
expect(observed[0]).toMatchObject({ question: { question: "First?" } });
|
||||
|
||||
const firstService = observed[0]!.service;
|
||||
firstRequest.set({ question: "Updated?" }, BACKGROUND_CONTEXT);
|
||||
firstRequest.state.question = "Updated?";
|
||||
firstRequest.publish(BACKGROUND_CONTEXT);
|
||||
expect(firstService.request.value).toEqual({ question: "Updated?" });
|
||||
await expect(firstService.submit("yes", BACKGROUND_CONTEXT)).resolves.toEqual({ accepted: true });
|
||||
expect(firstSubmit).toHaveBeenCalledWith("yes", expect.objectContaining({ abortSignal: undefined }));
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import {
|
||||
type AttachmentEnvelope,
|
||||
createServiceCatalogueCall,
|
||||
createServiceStateDecoder,
|
||||
createServiceSubscribeCall,
|
||||
createServiceUnsubscribeCall,
|
||||
type DecodedServiceProviderUpdate,
|
||||
type DecodedServiceSubscriptionSnapshot,
|
||||
encodeClientMessage,
|
||||
isServerId,
|
||||
type ProtocolRpcCall,
|
||||
@@ -16,9 +19,9 @@ import {
|
||||
type ServiceCatalogueEntry,
|
||||
type ServiceEventEnvelope,
|
||||
type ServiceMode,
|
||||
type ServiceProviderUpdate,
|
||||
type ServiceSubscriptionSnapshot,
|
||||
type ServiceStateDecoder,
|
||||
type SessionTarget,
|
||||
type ServiceProviderUpdate as WireServiceProviderUpdate,
|
||||
} from "@earendil-works/pi-protocol";
|
||||
import { Connection } from "./connection.ts";
|
||||
import { ClientDisposedError, DisconnectedError, ServerError, toError } from "./errors.ts";
|
||||
@@ -40,9 +43,12 @@ interface PendingRequest {
|
||||
|
||||
interface ActiveServiceListener {
|
||||
readonly target: RpcTarget;
|
||||
readonly listener: (update: ServiceProviderUpdate) => void | Promise<void>;
|
||||
readonly queued: ServiceProviderUpdate[];
|
||||
readonly listener: (update: DecodedServiceProviderUpdate) => void | Promise<void>;
|
||||
readonly decoder: ServiceStateDecoder;
|
||||
readonly queuedWireUpdates: WireServiceProviderUpdate[];
|
||||
readonly queued: DecodedServiceProviderUpdate[];
|
||||
deliveryTail: Promise<void>;
|
||||
hydrated: boolean;
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
@@ -160,34 +166,36 @@ export class Client {
|
||||
target: RpcTarget,
|
||||
serviceId: string,
|
||||
mode: ServiceMode,
|
||||
listener: (update: ServiceProviderUpdate) => void | Promise<void>,
|
||||
listener: (update: DecodedServiceProviderUpdate) => void | Promise<void>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ServiceSubscription> {
|
||||
const subscriptionId = `service-${++this.#serviceSubscriptionSequence}`;
|
||||
const active: ActiveServiceListener = {
|
||||
target,
|
||||
listener,
|
||||
decoder: createServiceStateDecoder(),
|
||||
queuedWireUpdates: [],
|
||||
queued: [],
|
||||
deliveryTail: Promise.resolve(),
|
||||
hydrated: false,
|
||||
ready: false,
|
||||
};
|
||||
this.#serviceListeners.set(subscriptionId, active);
|
||||
let snapshot: ServiceSubscriptionSnapshot;
|
||||
let snapshot: DecodedServiceSubscriptionSnapshot;
|
||||
try {
|
||||
const result = await this.#request(
|
||||
snapshot = await this.#request(
|
||||
target,
|
||||
createServiceSubscribeCall(subscriptionId, serviceId, mode),
|
||||
signal,
|
||||
(result) => {
|
||||
const decoded = active.decoder.decodeSnapshot(parseServiceSubscriptionSnapshot(result));
|
||||
active.hydrated = true;
|
||||
for (const update of active.queuedWireUpdates.splice(0)) {
|
||||
active.queued.push(active.decoder.decodeUpdate(update));
|
||||
}
|
||||
return decoded;
|
||||
},
|
||||
);
|
||||
try {
|
||||
snapshot = parseServiceSubscriptionSnapshot(result);
|
||||
} catch (error) {
|
||||
const validationError = new ProtocolValidationError(
|
||||
error instanceof Error ? error.message : "Invalid service subscription snapshot",
|
||||
);
|
||||
this.#connection.fail(validationError);
|
||||
throw validationError;
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.#serviceListeners.get(subscriptionId) === active) this.#serviceListeners.delete(subscriptionId);
|
||||
throw error;
|
||||
@@ -213,18 +221,24 @@ export class Client {
|
||||
}
|
||||
await active.deliveryTail;
|
||||
} finally {
|
||||
active.queuedWireUpdates.length = 0;
|
||||
active.queued.length = 0;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#request(target: RpcTarget, call: ProtocolRpcCall, signal?: AbortSignal): Promise<ProtocolRpcResult> {
|
||||
#request<T = ProtocolRpcResult>(
|
||||
target: RpcTarget,
|
||||
call: ProtocolRpcCall,
|
||||
signal?: AbortSignal,
|
||||
transform?: (result: ProtocolRpcResult) => T,
|
||||
): Promise<T> {
|
||||
if (this.#disposed) return Promise.reject(new ClientDisposedError());
|
||||
if (!this.connected) return Promise.reject(new DisconnectedError());
|
||||
if (signal?.aborted) return Promise.reject(abortError(signal));
|
||||
const id = `request-${++this.#requestSequence}`;
|
||||
const { promise, resolve, reject } = createPromiseResolvers<ProtocolRpcResult>();
|
||||
const { promise, resolve, reject } = createPromiseResolvers<T>();
|
||||
let sent = false;
|
||||
let aborted = false;
|
||||
let onAbort: (() => void) | undefined;
|
||||
@@ -248,7 +262,17 @@ export class Client {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
this.#pendingRequests.set(id, {
|
||||
resolve,
|
||||
resolve: (result) => {
|
||||
try {
|
||||
resolve(transform === undefined ? (result as T) : transform(result));
|
||||
} catch (error) {
|
||||
const validationError = new ProtocolValidationError(
|
||||
error instanceof Error ? error.message : "Invalid service operation stream",
|
||||
);
|
||||
this.#connection.fail(validationError);
|
||||
reject(validationError);
|
||||
}
|
||||
},
|
||||
reject,
|
||||
cleanup: () => {
|
||||
if (signal !== undefined && onAbort !== undefined) signal.removeEventListener("abort", onAbort);
|
||||
@@ -282,8 +306,21 @@ export class Client {
|
||||
if (message.type === "service_update") {
|
||||
const active = this.#serviceListeners.get(message.subscriptionId);
|
||||
if (active === undefined) return;
|
||||
if (active.ready) this.#deliverServiceUpdate(active, message.update);
|
||||
else active.queued.push(message.update);
|
||||
if (!active.hydrated) {
|
||||
active.queuedWireUpdates.push(message.update);
|
||||
return;
|
||||
}
|
||||
let update: DecodedServiceProviderUpdate;
|
||||
try {
|
||||
update = active.decoder.decodeUpdate(message.update);
|
||||
} catch (error) {
|
||||
this.#connection.fail(
|
||||
new ProtocolValidationError(error instanceof Error ? error.message : "Invalid service operation stream"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (active.ready) this.#deliverServiceUpdate(active, update);
|
||||
else active.queued.push(update);
|
||||
return;
|
||||
}
|
||||
const pending = this.#takePendingRequest(message.id);
|
||||
@@ -370,7 +407,7 @@ export class Client {
|
||||
}
|
||||
}
|
||||
|
||||
#deliverServiceUpdate(active: ActiveServiceListener, update: ServiceProviderUpdate): void {
|
||||
#deliverServiceUpdate(active: ActiveServiceListener, update: DecodedServiceProviderUpdate): void {
|
||||
active.deliveryTail = active.deliveryTail
|
||||
.then(() => active.listener(update))
|
||||
.catch((error: unknown) => this.#reportListenerError(error));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RpcTarget, ServiceSubscriptionSnapshot, SessionTarget } from "@earendil-works/pi-protocol";
|
||||
import type { DecodedServiceSubscriptionSnapshot, RpcTarget, SessionTarget } from "@earendil-works/pi-protocol";
|
||||
import type { ByteTransportFactory } from "./transport.ts";
|
||||
|
||||
export type ConnectionState = "disconnected" | "connecting" | "connected";
|
||||
@@ -15,7 +15,7 @@ export type AttachmentChangeListener = (attachment: SessionTarget | undefined) =
|
||||
export interface ServiceSubscription {
|
||||
readonly id: string;
|
||||
readonly target: RpcTarget;
|
||||
readonly snapshot: ServiceSubscriptionSnapshot;
|
||||
readonly snapshot: DecodedServiceSubscriptionSnapshot;
|
||||
/** Begin ordered update delivery after the caller has installed the snapshot. */
|
||||
start(): void;
|
||||
dispose(): Promise<void>;
|
||||
|
||||
@@ -76,9 +76,9 @@ describe("Client service operations", () => {
|
||||
const client = await connectClient(server);
|
||||
await attachClient(client, server, "session-1");
|
||||
const target = client.attachment!;
|
||||
const updates: string[] = [];
|
||||
const updates: Array<{ readonly type: string; readonly ops?: readonly unknown[] }> = [];
|
||||
const opening = client.subscribeService(target, "pi.models", "singleton", (update) => {
|
||||
updates.push(update.type);
|
||||
updates.push(update);
|
||||
});
|
||||
await server.waitForMessages(3);
|
||||
expect(server.messages[2]).toMatchObject({
|
||||
@@ -93,7 +93,7 @@ describe("Client service operations", () => {
|
||||
server.send({
|
||||
type: "service_update",
|
||||
subscriptionId: "service-1",
|
||||
update: { type: "state", member: "state", sequence: 1, value: { revision: 1 } },
|
||||
update: { type: "state", member: "state", sequence: 1, ops: [["s", ["revision"], 1]] },
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(updates).toEqual([]);
|
||||
@@ -104,16 +104,43 @@ describe("Client service operations", () => {
|
||||
result: {
|
||||
serviceId: "pi.models",
|
||||
mode: "singleton",
|
||||
instances: [{ members: [{ name: "state", kind: "state", sequence: 0, value: { revision: 0 } }] }],
|
||||
instances: [{ members: [{ name: "state", kind: "state", sequence: 0, ops: [["r", { revision: 0 }]] }] }],
|
||||
},
|
||||
});
|
||||
const subscription = await opening;
|
||||
expect(updates).toEqual([]);
|
||||
server.send({
|
||||
type: "service_update",
|
||||
subscriptionId: "closed-subscription",
|
||||
update: { type: "state", member: "state", sequence: 99, ops: [["s", 99, 99]] },
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(client.connected).toBe(true);
|
||||
expect(subscription.snapshot.instances[0]?.members).toEqual([
|
||||
{ name: "state", kind: "state", sequence: 0, value: { revision: 0 } },
|
||||
{ name: "state", kind: "state", sequence: 0, ops: [["r", { revision: 0 }]] },
|
||||
]);
|
||||
subscription.start();
|
||||
await vi.waitFor(() => expect(updates).toEqual(["state"]));
|
||||
await vi.waitFor(() => expect(updates.map(({ type }) => type)).toEqual(["state"]));
|
||||
server.send({
|
||||
type: "service_update",
|
||||
subscriptionId: "service-1",
|
||||
update: { type: "state", member: "state", sequence: 2, ops: [["s", ["revision"], 2]] },
|
||||
});
|
||||
server.send({
|
||||
type: "service_update",
|
||||
subscriptionId: "service-1",
|
||||
update: {
|
||||
type: "state",
|
||||
member: "state",
|
||||
sequence: 3,
|
||||
ops: [
|
||||
["#", 0, ["revision"]],
|
||||
["s", 0, 3],
|
||||
],
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(updates).toHaveLength(3));
|
||||
expect(updates[2]?.ops).toEqual([["s", ["revision"], 3]]);
|
||||
|
||||
const disposing = subscription.dispose();
|
||||
await server.waitForMessages(4);
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface ExampleFacetReply {
|
||||
}
|
||||
|
||||
export interface ExampleFacetService {
|
||||
readonly workerActivations: ReplicatedState<number>;
|
||||
readonly workerActivations: ReplicatedState<{ count: number }>;
|
||||
greet(input: { readonly name: string }, context: Context): Promise<ExampleFacetReply>;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,18 +5,19 @@ import { ExampleFacetService } from "./contract.ts";
|
||||
export default defineFacet({
|
||||
id: "@earendil-works/pi-example-plugin/session",
|
||||
setup(env) {
|
||||
const workerActivations = env.replicatedState(0);
|
||||
const workerActivations = env.replicatedState({ count: 0 });
|
||||
env.provide(ExampleFacetService, {
|
||||
workerActivations,
|
||||
async greet({ name }) {
|
||||
return {
|
||||
message: `Hello ${name} from the bundled Session worker facet!!!`,
|
||||
workerActivations: workerActivations.value,
|
||||
workerActivations: workerActivations.value.count,
|
||||
};
|
||||
},
|
||||
});
|
||||
env.onActivate(() => {
|
||||
workerActivations.set(workerActivations.value + 1, BACKGROUND_CONTEXT);
|
||||
workerActivations.state.count += 1;
|
||||
workerActivations.publish(BACKGROUND_CONTEXT);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import { type LaneSnapshot, type LaneWatchEvent, reduceLaneSnapshot } from "@earendil-works/pi-agent-core";
|
||||
import type { Transcript, TranscriptSnapshot, TranscriptUpdate } from "./services/transcript.ts";
|
||||
import type { LaneSnapshot, LaneWatchEvent } from "@earendil-works/pi-agent-core";
|
||||
import type { Transcript } from "./services/transcript.ts";
|
||||
|
||||
export interface LaneReplica {
|
||||
readonly sessionId: string;
|
||||
@@ -9,68 +8,42 @@ export interface LaneReplica {
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Open a presentation-local Harness replica from the Session's Transcript service. */
|
||||
/** Open a presentation-local replica from the Session's Transcript state. */
|
||||
export async function openLaneReplica(
|
||||
transcript: Transcript,
|
||||
sessionId: string,
|
||||
onEvent?: (event: LaneWatchEvent) => void | Promise<void>,
|
||||
): Promise<LaneReplica> {
|
||||
const listeners = new Set<() => void>();
|
||||
const pending: TranscriptUpdate[] = [];
|
||||
let snapshot: LaneSnapshot | undefined;
|
||||
let revision = 0;
|
||||
let initialized = false;
|
||||
let closed = false;
|
||||
let deliveryTail = Promise.resolve();
|
||||
let resolveReady!: () => void;
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
resolveReady = resolve;
|
||||
});
|
||||
|
||||
const publish = (): void => {
|
||||
if (closed) return;
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
const installSnapshot = (captured: TranscriptSnapshot): void => {
|
||||
revision = captured.revision;
|
||||
snapshot = captured.snapshot;
|
||||
const unsubscribe = transcript.state.subscribe((value, _context, delivery) => {
|
||||
if (closed || value.snapshot === null) return;
|
||||
snapshot = value.snapshot;
|
||||
publish();
|
||||
};
|
||||
const apply = async (update: TranscriptUpdate): Promise<void> => {
|
||||
if (closed || update.revision <= revision) return;
|
||||
if (update.revision !== revision + 1) {
|
||||
installSnapshot(await transcript.snapshot(BACKGROUND_CONTEXT));
|
||||
if (update.revision <= revision) return;
|
||||
if (update.revision !== revision + 1) throw new Error("Transcript snapshot did not repair its revision gap");
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
resolveReady();
|
||||
}
|
||||
if (update.type === "snapshot") {
|
||||
revision = update.revision;
|
||||
snapshot = update.snapshot;
|
||||
publish();
|
||||
return;
|
||||
if (delivery.kind === "update" && value.event !== null) {
|
||||
const event = value.event;
|
||||
deliveryTail = deliveryTail.then(() => onEvent?.(event));
|
||||
}
|
||||
const event = update.event;
|
||||
const current = snapshot;
|
||||
if (current === undefined) throw new Error("Transcript replica has no snapshot");
|
||||
const reduced = reduceLaneSnapshot(current, event);
|
||||
revision = update.revision;
|
||||
if (!("rebase" in reduced)) {
|
||||
snapshot = reduced;
|
||||
publish();
|
||||
}
|
||||
await onEvent?.(event);
|
||||
};
|
||||
const enqueue = (update: TranscriptUpdate): void => {
|
||||
const detached = structuredClone(update);
|
||||
deliveryTail = deliveryTail.then(() => apply(detached));
|
||||
};
|
||||
const unsubscribe = transcript.updates.subscribe((update) => {
|
||||
if (closed || update === null) return;
|
||||
if (initialized) enqueue(update);
|
||||
else pending.push(structuredClone(update));
|
||||
});
|
||||
|
||||
try {
|
||||
installSnapshot(await transcript.snapshot(BACKGROUND_CONTEXT));
|
||||
initialized = true;
|
||||
for (const update of pending.splice(0)) enqueue(update);
|
||||
await deliveryTail;
|
||||
await ready;
|
||||
} catch (error) {
|
||||
closed = true;
|
||||
unsubscribe();
|
||||
|
||||
@@ -67,12 +67,10 @@ export async function connect(transport: Transport, sessionId: string | null, cw
|
||||
|
||||
const fold = (event: HarnessEvent): void => {
|
||||
if (!snapshot) return;
|
||||
const folded = reduceLaneSnapshot(snapshot.lane, event);
|
||||
if ("rebase" in folded) {
|
||||
if (reduceLaneSnapshot(snapshot.lane, event) === "rebase") {
|
||||
void resubscribe();
|
||||
return;
|
||||
}
|
||||
snapshot = { ...snapshot, lane: folded };
|
||||
publish();
|
||||
};
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@ Facet setup generates each host's RPC service catalogue from its provided non-lo
|
||||
| session | `SessionPlugins` | reloads the configured Session facet generation | add coordinated multi-worker reload reporting |
|
||||
| session | `Models` | state, default-persisting selection, thinking, refresh implemented | move provider/auth composition behind plugin facets |
|
||||
| session | `AgentController` | presentation-safe `AgentLane` facade for prompting, queueing, abort, resume, compaction, and navigation | add new lane operations only when a presentation requires them |
|
||||
| session | `Transcript` | coherent main-lane snapshots and ordered reducer events | move to a dedicated Chord feed primitive if other high-frequency observations need the same semantics |
|
||||
| session | `Transcript` | replicated lane state with source-event metadata | add projections only when another presentation needs them |
|
||||
| presentation | `SlashCommands` | process-local contribution registry with model, thinking, compact, reload, and example hello commands | add more presentation hookpoints only as concrete plugin slices require them |
|
||||
| presentation | `PresentationUI` | process-local selection and status capabilities | add narrowly scoped UI capabilities only when commands require them |
|
||||
|
||||
`ServerServiceSource.connection` and `SessionServiceSource.attachment` are implemented local control states. Session directory, creation, and address DTOs are owned by these coding-agent service contracts rather than `pi-protocol`; the transport treats their payloads as opaque service data.
|
||||
|
||||
With `PI_EXPERIMENTAL=1`, an interactive `pi client` creates and attaches a Session before opening the service-only chat TUI. `pi client -c` and `pi client -r` attach the newest existing Session instead, preserving its durable model and thinking configuration. Model selection is available on demand through `/model`; it is never a startup screen. The presentation always uses the stable coding agent's alternate-screen renderer and shared transcript/dock viewport. It loads configured theme resources and uses the stable terminal light/dark detection and appearance-change notifications. Its replicated state feeds the stable editor, message, tool, status, theme, and tool-renderer components. The presentation drives the worker-owned main lane through `AgentController` and renders the `Transcript` service's coherent replica with the Harness reducer while controller calls are pending.
|
||||
With `PI_EXPERIMENTAL=1`, an interactive `pi client` creates and attaches a Session before opening the service-only chat TUI. `pi client -c` and `pi client -r` attach the newest existing Session instead, preserving its durable model and thinking configuration. Model selection is available on demand through `/model`; it is never a startup screen. The presentation always uses the stable coding agent's alternate-screen renderer and shared transcript/dock viewport. It loads configured theme resources and uses the stable terminal light/dark detection and appearance-change notifications. Its replicated state feeds the stable editor, message, tool, status, theme, and tool-renderer components. The presentation drives the worker-owned main lane through `AgentController` and renders the `Transcript` service's complete replicated value while controller calls are pending. The provider remains the sole Harness reducer and mutates Transcript's tracked snapshot before publishing each event. Chord flushes compact operations once per publication, while each client/state pairing encodes them with an independent path dictionary. Chord reconstructs presentation replicas and owns hydration, sequencing, and gap detection.
|
||||
|
||||
A foreground server uses repeatable `-e` options to establish its default Session and TUI facets. A local client may instead select packages for the Session it creates or resumes; that branch selection is persisted with the Session and does not alter other workers or the server default. Before attaching, the server asks Chord to build conventional `src/session.ts` and `src/tui.ts` entries into separate `plugin-builds/` directories, passes the generated manifest paths to that Session worker, and returns the matching TUI artifacts. The Session worker loads built-in facets and separately owned plugin generations, then creates one active `FacetHost`. `/reload` atomically rebuilds the packages, loads fresh plugin candidates, cuts them over through `FacetHost.reload()`, and disposes the retired generations. Host-created implementation dependencies such as `AgentLane`, `ModelRuntime`, and `SettingsManager` are passed directly to built-in facet factories; they are not exposed as services. The TUI loads configured presentation facets and adds one private bridge facet that consumes the connected server and selected-Session services for `ExperimentalClientTui`. Its local presentation services are `SlashCommands`, which owns command contributions, and `PresentationUI`, which exposes selection and status rendering without exposing the raw TUI. Command callbacks receive Chord `Context` directly. Facets consume `AgentController` explicitly for prompting, steering, or queueing and return structured controller results to the command dispatcher.
|
||||
|
||||
|
||||
@@ -12,13 +12,7 @@ import {
|
||||
} from "@earendil-works/chord";
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import type { Client } from "@earendil-works/pi-client";
|
||||
import type {
|
||||
ProtocolRpcCall,
|
||||
RpcTarget,
|
||||
ServiceCatalogueEntry,
|
||||
ServiceProviderUpdate,
|
||||
SessionTarget,
|
||||
} from "@earendil-works/pi-protocol";
|
||||
import type { ProtocolRpcCall, RpcTarget, ServiceCatalogueEntry, SessionTarget } from "@earendil-works/pi-protocol";
|
||||
|
||||
export type ServerConnectionState =
|
||||
| { status: "connecting"; attempt: number }
|
||||
@@ -133,7 +127,11 @@ class ServerServiceSourceImpl implements ServerServiceSource {
|
||||
this.connection = connectionState;
|
||||
this.#removeConnectionListener = client.onConnectionStateChange(({ state, error }) => {
|
||||
if (state === "connecting") this.#connectionAttempt += 1;
|
||||
connectionState.set(toServerConnectionState(client, this.#connectionAttempt, error), BACKGROUND_CONTEXT);
|
||||
publishReplacement(
|
||||
connectionState,
|
||||
toServerConnectionState(client, this.#connectionAttempt, error),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
this.#transition = this.#transition
|
||||
.then(async () => {
|
||||
const results = await Promise.allSettled(
|
||||
@@ -213,7 +211,11 @@ class SessionServiceSourceImpl implements SessionServiceSource {
|
||||
this.#removeAttachmentListener = client.onAttachmentChange((attachment) => {
|
||||
const revision = ++this.#attachmentRevision;
|
||||
if (attachment !== undefined) {
|
||||
this.#attachmentState.set({ status: "attaching", sessionId: attachment.sessionId }, BACKGROUND_CONTEXT);
|
||||
publishReplacement(
|
||||
this.#attachmentState,
|
||||
{ status: "attaching", sessionId: attachment.sessionId },
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
void this.#client.serviceCatalogue(attachment).then(
|
||||
(catalogue) => {
|
||||
if (this.#attachmentRevision === revision && sameAttachment(this.#client.attachment, attachment)) {
|
||||
@@ -230,7 +232,8 @@ class SessionServiceSourceImpl implements SessionServiceSource {
|
||||
this.#transitions.delete(transition);
|
||||
if (this.#attachmentRevision !== revision || !sameAttachment(this.#client.attachment, attachment))
|
||||
return;
|
||||
this.#attachmentState.set(
|
||||
publishReplacement(
|
||||
this.#attachmentState,
|
||||
attachment === undefined
|
||||
? { status: "detached" }
|
||||
: { status: "attached", sessionId: attachment.sessionId },
|
||||
@@ -241,7 +244,8 @@ class SessionServiceSourceImpl implements SessionServiceSource {
|
||||
this.#transitions.delete(transition);
|
||||
if (this.#attachmentRevision !== revision || !sameAttachment(this.#client.attachment, attachment))
|
||||
return;
|
||||
this.#attachmentState.set(
|
||||
publishReplacement(
|
||||
this.#attachmentState,
|
||||
attachment === undefined
|
||||
? { status: "detached" }
|
||||
: { status: "degraded", sessionId: attachment.sessionId },
|
||||
@@ -321,7 +325,7 @@ class SessionServiceSourceImpl implements SessionServiceSource {
|
||||
await Promise.all([...this.#bindings].map((binding) => binding.serviceReady(context)));
|
||||
} catch (error) {
|
||||
if (this.#attachmentRevision === revision && sameAttachment(this.#client.attachment, attachment)) {
|
||||
this.#attachmentState.set({ status: "degraded", sessionId: attachment.sessionId }, context);
|
||||
publishReplacement(this.#attachmentState, { status: "degraded", sessionId: attachment.sessionId }, context);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -330,7 +334,7 @@ class SessionServiceSourceImpl implements SessionServiceSource {
|
||||
}
|
||||
const state = this.#attachmentState.value;
|
||||
if (state.status !== "attached" || state.sessionId !== attachment.sessionId) {
|
||||
this.#attachmentState.set({ status: "attached", sessionId: attachment.sessionId }, context);
|
||||
publishReplacement(this.#attachmentState, { status: "attached", sessionId: attachment.sessionId }, context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,7 +344,7 @@ class SessionServiceSourceImpl implements SessionServiceSource {
|
||||
throw new Error("The Session attachment changed while detaching");
|
||||
}
|
||||
if (this.#attachmentState.value.status !== "detached") {
|
||||
this.#attachmentState.set({ status: "detached" }, context);
|
||||
publishReplacement(this.#attachmentState, { status: "detached" }, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -355,6 +359,16 @@ export function createSessionServiceSource(client: Client, options: ServiceSourc
|
||||
return new SessionServiceSourceImpl(client, options);
|
||||
}
|
||||
|
||||
function publishReplacement<T extends object>(state: MutableReplicatedState<T>, value: T, context: Context): void {
|
||||
const target = state.state as Record<string, unknown>;
|
||||
const replacement = value as Record<string, unknown>;
|
||||
for (const key of Object.keys(target)) {
|
||||
if (!Object.hasOwn(replacement, key)) delete target[key];
|
||||
}
|
||||
Object.assign(target, replacement);
|
||||
state.publish(context);
|
||||
}
|
||||
|
||||
function createRemoteServiceTransport(client: Client, getTarget: () => RpcTarget | undefined): RemoteServiceTransport {
|
||||
return {
|
||||
invoke: async (call, context) => {
|
||||
@@ -375,7 +389,7 @@ function createRemoteServiceTransport(client: Client, getTarget: () => RpcTarget
|
||||
target,
|
||||
serviceId,
|
||||
mode,
|
||||
(update: ServiceProviderUpdate) => listener(update, BACKGROUND_CONTEXT),
|
||||
(update) => listener(update, BACKGROUND_CONTEXT),
|
||||
context.abortSignal,
|
||||
);
|
||||
return {
|
||||
|
||||
@@ -58,37 +58,31 @@ export function createModelsService(
|
||||
const index = levels.indexOf(current);
|
||||
const next = levels[(index + 1) % levels.length] ?? "off";
|
||||
await lane.setThinkingLevel(next, context);
|
||||
state.set({ ...state.value, configuration: await readConfiguration(context) }, context);
|
||||
state.state.configuration = await readConfiguration(context);
|
||||
state.publish(context);
|
||||
},
|
||||
async getThinkingLevels(context) {
|
||||
return [...(await readThinkingLevels(context))];
|
||||
},
|
||||
async refresh(context) {
|
||||
state.set({ ...state.value, refresh: { status: "refreshing" } }, context);
|
||||
state.state.refresh = { status: "refreshing" };
|
||||
state.publish(context);
|
||||
const refresh = modelRuntime?.refresh({ signal: context.abortSignal });
|
||||
if (refresh === undefined) {
|
||||
state.set(
|
||||
{
|
||||
...state.value,
|
||||
catalog: await readCatalog(context),
|
||||
configuration: await readConfiguration(context),
|
||||
refresh: { status: "done" },
|
||||
},
|
||||
context,
|
||||
);
|
||||
const [catalog, configuration] = await Promise.all([readCatalog(context), readConfiguration(context)]);
|
||||
state.state.catalog = catalog;
|
||||
state.state.configuration = configuration;
|
||||
state.state.refresh = { status: "done" };
|
||||
state.publish(context);
|
||||
return;
|
||||
}
|
||||
const result = await refresh;
|
||||
const errors = Object.fromEntries([...result.errors].map(([id, error]) => [id, error.message]));
|
||||
state.set(
|
||||
{
|
||||
...state.value,
|
||||
catalog: await readCatalog(context),
|
||||
configuration: await readConfiguration(context),
|
||||
refresh: Object.keys(errors).length === 0 ? { status: "done" } : { status: "warning", errors },
|
||||
},
|
||||
context,
|
||||
);
|
||||
const [catalog, configuration] = await Promise.all([readCatalog(context), readConfiguration(context)]);
|
||||
state.state.catalog = catalog;
|
||||
state.state.configuration = configuration;
|
||||
state.state.refresh = Object.keys(errors).length === 0 ? { status: "done" } : { status: "warning", errors };
|
||||
state.publish(context);
|
||||
},
|
||||
async select(model, context) {
|
||||
const selected = modelRuntime?.getModel(model.provider, model.modelId);
|
||||
@@ -96,7 +90,8 @@ export function createModelsService(
|
||||
await lane.setModel({ provider: selected.provider, modelId: selected.id }, context);
|
||||
settingsManager?.setDefaultModelAndProvider(selected.provider, selected.id);
|
||||
await settingsManager?.flush();
|
||||
state.set({ ...state.value, configuration: await readConfiguration(context) }, context);
|
||||
state.state.configuration = await readConfiguration(context);
|
||||
state.publish(context);
|
||||
},
|
||||
async selectThinking(level, context) {
|
||||
const levels = await readThinkingLevels(context);
|
||||
@@ -104,14 +99,18 @@ export function createModelsService(
|
||||
throw new Error(`Thinking level ${level} is unavailable; choose one of: ${levels.join(", ")}`);
|
||||
}
|
||||
await lane.setThinkingLevel(level, context);
|
||||
state.set({ ...state.value, configuration: await readConfiguration(context) }, context);
|
||||
state.state.configuration = await readConfiguration(context);
|
||||
state.publish(context);
|
||||
},
|
||||
};
|
||||
return {
|
||||
service,
|
||||
async activate(context) {
|
||||
const [catalog, configuration] = await Promise.all([readCatalog(context), readConfiguration(context)]);
|
||||
state.set({ catalog, configuration, refresh: { status: "idle" } }, context);
|
||||
state.state.catalog = catalog;
|
||||
state.state.configuration = configuration;
|
||||
state.state.refresh = { status: "idle" };
|
||||
state.publish(context);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import {
|
||||
type Context,
|
||||
type ServiceProviderUpdate as CoreServiceProviderUpdate,
|
||||
RemoteServiceProvider,
|
||||
replicatedState,
|
||||
type ServiceSubscription,
|
||||
} from "@earendil-works/chord";
|
||||
import { type Context, RemoteServiceProvider, replicatedState, type ServiceSubscription } from "@earendil-works/chord";
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import {
|
||||
decodeServiceControlCall,
|
||||
type JsonValue,
|
||||
type ServiceProviderUpdate as ProtocolServiceProviderUpdate,
|
||||
ServiceProviderUpdateSchema,
|
||||
} from "@earendil-works/pi-protocol";
|
||||
import { decodeServiceControlCall, type JsonValue } from "@earendil-works/pi-protocol";
|
||||
import type { RoutedServerServiceAttachment, RoutedServerServiceHost } from "@earendil-works/pi-server";
|
||||
import { Check } from "typebox/value";
|
||||
import { PresentationPlugins } from "./plugins.ts";
|
||||
import {
|
||||
type SessionCreateOptions,
|
||||
@@ -51,7 +39,9 @@ export async function createExperimentalServerServices(options: {
|
||||
const refreshNow = async (context: Context): Promise<void> => {
|
||||
const sessions = await options.list(context);
|
||||
revision += 1;
|
||||
directory.set({ revision, sessions }, context);
|
||||
directory.state.revision = revision;
|
||||
directory.state.sessions = sessions;
|
||||
directory.publish(context);
|
||||
};
|
||||
const serialize = <T>(operation: () => Promise<T>): Promise<T> => {
|
||||
const result = mutationTail.catch(() => {}).then(operation);
|
||||
@@ -149,9 +139,7 @@ function createProviderAttachment(
|
||||
throw new Error("Service subscription ID is already active");
|
||||
}
|
||||
const subscription = provider.subscribe(control.serviceId, control.mode, (update, updateContext) => {
|
||||
void Promise.resolve(
|
||||
publish(control.subscriptionId, toProtocolServiceUpdate(update), updateContext),
|
||||
).catch(() => {});
|
||||
void Promise.resolve(publish(control.subscriptionId, update, updateContext)).catch(() => {});
|
||||
});
|
||||
subscriptions.set(control.subscriptionId, subscription);
|
||||
subscription.activate();
|
||||
@@ -180,9 +168,3 @@ function createProviderAttachment(
|
||||
function toProtocolJson(value: unknown): JsonValue {
|
||||
return value as JsonValue;
|
||||
}
|
||||
|
||||
function toProtocolServiceUpdate(update: CoreServiceProviderUpdate): ProtocolServiceProviderUpdate {
|
||||
const candidate: unknown = update;
|
||||
if (!Check(ServiceProviderUpdateSchema, candidate)) throw new Error("Service produced an invalid update");
|
||||
return candidate;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { cloneJsonValue, defineFacet, type Facet, type MutableReplicatedState } from "@earendil-works/chord";
|
||||
import { defineFacet, type Facet, type MutableReplicatedState } from "@earendil-works/chord";
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import {
|
||||
type AgentLane,
|
||||
type HarnessEvent,
|
||||
type LaneSnapshot,
|
||||
type LaneTranscriptSnapshot,
|
||||
type LaneWatchEvent,
|
||||
reduceLaneSnapshot,
|
||||
type WatchHandle,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import { Transcript, type Transcript as TranscriptService, type TranscriptUpdate } from "./transcript.ts";
|
||||
|
||||
type PendingTranscriptUpdate =
|
||||
| Omit<Extract<TranscriptUpdate, { type: "event" }>, "revision">
|
||||
| Omit<Extract<TranscriptUpdate, { type: "snapshot" }>, "revision">;
|
||||
import { Transcript, type Transcript as TranscriptService, type TranscriptState } from "./transcript.ts";
|
||||
|
||||
interface TranscriptRuntime {
|
||||
readonly service: TranscriptService;
|
||||
@@ -22,33 +19,30 @@ interface TranscriptRuntime {
|
||||
|
||||
export function createTranscriptService(
|
||||
lane: AgentLane,
|
||||
createState: (initial: TranscriptUpdate | null) => MutableReplicatedState<TranscriptUpdate | null>,
|
||||
createState: (initial: TranscriptState) => MutableReplicatedState<TranscriptState>,
|
||||
): TranscriptRuntime {
|
||||
const updates = createState(null);
|
||||
const state = createState({ snapshot: null, event: null });
|
||||
let watch: WatchHandle<LaneSnapshot> | undefined;
|
||||
let snapshot: LaneSnapshot | undefined;
|
||||
let revision = 0;
|
||||
let rebase: Promise<void> | undefined;
|
||||
let rebaseError: Error | undefined;
|
||||
|
||||
const publish = (update: PendingTranscriptUpdate, context: Parameters<typeof updates.set>[1]): void => {
|
||||
revision += 1;
|
||||
updates.set(
|
||||
update.type === "event"
|
||||
? { type: "event", revision, event: update.event }
|
||||
: { type: "snapshot", revision, snapshot: update.snapshot },
|
||||
context,
|
||||
);
|
||||
const publishSnapshot = (
|
||||
next: LaneSnapshot,
|
||||
event: LaneWatchEvent | null,
|
||||
context: Parameters<typeof state.publish>[0],
|
||||
): void => {
|
||||
state.state.snapshot = next as LaneTranscriptSnapshot;
|
||||
state.state.event = event;
|
||||
state.publish(context);
|
||||
};
|
||||
|
||||
const scheduleRebase = (context: Parameters<typeof updates.set>[1]): void => {
|
||||
const scheduleRebase = (context: Parameters<typeof state.publish>[0]): void => {
|
||||
if (rebase !== undefined) return;
|
||||
const activeWatch = watch;
|
||||
if (activeWatch === undefined) return;
|
||||
const pending = (async () => {
|
||||
const refreshed = await activeWatch.resnapshot(context);
|
||||
snapshot = refreshed;
|
||||
publish({ type: "snapshot", snapshot: cloneJsonValue(refreshed) }, context);
|
||||
publishSnapshot(refreshed, null, context);
|
||||
})();
|
||||
rebase = pending;
|
||||
void pending.then(
|
||||
@@ -62,32 +56,24 @@ export function createTranscriptService(
|
||||
);
|
||||
};
|
||||
|
||||
const onEvent = (event: HarnessEvent, context: Parameters<typeof updates.set>[1]): void => {
|
||||
const onEvent = (event: HarnessEvent, context: Parameters<typeof state.publish>[0]): void => {
|
||||
if (rebaseError !== undefined) throw rebaseError;
|
||||
const forwarded = toLaneWatchEvent(event);
|
||||
if (forwarded === undefined) return;
|
||||
const current = snapshot;
|
||||
if (current === undefined) throw new Error("Transcript service is not active");
|
||||
const reduced = reduceLaneSnapshot(current, event);
|
||||
if ("rebase" in reduced) scheduleRebase(context);
|
||||
else snapshot = reduced;
|
||||
publish({ type: "event", event: forwarded }, context);
|
||||
const snapshot = state.state.snapshot;
|
||||
if (snapshot === null) throw new Error("Transcript service is not active");
|
||||
if (reduceLaneSnapshot(snapshot, event) === "rebase") scheduleRebase(context);
|
||||
state.state.event = forwarded;
|
||||
state.publish(context);
|
||||
};
|
||||
|
||||
return {
|
||||
service: {
|
||||
updates,
|
||||
async snapshot(_context) {
|
||||
await rebase;
|
||||
if (rebaseError !== undefined) throw rebaseError;
|
||||
if (snapshot === undefined) throw new Error("Transcript service is not active");
|
||||
return { revision, snapshot: cloneJsonValue(snapshot) };
|
||||
},
|
||||
},
|
||||
service: { state },
|
||||
async activate() {
|
||||
if (watch !== undefined) throw new Error("Transcript service is already active");
|
||||
const opened = await lane.watch(BACKGROUND_CONTEXT);
|
||||
watch = opened;
|
||||
snapshot = opened.snapshot;
|
||||
publishSnapshot(opened.snapshot, null, BACKGROUND_CONTEXT);
|
||||
opened.start(onEvent);
|
||||
},
|
||||
async dispose() {
|
||||
@@ -99,7 +85,6 @@ export function createTranscriptService(
|
||||
}
|
||||
watch?.unsubscribe();
|
||||
watch = undefined;
|
||||
snapshot = undefined;
|
||||
if (failure !== undefined) throw failure;
|
||||
},
|
||||
};
|
||||
@@ -129,15 +114,15 @@ function toLaneWatchEvent(event: HarnessEvent): LaneWatchEvent | undefined {
|
||||
if (event.property !== "model" && event.property !== "thinkingLevel" && event.property !== "activeTools") {
|
||||
return undefined;
|
||||
}
|
||||
return cloneJsonValue(event);
|
||||
return event as LaneWatchEvent;
|
||||
case "message_update": {
|
||||
if (event.message.role !== "assistant") {
|
||||
throw new TypeError("Harness message_update did not carry an assistant message");
|
||||
}
|
||||
const { event: _providerEvent, ...update } = event;
|
||||
return cloneJsonValue(update);
|
||||
return update as LaneWatchEvent;
|
||||
}
|
||||
default:
|
||||
return cloneJsonValue(event);
|
||||
return event as LaneWatchEvent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import { type Context, defineService, type ReplicatedState } from "@earendil-works/chord";
|
||||
import { defineService, type ReplicatedState } from "@earendil-works/chord";
|
||||
import type { LaneTranscriptSnapshot, LaneWatchEvent } from "@earendil-works/pi-agent-core";
|
||||
|
||||
export type TranscriptUpdate =
|
||||
| { readonly type: "event"; readonly revision: number; readonly event: LaneWatchEvent }
|
||||
| { readonly type: "snapshot"; readonly revision: number; readonly snapshot: LaneTranscriptSnapshot };
|
||||
|
||||
export interface TranscriptSnapshot {
|
||||
readonly revision: number;
|
||||
readonly snapshot: LaneTranscriptSnapshot;
|
||||
export interface TranscriptState {
|
||||
snapshot: LaneTranscriptSnapshot | null;
|
||||
/** The source event is retained for presentation side effects; hydration does not replay it. */
|
||||
event: LaneWatchEvent | null;
|
||||
}
|
||||
|
||||
/** Coherent main-lane observation published as an ordinary Chord service. */
|
||||
/** Coherent main-lane state replicated through Chord's operation stream. */
|
||||
export interface Transcript {
|
||||
readonly updates: ReplicatedState<TranscriptUpdate | null>;
|
||||
snapshot(context: Context): Promise<TranscriptSnapshot>;
|
||||
readonly state: ReplicatedState<TranscriptState>;
|
||||
}
|
||||
|
||||
export const Transcript = defineService<Transcript>("pi.transcript");
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import {
|
||||
type Context,
|
||||
type ServiceProviderUpdate as CoreServiceProviderUpdate,
|
||||
cloneJsonValue,
|
||||
createFacetHost,
|
||||
createStaticFacetLoader,
|
||||
defineFacet,
|
||||
type FacetHost,
|
||||
type FacetLoader,
|
||||
type ServiceCatalogueEntry,
|
||||
type ServiceProviderUpdate,
|
||||
type ServiceSubscription,
|
||||
} from "@earendil-works/chord";
|
||||
import type { AgentHarness, AgentLane } from "@earendil-works/pi-agent-core";
|
||||
@@ -16,8 +15,6 @@ import {
|
||||
type JsonValue,
|
||||
JsonValueSchema,
|
||||
type ProtocolRpcCall,
|
||||
type ServiceProviderUpdate as ProtocolServiceProviderUpdate,
|
||||
ServiceProviderUpdateSchema,
|
||||
} from "@earendil-works/pi-protocol";
|
||||
import Type, { type Static } from "typebox";
|
||||
import { Check } from "typebox/value";
|
||||
@@ -66,7 +63,7 @@ export async function createSessionWorkerServices(options: {
|
||||
readonly modelRuntime: ModelRuntime | undefined;
|
||||
readonly settingsManager?: SettingsManager;
|
||||
readonly facetLoader?: FacetLoader;
|
||||
publish(scope: WorkerServiceScope, subscriptionId: string, update: ProtocolServiceProviderUpdate): Promise<void>;
|
||||
publish(scope: WorkerServiceScope, subscriptionId: string, update: ServiceProviderUpdate): Promise<void>;
|
||||
}): Promise<SessionWorkerServices> {
|
||||
const agentControllerRuntimeFacet = defineFacet({
|
||||
id: "@pi/agent-controller-runtime",
|
||||
@@ -136,16 +133,16 @@ export async function createSessionWorkerServices(options: {
|
||||
catalogue: provider.catalogue,
|
||||
async invoke(call, scope, context) {
|
||||
const controlCall = decodeServiceControlCall(call);
|
||||
if (controlCall?.type === "catalogue") return cloneJsonValue(provider.catalogue);
|
||||
if (controlCall?.type === "catalogue") return toProtocolJsonValue(provider.catalogue);
|
||||
if (controlCall?.type === "subscribe") {
|
||||
const key = scopedSubscriptionKey(scope, controlCall.subscriptionId);
|
||||
if (subscriptions.has(key)) throw new Error("Service subscription ID is already active");
|
||||
const subscription = provider.subscribe(controlCall.serviceId, controlCall.mode, (update) => {
|
||||
void options.publish(scope, controlCall.subscriptionId, toProtocolServiceUpdate(update)).catch(() => {});
|
||||
void options.publish(scope, controlCall.subscriptionId, update).catch(() => {});
|
||||
});
|
||||
subscriptions.set(key, { scope, subscription });
|
||||
subscription.activate();
|
||||
return cloneJsonValue(subscription.snapshot);
|
||||
return toProtocolJsonValue(subscription.snapshot);
|
||||
}
|
||||
if (controlCall?.type === "unsubscribe") {
|
||||
const key = scopedSubscriptionKey(scope, controlCall.subscriptionId);
|
||||
@@ -180,8 +177,7 @@ function scopedSubscriptionKey(scope: WorkerServiceScope, subscriptionId: string
|
||||
return `${scope.serverConnectionId}\0${scope.attachmentId}\0${subscriptionId}`;
|
||||
}
|
||||
|
||||
function toProtocolServiceUpdate(update: CoreServiceProviderUpdate): ProtocolServiceProviderUpdate {
|
||||
const candidate: unknown = update;
|
||||
if (!Check(ServiceProviderUpdateSchema, candidate)) throw new Error("Service produced an invalid update");
|
||||
return candidate;
|
||||
function toProtocolJsonValue(value: unknown): JsonValue {
|
||||
if (!Check(JsonValueSchema, value)) throw new Error("Service produced invalid JSON");
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { isAbsolute } from "node:path";
|
||||
import type { ServiceProviderUpdate } from "@earendil-works/chord";
|
||||
import {
|
||||
BACKGROUND_CONTEXT,
|
||||
type Context,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
decodeServiceControlCall,
|
||||
type ProtocolRpcCall,
|
||||
type ProtocolRpcResult,
|
||||
type ServiceProviderUpdate,
|
||||
} from "@earendil-works/pi-protocol";
|
||||
import { type RoutedSessionAttachment, type RoutedSessionHandle, ServerError } from "@earendil-works/pi-server";
|
||||
import { Check } from "typebox/value";
|
||||
@@ -573,7 +573,7 @@ export class SessionWorkerManager {
|
||||
message.sessionKey,
|
||||
message.scope,
|
||||
message.subscriptionId,
|
||||
message.update,
|
||||
message.update as unknown as ServiceProviderUpdate,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createConnection, type Socket } from "node:net";
|
||||
import { isAbsolute } from "node:path";
|
||||
import { RemoteServiceError } from "@earendil-works/chord";
|
||||
import { RemoteServiceError, type ServiceProviderUpdate } from "@earendil-works/chord";
|
||||
import {
|
||||
AgentHarness,
|
||||
type AgentHarness as AgentHarnessInstance,
|
||||
@@ -441,6 +441,12 @@ function writeJsonLine(socket: Socket, message: unknown): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function toWorkerServiceUpdate(update: ServiceProviderUpdate): Static<typeof ServiceProviderUpdateSchema> {
|
||||
const candidate: unknown = update;
|
||||
if (!Check(ServiceProviderUpdateSchema, candidate)) throw new Error("Service produced an invalid update");
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function demandKey(serverConnectionId: string, attachmentId: string): string {
|
||||
return `${serverConnectionId}\0${attachmentId}`;
|
||||
}
|
||||
@@ -540,7 +546,7 @@ async function run(options: SessionWorkerOptions, createHarness: CreateSessionWo
|
||||
sessionKey,
|
||||
scope,
|
||||
subscriptionId,
|
||||
update,
|
||||
update: toWorkerServiceUpdate(update),
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { resolve } from "node:path";
|
||||
import {
|
||||
cloneJsonValue,
|
||||
createRemoteServiceBinding,
|
||||
type MutableReplicatedState,
|
||||
RemoteServiceProvider,
|
||||
type RemoteServiceTransport,
|
||||
replicatedState,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import {
|
||||
type AgentLane,
|
||||
type LaneSnapshot,
|
||||
type LaneTranscriptSnapshot,
|
||||
type LaneWatchEvent,
|
||||
reduceLaneSnapshot,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
@@ -39,7 +40,7 @@ import {
|
||||
SessionManagement,
|
||||
type SessionSummary,
|
||||
} from "../src/experimental/services/sessions.ts";
|
||||
import { Transcript, type TranscriptUpdate } from "../src/experimental/services/transcript.ts";
|
||||
import { Transcript, type TranscriptState } from "../src/experimental/services/transcript.ts";
|
||||
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
|
||||
|
||||
const serverId = "00000000-0000-4000-8000-000000000001";
|
||||
@@ -62,6 +63,16 @@ function createLoopbackServiceTransport(provider: RemoteServiceProvider): Remote
|
||||
};
|
||||
}
|
||||
|
||||
function publishReplacement<T extends object>(state: MutableReplicatedState<T>, value: T): void {
|
||||
const target = state.state as unknown as Record<string, unknown>;
|
||||
const replacement = value as unknown as Record<string, unknown>;
|
||||
for (const key of Object.keys(target)) {
|
||||
if (!Object.hasOwn(replacement, key)) delete target[key];
|
||||
}
|
||||
Object.assign(target, replacement);
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
}
|
||||
|
||||
function laneSnapshot(): LaneSnapshot {
|
||||
return {
|
||||
lane: "main",
|
||||
@@ -115,36 +126,30 @@ describe("experimental client TUI", () => {
|
||||
});
|
||||
const create = vi.fn(async () => {
|
||||
const created = session("two", 2);
|
||||
directoryState.set(
|
||||
{ revision: 2, sessions: [...directoryState.value.sessions, created] },
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
directoryState.state.revision = 2;
|
||||
directoryState.state.sessions.push(created);
|
||||
directoryState.publish(BACKGROUND_CONTEXT);
|
||||
return created;
|
||||
});
|
||||
const select = vi.fn(async (model: { provider: string; modelId: string }) => {
|
||||
modelsState.set(
|
||||
{ ...modelsState.value, configuration: { ...modelsState.value.configuration, model } },
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
modelsState.state.configuration.model = model;
|
||||
modelsState.publish(BACKGROUND_CONTEXT);
|
||||
});
|
||||
const selectThinking = vi.fn(async (thinkingLevel: "off" | "high") => {
|
||||
modelsState.set(
|
||||
{ ...modelsState.value, configuration: { ...modelsState.value.configuration, thinkingLevel } },
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
modelsState.state.configuration.thinkingLevel = thinkingLevel;
|
||||
modelsState.publish(BACKGROUND_CONTEXT);
|
||||
});
|
||||
const transcriptState = replicatedState<TranscriptState>({
|
||||
snapshot: laneSnapshot() as LaneTranscriptSnapshot,
|
||||
event: null,
|
||||
});
|
||||
let snapshot = laneSnapshot();
|
||||
let transcriptRevision = 0;
|
||||
const transcriptUpdates = replicatedState<TranscriptUpdate | null>(null);
|
||||
const transcriptSnapshot = vi.fn(async () => ({
|
||||
revision: transcriptRevision,
|
||||
snapshot: cloneJsonValue(snapshot),
|
||||
}));
|
||||
const emitTranscriptEvent = (event: LaneWatchEvent): void => {
|
||||
const reduced = reduceLaneSnapshot(snapshot, event);
|
||||
if (!("rebase" in reduced)) snapshot = reduced;
|
||||
transcriptRevision += 1;
|
||||
transcriptUpdates.set({ type: "event", revision: transcriptRevision, event }, BACKGROUND_CONTEXT);
|
||||
const snapshot = transcriptState.state.snapshot as LaneSnapshot;
|
||||
if (reduceLaneSnapshot(snapshot, event) === "rebase") {
|
||||
throw new Error("Test transcript event unexpectedly requires a rebase");
|
||||
}
|
||||
transcriptState.state.event = event;
|
||||
transcriptState.publish(BACKGROUND_CONTEXT);
|
||||
};
|
||||
const prompt = vi.fn(async () => {
|
||||
emitTranscriptEvent({
|
||||
@@ -174,7 +179,7 @@ describe("experimental client TUI", () => {
|
||||
provider: "test",
|
||||
model: "one",
|
||||
api: "test",
|
||||
usage: snapshot.stats.usage,
|
||||
usage: transcriptState.value.snapshot!.stats.usage,
|
||||
stopReason: "stop",
|
||||
timestamp: 2,
|
||||
},
|
||||
@@ -222,10 +227,10 @@ describe("experimental client TUI", () => {
|
||||
create,
|
||||
async remove() {},
|
||||
async attach(sessionId) {
|
||||
attachment.set({ status: "attaching", sessionId }, BACKGROUND_CONTEXT);
|
||||
publishReplacement(attachment, { status: "attaching", sessionId });
|
||||
},
|
||||
async detach() {
|
||||
attachment.set({ status: "detached" }, BACKGROUND_CONTEXT);
|
||||
publishReplacement(attachment, { status: "detached" });
|
||||
},
|
||||
});
|
||||
const sessionProvider = new RemoteServiceProvider([Models, AgentController, SessionPlugins, Transcript]);
|
||||
@@ -241,7 +246,7 @@ describe("experimental client TUI", () => {
|
||||
selectThinking,
|
||||
});
|
||||
sessionProvider.provide(AgentController, createAgentController({ prompt } as unknown as AgentLane));
|
||||
sessionProvider.provide(Transcript, { updates: transcriptUpdates, snapshot: transcriptSnapshot });
|
||||
sessionProvider.provide(Transcript, { state: transcriptState });
|
||||
|
||||
const serverNamespace = createRemoteServiceBinding({
|
||||
services: [SessionDirectory, SessionManagement, PresentationPlugins],
|
||||
@@ -293,11 +298,11 @@ describe("experimental client TUI", () => {
|
||||
async whenAttached(sessionId: string) {
|
||||
await sessionNamespace.rebind(true, BACKGROUND_CONTEXT);
|
||||
await sessionNamespace.ready(BACKGROUND_CONTEXT);
|
||||
attachment.set({ status: "attached", sessionId }, BACKGROUND_CONTEXT);
|
||||
publishReplacement(attachment, { status: "attached", sessionId });
|
||||
},
|
||||
async whenDetached() {
|
||||
await sessionNamespace.rebind(false, BACKGROUND_CONTEXT);
|
||||
attachment.set({ status: "detached" }, BACKGROUND_CONTEXT);
|
||||
publishReplacement(attachment, { status: "detached" });
|
||||
},
|
||||
});
|
||||
const server: ClientTuiServer = {
|
||||
@@ -331,7 +336,6 @@ describe("experimental client TUI", () => {
|
||||
expect.anything(),
|
||||
);
|
||||
expect(attachment.value).toEqual({ status: "attached", sessionId });
|
||||
expect(transcriptSnapshot).toHaveBeenCalledOnce();
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
expect(component.render(80).join("\n")).toContain(`Server: ${serverId}`);
|
||||
expect(component.render(80).join("\n")).toContain(`Session: ${sessionId}`);
|
||||
@@ -358,11 +362,13 @@ describe("experimental client TUI", () => {
|
||||
});
|
||||
await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("Reloaded plugins."));
|
||||
|
||||
attachment.set({ status: "detached" }, BACKGROUND_CONTEXT);
|
||||
connectionState.set(
|
||||
{ status: "disconnected", since: "later", reason: "network lost", retryAt: null },
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
publishReplacement(attachment, { status: "detached" });
|
||||
publishReplacement(connectionState, {
|
||||
status: "disconnected",
|
||||
since: "later",
|
||||
reason: "network lost",
|
||||
retryAt: null,
|
||||
});
|
||||
await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("retrying"));
|
||||
component.handleInput("\u0003");
|
||||
expect(finished).toBe(true);
|
||||
@@ -370,10 +376,9 @@ describe("experimental client TUI", () => {
|
||||
component.handleInput("\u0004");
|
||||
expect(finished).toBe(true);
|
||||
finished = false;
|
||||
connectionState.set({ status: "connecting", attempt: 1 }, BACKGROUND_CONTEXT);
|
||||
connectionState.set({ status: "connected", since: "reconnected" }, BACKGROUND_CONTEXT);
|
||||
attachment.set({ status: "attached", sessionId }, BACKGROUND_CONTEXT);
|
||||
await vi.waitFor(() => expect(transcriptSnapshot).toHaveBeenCalledTimes(2));
|
||||
publishReplacement(connectionState, { status: "connecting", attempt: 1 });
|
||||
publishReplacement(connectionState, { status: "connected", since: "reconnected" });
|
||||
publishReplacement(attachment, { status: "attached", sessionId });
|
||||
await vi.waitFor(() => expect(component.render(80).join("\n")).not.toContain("Reattaching"));
|
||||
|
||||
component.handleInput("/model");
|
||||
@@ -402,9 +407,12 @@ describe("experimental client TUI", () => {
|
||||
|
||||
await component.close();
|
||||
const rendersAfterClose = requestRender.mock.calls.length;
|
||||
directoryState.set({ revision: 3, sessions: [] }, BACKGROUND_CONTEXT);
|
||||
attachment.set({ status: "detached" }, BACKGROUND_CONTEXT);
|
||||
modelsState.set({ ...modelsState.value, refresh: { status: "refreshing" } }, BACKGROUND_CONTEXT);
|
||||
directoryState.state.revision = 3;
|
||||
directoryState.state.sessions = [];
|
||||
directoryState.publish(BACKGROUND_CONTEXT);
|
||||
publishReplacement(attachment, { status: "detached" });
|
||||
modelsState.state.refresh = { status: "refreshing" };
|
||||
modelsState.publish(BACKGROUND_CONTEXT);
|
||||
expect(requestRender).toHaveBeenCalledTimes(rendersAfterClose);
|
||||
} finally {
|
||||
await component.close();
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { cloneJsonValue, replicatedState } from "@earendil-works/chord";
|
||||
import { replicatedState } from "@earendil-works/chord";
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import type { LaneSnapshot } from "@earendil-works/pi-agent-core";
|
||||
import type { LaneTranscriptSnapshot } from "@earendil-works/pi-agent-core";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { openLaneReplica } from "../src/experimental/lane-replica.ts";
|
||||
import type { TranscriptUpdate } from "../src/experimental/services/transcript.ts";
|
||||
import type { TranscriptState } from "../src/experimental/services/transcript.ts";
|
||||
|
||||
function snapshot(tipId: string | null = null): LaneSnapshot {
|
||||
function snapshot(tipId: string | null = null): LaneTranscriptSnapshot {
|
||||
return {
|
||||
lane: "main",
|
||||
transcript: [],
|
||||
@@ -33,69 +33,35 @@ function snapshot(tipId: string | null = null): LaneSnapshot {
|
||||
}
|
||||
|
||||
describe("experimental lane replica", () => {
|
||||
test("uses the Harness reducer and installs provider rebases", async () => {
|
||||
const updates = replicatedState<TranscriptUpdate | null>(null);
|
||||
const readSnapshot = vi.fn(async () => ({ revision: 0, snapshot: cloneJsonValue(snapshot()) }));
|
||||
const replica = await openLaneReplica({ updates, snapshot: readSnapshot }, "session-1");
|
||||
test("installs complete transcript state and forwards only live events", async () => {
|
||||
const staleEvent = { type: "run_start", lane: "main", runId: "stale", startedAt: 1 } as const;
|
||||
const state = replicatedState<TranscriptState>({ snapshot: snapshot(), event: staleEvent });
|
||||
const onEvent = vi.fn();
|
||||
const replica = await openLaneReplica({ state }, "session-1", onEvent);
|
||||
const changed = vi.fn();
|
||||
replica.subscribe(changed);
|
||||
expect(onEvent).not.toHaveBeenCalled();
|
||||
|
||||
updates.set(
|
||||
{
|
||||
type: "event",
|
||||
revision: 1,
|
||||
event: { type: "run_start", lane: "main", runId: "run-1", startedAt: 1 },
|
||||
},
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
await vi.waitFor(() => expect(replica.state().operation).toMatchObject({ id: "run-1", kind: "run" }));
|
||||
state.state.snapshot = snapshot("replacement-tip");
|
||||
state.state.event = null;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
expect(replica.state().tipId).toBe("replacement-tip");
|
||||
expect(changed).toHaveBeenCalledOnce();
|
||||
|
||||
updates.set(
|
||||
{
|
||||
type: "event",
|
||||
revision: 2,
|
||||
event: {
|
||||
type: "navigation_end",
|
||||
lane: "main",
|
||||
runId: "navigation-1",
|
||||
status: "completed",
|
||||
fromTipId: null,
|
||||
tipId: "replacement-tip",
|
||||
endedAt: 2,
|
||||
},
|
||||
},
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
updates.set(
|
||||
{ type: "snapshot", revision: 3, snapshot: cloneJsonValue(snapshot("replacement-tip")) },
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
await vi.waitFor(() => expect(replica.state().tipId).toBe("replacement-tip"));
|
||||
expect(readSnapshot).toHaveBeenCalledOnce();
|
||||
|
||||
const liveEvent = { type: "run_resume", lane: "main", runId: "run-1" } as const;
|
||||
state.state.event = liveEvent;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
await vi.waitFor(() => expect(onEvent).toHaveBeenCalledWith(liveEvent));
|
||||
await replica.close();
|
||||
});
|
||||
|
||||
test("repairs an application revision gap from a fresh snapshot", async () => {
|
||||
const updates = replicatedState<TranscriptUpdate | null>(null);
|
||||
const readSnapshot = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ revision: 0, snapshot: cloneJsonValue(snapshot()) })
|
||||
.mockResolvedValueOnce({ revision: 2, snapshot: cloneJsonValue(snapshot("caught-up")) });
|
||||
const replica = await openLaneReplica({ updates, snapshot: readSnapshot }, "session-1");
|
||||
|
||||
updates.set(
|
||||
{
|
||||
type: "event",
|
||||
revision: 2,
|
||||
event: { type: "run_start", lane: "main", runId: "run-2", startedAt: 2 },
|
||||
},
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(replica.state().tipId).toBe("caught-up"));
|
||||
expect(readSnapshot).toHaveBeenCalledTimes(2);
|
||||
test("waits for the first initialized transcript value", async () => {
|
||||
const state = replicatedState<TranscriptState>({ snapshot: null, event: null });
|
||||
const opening = openLaneReplica({ state }, "session-1");
|
||||
state.state.snapshot = snapshot("ready");
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
const replica = await opening;
|
||||
expect(replica.state().tipId).toBe("ready");
|
||||
await replica.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { replicatedState } from "@earendil-works/chord";
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import type { AgentLane, EventListener, HarnessEvent, LaneSnapshot, WatchHandle } from "@earendil-works/pi-agent-core";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type { TranscriptState } from "../src/experimental/services/transcript.ts";
|
||||
import { createTranscriptService } from "../src/experimental/services/transcript-provider.ts";
|
||||
|
||||
function laneSnapshot(tipId: string | null = null): LaneSnapshot {
|
||||
@@ -32,7 +33,7 @@ function laneSnapshot(tipId: string | null = null): LaneSnapshot {
|
||||
}
|
||||
|
||||
describe("Transcript service", () => {
|
||||
test("publishes ordered reducer events and a snapshot after navigation", async () => {
|
||||
test("publishes coherent state and rebases after navigation", async () => {
|
||||
let listener: EventListener | undefined;
|
||||
const replacement = laneSnapshot("replacement-tip");
|
||||
const resnapshot = vi.fn(async () => replacement);
|
||||
@@ -47,21 +48,39 @@ describe("Transcript service", () => {
|
||||
};
|
||||
const lane = { watch: async () => handle } as unknown as AgentLane;
|
||||
const runtime = createTranscriptService(lane, replicatedState);
|
||||
const updates: Array<{ type: string; revision: number }> = [];
|
||||
runtime.service.updates.subscribe((update) => {
|
||||
if (update !== null) updates.push({ type: update.type, revision: update.revision });
|
||||
const states: TranscriptState[] = [];
|
||||
runtime.service.state.subscribe((value) => {
|
||||
if (value.snapshot !== null) states.push(value);
|
||||
});
|
||||
await runtime.activate();
|
||||
|
||||
expect(await runtime.service.snapshot(BACKGROUND_CONTEXT)).toMatchObject({
|
||||
revision: 0,
|
||||
snapshot: { lane: "main", operation: null },
|
||||
});
|
||||
expect(states).toHaveLength(1);
|
||||
expect(states[0]).toMatchObject({ snapshot: { lane: "main", operation: null }, event: null });
|
||||
await listener?.({ type: "run_start", lane: "main", runId: "run-1", startedAt: 1 }, BACKGROUND_CONTEXT);
|
||||
expect(updates).toEqual([{ type: "event", revision: 1 }]);
|
||||
expect(await runtime.service.snapshot(BACKGROUND_CONTEXT)).toMatchObject({
|
||||
revision: 1,
|
||||
expect(states).toHaveLength(2);
|
||||
expect(states[1]).toMatchObject({
|
||||
snapshot: { operation: { id: "run-1" } },
|
||||
event: { type: "run_start", runId: "run-1" },
|
||||
});
|
||||
|
||||
await listener?.(
|
||||
{
|
||||
type: "entry_added",
|
||||
lane: "main",
|
||||
entry: {
|
||||
id: "entry-1",
|
||||
parentId: null,
|
||||
seq: 1,
|
||||
timestamp: 2,
|
||||
type: "message",
|
||||
message: { role: "user", content: "hello", timestamp: 2 },
|
||||
},
|
||||
},
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
expect(states.at(-1)).toMatchObject({
|
||||
snapshot: { tipId: "entry-1", transcript: [{ id: "entry-1" }] },
|
||||
event: { type: "entry_added" },
|
||||
});
|
||||
|
||||
const navigation: HarnessEvent = {
|
||||
@@ -69,22 +88,18 @@ describe("Transcript service", () => {
|
||||
lane: "main",
|
||||
runId: "navigation-1",
|
||||
status: "completed",
|
||||
fromTipId: null,
|
||||
fromTipId: "entry-1",
|
||||
tipId: "replacement-tip",
|
||||
endedAt: 2,
|
||||
endedAt: 3,
|
||||
};
|
||||
await listener?.(navigation, BACKGROUND_CONTEXT);
|
||||
await vi.waitFor(() =>
|
||||
expect(updates).toEqual([
|
||||
{ type: "event", revision: 1 },
|
||||
{ type: "event", revision: 2 },
|
||||
{ type: "snapshot", revision: 3 },
|
||||
]),
|
||||
);
|
||||
expect(await runtime.service.snapshot(BACKGROUND_CONTEXT)).toMatchObject({
|
||||
revision: 3,
|
||||
snapshot: { tipId: "replacement-tip" },
|
||||
await vi.waitFor(() => expect(states.at(-1)?.event).toBeNull());
|
||||
expect(states.at(-2)).toMatchObject({
|
||||
snapshot: { tipId: "entry-1" },
|
||||
event: { type: "navigation_end" },
|
||||
});
|
||||
expect(states.at(-1)).toMatchObject({ snapshot: { tipId: "replacement-tip" }, event: null });
|
||||
expect(runtime.service.state.value).toMatchObject({ snapshot: { tipId: "replacement-tip" }, event: null });
|
||||
expect(resnapshot).toHaveBeenCalledOnce();
|
||||
|
||||
await runtime.dispose();
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
Runtime-neutral schemas, types, CBOR encoding, and byte-stream framing for the experimental Pi protocol.
|
||||
|
||||
Protocol version `7` carries the current server- and Session-scoped facet-service slice, including Session-branch plugin preparation:
|
||||
Protocol version `8` carries the current server- and Session-scoped facet-service slice, including operation-based replicated state and Session-branch plugin preparation:
|
||||
|
||||
- a version handshake that identifies the logical `serverId`;
|
||||
- explicit server and Session request targets;
|
||||
- contract-agnostic service calls with optional keyed-instance identity;
|
||||
- singleton and keyed-service subscription snapshots plus ordered state, spawn, and close updates;
|
||||
- singleton and keyed-service subscription snapshots plus ordered operation-based state, spawn, and close updates;
|
||||
- correlated responses, request cancellation, out-of-band attachment updates, attachment-scoped events, and bounded protocol errors.
|
||||
|
||||
The transport carries `{ serviceId, instance?, member, args }` calls. A server target contains `{ serverId }`; a Session target contains `{ serverId, sessionId, attachmentId }`. Keyed addresses contain an application key and provider-owned generation so delayed calls cannot reach a replacement instance. Service subscriptions return a complete member/state snapshot before update delivery starts. Service updates carry state, keyed spawn/close, temporary unavailability, and complete singleton replacement; there is no remote service-event member kind. Session-directory state and management results are opaque application service data, not protocol DTOs. Management `attach()` and `detach()` return no routing identifiers; the server publishes the selected live route in an out-of-band `attachment` message. The real `Session` and `AgentHarness` remain process-local. Disconnecting releases only that presentation's attachment after admitted service calls settle.
|
||||
|
||||
Experimental presentations consume server-owned `SessionDirectory` and `SessionManagement` plus provider-generated Session service catalogues; there is no handwritten worker service inventory. `Models` has an implemented provider. `AgentController` is the presentation-safe command facade over the worker-owned main `AgentLane`. `Transcript` publishes coherent snapshots and ordered reducer events through ordinary Chord service state. Session service calls and updates route opaquely to the attached worker, where the provider validates and invokes them. The protocol does not define Agent, lane, transcript-entry, branch, model, or plugin business schemas.
|
||||
Experimental presentations consume server-owned `SessionDirectory` and `SessionManagement` plus provider-generated Session service catalogues; there is no handwritten worker service inventory. `Models` has an implemented provider. `AgentController` is the presentation-safe command facade over the worker-owned main `AgentLane`. `Transcript` publishes through ordinary tracked Chord state. Chord flushes decoded operations behind the state API; each client subscription owns one stateful encoder and decoder per replicated member so hydration bases and path dictionaries remain independent. Session service calls and updates route opaquely to the attached worker, where the provider validates and invokes them. The protocol does not define Agent, lane, transcript-entry, branch, model, or plugin business schemas.
|
||||
|
||||
Server and worker lifecycle is intentionally outside this public protocol. The experimental local coordinator is only an opaque message router; each replaceable server process owns the private lifecycle protocol.
|
||||
|
||||
|
||||
@@ -37,3 +37,4 @@ export {
|
||||
type ServiceSubscriptionSnapshot,
|
||||
type SessionTarget,
|
||||
} from "./protocol.ts";
|
||||
export * from "./service-state.ts";
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { isJsonValue } from "@earendil-works/chord";
|
||||
import type { WireOp } from "@earendil-works/chord/delta";
|
||||
import Type, { type Static } from "typebox";
|
||||
import { Check } from "typebox/value";
|
||||
import { type JsonValue, JsonValueSchema } from "./json-value.ts";
|
||||
|
||||
export const PROTOCOL_VERSION = 7 as const;
|
||||
export const PROTOCOL_VERSION = 8 as const;
|
||||
|
||||
const IdSchema = Type.String({ minLength: 1 });
|
||||
const StrictObject = <const T extends Parameters<typeof Type.Object>[0]>(properties: T) =>
|
||||
@@ -49,6 +50,41 @@ export const ProtocolRpcCallSchema = StrictObject({
|
||||
export type ProtocolRpcCall = Static<typeof ProtocolRpcCallSchema>;
|
||||
export type ProtocolRpcResult = JsonValue | undefined;
|
||||
|
||||
const DeltaPathSegmentSchema = Type.Union([Type.String(), Type.Integer({ minimum: 0 })]);
|
||||
const DeltaPathSchema = Type.Array(DeltaPathSegmentSchema);
|
||||
const DeltaNonEmptyPathSchema = Type.Array(DeltaPathSegmentSchema, { minItems: 1 });
|
||||
const DeltaPathRefSchema = Type.Union([DeltaPathSchema, Type.Integer({ minimum: 0 })]);
|
||||
const DeltaNonEmptyPathRefSchema = Type.Union([DeltaNonEmptyPathSchema, Type.Integer({ minimum: 0 })]);
|
||||
const DeltaOpsSchema = Type.Array(
|
||||
Type.Unsafe<WireOp>(
|
||||
Type.Union([
|
||||
Type.Tuple([Type.Literal("r"), JsonValueSchema]),
|
||||
Type.Tuple([Type.Literal("#"), Type.Integer({ minimum: 0 }), DeltaPathSchema]),
|
||||
Type.Tuple([Type.Literal("s"), DeltaNonEmptyPathRefSchema, JsonValueSchema]),
|
||||
Type.Tuple([Type.Literal("s"), JsonValueSchema]),
|
||||
Type.Tuple([Type.Literal("d"), DeltaNonEmptyPathRefSchema]),
|
||||
Type.Tuple([Type.Literal("d")]),
|
||||
Type.Tuple([Type.Literal("a"), DeltaNonEmptyPathRefSchema, Type.String()]),
|
||||
Type.Tuple([Type.Literal("a"), Type.String()]),
|
||||
Type.Tuple([Type.Literal("t"), DeltaNonEmptyPathRefSchema, Type.Integer({ minimum: 0 })]),
|
||||
Type.Tuple([Type.Literal("t"), Type.Integer({ minimum: 0 })]),
|
||||
Type.Tuple([
|
||||
Type.Literal("p"),
|
||||
DeltaPathRefSchema,
|
||||
Type.Integer({ minimum: 0 }),
|
||||
Type.Integer({ minimum: 0 }),
|
||||
Type.Array(JsonValueSchema),
|
||||
]),
|
||||
Type.Tuple([
|
||||
Type.Literal("p"),
|
||||
Type.Integer({ minimum: 0 }),
|
||||
Type.Integer({ minimum: 0 }),
|
||||
Type.Array(JsonValueSchema),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const ServiceMemberSnapshotSchema = Type.Union([
|
||||
StrictObject({
|
||||
name: IdSchema,
|
||||
@@ -58,7 +94,7 @@ const ServiceMemberSnapshotSchema = Type.Union([
|
||||
name: IdSchema,
|
||||
kind: Type.Literal("state"),
|
||||
sequence: Type.Integer({ minimum: 0 }),
|
||||
value: JsonValueSchema,
|
||||
ops: DeltaOpsSchema,
|
||||
}),
|
||||
]);
|
||||
const ServiceInstanceSnapshotSchema = StrictObject({
|
||||
@@ -85,7 +121,7 @@ export const ServiceProviderUpdateSchema = Type.Union([
|
||||
instance: Type.Optional(ServiceInstanceAddressSchema),
|
||||
member: IdSchema,
|
||||
sequence: Type.Integer({ minimum: 1 }),
|
||||
value: JsonValueSchema,
|
||||
ops: DeltaOpsSchema,
|
||||
}),
|
||||
StrictObject({
|
||||
type: Type.Literal("unavailable"),
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import type {
|
||||
ServiceInstanceSnapshot as DecodedServiceInstanceSnapshot,
|
||||
ServiceProviderUpdate as DecodedServiceProviderUpdate,
|
||||
ServiceSubscriptionSnapshot as DecodedServiceSubscriptionSnapshot,
|
||||
ServiceInstanceAddress,
|
||||
} from "@earendil-works/chord";
|
||||
import { type Decoder, decoder, type Encoder, encoder } from "@earendil-works/chord/delta";
|
||||
import type {
|
||||
ServiceProviderUpdate as WireServiceProviderUpdate,
|
||||
ServiceSubscriptionSnapshot as WireServiceSubscriptionSnapshot,
|
||||
} from "./protocol.ts";
|
||||
|
||||
export type { DecodedServiceProviderUpdate, DecodedServiceSubscriptionSnapshot };
|
||||
|
||||
/** Stateful operation encoders for every state in one service subscription. */
|
||||
export interface ServiceStateEncoder {
|
||||
encodeSnapshot(snapshot: DecodedServiceSubscriptionSnapshot): WireServiceSubscriptionSnapshot;
|
||||
encodeUpdate(update: DecodedServiceProviderUpdate): WireServiceProviderUpdate;
|
||||
}
|
||||
|
||||
/** Stateful operation decoders for every state in one service subscription. */
|
||||
export interface ServiceStateDecoder {
|
||||
decodeSnapshot(snapshot: WireServiceSubscriptionSnapshot): DecodedServiceSubscriptionSnapshot;
|
||||
decodeUpdate(update: WireServiceProviderUpdate): DecodedServiceProviderUpdate;
|
||||
}
|
||||
|
||||
interface CodecEntry<C> {
|
||||
readonly instance: ServiceInstanceAddress | undefined;
|
||||
readonly codec: C;
|
||||
}
|
||||
|
||||
class StateCodecRegistry<C> {
|
||||
readonly #create: () => C;
|
||||
readonly #entries = new Map<string, CodecEntry<C>>();
|
||||
|
||||
constructor(create: () => C) {
|
||||
this.#create = create;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.#entries.clear();
|
||||
}
|
||||
|
||||
add(instance: ServiceInstanceAddress | undefined, member: string): C {
|
||||
const key = stateKey(instance, member);
|
||||
if (this.#entries.has(key)) throw new Error(`Duplicate service state ${describeState(instance, member)}`);
|
||||
const codec = this.#create();
|
||||
this.#entries.set(key, { instance, codec });
|
||||
return codec;
|
||||
}
|
||||
|
||||
get(instance: ServiceInstanceAddress | undefined, member: string): C {
|
||||
const codec = this.#entries.get(stateKey(instance, member))?.codec;
|
||||
if (codec === undefined) throw new Error(`Unknown service state ${describeState(instance, member)}`);
|
||||
return codec;
|
||||
}
|
||||
|
||||
removeInstance(instance: ServiceInstanceAddress): void {
|
||||
for (const [key, entry] of this.#entries) {
|
||||
if (sameAddress(entry.instance, instance)) this.#entries.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createServiceStateEncoder(): ServiceStateEncoder {
|
||||
const codecs = new StateCodecRegistry<Encoder>(encoder);
|
||||
return {
|
||||
encodeSnapshot(snapshot) {
|
||||
codecs.reset();
|
||||
return {
|
||||
...snapshot,
|
||||
instances: snapshot.instances.map((instance) => encodeInstance(instance, codecs)),
|
||||
};
|
||||
},
|
||||
encodeUpdate(update) {
|
||||
switch (update.type) {
|
||||
case "state":
|
||||
return { ...update, ops: codecs.get(update.instance, update.member).encode(update.ops) };
|
||||
case "replaced":
|
||||
codecs.reset();
|
||||
return { ...update, snapshot: encodeInstance(update.snapshot, codecs) };
|
||||
case "spawned":
|
||||
return { ...update, instance: encodeInstance(update.instance, codecs) };
|
||||
case "unavailable":
|
||||
codecs.reset();
|
||||
return update;
|
||||
case "closed":
|
||||
codecs.removeInstance(update.instance);
|
||||
return update;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createServiceStateDecoder(): ServiceStateDecoder {
|
||||
const codecs = new StateCodecRegistry<Decoder>(decoder);
|
||||
return {
|
||||
decodeSnapshot(snapshot) {
|
||||
codecs.reset();
|
||||
return {
|
||||
...snapshot,
|
||||
instances: snapshot.instances.map((instance) => decodeInstance(instance, codecs)),
|
||||
};
|
||||
},
|
||||
decodeUpdate(update) {
|
||||
switch (update.type) {
|
||||
case "state":
|
||||
return { ...update, ops: codecs.get(update.instance, update.member).decode(update.ops) };
|
||||
case "replaced":
|
||||
codecs.reset();
|
||||
return { ...update, snapshot: decodeInstance(update.snapshot, codecs) };
|
||||
case "spawned":
|
||||
return { ...update, instance: decodeInstance(update.instance, codecs) };
|
||||
case "unavailable":
|
||||
codecs.reset();
|
||||
return update;
|
||||
case "closed":
|
||||
codecs.removeInstance(update.instance);
|
||||
return update;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function encodeInstance(
|
||||
instance: DecodedServiceInstanceSnapshot,
|
||||
codecs: StateCodecRegistry<Encoder>,
|
||||
): WireServiceSubscriptionSnapshot["instances"][number] {
|
||||
return {
|
||||
...instance,
|
||||
members: instance.members.map((member) =>
|
||||
member.kind === "state"
|
||||
? { ...member, ops: codecs.add(instance.instance, member.name).encode(member.ops) }
|
||||
: member,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeInstance(
|
||||
instance: WireServiceSubscriptionSnapshot["instances"][number],
|
||||
codecs: StateCodecRegistry<Decoder>,
|
||||
): DecodedServiceInstanceSnapshot {
|
||||
return {
|
||||
...instance,
|
||||
members: instance.members.map((member) =>
|
||||
member.kind === "state"
|
||||
? { ...member, ops: codecs.add(instance.instance, member.name).decode(member.ops) }
|
||||
: member,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function stateKey(instance: ServiceInstanceAddress | undefined, member: string): string {
|
||||
return JSON.stringify([instance?.key ?? null, instance?.generation ?? null, member]);
|
||||
}
|
||||
|
||||
function sameAddress(left: ServiceInstanceAddress | undefined, right: ServiceInstanceAddress): boolean {
|
||||
return left?.key === right.key && left.generation === right.generation;
|
||||
}
|
||||
|
||||
function describeState(instance: ServiceInstanceAddress | undefined, member: string): string {
|
||||
return instance === undefined ? member : `${instance.key}@${instance.generation}.${member}`;
|
||||
}
|
||||
@@ -4,8 +4,12 @@ import {
|
||||
type ClientMessage,
|
||||
ClientMessageDecoder,
|
||||
createServiceCatalogueCall,
|
||||
createServiceStateDecoder,
|
||||
createServiceStateEncoder,
|
||||
createServiceSubscribeCall,
|
||||
createServiceUnsubscribeCall,
|
||||
type DecodedServiceProviderUpdate,
|
||||
type DecodedServiceSubscriptionSnapshot,
|
||||
decodeCbor,
|
||||
decodeServiceControlCall,
|
||||
encodeCbor,
|
||||
@@ -33,11 +37,11 @@ const serverHello: ServerHello = {
|
||||
};
|
||||
|
||||
describe("protocol validation", () => {
|
||||
test("negotiates protocol version 7", () => {
|
||||
expect(PROTOCOL_VERSION).toBe(7);
|
||||
expect(isSupportedProtocolVersion(7)).toBe(true);
|
||||
expect(isSupportedProtocolVersion(6)).toBe(false);
|
||||
expect(isSupportedProtocolVersion(7.5)).toBe(false);
|
||||
test("negotiates protocol version 8", () => {
|
||||
expect(PROTOCOL_VERSION).toBe(8);
|
||||
expect(isSupportedProtocolVersion(8)).toBe(true);
|
||||
expect(isSupportedProtocolVersion(7)).toBe(false);
|
||||
expect(isSupportedProtocolVersion(8.5)).toBe(false);
|
||||
});
|
||||
|
||||
test.each([0, PROTOCOL_VERSION, PROTOCOL_VERSION + 1])(
|
||||
@@ -98,7 +102,7 @@ describe("protocol validation", () => {
|
||||
mode: "singleton",
|
||||
instances: [
|
||||
{
|
||||
members: [{ name: "state", kind: "state", sequence: 0, value: { revision: 1 } }],
|
||||
members: [{ name: "state", kind: "state", sequence: 0, ops: [["r", { revision: 1 }]] }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -107,7 +111,7 @@ describe("protocol validation", () => {
|
||||
mode: "singleton",
|
||||
instances: [
|
||||
{
|
||||
members: [{ name: "state", kind: "state", sequence: 0, value: { revision: 1 } }],
|
||||
members: [{ name: "state", kind: "state", sequence: 0, ops: [["r", { revision: 1 }]] }],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -119,7 +123,7 @@ describe("protocol validation", () => {
|
||||
type: "state",
|
||||
member: "state",
|
||||
sequence: 1,
|
||||
value: { revision: 2 },
|
||||
ops: [["s", ["revision"], 2]],
|
||||
},
|
||||
}),
|
||||
).toMatchObject({ update: { type: "state", member: "state" } });
|
||||
@@ -158,6 +162,141 @@ describe("protocol validation", () => {
|
||||
expect(parseClientMessage(keyed)).toEqual(keyed);
|
||||
});
|
||||
|
||||
test("keeps one operation codec pair for one subscription state", () => {
|
||||
const enc = createServiceStateEncoder();
|
||||
const dec = createServiceStateDecoder();
|
||||
const snapshot: DecodedServiceSubscriptionSnapshot = {
|
||||
serviceId: "pi.models",
|
||||
mode: "singleton",
|
||||
instances: [
|
||||
{
|
||||
members: [{ name: "state", kind: "state", sequence: 0, ops: [["r", { revision: 0 }]] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(dec.decodeSnapshot(enc.encodeSnapshot(snapshot))).toEqual(snapshot);
|
||||
|
||||
const first: DecodedServiceProviderUpdate = {
|
||||
type: "state",
|
||||
member: "state",
|
||||
sequence: 1,
|
||||
ops: [["s", ["revision"], 1]],
|
||||
};
|
||||
const second: DecodedServiceProviderUpdate = {
|
||||
type: "state",
|
||||
member: "state",
|
||||
sequence: 2,
|
||||
ops: [["s", ["revision"], 2]],
|
||||
};
|
||||
const firstWire = enc.encodeUpdate(first);
|
||||
const secondWire = enc.encodeUpdate(second);
|
||||
expect(firstWire).toMatchObject({ ops: [["s", ["revision"], 1]] });
|
||||
expect(secondWire).toMatchObject({
|
||||
ops: [
|
||||
["#", 0, ["revision"]],
|
||||
["s", 0, 2],
|
||||
],
|
||||
});
|
||||
expect(dec.decodeUpdate(firstWire)).toEqual(first);
|
||||
expect(dec.decodeUpdate(secondWire)).toEqual(second);
|
||||
});
|
||||
|
||||
test("isolates operation dictionaries between states and subscriptions", () => {
|
||||
const snapshot: DecodedServiceSubscriptionSnapshot = {
|
||||
serviceId: "pi.states",
|
||||
mode: "singleton",
|
||||
instances: [
|
||||
{
|
||||
members: [
|
||||
{ name: "left", kind: "state", sequence: 0, ops: [["r", { revision: 0 }]] },
|
||||
{ name: "right", kind: "state", sequence: 0, ops: [["r", { revision: 0 }]] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const firstEncoder = createServiceStateEncoder();
|
||||
const firstDecoder = createServiceStateDecoder();
|
||||
const secondEncoder = createServiceStateEncoder();
|
||||
const secondDecoder = createServiceStateDecoder();
|
||||
firstDecoder.decodeSnapshot(firstEncoder.encodeSnapshot(snapshot));
|
||||
secondDecoder.decodeSnapshot(secondEncoder.encodeSnapshot(snapshot));
|
||||
|
||||
const update = (member: string, sequence: number, revision: number): DecodedServiceProviderUpdate => ({
|
||||
type: "state",
|
||||
member,
|
||||
sequence,
|
||||
ops: [["s", ["revision"], revision]],
|
||||
});
|
||||
const firstLeft = firstEncoder.encodeUpdate(update("left", 1, 1));
|
||||
const firstRight = firstEncoder.encodeUpdate(update("right", 1, 1));
|
||||
const secondLeft = firstEncoder.encodeUpdate(update("left", 2, 2));
|
||||
const secondRight = firstEncoder.encodeUpdate(update("right", 2, 2));
|
||||
expect(firstLeft).toMatchObject({ ops: [["s", ["revision"], 1]] });
|
||||
expect(firstRight).toMatchObject({ ops: [["s", ["revision"], 1]] });
|
||||
expect(secondLeft).toMatchObject({
|
||||
ops: [
|
||||
["#", 0, ["revision"]],
|
||||
["s", 0, 2],
|
||||
],
|
||||
});
|
||||
expect(secondRight).toMatchObject({
|
||||
ops: [
|
||||
["#", 0, ["revision"]],
|
||||
["s", 0, 2],
|
||||
],
|
||||
});
|
||||
expect(firstDecoder.decodeUpdate(firstLeft)).toEqual(update("left", 1, 1));
|
||||
expect(firstDecoder.decodeUpdate(firstRight)).toEqual(update("right", 1, 1));
|
||||
expect(firstDecoder.decodeUpdate(secondLeft)).toEqual(update("left", 2, 2));
|
||||
expect(firstDecoder.decodeUpdate(secondRight)).toEqual(update("right", 2, 2));
|
||||
|
||||
const independentLeft = secondEncoder.encodeUpdate(update("left", 1, 1));
|
||||
expect(independentLeft).toMatchObject({ ops: [["s", ["revision"], 1]] });
|
||||
expect(secondDecoder.decodeUpdate(independentLeft)).toEqual(update("left", 1, 1));
|
||||
|
||||
const leftBase: DecodedServiceProviderUpdate = {
|
||||
type: "state",
|
||||
member: "left",
|
||||
sequence: 3,
|
||||
ops: [["r", { revision: 3 }]],
|
||||
};
|
||||
expect(firstDecoder.decodeUpdate(firstEncoder.encodeUpdate(leftBase))).toEqual(leftBase);
|
||||
const thirdRight = firstEncoder.encodeUpdate(update("right", 3, 3));
|
||||
expect(thirdRight).toMatchObject({ ops: [["s", 0, 3]] });
|
||||
expect(firstDecoder.decodeUpdate(thirdRight)).toEqual(update("right", 3, 3));
|
||||
});
|
||||
|
||||
test("creates and removes keyed instance codecs with their lifecycle", () => {
|
||||
const enc = createServiceStateEncoder();
|
||||
const dec = createServiceStateDecoder();
|
||||
const snapshot: DecodedServiceSubscriptionSnapshot = {
|
||||
serviceId: "pi.dialogs",
|
||||
mode: "keyed",
|
||||
instances: [],
|
||||
};
|
||||
expect(dec.decodeSnapshot(enc.encodeSnapshot(snapshot))).toEqual(snapshot);
|
||||
const address = { key: "dialog-1", generation: 1 };
|
||||
const spawned: DecodedServiceProviderUpdate = {
|
||||
type: "spawned",
|
||||
instance: {
|
||||
instance: address,
|
||||
members: [{ name: "request", kind: "state", sequence: 0, ops: [["r", { value: 0 }]] }],
|
||||
},
|
||||
};
|
||||
expect(dec.decodeUpdate(enc.encodeUpdate(spawned))).toEqual(spawned);
|
||||
const update: DecodedServiceProviderUpdate = {
|
||||
type: "state",
|
||||
instance: address,
|
||||
member: "request",
|
||||
sequence: 1,
|
||||
ops: [["s", ["value"], 1]],
|
||||
};
|
||||
expect(dec.decodeUpdate(enc.encodeUpdate(update))).toEqual(update);
|
||||
const closed: DecodedServiceProviderUpdate = { type: "closed", instance: address };
|
||||
expect(dec.decodeUpdate(enc.encodeUpdate(closed))).toEqual(closed);
|
||||
expect(() => enc.encodeUpdate({ ...update, sequence: 2 })).toThrow("Unknown service state");
|
||||
});
|
||||
|
||||
test("keeps transport RPC payloads opaque while validating their envelope", () => {
|
||||
const message: ClientMessage = {
|
||||
type: "request",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ClientMessageDecoder, RpcTarget } from "@earendil-works/pi-protocol";
|
||||
import type { ClientMessageDecoder, RpcTarget, ServiceStateEncoder } from "@earendil-works/pi-protocol";
|
||||
|
||||
import type { MaybePromise, RoutedServerServiceAttachment } from "./types.ts";
|
||||
|
||||
@@ -22,6 +22,7 @@ export type ConnectionStage = "awaitingHello" | "handshaking" | "ready" | "closi
|
||||
export interface ConnectionState {
|
||||
connection: ByteConnection;
|
||||
decoder: ClientMessageDecoder;
|
||||
serviceStateEncoders: Map<string, ServiceStateEncoder>;
|
||||
stage: ConnectionStage;
|
||||
disconnected: boolean;
|
||||
handshake?: Promise<void>;
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { RemoteServiceError } from "@earendil-works/chord";
|
||||
import {
|
||||
RemoteServiceError,
|
||||
type ServiceProviderUpdate,
|
||||
type ServiceSubscriptionSnapshot,
|
||||
} from "@earendil-works/chord";
|
||||
import { BACKGROUND_CONTEXT, type SessionMetadata, TODO_CONTEXT, withAbortSignal } from "@earendil-works/pi-agent-core";
|
||||
import {
|
||||
type CancelEnvelope,
|
||||
type ClientHello,
|
||||
type ClientMessage,
|
||||
ClientMessageDecoder,
|
||||
createServiceStateEncoder,
|
||||
DEFAULT_MAX_FRAME_LENGTH,
|
||||
decodeServiceControlCall,
|
||||
encodeServerMessage,
|
||||
isServerId,
|
||||
isSupportedProtocolVersion,
|
||||
@@ -146,6 +152,7 @@ export class Server<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
state = {
|
||||
connection,
|
||||
decoder: new ClientMessageDecoder({ maxFrameLength: this.maxFrameLength }),
|
||||
serviceStateEncoders: new Map(),
|
||||
stage: "awaitingHello",
|
||||
disconnected: false,
|
||||
handshakeTimeout,
|
||||
@@ -308,47 +315,77 @@ export class Server<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
const active = { controller, target: envelope.target };
|
||||
state.activeRequests.set(envelope.id, active);
|
||||
const context = withAbortSignal(controller.signal, TODO_CONTEXT);
|
||||
const control = decodeServiceControlCall(envelope.call);
|
||||
const subscribing = control?.type === "subscribe" ? control : undefined;
|
||||
const pendingUpdates: { readonly update: ServiceProviderUpdate }[] = [];
|
||||
let subscriptionReady = subscribing === undefined;
|
||||
let installedSubscriptionEncoder = false;
|
||||
let responded = false;
|
||||
const publish = async (subscriptionId: string, update: ServiceProviderUpdate): Promise<void> => {
|
||||
if (subscribing !== undefined && subscriptionId === subscribing.subscriptionId && !subscriptionReady) {
|
||||
pendingUpdates.push({ update });
|
||||
return;
|
||||
}
|
||||
await this.sendServiceUpdate(state, subscriptionId, update);
|
||||
};
|
||||
try {
|
||||
if (envelope.target.serverId !== this.serverId) throw new WrongServerError();
|
||||
if (subscribing !== undefined && state.serviceStateEncoders.has(subscribing.subscriptionId)) {
|
||||
throw new ProtocolValidationError(`Duplicate service subscription ${subscribing.subscriptionId}`);
|
||||
}
|
||||
let result: ProtocolRpcResult;
|
||||
if ("sessionId" in envelope.target) {
|
||||
result = await this.sessions.executeServiceCall(
|
||||
envelope.call,
|
||||
envelope.target,
|
||||
state,
|
||||
async (message, _context) => {
|
||||
await this.sendMessage(state, message);
|
||||
},
|
||||
context,
|
||||
);
|
||||
result = await this.sessions.executeServiceCall(envelope.call, envelope.target, state, publish, context);
|
||||
} else if (state.serverServices !== undefined) {
|
||||
result = await state.serverServices.invokeService(
|
||||
envelope.call,
|
||||
async (subscriptionId, update) => {
|
||||
await this.sendMessage(state, { type: "service_update", subscriptionId, update });
|
||||
},
|
||||
context,
|
||||
);
|
||||
result = await state.serverServices.invokeService(envelope.call, publish, context);
|
||||
} else {
|
||||
throw new ProtocolValidationError(
|
||||
`Unknown service member ${envelope.call.serviceId}.${envelope.call.member}`,
|
||||
);
|
||||
}
|
||||
if (subscribing !== undefined) {
|
||||
if (result === undefined)
|
||||
throw new ProtocolValidationError("Service subscription did not return a snapshot");
|
||||
const stateEncoder = createServiceStateEncoder();
|
||||
result = stateEncoder.encodeSnapshot(result as unknown as ServiceSubscriptionSnapshot) as ProtocolRpcResult;
|
||||
state.serviceStateEncoders.set(subscribing.subscriptionId, stateEncoder);
|
||||
installedSubscriptionEncoder = true;
|
||||
} else if (control?.type === "unsubscribe") {
|
||||
state.serviceStateEncoders.delete(control.subscriptionId);
|
||||
}
|
||||
await this.sendMessage(
|
||||
state,
|
||||
result === undefined
|
||||
? { type: "response", id: envelope.id, ok: true }
|
||||
: { type: "response", id: envelope.id, ok: true, result },
|
||||
);
|
||||
responded = true;
|
||||
if (subscribing !== undefined) {
|
||||
while (pendingUpdates.length > 0) {
|
||||
const pending = pendingUpdates.shift();
|
||||
if (pending !== undefined)
|
||||
await this.sendServiceUpdate(state, subscribing.subscriptionId, pending.update);
|
||||
}
|
||||
subscriptionReady = true;
|
||||
}
|
||||
} catch (error) {
|
||||
await this.sendMessage(state, {
|
||||
type: "response",
|
||||
id: envelope.id,
|
||||
ok: false,
|
||||
error: controller.signal.aborted
|
||||
? { code: "cancelled", message: "RPC request cancelled" }
|
||||
: this.toProtocolError(error),
|
||||
} satisfies ResponseEnvelope);
|
||||
if (subscribing !== undefined && installedSubscriptionEncoder && !responded) {
|
||||
state.serviceStateEncoders.delete(subscribing.subscriptionId);
|
||||
}
|
||||
if (responded) {
|
||||
this.reportError(error);
|
||||
await this.closeConnection(state.connection);
|
||||
this.disconnect(state);
|
||||
} else {
|
||||
await this.sendMessage(state, {
|
||||
type: "response",
|
||||
id: envelope.id,
|
||||
ok: false,
|
||||
error: controller.signal.aborted
|
||||
? { code: "cancelled", message: "RPC request cancelled" }
|
||||
: this.toProtocolError(error),
|
||||
} satisfies ResponseEnvelope);
|
||||
}
|
||||
} finally {
|
||||
if (state.activeRequests.get(envelope.id) === active) state.activeRequests.delete(envelope.id);
|
||||
}
|
||||
@@ -374,6 +411,7 @@ export class Server<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
controller.abort(new Error("Client disconnected"));
|
||||
}
|
||||
connection.activeRequests.clear();
|
||||
connection.serviceStateEncoders.clear();
|
||||
if (this.connections.delete(connection)) this.notifyConnectionCountChanged();
|
||||
const serverServices = connection.serverServices;
|
||||
delete connection.serverServices;
|
||||
@@ -385,6 +423,20 @@ export class Server<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
});
|
||||
}
|
||||
|
||||
private async sendServiceUpdate(
|
||||
connection: ConnectionState,
|
||||
subscriptionId: string,
|
||||
update: ServiceProviderUpdate,
|
||||
): Promise<void> {
|
||||
const stateEncoder = connection.serviceStateEncoders.get(subscriptionId);
|
||||
if (stateEncoder === undefined) return;
|
||||
await this.sendMessage(connection, {
|
||||
type: "service_update",
|
||||
subscriptionId,
|
||||
update: stateEncoder.encodeUpdate(update),
|
||||
});
|
||||
}
|
||||
|
||||
private async sendMessage(connection: ConnectionState, message: ServerMessage): Promise<boolean> {
|
||||
if (connection.disconnected || connection.connection.closed) return false;
|
||||
let frame: Uint8Array;
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { ServiceProviderUpdate } from "@earendil-works/chord";
|
||||
import { BACKGROUND_CONTEXT, type Context, type SessionMetadata } from "@earendil-works/pi-agent-core";
|
||||
import type {
|
||||
ProtocolRpcCall,
|
||||
ProtocolRpcResult,
|
||||
RpcTarget,
|
||||
ServiceEventEnvelope,
|
||||
SessionTarget,
|
||||
} from "@earendil-works/pi-protocol";
|
||||
import type { ProtocolRpcCall, ProtocolRpcResult, RpcTarget, SessionTarget } from "@earendil-works/pi-protocol";
|
||||
import { NotSupportedError, ServerDrainingError, SessionNotAttachedError } from "./errors.ts";
|
||||
import type { RoutedSessionAttachment, RoutedSessionHandle, ServerHost } from "./types.ts";
|
||||
|
||||
@@ -53,7 +48,7 @@ export class SessionRouter<TMetadata extends SessionMetadata = SessionMetadata>
|
||||
call: ProtocolRpcCall,
|
||||
target: RpcTarget,
|
||||
client: object,
|
||||
publish: (message: ServiceEventEnvelope, context: Context) => Promise<void>,
|
||||
publish: (subscriptionId: string, update: ServiceProviderUpdate, context: Context) => Promise<void>,
|
||||
context: Context,
|
||||
): Promise<ProtocolRpcResult> {
|
||||
const admitted = await this.runForClient(client, () =>
|
||||
@@ -205,7 +200,7 @@ export class SessionRouter<TMetadata extends SessionMetadata = SessionMetadata>
|
||||
client: object,
|
||||
target: RpcTarget,
|
||||
call: ProtocolRpcCall,
|
||||
publish: (message: ServiceEventEnvelope, context: Context) => Promise<void>,
|
||||
publish: (subscriptionId: string, update: ServiceProviderUpdate, context: Context) => Promise<void>,
|
||||
context: Context,
|
||||
): Promise<{ result: Promise<ProtocolRpcResult> }> {
|
||||
const attachment = this.requireAttachment(client, target);
|
||||
@@ -214,8 +209,7 @@ export class SessionRouter<TMetadata extends SessionMetadata = SessionMetadata>
|
||||
const result = invoke.call(
|
||||
attachment.lease,
|
||||
call,
|
||||
(subscriptionId, update, updateContext) =>
|
||||
publish({ type: "service_update", subscriptionId, update }, updateContext),
|
||||
(subscriptionId, update, updateContext) => publish(subscriptionId, update, updateContext),
|
||||
context,
|
||||
);
|
||||
this.trackOperation(attachment, result);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ServiceProviderUpdate } from "@earendil-works/chord";
|
||||
import type { Context, SessionMetadata } from "@earendil-works/pi-agent-core";
|
||||
import type { ProtocolRpcCall, ProtocolRpcResult, ServiceProviderUpdate } from "@earendil-works/pi-protocol";
|
||||
import type { ProtocolRpcCall, ProtocolRpcResult } from "@earendil-works/pi-protocol";
|
||||
import type { ServerListener } from "./listener.ts";
|
||||
|
||||
export interface ServerOptions {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"@earendil-works/chord": ["./packages/chord/src/index.ts"],
|
||||
"@earendil-works/chord/bundler": ["./packages/chord/src/bundler.ts"],
|
||||
"@earendil-works/chord/context": ["./packages/chord/src/context/index.ts"],
|
||||
"@earendil-works/chord/delta": ["./packages/chord/src/delta/index.ts"],
|
||||
"@earendil-works/chord/node": ["./packages/chord/src/node.ts"],
|
||||
"@earendil-works/pi-telemetry": ["./packages/telemetry/src/index.ts"],
|
||||
"@earendil-works/pi-telemetry/testing": ["./packages/telemetry/src/testing/index.ts"],
|
||||
|
||||
Reference in New Issue
Block a user