pi-mono/apps/p2/plugins/workspace/worker.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

316 lines
12 KiB
TypeScript

import { AgentDoc, type ConversationId, ROOT_CONVERSATION_ID } from "@earendil-works/pi-durable";
import type { ExecutionEnv } from "@earendil-works/pi-durable/env";
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";
import { Workspace, WorkspaceBackend, WorkspaceTransfer, WorkspaceWrites } from "./contract.ts";
import { Workspace as EnvironmentWorkspace } from "./environment.ts";
import { workspacePath, workspaceSearchText } from "./protocol.ts";
export default defineFacet((host) => {
host.serve(WorkspaceWrites, (_params, self) => {
const harness = harnessOf(self);
const worker = workerOf(self);
const writerResource = self.open(
async () => {
// One workspace per environment: the session's where it is, or a subagent's own location. A move brings new
// environments (`at` resolves at each write), so new workspaces; p2's private data directory is denied on
// this machine only. Weak: environments a move closed go with their workspaces.
const workspaces = new WeakMap<ExecutionEnv, Promise<EnvironmentWorkspace>>();
const workspaceAt = async (location: Location, signal: AbortSignal) => {
const at = await harness().environments.at(location);
let workspace = workspaces.get(at);
if (!workspace) {
workspace = (async () => {
const privatePath = isLocalEnvironment(at)
? await at.canonicalPath(worker().init.dataDir, durableContext(signal))
: undefined;
const denied =
privatePath === undefined ? [] : privatePath.ok ? [privatePath.value] : [worker().init.dataDir];
return new EnvironmentWorkspace(at, denied);
})();
workspace.catch(() => workspaces.delete(at));
workspaces.set(at, workspace);
}
return workspace;
};
return { workspaceAt, tail: Promise.resolve() };
},
() => {},
);
return {
write: async ({ location, upload }, ctx) => {
const writer = writerResource.value;
const previous = writer.tail;
let release!: () => void;
writer.tail = new Promise<void>((resolve) => {
release = resolve;
});
await previous;
try {
signalOf(ctx).throwIfAborted();
await (await writer.workspaceAt(location, signalOf(ctx))).write(upload, durableContext(ctx));
return null;
} finally {
release();
}
},
};
});
host.serve(WorkspaceBackend, ({ conversation }, self) => {
const harness = harnessOf(self);
const worker = workerOf(self);
const writes = self.get(WorkspaceWrites, {});
/** 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;
const handle = await durable.conversation(conversation as ConversationId, context);
if (!handle) throw new Error("Unknown conversation");
// Where the viewed conversation works: a subagent may have its own location (e.g. a git worktree, on another
// machine than the session now).
const { location, env } = await conversationEnvironment(harness().environments, handle.id, context);
// p2's private data directory is denied on this machine, decided from the environment (not the location, which
// lags behind it during a move).
const privatePath = isLocalEnvironment(env)
? await env.canonicalPath(worker().init.dataDir, context)
: undefined;
const root = await env.canonicalPath(env.cwd, context);
if (!root.ok) throw new Error("Working directory unavailable");
const denied =
privatePath === undefined ? undefined : privatePath.ok ? privatePath.value : worker().init.dataDir;
if (denied && (root.value === denied || root.value.startsWith(`${denied}/`)))
throw new Error("Workspace is private");
const privateRelative = denied?.startsWith(`${root.value.replace(/\/+$/, "")}/`)
? denied.slice(root.value.replace(/\/+$/, "").length + 1)
: undefined;
const workspace = new EnvironmentWorkspace(env, denied ? [denied] : []);
// The workspace answers before its watch is up: walking a large tree (a home folder) takes many seconds. A
// failed watch leaves the panel without live updates; once one is up, viewers read again (a change made before
// it would be missed otherwise).
const watch = workspace.watch(notify, context).then(
(watcher) => {
notify();
return watcher;
},
(error: unknown) => {
if (!context.abortSignal?.aborted)
console.error("p2 workspace: watching failed:", error instanceof Error ? error.message : error);
return undefined;
},
);
return { workspace, location, watch, privateRelative };
};
type Opened = Awaited<ReturnType<typeof openWorkspace>>;
const workspaceResource = self.open(
async (ctx) => {
const signal = signalOf(ctx);
const listeners = new Set<(event: null) => void>();
const events: Subscribable<null> = {
subscribe: (fn) => {
listeners.add(fn);
return () => {
listeners.delete(fn);
};
},
};
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);
};
const location = worker().location;
const closing = new AbortController();
// A superseded open is abandoned (a watch still walking its tree stops), and its watch closed.
const release = (slot: Slot): void => {
slot.abandon.abort();
void slot.opened
.then(({ watch }) => watch)
.then((watcher) => watcher?.stop(durableContext(new AbortController().signal)))
.catch(() => {});
};
// The workspace of the location's current version. A move makes it older: the next use (or the move's
// listener) opens it again where the conversation works now and closes the superseded watch. A failed open
// is not kept: the next use tries again. `moves` counts moves of the conversation's own location (a subagent
// moved by its main agent or by itself), which the session's location does not see.
let moves = 0;
type Slot = {
readonly version: string;
readonly opened: Promise<Opened>;
readonly abandon: AbortController;
};
let slot: Slot | undefined;
const current = (): Promise<Opened> => {
const version = `${location.version}:${moves}`;
if (slot?.version === version) return slot.opened;
const previous = slot;
const abandon = new AbortController();
const opened = openWorkspace(durableContext(AbortSignal.any([closing.signal, abandon.signal])), notify);
const mine = { version, opened, abandon };
slot = mine;
opened.catch(() => {
if (slot === mine) slot = undefined;
});
if (previous) release(previous);
return opened;
};
// Subscribed before the first open: a move while it activates is not missed. After a move, viewers are told
// to read again (the conversation's directory or machine changed).
const stopSession = location.subscribe(() => void current().then(notify, notify));
// The conversation's agent changed (an own location's move configures its cwd): open again if it moved.
const reopenIfMoved = async (): Promise<void> => {
const opened = await slot?.opened.catch(() => undefined);
const now = await harness().environments.locate(
conversation as ConversationId,
durableContext(closing.signal),
);
if (!opened || sameLocation(opened.location, now)) return;
moves++;
await current().then(notify, notify);
};
const agentCommits = harness().durable.observeCommits({
advance: (publication) => {
// The session's move waits, or failed: viewers show it with the workspace's place.
if (
publication.changes.some(
(change) =>
change.type === "document" &&
change.record.kind === LocationMoveDoc.definition.kind &&
change.record.scope.kind === "conversation" &&
change.record.scope.conversationId === ROOT_CONVERSATION_ID,
)
)
notify();
if (
publication.changes.some(
(change) =>
change.type === "document" &&
change.record.kind === AgentDoc.definition.kind &&
change.record.scope.kind === "conversation" &&
change.record.scope.conversationId === conversation,
)
)
void reopenIfMoved().catch(() => {});
},
// The Session closed or failed: the worker's Harness, and this resource with it, ends.
end: () => {},
});
const stop = (): void => {
stopSession();
agentCommits.detach();
};
// An abandoned activation aborts its open.
const abandon = (): void => closing.abort();
signal.addEventListener("abort", abandon, { once: true });
try {
await current();
} catch (error) {
stop();
throw error;
} finally {
signal.removeEventListener("abort", abandon);
}
return {
events,
current,
get version() {
return version;
},
close: async () => {
stop();
closing.abort();
clearTimeout(pending);
if (slot) release(slot);
},
};
},
(value) => value.close(),
);
return {
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)
throw new Error("Invalid workspace path");
if (query.kind === "search" && !workspaceSearchText(query.text)) throw new Error("Invalid search text");
const { workspace, location, privateRelative } = await workspaceResource.value.current();
if (query.kind === "place") {
const move = await harness().durable.snapshot(
LocationMoveDoc,
ROOT_CONVERSATION_ID,
durableContext(ctx),
);
return {
kind: "place",
location,
...(move?.pending ? { pending: move.pending } : {}),
...(move?.failed ? { failed: move.failed } : {}),
};
}
if (
privateRelative &&
"path" in query &&
(query.path === privateRelative || query.path.startsWith(`${privateRelative}/`))
)
throw new Error("Path is private");
if (
privateRelative &&
query.kind === "search" &&
(!query.path || privateRelative.startsWith(`${query.path}/`))
) {
const relative = query.path ? privateRelative.slice(query.path.length + 1) : privateRelative;
query = { ...query, exclude: [query.exclude, `./${relative}/`].filter(Boolean).join(",") };
}
return workspace.query(query, durableContext(ctx));
}),
read: ({ path, ...range }, ctx) =>
worker().liveness.call(async () =>
(await workspaceResource.value.current()).workspace.read(path, range, durableContext(ctx)),
),
write: async (upload, ctx) => {
await worker().liveness.call(async () =>
writes.write({ location: (await workspaceResource.value.current()).location, upload }, ctx),
);
return null;
},
};
});
host.serve(Workspace, ({ session, conversation }, principal, self) => {
if (!canSend(principal, session)) throw forbidden("participants only");
const backend = self.get(WorkspaceBackend, { conversation });
return {
variant: "reader",
members: { changes: backend.changes, query: (query, ctx) => backend.query(query, ctx) },
};
});
host.serve(WorkspaceTransfer, ({ conversation }, principal, self) => {
if (principal.kind !== "server") throw forbidden("server only");
const backend = self.get(WorkspaceBackend, { conversation });
return {
variant: "server",
members: {
read: (query, ctx) => backend.read(query, ctx),
write: (upload, ctx) => backend.write(upload, ctx),
},
};
});
});