From 20d86c3657e90e857309d908b13adcf7fb8d9611 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 5 Nov 2026 18:07:24 +0000 Subject: [PATCH] feat(p2): Chord v2 cleanup pass p2 rewritten by the Chord v2 authoring rules (decision "App authoring rules confirmed by the p2 audit"), in four parts merged together: - core: session lifecycle (relocate, clone, fork, archive) and search rebuild are persisted jobs on a new server store; Sessions v6 with client-chosen ids and visible operations; Retry answers with the worker's real outcome; workers push branch, subagent and status state to the server; roots take the method's ctx. - services: models, envs, ports, login, mcp, logs, sharing and voice follow external state (self.follow, followNative) instead of copy loops; methods answer after their state commits; presence from host.references. - chat: chat mode, artifacts and prompt resources follow Durable and native state; shell commands are Chord jobs (shellId is the requestId); status pushed before send and stop answer; race-free draft claim. - ui: optimistic updates on web (useOptimistic) and terminal (TuiScope.optimistic, extended in Chord with a reducer and a pending flag), pending states for long work, visible rollback, load waterfalls removed; merged with the one composer and immediate first send (ccff9876e) and the folder + button (5a31a6d5f). docs/optimistic-ui.md describes the pattern. --- apps/p2/docs/handoff.md | 1 + apps/p2/docs/mcp-durable.md | 4 +- apps/p2/docs/optimistic-ui.md | 137 ++++ apps/p2/docs/p2-notes.md | 127 ++-- apps/p2/docs/p2-tui-port.md | 21 +- apps/p2/docs/plugin-hooks.md | 2 +- apps/p2/docs/tree.md | 7 +- apps/p2/plugins/artifacts/worker.ts | 67 +- apps/p2/plugins/chat-mode/contract.ts | 2 +- apps/p2/plugins/chat-mode/tui.ts | 59 +- apps/p2/plugins/chat-mode/web.tsx | 55 +- apps/p2/plugins/chat-mode/worker.ts | 132 ++-- apps/p2/plugins/chat/contract.ts | 47 +- apps/p2/plugins/chat/session-start.ts | 24 +- apps/p2/plugins/chat/shell.ts | 305 +++++++++ apps/p2/plugins/chat/tui.ts | 407 ++++++----- apps/p2/plugins/chat/web.tsx | 236 ++++--- apps/p2/plugins/chat/worker.ts | 396 +++-------- apps/p2/plugins/codemode/calls.ts | 32 +- apps/p2/plugins/codemode/worker.ts | 4 +- apps/p2/plugins/conversations/server.ts | 14 +- apps/p2/plugins/conversations/web.tsx | 8 +- apps/p2/plugins/conversations/worker.ts | 40 +- apps/p2/plugins/devices/server.ts | 13 +- apps/p2/plugins/drafts/worker.ts | 13 +- apps/p2/plugins/envs/EnvsPanel.tsx | 65 +- apps/p2/plugins/envs/server.ts | 48 +- apps/p2/plugins/envs/store.ts | 2 +- apps/p2/plugins/envs/worker.ts | 9 - apps/p2/plugins/file-index/worker.ts | 14 +- apps/p2/plugins/history/tools.ts | 27 +- apps/p2/plugins/history/worker.ts | 7 +- apps/p2/plugins/locations/worker.ts | 7 +- apps/p2/plugins/login/contract.ts | 5 +- apps/p2/plugins/login/logins.ts | 97 ++- apps/p2/plugins/login/server.ts | 148 +--- apps/p2/plugins/login/tui.ts | 5 +- apps/p2/plugins/login/web.tsx | 91 ++- apps/p2/plugins/logs/server.ts | 36 +- apps/p2/plugins/logs/tui.ts | 38 +- apps/p2/plugins/logs/web.tsx | 13 +- apps/p2/plugins/mcp/config.ts | 38 +- apps/p2/plugins/mcp/routes.ts | 26 +- apps/p2/plugins/mcp/server.ts | 192 +++--- apps/p2/plugins/mcp/tui.ts | 84 +-- apps/p2/plugins/mcp/web.tsx | 79 ++- apps/p2/plugins/mcp/worker.ts | 5 +- apps/p2/plugins/models/server.ts | 53 +- apps/p2/plugins/models/tui.ts | 221 +++--- apps/p2/plugins/models/worker.ts | 33 +- apps/p2/plugins/ports/PortsPanel.tsx | 107 +-- apps/p2/plugins/ports/server.ts | 29 +- apps/p2/plugins/ports/tui.ts | 144 ++-- apps/p2/plugins/ports/worker.ts | 2 +- apps/p2/plugins/powershell/worker.ts | 10 +- apps/p2/plugins/prompt/tui.ts | 9 +- apps/p2/plugins/prompt/worker.ts | 129 ++-- apps/p2/plugins/search/contract.ts | 36 +- apps/p2/plugins/search/server.ts | 148 ++-- apps/p2/plugins/search/tui.ts | 57 +- apps/p2/plugins/search/worker.ts | 18 +- apps/p2/plugins/sessions/BranchSheet.tsx | 17 +- apps/p2/plugins/sessions/SessionOptions.tsx | 65 -- apps/p2/plugins/sessions/SessionSheet.tsx | 15 + apps/p2/plugins/sessions/contract.ts | 108 ++- apps/p2/plugins/sessions/copy.ts | 15 +- apps/p2/plugins/sessions/server.ts | 647 +++++++++++++----- apps/p2/plugins/sessions/tui.ts | 238 +++++-- apps/p2/plugins/sessions/web.tsx | 196 +++++- apps/p2/plugins/sessions/worker.ts | 31 +- apps/p2/plugins/sharing/contract.ts | 9 +- apps/p2/plugins/sharing/db.ts | 39 +- apps/p2/plugins/sharing/server.ts | 140 ++-- apps/p2/plugins/sharing/tui.ts | 11 +- apps/p2/plugins/sharing/web.tsx | 17 +- apps/p2/plugins/status/contract.ts | 37 +- apps/p2/plugins/status/server.ts | 121 ++-- apps/p2/plugins/status/tui.ts | 4 +- apps/p2/plugins/status/worker.ts | 75 +- apps/p2/plugins/subagents/tui.ts | 56 +- apps/p2/plugins/subagents/worker.ts | 140 ++-- apps/p2/plugins/terminals/server.ts | 8 +- apps/p2/plugins/terminals/worker.ts | 2 +- apps/p2/plugins/tree/worker.ts | 53 +- apps/p2/plugins/voice/server.ts | 4 +- apps/p2/plugins/voice/web.tsx | 20 +- apps/p2/plugins/voice/worker.ts | 83 +-- apps/p2/plugins/workspace/server.ts | 24 +- apps/p2/plugins/workspace/tui.ts | 12 +- apps/p2/plugins/workspace/web.tsx | 32 +- apps/p2/plugins/workspace/worker.ts | 37 +- apps/p2/src/api/v1/index.ts | 8 + apps/p2/src/hosts/server.ts | 17 +- apps/p2/src/hosts/session-workers.ts | 62 +- apps/p2/src/hosts/tui-client.ts | 3 + apps/p2/src/hosts/tui-worker-runtime.ts | 43 +- apps/p2/src/hosts/tui.ts | 1 + apps/p2/src/hosts/worker.ts | 6 +- apps/p2/src/runtime/chord.ts | 31 +- apps/p2/src/runtime/refs.ts | 2 +- apps/p2/src/runtime/server.ts | 65 +- apps/p2/src/runtime/tui-context.ts | 7 + apps/p2/src/runtime/tui.ts | 108 ++- apps/p2/src/runtime/web.tsx | 97 +-- apps/p2/src/runtime/worker.ts | 9 +- apps/p2/src/web/AppLayout.tsx | 10 +- apps/p2/src/web/Drawer.tsx | 28 +- apps/p2/src/web/composer-state.tsx | 7 +- apps/p2/src/web/history.ts | 5 +- apps/p2/src/web/navigation.ts | 27 + apps/p2/src/web/style.css | 17 + apps/p2/test/fixtures/optimistic.ts | 54 ++ apps/p2/test/p2/chat-contracts.test.ts | 8 +- apps/p2/test/p2/chat-mode-host.ts | 3 + apps/p2/test/p2/chat-mode.test.ts | 20 +- apps/p2/test/p2/contract-versions.test.ts | 52 +- apps/p2/test/p2/drafts.test.ts | 11 + apps/p2/test/p2/envs.test.ts | 4 +- apps/p2/test/p2/fork-clone.test.ts | 32 +- apps/p2/test/p2/helpers.ts | 12 +- apps/p2/test/p2/lifecycle.test.ts | 7 +- apps/p2/test/p2/login.test.ts | 29 +- apps/p2/test/p2/logs.test.ts | 3 +- apps/p2/test/p2/m1.test.ts | 6 +- apps/p2/test/p2/mcp-sign-in.test.ts | 9 +- apps/p2/test/p2/mcp.test.ts | 7 +- apps/p2/test/p2/plugins.test.ts | 3 +- apps/p2/test/p2/ports.test.ts | 10 + apps/p2/test/p2/rebuild-index.test.ts | 9 +- apps/p2/test/p2/relocate.test.ts | 21 +- apps/p2/test/p2/session-operations.test.ts | 106 +++ apps/p2/test/p2/session-start.test.ts | 37 +- apps/p2/test/p2/sharing.test.ts | 8 +- apps/p2/test/p2/shell.test.ts | 18 +- apps/p2/test/p2/subagents.test.ts | 7 +- apps/p2/test/p2/test-host.test.ts | 14 +- apps/p2/test/p2/tui-fork.test.ts | 29 +- apps/p2/test/p2/tui-location.test.ts | 11 +- apps/p2/test/p2/tui-optimistic.test.ts | 262 +++++++ apps/p2/test/p2/tui-p4.test.ts | 16 +- apps/p2/test/p2/tui-render.test.ts | 6 +- apps/p2/test/p2/tui-tree.test.ts | 2 + apps/p2/test/p2/tui.test.ts | 2 + apps/p2/test/p2/ui-optimistic.test.ts | 42 ++ apps/p2/test/p2/ui-sheets.test.ts | 2 +- apps/p2/test/p2/voice.test.ts | 7 +- packages/chord/docs/design.md | 5 +- .../chord/docs/guide-plugins-hosts/book.md | 13 +- .../chord/docs/guide-plugins-hosts/index.html | 13 +- packages/chord/src/tui/README.md | 5 +- packages/chord/src/tui/index.ts | 2 +- packages/chord/src/tui/scope.ts | 47 +- .../test/examples/63-optimistic-answers.ts | 4 +- packages/chord/test/tui/scope.test.ts | 46 +- 154 files changed, 4996 insertions(+), 3002 deletions(-) create mode 100644 apps/p2/docs/optimistic-ui.md create mode 100644 apps/p2/plugins/chat/shell.ts delete mode 100644 apps/p2/plugins/sessions/SessionOptions.tsx create mode 100644 apps/p2/test/fixtures/optimistic.ts create mode 100644 apps/p2/test/p2/session-operations.test.ts create mode 100644 apps/p2/test/p2/tui-optimistic.test.ts create mode 100644 apps/p2/test/p2/ui-optimistic.test.ts diff --git a/apps/p2/docs/handoff.md b/apps/p2/docs/handoff.md index 9bdb83cb9..230f9be81 100644 --- a/apps/p2/docs/handoff.md +++ b/apps/p2/docs/handoff.md @@ -173,6 +173,7 @@ From `AGENTS.md` and this project's history: | `README.md`, `DEV.md` | running, deployment behind a proxy, TUI sign-in and isolation | | `docs/p2-notes.md` | decisions, configuration, plugin-by-plugin notes | | `docs/p2-tui-port.md` | TUI design, as-built notes, open phases | +| `docs/optimistic-ui.md` | optimistic UI: the pattern for actions, pending states and loads (web and terminal) | | `docs/workflows.md` | code mode workflows with one-shot agents | | `docs/tree.md` | `/tree`: branches of the main conversation as Durable forks | | `docs/remote-envs.md` | SSH environments | diff --git a/apps/p2/docs/mcp-durable.md b/apps/p2/docs/mcp-durable.md index 4792570eb..fab382895 100644 --- a/apps/p2/docs/mcp-durable.md +++ b/apps/p2/docs/mcp-durable.md @@ -225,8 +225,8 @@ export class RemoteProcess { (`mcpNamespace(name)|url`). The refresh lock is a promise chain per server in the process, not `proper-lockfile`: one p2 server owns a data directory (process lock), and nothing else reads this file. - **Flows:** `sign-in.ts` (`SignIns`, a native of the manager), in memory, one per server (a new one replaces it), - 10 minutes each; listed (`signIns`: id, server, `via: "loopback" | "route"`, `waiting | failed`, error) in the - manager's `Listed` document. Not a Chord flow object: the manager's methods are `beginSignIn`, `completeSignIn` + 10 minutes each; listed (`signIns`: id, server, `via: "loopback" | "route"`, `waiting | failed`, error) as the + manager follows them (`self.follow`: a method's change is listed when it answers). Not a Chord flow object: the manager's methods are `beginSignIn`, `completeSignIn` (callback route), `pasteSignIn`, `cancelSignIn`, `signOut`, `probe`. A done flow disappears; the instances of its server restart (`Instances.reconnect`). A pre-registered client (`oauth.clientId`) always uses loopback. - **Links by server, not flow:** `GET /mcp/sign-in/` begins (or replaces) the server's flow, so the link is diff --git a/apps/p2/docs/optimistic-ui.md b/apps/p2/docs/optimistic-ui.md new file mode 100644 index 000000000..9c6d49aa9 --- /dev/null +++ b/apps/p2/docs/optimistic-ui.md @@ -0,0 +1,137 @@ +# Optimistic UI in p2 + +Every action whose effect the user waits to see shows that effect at once: the switch flips, the row is renamed, the +message is in the transcript. This page is the pattern p2's plugins follow, on the web and in the terminal, and the +reference for plugin authors. + +## Why it works without flicker + +Two rules make an optimistic value safe to drop the moment its call settles: + +1. **A method that should feel instant awaits its own effects** and keeps them on the host that answers (Chord's + guide; decision "App authoring rules confirmed by the p2 audit"). `Sessions.rename` commits the catalog before it + returns; `ChatMode.set` returns once the followed Durable document changed. +2. **A call's answer follows its state** (Chord decision of 2026-11-05): before a host sends a `result`, it sends the + page the `state` it committed. When the call resolves on the page, the new value is already there. + +So the page shows a guess while the call is in flight and simply forgets it when the call settles: on success the +real value has replaced it in the same frame; on failure the real value is the old one, so the guess rolls back by +itself, and the error is shown. Nothing on the page reconciles a guess with the server's value, compares ids or +waits for state after an answer. + +A method that returns before its effect (a background job, a native that reports later) breaks rule 1: its guess +would vanish before the real value arrives. Such work shows a **pending state** instead (below), never a guess of +its outcome. + +## Web: `useOptimisticCall` + +`useOptimisticCall(value, apply, onError)` (`src/runtime/web.tsx`, from `@p2/app/v1/web`) is React's +`useOptimistic` plus a transition: it returns `[shown, run, pending]`. + +```tsx +// plugins/chat-mode/web.tsx: the chat mode chip +const { fail } = useComposerState(); // the app's toast +const [{ enabled, name }, guess] = useOptimisticCall>( + { enabled: useReadable(mode?.enabled) ?? false, name: useReadable(mode?.name) ?? DEFAULT_AGENT_NAME }, + (shown, change) => ({ ...shown, ...change }), + fail, +); +// ... + @@ -142,7 +147,7 @@ function ChatModeChip() { {renaming && ( mode.rename({ name: next }, BACKGROUND_CONTEXT)} + onSave={(next) => guess({ name: next }, () => mode.rename({ name: next }, BACKGROUND_CONTEXT))} onClose={() => setRenaming(false)} /> )} diff --git a/apps/p2/plugins/chat-mode/worker.ts b/apps/p2/plugins/chat-mode/worker.ts index 8a999710d..9d15f2c94 100644 --- a/apps/p2/plugins/chat-mode/worker.ts +++ b/apps/p2/plugins/chat-mode/worker.ts @@ -9,8 +9,9 @@ import { type EntryId, type EntryRecord, InboxDoc, + type InboxState, } from "@earendil-works/pi-durable"; -import { bool, num, obj } from "@earendil-works/pi-schema"; +import { num, obj } from "@earendil-works/pi-schema"; import { type Context, type Ctx, @@ -18,17 +19,15 @@ import { contribute, defineFacet, defineState, + deriveTyped, forbidden, invalid, isOwner, - signalOf, } from "@p2/app/v1"; -import { durableContext } from "@p2/app/v1/node"; -import { agentConversation, CommitSignal, harnessOf } from "@p2/app/v1/worker"; +import { agentConversation, harnessOf } from "@p2/app/v1/worker"; import { Conversation, SendRouters } from "../chat/contract.ts"; import { Attachments, MAX_ATTACHMENTS } from "../files/contract.ts"; import { - AgentNameSchema, CHAT_ENTRY, type ChatMessage, ChatMode, @@ -57,16 +56,11 @@ export const ChatModeDoc = defineDoc<{ enabled: boolean; name: string }>({ /** A conversation's newest entries, newest first, until `unmentioned` has what it needs (at most `limit`). */ async function recentEntries(durable: DurableHarness, id: ConversationId, ctx: Context, limit = 2000) { - const conversation = await durable.conversation(id, durableContext(ctx)); + const conversation = await durable.conversation(id, ctx); const entries: EntryRecord[] = []; let before = Number.MAX_SAFE_INTEGER; while (conversation && entries.length < limit && !readEnough(entries)) { - const page = await conversation.entries( - { maxEntryId: (before - 1) as EntryId }, - 256, - undefined, - durableContext(ctx), - ); + const page = await conversation.entries({ maxEntryId: (before - 1) as EntryId }, 256, undefined, ctx); for (const entry of page.items) { entries.push(entry); before = entry.id; @@ -81,20 +75,20 @@ async function recentEntries(durable: DurableHarness, id: ConversationId, ctx: C * conversation's own entries, so an ID from another conversation is not found. */ export async function replyTarget(durable: DurableHarness, id: ConversationId, entryId: number, ctx: Context) { - const conversation = await durable.conversation(id, durableContext(ctx)); + const conversation = await durable.conversation(id, ctx); const only = { minEntryId: entryId as EntryId, maxEntryId: entryId as EntryId }; - const page = await conversation?.entries(only, 1, undefined, durableContext(ctx)); + const page = await conversation?.entries(only, 1, undefined, ctx); const entry = page?.items[0]; const quote: ReplyTo | undefined = entry?.id === entryId ? quoteOf(entry) : undefined; if (!quote) throw invalid("args", "That message cannot be replied to"); return quote; } -/** What the cells show of the document, and how many messages wait for a mention. */ -const Shown = defineState("p2.chat-mode-setting", { - schema: obj({ enabled: bool(), name: AgentNameSchema, waiting: num() }), +/** How many chat messages a mention would pass on now: computed from the transcript, which the view only windows. */ +const Waiting = defineState("p2.chat-mode-setting.waiting", { + schema: obj({ count: num() }), version: 1, - initial: () => ({ enabled: false, name: DEFAULT_AGENT_NAME, waiting: 0 }), + initial: () => ({ count: 0 }), }); export default defineFacet((host) => { @@ -103,73 +97,47 @@ export default defineFacet((host) => { ({ conversation }, self) => { const id = conversation as ConversationId; const harness = harnessOf(self); - /** Where the agent's chat mode is (the main agent's on conversation 1); set when the object opens. */ + // The conversation's view (shared with its viewers): its entries and queue change what waits for a mention. + const chat = self.get(Conversation, { conversation }); + /** Where the agent's chat mode is (the main agent's on conversation 1); found when the document is followed. */ let holder = id; - const read = async (ctx: Context) => - (await harness().durable.snapshot(ChatModeDoc, holder, durableContext(ctx))) ?? { - enabled: false, - name: DEFAULT_AGENT_NAME, - }; - const shown = self.state(Shown); - const commits = self.open( - async (ctx) => { - const durable = harness().durable; - holder = await agentConversation(durable, id, durableContext(ctx)); - const doc = await read(ctx); - await shown.change((draft) => { - draft.enabled = doc.enabled; - draft.name = doc.name; + const settings = self.follow(async (observer, ctx) => { + const durable = harness().durable; + holder = await agentConversation(durable, id, ctx); + const observed = await durable.observeDoc(ChatModeDoc, holder, observer, ctx); + if (observed !== undefined) return observed; + // Nobody switched it yet: the document is absent, and an absent address cannot be followed. Create it as it + // reads (off, the default name). + await durable.commit(async (tx) => { + await tx.doc(ChatModeDoc, holder); + }, ctx); + return durable.observeDoc(ChatModeDoc, holder, observer, ctx); + }); + const waiting = self.state(Waiting); + const counted = deriveTyped(self, [settings, chat.view], ([doc, view]) => ({ + enabled: doc.enabled, + newest: view?.entries.at(-1)?.id ?? 0, + inbox: (view?.docs[InboxDoc.definition.kind] ?? null) as InboxState | null, + })); + // The view holds only a window of the entries: the router's backwards scan counts what waits. + self.on(counted, async ({ value }, ctx) => { + const count = value.enabled + ? unmentioned(await recentEntries(harness().durable, id, ctx), value.inbox ?? undefined).length + : 0; + if (waiting.value.count !== count) + await waiting.change((draft) => { + draft.count = count; }, ctx); - if (!(await durable.conversation(id, ctx))) throw new Error(`Unknown conversation ${conversation}`); - // The waiting count also follows the entries and the queue. - return new CommitSignal(durable, (publication) => - publication.changes.some( - (change) => - (change.type === "entry" && change.value.conversationId === id) || - (change.type === "document" && - change.record.kind === ChatModeDoc.definition.kind && - change.conversationId === holder) || - (change.type === "document" && - change.record.kind === InboxDoc.definition.kind && - change.conversationId === id), - ), - ); - }, - (signal) => signal.close(), - ); - // The document is the truth; the cells show it. Subscribed before reading, so no commit is missed. The view - // holds only a window of the entries, so the router's backwards scan counts what waits, while chat mode is on. - self.onLive(async (ctx) => { - const signal = signalOf(ctx); - while (!signal.aborted) { - const seen = commits.value.version; - const doc = await read(ctx); - const durable = harness().durable; - const count = doc.enabled - ? unmentioned( - await recentEntries(durable, id, ctx), - await durable.snapshot(InboxDoc, id, durableContext(ctx)), - ).length - : 0; - const current = shown.value; - if (current.enabled !== doc.enabled || current.name !== doc.name || current.waiting !== count) - await shown.change((draft) => { - draft.enabled = doc.enabled; - draft.name = doc.name; - draft.waiting = count; - }, ctx); - await commits.value.next(seen, signal).catch(() => {}); - } }); const commit = async (ctx: Ctx, change: (doc: { enabled: boolean; name: string }) => void) => { - const durable = await harness().durable.conversation(id, durableContext(ctx)); + const durable = await harness().durable.conversation(id, ctx); if (!durable) throw invalid("args", `Unknown conversation ${conversation}`); - await durable.commit(async (tx) => change(await tx.doc(ChatModeDoc, holder)), durableContext(ctx)); + await durable.commit(async (tx) => change(await tx.doc(ChatModeDoc, holder)), ctx); }; return { - enabled: self.derive([shown], ([value]) => value.enabled), - name: self.derive([shown], ([value]) => value.name), - waiting: self.derive([shown], ([value]) => value.waiting), + enabled: self.derive([settings], ([doc]) => doc.enabled), + name: self.derive([settings], ([doc]) => doc.name), + waiting: self.derive([waiting], ([value]) => value.count), set: async ({ enabled: on }, ctx) => { await commit(ctx, (doc) => { doc.enabled = on; @@ -203,11 +171,7 @@ export default defineFacet((host) => { "chat-mode": async ({ conversation, author, text, requestId, whenBusy, attachments, replyTo }, ctx) => { const durable = harness().durable; const id = conversation as ConversationId; - const settings = await durable.snapshot( - ChatModeDoc, - await agentConversation(durable, id, durableContext(ctx)), - durableContext(ctx), - ); + const settings = await durable.snapshot(ChatModeDoc, await agentConversation(durable, id, ctx), ctx); if (!settings?.enabled) return undefined; const chat = await ctx.get(Conversation, { conversation }); // The client names the entry; whom it quotes and what comes from the entry. @@ -222,7 +186,7 @@ export default defineFacet((host) => { }; return chat.write({ kind: CHAT_ENTRY, data, requestId, author }, ctx); } - const inbox = await durable.snapshot(InboxDoc, id, durableContext(ctx)); + const inbox = await durable.snapshot(InboxDoc, id, ctx); const earlier = unmentioned(await recentEntries(durable, id, ctx), inbox); const files = await resolve(digestFiles(earlier, attachments), conversation, ctx); const own = files.filter((file) => attachments.includes(file.id)); diff --git a/apps/p2/plugins/chat/contract.ts b/apps/p2/plugins/chat/contract.ts index c2617b158..7af4435ee 100644 --- a/apps/p2/plugins/chat/contract.ts +++ b/apps/p2/plugins/chat/contract.ts @@ -83,25 +83,23 @@ const ConversationConfigureSchema = obj({ const RestoredSchema = arr(obj({ author: AuthorSchema, text: str() })); -/** - * The conversation's user shell command (`!cmd`/`!!cmd`) while it runs and until its `p2.shell` entry is placed: - * the tail of its output (src/runtime/shell.ts limits). Null when none. - */ -export const ShellStateSchema = union([ - obj({ - id: str(), - command: str(), - author: AuthorSchema, - includeInContext: bool(), - output: str(), - truncated: bool(), - running: bool(), - exitCode: opt(num()), - cancelled: bool(), - error: opt(str()), - }), - nul(), -]); +/** A user shell command (`!cmd`/`!!cmd`) while it runs and until its `p2.shell` entry is placed. */ +export const LiveShellSchema = obj({ + id: str(), + command: str(), + author: AuthorSchema, + includeInContext: bool(), + /** The tail of its output (src/runtime/shell.ts limits). */ + output: str(), + truncated: bool(), + running: bool(), + exitCode: opt(num()), + cancelled: bool(), + error: opt(str()), +}); +export type LiveShell = Static; +/** The conversation's user shell command; null when none. */ +export const ShellStateSchema = union([LiveShellSchema, nul()]); export type ShellState = Static; /** pi's text when a second command is started while one runs (interactive-mode `isBashRunning`). */ export const SHELL_BUSY = "A bash command is already running. Press Esc to cancel it first."; @@ -109,7 +107,7 @@ export const ShellSchema = obj({ command: str({ minLength: 1, maxLength: 100_000 }), /** `!` (true): the output joins the agent's context; `!!` (false): display only. */ includeInContext: bool(), - /** Client-chosen; a retried call with the same ID runs once (scoped to the author). */ + /** Client-chosen, the command's `shellId` (`liveShell.id`); a retried call with the same ID runs once (per author). */ requestId: str({ minLength: 1, maxLength: 200 }), }); @@ -165,7 +163,8 @@ export type Submit = Static; * watch, and the operations that need an author, which callers pass explicitly. */ export const Conversation = local("p2.conversation", { - version: 1, + /** 2: user shell commands moved to `ShellCommands` (chat/shell.ts). */ + version: 2, params: { conversation: param.int() }, members: { /** The view's window, as every viewer holds it. */ @@ -202,12 +201,6 @@ export const Conversation = local("p2.conversation", { abortCompaction: method(undefined, obj({ aborted: int({ minimum: 0 }) })), /** Withdraws `authorId`'s queued inputs (by the server-stamped author) in submission order. */ dequeueOwn: method(obj({ authorId: str() }), obj({ restored: RestoredSchema, skipped: int({ minimum: 0 }) })), - /** The running (or just finished, not yet placed) user shell command. */ - shell: ShellStateSchema, - /** Runs a user shell command on the conversation's environment; one at a time per conversation. */ - runShell: method(obj({ ...propertiesOf(ShellSchema), author: AuthorSchema }), obj({ shellId: str() })), - /** Cancels the running command `shellId`; false when it is not the running one. */ - abortShell: method(obj({ shellId: str() }), obj({ aborted: bool() })), }, }); diff --git a/apps/p2/plugins/chat/session-start.ts b/apps/p2/plugins/chat/session-start.ts index d911f59b5..4ad48ae7c 100644 --- a/apps/p2/plugins/chat/session-start.ts +++ b/apps/p2/plugins/chat/session-start.ts @@ -4,16 +4,21 @@ * exists, the messages move to its main conversation's outbox, which delivers them (uploads, template expansion, * retries), and the page opens the session. * - * One start per page, kept for the page's lifetime: messages sent while the session is being created join it. A - * creation the server refuses (a directory that does not exist, an unknown model) puts the messages back into the - * composer; a failure of the connection is tried again, at most `GIVE_UP` times while connected. A creation whose answer - * was lost with the connection is tried again too and may leave an empty session behind. + * One start per page, kept for the page's lifetime: messages sent while the session is being created join it. The page + * chooses the session's ID at the first Send (`Sessions.create` takes it), so a creation whose answer was lost with + * the connection is tried again with the same ID and answers the same session. A creation the server refuses (a + * directory that does not exist, an unknown model) puts the messages back into the composer; a failure of the + * connection is tried again, at most `GIVE_UP` times while connected. */ import type { ModelChoice } from "@p2/app/v1/web"; import { GIVE_UP, type Outgoing, outboxOf, retryDelay } from "./outbox.ts"; -/** Where and with which model the session starts (as chosen at the first Send), and the messages it starts with. */ +/** + * The session's ID, where and with which model it starts (as chosen at the first Send), and the messages it starts + * with. + */ export type Start = { + readonly session: string; readonly location: { readonly env: string | null; readonly cwd: string }; readonly choice: ModelChoice; readonly messages: readonly Outgoing[]; @@ -21,8 +26,8 @@ export type Start = { /** How the connected new-session screen creates the session, and what it does with the outcome. */ export type Creator = { - /** Creates the session; resolves with its ID. */ - create(start: Start): Promise; + /** Creates the session `start.session` (again, after a lost answer: the server answers the same row). */ + create(start: Start): Promise; /** Whether `error` refuses the creation for good; anything else is retried. */ refused(error: unknown): boolean; /** The session exists and its main conversation's outbox has the messages. */ @@ -51,7 +56,7 @@ export class SessionStart { }; snapshot = (): Start | undefined => this.#start; - /** Adds `message`; the first one's `place` decides where and with which model the session starts. */ + /** Adds `message`; the first one's `place` decides the session's ID, where and with which model it starts. */ add(message: Outgoing, place: Omit): void { this.#set( this.#start @@ -83,7 +88,8 @@ export class SessionStart { this.#running = true; clearTimeout(this.#retry); try { - const session = await creator.create(start); + await creator.create(start); + const { session } = start; this.#attempt = 0; // Read after the creation: messages sent meanwhile go with the first. const messages = this.#start?.messages ?? []; diff --git a/apps/p2/plugins/chat/shell.ts b/apps/p2/plugins/chat/shell.ts new file mode 100644 index 000000000..d751df267 --- /dev/null +++ b/apps/p2/plugins/chat/shell.ts @@ -0,0 +1,305 @@ +// User shell commands (`!cmd`/`!!cmd`) of the worker's conversations: each command is a job whose state shows its +// output while it runs; the run places the command's `p2.shell` entry before it ends, so the live block gives way to +// the entry without a gap. One command at a time per conversation; the session neither moves nor clones while one runs. + +import type { ConversationId, Harness as DurableHarness } from "@earendil-works/pi-durable"; +import { bool, int, map, nul, num, obj, opt, type Static, str, union } from "@earendil-works/pi-schema"; +import { + authorSender, + defineJob, + defineState, + defineTypedState, + deriveTyped, + type FacetHost, + type JobCtx, + type JsonValue, + literal, + local, + method, + type P2Principal, + SHELL_ENTRY, + SHELL_MAX_BYTES, + SHELL_MAX_LINES, + type ShellData, + senderText, + shellContextText, + shellTail, + until, + withoutAbortSignal, +} from "@p2/app/v1"; +import { conversationEnvironment, type ExecutionEnvironments, harnessOf, workerOf } from "@p2/app/v1/worker"; +import { type LiveShell, LiveShellSchema, SHELL_BUSY } from "./contract.ts"; + +const CommandSchema = obj({ + conversation: int({ minimum: 1 }), + command: str({ minLength: 1, maxLength: 100_000 }), + /** `!` (true): the output joins the agent's context; `!!` (false): display only. */ + includeInContext: bool(), + /** Closed, unlike the stored entry's author: job arguments are checked like method arguments. */ + author: obj({ kind: union([literal("owner"), literal("guest")]), id: str(), name: str() }), + /** The caller's ID for the command, its `shellId`: a client shows the command under it before the state arrives. */ + requestId: str({ minLength: 1, maxLength: 200 }), +}); +type Command = Static; + +/** + * The worker's user shell commands (host-internal): each conversation's running command, and how to start and cancel + * one. Chat's views show their conversation's command and pass the caller as its author. + */ +export const ShellCommands = local("p2.shell-commands", { + version: 1, + params: {}, + members: { + /** The running (or finished, its entry not placed yet) command of each conversation, by conversation. */ + shells: map(LiveShellSchema), + /** + * Starts a command; refuses while its conversation runs one (`SHELL_BUSY`). Its `shellId` is the caller's + * `requestId`; a retried `requestId` of the same author starts nothing (among the last 100 starts). + */ + start: method(CommandSchema, obj({ shellId: str() })), + /** Cancels the running command `shellId`; resolves once it shows as cancelled, false when it is not running. */ + abort: method(obj({ shellId: str() }), obj({ aborted: bool() })), + }, +}); + +/** How often the live output is published at most (the bash tool's sampling, 100 KiB/s). */ +const SAMPLE_MS = 200; + +const OutputSchema = obj({ + output: str(), + truncated: bool(), + running: bool(), + cancelled: bool(), + exitCode: opt(num()), + /** Where the environment kept the complete output, when it spilled. */ + fullOutputPath: opt(str()), + error: opt(str()), +}); +type Output = Static; + +/** A command's live output: the run's state document. */ +const OutputDoc = defineTypedState("p2.shell-commands.output", { + schema: OutputSchema, + version: 1, + initial: () => ({ output: "", truncated: false, running: true, cancelled: false }), +}); + +/** 2: the command's `requestId` (its `shellId`). */ +const ShellJob = defineJob("p2.shell-commands.run", { + version: 2, + args: CommandSchema, + result: nul(), + state: OutputDoc, +}); + +type RunCtx = JobCtx; + +/** Request keys (`author:requestId`) of recent starts and the command each started, oldest first. */ +const Admitted = defineState("p2.shell-commands.admitted", { + schema: obj({ starts: map(str()) }), + version: 1, + initial: () => ({ starts: {} }), +}); + +const MAX_ADMITTED = 100; + +/** Serves `ShellCommands` (the chat worker facet). */ +export function serveShellCommands(host: FacetHost): void { + host.serve(ShellCommands, (_params, self) => { + const harness = harnessOf(self); + const worker = workerOf(self); + const admitted = self.state(Admitted); + const job = self.job(ShellJob, { + run: async (command, ctx) => { + try { + await runCommand(command, harness().environments, ctx); + await placeEntry(command, ctx, harness().durable); + return null; + } finally { + worker().liveness.add("activeCalls", -1); + } + }, + }); + const shells = deriveTyped(self, [job.running], ([runs]) => { + const byConversation: Record = {}; + for (const run of runs) + if (run.phase !== "blocked") byConversation[run.args.conversation] = liveShell(run.args, run.state); + return byConversation; + }); + const running = (shellId: string) => + Object.values(shells.value).some((shell) => shell.id === shellId && shell.running); + return { + shells, + start: async (command, ctx) => { + // A running command is work the session's fence waits for (a move, a clone), as a call is: counted from + // before its start (the caller's own call still counts) until its run ends. + worker().liveness.add("activeCalls", 1); + let started = false; + try { + const result = await self.tx(async (tx) => { + const starts = (await tx.draft(admitted)).starts; + const key = `${command.author.id}:${command.requestId}`; + if (starts[key] !== undefined) return { started: false }; + if (shells.value[command.conversation] !== undefined) throw new Error(SHELL_BUSY); + starts[key] = tx.start(job, command).id; + const keys = Object.keys(starts); + for (const old of keys.slice(0, Math.max(0, keys.length - MAX_ADMITTED))) delete starts[old]; + return { started: true }; + }, ctx); + started = result.started; + return { shellId: command.requestId }; + } finally { + if (!started) worker().liveness.add("activeCalls", -1); + } + }, + abort: async ({ shellId }, ctx) => { + if (!running(shellId)) return { aborted: false }; + // `abort` resolves once the run ended, after placing its entry (at the next boundary while the agent works): + // the caller waits only until the command shows as cancelled. + const run = job.running.value.find((each) => each.phase !== "blocked" && each.args.requestId === shellId); + if (run) await Promise.race([job.abort(run.id, ctx), until(shells, () => !running(shellId), ctx)]); + return { aborted: true }; + }, + }; + }); +} + +/** A run as Chat shows it. */ +function liveShell(command: Command, state: Output): LiveShell { + return { + id: command.requestId, + command: command.command, + author: command.author, + includeInContext: command.includeInContext, + output: state.output, + truncated: state.truncated, + running: state.running, + cancelled: state.cancelled, + ...(state.exitCode === undefined ? {} : { exitCode: state.exitCode }), + ...(state.error === undefined ? {} : { error: state.error }), + }; +} + +/** + * Runs the command where its conversation works (a subagent's own location may be on another machine than the + * session's), sampling its output into the run's state, which ends with how it ended. + */ +async function runCommand(command: Command, environments: ExecutionEnvironments, ctx: RunCtx): Promise { + let output = ""; + let truncated = false; + let timer: ReturnType | undefined; + // State changes are commits: chained, so the last one is the last written, and awaited before the run moves on. + let writes: Promise = Promise.resolve(); + const write = (change: (draft: Output) => void) => { + writes = writes.then(() => ctx.state.change(change, ctx)); + }; + const sample = () => { + timer = undefined; + const [text, cut] = [output, truncated]; + write((draft) => { + draft.output = text; + draft.truncated = cut; + }); + }; + let ended: Pick = {}; + try { + const { location, env } = await conversationEnvironment( + environments, + command.conversation as ConversationId, + ctx, + ); + const result = await env.exec( + command.command, + { + cwd: location.cwd, + inheritEnv: true, + spill: { afterBytes: SHELL_MAX_BYTES, afterLines: SHELL_MAX_LINES }, + window: { + maxBytes: SHELL_MAX_BYTES, + maxLines: SHELL_MAX_LINES, + minIntervalMs: SAMPLE_MS, + bytesPerSecond: 100 * 1024, + }, + onOutput: (text, _context, info) => { + // The environment omitted output outside the kept tail: what follows replaces it. + if (info.skipped) { + output = ""; + truncated = true; + } + const tail = shellTail(output + text); + output = tail.text; + truncated ||= tail.truncated; + timer ??= setTimeout(sample, SAMPLE_MS); + }, + }, + ctx, + ); + if (result.ok) + ended = { + exitCode: result.value.exitCode, + ...(result.value.spillPath ? { fullOutputPath: result.value.spillPath } : {}), + }; + else if (result.error.code !== "aborted") + ended = { + error: result.error.code === "timeout" ? "Command timed out" : result.error.message, + ...(result.error.spillPath ? { fullOutputPath: result.error.spillPath } : {}), + }; + } catch (error) { + ended = { error: error instanceof Error ? error.message : String(error) }; + } finally { + clearTimeout(timer); + } + // A command's last newline ends its output; it is not an empty last line. + const final = output.replace(/\r?\n$/, ""); + const cancelled = ctx.abortSignal?.aborted === true; + write((draft) => { + Object.assign(draft, { output: final, truncated, running: false, cancelled, ...ended }); + }); + await writes; +} + +/** + * Places the finished command as its `p2.shell` entry and waits until it is placed. A write never starts a run; while + * the agent works it is placed at the run's next boundary. A cancelled command is placed too: without the run's abort + * signal. + */ +async function placeEntry(command: Command, ctx: RunCtx, durable: DurableHarness): Promise { + const { output, truncated, cancelled, exitCode, fullOutputPath, error } = ctx.state.value; + const data: ShellData = { + author: command.author, + command: command.command, + output, + cancelled, + truncated, + includeInContext: command.includeInContext, + ...(exitCode === undefined ? {} : { exitCode }), + ...(fullOutputPath === undefined ? {} : { fullOutputPath }), + ...(error === undefined ? {} : { error }), + }; + const context = withoutAbortSignal(ctx); + const conversation = await durable.conversation(command.conversation as ConversationId, context); + if (conversation === undefined) throw new Error(`Unknown conversation ${command.conversation}`); + const model = [ + { + role: "user" as const, + content: [ + { type: "text" as const, text: senderText(authorSender(command.author)) }, + { type: "text" as const, text: shellContextText(data) }, + ], + timestamp: Date.now(), + }, + ]; + const submission = await conversation.submit( + { + type: "write", + entry: { + kind: SHELL_ENTRY, + data: data as unknown as JsonValue, + ...(command.includeInContext ? { model } : {}), + }, + requestId: `${command.author.id}:shell:${ctx.jobId}`, + }, + context, + ); + await submission.wait(context); +} diff --git a/apps/p2/plugins/chat/tui.ts b/apps/p2/plugins/chat/tui.ts index 21bbb90c1..76cb089a2 100644 --- a/apps/p2/plugins/chat/tui.ts +++ b/apps/p2/plugins/chat/tui.ts @@ -14,8 +14,8 @@ import { truncateToWidth, VStack, } from "@earendil-works/pi-tui"; -import type { Ctx, Held, HeldIn } from "@p2/app/v1"; -import { BACKGROUND_CONTEXT, FileIndex, followRegistry, liveIn, serveKeyed, statusOf } from "@p2/app/v1"; +import type { Held, HeldIn } from "@p2/app/v1"; +import { BACKGROUND_CONTEXT, FileIndex, followRegistry, liveIn, serveKeyed, statusOf, whenLive } from "@p2/app/v1"; import { BashExecutionComponent, buildTranscript, @@ -31,6 +31,7 @@ import { identityKey, lineText, type MarkdownTransformer, + type OptimisticAdd, ordered, type Pages, pagesWatchable, @@ -67,6 +68,17 @@ import { SessionTree } from "../tree/contract.ts"; import { Chat, type EntryDisplay, SHELL_BUSY, type ShellState, TranscriptEntries } from "./contract.ts"; import { CHAT_COMMANDS, formatSize, type LauncherUpload, registerLauncherAnswers } from "./tui-commands.ts"; +/** A message sent from here that the server does not show yet: in the transcript, or the queue while a run is busy. */ +type Outgoing = { + readonly id: string; + readonly text: string; + readonly mode: "steer" | "followUp"; + readonly files: readonly LauncherUpload[]; +}; + +/** No message in flight: one array, so a transcript rebuild sees "unchanged". */ +const NO_OUTGOING: readonly Outgoing[] = []; + /** pi: a second Ctrl+C within this window exits; a second Escape runs `doubleEscapeAction`. */ const DOUBLE_CTRL_C_MS = 500; const DOUBLE_ESCAPE_MS = 500; @@ -114,7 +126,23 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi { const agent = this.view?.docs["pi.agent"] as { cwd?: unknown } | undefined; return typeof agent?.cwd === "string" ? agent.cwd : (this.rows.find((row) => row.id === this.session)?.cwd ?? ""); } - private ref: (() => Held) | undefined; + private ref: Held | undefined; + /** Messages in flight (`send`): shown at once, the editor already cleared; a refused one comes back to the editor. */ + private outgoing: () => readonly Outgoing[] = () => NO_OUTGOING; + private sending: OptimisticAdd | undefined; + /** A `!cmd` in flight: its block shows as running before the server's state does. */ + private starting: () => ShellState = () => null; + private start: OptimisticAdd | undefined; + /** A `/compact` in flight: the compaction indicator shows before `pi.live` does. */ + private compacting: () => boolean = () => false; + private compactNow: OptimisticAdd | undefined; + /** The session catalog (`/fork`, `/clone`). */ + private catalog: Held; + /** + * The viewed session's branches (`/tree`), held from its first use until another session is viewed: a switch of + * the main conversation changes the view's identity but must not release it while `navigate` is in flight. + */ + private tree: { session: string; scope: TuiScope; ref: Held } | undefined; private view: ConversationView | null | undefined; /** The conversation's user shell command while it runs (`Chat.liveShell`), and its component (the transcript tail). */ private liveShell: ShellState = null; @@ -134,9 +162,6 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi { private current: Transcript | undefined; private synced: unknown[] = []; private disposed = false; - private sending = false; - /** A send asked for while the conversation was connecting. */ - private waiting: "steer" | "followUp" | undefined; private lastCtrlC = 0; private lastEscape = 0; private indicator: StatusIndicator | undefined; @@ -279,7 +304,7 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi { ); // The session's cwd: tool paths display relative to it. this.sessions = new TuiScope(runtime); - this.sessions.watch(Sessions, {}, (ref, follow) => { + this.catalog = this.sessions.watch(Sessions, {}, (ref, follow) => { const viewer = liveIn(ref, "owner", "guest"); if (viewer) follow(viewer.sessions, (rows) => { @@ -330,7 +355,6 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi { if (identity !== this.identity) this.switchTo(identity); this.transcript.setState({ expanded: runtime.expandedTools, showThinking: runtime.showThinking }); this.sync(); - if (this.waiting && runtime.connected && this.participant()) this.send(this.waiting); } private switchTo(identity: string | undefined): void { @@ -339,14 +363,18 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi { this.scope?.dispose(); this.scope = undefined; this.ref = undefined; + this.outgoing = () => NO_OUTGOING; + this.sending = undefined; + this.starting = () => null; + this.start = undefined; + this.compacting = () => false; + this.compactNow = undefined; this.view = undefined; this.transcriptStore = undefined; this.files = undefined; this.current = undefined; this.liveShell = null; this.synced = []; - this.sending = false; - this.waiting = undefined; this.replyTo = undefined; this.transcript.reset(); this.identity = identity; @@ -359,6 +387,12 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi { const conversation = this.conversation; const scope = new TuiScope(runtime); this.scope = scope; + [this.outgoing, this.sending] = scope.optimistic( + (): readonly Outgoing[] => NO_OUTGOING, + (list, message: Outgoing) => [...list, message], + ); + [this.starting, this.start] = scope.optimistic((): ShellState => null); + [this.compacting, this.compactNow] = scope.optimistic(() => false); this.ref = scope.watch(Chat, { session, conversation }, (ref, follow) => { follow(ref.view, (view) => { if (view !== undefined) this.view = view; @@ -373,29 +407,36 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi { }); this.transcriptStore = store; follow(pagesWatchable(store), () => {}); - if (ref.variant !== "participant") return; - scope.watch(Drafts, { session, conversation }, (drafts, subscribe) => { - subscribe(drafts.drafts, (items) => { - if (items?.length) - runtime.perform( - drafts - .claim({ ids: items.map((item) => item.id) }, BACKGROUND_CONTEXT) - .then(({ texts }) => this.restore(identityKey(session, conversation), texts)), - ); - }); - }); - // `@` and Tab file completion (participants, as the workspace); a server without the plugin has none. - if (!this.files) - try { - this.files = scope.watch(FileIndex, { session, conversation }, () => {}); - } catch {} + if (ref.variant === "participant") this.holdWriterParts(scope, session, conversation); }); + // Who may write is known before the conversation is live (the role, a guest's grant): no wait for it. + if (runtime.role === "owner" || runtime.guestMode() === "full") + this.holdWriterParts(scope, session, conversation); + } + + /** + * What a writer of the conversation holds besides it: its drafts withdrawn by others (claimed back into the editor) + * and its file index (`@` and Tab completion). Once per view. + */ + private holdWriterParts(scope: TuiScope, session: string, conversation: number): void { + if (this.files) return; + scope.watch(Drafts, { session, conversation }, (drafts, subscribe) => { + subscribe(drafts.drafts, (items) => { + if (items?.length) + this.runtime.perform( + drafts + .claim({ ids: items.map((item) => item.id) }, BACKGROUND_CONTEXT) + .then(({ texts }) => this.restore(identityKey(session, conversation), texts)), + ); + }); + }); + this.files = scope.watch(FileIndex, { session, conversation }, () => {}); } /** The viewed conversation's file index once live (P7b). */ - private files: (() => Held) | undefined; + private files: Held | undefined; private fileSource(): FileSource | undefined { - const ref = liveIn(this.files?.(), "reader"); + const ref = liveIn(this.files, "reader"); return ( ref && { complete: (args) => ref.complete(args, BACKGROUND_CONTEXT), @@ -446,18 +487,23 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi { } private participant(): HeldIn | undefined { - const ref = this.ref?.(); - return liveIn(ref, "participant"); + return liveIn(this.ref, "participant"); + } + + /** Whether the reference is live in a variant that cannot write (a viewer). */ + private get readOnlyView(): boolean { + return this.ref !== undefined && statusOf(this.ref) === "live" && this.ref.variant !== "participant"; } private sync(): void { - const ref = this.ref?.(); + const ref = this.ref; const view = this.view; const pages = this.pages; - const inputs = [pages, this.displays, this.toolRenderers, this.resolvers, this.entryRenderers]; + const outgoing = this.outgoing(); + const inputs = [pages, outgoing, this.displays, this.toolRenderers, this.resolvers, this.entryRenderers]; if (inputs.length !== this.synced.length || inputs.some((input, index) => input !== this.synced[index])) { this.synced = inputs; - this.current = pages ? buildTranscript(pages.view, this.display, pages.filling) : undefined; + this.current = pages ? this.withOutgoing(buildTranscript(pages.view, this.display, pages.filling)) : undefined; const entries = (pages?.view.items ?? []).flatMap((item) => (item.kind === "entry" ? [item.entry] : [])); this.transcript.sync(this.current, entries); } @@ -505,7 +551,7 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi { /** The running command as pi's bash execution component at the end of the transcript. */ private syncShell(): void { - const live = this.liveShell; + const live = this.shownShell; if (!live) { this.shellView?.component.dispose(); this.shellView = undefined; @@ -532,32 +578,70 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi { this.transcript.tail = component; } - /** `!cmd`/`!!cmd`: runs the command (`Chat.shell`); one at a time, as pi. */ + /** The transcript with the messages in flight: at its end while idle, else in the queue (`syncPending`). */ + private withOutgoing(transcript: Transcript): Transcript { + const outgoing = this.outgoing(); + if (!outgoing.length || transcript.busy) return transcript; + const author = this.ownAuthor(); + const items = outgoing.map((message) => ({ + type: "user" as const, + key: `outgoing-${message.id}`, + text: message.text, + images: [], + files: message.files.map((file) => file.name), + author: { ...author, id: "" }, + })); + return { ...transcript, items: [...transcript.items, ...items] }; + } + + /** Who the server will record as this visitor (for messages and commands shown before it does). */ + private ownAuthor(): { kind: "owner" | "guest"; name: string } { + return this.runtime.role === "guest" + ? { kind: "guest", name: this.runtime.grant?.name ?? "Guest" } + : { kind: "owner", name: "Owner" }; + } + + /** The running command as shown: the server's, else one just started from here. */ + private get shownShell(): ShellState { + return this.liveShell ?? this.starting(); + } + + /** + * `!cmd`/`!!cmd`: runs the command (`Chat.shell`); one at a time, as pi. Its block shows as running at once and the + * editor clears; a refused command comes back to the editor with the error. + */ shell(command: string, includeInContext: boolean): void { const ref = this.participant(); const identity = this.identity; - if (!ref || !identity) { - this.runtime.fail( - this.ref?.() !== undefined && statusOf(this.ref()) === "live" - ? "This conversation is read-only" - : "Not connected yet", - ); + if (!ref || !identity || !this.start) { + this.runtime.fail(this.readOnlyView ? "This conversation is read-only" : "Not connected yet"); return; } - if (this.liveShell?.running) { + if (this.shownShell?.running) { this.runtime.notify(SHELL_BUSY, "warning"); return; } const draft = this.editor.getExpandedText(); + this.editor.addToHistory(draft.trim()); + this.editor.setText(""); + this.runtime.drafts.set(identity, ""); this.runtime.clearNotices(); this.scroll.scrollToEnd(); + const requestId = randomUUID(); + const guess = { + id: requestId, + command, + author: { ...this.ownAuthor(), id: "" }, + includeInContext, + output: "", + truncated: false, + running: true, + cancelled: false, + }; this.runtime.perform( - ref.shell({ command, includeInContext, requestId: randomUUID() }, BACKGROUND_CONTEXT).then(() => { - if (!this.disposed) this.editor.addToHistory(draft.trim()); - if (!this.disposed && identity === this.identity && this.editor.getExpandedText() === draft) - this.editor.setText(""); - if (this.runtime.drafts.get(identity) === draft) this.runtime.drafts.set(identity, ""); - this.runtime.changed(); + this.start(guess, ref.shell({ command, includeInContext, requestId }, BACKGROUND_CONTEXT)).catch((error) => { + this.restore(identity, [draft]); + throw error; }), ); } @@ -567,30 +651,26 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi { const theme = this.runtime.theme.current; this.pending.clear(); const queued = this.current?.queued ?? []; + // Messages in flight while a run is busy wait in the queue, as the server will place them; before the transcript + // arrived (the conversation still connecting) they show here too. + const outgoing = !this.current || this.current.busy ? this.outgoing() : NO_OUTGOING; const replying = this.canReply ? this.replyTo : undefined; - if (queued.length === 0 && !this.sending && !this.waiting && !replying) return; + if (queued.length === 0 && outgoing.length === 0 && !replying) return; this.pending.addChild(new Text("", 0, 0)); if (replying) this.pending.addChild( new TruncatedText(theme.fg("accent", `↪ Replying to ${lineText(replying.name)} · /reply cancels`), 1, 0), ); - for (const input of queued) + const label = (mode: string) => (!this.current ? "Sending" : mode === "followUp" ? "Follow-up" : "Steering"); + for (const input of [...queued, ...outgoing]) this.pending.addChild( - new TruncatedText( - theme.fg("dim", `${input.mode === "followUp" ? "Follow-up" : "Steering"}: ${lineText(input.text)}`), - 1, - 0, - ), + new TruncatedText(theme.fg("dim", `${label(input.mode)}: ${lineText(input.text)}`), 1, 0), ); if (queued.length && participant) { const hint = this.runtime.ctx.keyText("app.message.dequeue"); if (hint) this.pending.addChild(new TruncatedText(theme.fg("dim", `↳ ${hint} to edit your queued messages`), 1, 0)); } - if (this.sending || this.waiting) - this.pending.addChild( - new TruncatedText(theme.fg("dim", this.sending ? "Sending…" : "Sending when connected…"), 1, 0), - ); } /** DTUI `#syncStatus`: retry, compaction, running tool or working, from `pi.live`. */ @@ -599,7 +679,10 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi { const tui = this.runtime.tui; const live = this.view?.docs["pi.live"] as unknown as LiveState | undefined; const generation = live?.generation; - const compaction = live?.compactions?.[0]; + // A `/compact` in flight shows as a manual compaction until `pi.live` has it. + const compaction = + live?.compactions?.[0] ?? + (this.compacting() ? { trigger: "manual" as const, attempt: 1, retry: undefined } : undefined); const running = live?.tools?.find((slot) => slot.status === "running"); const abort = `(${ctx.keyText("app.interrupt")} to abort)`; let key = ""; @@ -639,61 +722,62 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi { if (!participant && this.indicator) this.status.addChild(this.indicator); } + /** + * Sends the editor's message the way the web's outbox does: it leaves the editor and shows in the transcript (the + * queue while a run is busy) at once, and is delivered once the conversation is live (a reload, a reconnect). A + * refused message comes back to the editor with the error; a retry of the same text reuses its request ID, so it + * is admitted once. + */ send(mode: "steer" | "followUp"): void { - const ref = this.participant(); const text = this.editor.getExpandedText().trim(); - const files = this.identity ? (this.attachments.get(this.identity) ?? []) : []; - if ((!text && !files.length) || this.sending) return; - const current = this.ref?.(); - if (current !== undefined && statusOf(current) === "live" && current.variant !== "participant") { + const identity = this.identity; + const files = identity ? (this.attachments.get(identity) ?? []) : []; + const ref = this.ref; + if ((!text && !files.length) || !identity || !ref || !this.sending) return; + if (this.readOnlyView) { this.runtime.fail("This conversation is read-only"); return; } - if (!this.runtime.connected || !ref) { - // Still connecting (a reload, a reconnect): sent once the conversation is live, with the same request ID. - this.waiting = mode; - this.runtime.changed(); - return; - } - this.waiting = undefined; - const identity = this.identity!; const draft = this.editor.getExpandedText(); let submission = this.runtime.submissions.get(identity); if (submission?.text !== text || submission.mode !== mode) { submission = { text, mode, id: randomUUID() }; this.runtime.submissions.set(identity, submission); } - this.sending = true; + const replyTo = this.canReply ? this.replyTo : undefined; + this.attachments.set(identity, []); + this.replyTo = undefined; + this.editor.addToHistory(text); + this.editor.setText(""); + this.runtime.drafts.set(identity, ""); this.runtime.clearNotices(); this.scroll.scrollToEnd(); - this.runtime.perform( - ref - .send( + const sent = submission; + const delivered = (async () => { + try { + // Waits through a connecting conversation; calls made while it reconnects wait for it too. + await whenLive(ref); + const writer = liveIn(ref, "participant"); + if (!writer) throw new Error("This conversation is read-only"); + await writer.send( { text, - requestId: submission.id, + requestId: sent.id, whenBusy: mode, ...(files.length ? { attachments: files.map((file) => file.id) } : {}), - ...(this.canReply && this.replyTo ? { replyTo: this.replyTo.entryId } : {}), + ...(replyTo ? { replyTo: replyTo.entryId } : {}), }, BACKGROUND_CONTEXT, - ) - .then(() => { - // Sent: these attachments belong to the message now (later uploads stay pending). - const left = (this.attachments.get(identity) ?? []).filter((file) => !files.includes(file)); - this.attachments.set(identity, left); - if (identity === this.identity) this.replyTo = undefined; - if (!this.disposed) this.editor.addToHistory(text); - if (!this.disposed && identity === this.identity && this.editor.getExpandedText() === draft) - this.editor.setText(""); - if (this.runtime.drafts.get(identity) === draft) this.runtime.drafts.set(identity, ""); - if (this.runtime.submissions.get(identity) === submission) this.runtime.submissions.delete(identity); - }) - .finally(() => { - if (identity === this.identity) this.sending = false; - this.runtime.changed(); - }), - ); + ); + if (this.runtime.submissions.get(identity) === sent) this.runtime.submissions.delete(identity); + } catch (error) { + this.restore(identity, [draft]); + this.attachments.set(identity, [...files, ...(this.attachments.get(identity) ?? [])]); + if (identity === this.identity) this.replyTo ??= replyTo; + throw error; + } + })(); + this.runtime.perform(this.sending({ id: sent.id, text, mode, files }, delivered)); } private restore(identity: string, texts: string[]): void { @@ -705,7 +789,7 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi { } private loadEarlier(): void { - const ref = this.ref?.(); + const ref = this.ref; const store = this.transcriptStore; if (ref === undefined || statusOf(ref) !== "live" || !store || this.earlierFailed.has(store)) return; this.runtime.perform( @@ -725,7 +809,7 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi { /** Loads a gap's missing entries while it is in view; a failed load is not retried for that gap. */ private fillGap(gap: Gap): void { - const ref = this.ref?.(); + const ref = this.ref; const store = this.transcriptStore; if ( ref === undefined || @@ -800,20 +884,19 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi { const session = this.session; if (!session) return; const title = () => this.rows.find((row) => row.id === session)?.title ?? ""; + // One reference per viewed session: opening `/tree` again and each action reuse it. + if (this.tree?.session !== session) { + this.tree?.scope.dispose(); + const scope = new TuiScope(runtime); + this.tree = { session, scope, ref: scope.watch(SessionTree, { session }, () => {}) }; + } + const held = this.tree.ref; runtime.perform( - runtime.host.root(async (root) => { - const ref = await root.get(SessionTree, { session }); - if (ref.variant !== "owner") throw new Error("Only the owner switches branches"); - const tree = await ref.tree(root); - // Each action runs in a root of its own: the selector outlives this one. - const act = (action: (tree: typeof ref, ctx: Ctx) => Promise) => - runtime.perform( - runtime.host.root(async (inner) => { - const owned = await inner.get(SessionTree, { session }); - if (owned.variant !== "owner") throw new Error("Only the owner switches branches"); - await action(owned, inner); - }, BACKGROUND_CONTEXT), - ); + (async () => { + await whenLive(held); + const owner = liveIn(held, "owner"); + if (!owner) throw new Error("Only the owner switches branches"); + const tree = await owner.tree(BACKGROUND_CONTEXT); const selector: TreeSelectorComponent = new TreeSelectorComponent( runtime.ctx, tree.nodes, @@ -821,24 +904,29 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi { new Map(tree.branches.map((branch) => [branch.conversation, branch.label ?? title()])), (entry) => { overlay.close(); - act(async (owned, ctx) => { - const { conversation, text } = await owned.navigate({ entry }, ctx); - const identity = identityKey(session, conversation); - if (text !== null) runtime.drafts.set(identity, text); - runtime.navigate("chat", session, conversation); - // Already shown (the session continues in the same conversation): the editor takes the text now. - if (text !== null && !this.disposed && this.identity === identity) this.editor.setText(text); - }); + runtime.perform( + owner.navigate({ entry }, BACKGROUND_CONTEXT).then(({ conversation, text }) => { + const identity = identityKey(session, conversation); + if (text !== null) runtime.drafts.set(identity, text); + runtime.navigate("chat", session, conversation); + // Already shown (the session continues in the same conversation): the editor takes the text now. + if (text !== null && !this.disposed && this.identity === identity) this.editor.setText(text); + }), + ); }, () => overlay.close(), - (conversation, label) => - act(async (owned, ctx) => { - await owned.rename({ conversation, label: label || null }, ctx); - selector.setLabel(conversation, label || title()); - }), + // The label shows at once; a refused rename puts the old one back. + (conversation, label) => { + const before = tree.branches.find((branch) => branch.conversation === conversation)?.label ?? title(); + selector.setLabel(conversation, label || title()); + owner.rename({ conversation, label: label || null }, BACKGROUND_CONTEXT).catch((error: unknown) => { + selector.setLabel(conversation, before); + runtime.fail(error); + }); + }, ); const overlay = runtime.ctx.overlay("tree", selector, { width: "100%", anchor: "bottom-center" }); - }, BACKGROUND_CONTEXT), + })(), ); } @@ -871,18 +959,17 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi { (id) => { overlay.close(); const text = messages.find((message) => message.id === id)?.text ?? ""; - runtime.perform( - runtime.host.root(async (root) => { - const sessions = await root.get(Sessions, {}); - if (sessions.variant !== "owner") throw new Error("Only the owner forks sessions"); - const created = await sessions.fork( - { id: session, conversation: this.conversation, before: id }, - root, - ); - runtime.drafts.set(identityKey(created.id, 1), text); - runtime.navigate("chat", created.id, 1); - runtime.notify("Forked to new session"); - }, BACKGROUND_CONTEXT), + const catalog = liveIn(this.catalog, "owner"); + if (!catalog) return; + // The forked message waits in the new session's editor. + this.openCopy( + session, + (copy) => + catalog.fork( + { id: session, conversation: this.conversation, before: id, copy }, + BACKGROUND_CONTEXT, + ), + text, ); }, () => overlay.close(), @@ -892,28 +979,39 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi { ); } - /** pi's `/clone`: the whole session as a new one (`Sessions.clone`), opened. */ + /** pi's `/clone`: the whole session as a new one (`Sessions.clone`), opened at once. */ cloneSession(): void { - const runtime = this.runtime; const session = this.session; - if (!session) return; - runtime.perform( - runtime.host.root(async (root) => { - const sessions = await root.get(Sessions, {}); - if (sessions.variant !== "owner") throw new Error("Only the owner clones sessions"); - const created = await sessions.clone({ id: session }, root); - runtime.navigate("chat", created.id, 1); - runtime.notify("Cloned to new session"); - }, BACKGROUND_CONTEXT), - ); + const catalog = liveIn(this.catalog, "owner"); + if (!session || !catalog) return; + this.openCopy(session, (copy) => catalog.clone({ id: session, copy }, BACKGROUND_CONTEXT)); } - /** `/compact [instructions]`: summarize earlier context now (Escape aborts it). */ + /** + * Opens a copy of `session` (a clone, a fork) at once: this TUI chooses its ID (`copy`), the new conversation shows + * loading while its files are copied (a job; its worker starts after), with `draft` in its editor. A refused copy + * returns to `session` with the error. + */ + private openCopy(session: string, call: (copy: string) => Promise, draft?: string): void { + const runtime = this.runtime; + const copy = randomUUID(); + if (draft !== undefined) runtime.drafts.set(identityKey(copy, 1), draft); + runtime.navigate("chat", copy, 1); + call(copy).catch((error: unknown) => { + if (runtime.session === copy) runtime.navigate("chat", session); + runtime.fail(error); + }); + } + + /** `/compact [instructions]`: summarize earlier context now (Escape aborts it); its indicator shows at once. */ compact(instructions?: string): void { const ref = this.participant(); - if (!ref) return; + if (!ref || !this.compactNow) return; this.runtime.perform( - ref.compact(instructions?.trim() ? { instructions: instructions.trim() } : {}, BACKGROUND_CONTEXT), + this.compactNow( + true, + ref.compact(instructions?.trim() ? { instructions: instructions.trim() } : {}, BACKGROUND_CONTEXT), + ), ); } @@ -1014,9 +1112,7 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi { /** The editor keeps input while the conversation connects (a reload, a reconnect); read-only views drop it. */ handleInput(data: string): void { - const ref = this.ref?.(); - if (ref !== undefined && statusOf(ref) === "live" && ref.variant !== "participant") return; - this.editor.handleInput(data); + if (!this.readOnlyView) this.editor.handleInput(data); } /** Without the fullscreen layout (tests): transcript, dock. */ @@ -1037,6 +1133,7 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi { this.shellView?.component.dispose(); this.transcript.dispose(); this.sessions.dispose(); + this.tree?.scope.dispose(); this.scope?.dispose(); } } diff --git a/apps/p2/plugins/chat/web.tsx b/apps/p2/plugins/chat/web.tsx index 248110ddc..fd29a535a 100644 --- a/apps/p2/plugins/chat/web.tsx +++ b/apps/p2/plugins/chat/web.tsx @@ -10,6 +10,7 @@ import { isChordError, liveIn, serveKeyed, + UI_LINGER, whenLive, } from "@p2/app/v1"; import { @@ -23,6 +24,7 @@ import { closeLayers, conversationRoute, DownIcon, + errorText, type FileCompletion, FileCompletionPopover, FillGap, @@ -33,6 +35,7 @@ import { keep, keepSessions, keptSizes, + knownMain, loadKept, locationLabel, MenuIcon, @@ -76,6 +79,7 @@ import { useNavigation, useNewSessionState, useObject, + useOptimisticCall, useOwner, useReadable, useRegistry, @@ -110,8 +114,6 @@ import { Chat, ChatCosts, TranscriptEntries } from "./contract.ts"; import { type Outgoing, outboxOf } from "./outbox.ts"; import { sessionStart } from "./session-start.ts"; -const errorText = (error: unknown): string => (error instanceof Error ? error.message : String(error)); - /** An upload the server answered with an error status. */ class UploadError extends Error { readonly status: number; @@ -207,6 +209,30 @@ const attachingOf = ({ files, setFiles, addFiles }: ReturnType setFiles((current) => current.filter((entry) => entry !== file)), }); +/** + * The owner's model catalog and settings, with the default model as just chosen: `setDefault` shows the choice as the + * default at once (the picker's mark) and rolls back if the server refuses it. + */ +function useModelResources(owner: boolean, fail: (error: unknown) => void) { + const models = useObject(Models, owner ? {} : undefined); + const [resources, guess] = useOptimisticCall( + useReadable(models?.resources), + (snapshot, choice: ModelChoice) => + snapshot && { + ...snapshot, + settings: { ...snapshot.settings, defaultModel: choice.model, thinkingLevel: choice.thinkingLevel }, + }, + fail, + ); + const setDefault = (choice: ModelChoice) => { + if (models) + guess(choice, () => + models.setDefault({ ...choice.model, thinkingLevel: choice.thinkingLevel }, BACKGROUND_CONTEXT), + ); + }; + return [resources, setDefault] as const; +} + /** The model and thinking chip that opens the model sheet. */ function ModelControl({ resources, choice }: { resources: ResourceSnapshot | undefined; choice?: ModelChoice }) { const name = choice ? modelName(resources, choice.model) : "Choose model"; @@ -490,7 +516,18 @@ function Dock({ frame, dockRef }: { frame: Frame; dockRef: (element: HTMLDivElem ); } -function TopBar({ session, child, owner = false }: { session?: Session; child?: Child; owner?: boolean }) { +function TopBar({ + session, + child, + owner = false, + moving = false, +}: { + session?: Session; + child?: Child; + owner?: boolean; + /** The session is being moved (a relocation job runs). */ + moving?: boolean; +}) { const connected = useConnected(); const nav = useNavigation(); const route = useRoute(); @@ -530,7 +567,7 @@ function TopBar({ session, child, owner = false }: { session?: Session; child?: className="title-cwd" onClick={() => openLayer(directoryPickerLayer(session.env ?? null, session.cwd))} > - {locationLabel(session.env, session.cwd)} + {moving ? "Moving…" : locationLabel(session.env, session.cwd)} ) : (

{locationLabel(session.env, session.cwd)}

@@ -663,8 +700,8 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers // Taken while the object is live, a removed grant (null) included. if (useStatus(access) === "live") lastGrant.current = liveGrant; const grant = owner ? undefined : lastGrant.current; - const models = useObject(Models, owner ? {} : undefined); - const resources = useReadable(models?.resources); + const { fail } = useComposerState(); + const [resources, setDefault] = useModelResources(owner, fail); const subagents = liveIn(useObject(Subagents, {}), "reader"); const children = useReadable(subagents?.children); const child = children?.[sessionId]?.find((entry) => entry.conversationId === conversationId); @@ -744,10 +781,10 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers // Who may write decides what shows (as Pi Mobile's `canWrite`), not whether the chat object is live yet: during a // load, a cold start or a reconnect the composer stays and only Send waits. const canWrite = owner || grantMode(grant, sessionId) === "full"; - const subagentControl = useObject( - SubagentControl, - canWrite && child && !oneShot ? { session: sessionId } : undefined, - ); + // Held with the view, not once the subagent list names the child: Archive works right after load. + const subagentControl = useObject(SubagentControl, canWrite ? { session: sessionId } : undefined); + // The bar's archived mark flips at the tap. + const [archived, archive] = useOptimisticCall(child?.archived ?? false, (_, next: boolean) => next, fail); const composer = useComposerState(); const { draft, @@ -802,7 +839,7 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers const fileCompletion = useFileCompletion( composerRef, setDraft, - participant ? { session: sessionId, conversation: conversationId } : undefined, + canWrite ? { session: sessionId, conversation: conversationId } : undefined, { mention: canReply ? mentionName : undefined }, ); const metrics = useObject(SessionMetrics, { session: sessionId, viewer }); @@ -823,7 +860,8 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers }, [setDraft], ); - useRestoredDrafts(sessionId, conversationId, participant, restore); + // Who may write is known before the chat object is live (`canWrite`): drafts hold at once. + useRestoredDrafts(sessionId, conversationId, canWrite, restore); const [pages, transcriptStore] = useTranscriptPages(cacheKey, chat); // What this device kept starts the transcript; the live view is merged into it when it continues it. useEffect(() => { @@ -837,12 +875,7 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers if (!owner || !liveView || !store?.value) return; keep(cacheKey, () => ({ view: store.value!.view, sizes: [...keptSizes(cacheKey)] })); }, [owner, liveView, pages, transcriptStore, cacheKey]); - const perform = useCallback( - (action: Promise | undefined) => { - void action?.catch((reason: unknown) => setError(errorText(reason))); - }, - [setError], - ); + const perform = useCallback((action: Promise | undefined) => void action?.catch(fail), [fail]); // Plugins show their own display-only entry kinds (chat-mode's chat messages) as people's messages. const entryKinds = useRegistry(TranscriptEntries); const display = useCallback((kind: string, data: unknown) => entryKinds.get(kind)?.(data), [entryKinds]); @@ -891,7 +924,7 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers [pending, busy, started], ); // The server's queued inputs, without those still shown as messages sent from here. - const queued = useMemo(() => { + const serverQueued = useMemo(() => { const sentIds = new Set(pending.map((message) => message.sent?.submissionId)); return transcript?.queued.filter((item) => !sentIds.has(item.id)) ?? []; }, [transcript, pending]); @@ -962,15 +995,17 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers const start = started.get(message.id) ?? { after: newest.current, idle: !latest.current.busy }; started.set(message.id, start); const { after } = start; - const attachments: string[] = []; - for (const file of message.files) { - if (file.session !== session) { - file.uploaded = undefined; - file.session = session; - } - file.uploaded ??= await upload(session, file.file); - attachments.push(file.uploaded); - } + // Uploads run side by side; one kept from an earlier try of this send is reused. + const attachments = await Promise.all( + message.files.map(async (file) => { + if (file.session !== session) { + file.uploaded = undefined; + file.session = session; + } + file.uploaded ??= await upload(session, file.file); + return file.uploaded; + }), + ); // `/template args` and `/skill:name args` expand on the session worker first (10.8). const text = message.text.startsWith("/") ? await expandComposerText(client, session, conversation, message.text) @@ -1062,12 +1097,45 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers const first = pages?.view.items.find((item) => item.kind === "entry"); const paged = (first?.kind === "entry" ? first.entry.id : Number.POSITIVE_INFINITY) < (view?.entries[0]?.id ?? 0); const earlier = transcript?.earlier ? (pages?.loadingEarlier ? "loading" : "more") : paged ? "start" : undefined; - const choice: ModelChoice | undefined = session - ? { - model: agentModel ?? session.model, - thinkingLevel: transcript?.agent.thinkingLevel ?? (session.thinkingLevel as ModelChoice["thinkingLevel"]), - } - : undefined; + // The chip and the picker show a model change at once; the conversation's state replaces it with the answer. + const [choice, chooseModel] = useOptimisticCall( + session + ? { + model: agentModel ?? session.model, + thinkingLevel: + transcript?.agent.thinkingLevel ?? (session.thinkingLevel as ModelChoice["thinkingLevel"]), + } + : undefined, + (_, next) => next, + fail, + ); + // A move is long work (a job): the top bar says "Moving…" from the tap until the job is done; a refused move + // (a busy session) shows as a failed operation in the sidebar. + const operations = useReadable(liveIn(sessions, "owner")?.operations); + const moveRunning = + operations?.some( + (operation) => + operation.phase === "running" && operation.kind === "relocate" && operation.session === sessionId, + ) ?? false; + const [moving, relocate] = useOptimisticCall(moveRunning, () => true, fail); + // A clone or fork into this session whose files are still being copied: nothing is sent until its worker runs. + const copyTarget = + operations?.some((operation) => operation.phase === "running" && operation.target === sessionId) ?? false; + // A move of this session the worker refused (busy) or that failed: said in the toast once; the sidebar row keeps it + // until dismissed. + const failedMove = operations?.find( + (operation) => operation.phase === "failed" && operation.kind === "relocate" && operation.session === sessionId, + ); + const failedMoveText = failedMove?.phase === "failed" ? `Move failed: ${failedMove.error}` : undefined; + useEffect(() => { + if (failedMoveText) toast(failedMoveText); + }, [failedMoveText, toast]); + // Removed queued inputs leave the queue at once. + const [queued, dequeue] = useOptimisticCall( + serverQueued, + (items, submissionId: number) => items.filter((item) => item.id !== submissionId), + fail, + ); const modelControl = owner ? ( ) : ( @@ -1107,30 +1175,25 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers Tools: {child.tools.join(", ") || "none"} {!oneShot && viewLocation && ` · in ${locationLabel(viewLocation.env, viewLocation.cwd)}`} {!oneShot && !child.location && " · follows the session"} - {child.archived ? " · archived" : ""} + {archived ? " · archived" : ""} - {canWrite && !oneShot && ( + {canWrite && !oneShot && subagentControl && ( )} {canWrite && oneShot && busy && ( @@ -1179,13 +1242,14 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers className="queue-edit" title="Move back to editor" onClick={() => - perform( - writer - ?.dequeue({ submissionId: item.id, restore: true }, BACKGROUND_CONTEXT) - .then(({ text }) => { - if (text) restore([text], true); - }), - ) + writer && + dequeue(item.id, async () => { + const { text } = await writer.dequeue( + { submissionId: item.id, restore: true }, + BACKGROUND_CONTEXT, + ); + if (text) restore([text], true); + }) } > {item.mode === "steer" ? "Steer" : "Queued"} {item.text} @@ -1194,7 +1258,9 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers type="button" className="queue-remove" aria-label="Remove from queue" - onClick={() => perform(writer?.dequeue({ submissionId: item.id }, BACKGROUND_CONTEXT))} + onClick={() => + writer && dequeue(item.id, () => writer.dequeue({ submissionId: item.id }, BACKGROUND_CONTEXT)) + } > @@ -1221,7 +1287,7 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers busy, steer, setSteer, - disabled: false, + disabled: copyTarget, onSend: send, onStop: stop, attaching, @@ -1239,7 +1305,7 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers ); return ( <> - + {cwdSheet && owner && session && ( { - perform(liveIn(sessions, "owner")?.relocate({ id: sessionId, env, cwd }, BACKGROUND_CONTEXT)); + const catalog = liveIn(sessions, "owner"); + if (catalog) relocate(true, () => catalog.relocate({ id: sessionId, env, cwd }, BACKGROUND_CONTEXT)); closeLayers(); }} /> @@ -1259,12 +1326,10 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers perform(writer?.configure(choice, BACKGROUND_CONTEXT))} - onDefault={(choice) => - perform( - models?.setDefault({ ...choice.model, thinkingLevel: choice.thinkingLevel }, BACKGROUND_CONTEXT), - ) - } + onChange={(next) => { + if (writer) chooseModel(next, () => writer.configure(next, BACKGROUND_CONTEXT)); + }} + onDefault={setDefault} onClose={closeLayers} /> )} @@ -1298,7 +1363,7 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers {owner ? (
@@ -468,12 +505,12 @@ export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: () diff --git a/apps/p2/plugins/envs/server.ts b/apps/p2/plugins/envs/server.ts index fe53c0a00..c07ebcf0a 100644 --- a/apps/p2/plugins/envs/server.ts +++ b/apps/p2/plugins/envs/server.ts @@ -4,19 +4,19 @@ import { defineFacet, defineState, deriveTyped, + followNative, forbidden, isOwner, nativeOf, record, serveNative, - untilAborted, } from "@p2/app/v1"; import { listLocalDirectory } from "@p2/app/v1/node"; import { Hosts, Server } from "@p2/app/v1/server"; import { Catalog, type Session } from "../sessions/contract.ts"; import { StatusStore } from "../status/contract.ts"; import { type Child, ChildStore } from "../subagents/contract.ts"; -import { EnvConfigSchema, envRevision } from "./config.ts"; +import { envRevision } from "./config.ts"; import { EnvManagers, EnvService, Envs, EnvTestSchema } from "./contract.ts"; import { EnvManager } from "./manager.ts"; import { EnvStore } from "./store.ts"; @@ -41,15 +41,11 @@ const Restarts = defineState("p2.env-restarts", { initial: () => ({ revisions: null, pending: {} }), }); -/** The observed configuration, its last error, and the latest test of each environment (with its revision). */ -const Observed = defineState("p2.env-service", { - schema: obj({ - configs: record(EnvConfigSchema), - tests: record(obj({ revision: str(), test: EnvTestSchema })), - failure: union([str(), nul()]), - }), +/** The latest test of each environment, with the revision of the configuration it tested. */ +const Tests = defineState("p2.env-tests", { + schema: obj({ tests: record(obj({ revision: str(), test: EnvTestSchema })) }), version: 1, - initial: () => ({ configs: {}, tests: {}, failure: null }), + initial: () => ({ tests: {} }), }); export default defineFacet((host) => { @@ -98,7 +94,7 @@ export default defineFacet((host) => { const server = self.get(Server, {}); const catalog = self.get(Catalog, {}); const children = self.get(ChildStore, {}); - const observed = self.state(Observed); + const tested = self.state(Tests); const store = self.open( async () => { const opened = new EnvStore(server.info.value.dataDir); @@ -120,29 +116,21 @@ export default defineFacet((host) => { return manager.value; }, }); - self.onLive(async (ctx) => { - const stop = store.value.subscribe((snapshot) => { - void observed - .change((draft) => { - draft.configs = snapshot.configs; - draft.failure = snapshot.error; - }, ctx) - .catch(() => {}); - }); - try { - await untilAborted(ctx); - } finally { - stop(); - } - }); + // envs.json as the store last read it validly, and why the last read failed: `reload` resolves after the store + // told this source. + const snapshot = followNative( + self, + (changed) => store.value.subscribe(changed), + () => store.value.snapshot, + ); return { - configs: self.derive([observed], ([value]) => value.configs), - tests: self.derive([observed], ([value]) => value.tests), - failure: self.derive([observed], ([value]) => value.failure), + configs: deriveTyped(self, [snapshot], ([value]) => value.configs), + tests: self.derive([tested], ([value]) => value.tests), + failure: self.derive([snapshot], ([value]) => value.error), test: async ({ name }, ctx) => { const config = await manager.value.config(name); const test = await manager.value.test(name); - await observed.change((draft) => { + await tested.change((draft) => { draft.tests[name] = { revision: envRevision(config), test }; }, ctx); return test; diff --git a/apps/p2/plugins/envs/store.ts b/apps/p2/plugins/envs/store.ts index fec581fe5..c1a9e405a 100644 --- a/apps/p2/plugins/envs/store.ts +++ b/apps/p2/plugins/envs/store.ts @@ -30,9 +30,9 @@ export class EnvStore { get snapshot(): EnvSnapshot { return this.#snapshot; } + /** `listener` hears of every load's snapshot from now on; the result stops it. */ subscribe(listener: (snapshot: EnvSnapshot) => void): () => void { this.#listeners.add(listener); - listener(this.#snapshot); return () => this.#listeners.delete(listener); } } diff --git a/apps/p2/plugins/envs/worker.ts b/apps/p2/plugins/envs/worker.ts index 2a0b4f396..c28e6d2c7 100644 --- a/apps/p2/plugins/envs/worker.ts +++ b/apps/p2/plugins/envs/worker.ts @@ -2,19 +2,10 @@ import { contribute, defineFacet } from "@p2/app/v1"; import { HarnessEnvironments, workerOf } from "@p2/app/v1/worker"; import { loadEnvs } from "./config.ts"; import { remoteSession } from "./remote-env.ts"; -import { EnvStore } from "./store.ts"; export default defineFacet((host) => { contribute(host, "p2.worker-envs", (self) => { const worker = workerOf(self); - self.open( - async () => { - const store = new EnvStore(worker().init.dataDir); - await store.observer.start(); - return store; - }, - (store) => store.observer.close(), - ); self.contribute(HarnessEnvironments, { remote: { open: async (env) => { diff --git a/apps/p2/plugins/file-index/worker.ts b/apps/p2/plugins/file-index/worker.ts index 67a930ff3..e0b0744c3 100644 --- a/apps/p2/plugins/file-index/worker.ts +++ b/apps/p2/plugins/file-index/worker.ts @@ -1,7 +1,6 @@ import type { ConversationId } from "@earendil-works/pi-durable"; import type { ExecutionEnv } from "@earendil-works/pi-durable/env"; import { type Ctx, canSend, defineFacet, FileIndex, FileIndexCache, type FileItem, forbidden } from "@p2/app/v1"; -import { durableContext } from "@p2/app/v1/node"; import { conversationEnvironment, harnessOf, isLocalEnvironment, workerOf } from "@p2/app/v1/worker"; import { WorkspaceBackend, workspacePath } from "../workspace/contract.ts"; import { inside, listIndex } from "./listing.ts"; @@ -30,27 +29,24 @@ export default defineFacet((host) => { * workspace's index, which also reports a private or unavailable working directory. */ const listed = async (ctx: Ctx) => { - const context = durableContext(ctx); try { return await worker().liveness.call(async () => { - const handle = await harness().durable.conversation(conversation as ConversationId, context); + const handle = await harness().durable.conversation(conversation as ConversationId, ctx); if (!handle) return undefined; - const { env } = await conversationEnvironment(harness().environments, handle.id, context); + const { env } = await conversationEnvironment(harness().environments, handle.id, ctx); if (env === unsupported) return undefined; if (env.listFiles === undefined) { unsupported = env; return undefined; } - const root = await env.canonicalPath(env.cwd, context); + const root = await env.canonicalPath(env.cwd, ctx); if (!root.ok) return undefined; const init = worker().init; // Decided from the environment, not the location, which lags behind it during a move. - const privatePath = isLocalEnvironment(env) - ? await env.canonicalPath(init.dataDir, context) - : undefined; + const privatePath = isLocalEnvironment(env) ? await env.canonicalPath(init.dataDir, ctx) : undefined; const denied = privatePath === undefined ? [] : [privatePath.ok ? privatePath.value : init.dataDir]; if (denied.some((directory) => inside(directory, root.value))) return undefined; - const result = await listIndex(env, root.value, denied, context); + const result = await listIndex(env, root.value, denied, ctx); if (result === undefined) unsupported = env; return result; }); diff --git a/apps/p2/plugins/history/tools.ts b/apps/p2/plugins/history/tools.ts index 9f402da4a..a9dbfdeaf 100644 --- a/apps/p2/plugins/history/tools.ts +++ b/apps/p2/plugins/history/tools.ts @@ -15,7 +15,7 @@ import { type Tx, } from "@earendil-works/pi-durable"; import { arr, int, obj, opt, str, union } from "@earendil-works/pi-schema"; -import type { JsonValue } from "@p2/app/v1"; +import type { Context, JsonValue } from "@p2/app/v1"; import { agentMessage, literal, messageData, peerMessage, SHELL_ENTRY, shellData } from "@p2/app/v1"; import type { HistoryHits } from "../search/contract.ts"; @@ -29,12 +29,15 @@ export const READ_BUDGET = 50_000; const PREVIEW = 200; /** Searches the calling conversation in the server's index (the search plugin's worker feed). */ -export type HistorySearch = (query: { - query: string; - conversation: number; - before?: number; - limit: number; -}) => Promise; +export type HistorySearch = ( + query: { + query: string; + conversation: number; + before?: number; + limit: number; + }, + context: Context, +) => Promise; export type HistoryRole = "user" | "assistant" | "marker"; /** An entry as the history tools show it. */ @@ -292,12 +295,10 @@ export function createHistoryExtension(search: HistorySearch, label: (conversati context, ); const before = args.before ?? boundary; - const result = await search({ - query: args.query, - conversation, - ...(before === undefined ? {} : { before }), - limit: args.limit ?? 10, - }); + const result = await search( + { query: args.query, conversation, ...(before === undefined ? {} : { before }), limit: args.limit ?? 10 }, + context, + ); // Authors from the entries themselves (the index keeps roles only). const hits = await api.commit(async (tx) => { const out: { entry: number; role: "user" | "assistant"; author?: string; snippet: string }[] = []; diff --git a/apps/p2/plugins/history/worker.ts b/apps/p2/plugins/history/worker.ts index 93ff790b6..41dcdfaf0 100644 --- a/apps/p2/plugins/history/worker.ts +++ b/apps/p2/plugins/history/worker.ts @@ -9,8 +9,11 @@ export default defineFacet((host) => { contribute(host, "p2.history-tools", (self) => { const worker = workerOf(self); const extension = createHistoryExtension( - (query) => - worker().root(async (ctx) => expectVariant(await ctx.get(SearchFeed, {}), "worker").history(query, ctx)), + (query, context) => + worker().root( + async (ctx) => expectVariant(await ctx.get(SearchFeed, {}), "worker").history(query, ctx), + context, + ), (conversation) => `${worker().init.values.session.id}#${conversation}`, ); self.contribute(Extensions, { [extension.name]: extension }); diff --git a/apps/p2/plugins/locations/worker.ts b/apps/p2/plugins/locations/worker.ts index 73b391396..62728c1e7 100644 --- a/apps/p2/plugins/locations/worker.ts +++ b/apps/p2/plugins/locations/worker.ts @@ -24,14 +24,11 @@ export default defineFacet((host) => { const extension = createLocationExtension({ server: { envs: (context) => - worker().root( - async (ctx) => (await (await server(ctx)).envs({ origin: "owner" }, ctx)).envs, - context.abortSignal, - ), + worker().root(async (ctx) => (await (await server(ctx)).envs({ origin: "owner" }, ctx)).envs, context), directories: (env, path, context) => worker().root( async (ctx) => (await server(ctx)).directories({ origin: "owner", env, path }, ctx), - context.abortSignal, + context, ), }, locate: (id, read, context) => locateConversation(harness, id, context, read), diff --git a/apps/p2/plugins/login/contract.ts b/apps/p2/plugins/login/contract.ts index 7c18535ce..f03bff4df 100644 --- a/apps/p2/plugins/login/contract.ts +++ b/apps/p2/plugins/login/contract.ts @@ -48,7 +48,8 @@ const OwnerSchema = obj({ owner: str({ minLength: 1 }) }); * dismisses it, and only its leaving ends it. */ export const LoginService = local("p2.login-service", { - version: 2, + /** 3: no `abandon` (the service follows who holds `ProviderLogin`). */ + version: 3, params: {}, members: { login: union([LoginStateSchema, nul()]), @@ -73,8 +74,6 @@ export const LoginService = local("p2.login-service", { ), cancel: method(obj({ ...propertiesOf(OwnerSchema), ...propertiesOf(AttemptSchema) })), dismiss: method(obj({ ...propertiesOf(OwnerSchema), ...propertiesOf(AttemptSchema) })), - /** Its owner left (no hold of its Providers sheet remains): cancels that owner's unfinished login only. */ - abandon: method(OwnerSchema), logout: method(ProviderIdSchema), refresh: method(ProviderIdSchema, obj({ models: num() })), }, diff --git a/apps/p2/plugins/login/logins.ts b/apps/p2/plugins/login/logins.ts index e6f56e2da..e8725f5f5 100644 --- a/apps/p2/plugins/login/logins.ts +++ b/apps/p2/plugins/login/logins.ts @@ -1,9 +1,9 @@ import { randomUUID } from "node:crypto"; import { join } from "node:path"; import type { AuthPrompt, AuthType } from "@earendil-works/pi-ai"; -import { LockedFile } from "@p2/app/v1/node"; +import { LockedFile, processLogs } from "@p2/app/v1/node"; import type { Resources } from "../models/contract.ts"; -import type { LoginEvent, LoginPrompt } from "./contract.ts"; +import type { LoginEvent, LoginPrompt, LoginState } from "./contract.ts"; type PendingPrompt = { prompt: LoginPrompt; resolve: (value: string) => void }; type Attempt = { @@ -16,20 +16,42 @@ type Attempt = { done: Promise; }; +/** The current login (one at a time) and the principal key of the owner that started it. */ +export type LoginsState = { readonly login: LoginState | null; readonly owner: string | null }; + +/** + * Provider logins (provider-owned api-key and OAuth flows over the shared credentials), one at a time. The flow runs + * here, outside Chord; its state changes synchronously with each event, and listeners hear of every change (the + * server's `LoginService` follows it). + */ export class Logins { private readonly resources: Resources; - private readonly emit: (owner: string, event: LoginEvent) => void; - private readonly lifetime = new AbortController(); private readonly device: LockedFile; + private readonly listeners = new Set<() => void>(); private active: Attempt | undefined; + private current: LoginsState = { login: null, owner: null }; - constructor(resources: Resources, emit: (owner: string, event: LoginEvent) => void) { + constructor(resources: Resources) { this.resources = resources; - this.emit = emit; this.device = new LockedFile(join(resources.directory, "device.json")); } + get state(): LoginsState { + return this.current; + } + + /** `listener` hears of every change of `state`; the result stops it. */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + /** The current login, if `owner` started it. */ + private mine(owner: string): LoginState | null { + return this.current.owner === owner ? this.current.login : null; + } + start(owner: string, providerId: string, authType: AuthType, remote = false): string { if (this.lifetime.signal.aborted) throw new Error("Server is shutting down"); if (this.active) @@ -176,17 +198,31 @@ export class Logins { return this.active; } + /** Answers the prompt (closed at once); secrets and pasted codes are redacted from the logs first. */ answer(owner: string, attemptId: string, promptId: string, value: string): void { const attempt = this.owned(owner, attemptId); const pending = attempt.prompts.get(promptId); if (!pending) throw new Error("Login prompt is no longer active"); if (pending.prompt.type === "select" && !pending.prompt.options.some((option) => option.id === value)) throw new Error("Select one of the listed options"); + if (pending.prompt.type === "secret" || pending.prompt.type === "manual_code") + processLogs.redactor.addSecret(value); pending.resolve(value); } - cancel(owner: string, attemptId: string): void { - this.owned(owner, attemptId).controller.abort(); + /** Cancels the login; resolves once it finished (its state shows "cancelled", or what it reached first). */ + async cancel(owner: string, attemptId: string): Promise { + const attempt = this.owned(owner, attemptId); + attempt.controller.abort(); + await attempt.done; + } + + /** Clears `owner`'s finished login `attemptId` from the state. */ + dismiss(owner: string, attemptId: string): void { + const login = this.mine(owner); + if (login?.attemptId !== attemptId || login.status === "working") return; + this.current = { ...this.current, login: null }; + this.changed(); } /** Its owner left (closed the sheet everywhere, or disconnected): end its unfinished login, nobody else's. */ @@ -206,6 +242,22 @@ export class Logins { await this.resources.refreshModels(providerId, this.lifetime.signal); } + /** Applies `event` of `owner`'s login to the state, redacting the codes and URLs it shows from the logs. */ + private emit(owner: string, event: LoginEvent): void { + if (event.type === "notice" && event.event.type === "device_code") + processLogs.redactor.addSecret(event.event.userCode); + if (event.type === "notice" && event.event.type === "auth_url") processLogs.redactor.addSecret(event.event.url); + this.current = { + owner: event.type === "started" ? owner : this.current.owner, + login: reduce(this.current.login, event), + }; + this.changed(); + } + + private changed(): void { + for (const listener of this.listeners) listener(); + } + async close(): Promise { this.lifetime.abort(); this.active?.controller.abort(); @@ -213,3 +265,32 @@ export class Logins { await this.device.close(); } } + +/** The next state of the current login after `event`. */ +function reduce(state: LoginState | null, event: LoginEvent): LoginState | null { + if (event.type === "started") + return { + attemptId: event.attemptId, + providerId: event.providerId, + authType: event.authType, + status: "working", + prompt: null, + notices: [], + }; + if (state === null || state.attemptId !== event.attemptId) return state; + switch (event.type) { + case "prompt": + return { ...state, prompt: event.prompt }; + case "promptClosed": + return state.prompt?.id === event.promptId ? { ...state, prompt: null } : state; + case "notice": + return { ...state, notices: [...state.notices, { id: state.notices.length, event: event.event }] }; + case "finished": + return { + ...state, + status: event.status, + prompt: null, + ...(event.message === undefined ? {} : { message: event.message }), + }; + } +} diff --git a/apps/p2/plugins/login/server.ts b/apps/p2/plugins/login/server.ts index deaa9d3ca..d8a37de1a 100644 --- a/apps/p2/plugins/login/server.ts +++ b/apps/p2/plugins/login/server.ts @@ -1,127 +1,63 @@ -// Provider login: p2's Logins (provider-owned api-key and OAuth flows) as chord2 objects. +// Provider login: the server's `Logins` (provider-owned api-key and OAuth flows) followed by `LoginService`, and its +// view per owner. -import { nul, obj, str, union } from "@earendil-works/pi-schema"; -import { - BACKGROUND_CONTEXT, - defineFacet, - defineTypedState, - deriveTyped, - forbidden, - isChordError, - isOwner, - nativeOf, - principalKey, -} from "@p2/app/v1"; -import { processLogs } from "@p2/app/v1/node"; -import { serverOf } from "@p2/app/v1/server"; +import { defineFacet, deriveTyped, followNative, forbidden, isOwner, nativeOf, principalKey } from "@p2/app/v1"; import { ModelConfig, ModelResources } from "../models/contract.ts"; -import { type LoginEvent, LoginService, type LoginState, LoginStateSchema, ProviderLogin } from "./contract.ts"; +import { LoginService, ProviderLogin } from "./contract.ts"; import { Logins } from "./logins.ts"; -/** The next state of the current login after `event`. */ -function reduce(state: LoginState | null, event: LoginEvent): LoginState | null { - if (event.type === "started") - return { - attemptId: event.attemptId, - providerId: event.providerId, - authType: event.authType, - status: "working", - prompt: null, - notices: [], - }; - if (state === null || state.attemptId !== event.attemptId) return state; - switch (event.type) { - case "prompt": - return { ...state, prompt: event.prompt }; - case "promptClosed": - return state.prompt?.id === event.promptId ? { ...state, prompt: null } : state; - case "notice": - return { ...state, notices: [...state.notices, { id: state.notices.length, event: event.event }] }; - case "finished": - return { - ...state, - status: event.status, - prompt: null, - ...(event.message === undefined ? {} : { message: event.message }), - }; - } -} - -/** The current login and the owner that started it (in memory). */ -const Current = defineTypedState("p2.login-service", { - schema: obj({ login: union([LoginStateSchema, nul()]), owner: union([str(), nul()]) }), - version: 1, - initial: () => ({ login: null, owner: null }), -}); - export default defineFacet((host) => { + const holders = host.references(ProviderLogin); host.serve(LoginService, (_params, self) => { // Declared, so the configuration is open before the logins are. self.get(ModelConfig, {}); const resources = nativeOf(self, ModelResources, "The model configuration"); - const current = self.state(Current); - // Login flows run outside Chord calls: each event changes the state on its own. - const apply = (owner: string, event: LoginEvent) => - void current - .change((draft) => { - if (event.type === "started") draft.owner = owner; - // A draft's arrays are mutable; the reduced state (readonly prompt options) is the same JSON. - draft.login = reduce(current.value.login, event) as typeof draft.login; - }, BACKGROUND_CONTEXT) - .catch(() => {}); - const loginsResource = self.open( - async () => - new Logins(resources().resources, (owner, event) => { - if (event.type === "notice" && event.event.type === "device_code") - processLogs.redactor.addSecret(event.event.userCode); - if (event.type === "notice" && event.event.type === "auth_url") - processLogs.redactor.addSecret(event.event.url); - apply(owner, event); - }), - (logins) => logins.close(), + const logins = self.open( + async () => new Logins(resources().resources), + (opened) => opened.close(), ); - const logins = (): Logins => loginsResource.value; - /** The current login, if `owner` started it. */ - const mine = (owner: string): LoginState | null => (current.value.owner === owner ? current.value.login : null); + // The flow's state as the logins hold it: each method's change is made before it returns. + const state = followNative( + self, + (changed) => logins.value.subscribe(changed), + () => logins.value.state, + ); + // An owner leaving the Providers sheet everywhere (no reference of theirs left: closed sheets, disconnects) ends + // their unfinished login; another owner's sheet coming and going does not. + const abandoned = self.derive([holders, state], ([references, { login, owner }]) => + login?.status === "working" && + owner !== null && + !references.some(({ principal }) => principal !== undefined && principalKey(principal) === owner) + ? owner + : null, + ); + self.on(abandoned, async ({ value: owner }) => { + if (owner !== null) logins.value.disconnect(owner); + }); return { - login: deriveTyped(self, [current], ([value]) => value.login), - owner: self.derive([current], ([value]) => value.owner), + login: deriveTyped(self, [state], ([value]) => value.login), + owner: self.derive([state], ([value]) => value.owner), start: async ({ owner, providerId, authType, remote }) => ({ - attemptId: logins().start(owner, providerId, authType, remote), + attemptId: logins.value.start(owner, providerId, authType, remote), }), answer: async ({ owner, attemptId, promptId, value }) => { - const current = mine(owner); - if ( - current?.attemptId === attemptId && - current.prompt?.id === promptId && - (current.prompt.type === "secret" || current.prompt.type === "manual_code") - ) - processLogs.redactor.addSecret(value); - logins().answer(owner, attemptId, promptId, value); + logins.value.answer(owner, attemptId, promptId, value); return null; }, cancel: async ({ owner, attemptId }) => { - logins().cancel(owner, attemptId); + await logins.value.cancel(owner, attemptId); return null; }, - abandon: async ({ owner }) => { - logins().disconnect(owner); - return null; - }, - dismiss: async ({ owner, attemptId }, ctx) => { - const login = mine(owner); - if (login?.attemptId === attemptId && login.status !== "working") - await current.change((draft) => { - draft.login = null; - }, ctx); + dismiss: async ({ owner, attemptId }) => { + logins.value.dismiss(owner, attemptId); return null; }, logout: async ({ providerId }) => { - await logins().logout(providerId); + await logins.value.logout(providerId); return null; }, refresh: async ({ providerId }) => { - await logins().refresh(providerId); + await logins.value.refresh(providerId); return { models: resources() .resources.models.getModels() @@ -137,20 +73,6 @@ export default defineFacet((host) => { const service = self.get(LoginService, {}); // This owner's logins: one device's (its cookie), the Radius owner's, or the device owner's (every local tab). const owner = principalKey(principal); - // This owner leaving the sheet everywhere (release, disconnect) ends its unfinished login; another owner's - // sheet coming and going does not. - const server = serverOf(self); - self.open( - async () => service, - async (held) => { - try { - await server().root((ctx) => held.abandon({ owner }, ctx)); - } catch (error) { - // The host is closing (no root starts any more): the login service ends with it. - if (!isChordError(error, "released")) throw error; - } - }, - ); // Prompts and notices (an auth URL, a device code) only for the owner that started the login. const loginView = deriveTyped(self, [service.login, service.owner], ([login, started]) => started === owner ? login : null, diff --git a/apps/p2/plugins/login/tui.ts b/apps/p2/plugins/login/tui.ts index 5d4fa2135..64d2c7ae2 100644 --- a/apps/p2/plugins/login/tui.ts +++ b/apps/p2/plugins/login/tui.ts @@ -422,10 +422,11 @@ async function logout(ctx: TuiContext, name: string | undefined): Promise ref.release(); return; } + // Shown at once; a refused sign-out says so after it. + ctx.notify(`Logged out of ${lineText(provider.name)}`); void ref .logout({ providerId: provider.id }, BACKGROUND_CONTEXT) - .then(() => ctx.notify(`Logged out of ${lineText(provider.name)}`)) - .catch((error: unknown) => ctx.notify(error instanceof Error ? error.message : String(error), "error")) + .catch((error: unknown) => ctx.fail(error)) .finally(() => ref.release()); }; if (!list.length) { diff --git a/apps/p2/plugins/login/web.tsx b/apps/p2/plugins/login/web.tsx index 77f6b6437..10ca0ed87 100644 --- a/apps/p2/plugins/login/web.tsx +++ b/apps/p2/plugins/login/web.tsx @@ -1,7 +1,6 @@ // The Providers sheet (any owner): sign in to model providers, refresh their catalogs, sign out. import type { AuthEvent } from "@earendil-works/pi-ai"; -import type { Held } from "@p2/app/v1"; import { BACKGROUND_CONTEXT, contribute, defineFacet } from "@p2/app/v1"; import { CheckIcon, @@ -15,6 +14,7 @@ import { useHost, useNavigation, useObject, + useOptimisticCall, useOwner, useReadable, useRoute, @@ -23,8 +23,6 @@ import { import { type ReactNode, useEffect, useRef, useState } from "react"; import { type LoginState, ProviderLogin } from "./contract.ts"; -const errorText = (reason: unknown): string => (reason instanceof Error ? reason.message : String(reason)); - /** Links from providers open only over http(s). */ function Link({ url, label }: { url: string; label: string }) { let safe = false; @@ -77,20 +75,14 @@ function Notice({ event }: { event: AuthEvent }): ReactNode { } } -function PromptForm({ state, login }: { state: LoginState; login: Held }) { +/** The provider's question; answering it shows the flow working at once (the next prompt or progress follows). */ +function PromptForm({ state, onAnswer }: { state: LoginState; onAnswer: (promptId: string, value: string) => void }) { const prompt = state.prompt; const [value, setValue] = useState(""); - const [sending, setSending] = useState(false); - const [error, setError] = useState(); if (!prompt) return null; const answer = (input: string) => { - setSending(true); - setError(undefined); - login - .answer({ attemptId: state.attemptId, promptId: prompt.id, value: input }, BACKGROUND_CONTEXT) - .then(() => setValue("")) - .catch((reason: unknown) => setError(errorText(reason))) - .finally(() => setSending(false)); + onAnswer(prompt.id, input); + setValue(""); }; if (prompt.type === "select") return ( @@ -98,19 +90,12 @@ function PromptForm({ state, login }: { state: LoginState; login: Held{prompt.message}

{prompt.options.map((option) => ( - ))}
- {error &&

{error}

}
); return ( @@ -140,8 +125,7 @@ function PromptForm({ state, login }: { state: LoginState; login: Held setValue(event.target.value)} /> - {error &&

{error}

} - @@ -152,8 +136,22 @@ function PromptForm({ state, login }: { state: LoginState; login: Held void }) { const login = useObject(ProviderLogin, {}); - const providers = useReadable(login?.providers) ?? []; - const state = useReadable(login?.login) ?? null; + // Provider errors go to the app's toast, as in Pi Mobile. + const { fail } = useComposerState(); + // Signing out shows at once; a refused sign-out puts the provider back. + const [providers, signOut] = useOptimisticCall( + useReadable(login?.providers) ?? [], + (list, providerId: string) => + list.map((entry) => (entry.id === providerId ? { ...entry, signedIn: null } : entry)), + fail, + ); + // A start, an answer or a cancel shows the flow's next state at once: working (the provider's attempt and its + // prompts are the server's, so a start shows the spinner until they arrive), or cancelled. + const [state, guessLogin] = useOptimisticCall( + useReadable(login?.login) ?? null, + (_, next: LoginState) => next, + fail, + ); const [query, setQuery] = useState(""); const nav = useNavigation(); const route = useRoute(); @@ -184,11 +182,6 @@ function ProvidersSheet({ onClose }: { onClose: () => void }) { }, [host, login, selected]); const [result, setResult] = useState(); const [busy, setBusy] = useState(false); - // Provider errors go to the app's toast, as in Pi Mobile (a prompt's own error stays in its form). - const { setError } = useComposerState(); - const perform = (action: Promise | undefined) => { - void action?.catch((reason: unknown) => setError(errorText(reason))); - }; const providerId = selected; const provider = providers.find((entry) => entry.id === providerId); if (login && provider) { @@ -203,7 +196,17 @@ function ProvidersSheet({ onClose }: { onClose: () => void }) { {state.notices.map((entry) => ( ))} - {state.prompt && } + {state.prompt && ( + + guessLogin({ ...state, prompt: null }, () => + login.answer({ attemptId: state.attemptId, promptId, value }, BACKGROUND_CONTEXT), + ) + } + /> + )} {state.status === "working" && !state.prompt && !state.notices.length && (
)} @@ -219,7 +222,13 @@ function ProvidersSheet({ onClose }: { onClose: () => void }) { @@ -247,7 +256,17 @@ function ProvidersSheet({ onClose }: { onClose: () => void }) { key={method.type} className="login-action primary" onClick={() => - perform(login.start({ providerId: provider.id, authType: method.type }, BACKGROUND_CONTEXT)) + guessLogin( + { + attemptId: "", + providerId: provider.id, + authType: method.type, + status: "working", + prompt: null, + notices: [], + }, + () => login.start({ providerId: provider.id, authType: method.type }, BACKGROUND_CONTEXT), + ) } > {method.label} @@ -271,7 +290,7 @@ function ProvidersSheet({ onClose }: { onClose: () => void }) { : "Catalog updated; no models reported.", ), ) - .catch((reason: unknown) => setError(errorText(reason))) + .catch(fail) .finally(() => setBusy(false)); }} > @@ -283,7 +302,9 @@ function ProvidersSheet({ onClose }: { onClose: () => void }) { diff --git a/apps/p2/plugins/logs/server.ts b/apps/p2/plugins/logs/server.ts index e8429035a..bae2eb35d 100644 --- a/apps/p2/plugins/logs/server.ts +++ b/apps/p2/plugins/logs/server.ts @@ -1,7 +1,7 @@ import { readFile, stat } from "node:fs/promises"; import { join } from "node:path"; import { arr, compile, obj, str } from "@earendil-works/pi-schema"; -import { contribute, defineFacet, deriveTyped, forbidden, isOwner } from "@p2/app/v1"; +import { contribute, defineFacet, deriveTyped, followNative, forbidden, isOwner } from "@p2/app/v1"; import { captureProcessLogs, type LogSnapshot, processLogs } from "@p2/app/v1/node"; import { Server } from "@p2/app/v1/server"; import { LogLineSchema, LogNotices, Logs } from "./contract.ts"; @@ -71,14 +71,16 @@ export default defineFacet((host) => { host.serve(LogNotices, (_params, principal, self) => { if (!isOwner(principal)) throw forbidden("Notices are only available to owners"); - const notices = self.writable(processLogs.notices()); - self.open( - async () => processLogs.events.subscribe(() => notices.set(processLogs.notices())), - (stop) => stop(), + // The process's notices as it keeps them: a dismissal tells this source before `dismiss` returns. + const notices = followNative( + self, + (changed) => processLogs.events.subscribe(changed), + () => processLogs.notices(), ); return { variant: "owner", members: { + // Diffed: the process tells of every log line, and the notices rarely change with one. notices: deriveTyped(self, [notices], ([value]) => value), dismiss: async ({ id }) => { processLogs.dismissNotice(id); @@ -90,17 +92,21 @@ export default defineFacet((host) => { host.serve(Logs, ({ source }, principal, self) => { if (!isOwner(principal)) throw forbidden("Logs are only available to owners"); - const sources = self.writable(processLogs.sources()); - const lines = self.writable(processLogs.lines(source)); - self.open( - async () => - processLogs.events.subscribe((changed) => { - sources.set(processLogs.sources()); - if (changed === source || !processLogs.sources().some((entry) => entry.id === source)) - lines.set(processLogs.lines(source)); - }), - (stop) => stop(), + const sources = followNative( + self, + (changed) => processLogs.events.subscribe(changed), + () => processLogs.sources(), ); + // Read again only when this source changed (or went), not for every line of another one. + const lines = followNative( + self, + (changed) => + processLogs.events.subscribe((changedSource) => { + if (changedSource === source || !processLogs.sources().some((entry) => entry.id === source)) changed(); + }), + () => processLogs.lines(source), + ); + // Diffed: a frame carries the lines and sources that changed, not the whole buffer. return { variant: "owner", members: { diff --git a/apps/p2/plugins/logs/tui.ts b/apps/p2/plugins/logs/tui.ts index c8f185a0e..593d250f5 100644 --- a/apps/p2/plugins/logs/tui.ts +++ b/apps/p2/plugins/logs/tui.ts @@ -9,6 +9,7 @@ import type { TuiContext } from "@p2/app/v1/tui"; import { ListOverlay, lineText, + type OptimisticAdd, type TuiCommand, TuiCommands, type TuiRuntime, @@ -30,8 +31,11 @@ function sourceLabel(source: string): string { export class NoticeWatch implements TuiSurface { readonly runtime: TuiRuntime; private scope: TuiScope | undefined; - private service: () => Held | undefined = () => undefined; - notices: Notice[] = []; + private service: Held | undefined; + /** The notices as shown (those dismissed while their calls are in flight left out), and how to dismiss one. */ + private shown: () => Notice[] = () => this.received; + private dismissing: OptimisticAdd | undefined; + private received: Notice[] = []; private seen = new Set(); private first = true; private stop: () => void; @@ -40,6 +44,10 @@ export class NoticeWatch implements TuiSurface { const changed = (): void => { if (runtime.role !== "owner" || this.scope) return; this.scope = new TuiScope(runtime); + [this.shown, this.dismissing] = this.scope.optimistic( + () => this.received, + (notices, id: number) => notices.filter((notice) => notice.id !== id), + ); this.service = this.scope.watch(LogNotices, {}, (ref, follow) => follow(ref.notices, (notices) => this.arrived(notices ?? [])), ); @@ -48,8 +56,18 @@ export class NoticeWatch implements TuiSurface { this.stop = () => runtime.listeners.delete(changed); changed(); } + /** The notices as shown: those dismissed meanwhile left out. */ + get notices(): Notice[] { + return this.shown(); + } + /** Dismisses notice `id`: it leaves the list at once and comes back if the server refuses. */ + dismiss(id: number): void { + const service = liveIn(this.service, "owner"); + if (service && this.dismissing) + this.runtime.perform(this.dismissing(id, service.dismiss({ id }, BACKGROUND_CONTEXT))); + } private arrived(notices: Notice[]): void { - this.notices = notices; + this.received = notices; const fresh = notices.filter((notice) => !this.seen.has(notice.id)); for (const notice of notices) this.seen.add(notice.id); if (!fresh.length) return; @@ -61,10 +79,6 @@ export class NoticeWatch implements TuiSurface { this.runtime.notify(`${sourceLabel(notice.source)}: ${lineText(notice.message)}`, "warning"); this.first = false; } - owner(): Held | undefined { - const ref = this.service(); - return liveIn(ref, "owner"); - } render(): string[] { return []; } @@ -102,11 +116,7 @@ function openNotices(ctx: TuiContext): void { id: "app.session.delete", label: "dismiss", run: (row) => { - const service = watch.owner(); - if (row && service?.variant === "owner") - void service - .dismiss({ id: Number(row.key) }, BACKGROUND_CONTEXT) - .catch((error: unknown) => ctx.notify(String(error), "error")); + if (row) watch.dismiss(Number(row.key)); }, }, ], @@ -139,9 +149,7 @@ function openLogs(ctx: TuiContext, initial: string | undefined): void { ctx.requestRender(); }), ]; - void whenLive(current).catch((error: unknown) => - ctx.notify(error instanceof Error ? error.message : String(error), "error"), - ); + void whenLive(current).catch((error: unknown) => ctx.fail(error)); }; acquire(); const overlay = ctx.overlay( diff --git a/apps/p2/plugins/logs/web.tsx b/apps/p2/plugins/logs/web.tsx index eb9710797..249c4738f 100644 --- a/apps/p2/plugins/logs/web.tsx +++ b/apps/p2/plugins/logs/web.tsx @@ -1,6 +1,7 @@ -import { defineFacet, whenLive } from "@p2/app/v1"; +import { contribute, defineFacet, whenLive } from "@p2/app/v1"; import { closeLayers, + errorText, openLayer, Sheet, Slots, @@ -14,7 +15,6 @@ import { import { useEffect, useRef, useState } from "react"; import { Logs } from "./contract.ts"; import "./style.css"; -import { contribute } from "@p2/app/v1"; function sourceLabel(source: string): string { if (source.startsWith("session:")) return `Worker ${source.slice(8)}`; @@ -41,7 +41,7 @@ function LogsSheet({ source }: { source: string }) { useEffect(() => { let current = true; void (logs ? whenLive(logs) : Promise.resolve()).catch((error: unknown) => { - if (current) setMessage(error instanceof Error ? error.message : String(error)); + if (current) setMessage(errorText(error)); }); return () => { current = false; @@ -104,7 +104,12 @@ function LogsSheet({ source }: { source: string }) {
{message && {message}}
-				{text || (status === "live" ? "No log lines" : status === "gone" ? "Logs unavailable" : "Loading logs…")}
+				{text ||
+					(status === "live"
+						? "No log lines"
+						: status === "failed" || status === "ended"
+							? "Logs unavailable"
+							: "Loading logs…")}
 			
); diff --git a/apps/p2/plugins/mcp/config.ts b/apps/p2/plugins/mcp/config.ts index 4a94c4a2f..57bf79b78 100644 --- a/apps/p2/plugins/mcp/config.ts +++ b/apps/p2/plugins/mcp/config.ts @@ -38,14 +38,8 @@ export type OwnerServer = /** The owner's servers and why entries were skipped. */ export type OwnerConfig = { readonly servers: readonly OwnerServer[]; readonly errors: readonly string[] }; -/** - * The file as last read: not yet, a valid configuration, or why it is not one (its readers keep the last valid servers, - * also from before this reader existed: a reload's new reader may first see a save caught half-way). - */ -type OwnerConfigRead = - | { readonly kind: "unread" } - | { readonly kind: "valid"; readonly config: OwnerConfig } - | { readonly kind: "invalid"; readonly failure: string }; +/** The file as last read: the last valid servers and their errors, and why the last read failed (null: it did not). */ +export type OwnerConfigState = OwnerConfig & { readonly failure: string | null }; /** * The owner's configuration in `text` (`{ "mcpServers": { name: entry } }`, comments allowed): invalid entries are @@ -158,32 +152,40 @@ export function expandHostVariables(value: string): string { export class OwnerConfigFile { /** Watches the file and reads it at each change; `start()` reads it the first time, `close()` stops. */ readonly observer: Observer; - #read: OwnerConfigRead = { kind: "unread" }; - readonly #listeners = new Set<(read: OwnerConfigRead) => void>(); + #state: OwnerConfigState; + readonly #listeners = new Set<() => void>(); - constructor(dataDir: string) { + /** + * `last`: the servers last read validly before this reader existed (a restarted manager's: its new reader may first + * see a save caught half-way, and keeps them then). + */ + constructor(dataDir: string, last: OwnerConfig) { const path = join(dataDir, "mcp.json"); + this.#state = { ...last, failure: null }; this.observer = new Observer( [{ env: new NodeExecutionEnv({ cwd: dataDir }), targets: () => [{ path }] }], async (retrying) => { try { - this.#read = { kind: "valid", config: parseOwnerConfig((await optionalText(path)) ?? "{}") }; + this.#state = { ...parseOwnerConfig((await optionalText(path)) ?? "{}"), failure: null }; } catch (error) { - // A save caught half-way is read once more before it counts. + // A save caught half-way is read once more before it counts; an invalid read keeps the last valid servers. if (!retrying) return true; - this.#read = { kind: "invalid", failure: error instanceof Error ? error.message : String(error) }; + this.#state = { ...this.#state, failure: error instanceof Error ? error.message : String(error) }; } - for (const listener of this.#listeners) listener(this.#read); + for (const listener of this.#listeners) listener(); return false; }, 2000, ); } - /** `listener` gets the last read at once (once there is one), then every read's. */ - subscribe(listener: (read: OwnerConfigRead) => void): () => void { + get state(): OwnerConfigState { + return this.#state; + } + + /** `listener` hears of every read from now on; the result stops it. */ + subscribe(listener: () => void): () => void { this.#listeners.add(listener); - if (this.#read.kind !== "unread") listener(this.#read); return () => this.#listeners.delete(listener); } } diff --git a/apps/p2/plugins/mcp/routes.ts b/apps/p2/plugins/mcp/routes.ts index 463c47dc6..7ab0a2281 100644 --- a/apps/p2/plugins/mcp/routes.ts +++ b/apps/p2/plugins/mcp/routes.ts @@ -4,16 +4,15 @@ // p2 host; for any other owner a probe page finds out first (`LoopbackProbe`). import type { IncomingMessage, ServerResponse } from "node:http"; -import { type Ctx, isOwner, type StoreSelf, until } from "@p2/app/v1"; -import { type RequestAccess, Routes, type ServerParts, serverOf } from "@p2/app/v1/server"; +import { type Ctx, isOwner, type StoreSelf } from "@p2/app/v1"; +import { type RequestAccess, Routes } from "@p2/app/v1/server"; import { McpManager } from "./contract.ts"; import { resultPage, sendPage, sendProbePage } from "./pages.ts"; /** Contributes the sign-in routes. */ export function signInRoutes(self: StoreSelf): void { - const server = serverOf(self); self.contribute(Routes, { - "GET /mcp/sign-in/*": (request, response, access, ctx) => signIn(request, response, access, server, ctx), + "GET /mcp/sign-in/*": (request, response, access, ctx) => signIn(request, response, access, ctx), "GET /mcp/callback": (request, response, access, ctx) => callback(request, response, access, ctx), }); } @@ -29,7 +28,6 @@ async function signIn( request: IncomingMessage, response: ServerResponse, { principal, origin }: RequestAccess, - server: () => ServerParts, ctx: Ctx, ): Promise { if (!isOwner(principal)) return refuse(response, principal === undefined); @@ -64,28 +62,10 @@ async function signIn( } if (start.kind === "authorized") return sendPage(response, 200, resultPage(`Signed in to ${name}`, "You may close this page.")); - keepManager(name, server); response.writeHead(302, { Location: start.url }); response.end(); } -/** - * Keeps the manager, which holds the flow, while the sign-in to `name` waits for its browser step (until it finishes, - * fails or expires), also when no view holds it meanwhile. - */ -function keepManager(name: string, server: () => ServerParts): void { - void server() - .root(async (ctx) => { - const manager = await ctx.get(McpManager, {}); - await until( - manager.signIns, - (signIns) => !signIns.some((entry) => entry.server === name && entry.status === "waiting"), - ctx, - ); - }) - .catch(() => {}); -} - /** Completes a route sign-in with the authorization response the browser brings back. */ async function callback( request: IncomingMessage, diff --git a/apps/p2/plugins/mcp/server.ts b/apps/p2/plugins/mcp/server.ts index deb156d36..378ec7d36 100644 --- a/apps/p2/plugins/mcp/server.ts +++ b/apps/p2/plugins/mcp/server.ts @@ -6,7 +6,7 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; -import { arr, int, nul, obj, str, union, unknown } from "@earendil-works/pi-schema"; +import { arr, int, obj, str, union, unknown } from "@earendil-works/pi-schema"; import { type Ctx, contribute, @@ -15,6 +15,7 @@ import { deriveTyped, type Facet, type Factory, + followNative, forbidden, type HandleOf, isOwner, @@ -23,7 +24,6 @@ import { LocationSchema, literal, locationText, - type Native, nativeOf, PluginError, type Ref, @@ -32,7 +32,6 @@ import { signalOf, unsafe, until, - untilAborted, type Watchable, type WorkerPrincipal, } from "@p2/app/v1"; @@ -41,8 +40,8 @@ import type { EnvConfig } from "../envs/config.ts"; import { EnvManagers, EnvService } from "../envs/contract.ts"; import { Catalog, type Session } from "../sessions/contract.ts"; import { type Child, ChildStore } from "../subagents/contract.ts"; -import { type OwnerConfig, OwnerConfigFile, type OwnerServer } from "./config.ts"; -import { InstanceSchema, Mcp, McpManager, type ProjectFile, SignInSchema } from "./contract.ts"; +import { type OwnerConfig, OwnerConfigFile, type OwnerConfigState, type OwnerServer } from "./config.ts"; +import { type Instance, InstanceSchema, Mcp, McpManager, type ProjectFile } from "./contract.ts"; import { McpCredentials } from "./credentials.ts"; import type { ConnectEnv } from "./environments.ts"; import { type ClientInfo, Instances, RECONNECT } from "./instances.ts"; @@ -88,16 +87,6 @@ export function mcpServerFacet(viewLingerMs: number, connector: EnvConnector): F }); } -/** - * The owner's configuration as last validly read, and why the last read failed (null: it did not). In memory, kept - * across a reload: a new reader seeing a save caught half-way keeps these servers. - */ -const Configured = defineTypedState("p2.mcp.config", { - schema: obj({ servers: unsafe(unknown()), errors: arr(str()), failure: union([str(), nul()]) }), - version: 1, - initial: () => ({ servers: [], errors: [], failure: null }), -}); - /** The leases of session workers; kept across a reload of the plugin, so the new supervisor starts what they use. */ const Leased = defineTypedState("p2.mcp.leases", { schema: obj({ @@ -120,46 +109,41 @@ const Leased = defineTypedState("p2.mcp.leases", { }); /** - * The supervisor's instances as it last listed them (a reload's new supervisor starts with their tools), and the - * sign-ins under way. + * What a restarted manager's natives start from (in memory, kept across a reload's restart; never shown): the servers + * last read validly (a new reader may first see a save caught half-way) and the instances as last listed (their tools + * stay listed until their servers answer again). */ -const Listed = defineTypedState("p2.mcp.instances", { - schema: obj({ instances: arr(InstanceSchema), signIns: arr(SignInSchema) }), - version: 2, - initial: () => ({ instances: [], signIns: [] }), +const Memento = defineTypedState("p2.mcp.memento", { + schema: obj({ servers: unsafe(unknown()), errors: arr(str()), instances: arr(InstanceSchema) }), + version: 1, + initial: () => ({ servers: [], errors: [], instances: [] }), }); -/** The manager: its documents, the file and the supervisor as natives, and the routines that keep them in step. */ +/** + * The manager: the file, the supervisor and the sign-ins as natives it follows, the leases as its document, and the + * routine that starts and closes instances as they change. + */ function managerFactory(connector: EnvConnector): Factory { return (_params, self) => { const server = self.get(Server, {}); const envs = self.get(EnvService, {}); - const work = workEnvironments(self.get(Catalog, {}), self.get(ChildStore, {}), self); - const configured = self.state(Configured); + const catalog = self.get(Catalog, {}); + const store = self.get(ChildStore, {}); + const work = deriveTyped(self, [catalog.sessions, store.children], ([sessions, children]) => + workEnvironments(sessions, children), + ); const leased = self.state(Leased); - const listed = self.state(Listed); + const memento = self.state(Memento); const placed = placements(); - const placement = () => placed(configured.value, leased.value.leases, envs.configs.value, work.value); const connect = connector(self); const file = self.open( async () => { - const opened = new OwnerConfigFile(server.info.value.dataDir); + const opened = new OwnerConfigFile(server.info.value.dataDir, memento.value); await opened.observer.start(); return opened; }, (opened) => opened.observer.close(), ); - const instances = self.open( - async () => { - // After a reload the leases are still here: the routine below starts what they use, with the tools listed - // before. Not here: natives open before derives compute. - const info = await clientInfo(server.info.value.app); - return new Instances(info, RECONNECT, listed.value.instances, connect, (oauth) => - signIns.value.authProvider(oauth), - ); - }, - (opened) => opened.close(), - ); const signIns = self.open( async () => new SignIns( @@ -170,11 +154,42 @@ function managerFactory(connector: EnvConnector): Factory { ), (opened) => opened.close(), ); + const instances = self.open( + async () => { + const info = await clientInfo(server.info.value.app); + const opened = new Instances(info, RECONNECT, memento.value.instances, connect, (oauth) => + signIns.value.authProvider(oauth), + ); + // What the leases use now starts at once (after a restart with the tools listed before), so the supervisor's + // first list already shows it; derives compute only after the natives opened, so placed from the sources. + const at = workEnvironments(catalog.sessions.value, store.children.value); + opened.reconcile(placed(file.value.state, leased.value.leases, envs.configs.value, at)); + return opened; + }, + (opened) => opened.close(), + ); const probe = self.open( async () => new LoopbackProbe(), (opened) => opened.close(), ); - self.onLive((ctx) => followNatives(file, instances, signIns, configured, listed, ctx)); + // The file, the supervisor and the sign-ins as they are: every method's change is made before it returns. + const configured = followNative( + self, + (changed) => file.value.subscribe(changed), + () => file.value.state, + ); + const listed = followNative( + self, + (changed) => { + const stops = [instances.value.subscribe(changed), signIns.value.subscribe(changed)]; + return () => { + for (const stop of stops) stop(); + }; + }, + () => ({ instances: instances.value.list(), signIns: signIns.value.list() }), + ); + keepMemento(self, memento, configured, listed); + const placement = () => placed(configured.value, leased.value.leases, envs.configs.value, work.value); // The current configuration, leases, environments and places of work, then every change of them, start and close // instances; methods that change leases reconcile at once too, so a call right after `setLocations` finds its // instance. @@ -209,7 +224,6 @@ function managerFactory(connector: EnvConnector): Factory { ...managerMethods(leased, () => instances.value, placement), ...signInMethods( configured, - listed, () => signIns.value, () => probe.value, ), @@ -217,14 +231,36 @@ function managerFactory(connector: EnvConnector): Factory { }; } +/** + * Keeps the memento a restarted manager's natives start from: the servers of each valid read, and the instances once + * one of them lists other tools (instances closing change no tools, so a manager ending writes nothing). + */ +function keepMemento( + self: Self, + memento: HandleOf, + configured: Watchable, + listed: Watchable<{ readonly instances: readonly Instance[] }>, +): void { + self.on(configured, async ({ value: { servers, errors, failure } }, ctx) => { + if (failure === null) await memento.change((draft) => Object.assign(draft, { servers, errors }), ctx); + }); + self.on(listed, async ({ value: { instances } }, ctx) => { + const remembered = new Map(memento.value.instances.map((instance) => [instance.id, toolNames(instance)])); + if (instances.some((instance) => remembered.get(instance.id) !== toolNames(instance))) + await memento.change((draft) => Object.assign(draft, { instances }), ctx); + }); +} + +/** The names of the tools `instance` lists, as one string. */ +const toolNames = (instance: Instance): string => instance.tools.map((tool) => tool.name).join("\n"); + /** The environments each session's conversations work on, as the catalog and the subagent store record them. */ function workEnvironments( - catalog: Ref, - store: Ref, - self: Self, -): Watchable { - return deriveTyped(self, [catalog.sessions, store.children], ([sessions, children]) => - Object.fromEntries(sessions.map((session) => [session.id, sessionEnvironments(session, children[session.id])])), + sessions: readonly Session[], + children: Readonly>, +): WorkEnvironments { + return Object.fromEntries( + sessions.map((session) => [session.id, sessionEnvironments(session, children[session.id])]), ); } @@ -254,52 +290,6 @@ function placements(): ( }; } -/** Copies what the file reads, the supervisor lists and the sign-ins are into the manager's documents until `ctx` ends. */ -async function followNatives( - file: Native, - instances: Native, - signIns: Native, - configured: HandleOf, - listed: HandleOf, - ctx: Ctx, -): Promise { - // Changes heard while this routine stops would change state with an ended ctx. - const show = (): void => { - if (signalOf(ctx).aborted) return; - void listed - .change((draft) => { - draft.instances = instances.value.list(); - draft.signIns = signIns.value.list(); - }, ctx) - .catch(() => {}); - }; - const stops = [ - file.value.subscribe((read) => { - if (signalOf(ctx).aborted) return; - void configured - .change((draft) => { - // An invalid read keeps the servers last read validly. - if (read.kind === "invalid") draft.failure = read.failure; - else if (read.kind === "valid") - Object.assign(draft, { - servers: [...read.config.servers], - errors: [...read.config.errors], - failure: null, - }); - }, ctx) - .catch(() => {}); - }), - instances.value.subscribe(show), - signIns.value.subscribe(show), - ]; - show(); - try { - await untilAborted(ctx); - } finally { - for (const stop of stops) stop(); - } -} - /** The manager's methods: the lease rules applied on the leases' line, calls through the leased instance, restarts. */ function managerMethods(leased: HandleOf, instances: () => Instances, placement: () => Placement) { return { @@ -341,12 +331,7 @@ function managerMethods(leased: HandleOf, instances: () => Instan } /** The manager's sign-in methods: flows of the owner's OAuth servers, the callback route's completion, the probe. */ -function signInMethods( - configured: HandleOf, - listed: HandleOf, - signIns: () => SignIns, - probe: () => LoopbackProbe, -) { +function signInMethods(configured: Watchable, signIns: () => SignIns, probe: () => LoopbackProbe) { const oauthServer = (name: string): OAuthServer => { const server = configured.value.servers.find((candidate) => candidate.name === name); const oauth = server === undefined ? undefined : oauthServerOf(server); @@ -354,14 +339,9 @@ function signInMethods( return oauth; }; return { - beginSignIn: async ({ server, redirect }: { server: string; redirect: SignInRedirect }, ctx: Ctx) => { - const start = await asPluginError(signIns().begin(oauthServer(server), redirect)); - // Listed before the answer: a caller following `signIns` for this flow's end sees it under way first. - await listed.change((draft) => { - draft.signIns = signIns().list(); - }, ctx); - return start; - }, + // The flow is listed before the answer: a caller following `signIns` for its end sees it under way first. + beginSignIn: ({ server, redirect }: { server: string; redirect: SignInRedirect }) => + asPluginError(signIns().begin(oauthServer(server), redirect)), completeSignIn: ({ state, code, iss, error }: { state: string; code?: string; iss?: string; error?: string }) => signIns().complete( error !== undefined || code === undefined diff --git a/apps/p2/plugins/mcp/tui.ts b/apps/p2/plugins/mcp/tui.ts index 95d665a03..b4fe6389a 100644 --- a/apps/p2/plugins/mcp/tui.ts +++ b/apps/p2/plugins/mcp/tui.ts @@ -7,7 +7,6 @@ import { BACKGROUND_CONTEXT, contribute, defineFacet, - follow, type Held, type HeldIn, liveIn, @@ -39,48 +38,51 @@ const messageOf = (error: unknown): string => (error instanceof Error ? error.me /** `/mcp [paste ]`: the overlay, or a pasted redirect URL for the sign-in waiting for it. */ function openMcp(ctx: TuiContext, args: string | undefined): void { - const ref = ctx.host.hold(Mcp, {}, BACKGROUND_CONTEXT); const words = args?.trim().split(/\s+/).filter(Boolean) ?? []; if (words[0] === "paste") { + const ref = ctx.host.hold(Mcp, {}, BACKGROUND_CONTEXT); void paste(ctx, ref, words[1]).finally(() => ref.release()); return; } - let instances: Instance[] = []; + const scope = ctx.scope(); + let received: Instance[] = []; let signIns: SignIn[] = []; let status = ""; - let closed = false; - const stops: (() => void)[] = []; - void whenLive(ref) - .then(() => { - const view = liveIn(ref, "owner"); - if (closed || view === undefined) return; - stops.push( - follow(view.instances, (value) => { - instances = value; - ctx.requestRender(); - }), - follow(view.signIns, (value) => { - signIns = value; - ctx.requestRender(); - }), - ); - }) - .catch(() => {}); - const run = (label: string, action: (view: OwnerView) => Promise) => { - const view = liveIn(ref, "owner"); + const held = scope.watch(Mcp, {}, (mcp, follow) => { + const view = liveIn(mcp, "owner"); + if (!view) return; + follow(view.instances, (value) => { + received = value; + }); + follow(view.signIns, (value) => { + signIns = value; + }); + }); + // A restart shows the server starting and a sign-out signed out at once; the status line follows the call. + type StatusGuess = { instance: string; status: Instance["status"] }; + const [instances, changing] = scope.optimistic( + () => received, + (list, guess: StatusGuess) => + list.map((instance) => (instance.id === guess.instance ? { ...instance, status: guess.status } : instance)), + ); + const run = (label: string, action: (view: OwnerView) => Promise, guess?: StatusGuess) => { + const view = liveIn(held, "owner"); if (view === undefined) return; status = `${label}…`; - ctx.requestRender(); - void action(view) - .then(() => { + const call = (async () => { + try { + await action(view); status = `${label}: done`; - }) - .catch((error: unknown) => { + } catch (error) { status = `${label}: ${messageOf(error)}`; - }) - .finally(() => ctx.requestRender()); + throw error; + } + })(); + // The status line shows how it ended, a failure included. + void (guess ? changing(guess, call) : call).catch(() => ctx.requestRender()); + ctx.requestRender(); }; - const byRow = (row: ListRow | undefined) => instances.find((instance) => instance.id === row?.key); + const byRow = (row: ListRow | undefined) => instances().find((instance) => instance.id === row?.key); const signIn = (view: OwnerView, server: string) => view.signIn({ server }, BACKGROUND_CONTEXT).then(async (start) => { if (start.kind === "authorized") return; @@ -92,9 +94,9 @@ function openMcp(ctx: TuiContext, args: string | undefined): void { const overlay = ctx.overlay( "mcp", new ListOverlay(ctx, { - title: () => `MCP servers (${instances.length})`, + title: () => `MCP servers (${instances().length})`, rows: () => - instances.map((instance) => { + instances().map((instance) => { const flow = signIns.find((entry) => entry.server === instance.server); const place = [ instance.location ? locationText(instance.location) : "", @@ -127,8 +129,10 @@ function openMcp(ctx: TuiContext, args: string | undefined): void { run: (row) => { const instance = byRow(row); if (instance) - run(`Restarting ${instance.server}`, (view) => - view.restart({ instance: instance.id }, BACKGROUND_CONTEXT), + run( + `Restarting ${instance.server}`, + (view) => view.restart({ instance: instance.id }, BACKGROUND_CONTEXT), + { instance: instance.id, status: "starting" }, ); }, }, @@ -147,17 +151,17 @@ function openMcp(ctx: TuiContext, args: string | undefined): void { run: (row) => { const instance = byRow(row); if (instance?.auth === "oauth") - run(`Signing out of ${instance.server}`, (view) => - view.signOut({ server: instance.server }, BACKGROUND_CONTEXT), + run( + `Signing out of ${instance.server}`, + (view) => view.signOut({ server: instance.server }, BACKGROUND_CONTEXT), + { instance: instance.id, status: "sign-in" }, ); }, }, ], footer: () => (status ? [ctx.theme.current.fg("muted", ` ${lineText(status)}`)] : []), onClose: () => { - closed = true; - for (const stop of stops) stop(); - ref.release(); + scope.dispose(); overlay.close(); }, }), diff --git a/apps/p2/plugins/mcp/web.tsx b/apps/p2/plugins/mcp/web.tsx index 49cc59d51..fdf27d698 100644 --- a/apps/p2/plugins/mcp/web.tsx +++ b/apps/p2/plugins/mcp/web.tsx @@ -6,11 +6,13 @@ import { BACKGROUND_CONTEXT, contribute, defineFacet, type HeldIn, locationText import { ChevronIcon, closeLayers, + errorText, openLayer, Sheet, Slots, useComposerState, useObject, + useOptimisticCall, useOwner, useReadable, useSheet, @@ -20,7 +22,29 @@ import { type Instance, Mcp, type SignIn, signInLink } from "./contract.ts"; type OwnerView = HeldIn; -const errorText = (reason: unknown): string => (reason instanceof Error ? reason.message : String(reason)); +/** A change of the instances while its call is in flight: a restart starts it, a sign-out leaves it signed out. */ +type InstanceGuess = + | { readonly kind: "restart"; readonly instance: string } + | { readonly kind: "signOut"; readonly server: string }; + +function applyInstance(instances: readonly Instance[], guess: InstanceGuess): readonly Instance[] { + return instances.map((instance) => + guess.kind === "restart" + ? instance.id === guess.instance + ? { ...instance, status: "starting", error: undefined } + : instance + : instance.server === guess.server && instance.auth === "oauth" + ? { ...instance, status: "sign-in" } + : instance, + ); +} + +/** What a server's entry does; each shows its effect at once and rolls back with the error in the toast. */ +type Actions = { + readonly restart: (instance: string) => void; + readonly signOut: (server: string) => void; + readonly cancelSignIn: (id: string) => void; +}; const STATUS: Record = { starting: "Starting", @@ -32,10 +56,22 @@ const STATUS: Record = { /** The sheet: configuration errors, then each server with its instances and sign-in. */ function McpSheet({ onClose }: { onClose: () => void }) { const view = useObject(Mcp, {}, { variant: "owner" }); - const instances = useReadable(view?.instances) ?? []; const errors = useReadable(view?.errors) ?? []; - const signIns = useReadable(view?.signIns) ?? []; + const { fail } = useComposerState(); + const [instances, changeInstance] = useOptimisticCall(useReadable(view?.instances) ?? [], applyInstance, fail); + const [signIns, cancel] = useOptimisticCall( + useReadable(view?.signIns) ?? [], + (list, id: string) => list.filter((entry) => entry.id !== id), + fail, + ); const servers = [...new Set(instances.map((instance) => instance.server))]; + const actions: Actions | undefined = view && { + restart: (instance) => + changeInstance({ kind: "restart", instance }, () => view.restart({ instance }, BACKGROUND_CONTEXT)), + signOut: (server) => + changeInstance({ kind: "signOut", server }, () => view.signOut({ server }, BACKGROUND_CONTEXT)), + cancelSignIn: (id) => cancel(id, () => view.cancelSignIn({ id }, BACKGROUND_CONTEXT)), + }; return (

@@ -48,12 +84,13 @@ function McpSheet({ onClose }: { onClose: () => void }) {

))} {view && servers.length === 0 &&

No MCP server is running.

} - {view && ( + {view && actions && (
{servers.map((server) => ( instance.server === server)} signIn={signIns.find((entry) => entry.server === server)} @@ -68,17 +105,17 @@ function McpSheet({ onClose }: { onClose: () => void }) { /** One server: its instances (status, place, tools, error, restart), and signing in or out if it uses OAuth. */ function ServerEntry({ view, + actions, server, instances, signIn, }: { view: OwnerView; + actions: Actions; server: string; instances: readonly Instance[]; signIn: SignIn | undefined; }) { - const { setError } = useComposerState(); - const perform = (action: Promise) => void action.catch((reason: unknown) => setError(errorText(reason))); const oauth = instances.some((instance) => instance.auth === "oauth"); const needsSignIn = instances.some((instance) => instance.status === "sign-in"); const connected = instances.some((instance) => instance.status === "connected"); @@ -100,25 +137,19 @@ function ServerEntry({

{instance.error && instance.status !== "sign-in" &&

{instance.error}

}
-
))} - {oauth && signIn?.status === "waiting" && } + {oauth && signIn?.status === "waiting" && ( + actions.cancelSignIn(signIn.id)} /> + )} {oauth && signIn?.status === "failed" && (

{signIn.error ?? "The sign-in failed."}

-
@@ -132,11 +163,7 @@ function ServerEntry({ ) : null} {connected && ( - )} @@ -147,7 +174,7 @@ function ServerEntry({ } /** A sign-in waiting for the browser: paste the final redirect URL, or cancel. */ -function PendingSignIn({ view, signIn }: { view: OwnerView; signIn: SignIn }) { +function PendingSignIn({ view, signIn, onCancel }: { view: OwnerView; signIn: SignIn; onCancel: () => void }) { const [value, setValue] = useState(""); const [sending, setSending] = useState(false); const [error, setError] = useState(); @@ -184,11 +211,7 @@ function PendingSignIn({ view, signIn }: { view: OwnerView; signIn: SignIn }) { - diff --git a/apps/p2/plugins/mcp/worker.ts b/apps/p2/plugins/mcp/worker.ts index b266d09bf..d7af81e5d 100644 --- a/apps/p2/plugins/mcp/worker.ts +++ b/apps/p2/plugins/mcp/worker.ts @@ -64,10 +64,7 @@ function mcpWorker(self: StoreSelf): void { const view = self.get(Mcp, {}); const call: McpCall = async ({ conversationId, read, ...request }, context) => { const location = await harness().environments.locate(conversationId, context, read); - return worker().root( - (ctx) => expectVariant(view, "worker").call({ location, ...request }, ctx), - context.abortSignal, - ); + return worker().root((ctx) => expectVariant(view, "worker").call({ location, ...request }, ctx), context); }; // Code mode switched off or not loaded: the tools would be unreachable, so `tool_search` loads them, as pi falls back. const reach = self.derive( diff --git a/apps/p2/plugins/models/server.ts b/apps/p2/plugins/models/server.ts index 7c4d69271..84bc3ee57 100644 --- a/apps/p2/plugins/models/server.ts +++ b/apps/p2/plugins/models/server.ts @@ -1,36 +1,14 @@ -import { obj, unknown } from "@earendil-works/pi-schema"; -import { - defineFacet, - defineState, - forbidden, - isOwner, - type JsonValue, - unsafe, - untilAborted, - type Watchable, -} from "@p2/app/v1"; +import { defineFacet, deriveTyped, followNative, forbidden, isOwner } from "@p2/app/v1"; import { Server, serverOf } from "@p2/app/v1/server"; import { openResources, resourceState } from "./config.ts"; -import { ModelConfig, ModelResources, ModelSummarySchema, Models } from "./contract.ts"; -import type { ResourceSnapshot } from "./resources.ts"; +import { ModelConfig, ModelResources, Models } from "./contract.ts"; import { resolveModelScope } from "./scope.ts"; -/** What `ModelConfig` publishes of the configuration: the picker's summary and the secret-free snapshot. */ -const Published = defineState("p2.model-config", { - schema: obj({ summary: ModelSummarySchema, snapshot: unsafe(unknown()) }), - version: 1, - initial: () => ({ - summary: { models: [], defaultModel: null, errors: [] }, - snapshot: { version: 0, settings: {}, credentials: [], providers: [], models: [], errors: [] }, - }), -}); - export default defineFacet((host) => { host.serve(ModelResources, { compose: (table) => table[0]?.value }); host.serve(ModelConfig, (_params, self) => { const server = self.get(Server, {}); const natives = serverOf(self); - const published = self.state(Published); const resources = self.open( () => openResources(server.info.value.dataDir, server.info.value.fake, natives().resourceOptions), (opened) => opened.close(), @@ -40,25 +18,16 @@ export default defineFacet((host) => { return resources.value; }, }); - self.onLive(async (ctx) => { - const publish = () => - published.change((draft) => { - const state = resourceState(resources.value, server.info.value.fake); - draft.summary = state.summary; - draft.snapshot = state.snapshot as unknown as JsonValue; - }, ctx); - await publish(); - const stop = resources.value.subscribe(() => void publish().catch(() => {})); - try { - await untilAborted(ctx); - } finally { - stop(); - } - }); + // The configuration files' state as the resources hold it: a save below resolves after its reload, which + // told this source, so the caller's answer comes with the new state. + const state = followNative( + self, + (changed) => resources.value.subscribe(changed), + () => resourceState(resources.value, server.info.value.fake), + ); return { - summary: self.derive([published], ([value]) => value.summary), - // The snapshot is JSON (`Resources.snapshot()`); its type is the one the contract names. - snapshot: self.derive([published], ([value]) => value.snapshot) as unknown as Watchable, + summary: self.derive([state], ([value]) => value.summary), + snapshot: deriveTyped(self, [state], ([value]) => value.snapshot), setDefault: async ({ provider, modelId, thinkingLevel }) => { const opened = resources.value; if (!opened.models.getModel(provider, modelId)) throw new Error("Unknown model"); diff --git a/apps/p2/plugins/models/tui.ts b/apps/p2/plugins/models/tui.ts index d3fbc83e4..08875aaca 100644 --- a/apps/p2/plugins/models/tui.ts +++ b/apps/p2/plugins/models/tui.ts @@ -10,6 +10,7 @@ import type { TuiContext } from "@p2/app/v1/tui"; import { lineText, ModelSelectorComponent, + type OptimisticAdd, ScopedModelsSelectorComponent, type SelectorModel, ThinkingSelectorComponent, @@ -25,6 +26,22 @@ import { type ModelChoice, type ModelInfo, Models } from "./contract.ts"; type Scoped = { provider: string; modelId: string; thinkingLevel?: string }; +/** A choice in flight: the conversation's model and level, the default, or the scope. */ +type Guess = { + readonly model?: ModelMeta | null; + readonly thinkingLevel?: string; + readonly defaultModel?: ModelChoice | null; + readonly scoped?: Scoped[]; +}; + +/** What the commands read: the state with the choices in flight applied. */ +type Shown = { + readonly model: ModelMeta | null; + readonly thinkingLevel: string | undefined; + readonly defaultModel: ModelChoice | null; + readonly scoped: Scoped[]; +}; + /** * What the commands need, held per runtime (a non-screen slot): the owner's catalog, scope and default, and the viewed * conversation's model and thinking level. @@ -35,18 +52,22 @@ export class ModelState implements TuiSurface { private owner: TuiScope | undefined; private chat: TuiScope | undefined; private identity: string | undefined; - private models: () => Held | undefined = () => undefined; - private chatRef: () => Held | undefined = () => undefined; + private models: Held | undefined; + private chatRef: Held | undefined; + /** The state as shown: the choices in flight over what the server last sent. */ + private readonly shownState: () => Shown; + /** Shows a choice while its call is in flight: the selectors and cycling start from it (Ctrl+P twice moves twice). */ + readonly choosing: OptimisticAdd; catalog: ModelInfo[] = []; - scoped: Scoped[] = []; - defaultModel: ModelChoice | null = null; - /** The viewed conversation's model (guest-safe metadata) and thinking level. */ - model: ModelMeta | null = null; - thinkingLevel: string | undefined; + private received: Shown = { model: null, thinkingLevel: undefined, defaultModel: null, scoped: [] }; private stop: () => void; constructor(runtime: TuiRuntime) { this.runtime = runtime; this.scope = new TuiScope(runtime); + [this.shownState, this.choosing] = this.scope.optimistic( + () => this.received, + (shown, guess: Guess) => ({ ...shown, ...guess }), + ); const changed = (): void => { // The catalog is owner-only: guests never request it. if (runtime.role === "owner" && !this.owner) { @@ -56,10 +77,10 @@ export class ModelState implements TuiSurface { this.catalog = models ?? []; }); follow(ref.scopedModels, (scoped) => { - this.scoped = scoped ?? []; + this.received = { ...this.received, scoped: scoped ?? [] }; }); follow(ref.defaultModel, (value) => { - this.defaultModel = value ?? null; + this.received = { ...this.received, defaultModel: value ?? null }; }); }); } @@ -67,19 +88,18 @@ export class ModelState implements TuiSurface { this.identity = runtime.identity; this.chat?.dispose(); this.chat = undefined; - this.chatRef = () => undefined; - this.model = null; - this.thinkingLevel = undefined; + this.chatRef = undefined; + this.received = { ...this.received, model: null, thinkingLevel: undefined }; const session = runtime.session; if (!session) return; this.chat = new TuiScope(runtime); this.chatRef = this.chat.watch(Chat, { session, conversation: runtime.conversation }, (ref, follow) => { follow(ref.model, (model) => { - this.model = model ?? null; + this.received = { ...this.received, model: model ?? null }; }); follow(ref.view, (view) => { const agent = view?.docs["pi.agent"] as { thinkingLevel?: string } | undefined; - this.thinkingLevel = agent?.thinkingLevel; + this.received = { ...this.received, thinkingLevel: agent?.thinkingLevel }; }); }); }; @@ -87,15 +107,30 @@ export class ModelState implements TuiSurface { this.stop = () => runtime.listeners.delete(changed); changed(); } + /** The state as shown: the choices in flight over what the server last sent. */ + private get shown(): Shown { + return this.shownState(); + } + /** The viewed conversation's model (guest-safe metadata). */ + get model(): ModelMeta | null { + return this.shown.model; + } + get thinkingLevel(): string | undefined { + return this.shown.thinkingLevel; + } + get defaultModel(): ModelChoice | null { + return this.shown.defaultModel; + } + get scoped(): Scoped[] { + return this.shown.scoped; + } /** The owner's Models reference once live. */ service(): Held | undefined { - const ref = this.models(); - return liveIn(ref, "owner"); + return liveIn(this.models, "owner"); } /** The viewed conversation's participant reference once live. */ participant(): HeldIn | undefined { - const ref = this.chatRef(); - return liveIn(ref, "participant"); + return liveIn(this.chatRef, "participant"); } selectorModels(): SelectorModel[] { return this.catalog.map(({ provider, modelId, name }) => ({ provider, modelId, name })); @@ -122,39 +157,79 @@ const describe = (state: ModelState): string => ? `${lineText(state.model.provider)}/${lineText(state.model.modelId)} · thinking ${lineText(state.thinkingLevel ?? "off")}` : "no model"; -/** Applies a model and/or level: the conversation's (Chat.configure) with a session, else the default. */ -async function apply(ctx: TuiContext, state: ModelState, change: { model?: SelectorModel; thinkingLevel?: string }) { - const chat = ctx.identity.session ? state.participant() : undefined; +/** A model and/or level a command chose. */ +type Change = { readonly model?: SelectorModel; readonly thinkingLevel?: string }; + +/** The conversation's model as shown while a change to `model` is in flight: the catalog's facts about it. */ +function metaOf(state: ModelState, model: SelectorModel): ModelMeta { + const info = state.catalog.find((entry) => entry.provider === model.provider && entry.modelId === model.modelId); + const thinkingLevels = info?.thinkingLevels ?? []; + return { + provider: model.provider, + modelId: model.modelId, + name: info?.name ?? model.name, + contextWindow: info?.contextWindow ?? 0, + reasoning: thinkingLevels.length > 1, + thinkingLevels, + }; +} + +/** + * Applies a change: the conversation's (Chat.configure) with a session, else the default. The choice shows at once + * (the notice, and the selectors and cycling start from it); a refused call takes it back and says why. + */ +function choose(ctx: TuiContext, state: ModelState, change: Change, done: string): void { if (ctx.identity.session) { - if (!chat) throw new Error("This conversation is not connected or read-only"); - await chat.configure( - { - ...(change.model ? { model: { provider: change.model.provider, modelId: change.model.modelId } } : {}), - ...(change.thinkingLevel ? { thinkingLevel: change.thinkingLevel } : {}), - }, - BACKGROUND_CONTEXT, + const chat = state.participant(); + if (!chat) { + ctx.notify("This conversation is not connected or read-only", "error"); + return; + } + const guess = { + ...(change.model ? { model: metaOf(state, change.model) } : {}), + ...(change.thinkingLevel ? { thinkingLevel: change.thinkingLevel } : {}), + }; + ctx.notify(done); + ctx.perform( + state.choosing( + guess, + chat.configure( + { + ...(change.model + ? { model: { provider: change.model.provider, modelId: change.model.modelId } } + : {}), + ...(change.thinkingLevel ? { thinkingLevel: change.thinkingLevel } : {}), + }, + BACKGROUND_CONTEXT, + ), + ), ); return; } - const service = state.service(); const model = change.model ?? state.defaultModel; - if (!service || !model) throw new Error("Models are not available"); - await service.setDefault( - { - provider: model.provider, - modelId: model.modelId, - thinkingLevel: change.thinkingLevel ?? state.defaultModel?.thinkingLevel ?? "off", - }, - BACKGROUND_CONTEXT, - ); + if (model) setDefault(ctx, state, { ...model, thinkingLevel: change.thinkingLevel }, done); } -const perform = (ctx: TuiContext, action: Promise, done?: string) => - void action - .then(() => done && ctx.notify(done)) - .catch((error: unknown) => { - ctx.notify(error instanceof Error ? error.message : String(error), "error"); - }); +/** Sets the default for new sessions (keeping the default's level unless `thinkingLevel` is given), shown at once. */ +function setDefault( + ctx: TuiContext, + state: ModelState, + choice: { provider: string; modelId: string; thinkingLevel: string | undefined }, + done: string, +): void { + const service = state.service(); + if (!service) { + ctx.notify("Models are not available", "error"); + return; + } + const defaultModel = { + provider: choice.provider, + modelId: choice.modelId, + thinkingLevel: choice.thinkingLevel ?? state.defaultModel?.thinkingLevel ?? "off", + }; + ctx.notify(done); + ctx.perform(state.choosing({ defaultModel }, service.setDefault(defaultModel, BACKGROUND_CONTEXT))); +} /** Guests (and the owner without the catalog yet) see the model read-only. */ function readOnly(ctx: TuiContext, state: ModelState | undefined): boolean { @@ -184,24 +259,13 @@ function openModelSelector(ctx: TuiContext, initialSearch?: string): void { ...(initialSearch ? { initialSearch } : {}), onSelect: (model) => { handle.close(); - perform(ctx, apply(ctx, state, { model }), `Model: ${model.provider}/${model.modelId}`); + choose(ctx, state, { model }, `Model: ${model.provider}/${model.modelId}`); }, onSelectAsDefault: (model) => { handle.close(); - const service = state.service(); - if (!service) return; const thinkingLevel = state.thinkingLevel ?? state.defaultModel?.thinkingLevel ?? "off"; - perform( - ctx, - Promise.all([ - service.setDefault( - { provider: model.provider, modelId: model.modelId, thinkingLevel }, - BACKGROUND_CONTEXT, - ), - ...(ctx.identity.session ? [apply(ctx, state, { model })] : []), - ]), - `Default model: ${model.provider}/${model.modelId}`, - ); + setDefault(ctx, state, { ...model, thinkingLevel }, `Default model: ${model.provider}/${model.modelId}`); + if (ctx.identity.session) choose(ctx, state, { model }, `Model: ${model.provider}/${model.modelId}`); }, onCancel: () => handle.close(), }), @@ -222,24 +286,14 @@ function openThinkingSelector(ctx: TuiContext): void { ...(state.defaultModel ? { defaultLevel: state.defaultModel.thinkingLevel } : {}), onSelect: (level) => { handle.close(); - perform(ctx, apply(ctx, state, { thinkingLevel: level }), `Thinking level: ${level}`); + choose(ctx, state, { thinkingLevel: level }, `Thinking level: ${level}`); }, onSelectAsDefault: (level) => { handle.close(); - const service = state.service(); const model = state.model ?? state.defaultModel; - if (!service || !model) return; - perform( - ctx, - Promise.all([ - service.setDefault( - { provider: model.provider, modelId: model.modelId, thinkingLevel: level }, - BACKGROUND_CONTEXT, - ), - ...(ctx.identity.session ? [apply(ctx, state, { thinkingLevel: level })] : []), - ]), - `Default thinking level: ${level}`, - ); + if (!model) return; + setDefault(ctx, state, { ...model, thinkingLevel: level }, `Default thinking level: ${level}`); + if (ctx.identity.session) choose(ctx, state, { thinkingLevel: level }, `Thinking level: ${level}`); }, onCancel: () => handle.close(), }), @@ -260,9 +314,10 @@ function cycleModel(ctx: TuiContext, direction: 1 | -1): void { const next = ring[(index + direction + ring.length) % ring.length]!; const info = state.catalog.find((model) => model.provider === next.provider && model.modelId === next.modelId); const model = { provider: next.provider, modelId: next.modelId, name: info?.name ?? next.modelId }; - perform( + choose( ctx, - apply(ctx, state, { model, ...(next.thinkingLevel ? { thinkingLevel: next.thinkingLevel } : {}) }), + state, + { model, ...(next.thinkingLevel ? { thinkingLevel: next.thinkingLevel } : {}) }, `Model: ${next.provider}/${next.modelId}${next.thinkingLevel ? ` · thinking ${next.thinkingLevel}` : ""}`, ); } @@ -278,7 +333,7 @@ function cycleThinking(ctx: TuiContext): void { } const current = state.thinkingLevel ?? "off"; const next = levels[(levels.indexOf(current) + 1) % levels.length]!; - perform(ctx, apply(ctx, state, { thinkingLevel: next }), `Thinking level: ${next}`); + choose(ctx, state, { thinkingLevel: next }, `Thinking level: ${next}`); } function openScopedModels(ctx: TuiContext): void { @@ -292,12 +347,14 @@ function openScopedModels(ctx: TuiContext): void { onSave: (patterns) => { handle.close(); const service = state.service(); - if (service) - perform( - ctx, - service.setEnabledModels({ patterns }, BACKGROUND_CONTEXT), - patterns.length ? `Scoped models: ${patterns.length}` : "Model scope cleared", - ); + if (!service) return; + ctx.notify(patterns.length ? `Scoped models: ${patterns.length}` : "Model scope cleared"); + // The selector saves exact `provider/modelId` patterns: cycling uses them at once. + const scoped = patterns.map((pattern) => { + const slash = pattern.indexOf("/"); + return { provider: pattern.slice(0, slash), modelId: pattern.slice(slash + 1) }; + }); + ctx.perform(state.choosing({ scoped }, service.setEnabledModels({ patterns }, BACKGROUND_CONTEXT))); }, onCancel: () => handle.close(), }), @@ -331,7 +388,7 @@ export const MODEL_COMMANDS: Record = { ? state?.catalog.find((model) => `${model.provider}/${model.modelId}`.toLowerCase() === query.toLowerCase()) : undefined; if (state && exact && !readOnly(ctx, state)) - perform(ctx, apply(ctx, state, { model: exact }), `Model: ${exact.provider}/${exact.modelId}`); + choose(ctx, state, { model: exact }, `Model: ${exact.provider}/${exact.modelId}`); else if (!exact) openModelSelector(ctx, query); }, }, diff --git a/apps/p2/plugins/models/worker.ts b/apps/p2/plugins/models/worker.ts index 7807cb5b0..1de156ce2 100644 --- a/apps/p2/plugins/models/worker.ts +++ b/apps/p2/plugins/models/worker.ts @@ -1,17 +1,8 @@ -import { obj, unknown } from "@earendil-works/pi-schema"; -import { defineFacet, defineState, unsafe, untilAborted } from "@p2/app/v1"; -import type { MobileSettings } from "@p2/app/v1/node"; +import { defineFacet, followNative } from "@p2/app/v1"; import { HarnessConfig, workerOf } from "@p2/app/v1/worker"; import { openResources } from "./config.ts"; import { WorkerModels } from "./contract.ts"; -/** The worker's current, valid settings (hot-reloaded). */ -const Settings = defineState("p2.worker-models", { - schema: obj({ settings: unsafe(unknown()) }), - version: 1, - initial: () => ({ settings: {} }), -}); - export default defineFacet((host) => { host.serve(WorkerModels, (_params, self) => { const worker = workerOf(self); @@ -19,26 +10,18 @@ export default defineFacet((host) => { () => openResources(worker().init.dataDir, worker().init.fake), (opened) => opened.close(), ); - const settings = self.state(Settings); - self.onLive(async (ctx) => { - const publish = () => - settings.change((draft) => { - draft.settings = resources.value.settings; - }, ctx); - await publish(); - const stop = resources.value.subscribe(() => void publish().catch(() => {})); - try { - await untilAborted(ctx); - } finally { - stop(); - } - }); self.contribute(HarnessConfig, { default: { models: () => resources.value.models, settings: () => resources.value.harnessSettings(), }, }); - return { settings: self.derive([settings], ([value]) => value.settings) }; + return { + settings: followNative( + self, + (changed) => resources.value.subscribe(changed), + () => resources.value.settings, + ), + }; }); }); diff --git a/apps/p2/plugins/ports/PortsPanel.tsx b/apps/p2/plugins/ports/PortsPanel.tsx index 554ad37c0..10d39104d 100644 --- a/apps/p2/plugins/ports/PortsPanel.tsx +++ b/apps/p2/plugins/ports/PortsPanel.tsx @@ -1,29 +1,51 @@ +import type { Static } from "@earendil-works/pi-schema"; import { BACKGROUND_CONTEXT, liveIn } from "@p2/app/v1"; -import { CopyButton, Sheet, useObject, useReadable } from "@p2/app/v1/web"; +import { CopyButton, errorText, Sheet, useObject, useOptimisticCall, useReadable } from "@p2/app/v1/web"; import { useState } from "react"; -import { Ports } from "./contract.ts"; +import { type ForwardSchema, Ports } from "./contract.ts"; -/** Every forward is visible to the owner. Making one public requires a second explicit tap. */ +type Forward = Static; + +/** A change of the list while its call is in flight. */ +type PortGuess = + | { readonly kind: "add"; readonly key: string; readonly port: number; readonly name: string | undefined } + | { readonly kind: "remove"; readonly id: string } + | { readonly kind: "public"; readonly id: string; readonly open: boolean }; + +/** + * The list with a change applied. An added forward's id and addresses are the server's: until it answers, the row + * shows the port and name only (no Open, no link). + */ +function applyPort(forwards: Forward[] | undefined, guess: PortGuess): Forward[] | undefined { + if (!forwards) return forwards; + if (guess.kind === "add") + return [...forwards, { id: guess.key, port: guess.port, ...(guess.name ? { name: guess.name } : {}) }]; + if (guess.kind === "remove") return forwards.filter((forward) => forward.id !== guess.id); + return forwards.map((forward) => + forward.id === guess.id + ? { ...forward, public: guess.open, ...(guess.open ? {} : { publicUrl: undefined }) } + : forward, + ); +} + +/** + * Every forward is visible to the owner. Making one public requires a second explicit tap. Adding, removing and the + * public switch show at once; a refused one rolls back with its error. + */ export function PortsPanel({ onClose }: { onClose: () => void }) { const service = liveIn(useObject(Ports, {}), "owner"); - const forwards = useReadable(service?.forwards); const origin = useReadable(service?.radiusOrigin); const [port, setPort] = useState(""); const [name, setName] = useState(""); const [removing, setRemoving] = useState(); const [publishing, setPublishing] = useState(); - const [busy, setBusy] = useState(false); const [error, setError] = useState(); - const run = async (action: () => Promise) => { - setBusy(true); + const [forwards, change] = useOptimisticCall(useReadable(service?.forwards), applyPort, (reason) => + setError(errorText(reason)), + ); + const run = (guess: PortGuess, call: () => Promise) => { setError(undefined); - try { - await action(); - } catch (reason) { - setError(reason instanceof Error ? reason.message : String(reason)); - } finally { - setBusy(false); - } + change(guess, call); }; const onDevice = ["127.0.0.1", "localhost", "[::1]"].includes(location.hostname); return ( @@ -42,11 +64,14 @@ export function PortsPanel({ onClose }: { onClose: () => void }) {
{forwards?.map((forward) => { const url = forward.url ?? (onDevice ? `http://localhost:${forward.port}/` : undefined); - const setPublic = (open: boolean) => - void run(async () => { - await service?.setPublic({ id: forward.id, public: open }, BACKGROUND_CONTEXT); - setPublishing(undefined); - }); + const pending = forward.id.startsWith("adding-"); + const setPublic = (open: boolean) => { + setPublishing(undefined); + if (service) + run({ kind: "public", id: forward.id, open }, () => + service.setPublic({ id: forward.id, public: open }, BACKGROUND_CONTEXT), + ); + }; return (
@@ -63,10 +88,13 @@ export function PortsPanel({ onClose }: { onClose: () => void }) { @@ -76,7 +104,12 @@ export function PortsPanel({ onClose }: { onClose: () => void }) { ) : ( <> - {forward.url && } @@ -94,12 +127,7 @@ export function PortsPanel({ onClose }: { onClose: () => void }) { Anyone with the link can open localhost:{forward.port} without signing in. - diff --git a/apps/p2/plugins/ports/server.ts b/apps/p2/plugins/ports/server.ts index afff5f9b4..2bd4ac45a 100644 --- a/apps/p2/plugins/ports/server.ts +++ b/apps/p2/plugins/ports/server.ts @@ -1,9 +1,7 @@ -import { arr, nul, obj, str, union } from "@earendil-works/pi-schema"; import { - BACKGROUND_CONTEXT, defineFacet, - defineTypedState, deriveTyped, + followNative, forbidden, isOwner, isWorker, @@ -12,43 +10,30 @@ import { } from "@p2/app/v1"; import { Hosts, Server } from "@p2/app/v1/server"; import { Catalog, SessionArchived } from "../sessions/contract.ts"; -import { ForwardSchema, ForwardStore, ForwardsNative, Ports } from "./contract.ts"; +import { ForwardStore, ForwardsNative, Ports } from "./contract.ts"; import { forwardView } from "./protocol.ts"; import { Forwards } from "./store.ts"; -/** The forwards' list and Radius origin as the store shows them (in memory). */ -const Listed = defineTypedState("p2.forward-store", { - schema: obj({ list: arr(ForwardSchema), radiusOrigin: union([str(), nul()]) }), - version: 1, - initial: () => ({ list: [], radiusOrigin: null }), -}); - export default defineFacet((host) => { serveNative(host, ForwardsNative); host.serve(ForwardStore, (_params, self) => { const server = self.get(Server, {}); const catalog = self.get(Catalog, {}); - const listed = self.state(Listed); - const show = (store: Forwards) => - listed.change((draft) => { - // A draft's arrays are mutable; the store's list is the same JSON. - draft.list = store.list() as typeof draft.list; - draft.radiusOrigin = store.radiusOrigin; - }, BACKGROUND_CONTEXT); const forwards = self.open( async () => { const store = await Forwards.open(server.info.value.dataDir); // Forwards of archived sessions close first, so the list never shows them. for (const session of catalog.sessions.value) if (session.archivedAt !== null) await store.closeSession(session.id); - await show(store); return store; }, () => {}, ); - self.open( - async () => forwards.value.events.subscribe(() => void show(forwards.value).catch(() => {})), - (stop) => stop(), + // The store's list and Radius origin: a method's write resolves after the store told this source. + const listed = followNative( + self, + (changed) => forwards.value.events.subscribe(changed), + () => ({ list: forwards.value.list(), radiusOrigin: forwards.value.radiusOrigin }), ); self.contribute(ForwardsNative, { get forwards() { diff --git a/apps/p2/plugins/ports/tui.ts b/apps/p2/plugins/ports/tui.ts index 6fa75c108..bbc8f9fe9 100644 --- a/apps/p2/plugins/ports/tui.ts +++ b/apps/p2/plugins/ports/tui.ts @@ -3,7 +3,7 @@ // (p2-tui-port.md section 3.5); OSC 8 hyperlinks stay stripped. `/ports` (P11f) is the web's ports panel. import { Text } from "@earendil-works/pi-tui"; -import { BACKGROUND_CONTEXT, contribute, defineFacet, follow, liveIn, whenLive } from "@p2/app/v1"; +import { BACKGROUND_CONTEXT, contribute, defineFacet, type HeldIn, liveIn, whenLive } from "@p2/app/v1"; import type { TuiContext } from "@p2/app/v1/tui"; import { confirm, @@ -58,70 +58,62 @@ export const portsRenderers: ToolRenderers = { }, }; +/** A change of the list while its call is in flight; an added forward's id and addresses are the server's. */ +type PortGuess = + | { kind: "add"; key: string; port: number; name?: string; public?: boolean } + | { kind: "remove"; id: string } + | { kind: "public"; id: string; open: boolean }; + +function applyPort(forwards: ForwardView[], guess: PortGuess): ForwardView[] { + if (guess.kind === "add") + return [ + ...forwards, + { id: guess.key, port: guess.port, ...(guess.name ? { name: guess.name } : {}), public: guess.public }, + ]; + if (guess.kind === "remove") return forwards.filter((forward) => forward.id !== guess.id); + return forwards.map((forward) => (forward.id === guess.id ? { ...forward, public: guess.open } : forward)); +} + /** * `/ports [open [name] [public]]` (P11f), after the web's ports panel: the owner's forwards with their * addresses; Ctrl+P makes one public or private (public only after a confirmation: anyone with the address can open * it), Ctrl+D closes one. `open` adds a forward; a public one is confirmed first. */ function openPorts(ctx: TuiContext, args: string | undefined): void { - const ref = ctx.host.hold(Ports, {}, BACKGROUND_CONTEXT); - const owner = () => liveIn(ref, "owner"); - let forwards: ForwardView[] = []; - let status = ""; - let closed = false; - let stop = (): void => {}; - // The forwards are the owner variant's: subscribed once the reference knows its variant. - void whenLive(ref) - .then(() => { - if (closed || ref.variant !== "owner") return; - stop = follow(ref.forwards, (value) => { - forwards = value; - ctx.requestRender(); + const scope = ctx.scope(); + let received: ForwardView[] = []; + const ref = scope.watch(Ports, {}, (held, follow) => { + if (held.variant === "owner") + follow(held.forwards, (value) => { + received = value; }); - }) - .catch(() => {}); - const run = (label: string, action: () => Promise) => { - status = `${label}…`; - void action() - .then(() => { - status = `${label}: done`; - }) - .catch((error: unknown) => { - status = `${label}: ${error instanceof Error ? error.message : String(error)}`; - }) - .finally(() => ctx.requestRender()); - }; + }); + // Adding, closing and the public switch show at once; a refused one rolls back with its error. + const [forwards, changing] = scope.optimistic(() => received, applyPort); + const change = (guess: PortGuess, call: (service: HeldIn) => Promise) => + ctx.perform( + changing( + guess, + (async () => { + // `/ports open` runs before the list arrived: the call waits for the reference. + await whenLive(ref); + const service = liveIn(ref, "owner"); + if (!service) throw new Error("Ports are the owner's"); + await call(service); + })(), + ), + ); const publicWarning = (port: number) => [ `Make port ${port} public?`, "Anyone with its public address can open it without signing in.", ]; - const words = args?.trim().split(/\s+/).filter(Boolean) ?? []; - if (words[0] === "open") { - const port = Number(words[1]); - const isPublic = words.includes("public"); - const name = words.slice(2).find((word) => word !== "public"); - if (!Number.isInteger(port) || port < 1 || port > 65535) ctx.notify("Usage: /ports open [name] [public]"); - else - void whenLive(ref) - .then(async () => { - if (isPublic && !(await confirm(ctx, publicWarning(port)))) return; - const service = owner(); - if (!service) return; - await service.add( - { port, ...(name ? { name } : {}), ...(isPublic ? { public: true } : {}) }, - BACKGROUND_CONTEXT, - ); - ctx.notify(`Forwarding port ${port}`); - }) - .catch((error: unknown) => ctx.notify(error instanceof Error ? error.message : String(error), "error")); - } - const byRow = (row: ListRow | undefined) => forwards.find((forward) => forward.id === row?.key); + const byRow = (row: ListRow | undefined) => forwards().find((forward) => forward.id === row?.key); const overlay = ctx.overlay( "ports", new ListOverlay(ctx, { - title: () => `Ports (${forwards.length})`, + title: () => `Ports (${forwards().length})`, rows: () => - forwards.map((forward) => ({ + forwards().map((forward) => ({ key: forward.id, label: `${forward.name ?? `Port ${forward.port}`} localhost:${forward.port}${forward.public ? " public" : ""}`, detail: forward.session ? `by ${forward.sessionTitle ?? "another session"}` : "", @@ -138,20 +130,13 @@ function openPorts(ctx: TuiContext, args: string | undefined): void { label: "public/private", run: (row) => { const forward = byRow(row); - const service = owner(); - if (!forward || service?.variant !== "owner") return; - if (forward.public) - run(`Making ${forward.port} private`, () => - service.setPublic({ id: forward.id, public: false }, BACKGROUND_CONTEXT), - ); - else - void confirm(ctx, publicWarning(forward.port)).then( - (yes) => - yes && - run(`Making ${forward.port} public`, () => - service.setPublic({ id: forward.id, public: true }, BACKGROUND_CONTEXT), - ), + if (!forward || forward.id.startsWith("adding-")) return; + const setPublic = (open: boolean) => + change({ kind: "public", id: forward.id, open }, (service) => + service.setPublic({ id: forward.id, public: open }, BACKGROUND_CONTEXT), ); + if (forward.public) setPublic(false); + else void confirm(ctx, publicWarning(forward.port)).then((yes) => yes && setPublic(true)); }, }, { @@ -159,25 +144,38 @@ function openPorts(ctx: TuiContext, args: string | undefined): void { label: "close", run: (row) => { const forward = byRow(row); - const service = owner(); - if (forward && service?.variant === "owner") - run(`Closing ${forward.port}`, () => service.remove({ id: forward.id }, BACKGROUND_CONTEXT)); + if (forward && !forward.id.startsWith("adding-")) + change({ kind: "remove", id: forward.id }, (service) => + service.remove({ id: forward.id }, BACKGROUND_CONTEXT), + ); }, }, ], - footer: () => [ - ctx.theme.current.fg("dim", " /ports open [name] [public] adds one"), - ...(status ? [ctx.theme.current.fg("muted", ` ${lineText(status)}`)] : []), - ], + footer: () => [ctx.theme.current.fg("dim", " /ports open [name] [public] adds one")], onClose: () => { - closed = true; - stop(); - ref.release(); + scope.dispose(); overlay.close(); }, }), { width: "100%", anchor: "bottom-center" }, ); + // After the overlay: a confirmation opens on top of it. + const words = args?.trim().split(/\s+/).filter(Boolean) ?? []; + if (words[0] === "open") { + const port = Number(words[1]); + const isPublic = words.includes("public"); + const name = words.slice(2).find((word) => word !== "public"); + if (!Number.isInteger(port) || port < 1 || port > 65535) ctx.notify("Usage: /ports open [name] [public]"); + else + void (async () => { + if (isPublic && !(await confirm(ctx, publicWarning(port)))) return; + const added = { port, ...(name ? { name } : {}), ...(isPublic ? { public: true } : {}) }; + change({ kind: "add", key: `adding-${crypto.randomUUID()}`, ...added }, async (service) => { + await service.add(added, BACKGROUND_CONTEXT); + ctx.notify(`Forwarding port ${port}`); + }); + })(); + } } const PORT_COMMANDS: Record = { diff --git a/apps/p2/plugins/ports/worker.ts b/apps/p2/plugins/ports/worker.ts index 708db0c41..2326675b4 100644 --- a/apps/p2/plugins/ports/worker.ts +++ b/apps/p2/plugins/ports/worker.ts @@ -10,7 +10,7 @@ export default defineFacet((host) => { (query, context) => worker().root( async (ctx) => expectVariant(await ctx.get(Ports, {}), "worker").query({ conversation: 1, query }, ctx), - context.abortSignal, + context, ), () => worker().init.values.session.id, ); diff --git a/apps/p2/plugins/powershell/worker.ts b/apps/p2/plugins/powershell/worker.ts index 1791a7ceb..5d0f592a6 100644 --- a/apps/p2/plugins/powershell/worker.ts +++ b/apps/p2/plugins/powershell/worker.ts @@ -12,7 +12,6 @@ import { } from "@earendil-works/pi-durable"; import { createPowerShellTool } from "@earendil-works/pi-durable/tools"; import { defineFacet, type Location, signalOf } from "@p2/app/v1"; -import { durableContext } from "@p2/app/v1/node"; import { CommitSignal, Extensions, harnessOf, workerOf } from "@p2/app/v1/worker"; import { Subagents } from "../subagents/contract.worker.ts"; import { POWERSHELL_EXTENSION, POWERSHELL_TOOL, PowerShell } from "./contract.ts"; @@ -94,8 +93,7 @@ export default defineFacet((host) => { const seen = signal.version; let unknown = false; try { - const context = durableContext(ctx); - const registry = await durable.snapshot(Subagents, ROOT_CONVERSATION_ID, context); + const registry = await durable.snapshot(Subagents, ROOT_CONVERSATION_ID, ctx); const ids: ConversationId[] = [ ROOT_CONVERSATION_ID, ...Object.values(registry?.agents ?? {}).map((agent) => agent.conversationId), @@ -104,8 +102,8 @@ export default defineFacet((host) => { const offered: Record = {}; const changes: { id: ConversationId; on: boolean }[] = []; for (const id of ids) { - const state = await durable.snapshot(AgentDoc, id, context); - const windows = await isWindows(await harness().environments.locate(id, context)); + const state = await durable.snapshot(AgentDoc, id, ctx); + const windows = await isWindows(await harness().environments.locate(id, ctx)); if (windows === undefined) unknown = true; // Unknown keeps what it has: an unreachable machine does not take PowerShell away. const on = windows === undefined ? offersPowerShell(state) : windows && enables(state, "bash"); @@ -115,7 +113,7 @@ export default defineFacet((host) => { if (changes.length) await durable.commit(async (tx) => { for (const { id, on } of changes) setPowerShell(await tx.doc(AgentDoc, id), on); - }, context); + }, ctx); offeredState.set(offered); } catch (error) { if (signalOf(ctx).aborted) return; diff --git a/apps/p2/plugins/prompt/tui.ts b/apps/p2/plugins/prompt/tui.ts index 0c1457bcf..08a117a1d 100644 --- a/apps/p2/plugins/prompt/tui.ts +++ b/apps/p2/plugins/prompt/tui.ts @@ -2,7 +2,7 @@ // the viewed conversation's `PromptResources`, and their expansion on the session worker when the editor submits. import type { Held } from "@p2/app/v1"; -import { BACKGROUND_CONTEXT, contribute, defineFacet, statusOf } from "@p2/app/v1"; +import { BACKGROUND_CONTEXT, contribute, defineFacet, liveIn } from "@p2/app/v1"; import type { TuiContext } from "@p2/app/v1/tui"; import { lineText, @@ -21,7 +21,7 @@ export class PromptSurface implements TuiSurface { readonly runtime: TuiRuntime; private scope: TuiScope | undefined; private viewed: string | undefined; - private ref: (() => Held) | undefined; + private ref: Held | undefined; templates: readonly TemplateInfo[] = []; skills: readonly SkillInfo[] = []; private stop: () => void; @@ -66,9 +66,8 @@ export class PromptSurface implements TuiSurface { ]; } async expand(text: string): Promise { - const ref = this.ref?.(); - if (ref === undefined || statusOf(ref) !== "live" || ref.variant !== "participant") return undefined; - return (await ref.expand({ text }, BACKGROUND_CONTEXT)).text; + const ref = liveIn(this.ref, "participant"); + return ref && (await ref.expand({ text }, BACKGROUND_CONTEXT)).text; } render(): string[] { return []; diff --git a/apps/p2/plugins/prompt/worker.ts b/apps/p2/plugins/prompt/worker.ts index c2fb32527..5f680bf24 100644 --- a/apps/p2/plugins/prompt/worker.ts +++ b/apps/p2/plugins/prompt/worker.ts @@ -11,14 +11,13 @@ import { } from "@earendil-works/pi-durable"; import type { ExecutionEnv } from "@earendil-works/pi-durable/env"; import { RemoteExecutionEnv } from "@earendil-works/pi-env"; -import { arr, num, obj, opt, str } from "@earendil-works/pi-schema"; +import { num, obj, opt, str } from "@earendil-works/pi-schema"; import { BACKGROUND_CONTEXT, type Context, canSend, defineFacet, defineRegistry, - defineTypedState, deriveTyped, forbidden, local, @@ -26,6 +25,8 @@ import { nativeOf, type Subscribable, serveNative, + signalOf, + untilAborted, } from "@p2/app/v1"; import { remotePaths } from "@p2/app/v1/node"; import { @@ -38,7 +39,7 @@ import { workerOf, } from "@p2/app/v1/worker"; import type { LoadedPrompt } from "./context-files.ts"; -import { PromptResources, SkillInfoSchema, TemplateInfoSchema } from "./contract.ts"; +import { PromptResources, type SkillInfo, type TemplateInfo } from "./contract.ts"; import { envFiles, promptObserver } from "./environment.ts"; import { expandPrompt } from "./templates.ts"; @@ -75,7 +76,7 @@ type PromptLoads = { /** Where conversations find their prompt resources: the session's (following its moves) or their own location's. */ type PromptFiles = { - readonly session: PromptLoads; + readonly session: MovingPromptLoads; of: (id: ConversationId, context: Context, read?: DocumentReader) => Promise; }; @@ -140,11 +141,20 @@ function promptLoads(dataDir: string, at: Location, environment: Promise; +}; + /** - * The prompt resources of the session's current location: loaded for the location it is at, and loaded anew (from the - * new cwd, on the new machine) after each move. `loads` follows the current location's loads. + * The prompt resources of the session's current location: loaded for the location it is at, and loaded anew after + * each move. `loads` follows the current location's loads. */ -function followedPromptLoads(location: SessionLocation, open: (at: Location) => PromptLoads): PromptLoads { +function movingPromptLoads(location: SessionLocation, open: (at: Location) => PromptLoads): MovingPromptLoads { const listeners = new Set<(loaded: Loaded) => void>(); let latest: Loaded | undefined; const forward = (loaded: Loaded): void => { @@ -157,15 +167,9 @@ function followedPromptLoads(location: SessionLocation, open: (at: Location) => loads.loads.subscribe((loaded) => { if (inner === loads) forward(loaded); }); + let version = location.version; let inner = open(location.value); let unsubscribe = follow(inner); - const stop = location.subscribe((at) => { - const previous = inner; - unsubscribe(); - inner = open(at); - unsubscribe = follow(inner); - void previous.close().catch((error: unknown) => console.error("p2 prompt: closing old watches:", error)); - }); return { current: () => inner.current(), started: () => inner.started(), @@ -177,8 +181,16 @@ function followedPromptLoads(location: SessionLocation, open: (at: Location) => return () => listeners.delete(listener); }, }, + move: async (at, moved) => { + if (moved <= version) return; + version = moved; + const previous = inner; + unsubscribe(); + inner = open(at); + unsubscribe = follow(inner); + await previous.close().catch((error: unknown) => console.error("p2 prompt: closing old watches:", error)); + }, close: async () => { - stop(); unsubscribe(); await inner.close(); }, @@ -199,12 +211,18 @@ const Prompt = local("p2.prompt", { /** The open `Prompt`'s files (the native it contributes), for `PromptResources`. */ const PromptFilesNative = defineRegistry("p2.prompt-files", { version: 1 }); -/** The templates and skills where one conversation works, as `PromptResources` shows them. */ -const Listed = defineTypedState("p2.prompt-resources", { - schema: obj({ templates: arr(TemplateInfoSchema), skills: arr(SkillInfoSchema) }), - version: 1, - initial: () => ({ templates: [], skills: [] }), -}); +/** The templates and skills of a load, as `PromptResources` shows them. */ +function listedOf(loaded: LoadedPrompt): { templates: TemplateInfo[]; skills: SkillInfo[] } { + return { + templates: loaded.templates.map(({ name, description, argumentHint, source }) => ({ + name, + description, + ...(argumentHint ? { argumentHint } : {}), + source, + })), + skills: loaded.skillFiles.map(({ name, description }) => ({ name, description })), + }; +} export default defineFacet((host) => { serveNative(host, PromptFilesNative); @@ -216,7 +234,7 @@ export default defineFacet((host) => { const environments = harness().environments; const dataDir = worker().init.dataDir; const open = (at: Location): PromptLoads => promptLoads(dataDir, at, environments.at(at)); - const session = followedPromptLoads(worker().location, open); + const session = movingPromptLoads(worker().location, open); const own = new Map(); const files: PromptFiles = { session, @@ -241,13 +259,29 @@ export default defineFacet((host) => { }, (value) => value.close(), ); - self.open( - async () => - filesResource.value.files.session.loads.subscribe(({ error }) => { - if (error !== undefined) console.error(`p2 prompt: ${error}`); - }), - (stop) => stop(), - ); + // The session's resources follow its moves (one committed while the files opened included). + self.onLive(async (ctx) => { + const location = worker().location; + const session = filesResource.value.files.session; + for (;;) { + // Listening before moving: a move committed while the old watches close is not missed. + const moved = location.next(signalOf(ctx)); + moved.catch(() => {}); // The abort ends this routine below. + await session.move(location.value, location.version); + await moved; + } + }); + // Load failures of the session's resources go to the worker's log (they also leave the last good load in use). + self.onLive(async (ctx) => { + const stop = filesResource.value.files.session.loads.subscribe(({ error }) => { + if (error !== undefined) console.error(`p2 prompt: ${error}`); + }); + try { + await untilAborted(ctx); + } finally { + stop(); + } + }); self.contribute(PromptFilesNative, { get session() { return filesResource.value.files.session; @@ -312,31 +346,18 @@ export default defineFacet((host) => { if (!canSend(principal, session)) throw forbidden("participants only"); const prompt = self.get(Prompt, {}); const files = nativeOf(self, PromptFilesNative, "The prompt files"); - const listed = self.state(Listed); - let fingerprint: string | undefined; - const show = (loaded: LoadedPrompt, context: Context) => { - fingerprint = loaded.fingerprint; - void listed - .change((draft) => { - draft.templates = loaded.templates.map(({ name, description, argumentHint, source }) => ({ - name, - description, - ...(argumentHint ? { argumentHint } : {}), - source, - })); - draft.skills = loaded.skillFiles.map(({ name, description }) => ({ name, description })); - }, context) - .catch(() => {}); - }; - self.open( - async (ctx) => { - const loads = await files().of(conversation as ConversationId, ctx); - return loads.loads.subscribe(({ prompt: loaded }) => { - if (loaded && loaded.fingerprint !== fingerprint) show(loaded, BACKGROUND_CONTEXT); - }); - }, - (stop) => stop(), - ); + // The loads where the conversation works, followed: each new load (a changed fingerprint) is the next value. + const listed = self.follow(async (observer, ctx) => { + const loads = await files().of(conversation as ConversationId, ctx); + let fingerprint = loads.current().fingerprint; + const stop = loads.loads.subscribe(({ prompt: loaded }) => { + if (!loaded || loaded.fingerprint === fingerprint) return; + fingerprint = loaded.fingerprint; + const value = listedOf(loaded); + observer.advance({ kind: "value", value, ops: [["r", value]], retired: false }, BACKGROUND_CONTEXT); + }); + return { value: listedOf(loads.current()), detach: stop }; + }); return { variant: "participant", members: { diff --git a/apps/p2/plugins/search/contract.ts b/apps/p2/plugins/search/contract.ts index 2572b4939..5d9dafccb 100644 --- a/apps/p2/plugins/search/contract.ts +++ b/apps/p2/plugins/search/contract.ts @@ -1,5 +1,16 @@ import { arr, bool, int, nul, num, obj, opt, type Static, str, union } from "@earendil-works/pi-schema"; -import { literal, local, method, param, propertiesOf, record, remote, serverHost, sessionHost } from "@p2/app/v1"; +import { + evolve, + literal, + local, + method, + param, + propertiesOf, + record, + remote, + serverHost, + sessionHost, +} from "@p2/app/v1"; /** One committed user or assistant message of a session conversation, as indexed. */ export const IndexedMessageSchema = obj({ @@ -66,6 +77,13 @@ const HistoryQuerySchema = obj({ limit: int({ minimum: 1, maximum: 20 }), }); +/** A session's index rebuild: running, or failed with why (kept until the next rebuild of that session). */ +export const RebuildSchema = union([ + obj({ session: str(), phase: literal("running") }), + obj({ session: str(), phase: literal("failed"), error: str() }), +]); +export type Rebuild = Static; + /** Root conversation entry IDs by session. */ const EntryIdsSchema = record(num()); @@ -92,6 +110,10 @@ export const SearchIndex = local("p2.search-index", { history: method(obj({ session: str(), ...propertiesOf(HistoryQuerySchema) }), HistoryHitsSchema), /** Message matches among `sessions` (literal terms, all required). */ messages: method(obj({ query: str(), sessions: arr(str()) }), arr(MessageHitSchema)), + /** The index rebuilds running or failed (a job each: they survive a server restart). */ + rebuilds: arr(RebuildSchema), + /** Starts re-indexing `session` from the start (`Search.rebuildIndex`); refuses an unknown session. */ + rebuild: method(obj({ session: str() })), }, }); @@ -116,8 +138,8 @@ export const SearchFeed = remote("p2.search-feed", { }, }); -/** Search over session titles, working directories and message text (owner only). */ -export const Search = remote("p2.search", { +/** Search over session titles, working directories and message text (owner only): the base of the history. */ +const SearchV1 = remote("p2.search", { version: 1, params: {}, hostAddress: serverHost, @@ -128,7 +150,7 @@ export const Search = remote("p2.search", { /** * Re-indexes one session from the start (Pi Mobile's `rebuildIndex`): its worker stops, its index entries * go, and a fresh worker scans its history; an archived session's worker scans without resuming work and - * stops again. Returns once the scan committed. + * stops again. */ rebuildIndex: method(obj({ session: str() })), }, @@ -136,6 +158,12 @@ export const Search = remote("p2.search", { }, }); +/** + * Search over session titles, working directories and message text (owner only). 2: `rebuildIndex` answers once the + * rebuild started (a job); `rebuilds` shows it running, and why it failed. The provider still serves 1. + */ +export const Search = evolve(SearchV1, (e) => e.addState("owner", "rebuilds", arr(RebuildSchema))); + /** * Unread answers (owners only): a session is unread while its latest root answer (`answers`) is newer than what an owner * has read (`reads`), on any device. A browser marks a session read when its root conversation is shown. diff --git a/apps/p2/plugins/search/server.ts b/apps/p2/plugins/search/server.ts index 6c1bf10a7..e2ec17130 100644 --- a/apps/p2/plugins/search/server.ts +++ b/apps/p2/plugins/search/server.ts @@ -1,10 +1,10 @@ // The message index, the workers' feed into it, and owner search over sessions and messages. -import { num, obj } from "@earendil-works/pi-schema"; -import { type Ctx, defineFacet, defineState, forbidden, isOwner, isWorker, record } from "@p2/app/v1"; -import { Hosts, Server, serverOf } from "@p2/app/v1/server"; +import { arr, num, obj, str } from "@earendil-works/pi-schema"; +import { defineFacet, defineJob, defineState, deriveTyped, forbidden, isOwner, isWorker, record } from "@p2/app/v1"; +import { Server, serverOf } from "@p2/app/v1/server"; import { ArchivedStarts, Catalog, type Session } from "../sessions/contract.ts"; -import { IndexScan, Search, SearchFeed, SearchIndex, SessionReads } from "./contract.ts"; +import { IndexScan, type Rebuild, RebuildSchema, Search, SearchFeed, SearchIndex, SessionReads } from "./contract.ts"; import { SearchDb } from "./db.ts"; import { ftsQuery } from "./query.ts"; @@ -19,11 +19,29 @@ const Markers = defineState("p2.search-index", { initial: () => ({ answers: {}, reads: {} }), }); +/** Pi Mobile's rebuildIndex as a job: a rebuild interrupted by a restart runs again (it starts over). */ +const RebuildJob = defineJob("p2.search.rebuild", { + version: 1, + args: obj({ session: str() }), + result: obj({}), + persist: { resume: "rerun" }, +}); + +/** The latest failed rebuild per session, until it is rebuilt again. */ +const RebuildFailures = defineState("p2.search.rebuild-failures", { + schema: obj({ list: arr(RebuildSchema) }), + version: 1, + initial: () => ({ list: [] }), +}); + export default defineFacet((host) => { host.serve(SearchIndex, (_params, self) => { const server = self.get(Server, {}); const catalog = self.get(Catalog, {}); + const archivedStarts = self.get(ArchivedStarts, {}); + const natives = serverOf(self); const markers = self.state(Markers); + const failures = self.state(RebuildFailures); const dbResource = self.open( async (ctx) => { const db = new SearchDb(server.info.value.dataDir); @@ -35,7 +53,48 @@ export default defineFacet((host) => { }, (db) => db.close(), ); + // Stop the worker, reset the index, scan from a fresh worker (an archived one without resuming its work), and stop + // an archived session's worker again. This local object's `ctx.get` acts as the server, whom `IndexScan` serves. + const rebuilds = self.job(RebuildJob, { + run: async ({ session }, ctx) => { + const archived = (await catalog.get({ id: session }, ctx)).archivedAt !== null; + await natives().workers.stop(session); + dbResource.value.reset(session); + if (archived) await archivedStarts.allow({ session }, ctx); + try { + await (await ctx.get(IndexScan, { session })).scanned(ctx); + } finally { + if (archived) { + await archivedStarts.end({ session }, ctx); + await natives().workers.suspend(session); + } + } + return {}; + }, + finish: async (outcome, tx) => { + const draft = await tx.draft(failures); + draft.list = draft.list.filter((each) => each.session !== outcome.args.session); + if (outcome.kind === "failed") + draft.list.push({ + session: outcome.args.session, + phase: "failed", + error: outcome.error.kind === "threw" ? outcome.error.error.message : "The rebuild was interrupted", + }); + }, + }); return { + rebuilds: deriveTyped(self, [rebuilds.running, failures], ([running, { list }]): Rebuild[] => [ + ...running.flatMap((run) => + run.phase === "blocked" ? [] : [{ session: run.args.session, phase: "running" as const }], + ), + ...list, + ]), + rebuild: async ({ session }, ctx) => { + // Refused at once for a session the catalog does not know. + await catalog.get({ id: session }, ctx); + await rebuilds.start({ session }, ctx, { key: session }); + return null; + }, answers: self.derive([markers], ([value]) => value.answers), reads: self.derive([markers], ([value]) => value.reads), markRead: async ({ session, through }, ctx) => { @@ -121,55 +180,36 @@ export default defineFacet((host) => { }; }); - host.serve(Search, (_params, principal, self) => { - if (!isOwner(principal)) throw forbidden("search is owner-only"); - const catalog = self.get(Catalog, {}); - const index = self.get(SearchIndex, {}); - const hosts = self.get(Hosts, {}); - const archivedStarts = self.get(ArchivedStarts, {}); - const server = serverOf(self); - // Pi Mobile's rebuildIndex: stop the worker, reset the index, scan from a fresh worker (an archived one without - // resuming its work), and stop an archived session's worker again. - const rebuild = async (session: string, ctx: Ctx) => { - const archived = (await catalog.get({ id: session }, ctx)).archivedAt !== null; - await hosts.stop({ session }, ctx); - await index.reset({ session }, ctx); - if (archived) await archivedStarts.allow({ session }, ctx); - try { - // As the server: the worker answers only the server's own scan requests. - await server().root(async (root) => { - const scan = await root.get(IndexScan, { session }); - await scan.scanned(root); - }); - } finally { - if (archived) { - await archivedStarts.end({ session }, ctx); - await server().workers.suspend(session); - } - } - }; - return { - variant: "owner", - members: { - rebuildIndex: async ({ session }, ctx) => { - await rebuild(session, ctx); - return null; + host.serve( + Search, + (_params, principal, self) => { + if (!isOwner(principal)) throw forbidden("search is owner-only"); + const catalog = self.get(Catalog, {}); + const index = self.get(SearchIndex, {}); + return { + variant: "owner", + members: { + rebuilds: index.rebuilds, + rebuildIndex: (args, ctx) => index.rebuild(args, ctx), + search: async ({ query, archived = false }, ctx) => { + const scope = catalog.sessions.value.filter((session) => (session.archivedAt !== null) === archived); + const needle = query.toLowerCase(); + const sessions = scope + .filter((session) => `${session.title}\n${session.cwd}`.toLowerCase().includes(needle)) + .sort(sidebarOrder) + .map((session) => session.id); + const titles = new Map(scope.map((session) => [session.id, session.title])); + const messages = query.trim() + ? await index.messages({ query, sessions: [...titles.keys()] }, ctx) + : []; + return { + sessions, + messages: messages.map((hit) => ({ ...hit, title: titles.get(hit.session) ?? "" })), + }; + }, }, - search: async ({ query, archived = false }, ctx) => { - const scope = catalog.sessions.value.filter((session) => (session.archivedAt !== null) === archived); - const needle = query.toLowerCase(); - const sessions = scope - .filter((session) => `${session.title}\n${session.cwd}`.toLowerCase().includes(needle)) - .sort(sidebarOrder) - .map((session) => session.id); - const titles = new Map(scope.map((session) => [session.id, session.title])); - const messages = query.trim() ? await index.messages({ query, sessions: [...titles.keys()] }, ctx) : []; - return { - sessions, - messages: messages.map((hit) => ({ ...hit, title: titles.get(hit.session) ?? "" })), - }; - }, - }, - }; - }); + }; + }, + { accepts: { 1: {} } }, + ); }); diff --git a/apps/p2/plugins/search/tui.ts b/apps/p2/plugins/search/tui.ts index f981d4240..481d3a3fc 100644 --- a/apps/p2/plugins/search/tui.ts +++ b/apps/p2/plugins/search/tui.ts @@ -1,8 +1,15 @@ // The session selector's message search in the TUI (p2-tui-port.md section 1.17): `Search` is literal and bounded, and -// the owner's; the selector shows its hits as a separate group. +// the owner's; the selector shows its hits as a separate group. `/reindex` rebuilds the viewed session's index. -import { BACKGROUND_CONTEXT, contribute, defineFacet } from "@p2/app/v1"; -import { type TuiSessionSource, TuiSessionSources } from "@p2/app/v1/tui"; +import { BACKGROUND_CONTEXT, contribute, defineFacet, liveIn, whenLive } from "@p2/app/v1"; +import { + lineText, + type TuiCommand, + TuiCommands, + type TuiContext, + type TuiSessionSource, + TuiSessionSources, +} from "@p2/app/v1/tui"; import { Search } from "./contract.ts"; const source: TuiSessionSource = { @@ -14,8 +21,52 @@ const source: TuiSessionSource = { }, BACKGROUND_CONTEXT), }; +/** + * `/reindex`: re-indexes the viewed session's history (`Search.rebuildIndex`). Long work (a job on the server): a + * line says it runs at once, another when it is done or why it failed (`Search.rebuilds`). + */ +function reindex(ctx: TuiContext): void { + const session = ctx.identity.session; + if (!session) return; + const scope = ctx.scope(); + let started = false; + const search = scope.watch(Search, {}, (ref, follow) => { + if (ref.variant !== "owner") return; + follow(ref.rebuilds, (rebuilds) => { + const rebuild = rebuilds.find((each) => each.session === session); + if (!started || rebuild?.phase === "running") return; + if (rebuild?.phase === "failed") + ctx.notify(`Search index rebuild failed: ${lineText(rebuild.error)}`, "error"); + else ctx.notify("Search index rebuilt"); + scope.dispose(); + }); + }); + ctx.notify("Rebuilding the search index…"); + void (async () => { + await whenLive(search); + const owner = liveIn(search, "owner"); + if (!owner) throw new Error("Only the owner rebuilds the search index"); + await owner.rebuildIndex({ session }, BACKGROUND_CONTEXT); + started = true; + })().catch((error: unknown) => { + ctx.fail(error); + scope.dispose(); + }); +} + +const COMMANDS: Record = { + "p2.search.reindex": { + title: "Rebuild the session's search index", + category: "Owner", + when: "chat && owner && !overlay", + slash: { name: "reindex" }, + run: (ctx) => reindex(ctx), + }, +}; + export default defineFacet((host) => contribute(host, "p2.tui-search", (self) => { self.contribute(TuiSessionSources, { search: source }); + self.contribute(TuiCommands, COMMANDS); }), ); diff --git a/apps/p2/plugins/search/worker.ts b/apps/p2/plugins/search/worker.ts index 1b79cc5ba..7bcb30ad1 100644 --- a/apps/p2/plugins/search/worker.ts +++ b/apps/p2/plugins/search/worker.ts @@ -24,7 +24,6 @@ import { signalOf, until, } from "@p2/app/v1"; -import { durableContext } from "@p2/app/v1/node"; import { CommitSignal, harnessOf, topLevelRecords } from "@p2/app/v1/worker"; import { Subagents } from "../subagents/contract.worker.ts"; import { type IndexedMessage, IndexScan, SearchFeed } from "./contract.ts"; @@ -89,17 +88,11 @@ async function catchUp( through: number, ctx: Ctx, ): Promise { - const context = durableContext(ctx); - const upper = (await root.entries({}, 1, undefined, context)).items[0]?.id; + const upper = (await root.entries({}, 1, undefined, ctx)).items[0]?.id; if (upper === undefined || upper <= through) return through; let cursor: Cursor | undefined; do { - const page = await root.entries( - { minEntryId: (through + 1) as EntryId, maxEntryId: upper }, - PAGE, - cursor, - context, - ); + const page = await root.entries({ minEntryId: (through + 1) as EntryId, maxEntryId: upper }, PAGE, cursor, ctx); cursor = page.next; const messages = page.items.map(indexedMessage).filter((message) => message !== undefined); await feed.index({ conversation: root.id, messages, through: cursor === undefined ? upper : through }, ctx); @@ -137,16 +130,15 @@ export default defineFacet((host) => { while (!signalOf(ctx).aborted) { const seen = signal.version; try { - const context = durableContext(ctx); - const registry = await durable.snapshot(Subagents, ROOT_CONVERSATION_ID, context); + const registry = await durable.snapshot(Subagents, ROOT_CONVERSATION_ID, ctx); // A branch's history starts after its fork point: what it inherits is indexed with the conversation // storing it. const starts = new Map( - (await topLevelRecords(durable, context)).map((record) => [record.id, record.parent?.at ?? 0]), + (await topLevelRecords(durable, ctx)).map((record) => [record.id, record.parent?.at ?? 0]), ); for (const agent of Object.values(registry?.agents ?? {})) starts.set(agent.conversationId, 0); for (const [id, start] of starts) { - const conversation = await durable.conversation(id as ConversationId, context); + const conversation = await durable.conversation(id as ConversationId, ctx); if (!conversation) continue; const through = await feed.cursor({ conversation: id }, ctx); await catchUp(conversation, feed, Math.max(through, start), ctx); diff --git a/apps/p2/plugins/sessions/BranchSheet.tsx b/apps/p2/plugins/sessions/BranchSheet.tsx index 931b7d01b..e156ada7f 100644 --- a/apps/p2/plugins/sessions/BranchSheet.tsx +++ b/apps/p2/plugins/sessions/BranchSheet.tsx @@ -3,7 +3,10 @@ import { Sheet } from "@p2/app/v1/web"; import { useState } from "react"; import { PREVIEW_LENGTH } from "../tree/contract.ts"; -/** Renames a branch; `label` null shows `title` (conversation 1's default), and only conversation 1 may go back to it. */ +/** + * Renames a branch; `label` null shows `title` (conversation 1's default), and only conversation 1 may go back to it. + * Saving closes the sheet: the row shows the new label while the rename is in flight. + */ export function BranchSheet({ title, conversation, @@ -15,18 +18,17 @@ export function BranchSheet({ title: string; conversation: number; label: string | null; - onSave: (label: string | null) => Promise; + onSave: (label: string | null) => void; onClose: () => void; }) { const [draft, setDraft] = useState(label ?? title); - const [error, setError] = useState(); const next = draft.replace(/\s+/g, " ").trim(); const resettable = conversation === 1; const unchanged = next === (label ?? title); - const save = (value: string | null) => - void onSave(value) - .then(onClose) - .catch((failure: unknown) => setError(failure instanceof Error ? failure.message : String(failure))); + const save = (value: string | null) => { + onSave(value); + onClose(); + }; return (
The first branch shows the session title until it gets a label of its own.

)} - {error &&

{error}

}
- {sessionSheet && ( - perform(service?.rename({ id: session.id, title }, BACKGROUND_CONTEXT))} - onPin={() => perform(service?.pin({ id: session.id, pinned: !session.pinned }, BACKGROUND_CONTEXT))} - onShare={() => openLayer({ kind: "sheet", name: "share" })} - onCwd={() => openLayer(directoryPickerLayer(session.env ?? null, session.cwd))} - onArchive={() => { - closeLayers(); - void nav.replace(conversationRoute(route, undefined)); - // Leaving the session unmounts this view and releases `service`, which cancels its calls: the - // archive holds a reference of its own until it is done. - const archiving = host.hold(Sessions, {}, BACKGROUND_CONTEXT); - perform( - whenLive(archiving) - .then(() => liveIn(archiving, "owner")?.archive({ id: session.id }, BACKGROUND_CONTEXT)) - .finally(() => archiving.release()), - ); - }} - /> - )} - - ); -} diff --git a/apps/p2/plugins/sessions/SessionSheet.tsx b/apps/p2/plugins/sessions/SessionSheet.tsx index 803694d12..be04da80a 100644 --- a/apps/p2/plugins/sessions/SessionSheet.tsx +++ b/apps/p2/plugins/sessions/SessionSheet.tsx @@ -1,6 +1,10 @@ import { ChevronIcon, locationLabel, Sheet } from "@p2/app/v1/web"; import { useState } from "react"; import type { Session } from "./contract.ts"; + +/** The session's search index rebuild (`Search.rebuilds`): running, failed with why, or none. */ +export type Reindex = { readonly phase: "running" } | { readonly phase: "failed"; readonly error: string } | undefined; + export function SessionSheet({ session, onClose, @@ -9,6 +13,8 @@ export function SessionSheet({ onArchive, onShare, onCwd, + reindex, + onReindex, }: { session: Session; onClose: () => void; @@ -17,6 +23,9 @@ export function SessionSheet({ onArchive: () => void; onShare: () => void; onCwd: () => void; + reindex: Reindex; + /** Re-indexes the session's history for search (long work: the row shows it running); absent without search. */ + onReindex: (() => void) | undefined; }) { const [title, setTitle] = useState(session.title); const changed = title.trim() !== "" && title.trim() !== session.title; @@ -55,6 +64,12 @@ export function SessionSheet({ + {onReindex && ( + + )} diff --git a/apps/p2/plugins/sessions/contract.ts b/apps/p2/plugins/sessions/contract.ts index 0fadfbe3d..ab422c1c4 100644 --- a/apps/p2/plugins/sessions/contract.ts +++ b/apps/p2/plugins/sessions/contract.ts @@ -3,10 +3,12 @@ import type { Ctx } from "@p2/app/v1"; import { evolve, keyedRegistry, + literal, local, ModelRefSchema, method, param, + propertiesOf, remote, serverHost, sessionHost, @@ -73,6 +75,41 @@ export const CreateSchema = obj({ thinkingLevel: opt(ThinkingLevelSchema), }); +/** A session ID: a UUID, chosen by the server or by the client (so a page can navigate before the answer). */ +export const SessionIdSchema = str({ pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" }); + +/** `Sessions.create` from version 6: `id`, the new session's ID chosen by the caller (default a new one). */ +export const CreateArgsSchema = obj({ ...propertiesOf(CreateSchema), id: opt(SessionIdSchema) }); + +/** A lifecycle operation: what runs as a job after its method answered. */ +export const OperationKindSchema = union([literal("relocate"), literal("clone"), literal("fork"), literal("archive")]); + +/** + * A lifecycle operation of a session in progress or failed (`Sessions.operations`): `session` the session it acts on, + * `target` the new session a clone or fork makes (its row exists from the start; its worker starts once the copy is + * done). `copying`: a clone's or fork's files are being copied (the target's worker waits). A failed one stays until + * it is dismissed, with why it failed; what it changed was rolled back (a failed copy's row is archived). + */ +export const OperationSchema = union([ + obj({ + phase: literal("running"), + id: str(), + kind: OperationKindSchema, + session: str(), + target: union([str(), nul()]), + copying: bool(), + }), + obj({ + phase: literal("failed"), + id: str(), + kind: OperationKindSchema, + session: str(), + target: union([str(), nul()]), + error: str(), + }), +]); +export type Operation = Static; + /** * Archived sessions whose worker may start anyway, without resuming Durable work: rebuilding their search index * (server, host-internal; the search plugin's `rebuildIndex`, as Pi Mobile's). @@ -88,8 +125,8 @@ export const ArchivedStarts = local("p2.archived-starts", { }); 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}$" })), + /** A chosen ID (the caller's, a clone's or a fork's); default a new one. */ + id: opt(SessionIdSchema), env: opt(union([str(), nul()])), cwd: str(), title: str(), @@ -227,15 +264,8 @@ const SessionsV4 = evolve(SessionsV3, (e) => .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) => +/** 5: a branch's label may be null (the session title). */ +const SessionsV5 = evolve(SessionsV4, (e) => e .state("owner", "sessions", arr(SessionSchema)) .state("guest", "sessions", arr(SessionSchema)) @@ -246,6 +276,62 @@ export const Sessions = evolve(SessionsV4, (e) => .args("treeSelf", obj({ main: int({ minimum: 1 }), branches: arr(BranchSchema) })), ); +/** + * 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 provider still serves 3, 4 and 5 (its `accepts`): + * pages and TUIs loaded before a deploy keep working. + * + * 6, client-chosen IDs and lifecycle operations as jobs: `create` takes the new session's `id`, `clone` and `fork` the + * copy's (`copy`), so a page navigates at once. `clone`, `fork`, `relocate` and `archive` answer once their effect is + * visible (the copy's row, the archived row) or started (a move), and their long part runs as a job, shown in + * `operations` (running, then gone or failed with its error) until `dismissOperation`. A `relocate` the worker + * refuses (busy) is a failed operation. + * + * 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(SessionsV5, (e) => + e + .args("create", CreateArgsSchema) + .args("clone", obj({ id: str(), copy: opt(SessionIdSchema) })) + .args( + "fork", + obj({ + id: str(), + conversation: int({ minimum: 1 }), + before: int({ minimum: 1 }), + copy: opt(SessionIdSchema), + }), + ) + .addState("owner", "operations", arr(OperationSchema)) + .addMethod("owner", "dismissOperation", obj({ operation: str() }), nul()), +); + +const Location = obj({ id: str(), cwd: str({ minLength: 1 }), env: union([str(), nul()]) }); + +/** + * The sessions' lifecycle operations (server, host-internal; a host-lifetime object, so its job runs persist): each + * method answers once its visible effect committed and runs the rest as a job (`operations`). `move` is the agent's + * relocation (`SessionMoves`): it waits for the job and answers why a busy worker refused (nothing recorded). + */ +export const SessionOperations = local("p2.session-operations", { + version: 1, + params: {}, + members: { + operations: arr(OperationSchema), + move: method(Location, obj({ refused: union([str(), nul()]) })), + relocate: method(Location), + clone: method(obj({ id: str(), copy: SessionIdSchema }), SessionSchema), + fork: method( + obj({ id: str(), conversation: int({ minimum: 1 }), before: int({ minimum: 1 }), copy: SessionIdSchema }), + SessionSchema, + ), + archive: method(obj({ id: str() })), + restore: method(obj({ id: str() })), + dismiss: method(obj({ operation: str() })), + }, +}); + /** * 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/copy.ts b/apps/p2/plugins/sessions/copy.ts index 31f334de0..33af86182 100644 --- a/apps/p2/plugins/sessions/copy.ts +++ b/apps/p2/plugins/sessions/copy.ts @@ -52,12 +52,19 @@ export async function copySessionStore(dataDir: string, from: string, to: string ); } -/** Removes what a failed copy left for the session `id` (it has no catalog entry yet, or an archived one). */ +/** Removes the copy's per-session directories (a fork starting its copy over). */ +export async function removeSessionDirectories(dataDir: string, id: string): Promise { + await Promise.all( + ["artifacts", "downloads", "uploads"].map((kind) => + rm(join(dataDir, kind, id), { recursive: true, force: true }), + ), + ); +} + +/** Removes what a failed copy left for the session `id` (its row is archived, its worker stopped). */ export async function removeSessionCopy(dataDir: string, id: string): Promise { await Promise.all([ ...["", "-wal", "-shm"].map((suffix) => rm(join(dataDir, "sessions", `${id}.sqlite${suffix}`), { force: true })), - ...["artifacts", "downloads", "uploads"].map((kind) => - rm(join(dataDir, kind, id), { recursive: true, force: true }), - ), + removeSessionDirectories(dataDir, id), ]); } diff --git a/apps/p2/plugins/sessions/server.ts b/apps/p2/plugins/sessions/server.ts index 88105f7d3..406fcbe78 100644 --- a/apps/p2/plugins/sessions/server.ts +++ b/apps/p2/plugins/sessions/server.ts @@ -1,23 +1,34 @@ +// The session catalog and its views (server): the sidebar's rows, creating and renaming sessions, and the lifecycle +// operations (relocate, clone, fork, archive), each a method that answers once its visible effect committed and a job +// for its long part, shown in `Sessions.operations` while it runs and when it failed. import { randomUUID } from "node:crypto"; import { mkdir, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { isAbsolute, resolve } from "node:path"; +import { arr, bool, nul, obj, str, union, unknown } from "@earendil-works/pi-schema"; import { type AcceptsTable, + type Context, type Ctx, - contribute, defineFacet, + defineJob, + defineState, deriveTyped, forbidden, isGuest, isOwner, isWorker, + type JobError, type Location, + literal, nativeOf, type Ref, type SessionInit, serveKeyed, + type Tx, + until, WorkerBoundary, + withAbortSignal, } from "@p2/app/v1"; import { Server, serverOf, WorkerInitValues } from "@p2/app/v1/server"; import { EnvManagers, EnvService } from "../envs/contract.ts"; @@ -29,13 +40,16 @@ import { Catalog, isThinkingLevel, MAX_TITLE, + type Operation, + OperationSchema, type Session, SessionArchived, SessionMoves, + SessionOperations, Sessions, SessionTransfer, } from "./contract.ts"; -import { copySessionDirectories, copySessionStore, removeSessionCopy } from "./copy.ts"; +import { copySessionDirectories, copySessionStore, removeSessionCopy, removeSessionDirectories } 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) { @@ -71,9 +85,12 @@ const CATALOG_ACCEPTS: AcceptsTable = { }, }; +/** Older arguments of `create`, `clone` and `fork` are a subset of the current ones (no chosen IDs). */ +const SAME_ARGS = { args: (old: T) => old }; + /** * 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. + * back; v4's `treeSelf` labels are text, which the current version takes as it is. None of them sees `operations`. */ const SESSIONS_ACCEPTS: AcceptsTable = { 3: { @@ -82,7 +99,11 @@ const SESSIONS_ACCEPTS: AcceptsTable = { guest: { sessions: (rows) => rows.map(sessionV3) }, worker: { session: (row) => (row === null ? null : sessionV3(row)) }, }, - methods: { create: { result: sessionV3 }, clone: { result: sessionV3 }, fork: { result: sessionV3 } }, + methods: { + create: { ...SAME_ARGS, result: sessionV3 }, + clone: { ...SAME_ARGS, result: sessionV3 }, + fork: { ...SAME_ARGS, result: sessionV3 }, + }, }, 4: { state: { @@ -91,36 +112,39 @@ const SESSIONS_ACCEPTS: AcceptsTable = { worker: { session: (row) => (row === null ? null : sessionV4(row)) }, }, methods: { - create: { result: sessionV4 }, - clone: { result: sessionV4 }, - fork: { result: sessionV4 }, - treeSelf: { args: (old) => old }, + create: { ...SAME_ARGS, result: sessionV4 }, + clone: { ...SAME_ARGS, result: sessionV4 }, + fork: { ...SAME_ARGS, result: sessionV4 }, + treeSelf: SAME_ARGS, }, }, + 5: { methods: { create: SAME_ARGS, clone: SAME_ARGS, fork: SAME_ARGS } }, }; -/** Contributes each worker's session parameters; refuses archived and unknown sessions. */ - const cleanTitle = (title: string): string => title.replace(/\s+/g, " ").trim(); +/** `~` and `~/…` on this machine. */ +const expandHome = (cwd: string): string => + cwd === "~" ? homedir() : cwd.startsWith("~/") ? resolve(homedir(), cwd.slice(2)) : cwd; + /** * Fences the session's running worker for `doing` ("changing directory"): undefined once fenced, else why it refused - * (it has work, or `to` is a move its `RelocationChecks` refuse; nothing fenced). Runs in `root`, a root of the - * server's own (see `lifecycle`). + * (it has work, or `to` is a move its `RelocationChecks` refuse; nothing fenced). `ctx` acts as the server (a local + * object's job or method): the worker's `WorkerBoundary` answers only the server. */ async function fenceWorker( catalog: Ref, children: Ref, id: string, doing: string, - root: Ctx, + ctx: Ctx, to?: Location, ): Promise { - const boundary = await root.get(WorkerBoundary, { session: id }); - const result = await boundary.prepare(to ? { to } : null, root); + const boundary = await ctx.get(WorkerBoundary, { session: id }); + const result = await boundary.prepare(to ? { to } : null, ctx); if (result.accepted) return undefined; if (result.refusal !== undefined) return result.refusal; - const row = await catalog.get({ id }, root); + const row = await catalog.get({ id }, ctx); // A `/tree` branch left while running goes on in the background (docs/tree.md); conversation 1 is top-level before // the worker first reports `branches`. const branchOf = (conversation: number) => row.branches.find((branch) => branch.conversation === conversation); @@ -144,14 +168,81 @@ async function fenceWorker( : `Background work is still running (for example a message waiting for a subagent's answer): wait for it or stop the subagent before ${doing}`; } +/** Lifecycle jobs persist and run again after a crash: each run is idempotent (a copy starts over, a move repeats). */ +const RERUN = { resume: "rerun" } as const; + +const RelocateJob = defineJob("p2.sessions.relocate", { + version: 1, + args: obj({ + id: str(), + cwd: str(), + env: union([str(), nul()]), + /** The owner's move (a refusal is a failed operation) or the agent's (`SessionMoves`: its tool reports it). */ + by: union([literal("owner"), literal("agent")]), + }), + result: obj({ refused: union([str(), nul()]) }), + persist: RERUN, +}); + +const CloneJob = defineJob("p2.sessions.clone", { + version: 1, + args: obj({ id: str(), copy: str() }), + /** Why a busy worker refused the copy (rolled back: a failed operation), else null. */ + result: obj({ refused: union([str(), nul()]) }), + persist: RERUN, +}); + +/** A fork's progress: copying its files (its worker waits), then seeding its history (through its worker). */ +const ForkProgress = defineState("p2.sessions.fork.progress", { + schema: obj({ copying: bool() }), + version: 1, + initial: () => ({ copying: true }), + persist: true, + checkpointWhen: () => true, +}); + +const ForkJob = defineJob("p2.sessions.fork", { + version: 1, + /** `entries`: the history the fork holds, read from the source when the fork was asked for. */ + args: obj({ id: str(), copy: str(), entries: arr(unknown()) }), + result: nul(), + state: ForkProgress, + persist: RERUN, +}); + +const ArchiveJob = defineJob("p2.sessions.archive", { + version: 1, + args: obj({ id: str() }), + result: nul(), + persist: RERUN, +}); + +/** Failed operations, until dismissed (the latest `MAX_FAILED`). */ +const Failed = defineState("p2.sessions.failed", { + schema: obj({ list: arr(OperationSchema) }), + version: 1, + initial: () => ({ list: [] }), +}); +const MAX_FAILED = 20; + +/** Why a job failed, in words. */ +function failureText(error: JobError): string { + if (error.kind === "threw") return error.error.message; + if (error.kind === "aborted") return "The operation was interrupted"; + return `The operation was stopped after ${error.restarts} restarts`; +} + +/** How long a worker start waits for its session's row (a client-chosen ID held before `create` answered). */ +const ROW_WAIT_MS = 10_000; + 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. + // The catalog's sessions: its database is this object's native, and every write sets the rows in the same + // method, before it answers. const sessions = self.writable([]); const db = self.open( async () => { @@ -174,10 +265,9 @@ export default defineFacet((host) => { sessions.set(db.value.all()); return null; }, - update: async ({ id, ...change }, ctx) => { + update: async ({ id, ...change }) => { 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; }, }; @@ -200,83 +290,352 @@ export default defineFacet((host) => { }; }); - contribute(host, "p2.sessions-init", (self) => { + host.serve(SessionOperations, (_params, self) => { const catalog = self.get(Catalog, {}); - const envs = self.get(EnvService, {}); - const archivedStarts = self.get(ArchivedStarts, {}); - self.contribute(WorkerInitValues, { - session: async (id, ctx): Promise => { - const session = await catalog.get({ id }, ctx); - // An archived session starts only to rebuild its index, and then without resuming its work. - const indexing = session.archivedAt !== null && archivedStarts.allowed.value.includes(id); - if (session.archivedAt !== null && !indexing) throw forbidden("Session is archived"); - if (session.env !== null && session.env !== undefined && !Object.hasOwn(envs.configs.value, session.env)) - throw new Error(`Environment "${session.env}" is not configured; refusing local execution`); - return { - id: session.id, - cwd: session.cwd, - env: session.env ?? null, - model: session.model, - thinkingLevel: session.thinkingLevel as SessionInit["thinkingLevel"], - ...(indexing ? { resume: false } : {}), - }; - }, - }); - }); - - host.serve(SessionMoves, (_params, self) => { - const catalog = self.get(Catalog, {}); - const server = serverOf(self); - self.get(EnvService, {}); + const server = self.get(Server, {}); + const natives = serverOf(self); + const envService = self.get(EnvService, {}); const envs = nativeOf(self, EnvManagers, "The SSH manager"); const children = self.get(ChildStore, {}); - return { - relocate: async ({ id, cwd, env }, ctx) => { - const session = await catalog.get({ id }, ctx); - if (session.archivedAt !== null) throw new Error("Session is archived"); - // The main conversation and the conversations following it move; subagents with their own location stay - // where they are (on another machine too). A destination that is not configured refuses here. - if (env) cwd = await envs().manager.workingDirectory(env, cwd, false); - else { - cwd = resolve(cwd === "~" ? homedir() : cwd.startsWith("~/") ? resolve(homedir(), cwd.slice(2)) : cwd); - if (!(await stat(cwd)).isDirectory()) throw new Error("Working directory is not a directory"); - } - const location = { cwd, env: env ?? null }; + const archivedStarts = self.get(ArchivedStarts, {}); + const archived = self.registry(SessionArchived); + const failed = self.state(Failed); + /** Copies whose row is being made: their worker waits like one whose files are being copied. */ + const preparing = self.writable([]); + + /** A finished run that failed (a refused owner's move too) stays in `operations` until dismissed. */ + const recordFailure = async (tx: Tx, operation: Omit, "phase">) => { + const list = (await tx.draft(failed)).list; + list.push({ phase: "failed", ...operation }); + if (list.length > MAX_FAILED) list.splice(0, list.length - MAX_FAILED); + }; + + /** A failed copy: its row archived first (so its worker cannot start), its worker stopped, its files gone. */ + const failCopy = async (copy: string, ctx: Ctx) => { + if (catalog.sessions.value.some((session) => session.id === copy)) + await catalog.update({ id: copy, archived: true }, ctx); + await natives().workers.suspend(copy); + await removeSessionCopy(server.info.value.dataDir, copy); + }; + + /** + * Moves session `id` to `location`: a stopped worker only gets the catalog update; a running one is fenced, the + * catalog moves and the worker follows in place (restarted from the catalog when it cannot). Returns why a busy + * worker refused (nothing moved), else null. + */ + const relocate = async (id: string, location: Location, ctx: Ctx): Promise => { + let refused: string | null = null; + const boundary = () => ctx.get(WorkerBoundary, { session: id }); + try { + await natives().workers.relocate(id, { + prepare: async () => { + const refusal = await fenceWorker(catalog, children, id, "changing directory", ctx, location); + refused = refusal ?? null; + return refusal; + }, + update: async () => { + await catalog.update({ id, ...location }, ctx); + }, + move: async () => { + await (await boundary()).relocate(location, ctx); + }, + cancel: async () => { + await (await boundary()).cancel(ctx); + }, + }); + } catch (error) { + // A busy worker (or one starting or stopping): nothing moved. + if (refused !== null) return refused; + if (error instanceof Error && error.message.startsWith("Background work is still running")) + return error.message; + throw error; + } + return null; + }; + + const relocations = self.job(RelocateJob, { + run: async ({ id, cwd, env }, ctx) => ({ refused: await relocate(id, { cwd, env }, ctx) }), + finish: async (outcome, tx) => { + const error = + outcome.kind === "failed" + ? failureText(outcome.error) + : outcome.args.by === "owner" + ? outcome.result.refused + : null; + if (error !== null) + await recordFailure(tx, { + id: outcome.id, + kind: "relocate", + session: outcome.args.id, + target: null, + error, + }); + }, + }); + + const clones = self.job(CloneJob, { + run: async ({ id, copy }, ctx) => { + const dataDir = server.info.value.dataDir; let refused: string | null = null; try { - // In a root of the server's own: a caller giving up must not cut a fenced worker off between its steps. - await server().root((root) => { - // Only a running worker is asked (a hold would start a stopped one); it follows the move in place. - const boundary = () => root.get(WorkerBoundary, { session: id }); - return server().workers.relocate(id, { - prepare: async () => { - const refusal = await fenceWorker(catalog, children, id, "changing directory", root, location); - refused = refusal ?? null; - return refusal; - }, - update: async () => { - await catalog.update({ id, ...location }, root); - }, - move: async () => { - await (await boundary()).relocate(location, root); - }, - cancel: async () => { - await (await boundary()).cancel(root); - }, - }); - }); + // The store is copied while the worker is stopped: SQLite's file and WAL belong together then. + await natives().workers.pause( + id, + async () => { + const refusal = await fenceWorker(catalog, children, id, "cloning", ctx); + refused = refusal ?? null; + return refusal; + }, + async () => { + await removeSessionCopy(dataDir, copy); + await copySessionStore(dataDir, id, copy); + await copySessionDirectories(dataDir, id, copy); + }, + "cloning", + ); + // A move while the clone waited for the pause belongs to the copy too. + const [source, row] = await Promise.all([catalog.get({ id }, ctx), catalog.get({ id: copy }, ctx)]); + if (source.cwd !== row.cwd || (source.env ?? null) !== (row.env ?? null)) + await catalog.update({ id: copy, cwd: source.cwd, env: source.env ?? null }, ctx); + return { refused: null }; } catch (error) { - // A busy worker (or one starting or stopping): nothing moved. + await failCopy(copy, ctx); + // A busy worker (or one starting or stopping) is an answer, not a failure of the job. if (refused !== null) return { refused }; if (error instanceof Error && error.message.startsWith("Background work is still running")) return { refused: error.message }; throw error; } - return { refused: null }; + }, + finish: async (outcome, tx) => { + const error = outcome.kind === "failed" ? failureText(outcome.error) : outcome.result.refused; + if (error !== null) + await recordFailure(tx, { + id: outcome.id, + kind: "clone", + session: outcome.args.id, + target: outcome.args.copy, + error, + }); + }, + }); + + const forks = self.job(ForkJob, { + run: async ({ id, copy, entries }, ctx) => { + const dataDir = server.info.value.dataDir; + try { + if (ctx.state.value.copying) { + await removeSessionDirectories(dataDir, copy); + await copySessionDirectories(dataDir, id, copy); + await ctx.state.change((progress) => { + progress.copying = false; + }, ctx); + } + // The copy's worker starts now (this hold, or a page that navigated to it) and writes the history. + await (await ctx.get(SessionTransfer, { session: copy })).seed({ entries }, ctx); + } catch (error) { + await failCopy(copy, ctx); + throw error; + } + return null; + }, + finish: async (outcome, tx) => { + if (outcome.kind === "failed") + await recordFailure(tx, { + id: outcome.id, + kind: "fork", + session: outcome.args.id, + target: outcome.args.copy, + error: failureText(outcome.error), + }); + }, + }); + + const archives = self.job(ArchiveJob, { + run: async ({ id }, ctx) => { + // 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. + for (const close of archived.value.values()) await close(id, ctx); + await natives().workers.suspend(id); + return null; + }, + finish: async (outcome, tx) => { + if (outcome.kind === "failed") + await recordFailure(tx, { + id: outcome.id, + kind: "archive", + session: outcome.args.id, + target: null, + error: failureText(outcome.error), + }); + }, + }); + + const operations = deriveTyped( + self, + [relocations.running, clones.running, forks.running, archives.running, failed], + ([moving, cloning, forking, archiving, { list }]): Operation[] => [ + ...moving.flatMap((run) => + run.phase === "blocked" || run.args.by === "agent" + ? [] + : [running(run.id, "relocate", run.args.id, null, false)], + ), + ...cloning.flatMap((run) => + run.phase === "blocked" ? [] : [running(run.id, "clone", run.args.id, run.args.copy, true)], + ), + ...forking.flatMap((run) => + run.phase === "blocked" ? [] : [running(run.id, "fork", run.args.id, run.args.copy, run.state.copying)], + ), + ...archiving.flatMap((run) => + run.phase === "blocked" ? [] : [running(run.id, "archive", run.args.id, null, false)], + ), + ...list, + ], + ); + /** Sessions whose worker must not start yet: a copy's row being made, or its files being copied. */ + const copying = deriveTyped(self, [preparing, operations], ([made, list]) => [ + ...made, + ...list.flatMap((operation) => + operation.phase === "running" && operation.copying && operation.target !== null ? [operation.target] : [], + ), + ]); + + /** A destination as the move stores it: `~` expanded and checked to be a directory (on its environment). */ + const destination = async ({ cwd, env }: Location): Promise => { + if (env) return { cwd: await envs().manager.workingDirectory(env, cwd, false), env }; + const local = resolve(expandHome(cwd)); + if (!(await stat(local)).isDirectory()) throw new Error("Working directory is not a directory"); + return { cwd: local, env: null }; + }; + const liveSession = async (id: string, ctx: Ctx): Promise => { + const session = await catalog.get({ id }, ctx); + if (session.archivedAt !== null) throw new Error("Session is archived"); + return session; + }; + /** 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, + ); + /** + * Makes a copy's row and starts the job filling it, its worker held back meanwhile. A copy that exists already is + * a retry of the same request: its row is the answer. + */ + const startCopy = async ( + source: Session, + copy: string, + mark: string, + start: () => Promise, + ctx: Ctx, + ) => { + const existing = catalog.sessions.value.find((session) => session.id === copy); + if (existing) return existing; + preparing.set([...preparing.value, copy]); + try { + const row = await copyOf(source, copy, mark, ctx); + await start(); + return row; + } catch (error) { + await failCopy(copy, ctx); + throw error; + } finally { + preparing.set(preparing.value.filter((id) => id !== copy)); + } + }; + + // The start data of a session's worker; refuses archived and unknown sessions. A copy's worker waits until its + // files are there; a client-chosen ID held before its row exists waits for the row. + self.contribute(WorkerInitValues, { + session: async (id, context): Promise => { + const waiting = withAbortSignal(AbortSignal.timeout(ROW_WAIT_MS), context); + await until(catalog.sessions, (rows) => rows.some((row) => row.id === id), waiting).catch(() => {}); + await until(copying, (ids) => !ids.includes(id), context); + return startData(id, context); + }, + }); + const startData = async (id: string, context: Context): Promise => { + const session = await catalog.get({ id }, context); + // An archived session starts only to rebuild its index, and then without resuming its work. + const indexing = session.archivedAt !== null && archivedStarts.allowed.value.includes(id); + if (session.archivedAt !== null && !indexing) throw forbidden("Session is archived"); + if (session.env !== null && session.env !== undefined && !Object.hasOwn(envService.configs.value, session.env)) + throw new Error(`Environment "${session.env}" is not configured; refusing local execution`); + return { + id: session.id, + cwd: session.cwd, + env: session.env ?? null, + model: session.model, + thinkingLevel: session.thinkingLevel as SessionInit["thinkingLevel"], + ...(indexing ? { resume: false } : {}), + }; + }; + return { + operations, + move: async ({ id, ...target }, ctx) => { + await liveSession(id, ctx); + const location = await destination(target); + const run = await relocations.start({ id, ...location, by: "agent" }, ctx); + const outcome = await run.outcome(ctx); + if (outcome.kind === "failed") throw new Error(failureText(outcome.error)); + return outcome.result; + }, + relocate: async ({ id, ...target }, ctx) => { + await liveSession(id, ctx); + const location = await destination(target); + await relocations.start({ id, ...location, by: "owner" }, ctx); + return null; + }, + clone: async ({ id, copy }, ctx) => { + const source = await liveSession(id, ctx); + return startCopy(source, copy, "copy", () => clones.start({ id, copy }, ctx, { key: copy }), ctx); + }, + fork: async ({ id, copy, conversation, before }, ctx) => { + const source = await liveSession(id, ctx); + // Read now, so a fork of a subagent or of an unknown message is refused before anything is made. This local + // object's `ctx.get` acts as the server, whom `SessionTransfer` serves. + const { entries } = await (await ctx.get(SessionTransfer, { session: id })).history( + { conversation, before }, + ctx, + ); + return startCopy(source, copy, "fork", () => forks.start({ id, copy, entries }, ctx, { key: copy }), ctx); + }, + archive: async ({ id }, ctx) => { + await catalog.update({ id, archived: true }, ctx); + await archives.start({ id }, ctx, { key: id }); + return null; + }, + restore: async ({ id }, ctx) => { + // After a running archive of it: its worker stop must not land after the restore. + const archiving = archives.running.value.find((run) => run.phase !== "blocked" && run.key === id); + if (archiving) await archives.outcome(archiving.id, ctx).catch(() => {}); + await catalog.update({ id, archived: false }, ctx); + // A start refused while archived left the worker failed or suspended: its holders start it again. + await natives().workers.resume(id); + return null; + }, + dismiss: async ({ operation }, ctx) => { + await failed.change((draft) => { + draft.list = draft.list.filter((each) => each.id !== operation); + }, ctx); + return null; }, }; }); + host.serve(SessionMoves, (_params, self) => { + const operations = self.get(SessionOperations, {}); + return { relocate: (target, ctx) => operations.move(target, ctx) }; + }); + host.serve( Sessions, (_params, principal, self) => { @@ -322,35 +681,11 @@ export default defineFacet((host) => { 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(catalog, 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 lifecycle = self.get(SessionOperations, {}); const defaultCwd = deriveTyped( self, [catalog.sessions], @@ -368,10 +703,14 @@ export default defineFacet((host) => { sessions: catalog.sessions, defaultCwd, defaultEnv, - create: async ({ env, cwd, createDirectory, title, model, thinkingLevel }, ctx) => { + operations: lifecycle.operations, + create: async ({ id, env, cwd, createDirectory, title, model, thinkingLevel }, ctx) => { + // A chosen ID already in the catalog is a retry of the same request (its answer was lost). + const existing = id === undefined ? undefined : catalog.sessions.value.find((row) => row.id === id); + if (existing) return existing; if (env) cwd = await envs().manager.workingDirectory(env, cwd, createDirectory); else { - cwd = cwd === "~" ? homedir() : cwd.startsWith("~/") ? resolve(homedir(), cwd.slice(2)) : cwd; + cwd = expandHome(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"); @@ -385,6 +724,7 @@ export default defineFacet((host) => { const level = thinkingLevel ?? fallback?.thinkingLevel; return catalog.create( { + ...(id === undefined ? {} : { id }), cwd, env: env ?? null, title: cleanTitle(title ?? "").slice(0, 100) || "New session", @@ -395,63 +735,9 @@ export default defineFacet((host) => { 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; - } - }, + relocate: (target, ctx) => lifecycle.relocate(target, ctx), + clone: ({ id, copy }, ctx) => lifecycle.clone({ id, copy: copy ?? randomUUID() }, ctx), + fork: ({ copy, ...from }, ctx) => lifecycle.fork({ ...from, copy: copy ?? randomUUID() }, ctx), rename: async ({ id, title }, ctx) => { const clean = cleanTitle(title); if (!clean || clean.length > MAX_TITLE) @@ -463,24 +749,23 @@ export default defineFacet((host) => { 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; - }, + archive: (args, ctx) => lifecycle.archive(args, ctx), + restore: (args, ctx) => lifecycle.restore(args, ctx), + dismissOperation: (args, ctx) => lifecycle.dismiss(args, ctx), }, }; }, { accepts: SESSIONS_ACCEPTS }, ); }); + +/** A running operation's entry. */ +function running( + id: string, + kind: Operation["kind"], + session: string, + target: string | null, + copying: boolean, +): Operation { + return { phase: "running", id, kind, session, target, copying }; +} diff --git a/apps/p2/plugins/sessions/tui.ts b/apps/p2/plugins/sessions/tui.ts index 662807fbc..98c5bbfc7 100644 --- a/apps/p2/plugins/sessions/tui.ts +++ b/apps/p2/plugins/sessions/tui.ts @@ -18,13 +18,14 @@ import { truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui"; -import type { Held } from "@p2/app/v1"; -import { BACKGROUND_CONTEXT, contribute, defineFacet, followRegistry, liveIn, statusOf } from "@p2/app/v1"; +import type { Held, HeldIn } from "@p2/app/v1"; +import { BACKGROUND_CONTEXT, contribute, defineFacet, followRegistry, liveIn } from "@p2/app/v1"; import type { TuiContext } from "@p2/app/v1/tui"; import { confirm, keyHint, lineText, + type OptimisticAdd, type SessionChild, type SessionHit, type TuiCommand, @@ -38,7 +39,7 @@ import { TuiSlots, type TuiSurface, } from "@p2/app/v1/tui"; -import { MAX_TITLE, type Session, Sessions } from "./contract.ts"; +import { MAX_TITLE, type Operation, type Session, Sessions } from "./contract.ts"; import { completeLocation, envNames, @@ -74,6 +75,22 @@ function age(timestamp: number): string { } const where = (session: Session): string => `${session.env ? `${session.env}:` : ""}${session.cwd}`; +/** How operations read in the selector and on status lines, by kind. */ +const OPERATION_TEXT: Record = { + relocate: { running: "moving…", failed: "Move failed", done: "Session moved" }, + clone: { running: "cloning…", failed: "Clone failed", done: "Clone ready" }, + fork: { running: "forking…", failed: "Fork failed", done: "Fork ready" }, + archive: { running: "archiving…", failed: "Archive failed", done: "Session archived" }, +}; + +/** A change to one session's row while its call is in flight (a rename, a pin, an archive or restore, a move). */ +type RowGuess = { + readonly id: string; + readonly change: Partial>; +}; +const applyRow = (rows: Session[], { id, change }: RowGuess): Session[] => + rows.map((row) => (row.id === id ? { ...row, ...change } : row)); + export class SessionsSurface implements TuiSurface { readonly scope: TuiScope; readonly runtime: TuiRuntime; @@ -83,7 +100,12 @@ export class SessionsSurface implements TuiSurface { focus: Component & Focusable = this.search; /** The "+ New session" prompt (directory and environment). */ private location: LocationInput | undefined; - private rows: Session[] = []; + private received: Session[] = []; + /** The catalog's lifecycle operations (owner): running ones mark their rows, failed ones are announced. */ + private operations: Operation[] = []; + /** Row changes whose calls are in flight: the list shows them at once and rolls back a refused one. */ + private shownRows: () => Session[]; + private editing: OptimisticAdd; private children: Readonly> = {}; private hits: SessionHit[] = []; private sources: TuiSessionSource[] = []; @@ -105,21 +127,23 @@ export class SessionsSurface implements TuiSurface { private sort: SortMode = "threaded"; private selectedIndex = 0; private visible: Row[] = []; - private service: () => Held; + private service: Held; constructor(runtime: TuiRuntime) { this.runtime = runtime; this.scope = new TuiScope(runtime); + [this.shownRows, this.editing] = this.scope.optimistic(() => this.received, applyRow); this.service = this.scope.watch(Sessions, {}, (ref, follow) => { this.owner = ref.variant === "owner"; const viewer = liveIn(ref, "owner", "guest"); if (!viewer) return; follow(viewer.sessions, (rows) => { - this.rows = rows; + this.received = rows; this.followMains(rows); this.rebuild(); }); if (ref.variant === "owner") { + follow(ref.operations, (operations) => this.followOperations(operations)); follow(ref.defaultCwd, (value) => { this.defaultCwd = value ?? "~"; this.rebuild(); @@ -162,6 +186,73 @@ export class SessionsSurface implements TuiSurface { return this.runtime.ctx; } + /** + * Announces what changed in the lifecycle operations: a failure (as an error line until `/dismiss-failed`), and a + * running operation that ended without one (a move done, a copy ready). + */ + private followOperations(operations: Operation[]): void { + const before = new Map(this.operations.map((operation) => [operation.id, operation])); + this.operations = operations; + for (const operation of operations) + if (operation.phase === "failed" && before.get(operation.id)?.phase !== "failed") + this.runtime.notify( + `${OPERATION_TEXT[operation.kind].failed}: ${lineText(operation.error)} · /dismiss-failed`, + "error", + ); + for (const [id, operation] of before) + if (operation.phase === "running" && !operations.some((each) => each.id === id)) + this.runtime.notify(OPERATION_TEXT[operation.kind].done); + this.rebuild(); + } + + /** What a row says about an operation on it: failed, running, or a copy into it whose files are copied. */ + private operationText(session: string): string { + const operation = + this.operations.find((each) => each.phase === "failed" && each.session === session) ?? + this.operations.find((each) => each.session === session) ?? + this.operations.find((each) => each.phase === "running" && each.target === session && each.copying); + if (!operation) return ""; + if (operation.phase === "failed") return `${OPERATION_TEXT[operation.kind].failed} `; + return operation.session === session ? `${OPERATION_TEXT[operation.kind].running} ` : "copying files… "; + } + + /** `/dismiss-failed`: forgets every failed operation (their lines go from every client's list). */ + dismissFailed(): void { + const catalog = this.catalog; + const failed = this.operations.filter((operation) => operation.phase === "failed"); + if (!catalog || !failed.length) { + this.runtime.notify("No failed operations"); + return; + } + for (const operation of failed) + this.runtime.perform(catalog.dismissOperation({ operation: operation.id }, BACKGROUND_CONTEXT)); + } + + /** The catalog as shown: the server's rows with the changes in flight. */ + private get rows(): Session[] { + return this.shownRows(); + } + + /** The owner's live catalog, which every change goes through. */ + get catalog(): HeldIn | undefined { + return liveIn(this.service, "owner"); + } + + /** + * Changes a row: shown at once, `done` once the server took it (the selector's status, else a status line); a + * refused change rolls back and its error shows as a status line. + */ + edit(guess: RowGuess, call: (catalog: HeldIn) => Promise, done: string): void { + const catalog = this.catalog; + if (!catalog) return; + const settled = this.editing(guess, call(catalog)).then(() => { + if (this.runtime.screen === "sessions") this.setStatus(done); + else this.runtime.notify(done); + }); + this.rebuild(); + this.runtime.perform(settled.finally(() => this.rebuild())); + } + /** The viewed session, which "current folder" means. */ private get current(): Session | undefined { return this.rows.find((row) => row.id === this.runtime.session); @@ -262,7 +353,7 @@ export class SessionsSurface implements TuiSurface { } command(name: string): void { - if (name === "new" && this.owner && statusOf(this.service()) === "live") { + if (name === "new" && this.catalog) { this.mode = "create"; const current = this.current; this.location = new LocationInput(this.ctx, { @@ -298,15 +389,20 @@ export class SessionsSurface implements TuiSurface { } } + /** + * Creates a session and opens it at once: this TUI chooses its ID, and the conversation shows loading until the + * session's worker runs. A refused creation comes back to the selector with the error. + */ private create(location: Location): void { - const service = this.service(); - if (statusOf(service) !== "live" || service.variant !== "owner") return; - this.runtime.perform( - service.create({ cwd: location.path, env: location.env }, BACKGROUND_CONTEXT).then((session) => { - if (this.mode === "create") this.closePrompt(); - this.runtime.navigate("chat", session.id); - }), - ); + const catalog = this.catalog; + if (!catalog) return; + const id = crypto.randomUUID(); + if (this.mode === "create") this.closePrompt(); + this.runtime.navigate("chat", id); + catalog.create({ id, cwd: location.path, env: location.env }, BACKGROUND_CONTEXT).catch((error: unknown) => { + if (this.runtime.session === id) this.runtime.navigate("sessions", undefined); + this.runtime.fail(error); + }); } /** @@ -325,18 +421,20 @@ export class SessionsSurface implements TuiSurface { } private submitPrompt(): void { - const service = this.service(); - if (statusOf(service) !== "live" || service.variant !== "owner") return; const value = this.prompt.getValue().trim(); - if (this.mode === "rename" && this.renaming && value) { - const id = this.renaming.id; - this.runtime.perform( - service.rename({ id, title: value.slice(0, MAX_TITLE) }, BACKGROUND_CONTEXT).then(() => { - this.closePrompt(); - this.setStatus("Session renamed"); - }), - ); - } + if (this.mode !== "rename" || !this.renaming || !value) return; + this.renameSession(this.renaming.id, value); + this.closePrompt(); + } + + /** Renames session `id` (the selector's prompt, `/name`): the new title shows at once. */ + renameSession(id: string, value: string): void { + const title = value.slice(0, MAX_TITLE); + this.edit( + { id, change: { title } }, + (catalog) => catalog.rename({ id, title }, BACKGROUND_CONTEXT), + `Session name: ${lineText(title)}`, + ); } private closePrompt(): void { @@ -380,13 +478,13 @@ export class SessionsSurface implements TuiSurface { } pin(): void { const row = this.selected(); - const service = this.service(); - if (row?.kind !== "session" || statusOf(service) !== "live" || service.variant !== "owner") return; + if (row?.kind !== "session") return; + const { id } = row.session; const pinned = !row.session.pinned; - this.runtime.perform( - service - .pin({ id: row.session.id, pinned }, BACKGROUND_CONTEXT) - .then(() => this.setStatus(pinned ? "Pinned" : "Unpinned")), + this.edit( + { id, change: { pinned } }, + (catalog) => catalog.pin({ id, pinned }, BACKGROUND_CONTEXT), + pinned ? "Pinned" : "Unpinned", ); } /** Delete = archive (pi asks to confirm); in the archived scope it restores. */ @@ -394,13 +492,12 @@ export class SessionsSurface implements TuiSurface { const row = this.selected(); if (!this.owner || row?.kind !== "session") return; if (this.archived) { - const service = this.service(); - if (statusOf(service) === "live" && service.variant === "owner") - this.runtime.perform( - service - .restore({ id: row.session.id }, BACKGROUND_CONTEXT) - .then(() => this.setStatus("Session restored")), - ); + const { id } = row.session; + this.edit( + { id, change: { archivedAt: null } }, + (catalog) => catalog.restore({ id }, BACKGROUND_CONTEXT), + "Session restored", + ); return; } this.confirming = row.session; @@ -438,11 +535,12 @@ export class SessionsSurface implements TuiSurface { return; } if (this.confirming) { - const service = this.service(); - if (keys.matches(data, "tui.select.confirm") && statusOf(service) === "live" && service.variant === "owner") { - const id = this.confirming.id; - this.runtime.perform( - service.archive({ id }, BACKGROUND_CONTEXT).then(() => this.setStatus("Session archived")), + if (keys.matches(data, "tui.select.confirm")) { + const { id } = this.confirming; + this.edit( + { id, change: { archivedAt: Date.now() } }, + (catalog) => catalog.archive({ id }, BACKGROUND_CONTEXT), + "Session archived", ); } this.confirming = undefined; @@ -556,7 +654,7 @@ export class SessionsSurface implements TuiSurface { ? "warning" : undefined; left = color ? theme.fg(color, text) : text; - right = `${this.scopeAll ? `${lineText(where(session))} ` : ""}${this.showIds ? `${session.id} ` : ""}${age(session.updatedAt)}`; + right = `${this.operationText(session.id)}${this.scopeAll ? `${lineText(where(session))} ` : ""}${this.showIds ? `${session.id} ` : ""}${age(session.updatedAt)}`; } const available = Math.max(10, width - 2 - visibleWidth(right) - 2); let styled = truncateToWidth(left, available, "…"); @@ -628,9 +726,9 @@ const openSessions: TuiCommand["run"] = (ctx) => ctx.navigate("sessions"); /** * Moves the viewed session (the web's Relocate, `Sessions.relocate`): the location is resolved and checked as the - * web's DirectoryPicker does (`Envs.listDirectory`, `~` expanded on the session's environment) and relocated to the - * listed path; another environment is confirmed first with the web's note. Errors are the server's text, as on the - * web. Without an argument, a prompt with the environment choice (Tab). + * web's DirectoryPicker does (`Envs.listDirectory`, `~` expanded on the session's environment), then the session row + * shows the listed path at once while the move runs; a refused move (a busy session) rolls back with the server's + * text. Another environment is confirmed first with the web's note. */ async function relocate(ctx: TuiContext, session: Session, location: Location, confirmed = false): Promise { const moving = location.env !== (session.env ?? null); @@ -646,16 +744,15 @@ async function relocate(ctx: TuiContext, session: Session, location: Location, c return; const envs = await ownerEnvs(ctx); if (envs.variant !== "owner") throw new Error("Only the owner changes directories"); - const listing = await envs.listDirectory( + const { path } = await envs.listDirectory( { path: location.path, ...(location.env ? { env: location.env } : {}) }, BACKGROUND_CONTEXT, ); - await ctx.host.root(async (root) => { - const sessions = await root.get(Sessions, {}); - if (sessions.variant !== "owner") throw new Error("Only the owner changes directories"); - await sessions.relocate({ id: session.id, env: location.env, cwd: listing.path }, root); - }, BACKGROUND_CONTEXT); - ctx.notify(`Working directory: ${lineText(locationLabel({ env: location.env, path: listing.path }))}`); + // A move is long work (a job): the row says "moving…" until it is done ("Session moved"), or failed. + const catalog = surfaceOf(ctx)?.catalog; + if (!catalog) return; + ctx.notify(`Moving to ${lineText(locationLabel({ env: location.env, path }))}…`); + await catalog.relocate({ id: session.id, env: location.env, cwd: path }, BACKGROUND_CONTEXT); } /** @@ -695,9 +792,7 @@ async function moveTo(ctx: TuiContext, env: string | null): Promise { ...(error ? { error } : {}), onSubmit: (location) => { overlay.close(); - void relocate(ctx, session, location, true).catch((caught: unknown) => - ctx.notify(caught instanceof Error ? caught.message : String(caught), "error"), - ); + void relocate(ctx, session, location, true).catch((caught: unknown) => ctx.fail(caught)); }, onCancel: () => overlay.close(), }), @@ -733,9 +828,7 @@ async function changeDirectory(ctx: TuiContext, argument: string): Promise moving: true, onSubmit: (location) => { overlay.close(); - void relocate(ctx, session, location, true).catch((error: unknown) => - ctx.notify(error instanceof Error ? error.message : String(error), "error"), - ); + void relocate(ctx, session, location, true).catch((error: unknown) => ctx.fail(error)); }, onCancel: () => overlay.close(), }), @@ -812,22 +905,21 @@ const SESSION_COMMANDS: Record = { category: "Session", when: "chat && owner && !overlay", slash: { name: "name", argumentHint: "" }, - run: async (ctx, args) => { + run: (ctx, args) => { const session = ctx.identity.session; - const title = args?.trim().slice(0, MAX_TITLE); + const title = args?.trim(); if (!session) return; - if (!title) { - ctx.notify("Usage: /name <title>"); - return; - } - await ctx.host.root(async (root) => { - const sessions = await root.get(Sessions, {}); - if (sessions.variant !== "owner") throw new Error("Only the owner renames sessions"); - await sessions.rename({ id: session, title }, root); - }, BACKGROUND_CONTEXT); - ctx.notify(`Session name: ${title}`); + if (title) surfaceOf(ctx)?.renameSession(session, title); + else ctx.notify("Usage: /name <title>"); }, }, + "p2.session.dismissFailed": { + title: "Dismiss failed session operations", + category: "Session", + when: "owner && !overlay", + slash: { name: "dismiss-failed" }, + run: (ctx) => surfaceOf(ctx)?.dismissFailed(), + }, "app.session.toggleSort": selectorCommand("Toggle session sort", (surface) => surface.toggleSort()), "app.session.toggleNamedFilter": selectorCommand("Toggle pinned filter", (surface) => surface.togglePinned()), "app.session.toggleArchived": selectorCommand( diff --git a/apps/p2/plugins/sessions/web.tsx b/apps/p2/plugins/sessions/web.tsx index 3786cd3f1..ba001138d 100644 --- a/apps/p2/plugins/sessions/web.tsx +++ b/apps/p2/plugins/sessions/web.tsx @@ -1,31 +1,41 @@ -// The mobile drawer's component tree, with catalog/status/subagent/search handles supplying its data. +// The mobile drawer's component tree, with catalog/status/subagent/search handles supplying its data, and the owner's +// session actions on its rows. import { Access, BACKGROUND_CONTEXT, contribute, defineFacet, type HeldIn, liveIn, whenLive } from "@p2/app/v1"; import { ChevronIcon, + closeLayers, + conversationRoute, Drawer, + MoreIcon, navigate, openLayer, + rememberMains, + type SessionSummary, Slot, Slots, toggleSidebar, useComposerState, useConnected, useHost, + useNavigation, useNewSessionState, useObject, + useOptimisticCall, + useOwner, useReadable, useRoute, - useStatus, + useSheet, } from "@p2/app/v1/web"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { directoryPickerLayer } from "../envs/contract.web.tsx"; import { Search, SessionReads } from "../search/contract.ts"; import { SessionStatus } from "../status/contract.ts"; import { Subagents } from "../subagents/contract.ts"; import { SessionTree } from "../tree/contract.ts"; import { BranchSheet } from "./BranchSheet.tsx"; -import { Sessions } from "./contract.ts"; -import { SessionOptions } from "./SessionOptions.tsx"; +import { type Operation, type Session, Sessions } from "./contract.ts"; +import { SessionSheet } from "./SessionSheet.tsx"; const subscribeVisibility = (listener: () => void) => { document.addEventListener("visibilitychange", listener); @@ -87,23 +97,80 @@ function FooterMenu() { } const footer = <FooterMenu />; + +/** What a row says about a lifecycle operation (`Sessions.operations`), by kind: running, and failed. */ +const OPERATION_LABELS: Record<Operation["kind"], { running: string; failed: string }> = { + relocate: { running: "Moving…", failed: "Move failed" }, + clone: { running: "Cloning…", failed: "Clone failed" }, + fork: { running: "Forking…", failed: "Fork failed" }, + archive: { running: "Archiving…", failed: "Archive failed" }, +}; + +/** + * The operation row `session` shows: a failed one of it first (with Dismiss), else one running on it, else a copy + * into it whose files are still being copied (its worker waits for them). + */ +function operationOf( + operations: readonly Operation[], + session: string, + dismiss: (operation: string) => void, +): SessionSummary["operation"] { + const failed = operations.find((operation) => operation.phase === "failed" && operation.session === session); + if (failed?.phase === "failed") + return { label: OPERATION_LABELS[failed.kind].failed, error: failed.error, dismiss: () => dismiss(failed.id) }; + const running = operations.find((operation) => operation.session === session); + if (running) return { label: OPERATION_LABELS[running.kind].running }; + const copying = operations.find( + (operation) => operation.phase === "running" && operation.target === session && operation.copying, + ); + return copying ? { label: "Copying files…" } : undefined; +} + +/** A change to one session's row while its call is in flight. */ +type RowGuess = { readonly id: string; readonly change: Partial<Session> }; +const applyRow = (rows: Session[] | undefined, { id, change }: RowGuess): Session[] | undefined => + rows?.map((row) => (row.id === id ? { ...row, ...change } : row)); + +/** + * The drawer, and the owner's session actions on its rows: rename, pin, archive and restore (the Session sheet is + * rendered here, opened from the top bar's button), switching and renaming `/tree` branches. Each action shows its + * effect on the rows at once (`useOptimisticCall`); a refused one rolls back and the toast says why. + */ function Sidebar() { const connected = useConnected(); const route = useRoute(); + const nav = useNavigation(); const service = useObject(Sessions, {}); const catalog = liveIn(service, "owner"); const access = useObject(Access, {}); const guest = useReadable(liveIn(access, "guest")?.grant); - const owner = useStatus(access) === "live" && access?.variant === "owner"; + // As the server last said (kept across starts): unread marks and search hold without waiting for Access. + const owner = useOwner(); const status = liveIn(useObject(SessionStatus, {}), "reader"); const agents = liveIn(useObject(Subagents, {}), "reader"); - const list = useReadable(liveIn(service, "owner", "guest")?.sessions); + const { fail } = useComposerState(); + const [list, edit] = useOptimisticCall(useReadable(liveIn(service, "owner", "guest")?.sessions), applyRow, fail); const statuses = useReadable(status?.statuses); const children = useReadable(agents?.children); + useEffect(() => { + if (list) rememberMains(list); + }, [list]); // Unread answers are the owner's (any device); guests see none and mark nothing. const marks = useObject(SessionReads, owner ? {} : undefined); const answers = useReadable(marks?.answers); const reads = useReadable(marks?.reads); + // Dismissing a failed operation removes its line at once. + const [operations, dismissing] = useOptimisticCall( + useReadable(catalog?.operations) ?? [], + (list, operation: string) => list.filter((each) => each.id !== operation), + fail, + ); + const dismiss = useCallback( + (operation: string) => { + if (catalog) dismissing(operation, () => catalog.dismissOperation({ operation }, BACKGROUND_CONTEXT)); + }, + [catalog, dismissing], + ); const sessions = useMemo( () => (list ?? []).map((session) => { @@ -117,36 +184,52 @@ function Sidebar() { unread: (answers?.[session.id] ?? 0) > (reads?.[session.id] ?? 0), }, subagents: children?.[session.id] ?? [], + operation: operationOf(operations, session.id, dismiss), }; }), - [list, statuses, children, answers, reads], + [list, statuses, children, answers, reads, operations, dismiss], ); // The open session's main conversation, shown on a visible page, is read through its latest answer. const visible = usePageVisible(); - const shownMain = (list ?? []).find((session) => session.id === route.session)?.main; - const shown = visible && route.conversation === shownMain ? route.session : undefined; + const viewed = list?.find((session) => session.id === route.session); + const shown = visible && route.conversation === viewed?.main ? route.session : undefined; const latest = shown ? (answers?.[shown] ?? 0) : 0; const read = shown ? (reads?.[shown] ?? 0) : 0; useEffect(() => { if (shown && marks && latest > read) void marks.markRead({ session: shown, through: latest }, BACKGROUND_CONTEXT).catch(() => {}); }, [marks, shown, latest, read]); - const search = useObject(Search, owner ? {} : undefined); + const search = liveIn(useObject(Search, owner ? {} : undefined), "owner"); + // A rebuild shows as running at once; it is long work (a job), so the sheet shows it running until it is done. + const [rebuilds, reindex] = useOptimisticCall( + useReadable(search?.rebuilds) ?? [], + (list, session: string) => [ + ...list.filter((each) => each.session !== session), + { session, phase: "running" as const }, + ], + fail, + ); const onSearch = useCallback( (query: string, archived: boolean) => search!.search({ query, archived }, BACKGROUND_CONTEXT), [search], ); - const onRestore = useCallback( - (id: string) => - catalog?.restore({ id }, BACKGROUND_CONTEXT) ?? Promise.reject(new Error("Only the owner restores sessions")), - [catalog], - ); // A session opens at its main conversation; `/s/<id>` would open conversation 1, which redirects there. const onOpen = useCallback( (session: string, conversation?: number) => navigate({ session, conversation: conversation ?? list?.find((each) => each.id === session)?.main ?? 1 }), [list], ); + // The restored row is back at once; the session opens once the server has it (an archived one would bounce). + const onRestore = useCallback( + (id: string, conversation?: number) => { + if (!catalog) return; + edit({ id, change: { archivedAt: null } }, async () => { + await catalog.restore({ id }, BACKGROUND_CONTEXT); + onOpen(id, conversation); + }); + }, + [catalog, edit, onOpen], + ); // A branch's switch or rename: a reference of its own (the change also reaches the session row). const host = useHost(); const withTree = useCallback( @@ -162,14 +245,14 @@ function Sidebar() { }, [host], ); - // Errors go to the app's toast in main, as in Pi Mobile (not clipped inside the drawer). - const { setError } = useComposerState(); + // The row marks the chosen branch at once; the view follows once the session's main conversation moved. const onBranch = useCallback( (session: string, conversation: number) => - void withTree(session, (tree) => tree.select({ conversation }, BACKGROUND_CONTEXT)) - .then(() => navigate({ session, conversation })) - .catch((reason: unknown) => setError(reason instanceof Error ? reason.message : String(reason))), - [withTree, setError], + edit({ id: session, change: { main: conversation } }, async () => { + await withTree(session, (tree) => tree.select({ conversation }, BACKGROUND_CONTEXT)); + navigate({ session, conversation }); + }), + [withTree, edit], ); const [renaming, setRenaming] = useState<{ session: string; conversation: number }>(); const onRenameBranch = useCallback( @@ -178,6 +261,19 @@ function Sidebar() { ); const renamed = renaming && list?.find((each) => each.id === renaming.session); const branch = renamed?.branches.find((each) => each.conversation === renaming?.conversation); + const renameBranch = (session: Session, conversation: number, label: string | null) => + edit( + { + id: session.id, + change: { + branches: session.branches.map((each) => + each.conversation === conversation ? { ...each, label } : each, + ), + }, + }, + () => withTree(session.id, (tree) => tree.rename({ conversation, label }, BACKGROUND_CONTEXT)), + ); + const sessionSheet = useSheet("session"); const { setChoice, setLocation } = useNewSessionState(); const onNew = useCallback(() => { setChoice(undefined); @@ -201,14 +297,13 @@ function Sidebar() { conversationId={route.conversation} connected={connected} onOpen={onOpen} - onBranch={owner ? onBranch : undefined} - onRenameBranch={owner ? onRenameBranch : undefined} + onBranch={catalog ? onBranch : undefined} + onRenameBranch={catalog ? onRenameBranch : undefined} onNew={onNew} onNewIn={owner ? onNewIn : undefined} onCollapse={toggleSidebar} onSearch={search ? onSearch : undefined} onRestore={onRestore} - onError={setError} footer={footer} onProviders={() => openLayer({ kind: "sheet", name: "providers" }, { fromDrawer: true })} onEnvs={() => openLayer({ kind: "sheet", name: "envs" }, { fromDrawer: true })} @@ -219,17 +314,62 @@ function Sidebar() { title={renamed.title} conversation={branch.conversation} label={branch.label} - onSave={(label) => - withTree(renamed.id, (tree) => - tree.rename({ conversation: branch.conversation, label }, BACKGROUND_CONTEXT), + onSave={(label) => renameBranch(renamed, branch.conversation, label)} + onClose={() => setRenaming(undefined)} + /> + )} + {sessionSheet && catalog && viewed && route.conversation === viewed.main && ( + <SessionSheet + session={viewed} + onClose={closeLayers} + onRename={(title) => + edit({ id: viewed.id, change: { title } }, () => + catalog.rename({ id: viewed.id, title }, BACKGROUND_CONTEXT), ) } - onClose={() => setRenaming(undefined)} + onPin={() => + edit({ id: viewed.id, change: { pinned: !viewed.pinned } }, () => + catalog.pin({ id: viewed.id, pinned: !viewed.pinned }, BACKGROUND_CONTEXT), + ) + } + onShare={() => openLayer({ kind: "sheet", name: "share" })} + onCwd={() => openLayer(directoryPickerLayer(viewed.env ?? null, viewed.cwd))} + reindex={rebuilds.find((each) => each.session === viewed.id)} + onReindex={ + search && + (() => reindex(viewed.id, () => search.rebuildIndex({ session: viewed.id }, BACKGROUND_CONTEXT))) + } + onArchive={() => { + closeLayers(); + void nav.replace(conversationRoute(route, undefined)); + edit({ id: viewed.id, change: { archivedAt: Date.now() } }, () => + catalog.archive({ id: viewed.id }, BACKGROUND_CONTEXT), + ); + }} /> )} </> ); } + +/** The top bar's button that opens the Session sheet (the drawer renders the sheet, where the rows are). */ +function SessionOptions() { + const route = useRoute(); + const rows = useReadable(useObject(Sessions, {}, { variant: "owner" })?.sessions); + const session = rows?.find((row) => row.id === route.session); + if (!session || route.conversation !== session.main) return null; + return ( + <button + type="button" + className="icon-button" + aria-label="Session options" + onClick={() => openLayer({ kind: "sheet", name: "session" })} + > + <MoreIcon /> + </button> + ); +} + export default defineFacet((host) => { contribute(host, "p2.web-sessions", (self) => { self.contribute(Slots, { sidebar: Sidebar, "session-options": SessionOptions }); diff --git a/apps/p2/plugins/sessions/worker.ts b/apps/p2/plugins/sessions/worker.ts index b8aa6504b..7b8865e39 100644 --- a/apps/p2/plugins/sessions/worker.ts +++ b/apps/p2/plugins/sessions/worker.ts @@ -7,7 +7,6 @@ import { } from "@earendil-works/pi-durable"; import { obj, str } from "@earendil-works/pi-schema"; import { defineFacet, expectVariant, forbidden, local } from "@p2/app/v1"; -import { durableContext } from "@p2/app/v1/node"; import { defineP2Tool, Extensions, @@ -23,8 +22,8 @@ import { MAX_TITLE, Sessions, SessionTransfer } from "./contract.ts"; const SessionTools = local("p2.session-tools", { version: 1, params: {}, members: {} }); /** - * The tool runs outside Chord calls (a Durable tool), so each call starts a fresh short root of the worker's. Only - * top-level conversations (the main conversation and its `/tree` branches) may rename the session. + * The tool runs outside Chord calls (a Durable tool), so each call opens a short root of the worker's. Only top-level + * conversations (the main conversation and its `/tree` branches) may rename the session. */ const renameSession = (root: () => WorkerParts["root"]) => defineExtension<P2Tool>({ @@ -50,9 +49,11 @@ const renameSession = (root: () => WorkerParts["root"]) => const title = args.title.replace(/\s+/g, " ").trim(); if (!title) return reply("The title is empty.", true); try { - // A Durable tool is not a Chord root: each call starts a fresh short one. - const { previous } = await root()(async (ctx) => - expectVariant(await ctx.get(Sessions, {}), "worker").renameSelf({ title }, ctx), + // A Durable tool is not a Chord call: it pushes the title in a short root of the worker's, under its + // own context. + const { previous } = await root()( + async (ctx) => expectVariant(await ctx.get(Sessions, {}), "worker").renameSelf({ title }, ctx), + context, ); return reply( previous === title @@ -77,20 +78,19 @@ export default defineFacet((host) => { variant: "server", members: { history: async ({ conversation, before }, ctx) => { - const context = durableContext(ctx); const durable = harness().durable; // A subagent's history as a session of its own needs its cwd and tools: not yet. - if (!(await isTopLevel(durable, conversation as ConversationId, context))) + if (!(await isTopLevel(durable, conversation as ConversationId, ctx))) throw new Error("Forking a subagent conversation is not supported yet"); - const found = await durable.conversation(conversation as ConversationId, context); + const found = await durable.conversation(conversation as ConversationId, ctx); if (!found) throw new Error(`Unknown conversation ${conversation}`); - const [chosen] = (await found.entries({ maxEntryId: before as EntryId }, 1, undefined, context)).items; + const [chosen] = (await found.entries({ maxEntryId: before as EntryId }, 1, undefined, ctx)).items; if (chosen?.id !== before) throw new Error("Unknown message"); // The fork holds what came before the chosen message, as the context stood at the entry before it. - const [previous] = (await found.entries({ maxEntryId: (before - 1) as EntryId }, 1, undefined, context)) + const [previous] = (await found.entries({ maxEntryId: (before - 1) as EntryId }, 1, undefined, ctx)) .items; if (!previous) return { entries: [] }; - const view = await found.context(context, { at: previous.id }); + const view = await found.context(ctx, { at: previous.id }); // Edits are applied in `contributions` (their targets' IDs change); a head marker starts the copy's context. const entries: EntryDraft[] = view.entries.map((entry, index) => { const model = view.contributions[index] ?? []; @@ -104,15 +104,14 @@ export default defineFacet((host) => { return { entries }; }, seed: async ({ entries }, ctx) => { - const context = durableContext(ctx); const durable = harness().durable; - const root = await durable.conversation(ROOT_CONVERSATION_ID, context); + const root = await durable.conversation(ROOT_CONVERSATION_ID, ctx); if (!root) throw new Error("No main conversation"); - if ((await root.entries({}, 1, undefined, context)).items.length) + if ((await root.entries({}, 1, undefined, ctx)).items.length) throw new Error("The session has history already"); await durable.commit(async (tx) => { for (const entry of entries) await tx.appendEntry(ROOT_CONVERSATION_ID, entry as EntryDraft); - }, context); + }, ctx); return null; }, }, diff --git a/apps/p2/plugins/sharing/contract.ts b/apps/p2/plugins/sharing/contract.ts index f4482d6f5..458bc4b39 100644 --- a/apps/p2/plugins/sharing/contract.ts +++ b/apps/p2/plugins/sharing/contract.ts @@ -82,13 +82,10 @@ export const Shares = local("p2.shares", { /** Guests whose browsers are connected now (server, in memory). */ export const Presence = local("p2.share-presence", { - version: 1, + /** 2: derived from the references to `GuestPresence` (no enter and leave). */ + version: 2, params: {}, - members: { - guests: record(ConnectedSchema), - enter: method(ConnectedSchema), - leave: method(obj({ id: str() })), - }, + members: { guests: record(ConnectedSchema) }, }); /** Held by a guest's browser for its page's lifetime: marks the guest connected. Nothing for the owner. */ diff --git a/apps/p2/plugins/sharing/db.ts b/apps/p2/plugins/sharing/db.ts index d60521220..f8b9ad794 100644 --- a/apps/p2/plugins/sharing/db.ts +++ b/apps/p2/plugins/sharing/db.ts @@ -53,11 +53,21 @@ export type PersonRow = { readonly mode: ShareMode; }; +/** A grant to a person by email that is not removed: the email (normalized) and the session. */ +export type EmailGrant = { readonly email: string; readonly session: string }; + +/** The emails of `grants` on sessions `open` accepts, each once, sorted. */ +export function openEmails(grants: readonly EmailGrant[], open: (session: string) => boolean): string[] { + return [...new Set(grants.filter((grant) => open(grant.session)).map((grant) => grant.email))].sort(); +} + /** Thrown for join failures the guest may see. */ export class JoinError extends Error {} +/** The shares database (`shares.sqlite`); listeners hear of every committed change of links, guests and grants. */ export class ShareDb { readonly #db: DatabaseSync; + readonly #listeners = new Set<() => void>(); constructor(directory: string) { const path = join(directory, "shares.sqlite"); @@ -94,16 +104,24 @@ export class ShareDb { }); } + /** `listener` hears of every committed change from now on; the result stops it. */ + subscribe(listener: () => void): () => void { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } + #transaction<T>(fn: () => T): T { this.#db.exec("BEGIN"); + let result: T; try { - const result = fn(); + result = fn(); this.#db.exec("COMMIT"); - return result; } catch (error) { this.#db.exec("ROLLBACK"); throw error; } + for (const listener of this.#listeners) listener(); + return result; } links(): ShareLink[] { @@ -279,16 +297,19 @@ export class ShareDb { })); } - /** Every email with a grant that is not removed, on a session `open` accepts; sorted. */ - emails(open: (session: string) => boolean): string[] { - const emails = new Set<string>(); - for (const row of this.#db + /** Every grant to a person by email that is not removed. */ + emailGrants(): EmailGrant[] { + return this.#db .prepare( "SELECT guests.email,grants.session FROM grants JOIN guests ON guests.id=grants.guest WHERE grants.removed=0 AND guests.email IS NOT NULL", ) - .all()) - if (open(String(row.session))) emails.add(String(row.email)); - return [...emails].sort(); + .all() + .map((row) => ({ email: String(row.email), session: String(row.session) })); + } + + /** Every email with a grant that is not removed, on a session `open` accepts; sorted. */ + emails(open: (session: string) => boolean): string[] { + return openEmails(this.emailGrants(), open); } seen(id: string): void { diff --git a/apps/p2/plugins/sharing/server.ts b/apps/p2/plugins/sharing/server.ts index 18fd1db6e..508e12cd4 100644 --- a/apps/p2/plugins/sharing/server.ts +++ b/apps/p2/plugins/sharing/server.ts @@ -7,15 +7,14 @@ import { type Ctx, contribute, defineFacet, - defineState, defineTypedState, deriveTyped, + followNative, forbidden, type GuestPrincipal, isGuest, isOwner, type P2Principal, - record, } from "@p2/app/v1"; import { Authenticators, @@ -30,22 +29,11 @@ import { SessionAccess, ShareUrls, sendJson, - serverOf, } from "@p2/app/v1/server"; import { Catalog } from "../sessions/contract.ts"; import { ChildStore, remoteEnvironments } from "../subagents/contract.ts"; -import { - ConnectedSchema, - GuestPresence, - LinkSchema, - PersonSchema, - Presence, - type ShareLink, - type ShareMode, - Shares, - Sharing, -} from "./contract.ts"; -import { type GuestRow, JoinError, ShareDb } from "./db.ts"; +import { GuestPresence, Presence, type ShareLink, type ShareMode, Shares, Sharing } from "./contract.ts"; +import { type GuestRow, JoinError, openEmails, ShareDb } from "./db.ts"; /** Registers the join routes, the guest cookie and Radius authenticators, and guest session access. */ @@ -102,24 +90,11 @@ async function readJson(request: IncomingMessage, limit: number): Promise<unknow return JSON.parse(Buffer.concat(chunks).toString("utf8")); } -/** The database's links, emails and people as `Shares` shows them, and the archived sessions as last seen. */ -const Shown = defineTypedState("p2.shares", { - schema: obj({ - /** Null before the catalog was first seen. */ - archived: union([arr(str()), nul()]), - links: arr(LinkSchema), - emails: arr(str()), - people: arr(PersonSchema), - }), +/** The archived sessions as the routine below last saw them (null before it first saw the catalog). */ +const Archived = defineTypedState("p2.shares.archived", { + schema: obj({ sessions: union([arr(str()), nul()]) }), version: 1, - initial: () => ({ archived: null, links: [], emails: [], people: [] }), -}); - -/** The connected guests (in memory). */ -const Connected = defineState("p2.presence", { - schema: obj({ guests: record(ConnectedSchema) }), - version: 1, - initial: () => ({ guests: {} }), + initial: () => ({ sessions: null }), }); export default defineFacet((host) => { @@ -127,26 +102,22 @@ export default defineFacet((host) => { const catalog = self.get(Catalog, {}); const children = self.get(ChildStore, {}); const server = self.get(Server, {}); - const shown = self.state(Shown); - const unarchived = (session: string): boolean => !shown.value.archived?.includes(session); - const reload = (ctx: Ctx) => - shown.change((draft) => { - draft.links = dbResource.value.links(); - draft.emails = dbResource.value.emails(unarchived); - draft.people = dbResource.value.people(); - }, ctx); + const archived = self.state(Archived); const dbResource = self.open( - async (ctx) => { - const db = new ShareDb(server.info.value.dataDir); - await shown.change((draft) => { - draft.links = db.links(); - draft.emails = db.emails(unarchived); - draft.people = db.people(); - }, ctx); - return db; - }, + async () => new ShareDb(server.info.value.dataDir), (db) => db.close(), ); + // The database's links, people and email grants as it holds them: a method's write tells this source before the + // method returns. + const stored = followNative( + self, + (changed) => dbResource.value.subscribe(changed), + () => ({ + links: dbResource.value.links(), + people: dbResource.value.people(), + emails: dbResource.value.emailGrants(), + }), + ); /** The session is known and not archived. */ const open = async (session: string, ctx: Ctx): Promise<boolean> => { const found = await catalog.get({ id: session }, ctx).catch(() => undefined); @@ -188,24 +159,24 @@ export default defineFacet((host) => { }; // Archive ends every guest's access to the session, restore gives it back: both change their grants. self.on(catalog.sessions, async ({ value: sessions }, ctx) => { - const archived = sessions.filter((session) => session.archivedAt !== null).map((session) => session.id); - const previous = shown.value.archived; - await shown.change((draft) => { - draft.archived = archived; - }, ctx); - await shown.change((draft) => { - draft.emails = dbResource.value.emails(unarchived); + const now = sessions.filter((session) => session.archivedAt !== null).map((session) => session.id); + const previous = archived.value.sessions; + await archived.change((draft) => { + draft.sessions = now; }, ctx); if (previous === null) return; const before = new Set(previous); - const after = new Set(archived); - const changed = [...archived.filter((id) => !before.has(id)), ...previous.filter((id) => !after.has(id))]; + const after = new Set(now); + const changed = [...now.filter((id) => !before.has(id)), ...previous.filter((id) => !after.has(id))]; for (const session of changed) await revoke(dbResource.value.bump(session), ctx); }); return { - links: deriveTyped(self, [shown], ([value]) => value.links), - emails: self.derive([shown], ([value]) => value.emails), - people: deriveTyped(self, [shown], ([value]) => value.people), + links: deriveTyped(self, [stored], ([value]) => value.links), + emails: self.derive([stored, catalog.sessions], ([{ emails }, sessions]) => { + const archivedNow = new Set(sessions.filter((entry) => entry.archivedAt !== null).map((entry) => entry.id)); + return openEmails(emails, (session) => !archivedNow.has(session)); + }), + people: deriveTyped(self, [stored], ([value]) => value.people), authenticate: async ({ cookie }, ctx) => { const principal = await principalOf(dbResource.value.guestOfCookie(cookie), ctx); if (principal) dbResource.value.seen(principal.id); @@ -223,9 +194,7 @@ export default defineFacet((host) => { }, link: async ({ session, mode, reset, remoteConfirmed }, ctx) => { await confirmRemote(session, mode, remoteConfirmed, ctx); - const ended = dbResource.value.link(session, mode, reset); - await reload(ctx); - await revoke(ended, ctx); + await revoke(dbResource.value.link(session, mode, reset), ctx); return null; }, share: async ({ session, email, mode, remoteConfirmed }, ctx) => { @@ -233,13 +202,11 @@ export default defineFacet((host) => { if (!normalized) throw new Error("Enter an email address"); await confirmRemote(session, mode, remoteConfirmed, ctx); const shared = dbResource.value.share(session, normalized, email.trim(), mode); - await reload(ctx); if (shared.changed) await revoke([shared.guest], ctx); return null; }, remove: async ({ session, guestId }, ctx) => { if (dbResource.value.remove(session, guestId)) await revoke([guestId], ctx); - await reload(ctx); return null; }, join: async ({ token, name, cookie }, ctx) => { @@ -255,38 +222,21 @@ export default defineFacet((host) => { }; }); - host.serve(Presence, (_params, self) => { - const connected = self.state(Connected); - return { - guests: self.derive([connected], ([value]) => value.guests), - enter: async (guest, ctx) => { - await connected.change((draft) => { - draft.guests[guest.id] = guest; - }, ctx); - return null; - }, - leave: async ({ id }, ctx) => { - await connected.change((draft) => { - delete draft.guests[id]; - }, ctx); - return null; - }, - }; - }); + // The guests connected now: every guest principal holding a `GuestPresence` (one per tab; a link drop is a leaving). + const holders = host.references(GuestPresence); + host.serve(Presence, (_params, self) => ({ + guests: self.derive([holders], ([references]) => { + const guests: Record<string, { id: string; name: string; sessions: Record<string, ShareMode> }> = {}; + for (const { principal } of references) + if (isGuest(principal)) + guests[principal.id] = { id: principal.id, name: principal.name, sessions: principal.sessions }; + return guests; + }), + })); - host.serve(GuestPresence, (_params, principal, self) => { + host.serve(GuestPresence, (_params, principal) => { if (isOwner(principal)) return { variant: "owner", members: {} }; if (!isGuest(principal)) throw forbidden("owners and guests only"); - const presence = self.get(Presence, {}); - const server = serverOf(self); - const { id, name, sessions } = principal; - // One object per guest revision: live while any of the guest's tabs holds it. - self.open( - (ctx) => presence.enter({ id, name, sessions }, ctx), - async () => { - await server().root((ctx) => presence.leave({ id }, ctx)); - }, - ); return { variant: "guest", members: {} }; }); diff --git a/apps/p2/plugins/sharing/tui.ts b/apps/p2/plugins/sharing/tui.ts index a984a5427..b751f168f 100644 --- a/apps/p2/plugins/sharing/tui.ts +++ b/apps/p2/plugins/sharing/tui.ts @@ -79,15 +79,8 @@ function openShare(ctx: TuiContext): void { ) return undefined; await service.link({ mode, ...(remote ? { remoteConfirmed: true } : {}) }, BACKGROUND_CONTEXT); - // The new link arrives as state. - return new Promise<string | undefined>((resolve) => { - const stop = follow(service.links, (value) => { - const found = value?.find((link) => link.mode === mode)?.url; - if (found === undefined) return; - queueMicrotask(() => stop()); - resolve(found); - }); - }); + // The answer follows its state: the new link is in `links` when the call resolves. + return service.links.value.find((link) => link.mode === mode)?.url; }; const copy = async (mode: ShareMode) => { status = ""; diff --git a/apps/p2/plugins/sharing/web.tsx b/apps/p2/plugins/sharing/web.tsx index 78ea1da99..b9952ab17 100644 --- a/apps/p2/plugins/sharing/web.tsx +++ b/apps/p2/plugins/sharing/web.tsx @@ -1,9 +1,11 @@ // Sharing in the browser: the share sheet (owner), and the guest's presence while its page is open. -import { BACKGROUND_CONTEXT, contribute, defineFacet, follow } from "@p2/app/v1"; +import { BACKGROUND_CONTEXT, contribute, defineFacet } from "@p2/app/v1"; import { closeLayers, copyText, + errorText, + readValue, Sheet, Slots, useObject, @@ -17,8 +19,6 @@ import { GuestPresence, type ShareMode, Sharing } from "./contract.ts"; const LABEL: Record<ShareMode, string> = { view: "Read-only", full: "Read/write" }; -const errorText = (reason: unknown): string => (reason instanceof Error ? reason.message : String(reason)); - /** * Two links per session (read-only; read/write: send and Stop), the people it is shared with by email, and the guests * connected now. People are offered only while the brain is connected to Radius (`peopleUrl`): they open the session @@ -54,15 +54,8 @@ function ShareSheet({ session, onClose }: { session: string; onClose: () => void } if (url === undefined) { await sharing.link({ mode, ...(remoteConfirmed ? { remoteConfirmed } : {}) }, BACKGROUND_CONTEXT); - // The new link arrives as state; wait for it (the sheet is subscribed). - url = await new Promise<string | undefined>((resolve) => { - const stop = follow(sharing.links, (value) => { - const found = value?.find((link) => link.mode === mode)?.url; - if (found === undefined) return; - queueMicrotask(() => stop()); - resolve(found); - }); - }); + // The answer follows its state: the new link is in `links` when the call resolves. + url = readValue(sharing.links)?.find((link) => link.mode === mode)?.url; } if (url !== undefined && (await copyText(url))) setCopied(mode); } catch (reason) { diff --git a/apps/p2/plugins/status/contract.ts b/apps/p2/plugins/status/contract.ts index 20d2bf15b..b23e22021 100644 --- a/apps/p2/plugins/status/contract.ts +++ b/apps/p2/plugins/status/contract.ts @@ -15,12 +15,13 @@ export const StatusSchema = obj({ export type Status = Static<typeof StatusSchema>; export const StatusesSchema = record(StatusSchema); -const report = { +/** What a worker reports of its work: its live tasks, its busy conversations and its spend. */ +export const ReportFields = { tasks: num(), busy: arr(num()), usage: opt(unsafe<Usage>(unknown())), }; -const ReportSchema = obj(report); +const ReportSchema = obj(ReportFields); /** Live status of every session (server, in memory). Reports are fenced by worker generation. */ export const StatusStore = local("p2.status-store", { @@ -28,7 +29,7 @@ export const StatusStore = local("p2.status-store", { params: {}, members: { statuses: StatusesSchema, - report: method(obj({ ...report, session: str(), generation: num() })), + report: method(obj({ ...ReportFields, session: str(), generation: num() })), }, }); @@ -43,7 +44,21 @@ export const SessionStatus = remote("p2.session-status", { }, }); -/** Owner-only retry of a failed session worker; reading status never starts it. */ +/** + * The session worker's status push (worker, local): reports the worker's work now (its tasks, busy conversations and + * spend) to the server and returns once the server committed it. A method that starts or stops work (`Chat.send`, + * `Chat.stop`) awaits it, so the sidebar's working dot is current when the method answers. + */ +export const StatusReport = local("p2.status-report", { + version: 1, + params: {}, + members: { push: method() }, +}); + +/** + * Owner-only retry of a failed session worker; reading status never starts it. `restart` answers once the new worker + * runs, or fails with why it could not start (`worker_failed`). + */ export const WorkerControl = remote("p2.worker-control", { version: 1, params: { session: param.str() }, @@ -51,16 +66,10 @@ export const WorkerControl = remote("p2.worker-control", { variants: { owner: { methods: { restart: method() } } }, }); -/** Browser pages hold one unique lease for their active session, including child views. */ -export const Viewers = local("p2.session-viewers", { - version: 1, - params: {}, - members: { - counts: record(num()), - enter: method(obj({ session: str(), viewer: str() })), - leave: method(obj({ session: str(), viewer: str() })), - }, -}); +/** + * What a page shows about its session: how many viewers (distinct `viewer` params holding it: a page holds one for its + * active session, child views included) and the session's spend. + */ export const SessionMetrics = remote("p2.session-metrics", { version: 1, params: { session: param.str(), viewer: param.str({ min: 1, max: 80 }) }, diff --git a/apps/p2/plugins/status/server.ts b/apps/p2/plugins/status/server.ts index e9f538e52..08fedc1ad 100644 --- a/apps/p2/plugins/status/server.ts +++ b/apps/p2/plugins/status/server.ts @@ -2,68 +2,58 @@ import { num, obj } from "@earendil-works/pi-schema"; import { canView, defineFacet, - defineState, defineTypedState, deriveTyped, forbidden, isGuest, isOwner, isWorker, + PluginError, record, } from "@p2/app/v1"; import { Hosts, serverOf } from "@p2/app/v1/server"; -import { - SessionMetrics, - SessionStatus, - type Status, - StatusesSchema, - StatusStore, - Viewers, - WorkerControl, -} from "./contract.ts"; +import { ReportFields, SessionMetrics, SessionStatus, type Status, StatusStore, WorkerControl } from "./contract.ts"; -const Statuses = defineTypedState("p2.status-store", { - schema: obj({ statuses: StatusesSchema }), +/** The latest report of each session's worker, with the generation that sent it. */ +const Reports = defineTypedState("p2.status-reports", { + schema: obj({ reports: record(obj({ generation: num(), ...ReportFields })) }), version: 1, - initial: () => ({ statuses: {} }), -}); - -const Counts = defineState("p2.session-viewers", { - schema: obj({ counts: record(num()) }), - version: 1, - initial: () => ({ counts: {} }), + initial: () => ({ reports: {} }), }); export default defineFacet((host) => { + // Who looks at a session: one entry per `SessionMetrics` reference (its params name the session and the viewer). + const metrics = host.references(SessionMetrics); + host.serve(StatusStore, (_params, self) => { - const store = self.state(Statuses); + const reports = self.state(Reports); const hosts = self.get(Hosts, {}); - // Whether a worker runs comes from the workers component; what it does, from the worker's reports. - self.on(hosts.workers, async ({ value: workers }, ctx) => { - await store.change((draft) => { - for (const [session, worker] of Object.entries(workers)) { - const current = draft.statuses[session]; - if (!worker.running) { - draft.statuses[session] = { - running: false, - tasks: 0, - busy: [], - ...(worker.error ? { error: worker.error } : {}), - }; - } else if (current?.running !== true || current.error) - draft.statuses[session] = { running: true, tasks: 0, busy: [] }; - } - }, ctx); - }); + // Whether a worker runs comes from the workers component, what it does from its own generation's latest report. + const statuses = deriveTyped(self, [hosts.workers, reports], ([workers, { reports: latest }]) => + Object.fromEntries( + Object.entries(workers).map(([session, worker]): [string, Status] => { + if (!worker.running) + return [ + session, + { running: false, tasks: 0, busy: [], ...(worker.error ? { error: worker.error } : {}) }, + ]; + const report = latest[session]; + if (report === undefined || report.generation !== worker.generation) + return [session, { running: true, tasks: 0, busy: [] }]; + const { generation: _generation, ...work } = report; + return [session, { running: true, ...work }]; + }), + ), + ); return { - statuses: deriveTyped(self, [store], ([value]) => value.statuses as Record<string, Status>), + statuses, report: async ({ session, generation, tasks, busy, usage }, ctx) => { // A report of an older worker (one that exited while its report travelled) is dropped. const worker = hosts.workers.value[session]; if (worker === undefined || !worker.running || worker.generation !== generation) return null; - await store.change((draft) => { - draft.statuses[session] = { - running: true, + await reports.change((draft) => { + draft.reports[session] = { + generation, tasks, busy: [...busy].sort((a, b) => a - b), ...(usage ? { usage } : {}), @@ -80,53 +70,26 @@ export default defineFacet((host) => { return { variant: "owner", members: { - restart: async () => { - await server().workers.retry(session); + // Answers with the start's outcome: the worker runs, or the call fails with why it could not start. + restart: async (ctx) => { + const outcome = await server().workers.retry(session, ctx); + if (outcome.error !== undefined) throw new PluginError("worker_failed", outcome.error); return null; }, }, }; }); - host.serve(Viewers, (_params, self) => { - const viewers = new Map<string, Set<string>>(); - const counts = self.state(Counts); - return { - counts: self.derive([counts], ([value]) => value.counts), - enter: async ({ session, viewer }, ctx) => { - const list = viewers.get(session) ?? new Set<string>(); - list.add(viewer); - viewers.set(session, list); - await counts.change((draft) => { - draft.counts[session] = list.size; - }, ctx); - return null; - }, - leave: async ({ session, viewer }, ctx) => { - const list = viewers.get(session); - list?.delete(viewer); - await counts.change((draft) => { - draft.counts[session] = list?.size ?? 0; - }, ctx); - return null; - }, - }; - }); - host.serve(SessionMetrics, ({ session, viewer }, principal, self) => { + host.serve(SessionMetrics, ({ session }, principal, self) => { if (!canView(principal, session)) throw forbidden("viewers only"); - const counts = self.get(Viewers, {}); const status = self.get(StatusStore, {}); - const server = serverOf(self); - const clients = self.derive([counts.counts], ([value]) => value[session] ?? 0); - const usage = deriveTyped(self, [status.statuses], ([value]) => value[session]?.usage ?? null); - self.open( - async () => server().root((ctx) => counts.enter({ session, viewer }, ctx)), - async () => { - await server() - .root((ctx) => counts.leave({ session, viewer }, ctx)) - .catch(() => {}); - }, + const clients = self.derive( + [metrics], + ([references]) => + new Set(references.filter((each) => each.params.session === session).map((each) => each.params.viewer)) + .size, ); + const usage = deriveTyped(self, [status.statuses], ([value]) => value[session]?.usage ?? null); return { variant: "reader", members: { clients, usage } }; }); diff --git a/apps/p2/plugins/status/tui.ts b/apps/p2/plugins/status/tui.ts index fbaa0bf4d..0f257f5c0 100644 --- a/apps/p2/plugins/status/tui.ts +++ b/apps/p2/plugins/status/tui.ts @@ -268,14 +268,16 @@ export default tuiFacet("footer", (runtime) => new FooterSurface(runtime), { category: "Owner", when: "owner && !overlay", slash: { name: "retry-worker" }, + // Long work: the pending line shows at once, the outcome when the worker started (or failed again). run: (ctx) => { const session = ctx.identity.session; if (!session) return; + ctx.notify("Retrying the session worker…"); return ctx.host.root(async (root) => { const control = await root.get(WorkerControl, { session }); if (control.variant !== "owner") return; await control.restart(root); - ctx.notify("Retrying the session worker"); + ctx.notify("Session worker started"); }, BACKGROUND_CONTEXT); }, }, diff --git a/apps/p2/plugins/status/worker.ts b/apps/p2/plugins/status/worker.ts index 908eeb019..f96bce712 100644 --- a/apps/p2/plugins/status/worker.ts +++ b/apps/p2/plugins/status/worker.ts @@ -1,10 +1,10 @@ +// The session worker's part of status: its Durable task graph, followed, and pushed to the server's index of statuses +// (the sidebar's working dot). The worker pushes; the server never asks. import type { Usage } from "@earendil-works/pi-ai"; import type { TaskGraph } from "@earendil-works/pi-durable"; -import { contribute, defineFacet, expectVariant } from "@p2/app/v1"; +import { type Ctx, defineFacet, expectVariant } from "@p2/app/v1"; import { harnessOf, workerOf } from "@p2/app/v1/worker"; -import { SessionStatus } from "./contract.ts"; - -/** Reports the worker's Durable task graph to the server (status) and to the host manager (liveness). */ +import { SessionStatus, StatusReport } from "./contract.ts"; /** Live tasks, and conversations with foreground work: a background reporter does not make a conversation busy. */ function summarize(graph: TaskGraph): { tasks: number; busy: number[] } { @@ -13,34 +13,53 @@ function summarize(graph: TaskGraph): { tasks: number; busy: number[] } { return { tasks: nodes.length, busy: busy.sort((a, b) => a - b) }; } +/** The session's spend: every model and tool bucket summed, or undefined before the first one. */ +function totalUsage(totals: { models: Record<string, Usage>; tools: Record<string, Usage> }): Usage | undefined { + const buckets = [...Object.values(totals.models), ...Object.values(totals.tools)]; + if (!buckets.length) return undefined; + const usage: Usage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; + for (const bucket of buckets) { + for (const key of ["input", "output", "cacheRead", "cacheWrite", "totalTokens"] as const) + usage[key] += bucket[key]; + for (const key of ["input", "output", "cacheRead", "cacheWrite", "total"] as const) + usage.cost[key] += bucket.cost[key]; + } + return usage; +} + export default defineFacet((host) => { - contribute(host, "p2.status-reporter", (self) => { + host.serve(StatusReport, (_params, self) => { const harness = harnessOf(self); const worker = workerOf(self); const status = self.get(SessionStatus, {}); const graph = self.follow((observer, ctx) => harness().durable.observeTaskGraph(observer, ctx)); - self.on(graph, async ({ value }, ctx) => { - worker().liveness.setDurableTasks(summarize(value as TaskGraph).tasks); - const totals = await harness().durable.usage(ctx); - const usage: Usage = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }; - const buckets = [...Object.values(totals.models), ...Object.values(totals.tools)]; - for (const bucket of buckets) { - for (const key of ["input", "output", "cacheRead", "cacheWrite", "totalTokens"] as const) - usage[key] += bucket[key]; - for (const key of ["input", "output", "cacheRead", "cacheWrite", "total"] as const) - usage.cost[key] += bucket.cost[key]; - } - await expectVariant(status, "reporter").report( - { ...summarize(value as TaskGraph), ...(buckets.length ? { usage } : {}) }, - ctx, - ); - }); + /** The push running now: pushes run one after another, each reading the graph when it starts. */ + let last: Promise<unknown> = Promise.resolve(); + const report = async (ctx: Ctx): Promise<void> => { + const summary = summarize(graph.value as TaskGraph); + worker().liveness.setDurableTasks(summary.tasks); + const usage = totalUsage(await harness().durable.usage(ctx)); + await expectVariant(status, "reporter").report({ ...summary, ...(usage ? { usage } : {}) }, ctx); + }; + const push = (ctx: Ctx): Promise<void> => { + const run = last.then(() => report(ctx)); + last = run.catch(() => {}); + return run; + }; + // Work the agent starts on its own (a turn continuing, a subagent's report) is pushed as the graph changes. + self.on(graph, async (_call, ctx) => push(ctx)); + return { + push: async (ctx) => { + await push(ctx); + return null; + }, + }; }); }); diff --git a/apps/p2/plugins/subagents/tui.ts b/apps/p2/plugins/subagents/tui.ts index 5b9f97d7b..7779ee9bb 100644 --- a/apps/p2/plugins/subagents/tui.ts +++ b/apps/p2/plugins/subagents/tui.ts @@ -3,7 +3,7 @@ // session selector's subagent conversations (threaded sort, p2-tui-port.md section 1.17). import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui"; -import { BACKGROUND_CONTEXT, contribute, defineFacet, follow, locationText, statusOf, whenLive } from "@p2/app/v1"; +import { BACKGROUND_CONTEXT, contribute, defineFacet, liveIn, locationText } from "@p2/app/v1"; import type { TuiContext } from "@p2/app/v1/tui"; import { getTextOutput, @@ -111,27 +111,24 @@ function openAgents(ctx: TuiContext): void { ctx.notify("Open a session first"); return; } - const ref = ctx.host.hold(Subagents, {}, BACKGROUND_CONTEXT); - let children: Child[] = []; - let showArchived = false; - let closed = false; - let stop = (): void => {}; - // The children are the reader variant's: subscribed once the reference knows its variant. - void whenLive(ref) - .then(() => { - if (closed || ref.variant !== "reader") return; - stop = follow(ref.children, (value) => { - children = value[session] ?? []; - ctx.requestRender(); + const scope = ctx.scope(); + let received: Child[] = []; + scope.watch(Subagents, {}, (ref, follow) => { + if (ref.variant === "reader") + follow(ref.children, (value) => { + received = value[session] ?? []; }); - }) - .catch(() => {}); - const control = ctx.host.hold(SubagentControl, { session }, BACKGROUND_CONTEXT); + }); + const control = scope.watch(SubagentControl, { session }, () => {}); + // Archive and restore show at once; a refused one flips back with the error. + const [shown, archiving] = scope.optimistic( + () => received, + (children, { conversation, archived }: { conversation: number; archived: boolean }) => + children.map((child) => (child.conversationId === conversation ? { ...child, archived } : child)), + ); + let showArchived = false; const close = () => { - closed = true; - stop(); - ref.release(); - control.release(); + scope.dispose(); overlay.close(); }; const rows = (): ListRow[] => [ @@ -140,7 +137,7 @@ function openAgents(ctx: TuiContext): void { label: "main", detail: ctx.identity.conversation === ctx.identity.main ? "✓ shown" : `conversation ${ctx.identity.main}`, }, - ...children + ...shown() .filter((child) => showArchived || !child.archived) .map((child) => ({ key: String(child.conversationId), @@ -172,18 +169,19 @@ function openAgents(ctx: TuiContext): void { id: "app.session.delete", label: "archive/restore", run: (row) => { - const child = children.find((candidate) => String(candidate.conversationId) === row?.key); + const child = shown().find((candidate) => String(candidate.conversationId) === row?.key); if (!child || child.kind === "agent") return; - if (statusOf(control) !== "live" || control.variant !== "participant") { + const participant = liveIn(control, "participant"); + if (!participant) { ctx.notify("Only participants archive subagents", "warning"); return; } - void control - .archive({ conversation: child.conversationId, archived: !child.archived }, BACKGROUND_CONTEXT) - .then(() => ctx.notify(`${child.archived ? "Restored" : "Archived"} ${child.name}`)) - .catch((error: unknown) => - ctx.notify(error instanceof Error ? error.message : String(error), "error"), - ); + const change = { conversation: child.conversationId, archived: !child.archived }; + ctx.perform( + archiving(change, participant.archive(change, BACKGROUND_CONTEXT)).then(() => + ctx.notify(`${change.archived ? "Archived" : "Restored"} ${child.name}`), + ), + ); }, }, ], diff --git a/apps/p2/plugins/subagents/worker.ts b/apps/p2/plugins/subagents/worker.ts index 2aff52c97..5bd14f5bf 100644 --- a/apps/p2/plugins/subagents/worker.ts +++ b/apps/p2/plugins/subagents/worker.ts @@ -1,8 +1,17 @@ import { setTimeout as sleep } from "node:timers/promises"; import type { Models } from "@earendil-works/pi-ai/models"; -import { type ConversationId, ROOT_CONVERSATION_ID } from "@earendil-works/pi-durable"; -import { canSend, contribute, defineFacet, expectVariant, forbidden, signalOf } from "@p2/app/v1"; -import { durableContext, ExtensionSwitches } from "@p2/app/v1/node"; +import { type ConversationId, type Harness, ROOT_CONVERSATION_ID } from "@earendil-works/pi-durable"; +import { + type Ctx, + canSend, + contribute, + defineFacet, + defineRegistry, + expectVariant, + forbidden, + signalOf, +} from "@p2/app/v1"; +import { ExtensionSwitches } from "@p2/app/v1/node"; import { CommitSignal, ConversationConfigured, @@ -32,7 +41,58 @@ import { const READ_ONLY = "One-shot agents are read-only"; +/** Pushes the session's child list to the server, one push at a time, each reading the registry when it starts. */ +class ChildReporter { + readonly #report: (ctx: Ctx) => Promise<void>; + #last: Promise<unknown> = Promise.resolve(); + constructor(report: (ctx: Ctx) => Promise<void>) { + this.#report = report; + } + /** Resolves once the server committed a report read after every change made before this call. */ + push(ctx: Ctx): Promise<void> { + const run = this.#last.then(() => this.#report(ctx)); + this.#last = run.catch(() => {}); + return run; + } +} + +/** The worker's child reporter, for methods that push what they changed (contributed by the tools' object). */ +const ChildReports = defineRegistry<ChildReporter, ChildReporter | undefined>("p2.subagents.reports", { version: 1 }); + +/** The session's subagents and one-shot agents as the server's index lists them. */ +async function childrenOf(durable: Harness, ctx: Ctx): Promise<Child[]> { + const registry = await durable.snapshot(Registry, ROOT_CONVERSATION_ID, ctx); + const children: Child[] = []; + for (const [name, agent] of Object.entries(registry?.agents ?? {})) { + const child = await durable.conversation(agent.conversationId, ctx); + const latest = await child?.entries({}, 1, undefined, ctx); + children.push({ + name, + conversationId: agent.conversationId, + model: agent.model, + thinkingLevel: agent.thinkingLevel, + tools: [...agent.tools], + ...(agent.location === undefined ? {} : { location: agent.location }), + lastEntryId: latest?.items[0]?.id ?? 0, + ...(agent.archived ? { archived: true } : {}), + }); + } + for (const [callId, agent] of Object.entries(registry?.oneShots ?? {})) + children.push({ + name: agent.label, + kind: "agent", + callId, + conversationId: agent.conversationId, + model: agent.model, + thinkingLevel: agent.thinkingLevel, + tools: [...agent.tools], + lastEntryId: 0, + }); + return children; +} + export default defineFacet((host, self) => { + host.serve(ChildReports, { compose: (table) => table[0]?.value }); // Subagents spawned without a cwd and one-shot agents follow the session: the opening Harness gets this (without a // Harness dependency) and moves them with it. Only a subagent's own registry `location` keeps it elsewhere. self.contribute(ConversationLocations, { @@ -81,13 +141,12 @@ export default defineFacet((host, self) => { .sort(), ); self.on(scriptTools, async ({ value }, ctx) => { - await enableScriptTools(harness().durable, value, durableContext(ctx)); + await enableScriptTools(harness().durable, value, ctx); }); self.contribute(ConversationConfigured, { subagents: async (conversation, ctx) => { if (conversation.id === ROOT_CONVERSATION_ID) return; - const context = durableContext(ctx); - const agent = await conversation.agent(context); + const agent = await conversation.agent(ctx); await harness().durable.commit(async (tx) => { const registry = await tx.doc(Registry, ROOT_CONVERSATION_ID); const child = Object.values(registry.agents).find((entry) => entry.conversationId === conversation.id); @@ -95,23 +154,23 @@ export default defineFacet((host, self) => { child.model = agent.model ? `${agent.model.provider}/${agent.model.modelId}` : child.model; child.thinkingLevel = agent.thinkingLevel; child.tools = choosableTools(agent, access()).map((tool) => tool.name); - }, context); + }, ctx); }, }); // A one-shot agent belongs to the script waiting for its answer: it can be read and stopped, not steered. self.contribute(ReadOnlyConversations, { subagents: async (conversation, ctx) => { - const registry = await harness().durable.snapshot(Registry, ROOT_CONVERSATION_ID, durableContext(ctx)); + const registry = await harness().durable.snapshot(Registry, ROOT_CONVERSATION_ID, ctx); return Object.values(registry?.oneShots ?? {}).some((agent) => agent.conversationId === conversation) ? READ_ONLY : undefined; }, }); + // Only the sidebar orders by activity, and it lists no one-shot agents: their entries do not report. + const oneShots = new Set<number>(); const commits = self.open( - async () => { - // Only the sidebar orders by activity, and it lists no one-shot agents: their entries do not report. - const oneShots = new Set<number>(); - const signal = new CommitSignal(harness().durable, (publication) => + async () => + new CommitSignal(harness().durable, (publication) => publication.changes.some( (change) => (change.type === "document" && change.record.kind === Registry.definition.kind) || @@ -119,49 +178,24 @@ export default defineFacet((host, self) => { change.value.conversationId !== ROOT_CONVERSATION_ID && !oneShots.has(change.value.conversationId)), ), - ); - return { signal, oneShots }; - }, - ({ signal }) => signal.close(), + ), + (signal) => signal.close(), ); + const reporter = new ChildReporter(async (ctx) => { + const children = await childrenOf(harness().durable, ctx); + for (const child of children) if (child.kind === "agent") oneShots.add(child.conversationId); + await expectVariant(feed, "reporter").report({ children }, ctx); + }); + self.contribute(ChildReports, reporter); + // What the agent changes on its own (a spawn, a subagent's messages moving it up the list) is pushed after each + // commit that touches it; methods push their own changes before they answer (`SubagentControl.archive`). self.onLive(async (ctx) => { - const durable = harness().durable; - const { signal, oneShots } = commits.value; + const signal = commits.value; const aborted = signalOf(ctx); while (!aborted.aborted) { const seen = signal.version; try { - const context = durableContext(ctx); - const registry = await durable.snapshot(Registry, ROOT_CONVERSATION_ID, context); - const children: Child[] = []; - for (const [name, agent] of Object.entries(registry?.agents ?? {})) { - const child = await durable.conversation(agent.conversationId, context); - const latest = await child?.entries({}, 1, undefined, context); - children.push({ - name, - conversationId: agent.conversationId, - model: agent.model, - thinkingLevel: agent.thinkingLevel, - tools: [...agent.tools], - ...(agent.location === undefined ? {} : { location: agent.location }), - lastEntryId: latest?.items[0]?.id ?? 0, - ...(agent.archived ? { archived: true } : {}), - }); - } - for (const [callId, agent] of Object.entries(registry?.oneShots ?? {})) { - oneShots.add(agent.conversationId); - children.push({ - name: agent.label, - kind: "agent", - callId, - conversationId: agent.conversationId, - model: agent.model, - thinkingLevel: agent.thinkingLevel, - tools: [...agent.tools], - lastEntryId: 0, - }); - } - await expectVariant(feed, "reporter").report({ children }, ctx); + await reporter.push(ctx); await signal.next(seen, aborted); } catch (error) { if (aborted.aborted) return; @@ -176,19 +210,21 @@ export default defineFacet((host, self) => { if (!canSend(principal, session)) throw forbidden("participants only"); const harness = harnessOf(self); const worker = workerOf(self); + const reports = self.registry(ChildReports); return { variant: "participant", members: { archive: ({ conversation, archived }, ctx) => worker().liveness.call(async () => { const durable = harness().durable; - const context = durableContext(ctx); - const registry = await durable.snapshot(Registry, ROOT_CONVERSATION_ID, context); + const registry = await durable.snapshot(Registry, ROOT_CONVERSATION_ID, ctx); if (Object.values(registry?.oneShots ?? {}).some((agent) => agent.conversationId === conversation)) throw forbidden(READ_ONLY); if (!Object.values(registry?.agents ?? {}).some((agent) => agent.conversationId === conversation)) throw new Error("Unknown conversation"); - await setArchived(durable, ROOT_CONVERSATION_ID, conversation as ConversationId, archived, context); + await setArchived(durable, ROOT_CONVERSATION_ID, conversation as ConversationId, archived, ctx); + // The sidebar's archived flag is the server's: pushed before the answer. + await reports.value?.push(ctx); return null; }), }, diff --git a/apps/p2/plugins/terminals/server.ts b/apps/p2/plugins/terminals/server.ts index a116f5127..ee8e7956f 100644 --- a/apps/p2/plugins/terminals/server.ts +++ b/apps/p2/plugins/terminals/server.ts @@ -12,7 +12,7 @@ import { until, type WorkerPrincipal, } from "@p2/app/v1"; -import { Hosts, Server, serverOf, Upgrades, type WorkerState } from "@p2/app/v1/server"; +import { Hosts, Server, Upgrades, type WorkerState } from "@p2/app/v1/server"; import { Catalog, SessionArchived } from "../sessions/contract.ts"; import { ChildStore, conversationLocation } from "../subagents/contract.ts"; import { AgentTerminals, TerminalServiceObject, TerminalServices, Terminals } from "./contract.ts"; @@ -69,10 +69,10 @@ export default defineFacet((host) => { const service = () => services().service; const catalog = self.get(Catalog, {}); const children = self.get(ChildStore, {}); - const server = serverOf(self); const viewResource = self.open( - async () => { - await server().root((ctx) => catalog.get({ id }, ctx)); + async (context) => { + // An unknown session refuses the view. + await catalog.get({ id }, context); return service().open(id); }, (view) => service().release(view), diff --git a/apps/p2/plugins/terminals/worker.ts b/apps/p2/plugins/terminals/worker.ts index 69bf17a58..fdac207d5 100644 --- a/apps/p2/plugins/terminals/worker.ts +++ b/apps/p2/plugins/terminals/worker.ts @@ -21,7 +21,7 @@ export default defineFacet((host) => { const via = <T>( call: (server: Awaited<ReturnType<typeof terminals>>, ctx: Ctx) => Promise<T>, context: Context, - ): Promise<T> => worker().root(async (ctx) => call(await terminals(ctx), ctx), context.abortSignal); + ): Promise<T> => worker().root(async (ctx) => call(await terminals(ctx), ctx), context); const backend: TerminalBackend = { list: async (context) => (await via((server, ctx) => server.list({}, ctx), context)).windows, open: (args, context) => via((server, ctx) => server.open(args, ctx), context), diff --git a/apps/p2/plugins/tree/worker.ts b/apps/p2/plugins/tree/worker.ts index 17adbc672..9257ec602 100644 --- a/apps/p2/plugins/tree/worker.ts +++ b/apps/p2/plugins/tree/worker.ts @@ -1,6 +1,7 @@ // `/tree` in the session worker (`docs/tree.md`): the tree of the top-level conversations' messages, switching the // current main conversation (forking a new branch where none ends at the chosen point), and the rules that come with -// it: older branches are read-only, branches carry stored labels, and the session catalog follows `p2.main`. +// it: older branches are read-only, branches carry stored labels, and the session catalog follows `p2.main`, pushed by +// the worker inside the method that changed it. import type { AssistantMessage } from "@earendil-works/pi-ai"; import { AgentDoc, @@ -25,11 +26,8 @@ import { isOwner, messageData, peerMessage, - signalOf, } from "@p2/app/v1"; -import { durableContext } from "@p2/app/v1/node"; import { - CommitSignal, harnessOf, isTopLevel, MainDoc, @@ -67,42 +65,19 @@ export default defineFacet((host) => { self.contribute(ReadOnlyConversations, { tree: async (conversation, ctx) => { const durable = harness().durable; - const context = durableContext(ctx); const id = conversation as ConversationId; - return id !== (await mainConversation(durable, context)) && (await isTopLevel(durable, id, context)) + return id !== (await mainConversation(durable, ctx)) && (await isTopLevel(durable, id, ctx)) ? NOT_CURRENT : undefined; }, }); - // The catalog follows `p2.main` (the main conversation, the branches and their labels): at start (also after a - // worker stopped between a change and its report) and at every change. - const commits = self.open( - async () => - new CommitSignal(harness().durable, (publication) => - publication.changes.some( - (change) => change.type === "document" && change.record.kind === MainDoc.definition.kind, - ), - ), - (signal) => signal.close(), - ); + // The catalog follows `p2.main` (the main conversation, the branches and their labels): the methods changing it + // push it before they answer (`reportTree`); at start the worker pushes it once, which also covers a worker that + // stopped between a change and its push. Nothing else changes `p2.main`. self.onLive(async (ctx) => { - const signal = signalOf(ctx); - try { - await fillLabels(harness().durable, durableContext(ctx)); - } catch (error) { - if (signal.aborted) return; - console.error("p2 tree: labelling the branches failed:", error); - } - while (!signal.aborted) { - const seen = commits.value.version; - try { - await reportTree(harness().durable, ctx); - } catch (error) { - if (signal.aborted) return; - console.error("p2 tree: reporting the branches failed:", error); - } - await commits.value.next(seen, signal).catch(() => {}); - } + const durable = harness().durable; + await fillLabels(durable, ctx); + await reportTree(durable, ctx); }); }); @@ -115,25 +90,25 @@ export default defineFacet((host) => { return { variant: "owner", members: { - tree: (ctx) => worker().liveness.call(() => readTree(harness().durable, durableContext(ctx))), + tree: (ctx) => worker().liveness.call(() => readTree(harness().durable, ctx)), navigate: ({ entry }, ctx) => worker().liveness.call(async () => { const durable = harness().durable; - const switched = await navigate(durable, entry as EntryId, durableContext(ctx)); + const switched = await navigate(durable, entry as EntryId, 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 select(durable, conversation as ConversationId, 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 rename(durable, conversation, label, ctx); await reportTree(durable, ctx); return null; }), @@ -146,7 +121,7 @@ export default defineFacet((host) => { /** Tells the catalog the current main conversation and the branches, newest first (it ignores an unchanged report). */ async function reportTree(durable: Harness, ctx: Ctx): Promise<void> { - const state = await durable.snapshot(MainDoc, durableContext(ctx)); + const state = await durable.snapshot(MainDoc, ctx); const main = state?.conversation ?? ROOT_CONVERSATION_ID; const branches: Branch[] = (state?.branches ?? [{ conversation: ROOT_CONVERSATION_ID, label: null }]).toReversed(); await expectVariant(await ctx.get(Sessions, {}), "worker").treeSelf({ main, branches }, ctx); diff --git a/apps/p2/plugins/voice/server.ts b/apps/p2/plugins/voice/server.ts index c03538e42..ecfb9c729 100644 --- a/apps/p2/plugins/voice/server.ts +++ b/apps/p2/plugins/voice/server.ts @@ -18,6 +18,7 @@ import { join } from "node:path"; import type { Duplex } from "node:stream"; import { compile, nul, obj, type Static, str, union } from "@earendil-works/pi-schema"; import { + BACKGROUND_CONTEXT, type Ctx, canSend, contribute, @@ -265,8 +266,9 @@ export default defineFacet((host) => { open.set(key, count + 1); const recheck = isGuest(principal) ? setInterval(() => { + // A timer of the socket, nothing at hand: the background context. void natives() - .root((ctx) => mayTalk(principal, ctx, session)) + .root((ctx) => mayTalk(principal, ctx, session), BACKGROUND_CONTEXT) .catch(() => false) .then((allowed) => { if (!allowed) browser.close(1008, "Access ended"); diff --git a/apps/p2/plugins/voice/web.tsx b/apps/p2/plugins/voice/web.tsx index d31794887..14adb2dc1 100644 --- a/apps/p2/plugins/voice/web.tsx +++ b/apps/p2/plugins/voice/web.tsx @@ -6,14 +6,17 @@ // A read/write guest talks (its transcripts are its own messages) but hears nothing yet: the answers are read aloud only // in the owner's browser. View-only guests have no voice mode. Only when the server has a speech service (voice.json). -import { Access, BACKGROUND_CONTEXT, contribute, defineFacet, grantMode, type HeldIn, liveIn } from "@p2/app/v1"; +import { BACKGROUND_CONTEXT, contribute, defineFacet, type HeldIn, liveIn } from "@p2/app/v1"; import { CheckIcon, closeLayers, openLayer, Sheet, Slots, + useCanWrite, + useComposerState, useObject, + useOptimisticCall, useOwner, useReadable, useRoute, @@ -172,11 +175,7 @@ function MicIcon({ muted = false, size = 18 }: { muted?: boolean; size?: number * view-only guest only reads). */ function useTalker(): { owner: boolean; talker: boolean } { - const owner = useOwner(); - const { session } = useRoute(); - const access = useObject(Access, {}); - const grant = useReadable(liveIn(access, "guest")?.grant); - return { owner, talker: owner || grantMode(grant, session) === "full" }; + return { owner: useOwner(), talker: useCanWrite(useRoute().session) }; } /** The voice mode switch of a conversation (the owner's variant or a guest's) and its shared voice. */ @@ -379,7 +378,12 @@ function VoiceSheet({ config, owner, onClose }: { config: VoiceConfig; owner: bo const [, setTick] = useState(0); useEffect(() => shown.subscribe(() => setTick((tick) => tick + 1)), []); const mine = useStored(); - const settings = shared ?? mine; + // The owner's choice shows at once; a refused one goes back with the error in the toast. + const [settings, choose] = useOptimisticCall( + shared ?? mine, + (_, next: VoiceSettings) => next, + useComposerState().fail, + ); const { engine: ttsEngine, voice, language } = resolveVoice(config, settings); const languages = languagesOf(ttsEngine); const voiceName = ttsEngine?.voices.find((option) => option.id === voice)?.name ?? voice; @@ -416,7 +420,7 @@ function VoiceSheet({ config, owner, onClose }: { config: VoiceConfig; owner: bo const next = resolved(config, { ...settings, ...change }); sampling.current = next; stored.set(next); - void ownerMode?.choose(next, BACKGROUND_CONTEXT).catch((error: unknown) => console.error("[voice]", error)); + if (ownerMode) choose(next, () => ownerMode.choose(next, BACKGROUND_CONTEXT)); }; const sample = () => sampler.sample(); diff --git a/apps/p2/plugins/voice/worker.ts b/apps/p2/plugins/voice/worker.ts index d002daeb2..32469f482 100644 --- a/apps/p2/plugins/voice/worker.ts +++ b/apps/p2/plugins/voice/worker.ts @@ -5,24 +5,22 @@ // owner's switch or any guest's is, since that guest talks by voice too. import { type ConversationId, defineDoc, defineExtension, section } from "@earendil-works/pi-durable"; -import { bool, nul, obj, type Static, union } from "@earendil-works/pi-schema"; +import { bool, obj, type Static } from "@earendil-works/pi-schema"; import { - type Context, type Ctx, canSend, contribute, defineFacet, - defineTypedState, + defineState, deriveTyped, forbidden, invalid, isOwner, principalKey, - signalOf, } from "@p2/app/v1"; import { durableContext } from "@p2/app/v1/node"; -import { agentConversation, CommitSignal, Extensions, harnessOf } from "@p2/app/v1/worker"; -import { VoiceChoiceSchema, VoiceMode } from "./contract.ts"; +import { agentConversation, Extensions, harnessOf } from "@p2/app/v1/worker"; +import { type VoiceChoiceSchema, VoiceMode } from "./contract.ts"; export type VoiceChoice = Static<typeof VoiceChoiceSchema>; @@ -97,11 +95,11 @@ export const voiceExtension = defineExtension({ ], }); -/** What the cells show: the document's voice, and the switch (the document's for the owner, a guest's own). */ -const Shown = defineTypedState("p2.voice-mode", { - schema: obj({ voice: union([VoiceChoiceSchema, nul()]), enabled: bool() }), - version: 2, - initial: () => ({ voice: null, enabled: false }), +/** A read/write guest's own microphone switch (the owner's is the document's). */ +const GuestSwitch = defineState("p2.voice-mode.guest", { + schema: obj({ enabled: bool() }), + version: 1, + initial: () => ({ enabled: false }), }); export default defineFacet((host) => { @@ -115,57 +113,34 @@ export default defineFacet((host) => { if (!canSend(principal, session)) throw forbidden("participants only"); const id = conversation as ConversationId; const harness = harnessOf(self); - /** Where the agent's voice mode is (the main agent's on conversation 1); set when the object opens. */ + /** Where the agent's voice mode is (the main agent's on conversation 1); found when the document is followed. */ let holder = id; - const read = async (ctx: Context) => - (await harness().durable.snapshot(VoiceModeDoc, holder, ctx)) ?? { - enabled: false, - voice: null, - }; - const owner = isOwner(principal); - const shown = self.state(Shown); - const show = (doc: { enabled: boolean; voice: VoiceChoice | null }, ctx: Context) => - shown.change((draft) => { - draft.voice = doc.voice; - if (owner) draft.enabled = doc.enabled; - }, ctx); - const commits = self.open( - async (ctx) => { - const durable = harness().durable; - if (!(await durable.conversation(id, ctx))) throw new Error(`Unknown conversation ${conversation}`); - holder = await agentConversation(durable, id, ctx); - await show(await read(ctx), ctx); - return new CommitSignal(durable, (publication) => - publication.changes.some( - (change) => - change.type === "document" && - change.record.kind === VoiceModeDoc.definition.kind && - change.conversationId === holder, - ), - ); - }, - (signal) => signal.close(), - ); - // The document is the truth; the cells show it. Subscribed before reading, so no commit is missed. - self.onLive(async (ctx) => { - const signal = signalOf(ctx); - while (!signal.aborted) { - const seen = commits.value.version; - await show(await read(ctx), ctx); - await commits.value.next(seen, signal).catch(() => {}); - } + // The document is the truth, followed: a commit below reaches it before the method returns. + const doc = self.follow(async (observer, ctx) => { + const durable = harness().durable; + if (!(await durable.conversation(id, durableContext(ctx)))) + throw new Error(`Unknown conversation ${conversation}`); + holder = await agentConversation(durable, id, durableContext(ctx)); + const observed = await durable.observeDoc(VoiceModeDoc, holder, observer, ctx); + if (observed !== undefined) return observed; + // Nobody switched it yet: an absent address cannot be followed, so it is created as it reads (off, no voice). + await durable.commit(async (tx) => { + await tx.doc(VoiceModeDoc, holder); + }, durableContext(ctx)); + return durable.observeDoc(VoiceModeDoc, holder, observer, ctx); }); + const owner = isOwner(principal); const commit = async (ctx: Ctx, change: (doc: { enabled: boolean; voice: VoiceChoice | null }) => void) => { const durable = await harness().durable.conversation(id, durableContext(ctx)); if (!durable) throw invalid("args", `Unknown conversation ${conversation}`); await durable.commit(async (tx) => change(await tx.doc(VoiceModeDoc, holder)), durableContext(ctx)); return null; }; - const enabled = self.derive([shown], ([value]) => value.enabled); - const voice = deriveTyped(self, [shown], ([value]) => value.voice); + const voice = deriveTyped(self, [doc], ([value]) => value.voice); if (!owner) { // A read/write guest: its own microphone switch, counted while this object lives. const key = principalKey(principal); + const guestSwitch = self.state(GuestSwitch); self.open( async () => key, () => setGuestVoice(String(id), key, false), @@ -173,13 +148,13 @@ export default defineFacet((host) => { return { variant: "participant", members: { - enabled, + enabled: self.derive([guestSwitch], ([value]) => value.enabled), voice, set: async ({ enabled: on }, ctx) => { if (on && !(await harness().durable.conversation(id, durableContext(ctx)))) throw invalid("args", `Unknown conversation ${conversation}`); setGuestVoice(String(id), key, on); - await shown.change((draft) => { + await guestSwitch.change((draft) => { draft.enabled = on; }, ctx); return null; @@ -190,7 +165,7 @@ export default defineFacet((host) => { return { variant: "owner", members: { - enabled, + enabled: self.derive([doc], ([value]) => value.enabled), voice, set: ({ enabled: on }, ctx) => commit(ctx, (doc) => { diff --git a/apps/p2/plugins/workspace/server.ts b/apps/p2/plugins/workspace/server.ts index d50cd089a..36082d174 100644 --- a/apps/p2/plugins/workspace/server.ts +++ b/apps/p2/plugins/workspace/server.ts @@ -1,6 +1,14 @@ import { randomUUID } from "node:crypto"; import { once } from "node:events"; -import { canSend, contribute, defineFacet, filePreview, signalOf } from "@p2/app/v1"; +import { + canSend, + contribute, + defineFacet, + filePreview, + signalOf, + withAbortSignal, + withoutAbortSignal, +} from "@p2/app/v1"; import { fromOrigin, type RouteHandler, Routes, sendJson, serverOf } from "@p2/app/v1/server"; import { Catalog } from "../sessions/contract.ts"; import { WorkspaceTransfer } from "./contract.ts"; @@ -71,11 +79,13 @@ export default defineFacet((host) => { } catch (error) { // Cleanup has its own uncancelled root after a disconnect. await server() - .root(async (cleanup) => - (await cleanup.get(WorkspaceTransfer, { session, conversation })).write( - { path, name, token, offset, data: "", final: false, abort: true }, - cleanup, - ), + .root( + async (cleanup) => + (await cleanup.get(WorkspaceTransfer, { session, conversation })).write( + { path, name, token, offset, data: "", final: false, abort: true }, + cleanup, + ), + withoutAbortSignal(outer), ) .catch(() => {}); throw error; @@ -151,7 +161,7 @@ export default defineFacet((host) => { } response.end(); }, - AbortSignal.any([abort.signal, signalOf(outer)]), + withAbortSignal(abort.signal, outer), ); } catch (error) { request.resume(); diff --git a/apps/p2/plugins/workspace/tui.ts b/apps/p2/plugins/workspace/tui.ts index 226eb33ef..0c1e8d595 100644 --- a/apps/p2/plugins/workspace/tui.ts +++ b/apps/p2/plugins/workspace/tui.ts @@ -4,7 +4,7 @@ // filesystem watch, which read-only guests cannot get. Comments, commits and uploads stay on the web for now. import type { Held } from "@p2/app/v1"; -import { BACKGROUND_CONTEXT, contribute, defineFacet, statusOf, whenLive } from "@p2/app/v1"; +import { BACKGROUND_CONTEXT, contribute, defineFacet, liveIn, statusOf, whenLive } from "@p2/app/v1"; import type { TuiContext } from "@p2/app/v1/tui"; import { ListOverlay, @@ -334,17 +334,17 @@ export class GitBranch implements TuiSurface { if (!allowed || !session) return; const scope = new TuiScope(this.runtime); this.scope = scope; - const workspace = scope.watch(Workspace, { session, conversation: this.runtime.conversation }, (_ref, follow) => - follow(_ref.changes, () => { + scope.watch(Workspace, { session, conversation: this.runtime.conversation }, (workspace, follow) => + follow(workspace.changes, () => { // The first answer at once; file changes coalesce. clearTimeout(this.timer); this.timer = setTimeout(() => this.ask(workspace, key), this.branch === undefined ? 0 : 500); }), ); } - private ask(workspace: () => Held<typeof Workspace> | undefined, key: string | undefined): void { - const ref = workspace(); - if (ref === undefined || statusOf(ref) !== "live" || ref.variant !== "reader" || key !== this.key) return; + private ask(workspace: Held<typeof Workspace>, key: string | undefined): void { + const ref = liveIn(workspace, "reader"); + if (ref === undefined || key !== this.key) return; if (this.asking) { this.again = true; return; diff --git a/apps/p2/plugins/workspace/web.tsx b/apps/p2/plugins/workspace/web.tsx index ed7c50a90..a737520fe 100644 --- a/apps/p2/plugins/workspace/web.tsx +++ b/apps/p2/plugins/workspace/web.tsx @@ -1,12 +1,14 @@ -import { Access, BACKGROUND_CONTEXT, contribute, defineFacet, follow, grantMode, liveIn } from "@p2/app/v1"; +import { BACKGROUND_CONTEXT, contribute, defineFacet, follow, liveIn } from "@p2/app/v1"; import type { Navigation, WorkspaceRoute } from "@p2/app/v1/web"; import { FolderIcon, locationLabel, Slots, + useCanWrite, useComposerState, useNavigation, useObject, + useOwner, useReadable, useRoute, useStatus, @@ -21,15 +23,8 @@ import { WorkspacePanel as MobileWorkspacePanel } from "./Workspace.tsx"; function WorkspaceButton() { const nav = useNavigation(); const route = useRoute(); - const access = useObject(Access, {}); - const grant = useReadable(liveIn(access, "guest")?.grant); - const status = useStatus(access); - if ( - !route.session || - status !== "live" || - (access?.variant !== "owner" && grantMode(grant, route.session) !== "full") - ) - return null; + const canWrite = useCanWrite(route.session); + if (!route.session || !canWrite) return null; return ( <button type="button" @@ -50,25 +45,18 @@ function WorkspaceButton() { function WorkspacePanel() { const route = useRoute(), nav = useNavigation(); - const access = useObject(Access, {}); - const grant = useReadable(liveIn(access, "guest")?.grant); - const status = useStatus(access); + const owner = useOwner(); + const canWrite = useCanWrite(route.session); // Retry opens the workspace again: a new panel holds a new reference. const [attempt, setAttempt] = useState(0); - if ( - !route.session || - !route.workspace || - status !== "live" || - (access?.variant !== "owner" && grantMode(grant, route.session) !== "full") - ) - return null; + if (!route.session || !route.workspace || !canWrite) return null; return ( <NativePanel key={`${route.session}:${route.conversation}:${attempt}`} onRetry={() => setAttempt((current) => current + 1)} session={route.session} conversation={route.conversation} - owner={access?.variant === "owner"} + owner={owner} nav={nav} route={route.workspace} /> @@ -101,7 +89,7 @@ function NativePanel({ ); const cwd = location?.cwd ?? ""; const { steer, setError } = useComposerState(); - // Why the workspace is not open: a failed open (the reference is gone, and taken again shortly) shows its error + // Why the workspace is not open: a failed open shows its error (Retry opens it again with a new reference) // instead of loading forever. const [unavailable, setUnavailable] = useState<string>(); useEffect(() => { diff --git a/apps/p2/plugins/workspace/worker.ts b/apps/p2/plugins/workspace/worker.ts index 97ab9622e..fc9ce3850 100644 --- a/apps/p2/plugins/workspace/worker.ts +++ b/apps/p2/plugins/workspace/worker.ts @@ -1,6 +1,15 @@ import { AgentDoc, type ConversationId, ROOT_CONVERSATION_ID } from "@earendil-works/pi-durable"; import type { ExecutionEnv } from "@earendil-works/pi-durable/env"; -import { canSend, defineFacet, forbidden, type Location, type Subscribable, sameLocation, signalOf } from "@p2/app/v1"; +import { + canSend, + defineFacet, + followNative, + forbidden, + type Location, + type Subscribable, + sameLocation, + signalOf, +} from "@p2/app/v1"; import { durableContext } from "@p2/app/v1/node"; import { conversationEnvironment, harnessOf, isLocalEnvironment, workerOf } from "@p2/app/v1/worker"; import { LocationMoveDoc } from "../locations/contract.worker.ts"; @@ -62,15 +71,6 @@ export default defineFacet((host) => { const harness = harnessOf(self); const worker = workerOf(self); const writes = self.get(WorkspaceWrites, {}); - const changes = self.writable(0); - self.open( - async () => { - const liveness = worker().liveness; - liveness.add("viewers", 1); - return liveness; - }, - (liveness) => liveness.add("viewers", -1), - ); /** The viewed conversation's workspace where it works now, and the watch reporting its changes to `listeners`. */ const openWorkspace = async (context: ReturnType<typeof durableContext>, notify: () => void) => { const durable = harness().durable; @@ -124,10 +124,13 @@ export default defineFacet((host) => { }, }; let pending: ReturnType<typeof setTimeout> | undefined; + // Counts the workspace's changes (at most one per 100 ms): viewers read again at each. + let version = 0; const notify = (): void => { if (pending) return; pending = setTimeout(() => { pending = undefined; + version++; for (const fn of listeners) fn(null); }, 100); }; @@ -225,6 +228,9 @@ export default defineFacet((host) => { return { events, current, + get version() { + return version; + }, close: async () => { stop(); closing.abort(); @@ -235,13 +241,12 @@ export default defineFacet((host) => { }, (value) => value.close(), ); - // Closed before the workspace (reverse order), so no change arrives after the writable ends. - self.open( - async () => workspaceResource.value.events.subscribe(() => changes.set(changes.value + 1)), - (stop) => stop(), - ); return { - changes: self.derive([changes], ([value]) => value), + changes: followNative( + self, + (changed) => workspaceResource.value.events.subscribe(changed), + () => workspaceResource.value.version, + ), query: (query, ctx) => worker().liveness.call(async () => { if ("path" in query && workspacePath(query.path) === undefined) diff --git a/apps/p2/src/api/v1/index.ts b/apps/p2/src/api/v1/index.ts index 0e75bb9a0..b85c06982 100644 --- a/apps/p2/src/api/v1/index.ts +++ b/apps/p2/src/api/v1/index.ts @@ -9,11 +9,15 @@ export { ChordError, type Contract, constant, + defineJob, defineRegistry, defineState, evolve, type HandleOf, isChordError, + type JobCtx, + type JobError, + type JobHandle, type KeyedHandle, keyed, keyedEntries, @@ -21,11 +25,14 @@ export { mapShared, method, type Native, + type Outcome, orderedEntries, PluginError, type RefState, type Registry, type RegistryValue, + type Run, + type RunningEntry, remote, type SingletonHandle, sleep, @@ -40,6 +47,7 @@ export { type Context, withAbortSignal, withCancel, + withoutAbortSignal, } from "@earendil-works/chord/context"; export type { JsonValue } from "@earendil-works/chord/json"; export type { NativeWatchable, WatchCall, WatchHandle } from "@earendil-works/chord/watch"; diff --git a/apps/p2/src/hosts/server.ts b/apps/p2/src/hosts/server.ts index bb02da159..f7a549635 100644 --- a/apps/p2/src/hosts/server.ts +++ b/apps/p2/src/hosts/server.ts @@ -5,9 +5,10 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht import { homedir } from "node:os"; import { join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { isChordError, type PluginFacet, webSocketLink } from "@earendil-works/chord"; -import { BACKGROUND_CONTEXT, withAbortSignal } from "@earendil-works/chord/context"; +import { isChordError, openStore, type PluginFacet, sqliteBackend, webSocketLink } from "@earendil-works/chord"; +import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import type { RolloutResult } from "@earendil-works/chord/plugins"; +import { openNodeSqliteDatabase } from "@earendil-works/chord/sqlite/node"; import { WebSocketServer } from "ws"; import { type Ctx, createHost, type Host, type HostError, hostErrorText, Server } from "../runtime/chord.ts"; import { envDefaultModel } from "../runtime/default-model.ts"; @@ -122,10 +123,17 @@ export async function startServer(options: ServerOptions = {}): Promise<P2Server throw new Error(`Another p2 server uses ${dataDir}`, { cause: error }); }); const allowedHosts = options.allowedHosts ?? parseAllowedHosts(process.env.P2_ALLOWED_HOSTS); + // The server's Chord store: persisted documents and job runs of its host-lifetime objects (session lifecycle + // operations, search rebuilds). p2's older SQLite files stay their owners' natives. + const store = await openStore( + sqliteBackend(await openNodeSqliteDatabase(join(dataDir, "server.sqlite"))), + BACKGROUND_CONTEXT, + ); // The server's own requests act as `SERVER`: a worker tells them from a person's (`WorkerBoundary`). const host = createHost({ address: Server(), principal: SERVER, + store, onError: (error) => { if (isOwnerNotice(error)) processLogs.notice("server", hostErrorText(error)); console.error("p2 server:", error); @@ -306,8 +314,7 @@ export async function startServer(options: ServerOptions = {}): Promise<P2Server facet: serverCore(host, info, { resourceOptions: options.resources ?? {}, workers, - root: (fn, signal) => - host.root(fn, signal ? withAbortSignal(signal, BACKGROUND_CONTEXT) : BACKGROUND_CONTEXT), + root: (fn, context) => host.root(fn, context), }), }, { name: "@p2/tui-preferences", facet: tuiPreferencesFacet(dataDir) }, @@ -321,6 +328,7 @@ export async function startServer(options: ServerOptions = {}): Promise<P2Server processLogs.notice("server", `Server startup failed: ${error instanceof Error ? error.message : String(error)}`); await workers.close(BACKGROUND_CONTEXT).catch(() => {}); await host.close(BACKGROUND_CONTEXT).catch(() => {}); + await store.close(BACKGROUND_CONTEXT).catch(() => {}); p2Plugins.close(); stopLogs(); await new Promise<void>((resolve) => http.close(() => resolve())); @@ -694,6 +702,7 @@ export async function startServer(options: ServerOptions = {}): Promise<P2Server await new Promise<void>((resolve) => sockets.close(() => resolve())); await workers.close(BACKGROUND_CONTEXT); await host.close(BACKGROUND_CONTEXT); + await store.close(BACKGROUND_CONTEXT); p2Plugins.close(); stopLogs(); http.closeAllConnections(); diff --git a/apps/p2/src/hosts/session-workers.ts b/apps/p2/src/hosts/session-workers.ts index 375a7fa57..7381ef444 100644 --- a/apps/p2/src/hosts/session-workers.ts +++ b/apps/p2/src/hosts/session-workers.ts @@ -3,6 +3,7 @@ // data), logs and owner notices, restarts after a crash (as v1), and the lifecycle steps of relocation and cloning, // serialized per session. import { realpathSync } from "node:fs"; +import type { FollowAttachment, FollowObserver } from "@earendil-works/chord"; import { BACKGROUND_CONTEXT, type Context } from "@earendil-works/chord/context"; import type { JsonValue } from "@earendil-works/chord/json"; import type { Plugins } from "@earendil-works/chord/plugins"; @@ -10,7 +11,13 @@ import { createWorkers, type WorkerState, type Workers, type WorkersError } from import { type Host, Session } from "../runtime/chord.ts"; import { logWriter, processLogs } from "../runtime/logs.ts"; import { canView, type P2Principal } from "../runtime/principals.ts"; -import type { HostEvent, RelocationSteps, SessionWorkerControl } from "../runtime/server.ts"; +import type { + HostEvent, + RelocationSteps, + SessionWorkerControl, + WorkerState as SessionWorkerState, +} from "../runtime/server.ts"; +import { WorkerBoundary } from "../runtime/session-worker.ts"; import type { WorkerInit } from "../runtime/worker.ts"; export type SessionWorkersOptions = { @@ -43,6 +50,9 @@ export class SessionWorkers implements SessionWorkerControl { readonly #generations = new Map<string, number>(); readonly #operations = new Map<string, Promise<unknown>>(); readonly #listeners = new Set<(event: HostEvent) => void>(); + /** The followers of every worker's state (`observe`), and the state they were given last. */ + readonly #observers = new Set<FollowObserver<unknown>>(); + #workerStates: Readonly<Record<string, SessionWorkerState>> = {}; readonly #writers = new Map<string, ReturnType<typeof logWriter>>(); /** The redacted reason of each session's last failed start or crash (Chord's public error is a fixed message). */ readonly #reasons = new Map<string, string>(); @@ -69,7 +79,7 @@ export class SessionWorkers implements SessionWorkerControl { static async create(options: SessionWorkersOptions, context: Context): Promise<SessionWorkers> { const made = new SessionWorkers(options); made.#workers = await made.#createWorkers(context); - void made.#follow(); + await made.#follow(); return made; } @@ -140,6 +150,11 @@ export class SessionWorkers implements SessionWorkerControl { return () => this.#listeners.delete(listener); } + observe(observer: FollowObserver<unknown>): FollowAttachment<Readonly<Record<string, SessionWorkerState>>> { + this.#observers.add(observer); + return { value: this.#workerStates, detach: () => this.#observers.delete(observer) }; + } + async relocate(session: string, steps: RelocationSteps): Promise<void> { const source = `${SESSION_PREFIX}${session}`; const message = (error: unknown) => (error instanceof Error ? error.message : String(error)); @@ -232,7 +247,19 @@ export class SessionWorkers implements SessionWorkerControl { }); } - async retry(session: string): Promise<void> { + async retry(session: string, context: Context): Promise<SessionWorkerState> { + await this.resume(session); + // A hold of the server's own starts the worker when nothing else holds it; its outcome is the answer. + const boundary = this.#options.host.hold(WorkerBoundary, { session }, context); + try { + await this.#settled(session, context); + } finally { + boundary.release(); + } + return this.#workerStates[session] ?? { running: false, generation: this.generation(session) }; + } + + async resume(session: string): Promise<void> { this.#crashRestarts.delete(session); await this.#serially(session, async () => { await this.#suspended(session, async () => { @@ -244,6 +271,27 @@ export class SessionWorkers implements SessionWorkerControl { }); } + /** Resolves once the session's worker runs or failed (not while it starts or is stopped); `context` ends the wait. */ + async #settled(session: string, context: Context): Promise<void> { + const settled = () => { + const status = this.#status(session); + return status === "running" || status === "failed"; + }; + if (settled()) return; + const watch = await this.workers.state.watch(context); + try { + await new Promise<void>((resolve, reject) => { + context.abortSignal?.addEventListener("abort", () => reject(context.abortSignal?.reason), { once: true }); + if (settled()) return resolve(); + watch.start(async () => { + if (settled()) resolve(); + }); + }); + } finally { + await watch.stop(); + } + } + /** Stops the session's worker and clears its failure: the next hold (one waiting now too) starts it again. */ async stop(session: string): Promise<void> { await this.#serially(session, () => @@ -434,6 +482,7 @@ export class SessionWorkers implements SessionWorkerControl { async #follow(): Promise<void> { const watch = await this.workers.state.watch(BACKGROUND_CONTEXT); const publish = (states: typeof watch.value): void => { + const changed: Record<string, SessionWorkerState> = {}; for (const key of new Set([...Object.keys(this.#states), ...Object.keys(states)])) { const state = states[key]; if (JSON.stringify(state) === JSON.stringify(this.#states[key])) continue; @@ -451,8 +500,15 @@ export class SessionWorkers implements SessionWorkerControl { } : { session, running: state.status === "running", generation, pid: state.pid }; for (const listener of [...this.#listeners]) listener(event); + const { session: _session, ...worker } = event; + changed[session] = worker; } this.#states = states; + if (Object.keys(changed).length === 0) return; + this.#workerStates = { ...this.#workerStates, ...changed }; + const value = this.#workerStates; + for (const observer of [...this.#observers]) + observer.advance({ kind: "value", value, ops: [["r", value]], retired: false }, BACKGROUND_CONTEXT); }; publish(watch.value); watch.start(async (call) => { diff --git a/apps/p2/src/hosts/tui-client.ts b/apps/p2/src/hosts/tui-client.ts index adab57317..3684fa487 100644 --- a/apps/p2/src/hosts/tui-client.ts +++ b/apps/p2/src/hosts/tui-client.ts @@ -67,6 +67,8 @@ export async function startTuiClient(options: { actions?: Readonly<Record<string, LauncherAction>>; /** The visitor's mouse policy (launcher-only preference): which mouse modes worker output may enable. */ mouse?: MousePolicy; + /** Who the launcher was admitted as: the TUI starts with that role, so owner views hold before `Access` answers. */ + role?: "owner" | "guest"; url: URL; /** The visitor's validated local preferences (section 4.2) and their change feed; sent to every worker as data. */ preferences?: { @@ -409,6 +411,7 @@ export async function startTuiClient(options: { origin: options.url.origin, // The visitor's mouse policy decides whether pi-tui captures the mouse (wheel, app-owned selection). mouse: (options.mouse ?? "off") !== "off", + ...(options.role ? { role: options.role } : {}), }, onFailure: (message) => { if (sandbox === candidate) fail(message); diff --git a/apps/p2/src/hosts/tui-worker-runtime.ts b/apps/p2/src/hosts/tui-worker-runtime.ts index 03808ef32..68d0bb3d5 100644 --- a/apps/p2/src/hosts/tui-worker-runtime.ts +++ b/apps/p2/src/hosts/tui-worker-runtime.ts @@ -21,7 +21,7 @@ import { restoreRuntime, snapshotRuntime } from "../tui/snapshot.ts"; import { parseTerminalState } from "../tui/theme/theme.ts"; type Bridge = { - input: { columns: number; rows: number; origin?: unknown; mouse?: unknown }; + input: { columns: number; rows: number; origin?: unknown; mouse?: unknown; role?: unknown }; send(value: unknown): void; onMessage(handler: (message: Record<string, unknown>) => unknown): void; }; @@ -161,6 +161,8 @@ export async function setupTui(bridge: Bridge, facets: readonly PluginFacet<P2Pr host.connect(link, linkOptions, BACKGROUND_CONTEXT); const loaded = await host.load([...facets, { name: "@p2/tui-core", facet: tuiCoreFacet }], BACKGROUND_CONTEXT); runtime.origin = typeof bridge.input.origin === "string" ? bridge.input.origin.slice(0, 200) : undefined; + // The launcher's admission (a hint from outside the sandbox): owner views hold at once; `Access` confirms it. + if (bridge.input.role === "owner" || bridge.input.role === "guest") runtime.role = bridge.input.role; await runtime.start(); // A facet left out at load (its setup threw) is shown, not silently missing. for (const { plugin, error } of loaded.failed) @@ -210,7 +212,27 @@ export async function setupTui(bridge: Bridge, facets: readonly PluginFacet<P2Pr reported = { target, suspend, busy }; }; runtime.listeners.add(report); - let access: ReturnType<typeof host.hold<typeof Access>> | undefined; + // Who this TUI is: held once (Chord holds it again on every new link), the role and a guest's grant follow it. + const access = host.hold(Access, {}, BACKGROUND_CONTEXT); + follow(access.state, (state) => { + if (state.kind !== "live") return; + const role = access.variant === "owner" ? "owner" : "guest"; + if (role === runtime.role) return; + runtime.role = role; + runtime.changed(); + }); + void whenLive(access).then( + () => { + if (access.variant === "guest") + follow(access.grant, (grant) => { + runtime.grant = grant + ? { name: grant.name, sessions: grant.sessions ?? { [grant.session]: grant.mode } } + : undefined; + if (grant) runtime.navigate("chat", grant.session); + }); + }, + (error: unknown) => runtime.fail(error), + ); bridge.onMessage(async (message) => { if (message.kind === "snapshot") return snapshotRuntime(runtime); if (message.kind === "activate") { @@ -228,23 +250,6 @@ export async function setupTui(bridge: Bridge, facets: readonly PluginFacet<P2Pr host.connect(link, linkOptions, BACKGROUND_CONTEXT); link.activate(); } - // Held for the connection: the role and a guest's grant follow it (released when the link drops). - access?.release(); - const held = host.hold(Access, {}, BACKGROUND_CONTEXT); - access = held; - void whenLive(held) - .then(() => { - runtime.role = held.variant === "owner" ? "owner" : "guest"; - runtime.changed(); - if (held.variant === "guest") - follow(held.grant, (grant) => { - runtime.grant = grant - ? { name: grant.name, sessions: grant.sessions ?? { [grant.session]: grant.mode } } - : undefined; - if (grant) runtime.navigate("chat", grant.session); - }); - }) - .catch((error: unknown) => runtime.fail(error)); void host .root(async (ctx) => (await ctx.get(TuiPreferences, {})).read(ctx), BACKGROUND_CONTEXT) // Server defaults sit below the guest's local preferences (section 4.2). diff --git a/apps/p2/src/hosts/tui.ts b/apps/p2/src/hosts/tui.ts index b0cbd183b..044b0fd41 100644 --- a/apps/p2/src/hosts/tui.ts +++ b/apps/p2/src/hosts/tui.ts @@ -66,6 +66,7 @@ try { url: options.url, credentials: { cookie: admission.cookie }, mouse: local.mouse, + role: admission.role, actions: launcherActions({ url: options.url, credentials: { cookie: admission.cookie }, diff --git a/apps/p2/src/hosts/worker.ts b/apps/p2/src/hosts/worker.ts index 774533e44..0cbe1726a 100644 --- a/apps/p2/src/hosts/worker.ts +++ b/apps/p2/src/hosts/worker.ts @@ -3,7 +3,7 @@ // facet, which owns the session's Durable Harness. import { isChordError } from "@earendil-works/chord"; -import { BACKGROUND_CONTEXT, withAbortSignal } from "@earendil-works/chord/context"; +import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { workerMain } from "@earendil-works/chord/workers/child"; import { registerLazyBedrockProvider } from "@earendil-works/pi-ai/lazy-bedrock-provider"; import { registerLazyOAuthFlows } from "@earendil-works/pi-ai/lazy-oauth"; @@ -62,9 +62,7 @@ workerMain( core: [ { name: "@p2/core", - facet: workerCore(init, liveness, location, (fn, signal) => - host.root(fn, signal ? withAbortSignal(signal, context) : context), - ), + facet: workerCore(init, liveness, location, (fn, rootContext) => host.root(fn, rootContext)), }, ], }, diff --git a/apps/p2/src/runtime/chord.ts b/apps/p2/src/runtime/chord.ts index 237a7bcf3..41a082bfe 100644 --- a/apps/p2/src/runtime/chord.ts +++ b/apps/p2/src/runtime/chord.ts @@ -72,12 +72,9 @@ export const sessionHost = ({ session }: { readonly session: string }) => Sessio /** Params fields (Chord's typed schemas): a required string, integer, boolean or one of some strings. */ export const param = { str, int, bool, oneOf }; -/** An `invalid` ChordError with `reason` (`args`, `params`, …): a request p2 refuses as malformed. */ -export const invalid = (reason: string, message: string): ChordError => - new ChordError({ code: "invalid", reason, message } as never); - -/** A `forbidden` ChordError (p2's access checks in factories, methods and routes). */ -export const forbidden = (message: string): ChordError => new ChordError({ code: "forbidden", message }); +// Chord's decided errors for plugins: `invalid(reason, message)` for refused arguments or params, `forbidden(message)` +// for a refused principal. +export { forbidden, invalid } from "@earendil-works/chord"; type Variants<R> = R extends { readonly variant: infer V } ? Exclude<V, undefined> : never; /** The variants of a reference to `C` (remote types), or `never`. */ @@ -230,3 +227,25 @@ export function serveKeyed<V>(host: FacetHost, registry: KeyedRegistry<V>): void new Map([...keyedEntries(table, (value) => value, report)].map(([key, item]) => [key, item.value])), }); } + +/** + * A state source following a native that tells listeners of its changes (`subscribe` returns the stop): `self.follow` + * of a synchronous observer, so the state is the native's, never a copy a routine keeps. Its value is `read()` at + * activation and after every change, sent whole (`[["r", value]]`: derives over it diff for the wire). Both run once + * the object's natives opened, so they may read a native's `.value`. A change the native makes inside a method + * reaches the source before the method's answer (an off-batch change commits on the next microtask). + */ +export function followNative<T>( + self: Self, + subscribe: (changed: () => void) => () => void, + read: () => T, +): Watchable<T> { + return self.follow(async (observer, context) => { + const detach = subscribe(() => { + const value = read(); + // JSON at runtime: `read` gives the native's plain data (optional fields TypeScript does not see as JSON). + observer.advance({ kind: "value", value, ops: [["r", value as never]], retired: false }, context); + }); + return { value: read(), detach }; + }); +} diff --git a/apps/p2/src/runtime/refs.ts b/apps/p2/src/runtime/refs.ts index 67fda9920..eafa3aed4 100644 --- a/apps/p2/src/runtime/refs.ts +++ b/apps/p2/src/runtime/refs.ts @@ -126,7 +126,7 @@ export function errorOf(ref: HeldRef): Error | undefined { /** * Releases held references later: a chat stays held for a while after its screen is left, so that switching back is - * instant (v1's client linger), at most `cap` of them at once (the oldest goes first). + * instant, at most `cap` of them at once (the oldest goes first). */ export class Lingering { readonly #delays: Readonly<Record<string, number>>; diff --git a/apps/p2/src/runtime/server.ts b/apps/p2/src/runtime/server.ts index 1e274e054..50bf7a9b9 100644 --- a/apps/p2/src/runtime/server.ts +++ b/apps/p2/src/runtime/server.ts @@ -2,7 +2,14 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { Duplex } from "node:stream"; -import { constant, defineRegistry, defineState, local, method, untilAborted } from "@earendil-works/chord"; +import { + constant, + defineRegistry, + type FollowAttachment, + type FollowObserver, + local, + method, +} from "@earendil-works/chord"; import type { Context } from "@earendil-works/chord/context"; import type { JsonValue } from "@earendil-works/chord/json"; import type { CredentialStore, ModelsStore } from "@earendil-works/pi-ai"; @@ -19,7 +26,6 @@ import { keyedRegistry, type Self, serveKeyed, - signalOf, } from "./chord.ts"; import { isGuest, isOwner, type P2Principal } from "./principals.ts"; import { record } from "./schema.ts"; @@ -83,8 +89,16 @@ export interface SessionWorkerControl { work: () => Promise<void>, doing: string, ): Promise<void>; - /** Owner-requested retry: stops the previous instance and clears the failure before a fresh reference. */ - retry(session: string): Promise<void>; + /** + * Owner-requested retry: stops the previous instance, clears the failure and starts the worker again, resolving with + * the start's outcome (running, or failed with its redacted error). + */ + retry(session: string, context: Context): Promise<WorkerState>; + /** + * Clears the session's failure or suspension (a restore): its holders start a worker, a later hold does too. Nothing + * is started when nobody holds it. + */ + resume(session: string): Promise<void>; /** Stops the session's worker; its holders start a new one (a restart, e.g. for a changed environment). */ stop(session: string): Promise<void>; /** @@ -92,8 +106,13 @@ export interface SessionWorkerControl { * or their release, instead of a start the archived session refuses. */ suspend(session: string): Promise<void>; - /** Every change of a worker's state. */ + /** Every change of a worker's state (the dev supervisor's events). */ subscribe(listener: (event: HostEvent) => void): () => void; + /** + * Follows every session worker's state (latest generation only) with a synchronous observer: Chord's `self.follow` + * of `Hosts.workers`. + */ + observe(observer: FollowObserver<unknown>): FollowAttachment<Readonly<Record<string, WorkerState>>>; } /** The server's natives, contributed by the core facet from the start. */ @@ -101,10 +120,11 @@ export type ServerParts = { readonly resourceOptions: ServerResourceOptions; readonly workers: SessionWorkerControl; /** - * Runs `fn` in a root of the server's own (`SERVER`), not a caller's: lifecycle steps a caller giving up (an aborted - * call, a closed tab) must not cut off, and code outside Chord calls (Durable tools, routes). `signal` ends it early. + * Runs `fn` in a root of the server's own (`SERVER`) under `context`: code that acts as the server rather than as a + * method's caller. Inside a method pass `withoutAbortSignal(ctx)` (the call chain is kept; a caller giving up does not + * cut off lifecycle steps); outside Chord calls pass the context at hand. */ - readonly root: <T>(fn: (ctx: Ctx) => Promise<T>, signal?: AbortSignal) => Promise<T>; + readonly root: <T>(fn: (ctx: Ctx) => Promise<T>, context: Context) => Promise<T>; }; export const ServerNatives = defineRegistry<ServerParts, ServerParts | undefined>("p2.server-natives", { version: 1 }); @@ -250,12 +270,6 @@ export const Hosts = local("p2.hosts", { }, }); -const Workers = defineState("p2.hosts.workers", { - schema: obj({ workers: record(WorkerStateSchema) }), - version: 1, - initial: () => ({ workers: {} }), -}); - /** Serves the base keyed registries of the server (the core facet; test hosts without one). */ export function serveServerRegistries(facet: FacetHost): void { for (const registry of [ @@ -292,27 +306,10 @@ export function serverCore(host: Host, info: ServerInfo, parts: ServerParts): Fa }, })); facet.serve(Hosts, (_params, self) => { - const state = self.state(Workers); - self.onLive(async (ctx) => { - const stop = parts.workers.subscribe(({ session, ...worker }) => { - // An exit heard while this routine stops would change the state with an ended ctx. - if (signalOf(ctx).aborted) return; - void state - .change((draft) => { - // A late exit of an older generation never overwrites a newer worker. - if ((draft.workers[session]?.generation ?? 0) > worker.generation) return; - draft.workers[session] = worker; - }, ctx) - .catch(() => {}); - }); - try { - await untilAborted(ctx); - } finally { - stop(); - } - }); + // The workers component's state, followed: never copied into a document of this object. + const workers = self.follow(async (observer) => parts.workers.observe(observer)); return { - workers: self.derive([state], ([value]) => value.workers), + workers, stop: async ({ session }) => { await parts.workers.stop(session); return null; diff --git a/apps/p2/src/runtime/tui-context.ts b/apps/p2/src/runtime/tui-context.ts index 848cd2ca3..236570025 100644 --- a/apps/p2/src/runtime/tui-context.ts +++ b/apps/p2/src/runtime/tui-context.ts @@ -13,6 +13,7 @@ import type { PrivilegedResolution } from "../tui/keybindings.ts"; import type { RenderContext } from "../tui/render-context.ts"; import type { ThemeRef } from "../tui/theme/ref.ts"; import type { Host } from "./chord.ts"; +import type { TuiScope } from "./tui.ts"; import type { TuiCommand, TuiCommandConflict } from "./tui-commands.ts"; import type { TuiSettings } from "./tui-preferences.ts"; @@ -130,6 +131,12 @@ export interface TuiContext extends RenderContext { surface(name: string): Component | undefined; /** One status line (errors replace the runtime's error line until P2's status lines). */ notify(message: string, kind?: TuiNoticeKind): void; + /** Shows a failure (a refused call, a rolled-back guess) as an error status line. */ + fail(error: unknown): void; + /** Shows `action`'s failure, if it fails (an optimistic call's rejection is its caller's). */ + perform(action: Promise<unknown>): void; + /** A scope for an overlay or a command (references, optimistic guesses), disposed by its owner when it closes. */ + scope(): TuiScope; /** * Show an overlay on pi-tui's stack. While it is focused, commands whose `when` names `overlay` or `overlay:<id>` * run before the component (section 6.2 stage 3). Closed on navigation, reload and plugin replacement. diff --git a/apps/p2/src/runtime/tui.ts b/apps/p2/src/runtime/tui.ts index cf6f5d5fb..347c24116 100644 --- a/apps/p2/src/runtime/tui.ts +++ b/apps/p2/src/runtime/tui.ts @@ -1,5 +1,6 @@ -import { type Contract, type Held, isChordError, type ParamsOf } from "@earendil-works/chord"; +import type { Contract, Held, ParamsOf } from "@earendil-works/chord"; import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; +import { TuiScope as ChordTuiScope, type OptimisticAdd } from "@earendil-works/chord/tui"; import type { NativeWatchable, Watchable } from "@earendil-works/chord/watch"; import { type Component, @@ -19,6 +20,7 @@ import { formatKeyText, lineText } from "../tui/render-context.ts"; import { ThemeRef } from "../tui/theme/ref.ts"; import { BUILTIN_THEMES, resolveTheme, SYSTEM_THEME_NAME, type Theme } from "../tui/theme/theme.ts"; import { defineFacet, type FacetHost, type Host, keyedRegistry } from "./chord.ts"; +import type { P2Principal } from "./principals.ts"; import { follow, UI_LINGER } from "./refs.ts"; import { TuiAppearance } from "./tui-appearance.ts"; import { type TuiCommand, TuiCommandDispatcher, TuiCommands, TuiSlashSources } from "./tui-commands.ts"; @@ -60,86 +62,69 @@ export function tuiFacet( }); } -/** Ends that asking again cannot fix: a TuiScope stops re-holding after them. */ -const permanent = (error: unknown): boolean => - isChordError(error) && - (error.code === "forbidden" || error.code === "invalid" || error.code === "contract_mismatch"); +export type { OptimisticAdd } from "@earendil-works/chord/tui"; /** What `TuiScope.watch`'s bind follows: a member of the live reference (a state member, a derive). */ export type TuiFollow = <T>(source: Watchable<T> | NativeWatchable<T>, fn: (value: T) => void) => void; /** * Component-owned references: each is held until the scope is disposed (then released after its type's linger), its - * members followed from its first live binding; an ended one is held again after a backoff unless the end is a refusal. + * members followed from its first live binding. Chord rebinds a held reference by itself (a dropped link, a restarted + * worker, a reload), so the scope only reports what it cannot fix: a broken failure or an end. */ export class TuiScope { private stops: (() => void)[] = []; readonly runtime: TuiRuntime; + /** Chord's scope, for its optimistic readers: they redraw the runtime and stop with this scope. */ + readonly #optimistic: ChordTuiScope<P2Principal>; constructor(runtime: TuiRuntime) { this.runtime = runtime; + this.#optimistic = new ChordTuiScope(runtime.host, () => runtime.changed()); } - watch<C extends Contract>( - type: C, - params: ParamsOf<C>, - bind: (ref: Held<C>, follow: TuiFollow) => void, - ): () => Held<C> { - let ref!: Held<C>; - let disposed = false; - let timer: ReturnType<typeof setTimeout> | undefined; - /** Backoff for ends that are not refusals: 0.5 s doubling to 30 s, reset when live. */ - let delay = 500; - /** Each distinct failure is reported once per watch, not once per attempt. */ - const reported = new Set<string>(); + watch<C extends Contract>(type: C, params: ParamsOf<C>, bind: (ref: Held<C>, follow: TuiFollow) => void): Held<C> { + // Synchronous: the scope renders loading from the reference's state. + const ref = this.runtime.host.hold(type, params, BACKGROUND_CONTEXT); + let bound = false; const stops: (() => void)[] = []; - const report = (error: Error): void => { - if (reported.has(error.message)) return; - reported.add(error.message); - this.runtime.fail(error); - }; - const acquire = (): void => { - for (const stop of stops.splice(0)) stop(); - if (ref) UI_LINGER.release(ref, type.name); - // Synchronous: the scope renders loading from the reference's state. - const current = this.runtime.host.hold(type, params, BACKGROUND_CONTEXT); - ref = current; - let bound = false; - stops.push( - follow(current.state, (state) => { - if (disposed || current !== ref) return; - this.runtime.changed(); - if (state.kind === "live") { - delay = 500; - if (bound) return; - bound = true; - bind(current, (source, fn) => { - stops.push( - follow(source, (value) => { - fn(value); - this.runtime.changed(); - }), - ); - }); - } else if (state.kind === "failed" && state.cause.kind === "broken") report(state.cause.error); - else if (state.kind === "ended") { - report(state.error); - if (permanent(state.error)) return; - timer = setTimeout(acquire, delay); - delay = Math.min(30_000, delay * 2); - } - }), - ); - }; - acquire(); + stops.push( + follow(ref.state, (state) => { + this.runtime.changed(); + if (state.kind === "live" && !bound) { + bound = true; + bind(ref, (source, fn) => { + stops.push( + follow(source, (value) => { + fn(value); + this.runtime.changed(); + }), + ); + }); + } else if (state.kind === "failed" && state.cause.kind === "broken") this.runtime.fail(state.cause.error); + else if (state.kind === "ended") this.runtime.fail(state.error); + }), + ); this.stops.push(() => { - disposed = true; - clearTimeout(timer); for (const stop of stops) stop(); UI_LINGER.release(ref, type.name); }); - return () => ref; + return ref; + } + /** + * Optimistic UI in the terminal (docs/optimistic-ui.md): Chord's `TuiScope.optimistic`, React's `useOptimistic`, on + * this scope (redrawing the runtime). `add(action, call)` returns the call: its failure is the caller's to show + * (`runtime.perform`), and its action goes as a settled one's does. + */ + optimistic<T>(read: () => T): readonly [read: () => T, add: OptimisticAdd<T>, pending: () => boolean]; + optimistic<T, A>( + read: () => T, + reduce: (current: T, action: A) => T, + ): readonly [read: () => T, add: OptimisticAdd<A>, pending: () => boolean]; + optimistic<T, A>(read: () => T, reduce?: (current: T, action: A) => T) { + return reduce ? this.#optimistic.optimistic(read, reduce) : this.#optimistic.optimistic(read); } dispose(): void { for (const stop of this.stops.splice(0)) stop(); + this.#optimistic.dispose(); } } @@ -391,6 +376,9 @@ export class TuiRuntime implements Component, Focusable { }, surface: (name) => runtime.surfaces.get(name), notify: (message, kind) => runtime.notify(message, kind), + fail: (error) => runtime.fail(error), + perform: (action) => runtime.perform(action), + scope: () => new TuiScope(runtime), overlay: (id, component, options) => runtime.overlay(id, component, options), panel: { open: (id, content, title) => runtime.openPanel(id, content, title), diff --git a/apps/p2/src/runtime/web.tsx b/apps/p2/src/runtime/web.tsx index ec79aad95..2c74b0de8 100644 --- a/apps/p2/src/runtime/web.tsx +++ b/apps/p2/src/runtime/web.tsx @@ -1,6 +1,6 @@ // Browser runtime: the client host in React, slots that plugins fill, object hooks, connection state, navigation. -import { type Contract, type Held, isChordError, type ParamsOf } from "@earendil-works/chord"; +import type { Contract, Held, ParamsOf } from "@earendil-works/chord"; import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import type { NativeWatchable, Watchable } from "@earendil-works/chord/watch"; import { @@ -9,9 +9,11 @@ import { useCallback, useContext, useEffect, + useOptimistic, useRef, useState, useSyncExternalStore, + useTransition, } from "react"; import { createPortal } from "react-dom"; import { clearKept } from "../web/conversation-cache.ts"; @@ -37,10 +39,10 @@ export { useScrollMemory, } from "../web/navigation.ts"; -import { Access } from "./access.ts"; +import { Access, grantMode } from "./access.ts"; import type { KeyedRegistry } from "./chord.ts"; import { canonicalJson } from "./json.ts"; -import { follow, followRegistry, type HeldIn, type HeldRef, knownRegistry, statusOf, UI_LINGER } from "./refs.ts"; +import { follow, followRegistry, type HeldIn, type HeldRef, knownRegistry, liveIn, UI_LINGER } from "./refs.ts"; import { Slots } from "./web-core.ts"; export { Slots, webCoreFacet } from "./web-core.ts"; @@ -151,18 +153,13 @@ export function Sheet({ ); } -/** Ends that asking again cannot change: a `useObject` reference stays ended after them. */ -const refused = (error: unknown): boolean => - isChordError(error) && - (error.code === "forbidden" || error.code === "invalid" || error.code === "contract_mismatch"); - /** * Holds `type(params)` while mounted (on the host its contract places it), synchronously (`host.hold`): it may still be - * loading, and its state reads as `undefined` in `useReadable` until it is live. Different params or another host - * release it and hold anew; an unmount releases it after its type's linger (`UI_LINGER`: a conversation stays held a - * while, so switching back is instant). A reference that ends but not by a refusal (archive, a revoked grant renewed) - * is replaced by a fresh one after a short delay. `undefined` when `params` is undefined. With `variant`, `undefined` - * unless the reference is live in that variant (and typed as it). + * loading, and its state reads as `undefined` in `useReadable` until it is live. Chord rebinds it by itself (a dropped + * link, a restarted worker, a reload). Different params or another host release it and hold anew; an unmount releases + * it after its type's linger (`UI_LINGER`: a conversation stays held a while, so switching back is instant). + * `undefined` when `params` is undefined. With `variant`, `undefined` unless the reference is live in that variant (and + * typed as it). */ export function useObject<C extends Contract>(type: C, params: ParamsOf<C> | undefined): Held<C> | undefined; export function useObject<C extends Contract, const V extends VariantOf<C>>( @@ -181,44 +178,53 @@ export function useObject<C extends Contract>( key: undefined, ref: undefined, }); - const [, setTick] = useState(0); - const variant = options.variant; // biome-ignore lint/correctness/useExhaustiveDependencies: key includes params by value; inline params objects must not release/reacquire the reference every render. useEffect(() => { if (key === undefined || params === undefined) { setState({ key, ref: undefined }); return; } - let current = true; - let ref: Held<C> | undefined; - let timer: ReturnType<typeof setTimeout> | undefined; - let stop = (): void => {}; - const acquire = (): void => { - stop(); - if (ref) UI_LINGER.release(ref, type.name); - const acquired = host.hold(type, params, BACKGROUND_CONTEXT); - ref = acquired; - setState({ key, ref: acquired }); - stop = follow(acquired.state, (next) => { - if (!current || acquired !== ref) return; - setTick((tick) => tick + 1); - if (next.kind === "ended" && !refused(next.error)) timer = setTimeout(() => current && acquire(), 500); - }); - }; - acquire(); - return () => { - current = false; - clearTimeout(timer); - stop(); - if (ref) UI_LINGER.release(ref, type.name); - }; + const ref = host.hold(type, params, BACKGROUND_CONTEXT); + setState({ key, ref }); + return () => UI_LINGER.release(ref, type.name); }, [host, type, key]); const ref = state.key === key ? state.ref : undefined; - if (variant !== undefined && (ref === undefined || statusOf(ref) !== "live" || ref.variant !== variant)) + // Re-renders when the reference goes live, fails or ends: `variant` and `liveIn` read its state. + const status = useStatus(ref); + if (options.variant !== undefined && (ref === undefined || status !== "live" || ref.variant !== options.variant)) return undefined; return ref; } +/** + * Optimistic UI (docs/optimistic-ui.md): `value` as shown with the guesses of this component's calls in flight, with + * React's `useOptimistic`. `run(guess, call)` shows `apply(value, guess)` at once and keeps it until `call` settles. + * Chord applies the state the answering host committed before it sends the answer, so the real value has replaced + * the guess when it goes; a call that fails drops its guess (the value rolls back) and `onError` says why. `pending`: + * a call is in flight (long work shows that instead of a guess). + */ +export function useOptimisticCall<T, G>( + value: T, + apply: (value: T, guess: G) => T, + onError: (error: unknown) => void, +): readonly [shown: T, run: (guess: G, call: () => Promise<unknown>) => void, pending: boolean] { + const [shown, add] = useOptimistic(value, apply); + const [pending, start] = useTransition(); + const run = useCallback( + (guess: G, call: () => Promise<unknown>) => + start(async () => { + add(guess); + try { + await call(); + } catch (error) { + onError(error); + } + }), + [add, onError], + ); + return [shown, run, pending] as const; +} + /** * The value of a watchable (a reference's state member, a derive), re-rendering on change. `undefined` while it cannot * be read (its reference is still loading, the member is outside the variant, the reference ended): nothing throws @@ -396,6 +402,16 @@ export function useOwner(): boolean { return owner; } +/** + * Whether this browser may write in `session` (send, stop, the workspace): the owner as last known (`useOwner`, so no + * wait for `Access`), or a guest whose grant gives it full access there. + */ +export function useCanWrite(session: string | undefined): boolean { + const owner = useOwner(); + const grant = useReadable(liveIn(useObject(Access, {}), "guest")?.grant); + return owner || grantMode(grant, session) === "full"; +} + /** Forgets what this browser keeps for the owner: signing out. The kept app (public/sw.js) goes too. */ export async function forgetOwner(): Promise<void> { ownerKnown = false; @@ -409,6 +425,9 @@ export async function forgetOwner(): Promise<void> { } catch {} } +/** A failure's message as the UI shows it (a ChordError's message, a thrown string). */ +export const errorText = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + /** A home directory shown as `~` (Termux and Linux homes). */ export function shortPath(path: string): string { return path.replace(/^\/data\/data\/com\.termux\/files\/home/, "~").replace(/^\/home\/[^/]+/, "~"); diff --git a/apps/p2/src/runtime/worker.ts b/apps/p2/src/runtime/worker.ts index 84d1773aa..0382db37c 100644 --- a/apps/p2/src/runtime/worker.ts +++ b/apps/p2/src/runtime/worker.ts @@ -278,10 +278,10 @@ export type WorkerParts = { readonly liveness: Liveness; readonly location: SessionLocation; /** - * Runs `fn` in a root of the worker's own (`HOST`): for code outside Chord calls (Durable tools, sections). `signal` - * ends it early. + * Runs `fn` in a root of the worker's own (`HOST`) under `context`: for code outside Chord calls (a Durable tool or + * section passes its Durable context), or a method acting as the worker (`withoutAbortSignal(ctx)`). */ - readonly root: <T>(fn: (ctx: Ctx) => Promise<T>, signal?: AbortSignal) => Promise<T>; + readonly root: <T>(fn: (ctx: Ctx) => Promise<T>, context: Context) => Promise<T>; }; /** @@ -649,7 +649,7 @@ function openHarness(self: Self, init: WorkerInit, location: SessionLocation): H (await placements.value.get("subagents")?.place(harness, at, values, context)) ?? { followers: [], own: [] }; let reader: DocumentReader | undefined; const execution: Native<Execution> = self.open( - async (ctx) => { + async () => { const created = new Execution(location.value, open, async (read, id, context) => { const documents = read ?? reader; if (!documents) throw new Error("The session's Harness is not open"); @@ -661,7 +661,6 @@ function openHarness(self: Self, init: WorkerInit, location: SessionLocation): H await created.close(); throw error; } - void ctx; return created; }, (created) => created.close(), diff --git a/apps/p2/src/web/AppLayout.tsx b/apps/p2/src/web/AppLayout.tsx index edc83747a..fe561c3df 100644 --- a/apps/p2/src/web/AppLayout.tsx +++ b/apps/p2/src/web/AppLayout.tsx @@ -1,8 +1,5 @@ import { useRef, useState } from "react"; -import { Access, grantMode } from "../runtime/access.ts"; - -import { liveIn } from "../runtime/refs.ts"; -import { closeLayers, Slot, useDrawer, useObject, useReadable, useRoute, useStatus } from "../runtime/web.tsx"; +import { closeLayers, Slot, useCanWrite, useDrawer, useRoute } from "../runtime/web.tsx"; import { useComposerState } from "./composer-state.tsx"; import { useDrawerSwipe } from "./drawer-swipe.ts"; import { OwnerNotices } from "./OwnerNotices.tsx"; @@ -12,10 +9,7 @@ export function AppLayout() { const drawer = useDrawer(), route = useRoute(), collapsed = useSidebarCollapsed(); - const access = useObject(Access, {}), - guest = useReadable(liveIn(access, "guest")?.grant); - const canWrite = - (useStatus(access) === "live" && access?.variant === "owner") || grantMode(guest, route.session) === "full"; + const canWrite = useCanWrite(route.session); const { addFiles } = useComposerState(); const [draggingFiles, setDraggingFiles] = useState(false); const dragDepth = useRef(0); diff --git a/apps/p2/src/web/Drawer.tsx b/apps/p2/src/web/Drawer.tsx index f3697dc66..796420b4e 100644 --- a/apps/p2/src/web/Drawer.tsx +++ b/apps/p2/src/web/Drawer.tsx @@ -8,6 +8,8 @@ export type SessionSummary = Session & { subagents: Child[]; /** `unread`: a finished answer the owner has not read on any device (see `SessionReads`). */ worker: { busy: number[]; error?: string; unread?: boolean }; + /** A lifecycle operation of the session: running (`label` only), or failed with its error and a Dismiss. */ + operation?: { readonly label: string; readonly error?: string; readonly dismiss?: () => void }; }; import { requestReveal } from "./Chat.tsx"; @@ -225,6 +227,19 @@ function SessionRow({ </button> )} </div> + {session.operation && ( + <div className={`session-operation ${session.operation.error === undefined ? "" : "failed"}`}> + <span title={session.operation.error}> + {session.operation.label} + {session.operation.error === undefined ? "" : `: ${session.operation.error}`} + </span> + {session.operation.dismiss && ( + <button type="button" className="text-action" onClick={session.operation.dismiss}> + Dismiss + </button> + )} + </div> + )} {branches && ( <BranchRows session={session} @@ -264,7 +279,6 @@ function SessionRow({ export const Drawer = memo(function Drawer({ onSearch, onRestore, - onError, footer, sessions, activeId, @@ -282,8 +296,8 @@ export const Drawer = memo(function Drawer({ guest, }: { onSearch?: (query: string, archived: boolean) => Promise<SearchResults>; - onRestore: (id: string) => Promise<unknown>; - onError: (error: string) => void; + /** Restores an archived session and opens it (at `conversationId`, a message hit's). */ + onRestore: (id: string, conversationId?: number) => void; footer?: ReactNode; sessions: SessionSummary[]; activeId?: string; @@ -340,10 +354,6 @@ export const Drawer = memo(function Drawer({ const ordered = [...groups].sort(([a], [b]) => (latest.get(b) ?? 0) - (latest.get(a) ?? 0)); for (const [, list] of ordered) list.sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.updatedAt - a.updatedAt); - const restore = (id: string, conversationId?: number) => - void onRestore(id) - .then(() => onOpen(id, conversationId)) - .catch((error: Error) => onError(error.message)); if (guest) return ( <nav className="drawer-content" aria-label="Shared session"> @@ -450,7 +460,7 @@ export const Drawer = memo(function Drawer({ <button type="button" className={`session-main ${session.id === activeId && conversationId === session.main ? "active" : ""}`} - onClick={() => (archived ? restore(session.id) : onOpen(session.id))} + onClick={() => (archived ? onRestore(session.id) : onOpen(session.id))} > <span className="session-title"> {session.pinned && <PinIcon size={12} />} @@ -482,7 +492,7 @@ export const Drawer = memo(function Drawer({ // p2 indexes subagent messages too: a hit opens the conversation it was found in, at the message. onClick={() => { requestReveal(hit.session, hit.conversationId, hit.entryId); - if (archived) restore(hit.session, hit.conversationId); + if (archived) onRestore(hit.session, hit.conversationId); else onOpen(hit.session, hit.conversationId); }} > diff --git a/apps/p2/src/web/composer-state.tsx b/apps/p2/src/web/composer-state.tsx index 44e03141e..bea18cdb9 100644 --- a/apps/p2/src/web/composer-state.tsx +++ b/apps/p2/src/web/composer-state.tsx @@ -4,12 +4,13 @@ import { type Dispatch, type ReactNode, type SetStateAction, + useCallback, useContext, useEffect, useRef, useState, } from "react"; -import { useRoute } from "../runtime/web.tsx"; +import { errorText, useRoute } from "../runtime/web.tsx"; export type PickedFile = { key: string; file: File; preview?: string; uploaded?: string; session?: string }; /** The message the next send answers: an entry of the current conversation, and whose it is (for display only). */ export type ReplyTarget = { entryId: number; name: string }; @@ -22,6 +23,8 @@ type State = { setSteer: Dispatch<SetStateAction<boolean>>; error: string | undefined; setError: Dispatch<SetStateAction<string | undefined>>; + /** Shows a failed call's error in the app's toast (an optimistic guess rolled back, an action refused). */ + fail: (error: unknown) => void; stopped: { sessionId: string; conversationId: number } | undefined; setStopped: Dispatch<SetStateAction<{ sessionId: string; conversationId: number } | undefined>>; viewer: string; @@ -42,6 +45,7 @@ export function ComposerState({ children }: { children: ReactNode }) { const [files, setFiles] = useState<PickedFile[]>([]); const [steer, setSteer] = useState(true); const [error, setError] = useState<string>(); + const fail = useCallback((failure: unknown) => setError(errorText(failure)), []); const [stopped, setStopped] = useState<State["stopped"]>(); const viewer = useRef(crypto.randomUUID()); const [replyTo, setReplyTo] = useState<ReplyTarget>(); @@ -95,6 +99,7 @@ export function ComposerState({ children }: { children: ReactNode }) { setSteer, error, setError, + fail, stopped, setStopped, viewer: viewer.current, diff --git a/apps/p2/src/web/history.ts b/apps/p2/src/web/history.ts index 04dbca26a..a7ccea1d4 100644 --- a/apps/p2/src/web/history.ts +++ b/apps/p2/src/web/history.ts @@ -292,10 +292,13 @@ export function pagesWatchable(store: TranscriptStore): NativeWatchable<Pages | return { kind }; }; context.abortSignal?.addEventListener("abort", () => end("aborted"), { once: true }); + const value = store.value; return { - value: store.value, + value, start: (next) => { listener = next; + // A change between the watch and its start (the view arriving meanwhile) is not lost. + if (store.value !== value) void next({ kind: "value", value: store.value }, context); }, stop: async () => end("stopped"), closed: closed.promise, diff --git a/apps/p2/src/web/navigation.ts b/apps/p2/src/web/navigation.ts index b8eac3fbd..d6249b917 100644 --- a/apps/p2/src/web/navigation.ts +++ b/apps/p2/src/web/navigation.ts @@ -191,6 +191,33 @@ export function forgetLastConversation(): void { } catch {} } +/** Sessions whose main conversation is not 1 (a `/tree` branch), as the catalog last said, for short links. */ +const MAINS_STORAGE = "p2-mains"; + +/** Remembers the main conversations of `rows` that are not 1 (the Sidebar writes them as the catalog changes). */ +export function rememberMains(rows: readonly { readonly id: string; readonly main: number }[]): void { + try { + localStorage.setItem( + MAINS_STORAGE, + JSON.stringify(Object.fromEntries(rows.filter((row) => row.main !== 1).map((row) => [row.id, row.main]))), + ); + } catch {} +} + +/** + * The main conversation a short link (`/s/<id>`, conversation 1) opens: the session's `/tree` branch as last known + * on this device, so the page holds that conversation first instead of conversation 1 and a redirect. + */ +export function knownMain(session: string): number { + try { + const mains = JSON.parse(localStorage.getItem(MAINS_STORAGE) ?? "{}") as Record<string, unknown>; + const main = mains[session]; + return typeof main === "number" ? main : 1; + } catch { + return 1; + } +} + /** Whether the page runs as the installed app (a home-screen app), not in a browser tab. */ function installed(): boolean { return ( diff --git a/apps/p2/src/web/style.css b/apps/p2/src/web/style.css index d83bcd44e..a9907904f 100644 --- a/apps/p2/src/web/style.css +++ b/apps/p2/src/web/style.css @@ -2877,6 +2877,23 @@ button.diff-number:hover { color: var(--faint); font-size: var(--fs-xs); } +/* A lifecycle operation of the session (a move, a copy, an archive) running, or failed with Dismiss. */ +.session-operation { + display: flex; + align-items: center; + gap: var(--s1); + padding: 0 0 var(--s1); + color: var(--muted); + font-size: var(--fs-xs); +} +.session-operation.failed { + color: var(--error); +} +.session-operation > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .session-header { display: flex; align-items: stretch; diff --git a/apps/p2/test/fixtures/optimistic.ts b/apps/p2/test/fixtures/optimistic.ts new file mode 100644 index 000000000..8267c8194 --- /dev/null +++ b/apps/p2/test/fixtures/optimistic.ts @@ -0,0 +1,54 @@ +// Browser-only component fixture, bundled by `Browser.loadFixture`: a switch whose value is "server state" changed only +// by its call, shown through `useOptimisticCall`. The test settles each call from outside: `pass()` commits the new value +// before the call resolves (as Chord applies the answering host's state before its answer), `refuse(message)` rejects. +import { createElement, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { errorText, useOptimisticCall } from "../../src/runtime/web.tsx"; + +type Settle = { pass: () => void; refuse: (message: string) => void }; +const calls: Settle[] = []; + +function Switch() { + const [server, setServer] = useState(false); + const [error, setError] = useState<string>(); + const [shown, run, pending] = useOptimisticCall( + server, + (_, next: boolean) => next, + (reason) => setError(errorText(reason)), + ); + const toggle = () => { + const next = !shown; + setError(undefined); + run( + next, + () => + new Promise<void>((resolve, reject) => + calls.push({ + pass: () => { + setServer(next); + resolve(); + }, + refuse: (message) => reject(new Error(message)), + }), + ), + ); + }; + return createElement( + "div", + null, + createElement("button", { id: "switch", type: "button", onClick: toggle }, shown ? "on" : "off"), + createElement("span", { id: "pending" }, pending ? "pending" : "idle"), + createElement("span", { id: "error" }, error ?? ""), + ); +} + +const element = document.createElement("div"); +document.body.replaceChildren(element); +createRoot(element).render(createElement(Switch)); +Object.assign(window, { + settleCall: (outcome: { pass: true } | { refuse: string }) => { + const call = calls.shift(); + if (call && "pass" in outcome) call.pass(); + else call?.refuse("refuse" in outcome ? outcome.refuse : ""); + }, +}); diff --git a/apps/p2/test/p2/chat-contracts.test.ts b/apps/p2/test/p2/chat-contracts.test.ts index 434d14b3a..38ff3af3e 100644 --- a/apps/p2/test/p2/chat-contracts.test.ts +++ b/apps/p2/test/p2/chat-contracts.test.ts @@ -162,14 +162,14 @@ test("enabledModels: patterns resolve to the scope; the owner saves them to sett const ref = fixture.browser().hold(Models, {}, BACKGROUND_CONTEXT); await whenLive(ref); await ref.setEnabledModels({ patterns: ["faux-*:low", " "] }, BACKGROUND_CONTEXT); - const scoped = await until(ref.scopedModels, (value) => Boolean(value?.length)); - assert.deepEqual(scoped, [{ provider: "faux", modelId: "faux-1", thinkingLevel: "low" }]); - assert.deepEqual(await until(ref.enabledModels, (value) => Boolean(value?.length)), ["faux-*:low"]); + // The answer follows its state: the page holds the new scope when the call resolves. + assert.deepEqual(ref.scopedModels.value, [{ provider: "faux", modelId: "faux-1", thinkingLevel: "low" }]); + assert.deepEqual(ref.enabledModels.value, ["faux-*:low"]); assert.deepEqual(JSON.parse(readFileSync(join(fixture.dataDir, "settings.json"), "utf8")).enabledModels, [ "faux-*:low", ]); await ref.setEnabledModels({ patterns: [] }, BACKGROUND_CONTEXT); - await until(ref.scopedModels, (value) => value?.length === 0); + assert.deepEqual(ref.scopedModels.value, []); assert(!("enabledModels" in JSON.parse(readFileSync(join(fixture.dataDir, "settings.json"), "utf8")))); }); diff --git a/apps/p2/test/p2/chat-mode-host.ts b/apps/p2/test/p2/chat-mode-host.ts index e238d0405..7b28d4bac 100644 --- a/apps/p2/test/p2/chat-mode-host.ts +++ b/apps/p2/test/p2/chat-mode-host.ts @@ -10,6 +10,7 @@ import chatModeWorker from "../../plugins/chat-mode/worker.ts"; import { Attachments } from "../../plugins/files/contract.ts"; import { WorkerModels } from "../../plugins/models/contract.ts"; import { createP2Models } from "../../plugins/models/models.ts"; +import { StatusReport } from "../../plugins/status/contract.ts"; import { type Facet, Session } from "../../src/runtime/chord.ts"; import { durableContext } from "../../src/runtime/durable.ts"; import type { HarnessParts } from "../../src/runtime/worker.ts"; @@ -42,6 +43,8 @@ export async function chatModeHost(options: { facet?: Facet; directory?: string ], stubs: [ [Attachments, { resolve: async ({ ids }: { ids: string[] }) => ids.map(fakeUpload) }], + // No server here: the worker's status pushes go nowhere. + [StatusReport, { push: async () => null }], // No settings.json: the owner's entries carry the default name. [WorkerModels, (_input, _principal, self) => ({ settings: self.state({}) })], ], diff --git a/apps/p2/test/p2/chat-mode.test.ts b/apps/p2/test/p2/chat-mode.test.ts index 12766f917..6c06d1202 100644 --- a/apps/p2/test/p2/chat-mode.test.ts +++ b/apps/p2/test/p2/chat-mode.test.ts @@ -6,7 +6,13 @@ import { describe, it } from "node:test"; import type { ConversationView, EntryRecord, InboxState } from "@earendil-works/pi-durable"; import { BACKGROUND_CONTEXT, whenLive } from "@p2/app/v1"; import { Chat } from "../../plugins/chat/contract.ts"; -import { type ChatMessage, ChatMode, mentionsAgent, mentionToken } from "../../plugins/chat-mode/contract.ts"; +import { + type ChatMessage, + ChatMode, + ChatModeSetting, + mentionsAgent, + mentionToken, +} from "../../plugins/chat-mode/contract.ts"; import { type ChatEntry, composeMention, @@ -251,6 +257,18 @@ describe("chat-mode on a bare session host", () => { await expectChord(whenLive(stranger), "forbidden"); }); + it("shows a switch or a rename once the call returns: the worker follows the document, it copies nothing", async (t) => { + const fixture = await chatModeHost(); + t.after(() => fixture.close()); + const setting = await fixture.host.get(ChatModeSetting, { conversation: 1 }, BACKGROUND_CONTEXT); + const owner = await holdIn(fixture.client(OWNER), ChatMode, where, "owner"); + assert.equal(setting.enabled.value, false); + await owner.set({ enabled: true }, BACKGROUND_CONTEXT); + assert.equal(setting.enabled.value, true, "committed and followed before the answer"); + await owner.rename({ name: "Claude" }, BACKGROUND_CONTEXT); + assert.equal(setting.name.value, "Claude"); + }); + it("lets only the owner rename the agent; mentions then use the new name", async (t) => { const fixture = await chatModeHost(); t.after(() => fixture.close()); diff --git a/apps/p2/test/p2/contract-versions.test.ts b/apps/p2/test/p2/contract-versions.test.ts index ce8d9c6bc..6e1a2b8ae 100644 --- a/apps/p2/test/p2/contract-versions.test.ts +++ b/apps/p2/test/p2/contract-versions.test.ts @@ -1,8 +1,8 @@ // 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. +// (`Sessions` v3 and `Catalog` v2 from 0a730c8a7, `Sessions` v4, `Catalog` v3 and `SessionTree` v1 from e88525506, +// `Sessions` v5 from 9ff0ebfd0), so their fingerprints are the ones old bundles send. import assert from "node:assert/strict"; import { after, before, describe, it } from "node:test"; @@ -14,6 +14,7 @@ import { ModelRefSchema, method, param, + propertiesOf, remote, serverHost, sessionHost, @@ -127,6 +128,44 @@ const SessionsV4 = remote("p2.sessions", { }, }); +/** 9ff0ebfd0's `BranchSchema` and `SessionSchema`: a branch label may be null. */ +const BranchSchemaV5 = obj({ conversation: int({ minimum: 1 }), label: union([str(), nul()]) }); +const SessionSchemaV5 = obj({ ...propertiesOf(SessionSchemaV4), branches: arr(BranchSchemaV5) }); + +/** `Sessions` as 9ff0ebfd0 declared it (before client-chosen IDs and lifecycle operations). */ +const SessionsV5 = remote("p2.sessions", { + version: 5, + params: {}, + hostAddress: serverHost, + variants: { + owner: { + state: { sessions: arr(SessionSchemaV5), defaultCwd: str(), defaultEnv: union([str(), nul()]) }, + methods: { + create: method(CreateSchema, SessionSchemaV5), + 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, SessionSchemaV5), + fork: method( + obj({ id: str(), conversation: int({ minimum: 1 }), before: int({ minimum: 1 }) }), + SessionSchemaV5, + ), + }, + }, + guest: { state: { sessions: arr(SessionSchemaV5) } }, + worker: { + state: { session: union([SessionSchemaV5, 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(BranchSchemaV5) })), + }, + }, + }, +}); + /** `Catalog` as 0a730c8a7 declared it. */ const CatalogV2 = local("p2.catalog", { version: 2, @@ -264,6 +303,15 @@ describe("older contract versions against the current server (fake provider)", ( await fixture?.close(); }); + it("binds Sessions v5: rows as they are, create answered with its row", async () => { + const ref = await boundIn(owner, SessionsV5, {}, "owner"); + const rows = await until(ref.sessions, (list) => list.some((row) => row.id === session.id)); + assert.equal(rows.find((each) => each.id === session.id)?.main, branch); + const created = await ref.create({ cwd: fixture.workspace, title: "From a v5 tab" }, BACKGROUND_CONTEXT); + assert.equal(created.title, "From a v5 tab"); + ref.release(); + }); + 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)); diff --git a/apps/p2/test/p2/drafts.test.ts b/apps/p2/test/p2/drafts.test.ts index fcc4ae7e4..c4b9ac7fc 100644 --- a/apps/p2/test/p2/drafts.test.ts +++ b/apps/p2/test/p2/drafts.test.ts @@ -50,6 +50,17 @@ describe("drafts store", () => { assert.deepEqual(await store.claim({ conversation: 1, authorId: "alice", ids }, ctx), { texts: ["a1", "a2"] }); // A second tab claiming the same drafts gets nothing. assert.deepEqual(await store.claim({ conversation: 1, authorId: "alice", ids }, ctx), { texts: [] }); + // Two tabs claiming at once: each draft goes to one of them. + await store.add({ conversation: 3, items: [{ author: alice, text: "once" }] }, ctx); + const raced = (await settled(store.drafts))["3:alice"]?.map((item) => item.id) ?? []; + const claims = await Promise.all([ + store.claim({ conversation: 3, authorId: "alice", ids: raced }, ctx), + store.claim({ conversation: 3, authorId: "alice", ids: raced }, ctx), + ]); + assert.deepEqual( + claims.flatMap((claim) => claim.texts), + ["once"], + ); assert.equal(store.drafts.value?.["1:alice"], undefined); assert.deepEqual( store.drafts.value?.["2:alice"]?.map((item) => item.text), diff --git a/apps/p2/test/p2/envs.test.ts b/apps/p2/test/p2/envs.test.ts index 3debf51c5..21b6b3d1a 100644 --- a/apps/p2/test/p2/envs.test.ts +++ b/apps/p2/test/p2/envs.test.ts @@ -110,7 +110,8 @@ test( const browser = fixture.browser(); const envs = browser.hold(Envs, {}, BACKGROUND_CONTEXT); await envs.save({ name: "gpu", config }, BACKGROUND_CONTEXT); - await until(envs.envs, (entries) => entries?.[0]?.name === "gpu"); + // The answer follows its state: the saved environment is listed when the call resolves. + assert.equal(envs.envs.value[0]?.name, "gpu"); await writeFile(join(fixture.dataDir, "envs.json"), JSON.stringify({ gpu: { ...config, host: "changed" } })); await until(envs.envs, (entries) => entries?.[0]?.config.host === "changed"); await writeFile(join(fixture.dataDir, "envs.json"), "{"); @@ -293,6 +294,7 @@ test( ); assert.equal((await stat(uploadedKey.identityFile)).mode & 0o777, 0o600); await envs.delete({ name: "copy" }, BACKGROUND_CONTEXT); + assert(!envs.envs.value.some((entry) => entry.name === "copy"), "the deleted environment is gone at the answer"); const sessions = await holdIn(owner, Sessions, {}, "owner"); const session = await sessions.create( { env: "box", cwd: "~/project", createDirectory: true }, diff --git a/apps/p2/test/p2/fork-clone.test.ts b/apps/p2/test/p2/fork-clone.test.ts index d5c0b7bb0..0066c0748 100644 --- a/apps/p2/test/p2/fork-clone.test.ts +++ b/apps/p2/test/p2/fork-clone.test.ts @@ -2,6 +2,7 @@ // fake provider: copies under new IDs, history, artifacts, and the refusals. import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; import { after, before, describe, it } from "node:test"; import type { ConversationView } from "@earendil-works/pi-durable"; import { BACKGROUND_CONTEXT } from "@p2/app/v1"; @@ -55,10 +56,21 @@ describe("clone and fork (fake provider)", () => { return ref; }; - it("clones the whole session under a new ID; the original goes on", async () => { + it("clones the whole session under a new ID chosen by the client; the original goes on", async () => { const ref = await ownerSessions(); - const copy = await ref.clone({ id: source.id }, BACKGROUND_CONTEXT); - assert.notEqual(copy.id, source.id); + const chosen = randomUUID(); + // The page navigates to the copy before the answer: its worker waits until the copy is done. + const early = holdIn(owner, Chat, { session: chosen, conversation: 1 }, "participant"); + const copy = await ref.clone({ id: source.id, copy: chosen }, BACKGROUND_CONTEXT); + assert.equal(copy.id, chosen); + assert.deepEqual( + await ref.clone({ id: source.id, copy: chosen }, BACKGROUND_CONTEXT), + copy, + "a retry answers the same row", + ); + const waiting = await early; + await until(waiting.view, (next) => text(next).includes("Echo: second")); + waiting.release(); assert.equal(copy.title, "Parser work (copy)"); assert.equal(copy.cwd, source.cwd); assert.deepEqual(copy.model, source.model); @@ -92,6 +104,7 @@ describe("clone and fork (fake provider)", () => { const fork = await ref.fork({ id: source.id, conversation: 1, before: second.id }, BACKGROUND_CONTEXT); assert.equal(fork.title, "Parser work (fork)"); const forked = await chatOf(fork.id); + const forkView = await until(forked.view, (next) => text(next).includes("Echo: first")); assert.doesNotMatch(text(forkView), /Echo: second|second/); assert.equal(inputs(forkView).length, 2, "the inputs before the chosen one"); @@ -119,7 +132,18 @@ describe("clone and fork (fake provider)", () => { BACKGROUND_CONTEXT, ); await until(original.liveShell, (value) => Boolean(value?.running)); - await assert.rejects(ref.clone({ id: source.id }, BACKGROUND_CONTEXT), /before cloning/); + // The clone's row is there at once; the busy worker refuses the copy: the operation fails visibly and the row is + // archived again (rolled back). + const refused = await ref.clone({ id: source.id }, BACKGROUND_CONTEXT); + const operations = await until(ref.operations, (list) => + (list ?? []).some((each) => each.target === refused.id && each.phase === "failed"), + ); + const failure = operations!.find((each) => each.target === refused.id); + assert(failure?.phase === "failed"); + assert.match(failure.error, /before cloning/); + await until(ref.sessions, (rows) => rows?.find((row) => row.id === refused.id)?.archivedAt !== null); + await ref.dismissOperation({ operation: failure.id }, BACKGROUND_CONTEXT); + await until(ref.operations, (list) => !(list ?? []).some((each) => each.id === failure.id)); await original.abortShell({ shellId }, BACKGROUND_CONTEXT); await until(original.liveShell, (value) => value === null); forked.release(); diff --git a/apps/p2/test/p2/helpers.ts b/apps/p2/test/p2/helpers.ts index 765484350..a025b5609 100644 --- a/apps/p2/test/p2/helpers.ts +++ b/apps/p2/test/p2/helpers.ts @@ -17,7 +17,7 @@ import { type WebSocketLike, webSocketLink, } from "@earendil-works/chord"; -import { BACKGROUND_CONTEXT, withAbortSignal } from "@earendil-works/chord/context"; +import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import type { NativeWatchable, Watchable } from "@earendil-works/chord/watch"; import { WebSocket as NodeWebSocket } from "ws"; import type { WebManifest } from "../../src/hosts/bundles.ts"; @@ -308,10 +308,12 @@ const NO_WORKERS: SessionWorkerControl = { pause: async () => { throw new Error("no workers in a test host"); }, - retry: async () => {}, + retry: async () => ({ running: false, generation: 0, error: "no workers in a test host" }), + resume: async () => {}, stop: async () => {}, suspend: async () => {}, subscribe: () => () => {}, + observe: () => ({ value: {}, detach: () => {} }), }; /** @@ -333,9 +335,9 @@ export function workerFacet(parts: { init: {} as WorkerParts["init"], liveness: new Liveness(), location: new SessionLocation({ cwd: "/", env: null }), - root: (fn, signal) => { + root: (fn, context) => { if (!parts.host) throw new Error("no root in this test worker"); - return parts.host().root(fn, signal ? withAbortSignal(signal, TEST) : TEST); + return parts.host().root(fn, context); }, ...parts.worker, }); @@ -386,7 +388,7 @@ export async function testHost(options: { self.contribute(ServerNatives, { resourceOptions: {}, workers: NO_WORKERS, - root: (fn, signal) => host.root(fn, signal ? withAbortSignal(signal, TEST) : TEST), + root: (fn, context) => host.root(fn, context), ...options.server, }); } diff --git a/apps/p2/test/p2/lifecycle.test.ts b/apps/p2/test/p2/lifecycle.test.ts index 99e6c8abf..9666a430a 100644 --- a/apps/p2/test/p2/lifecycle.test.ts +++ b/apps/p2/test/p2/lifecycle.test.ts @@ -49,6 +49,10 @@ describe("worker lifecycle (fake provider)", () => { const statuses = await until(status.statuses, (value) => Boolean(value?.[session.id]?.error)); assert.match(statuses?.[session.id]?.error ?? "", /Unknown model/); chat.release(); + // Retry answers with the outcome: the start fails again, so does the call (with why), even with nobody holding. + const control = await holdIn(owner, WorkerControl, { session: session.id }, "owner"); + await assert.rejects(control.restart(BACKGROUND_CONTEXT), /Unknown model/); + control.release(); }); it("records an unrequested exit as a notice and restarts the worker for its holders", async () => { @@ -104,8 +108,9 @@ describe("worker lifecycle (fake provider)", () => { until(status.statuses, (value) => !value?.[session.id]?.error, 1_500), /timed out/, ); - // The owner's Retry starts it again, and clears the count. + // The owner's Retry starts it again, and clears the count: it answers once the new worker runs. await control.restart(BACKGROUND_CONTEXT); + assert.equal(hosts.workers.value[session.id]?.running, true, "running when Retry answered"); pid = await running(pid); process.kill(pid, "SIGKILL"); await running(pid); diff --git a/apps/p2/test/p2/login.test.ts b/apps/p2/test/p2/login.test.ts index 53ca36c15..8bb2e0d1d 100644 --- a/apps/p2/test/p2/login.test.ts +++ b/apps/p2/test/p2/login.test.ts @@ -7,7 +7,7 @@ import { after, before, describe, it } from "node:test"; import { createModels } from "@earendil-works/pi-ai/models"; import { fauxProvider } from "@earendil-works/pi-ai/providers/faux"; import { BACKGROUND_CONTEXT, follow, type Held, whenLive } from "@p2/app/v1"; -import { type LoginState, ProviderLogin } from "../../plugins/login/contract.ts"; +import { LoginService, type LoginState, ProviderLogin } from "../../plugins/login/contract.ts"; import { Models } from "../../plugins/models/contract.ts"; import type { Host } from "../../src/runtime/chord.ts"; import type { ServerResourceOptions } from "../../src/runtime/server.ts"; @@ -88,6 +88,8 @@ describe("provider login (fixture provider)", () => { const state = await until(login.login, (current) => current?.prompt?.type === type); assert(state?.prompt); await login.answer({ attemptId: state.attemptId, promptId: state.prompt.id, value }, BACKGROUND_CONTEXT); + // The answer follows its state: the answered prompt is closed on the page when the call resolves. + assert.notEqual(login.login.value?.prompt?.id, state.prompt.id); }; before(async () => { @@ -113,6 +115,7 @@ describe("provider login (fixture provider)", () => { it("runs an OAuth flow through prompts and notices", async () => { const { attemptId } = await login.start({ providerId: "faux", authType: "oauth" }, BACKGROUND_CONTEXT); + assert.equal(login.login.value?.attemptId, attemptId, "the started login is shown when the call resolves"); await assert.rejects( login.start({ providerId: "faux", authType: "api_key" }, BACKGROUND_CONTEXT), /already in progress/, @@ -140,12 +143,12 @@ describe("provider login (fixture provider)", () => { const published = JSON.stringify(seen); assert(!published.includes("private-access-token") && !published.includes("private-entered-code")); await login.dismiss({ attemptId }, BACKGROUND_CONTEXT); - await until(login.login, (value) => value === null); + assert.equal(login.login.value, null); }); it("signs in with an API key, refreshes, and signs out", async () => { await login.logout({ providerId: "faux" }, BACKGROUND_CONTEXT); - await until(login.providers, (value) => value?.[0]?.signedIn === null); + assert.equal(login.providers.value[0]?.signedIn, null, "signed out when the call resolves"); await login.start({ providerId: "faux", authType: "api_key" }, BACKGROUND_CONTEXT); await answer("secret", "private-api-key"); await until(login.login, (current) => current?.status === "success"); @@ -157,25 +160,23 @@ describe("provider login (fixture provider)", () => { it("cancels an unfinished login, also when the sheet is gone", async () => { const first = await login.start({ providerId: "faux", authType: "oauth" }, BACKGROUND_CONTEXT); await login.cancel({ attemptId: first.attemptId }, BACKGROUND_CONTEXT); - await until(login.login, (current) => current?.status === "cancelled"); + assert.equal(login.login.value?.status, "cancelled", "cancel resolves once the login finished"); const otherBrowser = fixture.browser(); const other = otherBrowser.hold(ProviderLogin, {}, BACKGROUND_CONTEXT); const { attemptId } = await other.start({ providerId: "faux", authType: "oauth" }, BACKGROUND_CONTEXT); await until(login.login, (current) => current?.attemptId === attemptId && current.prompt !== null); - // Both browsers share the owner's object; releasing every reference ends the flow. The two releases travel over - // different sockets: wait until the server has processed the other browser's (a later reference over its socket is - // ready), else `again` below may arrive first and keep the object, and so the login, alive. + // Both browsers hold the owner's sheet; once neither does (closed sheets, disconnects), the owner has left and the + // flow ends. The server's own service shows it (a new hold of the sheet would make the owner present again). other.release(); - const probe = otherBrowser.hold(Models, {}, BACKGROUND_CONTEXT); - await whenLive(probe); - probe.release(); login.release(); - const again = owner.hold(ProviderLogin, {}, BACKGROUND_CONTEXT); - const state = await until( - again.login, + const service = await fixture.server.host.get(LoginService, {}, BACKGROUND_CONTEXT); + const ended = await until( + service.login, (current) => current?.attemptId === attemptId && current.status !== "working", ); - assert.equal(state?.status, "cancelled"); + assert.equal(ended?.status, "cancelled"); + const again = owner.hold(ProviderLogin, {}, BACKGROUND_CONTEXT); + assert.equal((await until(again.login, (current) => current?.attemptId === attemptId))?.status, "cancelled"); login = again; }); }); diff --git a/apps/p2/test/p2/logs.test.ts b/apps/p2/test/p2/logs.test.ts index 0037136ab..dffb392e1 100644 --- a/apps/p2/test/p2/logs.test.ts +++ b/apps/p2/test/p2/logs.test.ts @@ -330,7 +330,8 @@ test("owner-only Logs exposes live per-source state without starting workers, an const notice = seen!.find((entry) => entry.source === source)!; assert.equal(notice.message, "Failed facet [redacted]"); await notices.dismiss({ id: notice.id }, BACKGROUND_CONTEXT); - await until(notices.notices, (value) => value !== undefined && !value.some((entry) => entry.id === notice.id)); + // The answer follows its state: the notice is gone on the page when the call resolves. + assert(!notices.notices.value.some((entry) => entry.id === notice.id)); const terminals = owner.hold(Logs, { source: "terminals" }, BACKGROUND_CONTEXT); const store = new LogStore(); store.redactor.addSecret("fixture-detached-secret"); diff --git a/apps/p2/test/p2/m1.test.ts b/apps/p2/test/p2/m1.test.ts index ec1ff4a0f..5e4ce1057 100644 --- a/apps/p2/test/p2/m1.test.ts +++ b/apps/p2/test/p2/m1.test.ts @@ -9,7 +9,7 @@ import { BACKGROUND_CONTEXT, statusOf } from "@p2/app/v1"; import { Chat } from "../../plugins/chat/contract.ts"; import { Models } from "../../plugins/models/contract.ts"; import { Sessions } from "../../plugins/sessions/contract.ts"; -import { SessionStatus } from "../../plugins/status/contract.ts"; +import { SessionStatus, StatusStore } from "../../plugins/status/contract.ts"; import type { Host, RefIn } from "../../src/runtime/chord.ts"; import { type Fixture, failureOf, holdIn, poll, startFixture, until } from "./helpers.ts"; @@ -99,12 +99,16 @@ describe("M1: a working chat (fake provider)", () => { it("streams partial text, shows the session busy, and stops", async () => { await chat.send({ text: "slow one", requestId: "r2" }, BACKGROUND_CONTEXT); + // Send pushes the worker's status before it answers: the server's index shows the session busy already. + const store = await fixture.server.host.get(StatusStore, {}, BACKGROUND_CONTEXT); + assert(store.statuses.value[sessionId]?.busy.includes(1)); await until(chat.view, (value) => partial(value).length > 20); await until(status.statuses, (value) => value?.[sessionId]?.busy.includes(1) === true); // A follow-up queued behind the run comes back to its author when stopping. await chat.send({ text: "queued later", requestId: "r3", whenBusy: "followUp" }, BACKGROUND_CONTEXT); const { restored } = await chat.stop(BACKGROUND_CONTEXT); assert.deepEqual(restored, ["queued later"]); + assert.deepEqual(store.statuses.value[sessionId]?.busy, [], "stop pushes the idle status before it answers"); const view = await until( chat.view, (value) => !live(value)?.run && assistants(value).at(-1)?.stopReason === "aborted", diff --git a/apps/p2/test/p2/mcp-sign-in.test.ts b/apps/p2/test/p2/mcp-sign-in.test.ts index 53291d40f..10c9a8698 100644 --- a/apps/p2/test/p2/mcp-sign-in.test.ts +++ b/apps/p2/test/p2/mcp-sign-in.test.ts @@ -252,7 +252,8 @@ test("a 401 waits for a sign-in; the owner view's loopback sign-in connects with assert.equal(waiting?.auth, "oauth"); const start = await owner.signIn({ server: "docs" }, TEST); assert.equal(start.kind, "redirect"); - const [flow] = await until(owner.signIns, (list) => list.length === 1); + // The answer follows its state: the flow is listed when the call resolves. + const [flow] = owner.signIns.value; assert.equal(flow?.via, "loopback"); assert.equal(flow?.status, "waiting"); // The browser goes to the authorization server, which redirects to the loopback callback on this machine. @@ -339,8 +340,9 @@ test("pasting the final redirect URL completes a sign-in; a URL of another sign- assert.equal((await until(owner.signIns, (list) => list.length === 1))[0]?.status, "waiting"); // The loopback page never loaded (the browser runs elsewhere): its address, pasted, completes the sign-in. await owner.paste({ id: flow!.id, redirectUrl: back.href }, TEST); + // The finished flow is gone when the call resolves; the instance reconnects with the tokens. + assert.deepEqual(owner.signIns.value, []); await until(owner.instances, (list) => docs(list)?.status === "connected"); - await until(owner.signIns, (list) => list.length === 0); }); test("refused tokens refresh without a browser; signing out forgets them and waits for a sign-in", async (t) => { @@ -353,12 +355,15 @@ test("refused tokens refresh without a browser; signing out forgets them and wai await until(owner.instances, (list) => docs(list)?.status === "connected"); oauth.expireAccessTokens(); await owner.restart({ instance: waiting!.id }, TEST); + // A restart shows the instance starting when the call resolves (a pending state, then its outcome). + assert.equal(docs(owner.instances.value)?.status, "starting"); await until(owner.instances, (list) => docs(list)?.status === "connected"); assert.deepEqual(oauth.grants, ["authorization_code", "refresh_token"]); assert.equal(oauth.authorizations.length, 1); // A refresh token a stored sign-in still has signs in at once, without the browser. assert.deepEqual(await owner.signIn({ server: "docs" }, TEST), { kind: "authorized" }); await owner.signOut({ server: "docs" }, TEST); + assert.notEqual(docs(owner.instances.value)?.status, "connected", "signed out instances reconnect at once"); await until(owner.instances, (list) => docs(list)?.status === "sign-in"); assert.doesNotMatch(readFileSync(join(host.dataDir, "mcp-auth.json"), "utf8"), /access-/); }); diff --git a/apps/p2/test/p2/mcp.test.ts b/apps/p2/test/p2/mcp.test.ts index 544da1cb3..24218fcc6 100644 --- a/apps/p2/test/p2/mcp.test.ts +++ b/apps/p2/test/p2/mcp.test.ts @@ -472,7 +472,10 @@ test("an invalid mcp.json keeps the last valid servers, also across a reload", a const [after] = await until(s1.servers, (value) => isUp("files")(value) && host.starts().length === 2); assert.equal(after!.id, instance!.id); assert.equal(alive(pid), false); - assert.match(owner.errors.value.join("\n"), /Invalid mcp.json syntax; keeping the last valid servers/); + // The restarted reader reads the half-saved file once more before it reports it. + await until(owner.errors, (errors) => + /Invalid mcp.json syntax; keeping the last valid servers/.test(errors.join("\n")), + ); }); test("a view outlives its last reference by the linger: a worker re-taking it keeps its processes", async (t) => { @@ -754,6 +757,8 @@ test("a reload of the plugin keeps the leases and the tools: the new supervisor const stop = follow(s1.servers, (servers) => { fewest = Math.min(fewest, servers[0]?.tools.length ?? 0); }); + // The manager's memento of the listed tools is written a moment after the list changed: let it catch up. + await settled(host.manager.instances); // A new facet value: a reload, not the same code again. await host.fixture.reload([{ ...mcpServer }]); const [after] = await until(s1.servers, (value) => isUp("files")(value) && host.starts().length === 2); diff --git a/apps/p2/test/p2/plugins.test.ts b/apps/p2/test/p2/plugins.test.ts index 2dc215029..81e7d18e5 100644 --- a/apps/p2/test/p2/plugins.test.ts +++ b/apps/p2/test/p2/plugins.test.ts @@ -74,7 +74,8 @@ describe("plugin listing and selection", () => { const models = fixture.browser().hold(Models, {}, BACKGROUND_CONTEXT); const choice = { provider: "faux", modelId: "faux-1", thinkingLevel: "off" }; await models.setDefault(choice, BACKGROUND_CONTEXT); - assert.deepEqual(await until(models.defaultModel, (value) => value?.thinkingLevel === "off"), choice); + // The answer follows its state: the new default is on the page when the call resolves. + assert.deepEqual(models.defaultModel.value, choice); const unavailable = fixture .browser() .hold(Terminals, { session: "not-opened", view: crypto.randomUUID() }, BACKGROUND_CONTEXT); diff --git a/apps/p2/test/p2/ports.test.ts b/apps/p2/test/p2/ports.test.ts index 1f8305582..4c76c728e 100644 --- a/apps/p2/test/p2/ports.test.ts +++ b/apps/p2/test/p2/ports.test.ts @@ -94,6 +94,16 @@ test( const store = (await fixture.server.host.registry(ForwardsNative, BACKGROUND_CONTEXT)).value!.forwards; store.radiusOrigin = ORIGIN; await ports.add({ port: 3000, name: "docs", public: true }, BACKGROUND_CONTEXT); + // The answer follows its state: the new forward and its public link are on the page when the call resolves. + const docs = ports.forwards.value.find((item) => item.port === 3000); + assert.match(docs?.publicUrl ?? "", /^https:\/\/pub--/); + await ports.setPublic({ id: docs!.id, public: false }, BACKGROUND_CONTEXT); + assert.equal(ports.forwards.value.find((item) => item.port === 3000)?.publicUrl, undefined); + await ports.setPublic({ id: docs!.id, public: true }, BACKGROUND_CONTEXT); + await ports.add({ port: 3999 }, BACKGROUND_CONTEXT); + const temporary = ports.forwards.value.find((item) => item.port === 3999)!; + await ports.remove({ id: temporary.id }, BACKGROUND_CONTEXT); + assert(!ports.forwards.value.some((item) => item.port === 3999), "the removed forward is gone at the answer"); let serial = 0; const call = async (query: PortsRequest, target = chat) => { const count = results(await until(target.view, Boolean)).length; diff --git a/apps/p2/test/p2/rebuild-index.test.ts b/apps/p2/test/p2/rebuild-index.test.ts index f48b8a38f..16f546eef 100644 --- a/apps/p2/test/p2/rebuild-index.test.ts +++ b/apps/p2/test/p2/rebuild-index.test.ts @@ -1,5 +1,5 @@ // Pi Mobile's rebuildIndex: an owner re-indexes one session from the start, an archived one too (its worker scans -// without resuming work and stops again). +// without resuming work and stops again). The rebuild is a job, shown in `Search.rebuilds` while it runs. import assert from "node:assert/strict"; import { test } from "node:test"; @@ -29,6 +29,7 @@ test("rebuildIndex re-indexes active and archived sessions", { timeout: 90_000 } (hit) => hit.session === session.id, ).length; await poll(async () => (await hits()) === 2, 20_000, "indexed"); + const rebuilt = () => until(search.rebuilds, (list) => list?.length === 0); chat.release(); // Emptied directly, the index stays empty until rebuilt. @@ -37,8 +38,11 @@ test("rebuildIndex re-indexes active and archived sessions", { timeout: 90_000 } BACKGROUND_CONTEXT, ); assert.equal(await hits(), 0); + // The rebuild is a job: the call answers once it started, `rebuilds` shows it running until the scan committed. await search.rebuildIndex({ session: session.id }, BACKGROUND_CONTEXT); - assert.equal(await hits(), 2, "rebuilt before the call returned"); + await until(search.rebuilds, (list) => list?.some((each) => each.session === session.id) === true); + await rebuilt(); + assert.equal(await hits(), 2, "rebuilt once the job is gone"); // An archived session: its worker starts only to scan (no resumed work) and stops again. await sessions.archive({ id: session.id }, BACKGROUND_CONTEXT); @@ -48,6 +52,7 @@ test("rebuildIndex re-indexes active and archived sessions", { timeout: 90_000 } ); assert.equal(await hits(true), 0); await search.rebuildIndex({ session: session.id }, BACKGROUND_CONTEXT); + await rebuilt(); assert.equal(await hits(true), 2); const hosts = fixture.server.host.hold(Hosts, {}, BACKGROUND_CONTEXT); t.after(() => hosts.release()); diff --git a/apps/p2/test/p2/relocate.test.ts b/apps/p2/test/p2/relocate.test.ts index 16ed13114..0a78570ca 100644 --- a/apps/p2/test/p2/relocate.test.ts +++ b/apps/p2/test/p2/relocate.test.ts @@ -538,10 +538,18 @@ describe("session relocation (fake provider)", () => { const session = await sessions.create({ cwd: fixture.workspace }, BACKGROUND_CONTEXT); const root = await holdIn(owner, Chat, { session: session.id, conversation: 1 }, "participant"); await root.send({ text: "slow run", requestId: "slow" }, BACKGROUND_CONTEXT); - await assert.rejects( - sessions.relocate({ id: session.id, cwd: target, env: null }, BACKGROUND_CONTEXT), - /Stop the current run/, - ); + // A refused move is a failed operation the owner sees (the call answered when the move started). + const refused = async (pattern: RegExp) => { + await sessions.relocate({ id: session.id, cwd: target, env: null }, BACKGROUND_CONTEXT); + const list = await until(sessions.operations, (value) => + (value ?? []).some((each) => each.session === session.id && each.phase === "failed"), + ); + const failure = list?.find((each) => each.session === session.id && each.phase === "failed"); + assert(failure?.phase === "failed"); + assert.match(failure.error, pattern); + await sessions.dismissOperation({ operation: failure.id }, BACKGROUND_CONTEXT); + }; + await refused(/Stop the current run/); // The refusal left no fence: input is admitted, and the session stayed where it was. await root.send({ text: "queued meanwhile", requestId: "queued", whenBusy: "followUp" }, BACKGROUND_CONTEXT); assert.equal(await catalogCwd(session.id), fixture.workspace); @@ -560,10 +568,7 @@ describe("session relocation (fake provider)", () => { "participant", ); await helper.send({ text: "slow helper", requestId: "slow-child" }, BACKGROUND_CONTEXT); - await assert.rejects( - sessions.relocate({ id: session.id, cwd: target, env: null }, BACKGROUND_CONTEXT), - /Subagent helper is still running/, - ); + await refused(/Subagent helper is still running/); await helper.stop(BACKGROUND_CONTEXT); await until(status.statuses, (value) => value?.[session.id]?.tasks === 0); await fixture.server.host.root(async (ctx) => { diff --git a/apps/p2/test/p2/session-operations.test.ts b/apps/p2/test/p2/session-operations.test.ts new file mode 100644 index 000000000..5c51521ed --- /dev/null +++ b/apps/p2/test/p2/session-operations.test.ts @@ -0,0 +1,106 @@ +// The sessions' lifecycle as the owner's pages see it (fake provider): client-chosen IDs (a page holds the new +// session before `create` answered), methods that answer once their visible effect committed on the server, and the +// long parts as jobs in `Sessions.operations`; the worker pushing what the server's index shows (`/tree`'s main +// conversation) before its method answers. + +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { after, before, describe, it } from "node:test"; +import type { ConversationView } from "@earendil-works/pi-durable"; +import { BACKGROUND_CONTEXT, whenLive } from "@p2/app/v1"; +import { Chat } from "../../plugins/chat/contract.ts"; +import { Catalog, Sessions } from "../../plugins/sessions/contract.ts"; +import { SessionTree } from "../../plugins/tree/contract.ts"; +import type { Host, RefIn } from "../../src/runtime/chord.ts"; +import { type Fixture, holdIn, startFixture, until } from "./helpers.ts"; + +const answered = (text: string) => (view: ConversationView | null | undefined) => + JSON.stringify(view?.entries ?? []).includes(text); + +describe("session operations (fake provider)", () => { + let fixture: Fixture; + let owner: Host; + let sessions: RefIn<typeof Sessions, "owner">; + before(async () => { + fixture = await startFixture(); + owner = fixture.browser(); + sessions = await holdIn(owner, Sessions, {}, "owner"); + }); + after(async () => { + await fixture?.close(); + }); + /** The server's catalog, read on the server: what the owner's call committed before it answered. */ + const catalogRow = async (id: string) => { + const catalog = fixture.server.host.hold(Catalog, {}, BACKGROUND_CONTEXT); + await whenLive(catalog); + const row = catalog.sessions.value.find((each) => each.id === id); + catalog.release(); + return row; + }; + + it("creates a session under the client's ID; the page held it before the answer", async () => { + const id = randomUUID(); + // The page navigates at once: its conversation's worker waits for the row. + const early = holdIn(owner, Chat, { session: id, conversation: 1 }, "participant"); + const created = await sessions.create({ id, cwd: fixture.workspace, title: "Chosen" }, BACKGROUND_CONTEXT); + assert.equal(created.id, id); + assert.deepEqual( + await sessions.create({ id, cwd: fixture.workspace, title: "Chosen" }, BACKGROUND_CONTEXT), + created, + "a retry answers the same row", + ); + const chat = await early; + await chat.send({ text: "hello", requestId: "one" }, BACKGROUND_CONTEXT); + await until(chat.view, answered("Echo: hello")); + chat.release(); + }); + + it("archives at once and runs the cleanup as an operation; a restore waits for it", async () => { + const session = await sessions.create({ cwd: fixture.workspace }, BACKGROUND_CONTEXT); + const chat = await holdIn(owner, Chat, { session: session.id, conversation: 1 }, "participant"); + await chat.send({ text: "hi", requestId: "one" }, BACKGROUND_CONTEXT); + await until(chat.view, answered("Echo: hi")); + await sessions.archive({ id: session.id }, BACKGROUND_CONTEXT); + assert.notEqual((await catalogRow(session.id))?.archivedAt, null, "archived when the call answered"); + // Restored right away: the running archive's worker stop lands before the restore. + await sessions.restore({ id: session.id }, BACKGROUND_CONTEXT); + assert.equal((await catalogRow(session.id))?.archivedAt, null); + await until(sessions.operations, (list) => !(list ?? []).some((each) => each.session === session.id)); + await until(chat.state, (state) => state.kind === "live"); + await chat.send({ text: "back", requestId: "two" }, BACKGROUND_CONTEXT); + await until(chat.view, answered("Echo: back")); + chat.release(); + }); + + it("rejects a move to a missing directory at once; nothing runs", async () => { + const session = await sessions.create({ cwd: fixture.workspace }, BACKGROUND_CONTEXT); + await assert.rejects( + sessions.relocate({ id: session.id, cwd: `${fixture.workspace}/missing`, env: null }, BACKGROUND_CONTEXT), + /ENOENT|not a directory/, + ); + assert.deepEqual( + (sessions.operations.value ?? []).filter((each) => each.session === session.id), + [], + ); + }); + + it("pushes a /tree switch to the catalog before the worker answers", async () => { + const session = await sessions.create({ cwd: fixture.workspace }, BACKGROUND_CONTEXT); + const chat = await holdIn(owner, Chat, { session: session.id, conversation: 1 }, "participant"); + await chat.send({ text: "first", requestId: "one" }, BACKGROUND_CONTEXT); + await until(chat.view, answered("Echo: first")); + await chat.send({ text: "second", requestId: "two" }, BACKGROUND_CONTEXT); + await until(chat.view, answered("Echo: second")); + const tree = await holdIn(owner, SessionTree, { session: session.id }, "owner"); + const nodes = (await tree.tree(BACKGROUND_CONTEXT)).nodes; + const answer = nodes.find((node) => node.role === "assistant" && node.text === "Echo: first"); + assert(answer); + const { conversation } = await tree.navigate({ entry: answer.entry }, BACKGROUND_CONTEXT); + assert.notEqual(conversation, 1); + assert.equal((await catalogRow(session.id))?.main, conversation, "the catalog follows before the answer"); + await tree.select({ conversation: 1 }, BACKGROUND_CONTEXT); + assert.equal((await catalogRow(session.id))?.main, 1); + tree.release(); + chat.release(); + }); +}); diff --git a/apps/p2/test/p2/session-start.test.ts b/apps/p2/test/p2/session-start.test.ts index 73f552d70..5a06f8117 100644 --- a/apps/p2/test/p2/session-start.test.ts +++ b/apps/p2/test/p2/session-start.test.ts @@ -13,10 +13,13 @@ const message = (id: string): Outgoing => ({ whenBusy: "followUp", item: { type: "user", key: `outgoing-${id}`, text: `text ${id}`, images: [], files: [] }, }); -const place = { - location: { env: null, cwd: "/tmp" }, - choice: { model: { provider: "faux", modelId: "faux-1" }, thinkingLevel: "off" }, -} as const; +/** Where a start begins, with a new session ID (the page chooses it at the first Send). */ +const place = () => + ({ + session: crypto.randomUUID(), + location: { env: null, cwd: "/tmp" }, + choice: { model: { provider: "faux", modelId: "faux-1" }, thinkingLevel: "off" }, + }) as const; const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); const ticks = async (count: number) => { for (let index = 0; index < count; index++) await tick(); @@ -25,17 +28,19 @@ const ticks = async (count: number) => { /** A creator recording what it did; `fail` decides per call what goes wrong, `gate` holds a creation until released. */ function creator(fail: (call: number) => "refuse" | "drop" | undefined = () => undefined) { const created: string[] = []; + /** The session ID each creation asked for. */ + const asked: string[] = []; const restored: { ids: string[]; error: string }[] = []; let calls = 0; let gate: Promise<void> | undefined; const value: Creator = { - create: async () => { + create: async ({ session }) => { const call = ++calls; + asked.push(session); await gate; const outcome = fail(call); if (outcome === "refuse") throw new Error("refused"); if (outcome === "drop") throw new Error("connection lost"); - return `session-${crypto.randomUUID()}`; }, refused: (error) => error instanceof Error && error.message === "refused", created: (session) => created.push(session), @@ -45,6 +50,7 @@ function creator(fail: (call: number) => "refuse" | "drop" | undefined = () => u return { value, created, + asked, restored, calls: () => calls, hold: () => { @@ -61,7 +67,7 @@ function creator(fail: (call: number) => "refuse" | "drop" | undefined = () => u describe("session start", () => { it("shows messages before a creator connects, then creates the session once and hands them to its outbox", async () => { const start = new SessionStart({ retryDelay: () => 1 }); - start.add(message("a"), place); + start.add(message("a"), place()); await tick(); assert.deepEqual( start.snapshot()?.messages.map((entry) => entry.id), @@ -86,8 +92,8 @@ describe("session start", () => { const { value, created, calls, hold } = creator(); const release = hold(); start.connect(value); - start.add(message("a"), place); - start.add(message("b"), { ...place, location: { env: null, cwd: "/elsewhere" } }); + start.add(message("a"), place()); + start.add(message("b"), { ...place(), location: { env: null, cwd: "/elsewhere" } }); await ticks(3); assert.equal(start.snapshot()?.location.cwd, "/tmp"); release(); @@ -104,8 +110,8 @@ describe("session start", () => { it("puts the messages back when the server refuses the creation", async () => { const start = new SessionStart({ retryDelay: () => 1 }); const { value, created, restored } = creator(() => "refuse"); - start.add(message("a"), place); - start.add(message("b"), place); + start.add(message("a"), place()); + start.add(message("b"), place()); start.connect(value); await ticks(5); assert.deepEqual(created, []); @@ -116,15 +122,18 @@ describe("session start", () => { it("tries a lost connection again, and gives up after six failures while connected", async () => { const start = new SessionStart({ retryDelay: () => 1 }); const flaky = creator((call) => (call === 1 ? "drop" : undefined)); - start.add(message("a"), place); + start.add(message("a"), place()); start.connect(flaky.value); await new Promise((resolve) => setTimeout(resolve, 20)); assert.equal(flaky.calls(), 2); assert.equal(flaky.created.length, 1); + // The retry after a lost answer asks for the same session (a retried create answers the same row). + assert.equal(flaky.asked[0], flaky.asked[1]); + assert.equal(flaky.created[0], flaky.asked[0]); const broken = creator(() => "drop"); start.connect(broken.value); - start.add(message("b"), place); + start.add(message("b"), place()); await new Promise((resolve) => setTimeout(resolve, 50)); assert.equal(broken.calls(), 6); assert.deepEqual(broken.restored, [{ ids: ["b"], error: "connection lost" }]); @@ -135,7 +144,7 @@ describe("session start", () => { const { value, calls } = creator(); start.connect(value); start.connect(undefined); - start.add(message("a"), place); + start.add(message("a"), place()); await ticks(5); assert.equal(calls(), 0); assert.deepEqual( diff --git a/apps/p2/test/p2/sharing.test.ts b/apps/p2/test/p2/sharing.test.ts index e6a8c4384..c5617ace7 100644 --- a/apps/p2/test/p2/sharing.test.ts +++ b/apps/p2/test/p2/sharing.test.ts @@ -45,11 +45,9 @@ describe("sharing (fake provider)", () => { }); const linkOf = async (mode: "view" | "full", reset = false) => { await sharing.link({ mode, reset }, BACKGROUND_CONTEXT); - const links = await until(sharing.links, (value) => - Boolean(value?.some((link) => link.mode === mode && (!reset || link.token !== tokens[mode]))), - ); - const link = links?.find((entry) => entry.mode === mode); - assert(link); + // The answer follows its state: the (new) link is on the page when the call resolves. + const link = sharing.links.value.find((entry) => entry.mode === mode); + assert(link && (!reset || link.token !== tokens[mode])); tokens[mode] = link.token; return link; }; diff --git a/apps/p2/test/p2/shell.test.ts b/apps/p2/test/p2/shell.test.ts index fe0289df8..74fd9143a 100644 --- a/apps/p2/test/p2/shell.test.ts +++ b/apps/p2/test/p2/shell.test.ts @@ -19,6 +19,7 @@ import { Chat, SHELL_BUSY } from "../../plugins/chat/contract.ts"; import chatWorker from "../../plugins/chat/worker.ts"; import { WorkerModels } from "../../plugins/models/contract.ts"; import { createP2Models } from "../../plugins/models/models.ts"; +import { StatusReport } from "../../plugins/status/contract.ts"; import { type Host, Session } from "../../src/runtime/chord.ts"; import { durableContext } from "../../src/runtime/durable.ts"; import { SHELL_ENTRY, type ShellData, shellTail } from "../../src/runtime/shell.ts"; @@ -60,9 +61,11 @@ async function shellHost() { relocate: async () => {}, } as unknown as HarnessParts, worker: { - // A running command is one active call (the worker stays up; a move or clone waits for it). + // Calls and running commands are active calls (a move or a clone waits for them). liveness: { - add: () => {}, + add: (kind: string, delta: number) => { + if (kind === "activeCalls") calls += delta; + }, call: async <T>(fn: () => Promise<T>) => { calls++; try { @@ -79,6 +82,8 @@ async function shellHost() { chatWorker, ], stubs: [ + // No server here: the worker's status pushes go nowhere. + [StatusReport, { push: async () => null }], // No settings.json: the owner's entries carry the default name. [WorkerModels, (_input, _principal, self) => ({ settings: self.state({}) })], ], @@ -145,7 +150,8 @@ describe("user shell commands", () => { { command: "echo private", includeInContext: false, requestId: "2" }, BACKGROUND_CONTEXT, ); - assert.equal(typeof again.shellId, "string"); + // The command's ID is the caller's request ID. + assert.equal(again.shellId, "2"); await new Promise((resolve) => setTimeout(resolve, 200)); assert.equal(shells(owner.view.value).length, 2); // The web shows it as a plain notice: `$ command`, the output fenced, how it ended. @@ -174,9 +180,11 @@ describe("user shell commands", () => { }, BACKGROUND_CONTEXT, ); - // Everyone sees it run, with its output so far; it is one active call while it runs. + // Everyone sees it run, with its output so far. It is a job (the worker stays up) and one active call while it + // runs (a move or a clone waits for it). const live = await until(bob.liveShell, (value) => Boolean(value?.running && value.output.includes("started"))); assert.deepEqual(live?.author, { kind: "guest", id: "alice", name: "Alice" }); + assert.equal(fixture.host.activity.value.jobs, 1); assert.equal(fixture.activeCalls(), 1); await assert.rejects( owner.shell({ command: "true", includeInContext: true, requestId: "b" }, BACKGROUND_CONTEXT), @@ -190,7 +198,7 @@ describe("user shell commands", () => { assert.doesNotMatch(cancelled?.data.output ?? "", /late/); assert.deepEqual(cancelled?.data.author, { kind: "guest", id: "alice", name: "Alice" }); await until(owner.liveShell, (value) => value === null); - for (let i = 0; i < 50 && fixture.activeCalls(); i++) await new Promise((resolve) => setTimeout(resolve, 10)); + await until(fixture.host.activity, (activity) => activity.jobs === 0); assert.equal(fixture.activeCalls(), 0); // Only its author or the owner cancels a running command. const second = await owner.shell( diff --git a/apps/p2/test/p2/subagents.test.ts b/apps/p2/test/p2/subagents.test.ts index 98f082a92..927812852 100644 --- a/apps/p2/test/p2/subagents.test.ts +++ b/apps/p2/test/p2/subagents.test.ts @@ -11,7 +11,7 @@ import { Mcp } from "../../plugins/mcp/contract.ts"; import { Sessions } from "../../plugins/sessions/contract.ts"; import { Sharing } from "../../plugins/sharing/contract.ts"; import { SessionStatus } from "../../plugins/status/contract.ts"; -import { type Child, SubagentControl, Subagents } from "../../plugins/subagents/contract.ts"; +import { type Child, ChildStore, SubagentControl, Subagents } from "../../plugins/subagents/contract.ts"; import { Workspace } from "../../plugins/workspace/contract.ts"; import { startServer } from "../../src/hosts/server.ts"; import type { Host, RefIn } from "../../src/runtime/chord.ts"; @@ -127,7 +127,12 @@ describe("subagents (fake provider)", () => { { type: "text", text: "1 archived (status with a name shows one)." }, ]); const control = owner.hold(SubagentControl, { session }, BACKGROUND_CONTEXT); + // The worker pushes the sidebar's flag to the server before it answers: the server's index has it then. + const store = fixture.server.host.hold(ChildStore, {}, BACKGROUND_CONTEXT); + await whenLive(store); await control.archive({ conversation: child.conversationId, archived: false }, BACKGROUND_CONTEXT); + assert.equal(store.children.value[session]?.[0]?.archived, undefined, "pushed before the answer"); + store.release(); await until(children.children, (value) => value?.[session]?.[0]?.archived === undefined); await assert.rejects( control.archive({ conversation: 999, archived: true }, BACKGROUND_CONTEXT), diff --git a/apps/p2/test/p2/test-host.test.ts b/apps/p2/test/p2/test-host.test.ts index c914ad930..d92e11873 100644 --- a/apps/p2/test/p2/test-host.test.ts +++ b/apps/p2/test/p2/test-host.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, it } from "node:test"; import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node"; -import { BACKGROUND_CONTEXT, follow, whenLive } from "@p2/app/v1"; +import { BACKGROUND_CONTEXT, whenLive } from "@p2/app/v1"; import { DraftStore, Drafts } from "../../plugins/drafts/contract.ts"; import draftsWorker from "../../plugins/drafts/worker.ts"; import { SessionStatus } from "../../plugins/status/contract.ts"; @@ -139,7 +139,7 @@ describe("testHost", () => { }); }); -it("a workspace hold protects a slow watch activation, and releases its liveness lease", async (t) => { +it("a workspace hold keeps the worker busy through a slow watch activation, until it is released", async (t) => { const root = await mkdtemp(join(tmpdir(), "p2-watch-unit-")); const project = join(root, "project"); const dataDir = join(root, "data"); @@ -159,11 +159,7 @@ it("a workspace hold protects a slow watch activation, and releases its liveness await blocked; return watch(...args); }; - let viewers = 0; const liveness = new Liveness(); - follow(liveness, (counts) => { - viewers = counts.viewers ?? 0; - }); const fixture = await testHost({ address: Session({ id: "sample" }), kind: "worker", @@ -210,12 +206,14 @@ it("a workspace hold protects a slow watch activation, and releases its liveness await rm(root, { recursive: true, force: true }); }); const workspace = fixture.client(OWNER).hold(Workspace, { session: "sample", conversation: 1 }, BACKGROUND_CONTEXT); + // Chord's activity counts the hold from its arrival (the worker's keep-alive), bound or not. + const references = () => fixture.host.activity.value.references; await Promise.race([watching, whenLive(workspace)]); - assert.equal(viewers, 1, "activation is held work even before FileSystem.watch returns"); + assert.equal(references(), 1, "the hold is counted even before FileSystem.watch returns"); unblock(); await whenLive(workspace); workspace.release(); - await poll(() => viewers === 0, 5000, "workspace activation lease released"); + await poll(() => references() === 0, 5000, "the released hold is no longer counted"); }); describe("live assertion helpers", () => { diff --git a/apps/p2/test/p2/tui-fork.test.ts b/apps/p2/test/p2/tui-fork.test.ts index e514dd520..6e6940ef2 100644 --- a/apps/p2/test/p2/tui-fork.test.ts +++ b/apps/p2/test/p2/tui-fork.test.ts @@ -62,19 +62,22 @@ test("/fork picks a message and opens the fork with it in the editor; /clone; do sessions, defaultCwd, defaultEnv, + operations: self.state([]), + dismissOperation: () => {}, create: () => SESSION, relocate: () => {}, rename: () => {}, pin: () => {}, archive: () => {}, restore: () => {}, - clone: (args: { id: string }, ctx: Ctx) => { + // The copy's ID is the TUI's (client-chosen, Sessions v6). + clone: (args: { id: string; copy: string }, ctx: Ctx) => { record("clone", args); - return copy(`clone-${calls.clone?.length}`, "copy", ctx); + return copy(args.copy, "copy", ctx); }, - fork: (args: { id: string; conversation: number; before: number }, ctx: Ctx) => { + fork: (args: { id: string; conversation: number; before: number; copy: string }, ctx: Ctx) => { record("fork", args); - return copy(`fork-${calls.fork?.length}`, "fork", ctx); + return copy(args.copy, "fork", ctx); }, }; }, @@ -124,16 +127,18 @@ test("/fork picks a message and opens the fork with it in the editor; /clone; do send(UP); send(ENTER); await poll(() => (calls.fork?.length ?? 0) === 1); - assert.deepEqual(calls.fork?.[0], { id: "s", conversation: 1, before: 1 }); - // The fork is open with the chosen message in its editor (this fixture's single worker serves only "s"). - await poll(() => runtime.identity === "fork-1#1"); - assert.equal(runtime.drafts.get("fork-1#1"), "first question"); + const forked = calls.fork?.[0] as { copy: string }; + assert.deepEqual(forked, { id: "s", conversation: 1, before: 1, copy: forked.copy }); + // The fork opened at once with the chosen message in its editor (this fixture's single worker serves only "s"). + assert.equal(runtime.identity, `${forked.copy}#1`); + assert.equal(runtime.drafts.get(`${forked.copy}#1`), "first question"); assert.equal(chat().editor.getText(), "first question"); - assert(runtime.notices.some((notice) => notice.text === "Forked to new session")); - // /clone opens the copy. + // /clone opens the copy at once. assert(runtime.ctx.commands.run("p2.session.clone")); - await poll(() => runtime.identity === "clone-1#1"); - assert.deepEqual(calls.clone, [{ id: "fork-1" }]); + await poll(() => (calls.clone?.length ?? 0) === 1); + const cloned = calls.clone?.[0] as { copy: string }; + assert.deepEqual(cloned, { id: forked.copy, copy: cloned.copy }); + assert.equal(runtime.identity, `${cloned.copy}#1`); runtime.navigate("chat", "s", 1); await poll(() => chat().editable); // doubleEscapeAction "fork": a second Escape within 500 ms on an empty editor opens the picker; "none" never. diff --git a/apps/p2/test/p2/tui-location.test.ts b/apps/p2/test/p2/tui-location.test.ts index 80f389552..c9ebb6f17 100644 --- a/apps/p2/test/p2/tui-location.test.ts +++ b/apps/p2/test/p2/tui-location.test.ts @@ -76,11 +76,14 @@ function stubs(calls: Calls, role: "owner" | "guest" = "owner") { sessions, defaultCwd, defaultEnv, - create: (args: { cwd: string; env?: string | null }, ctx: Ctx) => { - record("create", args); + operations: self.state([]), + dismissOperation: () => {}, + create: (args: { id: string; cwd: string; env?: string | null }, ctx: Ctx) => { + const { id: _id, ...location } = args; + record("create", location); const created = { ...SESSION, - id: `new-${calls.create?.length}`, + id: args.id, cwd: args.cwd, env: args.env ?? null, }; @@ -219,7 +222,7 @@ test("/cd relocates: folder completion, relative paths, the web's errors, env mo await poll(() => (calls.relocate?.length ?? 0) === 1); assert.deepEqual(calls.relocate?.[0], { id: "s", env: null, cwd: "/work/src" }); await poll(async () => (await screen()).includes("/work/src • Fixture")); - await poll(() => runtime.notices.some((notice) => notice.text === "Working directory: /work/src")); + await poll(() => runtime.notices.some((notice) => notice.text === "Moving to /work/src…")); // A missing folder: the server's error text, nothing relocated. typed(send, "/cd /nope"); send(ENTER); diff --git a/apps/p2/test/p2/tui-optimistic.test.ts b/apps/p2/test/p2/tui-optimistic.test.ts new file mode 100644 index 000000000..8416976c2 --- /dev/null +++ b/apps/p2/test/p2/tui-optimistic.test.ts @@ -0,0 +1,262 @@ +// Optimistic UI in the TUI (docs/optimistic-ui.md): a session rename, a pin and a sent message show at once, while +// their calls are still in flight; a refused call rolls back and says why. The stubs hold each call open until the +// test lets it settle, so what the screen shows meanwhile is the guess, not the server's state. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { ConversationView } from "@earendil-works/pi-durable"; +import { ROOT_CONVERSATION_ID } from "@earendil-works/pi-durable"; +import { Chat, type ModelMeta } from "../../plugins/chat/contract.ts"; +import chatTui, { ChatSurface } from "../../plugins/chat/tui.ts"; +import { type Operation, type Session, Sessions } from "../../plugins/sessions/contract.ts"; +import sessionsTui, { SessionsSurface } from "../../plugins/sessions/tui.ts"; +import type { Ctx } from "../../src/runtime/chord.ts"; +import { tuiCoreFacet } from "../../src/tui/core-commands.ts"; +import { poll } from "./helpers.ts"; +import { SESSION, type Stub, topology } from "./tui-fixture.ts"; + +const ENTER = "\r"; +const DOWN = "\x1b[B"; +const CTRL_R = "\x12"; +const ALT_P = "\x1bp"; +const CTRL_A = "\x01"; +const CTRL_K = "\x0b"; +const CTRL_N = "\x0e"; +const typed = (send: (data: string) => void, text: string) => { + for (const char of text) send(char); +}; +const META: ModelMeta = { + provider: "faux", + modelId: "faux-1", + name: "Faux", + contextWindow: 1000, + reasoning: false, + thinkingLevels: ["off"], +}; +const VIEW: ConversationView = { + conversation: { id: ROOT_CONVERSATION_ID } as ConversationView["conversation"], + entries: [], + docs: { "pi.agent": { model: { provider: "faux", modelId: "faux-1" } } } as ConversationView["docs"], +}; + +/** A call the test settles: `call()` waits until `pass()` or `refuse(message)`. */ +function gate() { + let open = Promise.withResolvers<void>(); + return { + call: async () => open.promise, + pass: () => open.resolve(), + refuse: (message: string) => open.reject(new Error(message)), + reset: () => { + open = Promise.withResolvers<void>(); + }, + }; +} + +/** The owner's catalog with rename and pin held open by `held`; each change commits only once its call passes. */ +function catalog(held: ReturnType<typeof gate>): Stub { + return [ + Sessions, + (_input, _principal, self) => { + const rows = self.state<Session[]>([SESSION]); + const update = (ctx: Ctx, patch: Partial<Session>) => + rows.replace( + ctx, + (rows.value ?? []).map((row) => ({ ...row, ...patch })), + ); + return { + variant: "owner", + sessions: rows, + defaultCwd: self.state("/work"), + defaultEnv: self.state(null), + operations: self.state([]), + dismissOperation: () => {}, + rename: async (args: { title: string }, ctx: Ctx) => { + await held.call(); + update(ctx, { title: args.title }); + }, + pin: async (args: { pinned: boolean }, ctx: Ctx) => { + await held.call(); + update(ctx, { pinned: args.pinned }); + }, + // The owner variant's other methods: not used here. + create: () => SESSION, + relocate: () => {}, + archive: () => {}, + restore: () => {}, + clone: () => SESSION, + fork: () => SESSION, + }; + }, + ]; +} + +test("session selector: a rename and a pin show before their calls answer; a refused rename rolls back", async (t) => { + const held = gate(); + const { screen, send, runtime } = await topology(t, { + server: [catalog(held)], + worker: [], + facets: [tuiCoreFacet, sessionsTui], + columns: 100, + }); + runtime.navigate("sessions", "s"); + const listed = async (title: string) => new RegExp(`^(?: {2}|› )${title}`, "m").test(await screen()); + await poll(() => listed("Fixture")); + send(DOWN); + send(CTRL_R); + await poll(async () => (await screen()).includes("Rename session")); + send(CTRL_A); + send(CTRL_K); + typed(send, "Guessed title"); + send(ENTER); + // The call is held open: the row already shows the new title. + await poll(() => listed("Guessed title")); + held.pass(); + await poll(async () => (await screen()).includes("Session name: Guessed title")); + assert(await listed("Guessed title")); + // A pin: with only pinned sessions listed, the row stays listed while the call is held open. + held.reset(); + send(ALT_P); + send(CTRL_N); + await poll(async () => (await screen()).includes("Pinned: Only")); + assert(await listed("Guessed title")); + held.pass(); + await poll(async () => /^\s*Pinned\s*$/m.test(await screen())); + send(CTRL_N); + // A refused rename: shown, then the old title is back and the error is a status line. + held.reset(); + send(DOWN); + send(CTRL_R); + await poll(async () => (await screen()).includes("Rename session")); + send(CTRL_A); + send(CTRL_K); + typed(send, "Refused title"); + send(ENTER); + await poll(() => listed("Refused title")); + held.refuse("titles are frozen"); + await poll(() => listed("Guessed title")); + await poll(() => + runtime.notices.some((notice) => notice.kind === "error" && notice.text.includes("titles are frozen")), + ); +}); + +test("chat: a sent message shows and leaves the editor at once; a refused one comes back to the editor", async (t) => { + const held = gate(); + const sent: string[] = []; + const { screen, send, runtime } = await topology(t, { + server: [[Sessions, (_input, _principal, self) => ({ variant: "guest", sessions: self.state([SESSION]) })]], + worker: [ + [ + Chat, + (_input, _principal, self) => ({ + variant: "participant", + view: self.state(VIEW), + model: self.state(META), + liveShell: self.state(null), + stats: self.state(null), + entries: () => ({ items: [], more: false }), + send: async (args: { text: string }) => { + sent.push(args.text); + await held.call(); + return { submissionId: sent.length }; + }, + stop: () => ({ restored: [] }), + dequeue: () => ({ text: null }), + configure: () => {}, + compact: () => ({ taskId: 1 }), + abortCompaction: () => ({ aborted: 0 }), + reset: () => {}, + dequeueOwn: () => ({ restored: [], skipped: 0 }), + shell: () => ({ shellId: "x" }), + abortShell: () => ({ aborted: false }), + }), + ], + ], + facets: [tuiCoreFacet, chatTui], + columns: 100, + }); + runtime.navigate("chat", "s"); + const chat = () => { + const surface = runtime.ctx.surface("chat"); + assert(surface instanceof ChatSurface); + return surface; + }; + await poll(() => chat().editable); + typed(send, "hello there"); + send(ENTER); + await poll(() => sent.length === 1); + // Held open: the editor is empty and the message is in the transcript. + assert.equal(chat().editor.getText(), ""); + await poll(async () => (await screen()).includes("hello there")); + held.refuse("not now"); + // Refused: back in the editor, out of the transcript, the error on a status line. + await poll(() => chat().editor.getText() === "hello there"); + await poll(() => runtime.notices.some((notice) => notice.kind === "error" && notice.text.includes("not now"))); + // Only the editor shows it now, not the transcript. + await poll(async () => (await screen()).split("hello there").length === 2); +}); + +test("session selector: lifecycle operations mark their rows; a failure is announced and /dismiss-failed clears it", async (t) => { + const dismissed: string[] = []; + const { screen, runtime } = await topology(t, { + server: [ + [ + Sessions, + (_input, _principal, self) => { + const operations = self.state<Operation[]>([ + { phase: "running", id: "op1", kind: "relocate", session: "s", target: null, copying: false }, + ]); + return { + variant: "owner", + sessions: self.state<Session[]>([SESSION]), + defaultCwd: self.state("/work"), + defaultEnv: self.state(null), + operations, + dismissOperation: ({ operation }: { operation: string }, ctx: Ctx) => { + dismissed.push(operation); + operations.replace( + ctx, + (operations.value ?? []).filter((each) => each.id !== operation), + ); + }, + // The worker refuses the move: the job ends failed. + rename: (_args: unknown, ctx: Ctx) => + operations.replace(ctx, [ + { + phase: "failed", + id: "op1", + kind: "relocate", + session: "s", + target: null, + error: "Stop the current run first", + }, + ]), + create: () => SESSION, + relocate: () => {}, + pin: () => {}, + archive: () => {}, + restore: () => {}, + clone: () => SESSION, + fork: () => SESSION, + }; + }, + ], + ], + worker: [], + facets: [tuiCoreFacet, sessionsTui], + columns: 100, + }); + runtime.navigate("sessions", "s"); + await poll(async () => /Fixture.*moving…/.test(await screen())); + // (The stub turns the running move into a failed one on any rename.) + const surface = runtime.ctx.surface("sessions"); + assert(surface instanceof SessionsSurface); + surface.renameSession("s", "x"); + await poll(() => + runtime.notices.some( + (notice) => notice.kind === "error" && notice.text.includes("Move failed: Stop the current run first"), + ), + ); + await poll(async () => /Fixture.*Move failed/.test(await screen())); + assert(runtime.ctx.commands.run("p2.session.dismissFailed")); + await poll(() => dismissed.length === 1); + await poll(async () => !(await screen()).includes("Move failed ")); +}); diff --git a/apps/p2/test/p2/tui-p4.test.ts b/apps/p2/test/p2/tui-p4.test.ts index b777d3a2a..5d5dafa19 100644 --- a/apps/p2/test/p2/tui-p4.test.ts +++ b/apps/p2/test/p2/tui-p4.test.ts @@ -103,9 +103,13 @@ function stubs(calls: Calls, sessions: Session[], options: { view?: Conversation sessions: sessionsState, defaultCwd, defaultEnv, - create: (args: { cwd: string }, ctx: Ctx) => { - record("create", args); - const created = session(`new-${(calls.create ?? []).length}`, { + operations: self.state([]), + dismissOperation: () => {}, + create: (args: { id: string; cwd: string }, ctx: Ctx) => { + const { id, ...location } = args; + record("create", location); + record("created", id); + const created = session(id, { cwd: args.cwd, updatedAt: Date.now(), }); @@ -210,8 +214,9 @@ function stubs(calls: Calls, sessions: Session[], options: { view?: Conversation ], [ Search, - () => ({ + (_input, _principal, self) => ({ variant: "owner", + rebuilds: self.state([]), search: ({ query }: { query: string }) => ({ sessions: [], messages: [ @@ -469,7 +474,8 @@ test("/new, /name and /session", async (t) => { send(ENTER); await poll(() => (calls.create?.length ?? 0) === 1); assert.deepEqual(calls.create?.[0], { cwd: "/work", env: null }); - await poll(() => runtime.session === "new-1"); + // The TUI chose the new session's ID and opened it at once. + assert.equal(runtime.session, calls.created?.[0]); }); test("chat controls: /compact, Escape aborts compaction, /new-context, Alt+Up restores own queued messages", async (t) => { diff --git a/apps/p2/test/p2/tui-render.test.ts b/apps/p2/test/p2/tui-render.test.ts index 7baa98dad..bcd6522a4 100644 --- a/apps/p2/test/p2/tui-render.test.ts +++ b/apps/p2/test/p2/tui-render.test.ts @@ -515,13 +515,13 @@ test("while the conversation connects, typed keys stay in the editor and Enter s const { runtime, stubs, screen, send } = await chatRuntime(t, () => viewOf([answerEntry(1, "ready")]), { ready }); for (const char of "typed early") send(char); send("\r"); - await poll(() => runtime.drafts.get("s#1") === "typed early"); - await poll(async () => (await screen()).includes("Sending when connected…")); + // Shown at once (no transcript yet: in the pending area) and out of the editor; sent once the conversation is live. + await poll(() => runtime.drafts.get("s#1") === ""); + await poll(async () => (await screen()).includes("Sending: typed early")); assert.equal(stubs.get(1)?.sent.length ?? 0, 0); open(); await poll(() => stubs.get(1)?.sent.length === 1); assert.equal(stubs.get(1)!.sent[0]!.text, "typed early"); - await poll(() => runtime.drafts.get("s#1") === ""); }); test("Ctrl+D exits only on an empty editor; Ctrl+C clears, twice exits", async (t) => { diff --git a/apps/p2/test/p2/tui-tree.test.ts b/apps/p2/test/p2/tui-tree.test.ts index 9c7d8c608..c13a72802 100644 --- a/apps/p2/test/p2/tui-tree.test.ts +++ b/apps/p2/test/p2/tui-tree.test.ts @@ -66,6 +66,8 @@ test("/tree switches branches with the chosen message in the editor; double Esca sessions: rows, defaultCwd: self.state("/work"), defaultEnv: self.state(null), + operations: self.state([]), + dismissOperation: () => {}, create: () => SESSION, relocate: () => {}, rename: () => {}, diff --git a/apps/p2/test/p2/tui.test.ts b/apps/p2/test/p2/tui.test.ts index bb3096f1d..4f2d29084 100644 --- a/apps/p2/test/p2/tui.test.ts +++ b/apps/p2/test/p2/tui.test.ts @@ -178,6 +178,8 @@ test("bare-host TUI facets register, render session/model pickers and reload wit sessions, defaultCwd, defaultEnv, + operations: self.state([]), + dismissOperation: () => {}, create: () => session, relocate: () => {}, rename: () => {}, diff --git a/apps/p2/test/p2/ui-optimistic.test.ts b/apps/p2/test/p2/ui-optimistic.test.ts new file mode 100644 index 000000000..1af160e6f --- /dev/null +++ b/apps/p2/test/p2/ui-optimistic.test.ts @@ -0,0 +1,42 @@ +// `useOptimisticCall` (docs/optimistic-ui.md) in a real browser: a tap shows its guess at once and the pending state +// while the call is in flight; the server's value replaces the guess when the call passes; a refused call rolls the +// value back and shows the error. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { Browser, browserTests } from "./browser.ts"; +import { startFixture } from "./helpers.ts"; + +test( + "useOptimisticCall: guess at once, server value on success, visible rollback on refusal", + { skip: !browserTests, timeout: 60_000 }, + async () => { + const fixture = await startFixture({ web: true, plugins: ["web-shell"] }); + let browser: Browser | undefined; + try { + browser = await Browser.start(); + await browser.open(`http://127.0.0.1:${fixture.server.port}`); + await browser.wait("document.body"); + await browser.loadFixture("optimistic.ts"); + await browser.wait("document.querySelector('#switch')?.textContent === 'off'"); + const text = (selector: string) => + browser!.evaluate<string>(`document.querySelector('${selector}').textContent`); + // The guess shows while the call is held open, with the pending state. + await browser.evaluate("document.querySelector('#switch').click()"); + await browser.wait("document.querySelector('#switch').textContent === 'on'"); + assert.equal(await text("#pending"), "pending"); + await browser.evaluate("window.settleCall({ pass: true })"); + await browser.wait("document.querySelector('#pending').textContent === 'idle'"); + assert.equal(await text("#switch"), "on"); + // Refused: the guess goes, the value is the server's again, and the error is shown. + await browser.evaluate("document.querySelector('#switch').click()"); + await browser.wait("document.querySelector('#switch').textContent === 'off'"); + await browser.evaluate("window.settleCall({ refuse: 'not allowed' })"); + await browser.wait("document.querySelector('#switch').textContent === 'on'"); + assert.equal(await text("#error"), "not allowed"); + assert.equal(await text("#pending"), "idle"); + } finally { + await browser?.close(); + await fixture.close(); + } + }, +); diff --git a/apps/p2/test/p2/ui-sheets.test.ts b/apps/p2/test/p2/ui-sheets.test.ts index 77ac1f3b1..c498df9c4 100644 --- a/apps/p2/test/p2/ui-sheets.test.ts +++ b/apps/p2/test/p2/ui-sheets.test.ts @@ -122,7 +122,7 @@ describe("sheet parity", { skip: !browserTests }, () => { socket?.close(); process?.kill("SIGTERM"); await fixture?.close(); - if (profile) rmSync(profile, { recursive: true, force: true }); + if (profile) rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }); it("Done returns to All providers, and Back cancels and dismisses an unfinished provider flow", async () => { diff --git a/apps/p2/test/p2/voice.test.ts b/apps/p2/test/p2/voice.test.ts index d5d52807b..cc167cea7 100644 --- a/apps/p2/test/p2/voice.test.ts +++ b/apps/p2/test/p2/voice.test.ts @@ -777,15 +777,18 @@ describe("voice mode's switch and voice", () => { "participant", ); await owner.set({ enabled: true }, BACKGROUND_CONTEXT); + // The answer follows its state: the owner's switch and voice are on the page when each call resolves. + assert.equal(owner.enabled.value, true); await owner.choose({ engine: "kokoro", voice: "kokoro-af_heart", speed: 1, language: "de" }, BACKGROUND_CONTEXT); + assert.equal(owner.voice.value?.voice, "kokoro-af_heart"); // The guest sees the owner's choice, language included. assert.equal((await until(alice.voice, (voice) => voice?.voice === "kokoro-af_heart"))?.language, "de"); assert.equal(alice.enabled.value, false, "a guest's switch is its own"); await alice.set({ enabled: true }, BACKGROUND_CONTEXT); - await until(alice.enabled, (value) => value); + assert.equal(alice.enabled.value, true); // The guest turning its microphone off leaves the owner's voice mode on. await alice.set({ enabled: false }, BACKGROUND_CONTEXT); - await until(alice.enabled, (value) => !value); + assert.equal(alice.enabled.value, false); assert.equal(owner.enabled.value, true); // A reference carries every variant's members; the participant's binding refuses the owner's (cast: the type has // only the participant's). diff --git a/packages/chord/docs/design.md b/packages/chord/docs/design.md index 8e6d835df..91d987fcd 100644 --- a/packages/chord/docs/design.md +++ b/packages/chord/docs/design.md @@ -2521,8 +2521,9 @@ Decision "Slice 11's points", "Slice 11, P3" to "P9", and "Tutorial gaps" (1) (p with an empty environment, `--permission` granting nothing and no SQLite; the builtin allowlist and a frozen substitute `process` inside; a bridge terminal; the trusted launcher outside, confirming copy, open and attach on its own prompt); a component scope (`hold`, `watch`, `registry`, released on dispose, one `changed` callback for the - app's redraw; `pageState(key, initial)`, the host's page state shared with `usePageState`; `optimistic(read)`, a - reader showing an optimistic value while a call is pending, React's `useOptimistic`, §3), no pi-tui dependency. + app's redraw; `pageState(key, initial)`, the host's page state shared with `usePageState`; `optimistic(read, reduce?)`, + a reader showing optimistic actions while their calls are pending, with `add` and `pending`, React's `useOptimistic`, + §3), no pi-tui dependency. ### Messages and languages (built after slice 11) diff --git a/packages/chord/docs/guide-plugins-hosts/book.md b/packages/chord/docs/guide-plugins-hosts/book.md index f0d573b35..fd633cd4b 100644 --- a/packages/chord/docs/guide-plugins-hosts/book.md +++ b/packages/chord/docs/guide-plugins-hosts/book.md @@ -445,13 +445,18 @@ full (design §3). Without that, the answer could overtake the index change and So the rule for a method that should feel instant: it awaits its own changes, the pushes to other hosts included (the worker's `rename` awaits its report to the index), and keeps them on the host that answers or relays the answer. -A terminal UI does the same with its component scope: `scope.optimistic(read)` wraps a reader and returns it with -`show(value, call)`, which shows `value` until the call settles (until every call shown, when they overlap): +A terminal UI does the same with its component scope: `scope.optimistic(read, reduce?)` wraps a reader and returns +it with `add(action, call)` and `pending()`, as React's `useOptimistic`: while calls are in flight the reader shows +`reduce(current, action)` over each action, in order (without a reducer the last action is the value), until every +call added settles. `add` returns the call; a rejection is the caller's to handle. ```ts const titles = scope.watch(sessions.titles); -const [title, show] = scope.optimistic(() => titles()?.[id]); -await show(next, session.rename(next, ctx)); // title() is `next` at once, the real one when it settles +const [title, add] = scope.optimistic(() => titles()?.[id]); +await add(next, session.rename(next, ctx)); // title() is `next` at once, the real one when it settles + +const [rows, change] = scope.optimistic(() => list() ?? [], (rows, id: string) => rows.filter((row) => row.id !== id)); +change(id, store.remove({ id }, ctx)).catch(fail); // gone at once; back with the error if refused ``` `test/examples/63-optimistic-answers.ts` runs this with a stalled terminal client and prints what each redraw showed. diff --git a/packages/chord/docs/guide-plugins-hosts/index.html b/packages/chord/docs/guide-plugins-hosts/index.html index a1c99f574..c83367255 100644 --- a/packages/chord/docs/guide-plugins-hosts/index.html +++ b/packages/chord/docs/guide-plugins-hosts/index.html @@ -442,11 +442,16 @@ own and may come later.</li> </ul> <p>So the rule for a method that should feel instant: it awaits its own changes, the pushes to other hosts included (the worker's <code>rename</code> awaits its report to the index), and keeps them on the host that answers or relays the answer.</p> -<p>A terminal UI does the same with its component scope: <code>scope.optimistic(read)</code> wraps a reader and returns it with -<code>show(value, call)</code>, which shows <code>value</code> until the call settles (until every call shown, when they overlap):</p> +<p>A terminal UI does the same with its component scope: <code>scope.optimistic(read, reduce?)</code> wraps a reader and returns +it with <code>add(action, call)</code> and <code>pending()</code>, as React's <code>useOptimistic</code>: while calls are in flight the reader shows +<code>reduce(current, action)</code> over each action, in order (without a reducer the last action is the value), until every +call added settles. <code>add</code> returns the call; a rejection is the caller's to handle.</p> <pre><code><span class="tk-k">const</span> titles = scope.<span class="tk-f">watch</span>(sessions.titles); -<span class="tk-k">const</span> [title, show] = scope.<span class="tk-f">optimistic</span>(() => <span class="tk-f">titles</span>()?.[id]); -<span class="tk-k">await</span> <span class="tk-f">show</span>(next, session.<span class="tk-f">rename</span>(next, ctx)); <span class="tk-c">// title() is `next` at once, the real one when it settles</span></code></pre> +<span class="tk-k">const</span> [title, add] = scope.<span class="tk-f">optimistic</span>(() => <span class="tk-f">titles</span>()?.[id]); +<span class="tk-k">await</span> <span class="tk-f">add</span>(next, session.<span class="tk-f">rename</span>(next, ctx)); <span class="tk-c">// title() is `next` at once, the real one when it settles</span> + +<span class="tk-k">const</span> [rows, change] = scope.<span class="tk-f">optimistic</span>(() => <span class="tk-f">list</span>() ?? [], (rows, id: string) => rows.<span class="tk-f">filter</span>((row) => row.id !== id)); +<span class="tk-f">change</span>(id, store.<span class="tk-f">remove</span>({ id }, ctx)).<span class="tk-f">catch</span>(fail); <span class="tk-c">// gone at once; back with the error if refused</span></code></pre> <p><code>test/examples/63-optimistic-answers.ts</code> runs this with a stalled terminal client and prints what each redraw showed.</p> </section> diff --git a/packages/chord/src/tui/README.md b/packages/chord/src/tui/README.md index 0f1491b94..34c026ed4 100644 --- a/packages/chord/src/tui/README.md +++ b/packages/chord/src/tui/README.md @@ -29,8 +29,9 @@ startTerminalRuntime<P>({ - `scope.ts`: `TuiScope(host, changed)`: `hold`, `watch` (a reader, `undefined` while no value), `registry`, `pageState(key, initial)` (the host's value at `key`, shared with React's `usePageState`: a scope opened again after a plugin reload finds it), `messages(t)` (a plugin's messages in the host's language at each call; a change of the - locale or a translation calls `changed`), `optimistic(read)` (the reader and `show(value, call)`: `value` shown until - every call shown settled, React's `useOptimistic`), `dispose` (no `changed` after it). References wait and rebind by + locale or a translation calls `changed`), `optimistic(read, reduce?)` (React's `useOptimistic`: the reader, `add(action, + call)` showing `reduce(current, action)` over every action in flight (no reducer: the last action) until every call + added settled, and `pending`), `dispose` (no `changed` after it). References wait and rebind by themselves; no terminal library dependency. Tests: `test/tui/` and, with the launcher and sandbox, `test/node/tui/`. diff --git a/packages/chord/src/tui/index.ts b/packages/chord/src/tui/index.ts index dba8fa5a1..859e237bc 100644 --- a/packages/chord/src/tui/index.ts +++ b/packages/chord/src/tui/index.ts @@ -10,6 +10,6 @@ export { type TerminalRuntime, type TerminalRuntimeOptions, } from "./runtime.ts"; -export { TuiScope } from "./scope.ts"; +export { type OptimisticAdd, TuiScope } from "./scope.ts"; export { BridgeTerminal } from "./terminal.ts"; export { TerminalTheme } from "./theme.ts"; diff --git a/packages/chord/src/tui/scope.ts b/packages/chord/src/tui/scope.ts index 078bdc612..99d407192 100644 --- a/packages/chord/src/tui/scope.ts +++ b/packages/chord/src/tui/scope.ts @@ -15,6 +15,9 @@ import { hostLanguage, type Language, type MessageData, type Messages, type Tran import { type PageState, pageState } from "../surfaces/page-state.ts"; import type { NativeWatchable, Watchable } from "../watch.ts"; +/** Adds an optimistic action shown while `call` is in flight; returns `call` (see `TuiScope.optimistic`). */ +export type OptimisticAdd<A> = <R>(action: A, call: Promise<R>) => Promise<R>; + /** A component's scope: what it holds and follows, until `dispose`. */ export class TuiScope<P extends Principal = Principal> { readonly #host: Host<P>; @@ -74,32 +77,52 @@ export class TuiScope<P extends Principal = Principal> { } /** - * `read` with an optimistic value (React's `useOptimistic`): `show(value, call)` returns `call` and shows `value` in - * place of `read`'s until every call shown so settled, then `read`'s again; each change calls `changed`. A call over - * a link settles after the changes its answering host committed before answering (decision "A call's answer follows - * its state"), so the value shown next is the real one. + * `read` with optimistic values, as React's `useOptimistic`: `add(action, call)` returns `call` and, until every call + * added so settled, the reader shows `reduce(current, action)` applied over `read`'s value for each action in flight, + * in order (without `reduce`, the last action is the value); then `read`'s again. `pending` says a call is in flight. + * Each change calls `changed`. A call over a link settles after the changes its answering host committed before + * answering (decision "A call's answer follows its state"), so the value shown next is the real one. A rejected + * call is its caller's to handle (React leaves it to the transition); its action goes as a resolved one's does. */ - optimistic<T>( - read: () => T | undefined, - ): readonly [read: () => T | undefined, show: <R>(value: T, call: Promise<R>) => Promise<R>] { - let shown: T | undefined; + optimistic<T>(read: () => T): readonly [read: () => T, add: OptimisticAdd<T>, pending: () => boolean]; + optimistic<T, A>( + read: () => T, + reduce: (current: T, action: A) => T, + ): readonly [read: () => T, add: OptimisticAdd<A>, pending: () => boolean]; + optimistic<T, A>( + read: () => T, + // Without `reduce` the first overload makes the action a `T`: the last one is the value. + reduce: (current: T, action: A) => T = (_current, action) => action as unknown as T, + ): readonly [read: () => T, add: OptimisticAdd<A>, pending: () => boolean] { + // Kept until every call settled, as React keeps the updates of transitions still in flight. + let actions: A[] = []; let pending = 0; const redraw = (): void => { if (!this.#abort.signal.aborted) this.#changed(); }; const settled = (): void => { - if (--pending === 0) shown = undefined; + if (--pending === 0) actions = []; redraw(); }; - const show = <R>(value: T, call: Promise<R>): Promise<R> => { - shown = value; + const add = <R>(action: A, call: Promise<R>): Promise<R> => { + actions = [...actions, action]; pending++; // Its caller handles a rejection of `call`; this only follows it. call.then(settled, settled); redraw(); return call; }; - return [() => (pending > 0 ? shown : read()), show]; + // The value shown is computed again only when `read`'s value or the actions changed: a redraw reading it often + // gets the same value (by identity) until then. + let last: { base: T; actions: A[]; value: T } | undefined; + const shown = (): T => { + const base = read(); + if (pending === 0) return base; + if (last?.base !== base || last.actions !== actions) + last = { base, actions, value: actions.reduce(reduce, base) }; + return last.value; + }; + return [shown, add, () => pending > 0]; } /** diff --git a/packages/chord/test/examples/63-optimistic-answers.ts b/packages/chord/test/examples/63-optimistic-answers.ts index ceff74a64..f211899e7 100644 --- a/packages/chord/test/examples/63-optimistic-answers.ts +++ b/packages/chord/test/examples/63-optimistic-answers.ts @@ -179,7 +179,7 @@ const scope = new TuiScope(client, () => drawn.push(title())); const sessions = scope.hold(Sessions, {}); const session = scope.hold(SessionTitle, { id: "S1" }); const titles = scope.watch(sessions.titles); -const [title, show] = scope.optimistic(() => titles()?.S1); +const [title, add] = scope.optimistic(() => titles()?.S1); await session.rename("Draft", context); await settle(); console.log("the index shows:", title()); @@ -194,7 +194,7 @@ for (const id of ["S2", "S3", "S4"]) await index.report({ id, title: `session ${ // (whose change waits in the server's window too) and answers. The server sends its waiting state before it forwards // the answer, past its window. drawn.length = 0; -const renamed = show("Renamed", session.rename("Renamed", context)); +const renamed = add("Renamed", session.rename("Renamed", context)); await settle(); console.log("while the call is pending, the title shows:", title()); diff --git a/packages/chord/test/tui/scope.test.ts b/packages/chord/test/tui/scope.test.ts index 70b827a39..7e26d9758 100644 --- a/packages/chord/test/tui/scope.test.ts +++ b/packages/chord/test/tui/scope.test.ts @@ -99,10 +99,10 @@ describe("a scope's optimistic values", () => { let changes = 0; const scope = new TuiScope(h, () => changes++); const notes = scope.hold(Notes, {}); - const [text, show] = scope.optimistic(scope.watch(notes.text)); + const [text, add] = scope.optimistic(scope.watch(notes.text)); await until(() => text() === "first"); const before = changes; - const written = show("second", notes.write("second", context)); + const written = add("second", notes.write("second", context)); expect(text()).toBe("second"); expect(changes).toBe(before + 1); await written; @@ -116,11 +116,11 @@ describe("a scope's optimistic values", () => { const h = await host(); const calls: string[] = []; const scope = new TuiScope(h, () => calls.push(text()!)); - const [text, show] = scope.optimistic(() => "real"); + const [text, add] = scope.optimistic(() => "real"); const first = Promise.withResolvers<null>(); const second = Promise.withResolvers<null>(); - show("one", first.promise); - const failing = show("two", second.promise); + add("one", first.promise); + const failing = add("two", second.promise); first.resolve(null); await first.promise; await Promise.resolve(); @@ -133,13 +133,45 @@ describe("a scope's optimistic values", () => { await h.close(context); }); + it("apply a reducer over every action in flight, in order, and say whether a call is pending", async () => { + const h = await host(); + const scope = new TuiScope(h, () => {}); + let real = ["a", "b", "c"]; + const [list, add, pending] = scope.optimistic( + () => real, + (current: string[], removed: string) => current.filter((each) => each !== removed), + ); + const first = Promise.withResolvers<null>(); + const second = Promise.withResolvers<null>(); + add("a", first.promise); + add("c", second.promise); + expect(list()).toEqual(["b"]); + // Unchanged reader and actions: the same value, not a new array per read. + expect(list()).toBe(list()); + expect(pending()).toBe(true); + // The first call settled with its change committed: both actions stay until the second settles too. + real = ["b", "c"]; + first.resolve(null); + await first.promise; + await Promise.resolve(); + expect(list()).toEqual(["b"]); + real = ["b"]; + second.resolve(null); + await second.promise; + await Promise.resolve(); + expect(list()).toBe(real); + expect(pending()).toBe(false); + scope.dispose(); + await h.close(context); + }); + it("never call changed after dispose", async () => { const h = await host(); let changes = 0; const scope = new TuiScope(h, () => changes++); - const [, show] = scope.optimistic(() => "real"); + const [, add] = scope.optimistic(() => "real"); const call = Promise.withResolvers<null>(); - show("shown", call.promise); + add("shown", call.promise); scope.dispose(); call.resolve(null); await call.promise;