mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
7670c8be43
commit
0a180b049a
9 changed files with 848 additions and 337 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
312
apps/p2/test/p2/contract-versions.test.ts
Normal file
312
apps/p2/test/p2/contract-versions.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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)}`), {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue