feat(p2): keep serving older Sessions, Catalog and SessionTree versions

Pages and TUIs loaded before a deploy hold the contracts they were built
with; without accepts they wait unbound (contract_mismatch) until they
reload. The histories are now written with evolve from the released
versions, and the providers accept them:

- Sessions 3 (rows without main and branches) and 4 (a null branch label
  reads as the session title); v4's treeSelf arguments pass as they are
- Catalog 2 and 3, the same row mappings
- SessionTree 1: the tree without labels (no branches, no node
  conversation); rename is new and needs nothing

test/p2/contract-versions.test.ts binds the released declarations
against the current server.
This commit is contained in:
Mario Zechner 2026-11-04 15:32:51 +01:00
parent 7670c8be43
commit 0a180b049a
9 changed files with 848 additions and 337 deletions

View file

@ -359,6 +359,12 @@ p2 runs on Chord v2 (`packages/chord`, `docs/design.md` there); the port's notes
- **Plugins:** see **Plugin layout** and **Plugin reloads**; the server loads its facets with `createPlugins`, workers
get theirs from the workers component, the TUI and web bundles load theirs with `host.load`.
- **Protocol versions:** a page or TUI reloads when the server speaks a newer link protocol (`newerPeerProtocol`).
- **Contract versions:** pages and TUIs keep the contracts they were built with until they reload, so a deploy that
bumps a contract a client holds must keep serving the old version: write the history with `evolve` (the released
version as the base, verbatim, so old bundles' fingerprints match) and give the provider an `accepts` table mapping
current state and results back (`Sessions` v3/v4, `Catalog` v2/v3, `SessionTree` v1;
`test/p2/contract-versions.test.ts` binds the released declarations). Without it, old clients wait unbound
(`contract_mismatch`) until they reload.
## Radius and port forwards

View file

@ -42,7 +42,9 @@ version 2; absent: `{ 1, [{ 1, null }] }`):
(`main`; model and thinking level follow it), the web home route, the drawer, the chat's back links, TUI checks,
terminals, unread answers, `Sessions.fork`, peer messages (the conversations broker), and where subagents report.
- Clients learn it through the session row (`Sessions` version 4, catalog schema 4; version 5: null labels); the
worker reports it after a switch and at every start (`treeSelf`). A web page or TUI viewing the previous main conversation follows a switch.
worker reports it after a switch and at every start (`treeSelf`). Clients built before still bind: the providers
accept `Sessions` 3 and 4 and `SessionTree` 1, mapping rows and the tree back (no `main`/`branches` for 3, a null
label as the session title for 4, no labels for `SessionTree` 1). A web page or TUI viewing the previous main conversation follows a switch.
- Earlier branches are read-only (`ReadOnlyConversations`): only the main conversation runs. The web shows only the
main conversation and subagents (others redirect to the main one).
- **Labels** are stored per branch (see [Labels](#labels)).

View file

@ -1,6 +1,7 @@
import { arr, bool, int, nul, num, obj, opt, type Static, str, union, unknown } from "@earendil-works/pi-schema";
import type { Ctx } from "@p2/app/v1";
import {
evolve,
keyedRegistry,
local,
ModelRefSchema,
@ -19,14 +20,9 @@ export {
type ThinkingLevel,
ThinkingLevelSchema,
} from "@p2/app/v1";
/**
* A `/tree` branch as the sidebar lists it: its conversation and its stored label (`MainDoc`); null shows the session
* title (conversation 1 until it is renamed).
*/
export const BranchSchema = obj({ conversation: int({ minimum: 1 }), label: union([str(), nul()]) });
export type Branch = Static<typeof BranchSchema>;
export const SessionSchema = obj({
/** A session row's fields before `/tree` (`Sessions` v3, `Catalog` v2). */
const SESSION_FIELDS = {
id: str(),
env: opt(union([str(), nul()])),
cwd: str(),
@ -37,6 +33,22 @@ export const SessionSchema = obj({
pinned: bool(),
model: ModelRefSchema,
thinkingLevel: str(),
};
const SessionSchemaV3 = obj(SESSION_FIELDS);
/** `Sessions` v4 and `Catalog` v3: branch labels were always text. */
const BranchSchemaV4 = obj({ conversation: int({ minimum: 1 }), label: str() });
const SessionSchemaV4 = obj({ ...SESSION_FIELDS, main: int({ minimum: 1 }), branches: arr(BranchSchemaV4) });
/**
* A `/tree` branch as the sidebar lists it: its conversation and its stored label (`MainDoc`); null shows the session
* title (conversation 1 until it is renamed).
*/
export const BranchSchema = obj({ conversation: int({ minimum: 1 }), label: union([str(), nul()]) });
export type Branch = Static<typeof BranchSchema>;
export const SessionSchema = obj({
...SESSION_FIELDS,
/** The current main conversation: 1, or the `/tree` branch the session continues in (`docs/tree.md`). */
main: int({ minimum: 1 }),
/** The top-level conversations, newest first; empty until the worker reported them. */
@ -75,72 +87,89 @@ export const ArchivedStarts = local("p2.archived-starts", {
},
});
/** The session catalog (server, SQLite): every session, archived ones included. Host-internal. */
export const Catalog = local("p2.catalog", {
/** 2: thinking levels are an enum. 3: `main`. 4: a branch's label may be null (the session title). */
version: 4,
const CatalogCreate = obj({
/** A chosen ID (a clone's or fork's, whose files exist before its entry); default a new one. */
id: opt(str({ pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" })),
env: opt(union([str(), nul()])),
cwd: str(),
title: str(),
/** The title was given, not the placeholder: automatic titles never replace it. */
titleEdited: opt(bool()),
model: ModelRefSchema,
thinkingLevel: ThinkingLevelSchema,
});
const UPDATE_FIELDS = {
id: str(),
cwd: opt(str()),
env: opt(union([str(), nul()])),
title: opt(str()),
pinned: opt(bool()),
archived: opt(bool()),
model: opt(ModelRefSchema),
thinkingLevel: opt(ThinkingLevelSchema),
};
/** The catalog before `/tree`, the base of its history (2: thinking levels are an enum). */
const CatalogV2 = local("p2.catalog", {
version: 2,
params: {},
members: {
sessions: arr(SessionSchema),
get: method(Id, SessionSchema),
create: method(
obj({
/** A chosen ID (a clone's or fork's, whose files exist before its entry); default a new one. */
id: opt(str({ pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" })),
env: opt(union([str(), nul()])),
cwd: str(),
title: str(),
/** The title was given, not the placeholder: automatic titles never replace it. */
titleEdited: opt(bool()),
model: ModelRefSchema,
thinkingLevel: ThinkingLevelSchema,
}),
SessionSchema,
),
sessions: arr(SessionSchemaV3),
get: method(Id, SessionSchemaV3),
create: method(CatalogCreate, SessionSchemaV3),
/** Applies indexed activity without replacing an explicit title or moving time backwards. */
activity: method(obj({ id: str(), timestamp: num(), firstUserText: opt(str()) })),
update: method(
obj({
id: str(),
cwd: opt(str()),
env: opt(union([str(), nul()])),
title: opt(str()),
pinned: opt(bool()),
archived: opt(bool()),
model: opt(ModelRefSchema),
thinkingLevel: opt(ThinkingLevelSchema),
main: opt(int({ minimum: 1 })),
branches: opt(arr(BranchSchema)),
}),
SessionSchema,
),
update: method(obj(UPDATE_FIELDS), SessionSchemaV3),
},
});
/** 3: `main` and `branches`. */
const CatalogV3 = evolve(CatalogV2, (e) =>
e
.state("sessions", arr(SessionSchemaV4))
.result("get", SessionSchemaV4)
.result("create", SessionSchemaV4)
.method(
"update",
obj({ ...UPDATE_FIELDS, main: opt(int({ minimum: 1 })), branches: opt(arr(BranchSchemaV4)) }),
SessionSchemaV4,
),
);
/**
* Sessions as a principal sees them. The owner lists and manages all; a guest sees its own session; a worker sees
* and renames only its own session (its principal names it).
* The session catalog (server, SQLite): every session, archived ones included. Host-internal. 4: a branch's label may
* be null (the session title). The provider still serves 2 and 3 (its `accepts`).
*/
export const Sessions = remote("p2.sessions", {
/**
* 2: thinking levels are an enum; configureSelf takes either field alone.
* 3: `clone` and `fork` (owner).
* 4: rows carry `main` and `branches`; the worker's `treeSelf`.
* 5: a branch's label may be null (the session title).
*/
version: 5,
export const Catalog = evolve(CatalogV3, (e) =>
e
.state("sessions", arr(SessionSchema))
.result("get", SessionSchema)
.result("create", SessionSchema)
.method(
"update",
obj({ ...UPDATE_FIELDS, main: opt(int({ minimum: 1 })), branches: opt(arr(BranchSchema)) }),
SessionSchema,
),
);
/**
* Sessions as a principal sees them, before `/tree`: the base of the history (2: thinking levels are an enum;
* configureSelf takes either field alone. 3: `clone` and `fork` (owner)).
*/
const SessionsV3 = remote("p2.sessions", {
version: 3,
params: {},
hostAddress: serverHost,
variants: {
owner: {
state: {
sessions: arr(SessionSchema),
sessions: arr(SessionSchemaV3),
/** Working directory suggested for a new session: the most recently active one, or the home directory. */
defaultCwd: str(),
defaultEnv: union([str(), nul()]),
},
methods: {
create: method(CreateSchema, SessionSchema),
create: method(CreateSchema, SessionSchemaV3),
relocate: method(
obj({
id: str(),
@ -156,10 +185,10 @@ export const Sessions = remote("p2.sessions", {
* A copy of the session as it is now (pi's `/clone`): its store with every conversation, and its artifacts,
* downloads and uploads, under a new ID. Refused while the session's worker has work running.
*/
clone: method(Id, SessionSchema),
clone: method(Id, SessionSchemaV3),
/**
* A new session holding the active context of `conversation` before the entry `before` (pi's `/fork` from a
* user message: that message is not part of it). Only the main conversation (1) for now.
* user message: that message is not part of it). The main conversation or one of its `/tree` branches.
*/
fork: method(
obj({
@ -167,30 +196,56 @@ export const Sessions = remote("p2.sessions", {
conversation: int({ minimum: 1 }),
before: int({ minimum: 1 }),
}),
SessionSchema,
SessionSchemaV3,
),
},
},
/** A guest sees its one session while it is not archived. */
guest: {
state: { sessions: arr(SessionSchema) },
state: { sessions: arr(SessionSchemaV3) },
},
worker: {
state: { session: union([SessionSchema, nul()]) },
state: { session: union([SessionSchemaV3, nul()]) },
methods: {
renameSelf: method(obj({ title: Title }), obj({ previous: str() })),
/** The root conversation's configuration changed (either field alone): the catalog entry follows. */
configureSelf: method(obj({ model: opt(ModelRefSchema), thinkingLevel: opt(ThinkingLevelSchema) })),
/**
* The session's main conversation or its branches changed (a `/tree` switch or fork, a branch renamed), or
* the worker started: the catalog entry follows, so clients open the main one and list the branches.
*/
treeSelf: method(obj({ main: int({ minimum: 1 }), branches: arr(BranchSchema) })),
},
},
},
});
/** 4: rows carry `main` and `branches`; the worker's `treeSelf`. */
const SessionsV4 = evolve(SessionsV3, (e) =>
e
.state("owner", "sessions", arr(SessionSchemaV4))
.state("guest", "sessions", arr(SessionSchemaV4))
.state("worker", "session", union([SessionSchemaV4, nul()]))
.result("create", SessionSchemaV4)
.result("clone", SessionSchemaV4)
.result("fork", SessionSchemaV4)
.addMethod("worker", "treeSelf", obj({ main: int({ minimum: 1 }), branches: arr(BranchSchemaV4) }), nul()),
);
/**
* Sessions as a principal sees them. The owner lists and manages all; a guest sees its own session; a worker sees
* and renames only its own session (its principal names it). 5: a branch's label may be null (the session title).
* The provider still serves 3 and 4 (its `accepts`): pages and TUIs loaded before a deploy keep working.
*
* The worker's `treeSelf`: the session's main conversation or its branches changed (a `/tree` switch or fork, a branch
* renamed), or the worker started; the catalog entry follows, so clients open the main one and list the branches.
*/
export const Sessions = evolve(SessionsV4, (e) =>
e
.state("owner", "sessions", arr(SessionSchema))
.state("guest", "sessions", arr(SessionSchema))
.state("worker", "session", union([SessionSchema, nul()]))
.result("create", SessionSchema)
.result("clone", SessionSchema)
.result("fork", SessionSchema)
.args("treeSelf", obj({ main: int({ minimum: 1 }), branches: arr(BranchSchema) })),
);
/**
* Moving a session to another location (server, host-internal): `Sessions.relocate` for the owner, and the locations
* plugin's `location_move` once the main agent's turn has ended. Validates the destination (throws when it is not a

View file

@ -3,6 +3,7 @@ import { mkdir, stat } from "node:fs/promises";
import { homedir } from "node:os";
import { isAbsolute, resolve } from "node:path";
import {
type AcceptsTable,
type Ctx,
contribute,
defineFacet,
@ -36,6 +37,68 @@ import {
} from "./contract.ts";
import { copySessionDirectories, copySessionStore, removeSessionCopy } from "./copy.ts";
/** A row as `Sessions` v4 and `Catalog` v3 consumers read it: a null branch label is the session title. */
function sessionV4(row: Session) {
return {
...row,
branches: row.branches.map(({ conversation, label }) => ({ conversation, label: label ?? row.title })),
};
}
/** A row as `Sessions` v3 and `Catalog` v2 consumers read it: before `/tree`, without `main` and `branches`. */
function sessionV3({ main: _main, branches: _branches, ...row }: Session) {
return row;
}
/** Catalog consumers built before its current version (a server plugin not reloaded yet) read rows mapped back. */
const CATALOG_ACCEPTS: AcceptsTable<typeof Catalog> = {
2: {
state: { sessions: (rows) => rows.map(sessionV3) },
methods: {
get: { result: sessionV3 },
create: { result: sessionV3 },
// Older arguments are a subset of the current ones.
update: { args: (old) => old, result: sessionV3 },
},
},
3: {
state: { sessions: (rows) => rows.map(sessionV4) },
methods: {
get: { result: sessionV4 },
create: { result: sessionV4 },
update: { args: (old) => old, result: sessionV4 },
},
},
};
/**
* Older `Sessions` consumers (a page or TUI loaded before a deploy, a worker not restarted yet) read rows mapped
* back; v4's `treeSelf` labels are text, which the current version takes as it is.
*/
const SESSIONS_ACCEPTS: AcceptsTable<typeof Sessions> = {
3: {
state: {
owner: { sessions: (rows) => rows.map(sessionV3) },
guest: { sessions: (rows) => rows.map(sessionV3) },
worker: { session: (row) => (row === null ? null : sessionV3(row)) },
},
methods: { create: { result: sessionV3 }, clone: { result: sessionV3 }, fork: { result: sessionV3 } },
},
4: {
state: {
owner: { sessions: (rows) => rows.map(sessionV4) },
guest: { sessions: (rows) => rows.map(sessionV4) },
worker: { session: (row) => (row === null ? null : sessionV4(row)) },
},
methods: {
create: { result: sessionV4 },
clone: { result: sessionV4 },
fork: { result: sessionV4 },
treeSelf: { args: (old) => old },
},
},
};
/** Contributes each worker's session parameters; refuses archived and unknown sessions. */
const cleanTitle = (title: string): string => title.replace(/\s+/g, " ").trim();
@ -72,40 +135,44 @@ async function fenceWorker(
export default defineFacet((host) => {
serveKeyed(host, SessionArchived);
host.serve(Catalog, (_params, self) => {
const server = self.get(Server, {});
const archived = self.registry(SessionArchived);
// The catalog's sessions, read from the database when it opens and after every change.
const sessions = self.writable<Session[]>([]);
const db = self.open(
async () => {
const opened = new CatalogDb(server.info.value.dataDir);
sessions.set(opened.all());
return opened;
},
(opened) => opened.close(),
);
return {
sessions: deriveTyped(self, [sessions], ([value]) => value),
get: async ({ id }) => db.value.get(id),
create: async (input) => {
const session = db.value.create(input);
sessions.set(db.value.all());
return session;
},
activity: async ({ id, timestamp, firstUserText }) => {
db.value.activity(id, timestamp, firstUserText);
sessions.set(db.value.all());
return null;
},
update: async ({ id, ...change }, ctx) => {
const session = db.value.update(id, change);
sessions.set(db.value.all());
if (change.archived === true) for (const close of archived.value.values()) await close(id, ctx);
return session;
},
};
});
host.serve(
Catalog,
(_params, self) => {
const server = self.get(Server, {});
const archived = self.registry(SessionArchived);
// The catalog's sessions, read from the database when it opens and after every change.
const sessions = self.writable<Session[]>([]);
const db = self.open(
async () => {
const opened = new CatalogDb(server.info.value.dataDir);
sessions.set(opened.all());
return opened;
},
(opened) => opened.close(),
);
return {
sessions: deriveTyped(self, [sessions], ([value]) => value),
get: async ({ id }) => db.value.get(id),
create: async (input) => {
const session = db.value.create(input);
sessions.set(db.value.all());
return session;
},
activity: async ({ id, timestamp, firstUserText }) => {
db.value.activity(id, timestamp, firstUserText);
sessions.set(db.value.all());
return null;
},
update: async ({ id, ...change }, ctx) => {
const session = db.value.update(id, change);
sessions.set(db.value.all());
if (change.archived === true) for (const close of archived.value.values()) await close(id, ctx);
return session;
},
};
},
{ accepts: CATALOG_ACCEPTS },
);
host.serve(ArchivedStarts, (_params, self) => {
const allowed = self.writable<string[]>([]);
@ -199,204 +266,210 @@ export default defineFacet((host) => {
};
});
host.serve(Sessions, (_params, principal, self) => {
const catalog = self.get(Catalog, {});
if (isWorker(principal)) {
const id = principal.session;
const sessionView = deriveTyped(
host.serve(
Sessions,
(_params, principal, self) => {
const catalog = self.get(Catalog, {});
if (isWorker(principal)) {
const id = principal.session;
const sessionView = deriveTyped(
self,
[catalog.sessions],
([sessions]) => sessions.find((s) => s.id === id) ?? null,
);
return {
variant: "worker",
members: {
session: sessionView,
renameSelf: async ({ title }, ctx) => {
const previous = (await catalog.get({ id }, ctx)).title;
await catalog.update({ id, title: cleanTitle(title) }, ctx);
return { previous };
},
// Either field alone updates the entry; the other keeps its value (Pi Mobile's configure).
configureSelf: async ({ model, thinkingLevel }, ctx) => {
await catalog.update(
{ id, ...(model ? { model } : {}), ...(thinkingLevel ? { thinkingLevel } : {}) },
ctx,
);
return null;
},
treeSelf: async ({ main, branches }, ctx) => {
const current = await catalog.get({ id }, ctx);
if (current.main !== main || JSON.stringify(current.branches) !== JSON.stringify(branches))
await catalog.update({ id, main, branches }, ctx);
return null;
},
},
};
}
if (isGuest(principal)) {
const shared = principal.sessions;
const sessionsView = deriveTyped(self, [catalog.sessions], ([sessions]) =>
sessions.filter((s) => Object.hasOwn(shared, s.id) && s.archivedAt === null),
);
return { variant: "guest", members: { sessions: sessionsView } };
}
if (!isOwner(principal)) throw forbidden("sessions are owner-only");
const lifecycleOf = serverOf(self);
const models = self.get(ModelConfig, {});
const resources = nativeOf(self, ModelResources, "The model configuration");
self.get(EnvService, {});
const envs = nativeOf(self, EnvManagers, "The SSH manager");
const children = self.get(ChildStore, {});
const server = self.get(Server, {});
const moves = self.get(SessionMoves, {});
const fence = (id: string, doing: string, root: Ctx, to?: Location) =>
fenceWorker(children, id, doing, root, to);
/**
* Runs a lifecycle operation's steps in a root of the server's own, not the caller's: a caller giving up (an
* aborted call, a closed tab) must not cut a fenced worker off between its steps.
*/
const lifecycle = <T>(fn: (root: Ctx) => Promise<T>): Promise<T> => lifecycleOf().root(fn);
/** A copy's catalog entry: the source's place, model and level under a marked title. */
const copyOf = (source: Session, id: string, mark: string, ctx: Ctx) =>
catalog.create(
{
id,
cwd: source.cwd,
env: source.env ?? null,
title: cleanTitle(`${source.title} (${mark})`).slice(0, MAX_TITLE),
titleEdited: true,
model: source.model,
thinkingLevel: source.thinkingLevel,
},
ctx,
);
const defaultCwd = deriveTyped(
self,
[catalog.sessions],
([sessions]) => sessions.find((s) => s.id === id) ?? null,
([sessions]) => sessions.find((session) => session.archivedAt === null)?.cwd ?? homedir(),
);
const defaultEnv = deriveTyped(
self,
[catalog.sessions],
([sessions]) => sessions.find((session) => session.archivedAt === null)?.env ?? null,
);
const defaultModel = deriveTyped(self, [models.summary], ([summary]) => summary.defaultModel);
return {
variant: "worker",
variant: "owner",
members: {
session: sessionView,
renameSelf: async ({ title }, ctx) => {
const previous = (await catalog.get({ id }, ctx)).title;
await catalog.update({ id, title: cleanTitle(title) }, ctx);
return { previous };
},
// Either field alone updates the entry; the other keeps its value (Pi Mobile's configure).
configureSelf: async ({ model, thinkingLevel }, ctx) => {
await catalog.update(
{ id, ...(model ? { model } : {}), ...(thinkingLevel ? { thinkingLevel } : {}) },
sessions: catalog.sessions,
defaultCwd,
defaultEnv,
create: async ({ env, cwd, createDirectory, title, model, thinkingLevel }, ctx) => {
if (env) cwd = await envs().manager.workingDirectory(env, cwd, createDirectory);
else {
cwd = cwd === "~" ? homedir() : cwd.startsWith("~/") ? resolve(homedir(), cwd.slice(2)) : cwd;
if (!isAbsolute(cwd)) throw new Error("The working directory must be an absolute path");
if (createDirectory) await mkdir(cwd, { recursive: true });
if (!(await stat(cwd)).isDirectory()) throw new Error("Working directory is not a directory");
}
const fallback = defaultModel.value;
const chosen = model ?? fallback;
if (!chosen) throw new Error("Select a model or configure P2_PROVIDER and P2_MODEL");
// As Pi Mobile and setDefault: a model the configuration does not know never reaches a worker.
if (!resources().resources.models.getModel(chosen.provider, chosen.modelId))
throw new Error("Unknown model");
const level = thinkingLevel ?? fallback?.thinkingLevel;
return catalog.create(
{
cwd,
env: env ?? null,
title: cleanTitle(title ?? "").slice(0, 100) || "New session",
titleEdited: Boolean(cleanTitle(title ?? "")),
model: { provider: chosen.provider, modelId: chosen.modelId },
thinkingLevel: isThinkingLevel(level) ? level : "high",
},
ctx,
);
},
relocate: async (target, ctx) => {
const { refused } = await moves.relocate(target, ctx);
if (refused !== null) throw new Error(refused);
return null;
},
treeSelf: async ({ main, branches }, ctx) => {
const current = await catalog.get({ id }, ctx);
if (current.main !== main || JSON.stringify(current.branches) !== JSON.stringify(branches))
await catalog.update({ id, main, branches }, ctx);
clone: async ({ id }, ctx) => {
if ((await catalog.get({ id }, ctx)).archivedAt !== null) throw new Error("Session is archived");
const dataDir = server.info.value.dataDir;
const copy = randomUUID();
try {
// The store is copied while the worker is stopped: SQLite's file and WAL belong together then.
await lifecycle((root) =>
lifecycleOf().workers.pause(
id,
() => fence(id, "cloning", root),
async () => {
await copySessionStore(dataDir, id, copy);
await copySessionDirectories(dataDir, id, copy);
},
"cloning",
),
);
// Read after the copy: a move or rename while waiting for the pause belongs to the copy too.
return await copyOf(await catalog.get({ id }, ctx), copy, "copy", ctx);
} catch (error) {
await removeSessionCopy(dataDir, copy);
throw error;
}
},
fork: async ({ id, conversation, before }, ctx) => {
const source = await catalog.get({ id }, ctx);
if (source.archivedAt !== null) throw new Error("Session is archived");
// SessionTransfer is the server's: reached in a root of its own (a method's `ctx.get` acts as the caller).
const { entries } = await lifecycle(async (root) =>
(await root.get(SessionTransfer, { session: id })).history({ conversation, before }, root),
);
const dataDir = server.info.value.dataDir;
const copy = randomUUID();
let created: Session | undefined;
try {
await copySessionDirectories(dataDir, id, copy);
created = await copyOf(source, copy, "fork", ctx);
// Before anyone knows the new ID, its worker writes the history into its main conversation.
await lifecycle(async (root) =>
(await root.get(SessionTransfer, { session: copy })).seed({ entries }, root),
);
return created;
} catch (error) {
if (created) {
await catalog.update({ id: copy, archived: true }, ctx).catch(() => {});
await lifecycleOf()
.workers.suspend(copy)
.catch(() => {});
} else await removeSessionCopy(dataDir, copy);
throw error;
}
},
rename: async ({ id, title }, ctx) => {
const clean = cleanTitle(title);
if (!clean || clean.length > MAX_TITLE)
throw new Error(`The title must be 1–${MAX_TITLE} characters`);
await catalog.update({ id, title: clean }, ctx);
return null;
},
pin: async ({ id, pinned }, ctx) => {
await catalog.update({ id, pinned }, ctx);
return null;
},
archive: async ({ id }) => {
await lifecycle(async (root) => {
await catalog.update({ id, archived: true }, root);
// Archive stops the worker whatever it is doing; its storage and history stay. Its holders (a page's
// lingering conversation) wait for a restore rather than start a worker the archive refuses.
await lifecycleOf().workers.suspend(id);
});
return null;
},
restore: async ({ id }, ctx) => {
await catalog.update({ id, archived: false }, ctx);
// A start refused while archived left the worker failed: the next hold starts it again.
await lifecycleOf().workers.retry(id);
return null;
},
},
};
}
if (isGuest(principal)) {
const shared = principal.sessions;
const sessionsView = deriveTyped(self, [catalog.sessions], ([sessions]) =>
sessions.filter((s) => Object.hasOwn(shared, s.id) && s.archivedAt === null),
);
return { variant: "guest", members: { sessions: sessionsView } };
}
if (!isOwner(principal)) throw forbidden("sessions are owner-only");
const lifecycleOf = serverOf(self);
const models = self.get(ModelConfig, {});
const resources = nativeOf(self, ModelResources, "The model configuration");
self.get(EnvService, {});
const envs = nativeOf(self, EnvManagers, "The SSH manager");
const children = self.get(ChildStore, {});
const server = self.get(Server, {});
const moves = self.get(SessionMoves, {});
const fence = (id: string, doing: string, root: Ctx, to?: Location) => fenceWorker(children, id, doing, root, to);
/**
* Runs a lifecycle operation's steps in a root of the server's own, not the caller's: a caller giving up (an
* aborted call, a closed tab) must not cut a fenced worker off between its steps.
*/
const lifecycle = <T>(fn: (root: Ctx) => Promise<T>): Promise<T> => lifecycleOf().root(fn);
/** A copy's catalog entry: the source's place, model and level under a marked title. */
const copyOf = (source: Session, id: string, mark: string, ctx: Ctx) =>
catalog.create(
{
id,
cwd: source.cwd,
env: source.env ?? null,
title: cleanTitle(`${source.title} (${mark})`).slice(0, MAX_TITLE),
titleEdited: true,
model: source.model,
thinkingLevel: source.thinkingLevel,
},
ctx,
);
const defaultCwd = deriveTyped(
self,
[catalog.sessions],
([sessions]) => sessions.find((session) => session.archivedAt === null)?.cwd ?? homedir(),
);
const defaultEnv = deriveTyped(
self,
[catalog.sessions],
([sessions]) => sessions.find((session) => session.archivedAt === null)?.env ?? null,
);
const defaultModel = deriveTyped(self, [models.summary], ([summary]) => summary.defaultModel);
return {
variant: "owner",
members: {
sessions: catalog.sessions,
defaultCwd,
defaultEnv,
create: async ({ env, cwd, createDirectory, title, model, thinkingLevel }, ctx) => {
if (env) cwd = await envs().manager.workingDirectory(env, cwd, createDirectory);
else {
cwd = cwd === "~" ? homedir() : cwd.startsWith("~/") ? resolve(homedir(), cwd.slice(2)) : cwd;
if (!isAbsolute(cwd)) throw new Error("The working directory must be an absolute path");
if (createDirectory) await mkdir(cwd, { recursive: true });
if (!(await stat(cwd)).isDirectory()) throw new Error("Working directory is not a directory");
}
const fallback = defaultModel.value;
const chosen = model ?? fallback;
if (!chosen) throw new Error("Select a model or configure P2_PROVIDER and P2_MODEL");
// As Pi Mobile and setDefault: a model the configuration does not know never reaches a worker.
if (!resources().resources.models.getModel(chosen.provider, chosen.modelId))
throw new Error("Unknown model");
const level = thinkingLevel ?? fallback?.thinkingLevel;
return catalog.create(
{
cwd,
env: env ?? null,
title: cleanTitle(title ?? "").slice(0, 100) || "New session",
titleEdited: Boolean(cleanTitle(title ?? "")),
model: { provider: chosen.provider, modelId: chosen.modelId },
thinkingLevel: isThinkingLevel(level) ? level : "high",
},
ctx,
);
},
relocate: async (target, ctx) => {
const { refused } = await moves.relocate(target, ctx);
if (refused !== null) throw new Error(refused);
return null;
},
clone: async ({ id }, ctx) => {
if ((await catalog.get({ id }, ctx)).archivedAt !== null) throw new Error("Session is archived");
const dataDir = server.info.value.dataDir;
const copy = randomUUID();
try {
// The store is copied while the worker is stopped: SQLite's file and WAL belong together then.
await lifecycle((root) =>
lifecycleOf().workers.pause(
id,
() => fence(id, "cloning", root),
async () => {
await copySessionStore(dataDir, id, copy);
await copySessionDirectories(dataDir, id, copy);
},
"cloning",
),
);
// Read after the copy: a move or rename while waiting for the pause belongs to the copy too.
return await copyOf(await catalog.get({ id }, ctx), copy, "copy", ctx);
} catch (error) {
await removeSessionCopy(dataDir, copy);
throw error;
}
},
fork: async ({ id, conversation, before }, ctx) => {
const source = await catalog.get({ id }, ctx);
if (source.archivedAt !== null) throw new Error("Session is archived");
// SessionTransfer is the server's: reached in a root of its own (a method's `ctx.get` acts as the caller).
const { entries } = await lifecycle(async (root) =>
(await root.get(SessionTransfer, { session: id })).history({ conversation, before }, root),
);
const dataDir = server.info.value.dataDir;
const copy = randomUUID();
let created: Session | undefined;
try {
await copySessionDirectories(dataDir, id, copy);
created = await copyOf(source, copy, "fork", ctx);
// Before anyone knows the new ID, its worker writes the history into its main conversation.
await lifecycle(async (root) =>
(await root.get(SessionTransfer, { session: copy })).seed({ entries }, root),
);
return created;
} catch (error) {
if (created) {
await catalog.update({ id: copy, archived: true }, ctx).catch(() => {});
await lifecycleOf()
.workers.suspend(copy)
.catch(() => {});
} else await removeSessionCopy(dataDir, copy);
throw error;
}
},
rename: async ({ id, title }, ctx) => {
const clean = cleanTitle(title);
if (!clean || clean.length > MAX_TITLE) throw new Error(`The title must be 1–${MAX_TITLE} characters`);
await catalog.update({ id, title: clean }, ctx);
return null;
},
pin: async ({ id, pinned }, ctx) => {
await catalog.update({ id, pinned }, ctx);
return null;
},
archive: async ({ id }) => {
await lifecycle(async (root) => {
await catalog.update({ id, archived: true }, root);
// Archive stops the worker whatever it is doing; its storage and history stay. Its holders (a page's
// lingering conversation) wait for a restore rather than start a worker the archive refuses.
await lifecycleOf().workers.suspend(id);
});
return null;
},
restore: async ({ id }, ctx) => {
await catalog.update({ id, archived: false }, ctx);
// A start refused while archived left the worker failed: the next hold starts it again.
await lifecycleOf().workers.retry(id);
return null;
},
},
};
});
},
{ accepts: SESSIONS_ACCEPTS },
);
});

View file

@ -1,50 +1,59 @@
// `/tree` (`docs/tree.md`): the session's branches as one tree of messages, switching the branch the session continues
// in, and the branches' labels. Each branch is a top-level Durable conversation forked from another at an entry.
import { arr, int, nul, obj, type Static, str, union } from "@earendil-works/pi-schema";
import { literal, method, param, remote, sessionHost } from "@p2/app/v1";
import { evolve, literal, method, param, remote, sessionHost } from "@p2/app/v1";
/** How much of a message a node previews, and the longest branch label. */
export const PREVIEW_LENGTH = 200;
/** One message of the tree: a user message or an assistant answer with text. */
export const TreeNodeSchema = obj({
/** A tree node's fields in `SessionTree` v1. */
const NODE_FIELDS = {
/** The entry's ID (Session-global, so unique across branches). */
entry: int({ minimum: 1 }),
/** The node before it on its branch, or null for the first message. */
parent: union([int({ minimum: 1 }), nul()]),
/** The top-level conversation storing it: the branch whose label it shows under, the first of its own nodes. */
conversation: int({ minimum: 1 }),
role: union([literal("user"), literal("assistant")]),
/** The message's text, at most `PREVIEW_LENGTH` characters. */
text: str(),
};
/** One message of the tree: a user message or an assistant answer with text. */
export const TreeNodeSchema = obj({
...NODE_FIELDS,
/** The top-level conversation storing it: the branch whose label it shows under, the first of its own nodes. */
conversation: int({ minimum: 1 }),
});
export type TreeNode = Static<typeof TreeNodeSchema>;
/** A branch's label; null shows the session title (conversation 1's default, `MainDoc`). */
export const BranchLabelSchema = union([str(), nul()]);
/** The tree's fields in `SessionTree` v1. */
const TREE_FIELDS = {
/** The current main conversation. */
main: int({ minimum: 1 }),
/** The current branch's last node: where the next message continues; null before the first message. */
current: union([int({ minimum: 1 }), nul()]),
};
export const TreeSchema = obj({
/** In entry ID order, so a parent always comes before its children. */
nodes: arr(TreeNodeSchema),
/** The top-level conversations and their labels, in creation order. */
branches: arr(obj({ conversation: int({ minimum: 1 }), label: BranchLabelSchema })),
/** The current main conversation. */
main: int({ minimum: 1 }),
/** The current branch's last node: where the next message continues; null before the first message. */
current: union([int({ minimum: 1 }), nul()]),
...TREE_FIELDS,
});
export type Tree = Static<typeof TreeSchema>;
/** The session's branches, for the owner (worker). */
export const SessionTree = remote("p2.tree", {
/** 2: branch labels (`Tree.branches`, `TreeNode.conversation`, `rename`). */
version: 2,
/** The session's branches, for the owner (worker): the base of the history, before branch labels. */
const SessionTreeV1 = remote("p2.tree", {
version: 1,
params: { session: param.str() },
hostAddress: sessionHost,
variants: {
owner: {
methods: {
tree: method(undefined, TreeSchema),
tree: method(undefined, obj({ nodes: arr(obj(NODE_FIELDS)), ...TREE_FIELDS })),
/**
* Continues the session at `entry` (pi's `/tree`): after it for an answer, before it for a user message, whose
* text comes back for the editor (null otherwise). Switches to the branch ending there, else forks a new one
@ -57,12 +66,18 @@ export const SessionTree = remote("p2.tree", {
),
/** Continues the session at the end of the branch `conversation` (a sidebar row); refused while busy. */
select: method(obj({ conversation: int({ minimum: 1 }) })),
/**
* Renames the branch `conversation`: 1 to `PREVIEW_LENGTH` characters after trimming. Null or empty resets
* conversation 1 to the session title; other branches need a label.
*/
rename: method(obj({ conversation: int({ minimum: 1 }), label: BranchLabelSchema })),
},
},
},
});
/**
* The session's branches, for the owner (worker). 2: branch labels (`Tree.branches`, `TreeNode.conversation`) and
* `rename`: renames the branch `conversation`, 1 to `PREVIEW_LENGTH` characters after trimming; null or empty resets
* conversation 1 to the session title, other branches need a label. The provider still serves 1 (its `accepts`).
*/
export const SessionTree = evolve(SessionTreeV1, (e) =>
e
.result("tree", TreeSchema)
.addMethod("owner", "rename", obj({ conversation: int({ minimum: 1 }), label: BranchLabelSchema }), nul()),
);

View file

@ -14,6 +14,7 @@ import {
ROOT_CONVERSATION_ID,
} from "@earendil-works/pi-durable";
import {
type AcceptsTable,
type Context,
type Ctx,
contribute,
@ -39,6 +40,21 @@ import {
import { type Branch, Sessions } from "../sessions/contract.ts";
import { PREVIEW_LENGTH, SessionTree, type Tree, type TreeNode } from "./contract.ts";
/** `SessionTree` v1 consumers (a page or TUI loaded before a deploy): the tree without branch labels. */
const TREE_ACCEPTS: AcceptsTable<typeof SessionTree> = {
1: {
methods: {
tree: {
result: ({ nodes, main, current }) => ({
nodes: nodes.map(({ entry, parent, role, text }) => ({ entry, parent, role, text })),
main,
current,
}),
},
},
},
};
const NOT_CURRENT = "This is an earlier branch of the session: switch to it with /tree to continue it";
export default defineFacet((host) => {
@ -87,38 +103,42 @@ export default defineFacet((host) => {
});
});
host.serve(SessionTree, (_params, principal, self) => {
if (!isOwner(principal)) throw forbidden("the tree is the owner's");
const harness = harnessOf(self);
const worker = workerOf(self);
return {
variant: "owner",
members: {
tree: (ctx) => worker().liveness.call(() => readTree(harness().durable, durableContext(ctx))),
navigate: ({ entry }, ctx) =>
worker().liveness.call(async () => {
const durable = harness().durable;
const switched = await navigate(durable, entry as EntryId, durableContext(ctx));
await reportTree(durable, ctx);
return switched;
}),
select: ({ conversation }, ctx) =>
worker().liveness.call(async () => {
const durable = harness().durable;
await select(durable, conversation as ConversationId, durableContext(ctx));
await reportTree(durable, ctx);
return null;
}),
rename: ({ conversation, label }, ctx) =>
worker().liveness.call(async () => {
const durable = harness().durable;
await rename(durable, conversation, label, durableContext(ctx));
await reportTree(durable, ctx);
return null;
}),
},
};
});
host.serve(
SessionTree,
(_params, principal, self) => {
if (!isOwner(principal)) throw forbidden("the tree is the owner's");
const harness = harnessOf(self);
const worker = workerOf(self);
return {
variant: "owner",
members: {
tree: (ctx) => worker().liveness.call(() => readTree(harness().durable, durableContext(ctx))),
navigate: ({ entry }, ctx) =>
worker().liveness.call(async () => {
const durable = harness().durable;
const switched = await navigate(durable, entry as EntryId, durableContext(ctx));
await reportTree(durable, ctx);
return switched;
}),
select: ({ conversation }, ctx) =>
worker().liveness.call(async () => {
const durable = harness().durable;
await select(durable, conversation as ConversationId, durableContext(ctx));
await reportTree(durable, ctx);
return null;
}),
rename: ({ conversation, label }, ctx) =>
worker().liveness.call(async () => {
const durable = harness().durable;
await rename(durable, conversation, label, durableContext(ctx));
await reportTree(durable, ctx);
return null;
}),
},
};
},
{ accepts: TREE_ACCEPTS },
);
});
/** Tells the catalog the current main conversation and the branches, newest first (it ignores an unchanged report). */

View file

@ -3,6 +3,7 @@
// relative paths into `src`: a plugin is built and reloaded on its own, and the API is the code its host provides.
export {
type AcceptsTable,
type Address,
broken,
ChordError,
@ -10,6 +11,7 @@ export {
constant,
defineRegistry,
defineState,
evolve,
type HandleOf,
isChordError,
type KeyedHandle,

View file

@ -0,0 +1,312 @@
// Pages, TUIs and plugins built before a deploy keep working: the providers of `Sessions`, `SessionTree` and `Catalog`
// still bind the versions released before `/tree` and its branch labels, through their `accepts`, and map the current
// state and results back. The old contracts below are copied verbatim from the releases that bundled them
// (`Sessions` v3 and `Catalog` v2 from 0a730c8a7, `Sessions` v4, `Catalog` v3 and `SessionTree` v1 from e88525506), so
// their fingerprints are the ones old bundles send.
import assert from "node:assert/strict";
import { after, before, describe, it } from "node:test";
import { arr, bool, int, nul, num, obj, opt, str, union } from "@earendil-works/pi-schema";
import {
BACKGROUND_CONTEXT,
literal,
local,
ModelRefSchema,
method,
param,
remote,
serverHost,
sessionHost,
ThinkingLevelSchema,
} from "@p2/app/v1";
import { Chat } from "../../plugins/chat/contract.ts";
import { CreateSchema, MAX_TITLE, type Session, Sessions } from "../../plugins/sessions/contract.ts";
import { SessionStatus } from "../../plugins/status/contract.ts";
import { SessionTree } from "../../plugins/tree/contract.ts";
import type { Host } from "../../src/runtime/chord.ts";
import { bound, boundIn, type Fixture, holdIn, startFixture, until } from "./helpers.ts";
const Id = obj({ id: str() });
const Title = str({ minLength: 1, maxLength: MAX_TITLE });
/** 0a730c8a7's `SessionSchema`. */
const SessionSchemaV3 = obj({
id: str(),
env: opt(union([str(), nul()])),
cwd: str(),
title: str(),
createdAt: num(),
updatedAt: num(),
archivedAt: union([num(), nul()]),
pinned: bool(),
model: ModelRefSchema,
thinkingLevel: str(),
});
/** e88525506's `BranchSchema` and `SessionSchema`. */
const BranchSchemaV4 = obj({ conversation: int({ minimum: 1 }), label: str() });
const SessionSchemaV4 = obj({
id: str(),
env: opt(union([str(), nul()])),
cwd: str(),
title: str(),
createdAt: num(),
updatedAt: num(),
archivedAt: union([num(), nul()]),
pinned: bool(),
model: ModelRefSchema,
thinkingLevel: str(),
main: int({ minimum: 1 }),
branches: arr(BranchSchemaV4),
});
/** `Sessions` as 0a730c8a7 declared it. */
const SessionsV3 = remote("p2.sessions", {
version: 3,
params: {},
hostAddress: serverHost,
variants: {
owner: {
state: { sessions: arr(SessionSchemaV3), defaultCwd: str(), defaultEnv: union([str(), nul()]) },
methods: {
create: method(CreateSchema, SessionSchemaV3),
relocate: method(obj({ id: str(), cwd: str({ minLength: 1 }), env: union([str(), nul()]) })),
rename: method(obj({ id: str(), title: Title })),
pin: method(obj({ id: str(), pinned: bool() })),
archive: method(Id),
restore: method(Id),
clone: method(Id, SessionSchemaV3),
fork: method(
obj({ id: str(), conversation: int({ minimum: 1 }), before: int({ minimum: 1 }) }),
SessionSchemaV3,
),
},
},
guest: { state: { sessions: arr(SessionSchemaV3) } },
worker: {
state: { session: union([SessionSchemaV3, nul()]) },
methods: {
renameSelf: method(obj({ title: Title }), obj({ previous: str() })),
configureSelf: method(obj({ model: opt(ModelRefSchema), thinkingLevel: opt(ThinkingLevelSchema) })),
},
},
},
});
/** `Sessions` as e88525506 declared it. */
const SessionsV4 = remote("p2.sessions", {
version: 4,
params: {},
hostAddress: serverHost,
variants: {
owner: {
state: { sessions: arr(SessionSchemaV4), defaultCwd: str(), defaultEnv: union([str(), nul()]) },
methods: {
create: method(CreateSchema, SessionSchemaV4),
relocate: method(obj({ id: str(), cwd: str({ minLength: 1 }), env: union([str(), nul()]) })),
rename: method(obj({ id: str(), title: Title })),
pin: method(obj({ id: str(), pinned: bool() })),
archive: method(Id),
restore: method(Id),
clone: method(Id, SessionSchemaV4),
fork: method(
obj({ id: str(), conversation: int({ minimum: 1 }), before: int({ minimum: 1 }) }),
SessionSchemaV4,
),
},
},
guest: { state: { sessions: arr(SessionSchemaV4) } },
worker: {
state: { session: union([SessionSchemaV4, nul()]) },
methods: {
renameSelf: method(obj({ title: Title }), obj({ previous: str() })),
configureSelf: method(obj({ model: opt(ModelRefSchema), thinkingLevel: opt(ThinkingLevelSchema) })),
treeSelf: method(obj({ main: int({ minimum: 1 }), branches: arr(BranchSchemaV4) })),
},
},
},
});
/** `Catalog` as 0a730c8a7 declared it. */
const CatalogV2 = local("p2.catalog", {
version: 2,
params: {},
members: {
sessions: arr(SessionSchemaV3),
get: method(Id, SessionSchemaV3),
create: method(
obj({
id: opt(str({ pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" })),
env: opt(union([str(), nul()])),
cwd: str(),
title: str(),
titleEdited: opt(bool()),
model: ModelRefSchema,
thinkingLevel: ThinkingLevelSchema,
}),
SessionSchemaV3,
),
activity: method(obj({ id: str(), timestamp: num(), firstUserText: opt(str()) })),
update: method(
obj({
id: str(),
cwd: opt(str()),
env: opt(union([str(), nul()])),
title: opt(str()),
pinned: opt(bool()),
archived: opt(bool()),
model: opt(ModelRefSchema),
thinkingLevel: opt(ThinkingLevelSchema),
}),
SessionSchemaV3,
),
},
});
/** `Catalog` as e88525506 declared it. */
const CatalogV3 = local("p2.catalog", {
version: 3,
params: {},
members: {
sessions: arr(SessionSchemaV4),
get: method(Id, SessionSchemaV4),
create: method(
obj({
id: opt(str({ pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" })),
env: opt(union([str(), nul()])),
cwd: str(),
title: str(),
titleEdited: opt(bool()),
model: ModelRefSchema,
thinkingLevel: ThinkingLevelSchema,
}),
SessionSchemaV4,
),
activity: method(obj({ id: str(), timestamp: num(), firstUserText: opt(str()) })),
update: method(
obj({
id: str(),
cwd: opt(str()),
env: opt(union([str(), nul()])),
title: opt(str()),
pinned: opt(bool()),
archived: opt(bool()),
model: opt(ModelRefSchema),
thinkingLevel: opt(ThinkingLevelSchema),
main: opt(int({ minimum: 1 })),
branches: opt(arr(BranchSchemaV4)),
}),
SessionSchemaV4,
),
},
});
/** `SessionTree` as e88525506 declared it. */
const SessionTreeV1 = remote("p2.tree", {
version: 1,
params: { session: param.str() },
hostAddress: sessionHost,
variants: {
owner: {
methods: {
tree: method(
undefined,
obj({
nodes: arr(
obj({
entry: int({ minimum: 1 }),
parent: union([int({ minimum: 1 }), nul()]),
role: union([literal("user"), literal("assistant")]),
text: str(),
}),
),
main: int({ minimum: 1 }),
current: union([int({ minimum: 1 }), nul()]),
}),
),
navigate: method(
obj({ entry: int({ minimum: 1 }) }),
obj({ conversation: int({ minimum: 1 }), text: union([str(), nul()]) }),
),
select: method(obj({ conversation: int({ minimum: 1 }) })),
},
},
},
});
describe("older contract versions against the current server (fake provider)", () => {
let fixture: Fixture;
let owner: Host;
let session: Session;
let branch: number;
before(async () => {
fixture = await startFixture();
owner = fixture.browser();
const sessions = await holdIn(owner, Sessions, {}, "owner");
session = await sessions.create({ cwd: fixture.workspace, title: "Versions" }, BACKGROUND_CONTEXT);
const chat = await holdIn(owner, Chat, { session: session.id, conversation: 1 }, "participant");
await chat.send({ text: "hello", requestId: "hello" }, BACKGROUND_CONTEXT);
await until(chat.view, (view) => JSON.stringify(view?.entries ?? []).includes("Echo: hello"));
chat.release();
const status = await holdIn(owner, SessionStatus, {}, "reader");
await until(status.statuses, (value) => value?.[session.id]?.tasks === 0);
status.release();
// Editing the first message: a new branch labelled "hello"; conversation 1 keeps null (the session title).
const tree = await holdIn(owner, SessionTree, { session: session.id }, "owner");
const first = (await tree.tree(BACKGROUND_CONTEXT)).nodes.find((node) => node.role === "user")!;
branch = (await tree.navigate({ entry: first.entry }, BACKGROUND_CONTEXT)).conversation;
await until(sessions.sessions, (rows) => rows.some((row) => row.id === session.id && row.branches.length === 2));
tree.release();
sessions.release();
});
after(async () => {
await fixture?.close();
});
it("binds Sessions v4: a null branch label reads as the session title", async () => {
const ref = await boundIn(owner, SessionsV4, {}, "owner");
const rows = await until(ref.sessions, (list) => list.some((row) => row.id === session.id));
const row = rows.find((each) => each.id === session.id)!;
assert.equal(row.main, branch);
assert.deepEqual(row.branches, [
{ conversation: branch, label: "hello" },
{ conversation: 1, label: "Versions" },
]);
ref.release();
});
it("binds Sessions v3: rows without main and branches, also in method results", async () => {
const ref = await boundIn(owner, SessionsV3, {}, "owner");
const rows = await until(ref.sessions, (list) => list.some((row) => row.id === session.id));
const row = rows.find((each) => each.id === session.id)!;
assert.equal(row.title, "Versions");
assert.equal("main" in row || "branches" in row, false);
const created = await ref.create({ cwd: fixture.workspace, title: "From an old tab" }, BACKGROUND_CONTEXT);
assert.equal(created.title, "From an old tab");
assert.equal("main" in created || "branches" in created, false);
ref.release();
});
it("binds SessionTree v1: the tree without labels", async () => {
const ref = await boundIn(owner, SessionTreeV1, { session: session.id }, "owner");
const tree = await ref.tree(BACKGROUND_CONTEXT);
assert.deepEqual(Object.keys(tree).sort(), ["current", "main", "nodes"]);
assert.equal(tree.main, branch);
assert.equal(tree.nodes.length, 2, "conversation 1's message and answer");
for (const node of tree.nodes) assert.deepEqual(Object.keys(node).sort(), ["entry", "parent", "role", "text"]);
ref.release();
});
it("binds Catalog v2 and v3 on the server", async () => {
const v2 = await bound(fixture.server.host.hold(CatalogV2, {}, BACKGROUND_CONTEXT));
assert.equal("branches" in (await v2.get({ id: session.id }, BACKGROUND_CONTEXT)), false);
v2.release();
const v3 = await bound(fixture.server.host.hold(CatalogV3, {}, BACKGROUND_CONTEXT));
assert.deepEqual((await v3.get({ id: session.id }, BACKGROUND_CONTEXT)).branches.at(-1), {
conversation: 1,
label: "Versions",
});
v3.release();
});
});

View file

@ -29,6 +29,7 @@ import {
defineFacet,
type Facet,
type FacetHost,
type Held,
type Host,
type HostError,
hostErrorText,
@ -172,6 +173,31 @@ export async function holdIn<C extends Contract, const V extends VariantOf<C>>(
): Promise<HeldIn<C, V & string>> {
const ref = host.hold(type, params, TEST);
await whenLive(ref);
return narrowed(ref, variant);
}
/**
* `holdIn` for an older contract version: fails, naming the state, when the reference does not bind (a version the
* provider does not accept is left `failed { waiting }` with `contract_mismatch`, where `holdIn` would wait).
*/
export async function boundIn<C extends Contract, const V extends VariantOf<C>>(
host: Pick<Host, "hold">,
type: C,
params: ParamsOf<C>,
variant: V,
): Promise<HeldIn<C, V & string>> {
return narrowed(await bound(host.hold(type, params, TEST)), variant);
}
/** `ref` once live; fails, naming the state, when it fails instead (`contract_mismatch`, say). */
export async function bound<R extends HeldRef>(ref: R): Promise<R> {
const state = await until(ref.state, (value) => value.kind === "live" || value.kind === "failed", 10_000);
assert.equal(state.kind, "live", `not bound: ${JSON.stringify(state)}`);
return ref;
}
/** A live `ref` narrowed to `variant`; rejects `invalid/member` (releasing it) for another variant. */
function narrowed<C extends Contract, V extends VariantOf<C>>(ref: Held<C>, variant: V): HeldIn<C, V & string> {
if (ref.variant !== variant) {
ref.release();
throw Object.assign(new Error(`expected variant ${String(variant)}, got ${String(ref.variant)}`), {