mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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.
99 lines
4.4 KiB
TypeScript
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);
|
|
},
|
|
},
|
|
};
|
|
});
|
|
});
|