pi-mono/apps/p2/plugins/conversations/server.ts
Mario Zechner 20d86c3657 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.
2026-11-05 18:22:13 +00:00

99 lines
4.4 KiB
TypeScript

import { awaitWithContext, defineFacet, forbidden, isChordError, isWorker, signalOf } from "@p2/app/v1";
import { Hosts } from "@p2/app/v1/server";
import { Catalog } from "../sessions/contract.ts";
import { StatusStore } from "../status/contract.ts";
import { ConversationBroker, Conversations, PeerConversation } from "./contract.ts";
// The server's side of peer conversations: the broker between session workers (a local object, so its references act
// as the server, whom `PeerConversation` serves), and the workers' view of it, fenced by worker generation.
export default defineFacet((host) => {
host.serve(ConversationBroker, (_params, self) => {
const catalog = self.get(Catalog, {});
const statuses = self.get(StatusStore, {});
return {
query: async ({ source, requestId, origin, query }, ctx) => {
const sender = await catalog.get({ id: source }, ctx);
if (sender.archivedAt !== null) throw forbidden("The sending session is archived");
if (query.action === "list") {
const sessions = catalog.sessions.value.filter((session) => session.archivedAt === null);
return {
action: "list",
self: source,
truncated: sessions.length > 200,
peers: sessions.slice(0, 200).map((session) => {
const status = statuses.statuses.value[session.id];
return {
id: session.id,
title: session.title,
model: `${session.model.provider}/${session.model.modelId}`,
state: !status?.running
? ("stopped" as const)
: status.busy.includes(1)
? ("working" as const)
: ("idle" as const),
};
}),
};
}
if (query.action === "send" && query.to === source) throw new Error("Cannot send a message to yourself");
const recipient = await catalog.get({ id: query.to }, ctx);
if (recipient.archivedAt !== null) throw new Error("The destination is archived");
if (query.action === "send" && !query.message.trim()) throw new Error("send needs a message");
const archived = async () =>
(await catalog.get({ id: source }, ctx)).archivedAt !== null ||
(await catalog.get({ id: recipient.id }, ctx)).archivedAt !== null;
// Each attempt holds the recipient's worker afresh, starting it if needed, as a browser opening it does.
const deliver = async () => {
const peer = await awaitWithContext(ctx.get(PeerConversation, { session: recipient.id }), ctx);
// Startup can yield to archive/restore. Recheck both sessions before admitting peer work.
if (await archived()) throw forbidden("Conversation is archived");
if (query.action === "answer") return { action: "answer" as const, answer: await peer.answer(ctx) };
const result = await peer.send(
{
source,
title: sender.title,
requestId,
message: query.message,
followUp: query.followUp,
...(origin ? { origin } : {}),
},
ctx,
);
return { action: "send" as const, result };
};
let delivered: Awaited<ReturnType<typeof deliver>>;
try {
delivered = await deliver();
} catch (error) {
// The worker went away under the reference (killed, stopped idle, paused): retry once with a fresh reference.
// The worker deduplicates a send by its request ID, so a send that did arrive is not repeated.
if (signalOf(ctx).aborted || !(isChordError(error, "unavailable") || isChordError(error, "gone")))
throw error;
delivered = await deliver();
}
if (delivered.action === "answer") {
if (await archived()) throw forbidden("Conversation is archived");
return { action: "answer", to: recipient.id, ...delivered.answer };
}
if (await archived()) throw forbidden("Conversation is archived; a sent message may already be delivered");
return { action: "send", to: recipient.id, ...delivered.result };
},
};
});
host.serve(Conversations, (_input, principal, self) => {
if (!isWorker(principal)) throw forbidden("conversation messaging is for session workers");
const broker = self.get(ConversationBroker, {});
const hosts = self.get(Hosts, {});
return {
variant: "worker",
members: {
query: async ({ requestId, origin, query }, ctx) => {
const current = hosts.workers.value[principal.session];
if (!current?.running || current.generation !== principal.generation) throw forbidden("stale worker");
return broker.query({ source: principal.session, requestId, ...(origin ? { origin } : {}), query }, ctx);
},
},
};
});
});