mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(chord): shared watch-handle implementation (Watch, NativeWatch); Durable uses it
This commit is contained in:
parent
936e5f40ae
commit
42cb822f1c
12 changed files with 780 additions and 237 deletions
|
|
@ -13,6 +13,10 @@
|
|||
|
||||
- `HarnessOptions.readOnly` opens a Harness as a viewer of a session another process drives: open leaves running tasks as committed instead of recovering them, every commit with writes rejects with `ReadOnlySession` before it reaches Storage, and `resume()` and every call that would run tasks reject with it.
|
||||
|
||||
### Changed
|
||||
|
||||
- Document, conversation view, task graph, and event watches use Chord's `Watch` implementation; behaviour is unchanged.
|
||||
|
||||
## [1.4.0] - 2026-10-21
|
||||
|
||||
### Breaking Changes
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type { Context, JsonValue, WatchEnd } from "@earendil-works/chord";
|
||||
import { type Context, type JsonValue, Watch, type WatchEnd } from "@earendil-works/chord";
|
||||
import type { Op, Path } from "@earendil-works/chord/delta";
|
||||
import type { AssistantMessage, Message, Usage } from "@earendil-works/pi-ai";
|
||||
import { CommittedWatch } from "../session/observation.ts";
|
||||
import type {
|
||||
CommitChange,
|
||||
CommitPublication,
|
||||
|
|
@ -168,7 +167,7 @@ export async function watchEvents(
|
|||
context: Context,
|
||||
): Promise<AgentEventStream> {
|
||||
const views = conversationViews(harness);
|
||||
let watch!: CommittedWatch<readonly AgentEvent[]>;
|
||||
let watch!: Watch<readonly AgentEvent[]>;
|
||||
let snapshot!: SnapshotEvent;
|
||||
await views.attach(
|
||||
conversationId,
|
||||
|
|
@ -180,7 +179,7 @@ export async function watchEvents(
|
|||
let current = initial;
|
||||
snapshot = snapshotOf(initial);
|
||||
// Batches are the watch's values; an overflow delivers a snapshot of the newest view instead.
|
||||
watch = new CommittedWatch<readonly AgentEvent[]>(
|
||||
watch = new Watch<readonly AgentEvent[]>(
|
||||
[],
|
||||
release,
|
||||
(error) => views.report(error),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type { Context, JsonValue, WatchHandle } from "@earendil-works/chord";
|
||||
import { type Context, type JsonValue, Watch, type WatchHandle } from "@earendil-works/chord";
|
||||
import { BACKGROUND_CONTEXT, withoutAbortSignal } from "@earendil-works/chord/context";
|
||||
import { applyImmutable, type Op } from "@earendil-works/chord/delta";
|
||||
import { CommittedWatch } from "../session/observation.ts";
|
||||
import { contained, type SessionImpl } from "../session/session.ts";
|
||||
import type {
|
||||
CommitPublication,
|
||||
|
|
@ -56,7 +55,7 @@ export type TaskGraph = {
|
|||
export type TaskGraphWatch = WatchHandle<TaskGraph>;
|
||||
|
||||
type AnyTaskRecord = TaskRecord<JsonValue, JsonValue, JsonValue>;
|
||||
type Observer = CommittedWatch<TaskGraph>;
|
||||
type Observer = Watch<TaskGraph>;
|
||||
type Mount = {
|
||||
value: TaskGraph;
|
||||
readonly observers: Set<Observer>;
|
||||
|
|
@ -108,7 +107,7 @@ export class TaskGraphView {
|
|||
/** A serialized exact-frame watch of the graph; cancelling `context` stops it. */
|
||||
async watch(context: Context): Promise<TaskGraphWatch> {
|
||||
const { observer } = await this.#attach(
|
||||
(value, release) => new CommittedWatch<TaskGraph>(value, release, (error) => this.#session.report(error)),
|
||||
(value, release) => new Watch<TaskGraph>(value, release, (error) => this.#session.report(error)),
|
||||
context,
|
||||
);
|
||||
const signal = context.abortSignal;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type { Context, JsonValue, WatchHandle } from "@earendil-works/chord";
|
||||
import { type Context, type JsonValue, Watch, type WatchHandle } from "@earendil-works/chord";
|
||||
import { withoutAbortSignal } from "@earendil-works/chord/context";
|
||||
import { applyImmutable, type NonEmptyPath, type Op, type Path } from "@earendil-works/chord/delta";
|
||||
import { CommittedWatch } from "../session/observation.ts";
|
||||
import { contained, type SessionImpl } from "../session/session.ts";
|
||||
import type {
|
||||
CommitPublication,
|
||||
|
|
@ -111,8 +110,7 @@ export class ConversationViews {
|
|||
async watch(id: ConversationId, context: Context): Promise<WatchHandle<ConversationView>> {
|
||||
const { observer } = await this.attach(
|
||||
id,
|
||||
(value, release) =>
|
||||
new CommittedWatch<ConversationView>(value, release, (error) => this.#session.report(error)),
|
||||
(value, release) => new Watch<ConversationView>(value, release, (error) => this.#session.report(error)),
|
||||
context,
|
||||
);
|
||||
const signal = context.abortSignal;
|
||||
|
|
|
|||
|
|
@ -1,186 +0,0 @@
|
|||
import type { Context, JsonValue, WatchEnd, WatchHandle } from "@earendil-works/chord";
|
||||
import { withoutAbortSignal } from "@earendil-works/chord/context";
|
||||
import type { Op } from "@earendil-works/chord/delta";
|
||||
import { errorMessage } from "../errors.ts";
|
||||
import type { JsonObject } from "../types.ts";
|
||||
|
||||
export type ObservedDocumentValue = Readonly<JsonObject> | null;
|
||||
|
||||
/** Maximum exact committed frames retained behind one unavailable watch listener. */
|
||||
const MAX_PENDING_WATCH_FRAMES = 100;
|
||||
|
||||
/** Canonical terminal frame of a retired document incarnation. */
|
||||
const RETIREMENT_OPERATIONS: readonly Op[] = [["r", null]];
|
||||
|
||||
type WatchFrame<T> = {
|
||||
readonly value: T;
|
||||
readonly ops: readonly Op[];
|
||||
readonly context: Context;
|
||||
/** The retirement frame: the watch ends `retired` once it is delivered. */
|
||||
readonly retires?: true;
|
||||
};
|
||||
|
||||
type WatchListener<T> = (value: T, ops: readonly Op[], context: Context) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Serialized exact-frame watch bound to one document incarnation or conversation view. Its source ends it explicitly:
|
||||
* `retire()` for a retired document, `end()` for a closed or failed Session. Termination drops every reference the
|
||||
* watch holds into its source, so a stopped watch that is still referenced keeps nothing but its last value alive.
|
||||
* @internal
|
||||
*/
|
||||
export class CommittedWatch<T = ObservedDocumentValue> implements WatchHandle<T> {
|
||||
#detach: (() => void) | undefined;
|
||||
#report: ((error: unknown) => void) | undefined;
|
||||
#replace: (() => T) | undefined;
|
||||
readonly #closed = Promise.withResolvers<WatchEnd>();
|
||||
readonly #pending: WatchFrame<T>[] = [];
|
||||
#value: T;
|
||||
#listener: WatchListener<T> | undefined;
|
||||
#started = false;
|
||||
#scheduled = false;
|
||||
#running = false;
|
||||
#retired = false;
|
||||
/** Set once, by `#terminate()`. */
|
||||
#end: WatchEnd | undefined;
|
||||
#cancellationSignal: AbortSignal | undefined;
|
||||
#cancellationListener: (() => void) | undefined;
|
||||
|
||||
/**
|
||||
* `report` gets a listener's throw, which also ends the watch. `replace` gives the value an overflow delivers; by
|
||||
* default the newest value.
|
||||
*/
|
||||
constructor(value: T, detach: () => void, report: (error: unknown) => void, replace?: () => T) {
|
||||
this.#value = value;
|
||||
this.#detach = detach;
|
||||
this.#report = report;
|
||||
this.#replace = replace;
|
||||
}
|
||||
|
||||
get value(): T {
|
||||
return this.#value;
|
||||
}
|
||||
|
||||
get closed(): Promise<WatchEnd> {
|
||||
return this.#closed.promise;
|
||||
}
|
||||
|
||||
start(listener: WatchListener<T>): void {
|
||||
if (this.#started) throw new Error("Watch is already started");
|
||||
if (this.#end !== undefined) throw new Error("Watch is stopped");
|
||||
this.#started = true;
|
||||
this.#listener = listener;
|
||||
if (this.#pending.length > 0) this.#schedule();
|
||||
}
|
||||
|
||||
stop(): Promise<WatchEnd> {
|
||||
this.#terminate({ reason: "stopped" });
|
||||
return this.#closed.promise;
|
||||
}
|
||||
|
||||
observeCancellation(signal: AbortSignal): void {
|
||||
if (this.#cancellationSignal !== undefined) throw new Error("Watch cancellation is already installed");
|
||||
if (this.#end !== undefined) return;
|
||||
this.#cancellationSignal = signal;
|
||||
this.#cancellationListener = () => this.cancel();
|
||||
signal.addEventListener("abort", this.#cancellationListener, { once: true });
|
||||
if (signal.aborted) this.cancel();
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.#terminate({ reason: "cancelled" });
|
||||
}
|
||||
|
||||
/** The source closed: end with `ended`, or with `failed` and the error when the Session failed. */
|
||||
end(failure?: { readonly error: unknown }): void {
|
||||
this.#terminate(failure === undefined ? { reason: "ended" } : { reason: "failed", error: failure.error });
|
||||
}
|
||||
|
||||
advance(value: T, ops: readonly Op[], context: Context): void {
|
||||
if (this.#end !== undefined || this.#retired) return;
|
||||
if (this.#pending.length >= MAX_PENDING_WATCH_FRAMES) {
|
||||
this.#pending.length = 0;
|
||||
const replacement = this.#replace?.() ?? value;
|
||||
this.#pending.push({ value: replacement, ops: [["r", replacement as JsonValue]], context });
|
||||
} else {
|
||||
this.#pending.push({ value, ops, context });
|
||||
}
|
||||
if (this.#started) this.#schedule();
|
||||
}
|
||||
|
||||
/**
|
||||
* The document incarnation retired: queue the last frame, `[["r", null]]` with value `null`, after which the watch
|
||||
* ends `retired`. An overflowing backlog is dropped, since the frame replaces the root anyway.
|
||||
*/
|
||||
retire(this: CommittedWatch<ObservedDocumentValue>, context: Context): void {
|
||||
if (this.#end !== undefined || this.#retired) return;
|
||||
this.#retired = true;
|
||||
if (this.#pending.length >= MAX_PENDING_WATCH_FRAMES) this.#pending.length = 0;
|
||||
this.#pending.push({ value: null, ops: RETIREMENT_OPERATIONS, context, retires: true });
|
||||
if (this.#started) this.#schedule();
|
||||
}
|
||||
|
||||
#schedule(): void {
|
||||
if (this.#scheduled || this.#running || this.#end !== undefined) return;
|
||||
this.#scheduled = true;
|
||||
queueMicrotask(() => {
|
||||
this.#scheduled = false;
|
||||
void this.#drain();
|
||||
});
|
||||
}
|
||||
|
||||
async #drain(): Promise<void> {
|
||||
if (this.#running || this.#end !== undefined || !this.#started) return;
|
||||
this.#running = true;
|
||||
try {
|
||||
while (this.#end === undefined) {
|
||||
const frame = this.#pending.shift();
|
||||
const listener = this.#listener;
|
||||
if (frame === undefined || listener === undefined) break;
|
||||
this.#value = frame.value;
|
||||
const deliveryContext = withoutAbortSignal(frame.context);
|
||||
try {
|
||||
await listener(frame.value, frame.ops, deliveryContext);
|
||||
} catch (error) {
|
||||
if (this.#end === undefined) {
|
||||
this.#report?.(error);
|
||||
this.#terminate({ reason: "listener_error", error: toError(error) });
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (frame.retires === true) {
|
||||
this.#terminate({ reason: "retired" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.#running = false;
|
||||
if (this.#end === undefined && this.#pending.length > 0) this.#schedule();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End the watch, once: detach, drop pending frames and every reference into the source, and settle `closed`. A
|
||||
* callback already running keeps running.
|
||||
*/
|
||||
#terminate(end: WatchEnd): void {
|
||||
if (this.#end !== undefined) return;
|
||||
this.#end = end;
|
||||
const detach = this.#detach;
|
||||
this.#detach = undefined;
|
||||
this.#report = undefined;
|
||||
this.#replace = undefined;
|
||||
this.#listener = undefined;
|
||||
this.#pending.length = 0;
|
||||
if (this.#cancellationListener !== undefined) {
|
||||
this.#cancellationSignal?.removeEventListener("abort", this.#cancellationListener);
|
||||
}
|
||||
this.#cancellationSignal = undefined;
|
||||
this.#cancellationListener = undefined;
|
||||
detach?.();
|
||||
this.#closed.resolve(end);
|
||||
}
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(errorMessage(error));
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Context, JsonValue } from "@earendil-works/chord";
|
||||
import { type Context, type JsonValue, Watch } from "@earendil-works/chord";
|
||||
import { awaitWithContext, BACKGROUND_CONTEXT, withoutAbortSignal } from "@earendil-works/chord/context";
|
||||
import { type Op, track } from "@earendil-works/chord/delta";
|
||||
import {
|
||||
|
|
@ -56,9 +56,11 @@ import type {
|
|||
TaskRecord,
|
||||
Tx,
|
||||
} from "../types.ts";
|
||||
import { CommittedWatch, type ObservedDocumentValue } from "./observation.ts";
|
||||
import { type LoadedDocument, Transaction, type TransactionHost, type TransactionScope } from "./transaction.ts";
|
||||
|
||||
/** What a document watch observes: the committed value of its incarnation, `null` once it retired. */
|
||||
type ObservedDocumentValue = Readonly<JsonObject> | null;
|
||||
|
||||
/** The default source of a report: a listener, which never fails what ran it. */
|
||||
const LISTENER: ReportInfo = { type: "listener_error" };
|
||||
|
||||
|
|
@ -775,10 +777,7 @@ export class SessionImpl implements Session {
|
|||
* Attach a watch to one committed incarnation: check the definition, then forward this incarnation's committed
|
||||
* changes, its retirement, and close. The watch's detach removes both subscriptions.
|
||||
*/
|
||||
#attachDocument(
|
||||
definition: AnyDocToken["definition"],
|
||||
loaded: LoadedDocument,
|
||||
): CommittedWatch<ObservedDocumentValue> {
|
||||
#attachDocument(definition: AnyDocToken["definition"], loaded: LoadedDocument): Watch<ObservedDocumentValue> {
|
||||
checkRecordScope(definition, loaded.record);
|
||||
checkRecordVersion(definition, loaded.record, loaded.storedVersion);
|
||||
let unsubscribeCommit = (): void => {};
|
||||
|
|
@ -790,9 +789,7 @@ export class SessionImpl implements Session {
|
|||
unsubscribeClose();
|
||||
unpin();
|
||||
};
|
||||
const observer = new CommittedWatch<ObservedDocumentValue>(loaded.tracker.value, detach, (error) =>
|
||||
this.report(error),
|
||||
);
|
||||
const observer = new Watch<ObservedDocumentValue>(loaded.tracker.value, detach, (error) => this.report(error));
|
||||
const observed = { version: loaded.valueVersion };
|
||||
unsubscribeCommit = this.observeCommits((publication, context) => {
|
||||
for (const change of publication.changes) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { setFlagsFromString } from "node:v8";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import type { Context, JsonValue } from "@earendil-works/chord";
|
||||
import type { Context } from "@earendil-works/chord";
|
||||
import {
|
||||
BACKGROUND_CONTEXT,
|
||||
createContextKey,
|
||||
|
|
@ -11,7 +11,6 @@ import {
|
|||
import { applyImmutable } from "@earendil-works/chord/delta";
|
||||
import { type DocumentWatch, defineDoc, MemoryStorage } from "@earendil-works/pi-durable";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CommittedWatch } from "../src/session/observation.ts";
|
||||
import { SessionImpl } from "../src/session/session.ts";
|
||||
import { context, documentChanges, flush, openTestSession } from "./session-support.ts";
|
||||
|
||||
|
|
@ -508,31 +507,6 @@ describe("Session document watches", () => {
|
|||
await watch.stop();
|
||||
});
|
||||
|
||||
it("ends retired only on its source's retirement, never on a null value", async () => {
|
||||
const watch = new CommittedWatch<JsonValue>(
|
||||
1,
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
const frames: Array<{ value: JsonValue; ops: unknown }> = [];
|
||||
watch.start(async (value, ops) => {
|
||||
frames.push({ value, ops });
|
||||
});
|
||||
let end: unknown;
|
||||
void watch.closed.then((settled) => {
|
||||
end = settled;
|
||||
});
|
||||
watch.advance(null, [["r", null]], context);
|
||||
watch.advance(2, [["r", 2]], context);
|
||||
await flush();
|
||||
expect(frames).toEqual([
|
||||
{ value: null, ops: [["r", null]] },
|
||||
{ value: 2, ops: [["r", 2]] },
|
||||
]);
|
||||
expect(end).toBeUndefined();
|
||||
expect(await watch.stop()).toEqual({ reason: "stopped" });
|
||||
});
|
||||
|
||||
it("retains neither its Session nor its listener once stopped, while still referenced", async () => {
|
||||
setFlagsFromString("--expose-gc");
|
||||
const gc = runInNewContext("gc") as () => void;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue