mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(chord): Chord v2 slice 8, registries and host.root
Registries: shared tokens (defineRegistry), one provider per host (host.serve(R, { compose, accepts })),
contributions from facets and host-lifetime objects, plain values or followed Chord watchables, a table
sorted by plugin, lazy compose in the batch that changed the table, failures left out and reported with
the last good value kept; self.registry as a declared dependency, host.registry outside objects, older
readers mapped by accepts[old].value; keyedEntries and orderedEntries. Native sources: self.writable,
registry values and ref.state. self.retryOn wakes failed objects on registry values. host.root gives code
outside objects a call scope sealed at settle, abort and host.close.
Durable: registryReader(value, context); a Session close ends synchronous observers failed
("session closed"), watches still end ended.
This commit is contained in:
parent
edab3efa83
commit
6eb99ad65b
66 changed files with 5036 additions and 196 deletions
|
|
@ -98,6 +98,8 @@
|
|||
- `RunFailureReason` (`pi.live.runFailure.reason`) gains `nothing_to_answer` and `turn_limit`, also an `unanswered` submission's `reason`: exhaustive switches over it need cases ([#11043](https://github.com/earendil-works/pi/issues/11043))
|
||||
- `pi.live.run` has a required `turns` field, the run's tool rounds so far; code that writes `run` itself sets it (`turns: 0`). `pi.live` is version 3: its `migrate` adds `turns: 0` to a stored run without it, version 1 or a version 2 an earlier unreleased build wrote, whose run in flight otherwise failed its next tool round with "Value contains a non-finite number" ([#11043](https://github.com/earendil-works/pi/issues/11043))
|
||||
|
||||
- A Session's close ends synchronous observers (`observeDoc()`, `observeConversation()`, `observeTaskGraph()`, `observeCommits()`) `failed` with the error "session closed" instead of `ended`, so an adapter following a Session (Chord's `self.follow`) fails and follows the next one; `ended` stays for an observed owner that ended (a task document's task), and watches still end `ended`. Before: `end: (end) => { if (end.kind === "ended") done(); }`. After: `end: (end) => { if (end.kind === "ended" || end.kind === "failed") done(); }`.
|
||||
|
||||
### Added
|
||||
|
||||
- `StoreStorage` (`@earendil-works/pi-durable/storage/store`): Durable's `Storage` on Chord v2's store. `StoreStorage.open(store, owner, context)` keeps a Session's records as declared tables of the store under `owner` and its documents in the store's documents engine, takes its ids from the store's one allocator, and commits each batch as one write of the store's commit step, adopted in the batch of the host the store serves, so a Chord object and a Durable Session commit through one queue and one outbox. It passes the storage conformance suite; `close()` leaves the store open (the app owns it).
|
||||
|
|
@ -115,6 +117,8 @@
|
|||
- `HookApi.entry()` and `entries()`, and `TaskRuntime.entries()`: a hook or task reads the conversation's raw history, which compaction never changes, so a range bounded by entry IDs reads the same before and after a compaction and on a rerun ([#11141](https://github.com/earendil-works/pi/issues/11141))
|
||||
- Run-end tasks: a `GenerationHooks.afterRun(run, api)` hook returns `{ tasks }`, built with the typed `runEndTask(task, input)`, after a run answered (a final answer or a `terminate` round; not when an `onYield` continuation is taken, not after a failure or an abort). The answer is committed and the run's inputs settle `done` first, so `Submission.wait()` resolves at the answer; the generation then creates the tasks as its children and waits for them, keeping `pi.live.run` (with `run.afterRun`) and the conversation busy, so idle waits include them, Stop aborts them, and a crash resumes them. Input queued meanwhile, steers included, is placed when they are terminal and starts the next run; a task admits a model-visible write or a fix-run follow-up in its own commit with `submit(tx, draft)`. A crash before the tasks exist asks the hooks again; a broken request (an uninstalled or built-in task, a non-JSON input, a throwing `initial()`) is reported and skipped. `run` names the run's entries: `first` (`pi.live.run.first`, carried into `runFailure.first` so a continue covers a failed run's history), `answer`, and `last` ([#11141](https://github.com/earendil-works/pi/issues/11141))
|
||||
|
||||
- `registryReader(value, context)`: a `RegistryReader` over a Chord v2 registry value whose composition is a registry snapshot (`RegistryValue<RegistrySnapshot>`): `snapshot()` reads the current composition, `subscribe` observes its compositions synchronously inside Chord's batch, so a Harness opened in a Chord object takes its extensions from a Chord registry.
|
||||
|
||||
### Changed
|
||||
|
||||
- Document, conversation view, and task graph watches use Chord's watch handles (`Watch`).
|
||||
|
|
|
|||
|
|
@ -5431,9 +5431,11 @@ advances without a commit, when a registry change or a failed migration changes
|
|||
a task's `blocked` reason (section 9.4): that call runs on the Session line,
|
||||
outside any `adopt`, with a context of its own. A document observer follows its
|
||||
address as a document watch does, and `observeDoc()` gives `undefined` for an
|
||||
absent one. `end` comes once: when the Session closes (`ended`) or fails
|
||||
(`failed` with the error), or, for a task document, right after the retirement change of
|
||||
the commit that makes its task terminal, inside that commit's `adopt` (`ended`).
|
||||
absent one. `end` comes once: when the Session closes (`failed` with the error
|
||||
"session closed": an adapter following the Session, such as Chord's `self.follow`,
|
||||
fails and follows the next Session; watches end `ended`) or fails (`failed` with
|
||||
the error), or, for a task document, right after the retirement change of the
|
||||
commit that makes its task terminal, inside that commit's `adopt` (`ended`).
|
||||
`detach()` is idempotent, and nothing follows it, not even the rest of the
|
||||
commit underway; a detached observer is referenced by nothing of the Session.
|
||||
`context` governs only the attachment: cancelling it while attaching rejects
|
||||
|
|
|
|||
34
packages/durable/src/harness/registry-reader.ts
Normal file
34
packages/durable/src/harness/registry-reader.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// A Harness's registry over a Chord registry (Chord v2 slice 8 §5): the composed value of p3's extensions registry,
|
||||
// read as Durable's `RegistryReader`.
|
||||
|
||||
import type { Context } from "@earendil-works/chord/context";
|
||||
import type { RegistryValue } from "@earendil-works/chord/next";
|
||||
import type { RegistryReader, RegistrySnapshot, ToolRegistration } from "./types.ts";
|
||||
|
||||
/**
|
||||
* A `RegistryReader` over a Chord registry value whose composition is a registry snapshot: `snapshot()` reads the
|
||||
* current composition (the last good one while `compose` fails), `subscribe` observes its compositions, its listener
|
||||
* called synchronously inside the Chord batch that composed (so it only wakes the scheduler; a Chord call there
|
||||
* throws `invalid/reentrant`). `context` is the observation's: aborting it detaches every listener.
|
||||
*/
|
||||
export function registryReader<Tool extends ToolRegistration = ToolRegistration>(
|
||||
value: RegistryValue<RegistrySnapshot<Tool>>,
|
||||
context: Context,
|
||||
): RegistryReader<Tool> {
|
||||
return {
|
||||
snapshot: () => value.value,
|
||||
subscribe: (listener) => {
|
||||
const observation = value.observe(
|
||||
{
|
||||
advance: (call) => {
|
||||
// A failed composition keeps the last snapshot: nothing to reconsider.
|
||||
if (call.kind === "value") listener();
|
||||
},
|
||||
end: () => {},
|
||||
},
|
||||
context,
|
||||
);
|
||||
return () => observation.detach();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,16 +1,10 @@
|
|||
import {
|
||||
type Context,
|
||||
type JsonValue,
|
||||
Watch,
|
||||
type WatchCall,
|
||||
type WatchEnd,
|
||||
type WatchHandle,
|
||||
} from "@earendil-works/chord";
|
||||
import { type Context, type JsonValue, Watch, type WatchCall, 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 {
|
||||
closeEndOf,
|
||||
closeEnds,
|
||||
contained,
|
||||
ENDED,
|
||||
guardObserver,
|
||||
type ObservedCommit,
|
||||
observation,
|
||||
|
|
@ -110,11 +104,10 @@ export class TaskGraphView {
|
|||
blocking.subscribeBlocked(() => this.#refreshBlocked());
|
||||
session.subscribeClose(() => {
|
||||
this.#closed = true;
|
||||
const failure = this.#session.failure;
|
||||
const end: WatchEnd = failure === undefined ? ENDED : { kind: "failed", error: failure.error };
|
||||
const ends = closeEnds(this.#session.failure);
|
||||
for (const observer of [...(this.#mount?.observers ?? [])]) {
|
||||
contained(
|
||||
() => observer.end(end),
|
||||
() => observer.end(closeEndOf(observer, ends)),
|
||||
(error) => session.report(error),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,9 @@ import { withoutAbortSignal } from "@earendil-works/chord/context";
|
|||
import { applyImmutable, type NonEmptyPath, type Op, type Path } from "@earendil-works/chord/delta";
|
||||
import { idFromNumber } from "../ids.ts";
|
||||
import {
|
||||
closeEndOf,
|
||||
closeEnds,
|
||||
contained,
|
||||
ENDED,
|
||||
guardObserver,
|
||||
type ObservedCommit,
|
||||
observation,
|
||||
|
|
@ -158,12 +159,11 @@ export class ConversationViews {
|
|||
});
|
||||
session.subscribeClose(() => {
|
||||
this.#closed = true;
|
||||
const failure = this.#session.failure;
|
||||
const end: WatchEnd = failure === undefined ? ENDED : { kind: "failed", error: failure.error };
|
||||
const ends = closeEnds(this.#session.failure);
|
||||
for (const mount of this.#mounts.values())
|
||||
for (const windowed of [...mount.observers]) {
|
||||
contained(
|
||||
() => windowed.observer.end(end),
|
||||
() => windowed.observer.end(closeEndOf(windowed.observer, ends)),
|
||||
(error) => this.report(error),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export {
|
|||
} from "./harness/live.ts";
|
||||
export { ProviderDoc, type ProviderState } from "./harness/provider.ts";
|
||||
export { createRegistry } from "./harness/registry.ts";
|
||||
export { registryReader } from "./harness/registry-reader.ts";
|
||||
export type { BlockedReason } from "./harness/scheduler.ts";
|
||||
export type { TaskGraph, TaskGraphNode, TaskGraphState, TaskGraphWatch } from "./harness/task-graph.ts";
|
||||
export { DURABLE_TELEMETRY_SCHEMA } from "./harness/telemetry.ts";
|
||||
|
|
|
|||
|
|
@ -612,8 +612,8 @@ export class SessionImpl implements Session {
|
|||
const observers = [...this.#commitListeners];
|
||||
this.#commitListeners.clear();
|
||||
// After the last publication: the commits admitted before the seal have settled, held changes are stored.
|
||||
const end: WatchEnd =
|
||||
this.#failure === undefined ? ENDED : { kind: "failed", error: this.#failure.error };
|
||||
// Commit observers are synchronous observers, all of them.
|
||||
const end = closeEnds(this.#failure).observer;
|
||||
for (const observer of observers) observer.end(end);
|
||||
this.#internalListeners.clear();
|
||||
this.#documents.clear();
|
||||
|
|
@ -1269,7 +1269,7 @@ export class SessionImpl implements Session {
|
|||
});
|
||||
unsubscribeClose = this.subscribeClose(() => {
|
||||
detach();
|
||||
observer.end(this.#failure === undefined ? ENDED : { kind: "failed", error: this.#failure.error });
|
||||
observer.end(closeEndOf(observer, closeEnds(this.#failure)));
|
||||
});
|
||||
return observer;
|
||||
}
|
||||
|
|
@ -1419,6 +1419,28 @@ function toError(error: unknown): Error {
|
|||
/** The end of an observer whose source closed. */
|
||||
export const ENDED: WatchEnd = { kind: "ended" };
|
||||
|
||||
/**
|
||||
* How a Session's close ends what follows it (decision "Slice 8 S1"), made once per close: an app's watch `ended`; a
|
||||
* synchronous observer (an adapter following the Session, which may open it again) `failed` with "session closed", so
|
||||
* it follows the next Session; `ended` stays for an observed owner that ended (a task document's task). A failed
|
||||
* Session ends both `failed` with its error.
|
||||
*/
|
||||
export function closeEnds(failure: { readonly error: unknown } | undefined): {
|
||||
readonly watch: WatchEnd;
|
||||
readonly observer: WatchEnd;
|
||||
} {
|
||||
if (failure !== undefined) {
|
||||
const failed: WatchEnd = { kind: "failed", error: failure.error };
|
||||
return { watch: failed, observer: failed };
|
||||
}
|
||||
return { watch: ENDED, observer: { kind: "failed", error: new Error("session closed") } };
|
||||
}
|
||||
|
||||
/** The end `ends` give `follower`: a watch's, or an observer's. */
|
||||
export function closeEndOf(follower: unknown, ends: ReturnType<typeof closeEnds>): WatchEnd {
|
||||
return follower instanceof Watch ? ends.watch : ends.observer;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Observation` of a guarded observer attached at `value`. Built outside the attaching closure, so a detached
|
||||
* observation keeps nothing but `value` and the spent guard alive.
|
||||
|
|
|
|||
|
|
@ -1101,8 +1101,9 @@ export type DocumentWatch<T extends JsonObject> = WatchHandle<Readonly<T>>;
|
|||
* may come before the attaching promise resolves; their ops apply from `Observation.value`. `advance` must only record
|
||||
* the call, synchronously and quickly, never await, call the Session, or throw. A throw is reported as
|
||||
* `listener_error`, detaches the observer, and ends it `listener_error` with the error. `end` is called once: `ended`
|
||||
* when the Session closes or the observed scope's owner ends (a task document's task, after its retirement change,
|
||||
* inside that commit's `adopt`), `failed` with the error that failed the Session; never after `detach()`.
|
||||
* when the observed scope's owner ends (a task document's task, after its retirement change, inside that commit's
|
||||
* `adopt`), `failed` with "session closed" when the Session closes (an adapter follows the next Session) or with the
|
||||
* error that failed the Session; never after `detach()`.
|
||||
*/
|
||||
export type Observer<T> = {
|
||||
advance(call: WatchCall<T>, context: Context): void;
|
||||
|
|
@ -1111,7 +1112,7 @@ export type Observer<T> = {
|
|||
|
||||
/**
|
||||
* A synchronous observer of stored commits (`Session.observeCommits()`): `advance` gets each `CommitPublication`, `end`
|
||||
* the Session's close.
|
||||
* the Session's close (`failed` with "session closed", or with the error that failed the Session).
|
||||
*/
|
||||
export type CommitObserver = {
|
||||
advance(publication: CommitPublication, context: Context): void;
|
||||
|
|
|
|||
172
packages/durable/test/examples/37-harness-registry.ts
Normal file
172
packages/durable/test/examples/37-harness-registry.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
// A Durable harness in Chord objects, p3's pattern (Chord v2 slice 8): the object that opens Durable on the host's store
|
||||
// takes its extensions from a Chord registry (`registryReader`), so any plugin extends the harness by contributing an
|
||||
// extension; and it contributes its Harness to a second registry, through which another object follows its views
|
||||
// (`self.follow`). When that Harness's Session closes, the follower fails and follows the next Harness, woken by its
|
||||
// contribution (`self.retryOn`).
|
||||
// Run from packages/durable:
|
||||
// node --conditions=source --experimental-strip-types test/examples/37-harness-registry.ts
|
||||
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import {
|
||||
arr,
|
||||
createHost,
|
||||
defineFacet,
|
||||
defineRegistry,
|
||||
local,
|
||||
method,
|
||||
type Native,
|
||||
openStore,
|
||||
type RegistryTable,
|
||||
sqliteBackend,
|
||||
str,
|
||||
} from "@earendil-works/chord/next";
|
||||
import { openNodeSqliteDatabase } from "@earendil-works/chord/sqlite/node";
|
||||
import { createModels } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
createRegistry,
|
||||
defineExtension,
|
||||
defineTask,
|
||||
type Extension,
|
||||
Harness,
|
||||
type RegistrySnapshot,
|
||||
ROOT_CONVERSATION_ID,
|
||||
registryReader,
|
||||
} from "../../src/index.ts";
|
||||
import { StoreStorage } from "../../src/storage/store/index.ts";
|
||||
|
||||
const context = BACKGROUND_CONTEXT;
|
||||
|
||||
// 1. The core's registries: extensions in, Durable's registry snapshot out; and the Harness, for other objects.
|
||||
const Extensions = defineRegistry<Extension, RegistrySnapshot>("example.extensions", { version: 1 });
|
||||
const Harnesses = defineRegistry<Native<Harness>, Harness | undefined>("example.harnesses", { version: 1 });
|
||||
|
||||
/** Builds Durable's snapshot without throwing: an extension Durable refuses (a task name taken) is reported, left out. */
|
||||
function snapshotOf(
|
||||
table: RegistryTable<Extension>,
|
||||
report: (conflict: { message: string; plugins: string[] }) => void,
|
||||
) {
|
||||
const registry = createRegistry();
|
||||
for (const { value, plugin } of table) {
|
||||
try {
|
||||
registry.install(value);
|
||||
} catch (error) {
|
||||
report({ message: (error as Error).message, plugins: [plugin] });
|
||||
}
|
||||
}
|
||||
return registry.snapshot();
|
||||
}
|
||||
|
||||
const Core = local("example.harness", {
|
||||
version: 1,
|
||||
params: {},
|
||||
members: { greet: method(undefined, str()), close: method() },
|
||||
});
|
||||
const Transcript = local("example.transcript", { version: 1, params: {}, members: { kinds: arr(str()) } });
|
||||
|
||||
// 2. The core facet: the harness object opens Durable on the store with its extensions read from the registry.
|
||||
const store = await openStore(sqliteBackend(await openNodeSqliteDatabase(":memory:")), context);
|
||||
const core = defineFacet((host) => {
|
||||
host.serve(Extensions, { compose: (table, { report }) => snapshotOf(table, report) });
|
||||
host.serve(Harnesses, { compose: (table) => table.at(-1)?.value.value });
|
||||
host.serve(Core, (_, self) => {
|
||||
// A declared dependency: the object activates (and opens Durable) once the registry has a value.
|
||||
const extensions = self.registry(Extensions);
|
||||
const harness = self.open(
|
||||
async (open) => {
|
||||
const opened = await Harness.open(
|
||||
await StoreStorage.open(store, Core.name, open),
|
||||
{ models: createModels(), registry: registryReader(extensions, open) },
|
||||
open,
|
||||
);
|
||||
await opened.root(open);
|
||||
return opened;
|
||||
},
|
||||
(h, _reason, close) => h.close(close),
|
||||
);
|
||||
self.contribute(Harnesses, harness);
|
||||
// Following its own task graph, the object fails with its Session (S1), and its retry opens the next Harness.
|
||||
self.follow((observer, ctx) => harness.value.observeTaskGraph(observer, ctx));
|
||||
return {
|
||||
greet: async (ctx) => {
|
||||
// The composed snapshot, read at use: is the task's extension installed?
|
||||
if (extensions.value.task(Greet.definition.name) === undefined) return "no greeter installed";
|
||||
const root = await harness.value.conversation(ROOT_CONVERSATION_ID, ctx);
|
||||
const id = await root!.commit(
|
||||
(tx) => tx.createTask(Greet, undefined, { ownership: { kind: "conversation" } }),
|
||||
ctx,
|
||||
);
|
||||
harness.value.resume();
|
||||
return JSON.stringify((await harness.value.waitForTask(id, ctx)).state.outcome);
|
||||
},
|
||||
close: async (ctx) => {
|
||||
await harness.value.close(ctx);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
});
|
||||
// 3. Another object follows the harness's conversation view through the registry.
|
||||
host.serve(Transcript, (_, self) => {
|
||||
const harness = self.registry(Harnesses);
|
||||
self.retryOn(harness);
|
||||
const view = self.follow(async (observer, ctx) =>
|
||||
harness.value?.observeConversation(ROOT_CONVERSATION_ID, observer, ctx),
|
||||
);
|
||||
return { kinds: self.derive([view], ([v]) => v.entries.map((entry) => entry.kind)) };
|
||||
});
|
||||
});
|
||||
|
||||
// 4. Two plugins contribute extensions with the same task name, which Durable refuses: the first by plugin name
|
||||
// (copycat) is installed, the other reported.
|
||||
const Greet = defineTask<undefined, { phase: "run" }, string>({
|
||||
name: "example.greet",
|
||||
version: 1,
|
||||
initial: () => ({ phase: "run" }),
|
||||
phases: {
|
||||
run: async (_task, runtime, taskContext) => {
|
||||
await runtime.commit(async (tx) => {
|
||||
await tx.appendEntry(ROOT_CONVERSATION_ID, { kind: "example.greeted", data: {} });
|
||||
return { status: "terminal", outcome: { status: "completed", result: "hello" } };
|
||||
}, taskContext);
|
||||
},
|
||||
},
|
||||
abort: async () => {},
|
||||
});
|
||||
const greeter = defineFacet((_, self) =>
|
||||
self.contribute(Extensions, defineExtension({ name: "greeter", tasks: [Greet] })),
|
||||
);
|
||||
const copycat = defineFacet((_, self) =>
|
||||
self.contribute(Extensions, defineExtension({ name: "copycat", tasks: [Greet] })),
|
||||
);
|
||||
|
||||
const host = createHost({
|
||||
store,
|
||||
onError: (error) => console.log("onError:", error.kind, error.kind === "registry_conflict" ? error.message : ""),
|
||||
});
|
||||
await host.load(
|
||||
[
|
||||
{ name: "@example/core", facet: core },
|
||||
{ name: "@example/greeter", facet: greeter },
|
||||
{ name: "@example/copycat", facet: copycat },
|
||||
],
|
||||
context,
|
||||
);
|
||||
|
||||
// 5. The task comes from a contributed extension; the transcript object sees its entry. (It failed once at start, before
|
||||
// the harness object contributed its Harness: `activation_failed`, then the contribution woke it.)
|
||||
const harness = host.hold(Core, {}, context);
|
||||
const transcript = host.hold(Transcript, {}, context);
|
||||
console.log("greet:", await harness.greet(context));
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
console.log("transcript:", transcript.kinds.value);
|
||||
|
||||
// 6. The Session closes: both objects fail; a retry of the harness object opens the next Harness (on the same store:
|
||||
// the transcript is still there), whose contribution wakes the follower.
|
||||
await harness.close(context).catch((error: Error) => console.log("close:", error.message));
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
console.log("transcript after the close:", transcript.state.value.kind);
|
||||
harness.retry();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
console.log("transcript after the retry:", transcript.state.value.kind, transcript.kinds.value);
|
||||
|
||||
await host.close(context);
|
||||
await store.close(context);
|
||||
|
|
@ -124,8 +124,11 @@ describe("observeConversation and observeTaskGraph", () => {
|
|||
await root.commit((tx) => tx.appendEntry(root.id, { kind: "note", data: {} }), context);
|
||||
expect(view.calls).toHaveLength(1);
|
||||
await watch.stop();
|
||||
const graphWatch = await harness.watchTaskGraph(context);
|
||||
await harness.close(context);
|
||||
expect(graph.calls).toEqual([{ end: { kind: "ended" } }]);
|
||||
// S1: a Session close fails an observer (it may follow the next Session); a watch ends `ended`.
|
||||
expect(graph.calls).toEqual([{ end: { kind: "failed", error: new Error("session closed") } }]);
|
||||
expect(await graphWatch.closed).toEqual({ kind: "ended" });
|
||||
});
|
||||
|
||||
it("end with the error that failed the Harness", async () => {
|
||||
|
|
|
|||
198
packages/durable/test/harness-registry-reader.test.ts
Normal file
198
packages/durable/test/harness-registry-reader.test.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import { BACKGROUND_CONTEXT as context } from "@earendil-works/chord/context";
|
||||
import {
|
||||
arr,
|
||||
createHost,
|
||||
defineFacet,
|
||||
defineRegistry,
|
||||
type FacetHost,
|
||||
local,
|
||||
method,
|
||||
type Native,
|
||||
type RegistryTable,
|
||||
str,
|
||||
} from "@earendil-works/chord/next";
|
||||
import { createModels } from "@earendil-works/pi-ai/models";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createRegistry,
|
||||
defineExtension,
|
||||
defineTask,
|
||||
type Extension,
|
||||
Harness,
|
||||
MemoryStorage,
|
||||
type RegistrySnapshot,
|
||||
ROOT_CONVERSATION_ID,
|
||||
registryReader,
|
||||
} from "../src/index.ts";
|
||||
|
||||
/** p3's pattern: the extensions registry composes Durable's snapshot, leaving out an extension Durable refuses. */
|
||||
const Extensions = defineRegistry<Extension, RegistrySnapshot>("app.harness.extensions", { version: 1 });
|
||||
|
||||
function compose(table: RegistryTable<Extension>, report: (conflict: { message: string; plugins: string[] }) => void) {
|
||||
const registry = createRegistry();
|
||||
for (const { value, plugin } of table) {
|
||||
try {
|
||||
registry.install(value);
|
||||
} catch (error) {
|
||||
report({ message: (error as Error).message, plugins: [plugin] });
|
||||
}
|
||||
}
|
||||
return registry.snapshot();
|
||||
}
|
||||
|
||||
const Done = defineTask<undefined, { phase: "run" }, null>({
|
||||
name: "app.done",
|
||||
version: 1,
|
||||
initial: () => ({ phase: "run" }),
|
||||
phases: {
|
||||
run: async (_task, runtime, taskContext) => {
|
||||
await runtime.commit(
|
||||
() => ({ status: "terminal", outcome: { status: "completed", result: null } }),
|
||||
taskContext,
|
||||
);
|
||||
},
|
||||
},
|
||||
abort: async () => {},
|
||||
});
|
||||
|
||||
/** A host-lifetime object contributing an extension it replaces with `install` (one with the task `app.done`). */
|
||||
const Plugin = local("app.plugin", { version: 1, params: {}, members: { install: method() } });
|
||||
|
||||
function serve(host: FacetHost): void {
|
||||
host.serve(Extensions, { compose: (table, { report }) => compose(table, report) });
|
||||
host.serve(Plugin, (_, self) => {
|
||||
const extension = self.writable<Extension>(defineExtension({ name: "app.plugin" }));
|
||||
self.contribute(Extensions, extension);
|
||||
return {
|
||||
install: async () => {
|
||||
extension.set(defineExtension({ name: "app.plugin", tasks: [Done] }));
|
||||
return null;
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
describe("registryReader", () => {
|
||||
it("reads the composed snapshot; a listener is called synchronously, inside the batch that composed", async () => {
|
||||
const host = createHost({ onError: () => {} });
|
||||
await host.load([{ name: "@app/core", facet: defineFacet(serve) }], context);
|
||||
const value = await host.registry(Extensions, context);
|
||||
const reader = registryReader(value, context);
|
||||
expect(reader.snapshot().extension("app.plugin")?.tasks).toBeUndefined();
|
||||
const calls: string[] = [];
|
||||
const unsubscribe = reader.subscribe(() => {
|
||||
// Inside Chord's batch: a Chord call here is refused, so a listener only wakes its scheduler.
|
||||
try {
|
||||
value.observe({ advance: () => {}, end: () => {} }, context);
|
||||
calls.push("outside");
|
||||
} catch (error) {
|
||||
calls.push((error as { reason?: string }).reason ?? "other");
|
||||
}
|
||||
});
|
||||
const plugin = await host.get(Plugin, {}, context);
|
||||
await plugin.install(context);
|
||||
expect(calls).toEqual(["reentrant"]);
|
||||
expect(reader.snapshot().task("app.done")).toBe(Done);
|
||||
unsubscribe();
|
||||
await host.close(context);
|
||||
});
|
||||
|
||||
it("wakes a Harness's scheduler: a task blocked on its missing definition runs once a contribution brings it", async () => {
|
||||
const host = createHost({ onError: () => {} });
|
||||
await host.load([{ name: "@app/core", facet: defineFacet(serve) }], context);
|
||||
const harness = await Harness.open(
|
||||
new MemoryStorage(),
|
||||
{ models: createModels(), registry: registryReader(await host.registry(Extensions, context), context) },
|
||||
context,
|
||||
);
|
||||
harness.resume();
|
||||
const root = await harness.root(context);
|
||||
const id = await root.commit(
|
||||
(tx) => tx.createTask(Done, undefined, { ownership: { kind: "conversation" } }),
|
||||
context,
|
||||
);
|
||||
const done = harness.waitForTask(id, context);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
await (await host.get(Plugin, {}, context)).install(context);
|
||||
expect((await done).state.outcome).toEqual({ status: "completed", result: null });
|
||||
await harness.close(context);
|
||||
await host.close(context);
|
||||
});
|
||||
});
|
||||
|
||||
/** p3's pattern for Durable's views from other objects: the object that opens Durable contributes its Harness. */
|
||||
const Harnesses = defineRegistry<Native<Harness>, Harness | undefined>("app.harnesses", { version: 1 });
|
||||
|
||||
/** Opens a Harness (a new one per attempt), contributes it, and follows its task graph: its Session closing fails it. */
|
||||
const Durable = local("app.durable", { version: 1, params: {}, members: { close: method(), write: method(str()) } });
|
||||
|
||||
/** Follows the root conversation's entry kinds through the contributed Harness, from another object. */
|
||||
const Viewer = local("app.viewer", { version: 1, params: {}, members: { kinds: arr(str()) } });
|
||||
|
||||
function serveViews(host: FacetHost): void {
|
||||
host.serve(Harnesses, { compose: (table) => table.at(-1)?.value.value });
|
||||
host.serve(Durable, (_, self) => {
|
||||
const harness = self.open(
|
||||
async (open) => {
|
||||
const opened = await Harness.open(
|
||||
new MemoryStorage(),
|
||||
{ models: createModels(), registry: createRegistry() },
|
||||
open,
|
||||
);
|
||||
await opened.root(open);
|
||||
return opened;
|
||||
},
|
||||
(h, _reason, close) => h.close(close),
|
||||
);
|
||||
self.contribute(Harnesses, harness);
|
||||
self.follow((observer, ctx) => harness.value.observeTaskGraph(observer, ctx));
|
||||
return {
|
||||
close: async (ctx) => {
|
||||
await harness.value.close(ctx);
|
||||
return null;
|
||||
},
|
||||
write: async (kind, ctx) => {
|
||||
const root = await harness.value.conversation(ROOT_CONVERSATION_ID, ctx);
|
||||
await root!.submit({ type: "write", entry: { kind } }, ctx);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
});
|
||||
host.serve(Viewer, (_, self) => {
|
||||
const harness = self.registry(Harnesses);
|
||||
self.retryOn(harness);
|
||||
const view = self.follow(async (observer, ctx) =>
|
||||
harness.value === undefined
|
||||
? undefined
|
||||
: harness.value.observeConversation(ROOT_CONVERSATION_ID, observer, ctx),
|
||||
);
|
||||
return { kinds: self.derive([view], ([v]) => v.entries.map((entry) => entry.kind)) };
|
||||
});
|
||||
}
|
||||
|
||||
describe("Durable's views from other objects (slice 8 §5, S1)", () => {
|
||||
it("a follower of a contributed Harness fails when that Harness's Session closes, and follows the next one", async () => {
|
||||
const host = createHost({ onError: () => {} });
|
||||
await host.load([{ name: "@app/core", facet: defineFacet(serveViews) }], context);
|
||||
const durable = host.hold(Durable, {}, context);
|
||||
const viewer = host.hold(Viewer, {}, context);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
await durable.write("app.first", context);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(viewer.kinds.value).toContain("app.first");
|
||||
// The Session closes: both followers fail `transient` (S1); a retry opens the next Harness, whose contribution
|
||||
// wakes the viewer (`retryOn`), which follows it.
|
||||
// The call is cut off by its object's failure: the Session's close fails the object that opened it.
|
||||
await expect(durable.close(context)).rejects.toThrow("session closed");
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(viewer.state.value).toMatchObject({ kind: "failed", cause: { kind: "transient" } });
|
||||
durable.retry();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(viewer.state.value).toEqual({ kind: "live" });
|
||||
expect(viewer.kinds.value).not.toContain("app.first");
|
||||
await durable.write("app.second", context);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(viewer.kinds.value).toContain("app.second");
|
||||
await host.close(context);
|
||||
});
|
||||
});
|
||||
|
|
@ -173,13 +173,31 @@ describe("observeDoc", () => {
|
|||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("ends once when the Session closes", async () => {
|
||||
it("ends once when the Session closes, `failed` (S1: an adapter follows the next Session), a watch `ended`", async () => {
|
||||
const { storage, session } = open();
|
||||
await add(session);
|
||||
const { calls, observer } = recorder(storage);
|
||||
await session.observeDoc(SessionCounter, observer, context);
|
||||
const watch = (await session.watchDoc(SessionCounter, context))!;
|
||||
await session.close(context);
|
||||
expect(calls).toEqual([{ end: { kind: "ended" } }]);
|
||||
expect(calls).toEqual([{ end: { kind: "failed", error: new Error("session closed") } }]);
|
||||
expect(await watch.closed).toEqual({ kind: "ended" });
|
||||
});
|
||||
|
||||
it("ends each close's observers with an error of that close, not one shared by every Session", async () => {
|
||||
const errors: unknown[] = [];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const { session } = open();
|
||||
await add(session);
|
||||
await session.observeDoc(
|
||||
SessionCounter,
|
||||
{ advance: () => {}, end: (end) => errors.push(end.kind === "failed" ? end.error : end) },
|
||||
context,
|
||||
);
|
||||
await session.close(context);
|
||||
}
|
||||
expect(errors).toEqual([new Error("session closed"), new Error("session closed")]);
|
||||
expect(errors[0]).not.toBe(errors[1]);
|
||||
});
|
||||
|
||||
it("ends with the error that failed the Session", async () => {
|
||||
|
|
@ -368,14 +386,18 @@ describe("observeCommits", () => {
|
|||
return { calls, observer };
|
||||
}
|
||||
|
||||
it("gets every stored commit, then `ended` once the Session closed, after its last publication", async () => {
|
||||
it("gets every stored commit, then `failed` once the Session closed (S1), after its last publication", async () => {
|
||||
const { session } = open();
|
||||
const { calls, observer } = commits();
|
||||
session.observeCommits(observer);
|
||||
await add(session);
|
||||
await add(session);
|
||||
await session.close(context);
|
||||
expect(calls).toEqual([{ seq: expect.any(Number) }, { seq: expect.any(Number) }, { end: { kind: "ended" } }]);
|
||||
expect(calls).toEqual([
|
||||
{ seq: expect.any(Number) },
|
||||
{ seq: expect.any(Number) },
|
||||
{ end: { kind: "failed", error: new Error("session closed") } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ends `failed` with the error that failed the Session", async () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue