diff --git a/apps/p2/docs/p2-notes.md b/apps/p2/docs/p2-notes.md index 521e2992c..b0e8e8103 100644 --- a/apps/p2/docs/p2-notes.md +++ b/apps/p2/docs/p2-notes.md @@ -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 diff --git a/apps/p2/docs/tree.md b/apps/p2/docs/tree.md index cabace3fd..df965bfd2 100644 --- a/apps/p2/docs/tree.md +++ b/apps/p2/docs/tree.md @@ -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)). diff --git a/apps/p2/plugins/sessions/contract.ts b/apps/p2/plugins/sessions/contract.ts index 5d8665574..0fadfbe3d 100644 --- a/apps/p2/plugins/sessions/contract.ts +++ b/apps/p2/plugins/sessions/contract.ts @@ -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; -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; + +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 diff --git a/apps/p2/plugins/sessions/server.ts b/apps/p2/plugins/sessions/server.ts index 29acca376..6b3ac2bd5 100644 --- a/apps/p2/plugins/sessions/server.ts +++ b/apps/p2/plugins/sessions/server.ts @@ -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 = { + 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 = { + 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([]); - 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([]); + 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([]); @@ -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 = (fn: (root: Ctx) => Promise): Promise => 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 = (fn: (root: Ctx) => Promise): Promise => 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 }, + ); }); diff --git a/apps/p2/plugins/tree/contract.ts b/apps/p2/plugins/tree/contract.ts index 139033ae4..2e0e2ad6c 100644 --- a/apps/p2/plugins/tree/contract.ts +++ b/apps/p2/plugins/tree/contract.ts @@ -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; /** 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; -/** 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()), +); diff --git a/apps/p2/plugins/tree/worker.ts b/apps/p2/plugins/tree/worker.ts index 33e5a2b86..2977dab6e 100644 --- a/apps/p2/plugins/tree/worker.ts +++ b/apps/p2/plugins/tree/worker.ts @@ -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 = { + 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). */ diff --git a/apps/p2/src/api/v1/index.ts b/apps/p2/src/api/v1/index.ts index 08ab7618f..9c3ddc9d8 100644 --- a/apps/p2/src/api/v1/index.ts +++ b/apps/p2/src/api/v1/index.ts @@ -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, diff --git a/apps/p2/test/p2/contract-versions.test.ts b/apps/p2/test/p2/contract-versions.test.ts new file mode 100644 index 000000000..ce8d9c6bc --- /dev/null +++ b/apps/p2/test/p2/contract-versions.test.ts @@ -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(); + }); +}); diff --git a/apps/p2/test/p2/helpers.ts b/apps/p2/test/p2/helpers.ts index bea3a13c1..765484350 100644 --- a/apps/p2/test/p2/helpers.ts +++ b/apps/p2/test/p2/helpers.ts @@ -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>( ): Promise> { 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>( + host: Pick, + type: C, + params: ParamsOf, + variant: V, +): Promise> { + 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(ref: R): Promise { + 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>(ref: Held, variant: V): HeldIn { if (ref.variant !== variant) { ref.release(); throw Object.assign(new Error(`expected variant ${String(variant)}, got ${String(ref.variant)}`), {