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.
215 lines
8.6 KiB
TypeScript
215 lines
8.6 KiB
TypeScript
// The message index, the workers' feed into it, and owner search over sessions and messages.
|
|
|
|
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, type Rebuild, RebuildSchema, Search, SearchFeed, SearchIndex, SessionReads } from "./contract.ts";
|
|
import { SearchDb } from "./db.ts";
|
|
import { ftsQuery } from "./query.ts";
|
|
|
|
/** Sidebar order: by directory, pinned first, most recently updated first. */
|
|
const sidebarOrder = (a: Session, b: Session): number =>
|
|
a.cwd.localeCompare(b.cwd) || Number(b.pinned) - Number(a.pinned) || b.updatedAt - a.updatedAt;
|
|
|
|
/** The newest root answer and the owner's read marker per session, mirrored from the database. */
|
|
const Markers = defineState("p2.search-index", {
|
|
schema: obj({ answers: record(num()), reads: record(num()) }),
|
|
version: 1,
|
|
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);
|
|
await markers.change((draft) => {
|
|
draft.answers = db.answers();
|
|
draft.reads = db.reads();
|
|
}, ctx);
|
|
return db;
|
|
},
|
|
(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) => {
|
|
if (through <= (markers.value.reads[session] ?? 0)) return null;
|
|
dbResource.value.markRead(session, through);
|
|
await markers.change((draft) => {
|
|
draft.reads[session] = through;
|
|
}, ctx);
|
|
return null;
|
|
},
|
|
cursor: async ({ session, conversation = 1 }) => dbResource.value.cursor(session, conversation),
|
|
index: async ({ session, conversation = 1, activityAt = 0, messages, through }, ctx) => {
|
|
const completed = through > dbResource.value.cursor(session, conversation);
|
|
dbResource.value.index(session, conversation, messages, through);
|
|
const answer = Math.max(
|
|
0,
|
|
...messages.filter((message) => message.role === "assistant").map((message) => message.entryId),
|
|
);
|
|
// Unread answers are the top-level conversations' (the main one and the other `/tree` branches): a branch left
|
|
// while running answers in the background. `branches` is empty until the worker first reports it.
|
|
const row =
|
|
answer > (markers.value.answers[session] ?? 0) ? await catalog.get({ id: session }, ctx) : undefined;
|
|
if (
|
|
row &&
|
|
(conversation === row.main || row.branches.some((branch) => branch.conversation === conversation))
|
|
)
|
|
await markers.change((draft) => {
|
|
draft.answers[session] = answer;
|
|
}, ctx);
|
|
await catalog.activity(
|
|
{
|
|
id: session,
|
|
timestamp: Math.max(activityAt, ...messages.map((message) => message.timestamp)),
|
|
firstUserText: completed ? dbResource.value.firstUserText(session) : undefined,
|
|
},
|
|
ctx,
|
|
);
|
|
return null;
|
|
},
|
|
reset: async ({ session }) => {
|
|
dbResource.value.reset(session);
|
|
return null;
|
|
},
|
|
messages: async ({ query, sessions }) => dbResource.value.messages(query, sessions),
|
|
history: async ({ session, query, conversation, before, limit }) => {
|
|
const db = dbResource.value;
|
|
const indexedThrough = db.cursor(session, conversation);
|
|
const match = ftsQuery(query);
|
|
if (match === undefined) return { hits: [], more: false, indexedThrough };
|
|
return { ...db.conversationMessages(session, conversation, match, before, limit), indexedThrough };
|
|
},
|
|
};
|
|
});
|
|
|
|
host.serve(SessionReads, (_params, principal, self) => {
|
|
// Guests never read for the owner, nor see what the owner has read.
|
|
if (!isOwner(principal)) throw forbidden("read markers are owner-only");
|
|
const index = self.get(SearchIndex, {});
|
|
return {
|
|
variant: "owner",
|
|
members: { answers: index.answers, reads: index.reads, markRead: (args, ctx) => index.markRead(args, ctx) },
|
|
};
|
|
});
|
|
|
|
host.serve(SearchFeed, (_params, principal, self) => {
|
|
// Only a session's own worker feeds it; the session comes from the principal, never from arguments.
|
|
if (!isWorker(principal)) throw forbidden("the search feed is for session workers");
|
|
const { session } = principal;
|
|
const index = self.get(SearchIndex, {});
|
|
return {
|
|
variant: "worker",
|
|
members: {
|
|
cursor: (args, ctx) => index.cursor({ session, conversation: args?.conversation ?? 1 }, ctx),
|
|
index: (batch, ctx) => {
|
|
const conversation = batch.conversation ?? 1;
|
|
if (batch.messages.some((message) => message.conversationId !== conversation))
|
|
throw forbidden("index messages must belong to the batch conversation");
|
|
return index.index({ session, ...batch, conversation }, ctx);
|
|
},
|
|
// The session is the worker's own, from its principal: a worker searches only its session.
|
|
history: (query, ctx) => index.history({ session, ...query }, ctx),
|
|
},
|
|
};
|
|
});
|
|
|
|
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) ?? "" })),
|
|
};
|
|
},
|
|
},
|
|
};
|
|
},
|
|
{ accepts: { 1: {} } },
|
|
);
|
|
});
|