mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
Chord's shared watch: listeners take a tagged call, { kind: "value", value, ops, retired } or
{ kind: "failed", error }; a recoverable failure keeps the watch open and the next value arrives as
[["r", value]]; WatchEnd is tagged by kind, and an aborted context ends a watch "aborted".
Durable: observers take the watch's shapes (advance(call, ctx), end(end)); onReport(report) with
report.kind and report.error, session_failed is storage_failed; subscribeCommits is observeCommits;
SessionEnd and ReportFrom are tagged by kind; FileChange is tagged and file watchers stop with
stop(ctx); CompactionFailure and CompactionStatus name their trigger (pi.live version 2 with a
migrate); the task_outdated report uses the scheduler's reasons; TaskInspection's ready is pending;
DocumentCommitChange is tagged. Stored formats are unchanged.
144 lines
5.9 KiB
TypeScript
144 lines
5.9 KiB
TypeScript
// A read-only Harness: a viewer of a session another process drives, such as a synced copy on another device.
|
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { DatabaseSync } from "node:sqlite";
|
|
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
|
|
import { Harness, LiveDoc, ReadOnlySession, UserEntry, type Wake } from "@earendil-works/pi-durable";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts";
|
|
import { allEntries, type ChatSetup, chatSetup, openChat, unanswered } from "./chat-support.ts";
|
|
import { context } from "./session-support.ts";
|
|
|
|
const directories = new Set<string>();
|
|
|
|
afterEach(async () => {
|
|
for (const directory of directories) await rm(directory, { recursive: true, force: true });
|
|
directories.clear();
|
|
});
|
|
|
|
async function sqlitePath(): Promise<string> {
|
|
const directory = await mkdtemp(join(tmpdir(), "pi-durable-read-only-"));
|
|
directories.add(directory);
|
|
return join(directory, "session.sqlite");
|
|
}
|
|
|
|
async function openReadOnly(path: string, setup: ChatSetup, onWake?: (wake: Wake) => void): Promise<Harness> {
|
|
return Harness.open(
|
|
await openNodeSqliteStorage(path),
|
|
{
|
|
models: setup.models,
|
|
registry: setup.registry,
|
|
readOnly: true,
|
|
onReport: ({ error }) => setup.reports.push(error),
|
|
...(onWake === undefined ? {} : { onWake }),
|
|
},
|
|
context,
|
|
);
|
|
}
|
|
|
|
/** The next sequence the file would commit, read beside every open handle; `undefined` before the schema exists. */
|
|
function head(path: string): number | undefined {
|
|
const db = new DatabaseSync(path);
|
|
try {
|
|
const exists = db.prepare("SELECT 1 FROM sqlite_master WHERE name = 'durable_metadata'").get();
|
|
if (exists === undefined) return undefined;
|
|
return Number(db.prepare("SELECT next_seq FROM durable_metadata WHERE singleton = 1").get()?.next_seq);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
/** A session whose root generation is running in another Harness, still open, waiting for the model. */
|
|
async function runningSession(path: string, setup: ChatSetup) {
|
|
const busy = unanswered();
|
|
setup.faux.setResponses([busy.step]);
|
|
const driver = await openChat(await openNodeSqliteStorage(path), setup);
|
|
await driver.root.submit({ type: "input", content: "slow" }, context);
|
|
await busy.reached;
|
|
return driver;
|
|
}
|
|
|
|
describe("read-only Harness", () => {
|
|
it("opens a session with work running elsewhere without writing", async () => {
|
|
const path = await sqlitePath();
|
|
const setup = chatSetup();
|
|
const driver = await runningSession(path, setup);
|
|
const before = head(path);
|
|
const wakes: Wake[] = [];
|
|
|
|
const viewer = await openReadOnly(path, chatSetup(), (wake) => wakes.push(wake));
|
|
const root = await viewer.root(context);
|
|
expect((await allEntries(root)).map((entry) => entry.kind)).toEqual([UserEntry.kind]);
|
|
const run = (await viewer.snapshot(LiveDoc, root.id, context))?.run;
|
|
expect(run).toBeDefined();
|
|
expect((await viewer.getTask(run!.taskId, context))?.state.status).toBe("running");
|
|
const inspection = await viewer.inspect(context);
|
|
expect(inspection.scheduling).toBe("paused");
|
|
expect(viewer.wake()).toEqual({ active: false });
|
|
await viewer.close(context);
|
|
|
|
expect(head(path)).toBe(before);
|
|
expect(wakes).toEqual([]);
|
|
await driver.harness.close(context);
|
|
});
|
|
|
|
it("refuses writes and scheduling, and stays usable", async () => {
|
|
const path = await sqlitePath();
|
|
const setup = chatSetup();
|
|
setup.faux.setResponses([fauxAssistantMessage("one")]);
|
|
const driver = await openChat(await openNodeSqliteStorage(path), setup);
|
|
await (await driver.root.submit({ type: "input", content: "hi" }, context)).wait(context);
|
|
await driver.harness.close(context);
|
|
const before = head(path);
|
|
|
|
const viewerSetup = chatSetup();
|
|
const viewer = await openReadOnly(path, viewerSetup);
|
|
const root = await viewer.root(context);
|
|
await expect(root.commit((tx) => tx.appendEntry(root.id, { kind: "app.note" }), context)).rejects.toBeInstanceOf(
|
|
ReadOnlySession,
|
|
);
|
|
await expect(root.submit({ type: "input", content: "again" }, context)).rejects.toBeInstanceOf(ReadOnlySession);
|
|
expect(() => viewer.resume()).toThrow(ReadOnlySession);
|
|
await expect(viewer.waitForIdle(context)).rejects.toBeInstanceOf(ReadOnlySession);
|
|
await expect(root.abort(context)).rejects.toBeInstanceOf(ReadOnlySession);
|
|
await expect(viewer.createConversation({ ownership: { kind: "ownerless" } }, context)).rejects.toBeInstanceOf(
|
|
ReadOnlySession,
|
|
);
|
|
|
|
// Still healthy: reads work, and a commit that writes nothing succeeds.
|
|
expect(await root.commit(() => "nothing", context)).toBe("nothing");
|
|
expect((await allEntries(root)).length).toBe(2);
|
|
await viewer.close(context);
|
|
expect(head(path)).toBe(before);
|
|
expect(viewerSetup.reports).toEqual([]);
|
|
});
|
|
|
|
it("refuses to create the root of an empty storage", async () => {
|
|
const path = await sqlitePath();
|
|
const viewer = await openReadOnly(path, chatSetup());
|
|
await expect(
|
|
viewer.root(context, { agent: { model: { provider: "faux", modelId: "faux-1" } } }),
|
|
).rejects.toBeInstanceOf(ReadOnlySession);
|
|
await viewer.close(context);
|
|
expect(head(path)).toBe(1);
|
|
});
|
|
|
|
it("leaves recovery to the next writable open", async () => {
|
|
const path = await sqlitePath();
|
|
const setup = chatSetup();
|
|
const driver = await runningSession(path, setup);
|
|
const taskId = (await driver.harness.snapshot(LiveDoc, driver.root.id, context))!.run!.taskId;
|
|
|
|
const viewer = await openReadOnly(path, chatSetup());
|
|
await viewer.close(context);
|
|
|
|
// The writable open recovers the task once, as if the viewer had never opened it.
|
|
const writer = await openChat(await openNodeSqliteStorage(path), chatSetup());
|
|
const record = await writer.harness.getTask(taskId, context);
|
|
expect(record?.state.status).toBe("pending");
|
|
expect(record?.restarts).toBe(1);
|
|
await writer.harness.close(context);
|
|
await driver.harness.close(context);
|
|
});
|
|
});
|