Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ harnesses as runtime package boundaries.
The browser edits Lexical bound to Yjs. One WebSocket multiplexes session,
document (`plan:*` on the wire), conversation, questions, comments, and
implementation lifecycle messages; the Projects sidebar's own `/ws/sidebar`
socket carries only decision counts. The server keeps each open channel as an
socket carries decision counts and says when a repository's documents change. The server keeps each open channel as an
authoritative Y.Doc with a headless Lexical mirror so it can validate and
serialize the document without trusting a browser.

Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/channels/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,8 @@ export function registerChannelRoutes(
onChannelArchived?: (channelId: string, now: Date) => Promise<ChannelArchiveResult>;
onChannelDeleted?: (channelId: string) => Promise<boolean>;
onChannelRenamed?: (channel: ChannelRecord) => void;
/** A person created a document. */
onChannelCreated?: (channel: ChannelRecord) => void;
renameChannel?: (id: string, title: string) => Promise<RenameResult>;
onChannelRestored?: (channelId: string, now: Date) => Promise<ChannelArchiveResult>;
/** Restores an earlier version's prose as a new edit by `author` (a user id). */
Expand Down Expand Up @@ -387,6 +389,7 @@ export function registerChannelRoutes(
}
}
if (!channel) throw new StorageError("conflict", "could not reserve a generated title");
options.onChannelCreated?.(channel);
return json(
{
repository: repository(repo),
Expand Down
26 changes: 25 additions & 1 deletion apps/server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,16 @@ import { SessionSockets } from "./socket/session-sockets";
import { StorageError } from "./storage/errors";
import { createStorage } from "./storage/registry";
import { WriterLease } from "./storage/writer-lease";
import { broadcast, fail, relay, reply, repositoryTopic, tell, topic } from "./wire";
import {
broadcast,
broadcastRepository,
fail,
relay,
reply,
repositoryTopic,
tell,
topic,
} from "./wire";

import { AGENT_PATH, LINK_CLOSE } from "@chopin/agent-link";

Expand Down Expand Up @@ -1939,9 +1948,21 @@ async function resetOpenAgents(
);
}

/** Tell every sidebar watching the document's repository that its documents changed. */
function announceCatalogue(channel: Pick<ChannelRecord, "id" | "repositoryId">): void {
if (!server || draining) return;
broadcastRepository(server, channel.repositoryId, {
kind: "sidebar:catalogue",
ts: 0,
repositoryId: channel.repositoryId,
channelId: channel.id,
});
}

function announceChannel(channel: ChannelRecord): void {
// A machine running its Planner hears a new address on its next Planner poll; wake that poll.
experiments?.connections.wake(channel.repositoryId);
announceCatalogue(channel);
let room = Rooms.get(channel.id);
if (!room) return;
for (let ws of room.members.values()) {
Expand Down Expand Up @@ -2095,6 +2116,7 @@ async function deleteChannelLocked(channelId: string): Promise<boolean> {
if (room) Rooms.forget(room);
buildReadiness?.forget(channelId);
void Service.announceDeletedUnanswered(server, storage, channel);
announceCatalogue(channel);
summaryCoordinator?.resume(channelId);
return true;
} catch (err) {
Expand Down Expand Up @@ -2738,6 +2760,7 @@ registerMcpRoutes(router, hostedAuth, {
archiveChannel,
isChannelDeleting: channelId => deletingChannels.has(channelId),
onChannelRenamed: announceChannel,
onChannelCreated: announceCatalogue,
onDocumentPersisted: target => {
void documentHistory.record(target);
if (summaryCoordinator) void summaryCoordinator.enqueueNow(target).catch(() => {});
Expand Down Expand Up @@ -2803,6 +2826,7 @@ registerChannelRoutes(router, hostedAuth, {
onAgentReset: channelOwnerReset,
onChannelArchived: archiveChannel,
onChannelDeleted: deleteChannel,
onChannelCreated: announceCatalogue,
renameChannel: (id, title) =>
withImplementationPlan(
id,
Expand Down
16 changes: 16 additions & 0 deletions apps/server/src/mcp/hosted.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,22 @@ describe("the hosted MCP adapter", () => {
expect(await adapter.create!.create(caller, creation)).toMatchObject({ kind: "replayed" });
});

it("tells sidebars about a document create_document made, once, not on a replay", async () => {
let context = setup();
context.github.repositoryValue.permissions.push = true;
let lease = (await context.storage.leases.acquire("writer", "announce-create", 60_000))!;
let created: string[] = [];
let adapter = hosted(context.auth, { lease: () => lease }, {
onChannelCreated: channel => created.push(`${channel.repositoryId} ${channel.id}`),
});
let caller = (await adapter.caller(request("Bearer allowed")))!;
expect(await adapter.create!.create(caller, creation)).toMatchObject({ kind: "created" });
let id = deterministicChannelId("R_score", creation.idempotencyKey);
expect(created).toEqual([`R_score ${id}`]);
expect(await adapter.create!.create(caller, creation)).toMatchObject({ kind: "replayed" });
expect(created).toEqual([`R_score ${id}`]);
});

it("never recreates an import deleted after its durable replay was read", async () => {
let context = setup();
context.github.repositoryValue.permissions.push = true;
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/mcp/hosted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export type HostedCallbacks = {
isChannelDeleting?: (channelId: string) => boolean;
serializeDocument?: <T>(channelId: string, action: () => Promise<T>) => Promise<T>;
onChannelRenamed?: (channel: ChannelRecord) => void;
/** `create_document` created a document. */
onChannelCreated?: (channel: ChannelRecord) => void;
onDocumentPersisted?: (target: Plan.DocumentTarget) => void;
invokePlanner?: (input: PlannerInvocation) => Promise<InvokePlannerError | undefined>;
/** What `planner-not-attached` says for a document, at its URL; how to attach one by default. */
Expand Down Expand Up @@ -616,6 +618,7 @@ export function hosted(
if (!stored) return { kind: "title-taken" };
return replay(stored);
}
callbacks.onChannelCreated?.(created);
callbacks.onDocumentPersisted?.({
channelId: id,
revision: 0,
Expand Down
35 changes: 33 additions & 2 deletions apps/web/src/sidebar-decision-counts.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";

import { decisionWatchPlan, fallbackRefreshBatch } from "./sidebar-decision-plan";
import { useSidebarDecisions } from "./use-sidebar-decisions";
Expand All @@ -9,6 +9,8 @@ import type * as Api from "./api";
import type { ProjectDocuments } from "./document-actions";

const UNWATCHED_REFRESH_MS = 60_000;
/** Changes to one repository's documents within this long are read once, after the last. */
export const CATALOGUE_SETTLE_MS = 250;

export default function SidebarDecisionCounts({
archived,
Expand All @@ -32,7 +34,36 @@ export default function SidebarDecisionCounts({
archived ? { watched: [], unwatched: [] } : decisionWatchPlan(projects, priorityRepositoryId),
[archived, priorityRepositoryId, projects],
);
let resyncDecisions = useSidebarDecisions(decisionWatch.watched, { onCounts, onSnapshot });
let latestProjects = useRef(projects);
latestProjects.current = projects;
let catalogueTimers = useRef(new Map<string, ReturnType<typeof setTimeout>>());
useEffect(() => {
let timers = catalogueTimers.current;
return () => {
for (let timer of timers.values()) clearTimeout(timer);
timers.clear();
};
}, []);
/** Someone changed a shown repository's documents: read them again, once a burst settles. */
let onCatalogue = useCallback((change: Sidebar.Catalogue) => {
let timers = catalogueTimers.current;
clearTimeout(timers.get(change.repositoryId));
timers.set(
change.repositoryId,
setTimeout(() => {
timers.delete(change.repositoryId);
let shown = latestProjects.current.find(value =>
value.project.repositoryId === change.repositoryId
);
if (shown) refreshProject(shown.project);
}, CATALOGUE_SETTLE_MS),
);
}, [refreshProject]);
let resyncDecisions = useSidebarDecisions(decisionWatch.watched, {
onCounts,
onSnapshot,
onCatalogue,
});
useEffect(() => {
resync.current = resyncDecisions;
return () => {
Expand Down
9 changes: 7 additions & 2 deletions apps/web/src/use-sidebar-decisions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ import type { ResyncQueue, WatchLedger } from "./sidebar-decision-watch";
type Handlers = {
onCounts: (counts: Sidebar.Decisions) => void;
onSnapshot: (snapshot: Sidebar.Snapshot) => void;
/** A watched repository's documents changed: one was created, renamed, archived, restored or deleted. */
onCatalogue: (change: Sidebar.Catalogue) => void;
};

/**
* Keep the Projects sidebar's decision counts live through its own socket, whether or
* not a document is open, watching every repository the sidebar shows. Returns a
* Keep the Projects sidebar's decision counts and documents live through its own socket,
* whether or not a document is open, watching every repository the sidebar shows. Returns a
* function that asks for a fresh snapshot of a repository whose total conflicts.
*/
export function useSidebarDecisions(
Expand Down Expand Up @@ -66,6 +68,9 @@ export function useSidebarDecisions(
socket.on<Sidebar.Snapshot>("sidebar:snapshot", frame => {
latestHandlers.current.onSnapshot(frame);
}),
socket.on<Sidebar.Catalogue>("sidebar:catalogue", frame => {
latestHandlers.current.onCatalogue(frame);
}),
];
setWire(socket);
return () => {
Expand Down
9 changes: 9 additions & 0 deletions docs/channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,15 @@ session and each watched repository. A denied repository is unsubscribed, an
unavailable answer keeps an existing subscription, an expired session closes the
socket, and closing the socket drops every subscription.

When a document is created, renamed, archived, restored or permanently deleted,
by a person, the Planner or a coding agent over MCP, the server also publishes
`sidebar:catalogue` to that repository's topic. It names the repository and the
document but no title, so a sidebar learns nothing it couldn't read: it reads that
repository's documents again through the usual access check, once a burst of
changes has been quiet for 250 ms. A document someone else creates therefore
shows in every sidebar watching its repository without a reload. Repositories
past the watch cap still refresh every minute.

When a commit changes a document's count, or a document is archived, restored or
permanently deleted, the server publishes `sidebar:decisions` to that
repository's topic, so every sidebar socket watching it receives the update. A
Expand Down
60 changes: 60 additions & 0 deletions e2e/sidebar-decision-counts.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -802,3 +802,63 @@ test("deleting an archived child updates the project total for a viewer in its p
await editor.context.close();
}
});

test("documents someone else creates, renames, archives or deletes show in the sidebar without a reload", async ({ baseURL, browser, join, room }) => {
let port = Number(new URL(baseURL!).port);
let existing = crypto.randomUUID();
await createChannel(port, existing, ARCHIVED_CHILD);
let creator = await openAs(
browser,
baseURL!,
`document-creator-${crypto.randomUUID()}`,
documentPath(ARCHIVED_CHILD, existing),
);
try {
let viewer = await join(`catalogue-live-${room.slice(0, 8)}`);
await addProject(viewer, baseURL!, ARCHIVED_CHILD);
await viewer.goto(`/documents/${ARCHIVED_CHILD.owner}/${ARCHIVED_CHILD.name}`);
await expect(viewer.getByRole("heading", { name: "No document open" })).toBeVisible();
let projects = sidebar(viewer);
await expect(projects.getByRole("link", { name: new RegExp(`^Test ${existing.slice(0, 8)}`) }))
.toBeVisible();
let reloads = 0;
viewer.on("framenavigated", frame => {
if (frame === viewer.mainFrame()) reloads++;
});
let headers = { origin: baseURL! };

// Created in another browser: it appears in the viewer's sidebar on its own.
let title = `Live ${room.slice(0, 8)}`;
let created = await creator.page.request.post(
`/api/repositories/${ARCHIVED_CHILD.owner}/${ARCHIVED_CHILD.name}/channels`,
{ headers, data: { title } },
);
expect(created.status()).toBe(201);
let id = (await created.json()).channel.id as string;
await expect(projects.getByRole("link", { name: new RegExp(`^${title}`) })).toBeVisible({
timeout: 10_000,
});

// Renamed: the row follows.
let renamedTitle = `Renamed ${room.slice(0, 8)}`;
let renamed = await creator.page.request.patch(`/api/channels/${id}`, {
headers,
data: { title: renamedTitle },
});
expect(renamed.ok()).toBe(true);
await expect(projects.getByRole("link", { name: new RegExp(`^${renamedTitle}`) }))
.toBeVisible({ timeout: 10_000 });
await expect(projects.getByRole("link", { name: new RegExp(`^${title}`) })).toHaveCount(0);

// Archived, then deleted: it leaves.
let archived = await creator.page.request.post(`/api/channels/${id}/archive`, { headers });
expect(archived.ok()).toBe(true);
await expect(projects.getByRole("link", { name: new RegExp(`^${renamedTitle}`) }))
.toHaveCount(0, { timeout: 10_000 });
let deleted = await creator.page.request.delete(`/api/channels/${id}`, { headers });
expect(deleted.status()).toBe(204);
expect(reloads).toBe(0);
} finally {
await creator.context.close();
}
});
21 changes: 19 additions & 2 deletions packages/protocol/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,14 +149,31 @@ export declare namespace Session {
/**
* The Projects sidebar's own socket at `/ws/sidebar`, which carries unanswered
* decision counts for every repository the sidebar shows whether or not a
* document is open. Admission authenticates the browser session only; each
* document is open, and says when a repository's documents change. Admission authenticates the browser session only; each
* repository is authorized when it is watched. Room sockets carry none of these
* frames. Requests fail with `Session.Failure`, and `Session.Ping` works here too.
*/
export declare namespace Sidebar {
export type Incoming = Request<Session.Ping> | Request<Watch> | Request<Unwatch>;

export type Outgoing = Decisions | Watched | Snapshot | Session.Failure | Session.Ping;
export type Outgoing =
| Decisions
| Catalogue
| Watched
| Snapshot
| Session.Failure
| Session.Ping;

/**
* A document in a watched repository joined, changed in or left its catalogue: someone
* created, renamed, archived, restored or deleted it. Sent to every socket watching the
* repository, the change's author's included. It names no title: read the repository's
* documents again, through the same access check as any read.
*/
export type Catalogue = KIND<"sidebar:catalogue"> & {
repositoryId: string;
channelId: string;
};

/**
* Unanswered decision counts read from authoritative question records after they
Expand Down
Loading