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
25 changes: 25 additions & 0 deletions apps/server/src/chat/draft.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,31 @@ test("a task draft is refused without a Planner or when the notice cannot be sav
expect(prompts).toEqual([]);
});

test("a task draft is refused while no remote Planner is attached, and runs once one is", async () => {
let { context, events, prompts, releases, saves, turnOpened } = await setup();
context.plannerNotAttached = async () => "No Planner is attached.";
expect(await Chat.draftTasks(context, "ana", 1, "Prepare.", "@ana asked.")).toEqual({
ok: false,
reason: "No Planner is attached.",
code: "planner-not-attached",
});
expect(context.chat.draft).toBeUndefined();
expect(context.chat.waiting).toEqual([]);
expect(drafting(events)).toEqual([]);
expect(saves()).toBe(0);
expect(prompts).toEqual([]);

// Attached: this harness stands in for the attached Planner owning the turn.
context.plannerNotAttached = undefined;
let started = turnOpened();
expect(await Chat.draftTasks(context, "ana", 1, "Prepare.", "@ana asked.")).toMatchObject({
ok: true,
});
await started;
expect(prompts).toHaveLength(1);
releases.forEach(release => release());
});

test("a task draft takes the same limits as a Chat message, even with the Planner idle", async () => {
let { context, prompts } = await setup();
context.chat.pendingSends = 20;
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/chat/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1075,7 +1075,7 @@ export type TaskDraftResult =
state: "queued" | "running" | "ended";
existing: boolean;
}
| { ok: false; reason: string };
| { ok: false; reason: string; code?: "planner-not-attached" };

/**
* Ask the Planner for implementation tasks without a member message.
Expand Down Expand Up @@ -1116,6 +1116,9 @@ async function postDraft(
let { chat, config, room, server } = context;
if (!config.agent) return { ok: false, reason: "the Planner is not running" };
if (chat.closed) return { ok: false, reason: "chat is closed" };
// Under HARNESS=remote a draft queued with no Planner attached would never run.
let unattached = await context.plannerNotAttached?.();
if (unattached) return { ok: false, reason: unattached, code: "planner-not-attached" };
let existing = liveDraft(chat);
if (existing) return { ok: true, draft: existing, state: existing.state, existing: true };
// The draft waits in the queue even when the Planner is idle, so it counts against it.
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -833,7 +833,7 @@ async function receive(ws: Socket, raw: string): Promise<void> {
said,
);
if (!result.ok) {
fail(ws, frame.rid, result.reason);
fail(ws, frame.rid, result.reason, result.code);
return;
}
if (frame.build) requestBuild(opened, ws.data.principalId);
Expand Down Expand Up @@ -2110,7 +2110,7 @@ registerMcpRoutes(router, hostedAuth, {
withDocumentTransition(channelId, () => withDocumentLock(channelId, action)),
});
implementations = registerImplementationRoutes(router, hostedAuth, {
buildable: (id, planRevision) => buildReadiness?.ready(id, planRevision) ?? false,
buildable: buildReadiness && buildReadiness.ready.bind(buildReadiness),
liveBuild: !!config.liveBuild,
connections: experiments.connections,
busy: async connectionId =>
Expand Down
16 changes: 15 additions & 1 deletion apps/server/src/tasks/builds.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from "zod";
import { changedBlocks } from "./changes";
import { approveGraph, validate } from "./graphs";
import { implementationReadiness } from "./plan-graphs";
import {
Expand Down Expand Up @@ -880,6 +881,17 @@ export function reportRebuild(
});
}

/** The last change count per open document, so repeated reads between edits parse nothing. */
let counted = new WeakMap<Plan, { revision: number; base: string; changes: number }>();

function changeCount(plan: Plan, base: string, current: string): number {
let cached = counted.get(plan);
if (cached?.revision === plan.revision && cached.base === base) return cached.changes;
let changes = changedBlocks(base, current);
counted.set(plan, { revision: plan.revision, base, changes });
return changes;
}

/** What the browser needs to show whether a living document matches its pull requests. */
export function liveSnapshot(
plan: Plan,
Expand All @@ -893,6 +905,7 @@ export function liveSnapshot(
let builder = viewer === live.user;
let latest = plan.builds.findLast(build => build.kind === "rebuild");
let rebuild = latest && (builder ? structuredClone(latest) : withoutConnection(latest));
let current = source(plan);
// The first build's tasks, then each rebuild's appended version, with their last reported state.
let first = plan.builds.find(build => build.id === live.buildId)?.graphVersion;
let history = plan.graph ? historyFor(plan.graph, plan.lifecycle) : [];
Expand All @@ -917,7 +930,8 @@ export function liveSnapshot(
tasks,
...(unfinished.length ? { outstandingTasks: unfinished } : {}),
...(rebuild ? { rebuild } : {}),
outOfSync: source(plan) !== baseSource,
outOfSync: current !== baseSource,
changes: current === baseSource ? 0 : changeCount(plan, baseSource, current),
...(builder
? {
checkout: structuredClone(checkout),
Expand Down
30 changes: 30 additions & 0 deletions apps/server/src/tasks/changes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, test } from "bun:test";
import { changedBlocks } from "./changes";

const BASE = "# Snooze\n\nFive minutes.\n\nFifteen minutes.\n\nAn hour.\n";

describe("changedBlocks", () => {
test("counts nothing for the same source", () => {
expect(changedBlocks(BASE, BASE)).toBe(0);
});

test("counts an edited block once", () => {
expect(changedBlocks(BASE, BASE.replace("An hour.", "Two hours."))).toBe(1);
});

test("counts added and removed blocks", () => {
expect(changedBlocks(BASE, `${BASE}\nA day.\n\nA week.\n`)).toBe(2);
expect(changedBlocks(BASE, BASE.replace("Fifteen minutes.\n\n", ""))).toBe(1);
});

test("counts the larger side when edits and additions mix", () => {
let after = BASE.replace("Five minutes.", "Ten minutes.").replace("An hour.", "Two hours.");
expect(changedBlocks(BASE, `${after}\nA day.\n`)).toBe(3);
});

test("counts a reorder of any size only through the floor of one change", () => {
let moved = "# Snooze\n\nAn hour.\n\nFive minutes.\n\nFifteen minutes.\n";
expect(changedBlocks(BASE, moved)).toBe(1);
expect(changedBlocks(BASE, `${BASE}\n`)).toBe(1);
});
});
31 changes: 31 additions & 0 deletions apps/server/src/tasks/changes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { parse } from "@chopin/dialect/parse";
import { serialize } from "@chopin/dialect/serialize";

/** Each top-level block in its canonical form, or blank-line paragraphs when it cannot parse. */
function blocks(source: string): string[] {
try {
return parse(source).children.map(node => serialize({ type: "root", children: [node] }).trim());
} catch {
return source.split(/\n{2,}/).map(block => block.trim()).filter(Boolean);
}
}

/**
* How many top-level blocks an edit added, removed or changed. A changed block is one removal and
* one addition, so the larger side counts. Blocks compare as a multiset, so a reorder of any size
* adds and removes nothing; the floor of one change is what counts it, as for any other difference
* that serializes the same.
*/
export function changedBlocks(before: string, after: string): number {
if (before === after) return 0;
let left = new Map<string, number>();
for (let block of blocks(before)) left.set(block, (left.get(block) ?? 0) + 1);
let added = 0;
for (let block of blocks(after)) {
let count = left.get(block) ?? 0;
if (count > 0) left.set(block, count - 1);
else added += 1;
}
let removed = [...left.values()].reduce((sum, count) => sum + count, 0);
return Math.max(added, removed, 1);
}
4 changes: 2 additions & 2 deletions apps/server/src/tasks/readiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,14 +170,14 @@ describe("BuildReadiness", () => {
expect(clock.queue[0]!.cancelled).toBe(true);
});

test("skipped documents are never judged", async () => {
test("skipped documents are never judged and read as ready", async () => {
let asks: JevRequest[] = [];
let readiness = new BuildReadiness({
current: async () => target(long),
ask: jev(1, asks),
skip: () => true,
});
expect(readiness.ready("doc", 1)).toBe(false);
expect(readiness.ready("doc", 1)).toBe(true);
await Bun.sleep(0);
expect(asks.length).toBe(0);
});
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/tasks/readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ export class BuildReadiness {
* its successor arrives, so a small edit does not hide a ready document.
*/
ready(channelId: string, revision: number): boolean {
if (this.#options.skip?.(channelId)) return false;
// A document past readiness was already judged buildable.
if (this.#options.skip?.(channelId)) return true;
let judged = this.#judged.get(channelId);
if (judged) {
this.#judged.delete(channelId);
Expand Down
7 changes: 5 additions & 2 deletions apps/server/src/tasks/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ type Options = {
connections: Connections;
/** Whether an investigation is executing on a connection, in any document. */
busy?: (connectionId: string) => Promise<boolean>;
/** Whether the document reads as ready to build, apart from its blockers. */
/**
* Whether the document reads as ready to build, apart from its blockers. Without a judge, a
* document with no blockers is ready.
*/
buildable?: (id: string, planRevision: number) => boolean;
/** LIVE_BUILD: builds may queue behind a prototype or another document's build. */
liveBuild?: boolean;
Expand Down Expand Up @@ -177,7 +180,7 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o
}),
blockers: ready.ok ? [] : ready.blockers,
localAgent,
buildReady: ready.ok && (options.buildable?.(id, plan.revision) ?? false),
buildReady: ready.ok && (options.buildable?.(id, plan.revision) ?? true),
...(waiting ? { waitingForPrototype: true as const } : {}),
...(ahead ? { waitingForDocument: aheadTitle ? { title: aheadTitle } : {} } : {}),
...(plan.buildRequested ? { buildRequested: { ...plan.buildRequested } } : {}),
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/testing/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { SocketData } from "../wire";

export type SeedState = {
graph?: import("../tasks/graphs").Graph;
builds?: import("@chopin/protocol/implementation").BuildRequest[];
revision?: number;
questions?: unknown[];
openQuestions?: unknown[];
Expand Down Expand Up @@ -185,6 +186,7 @@ export async function checkpointSource(
threads: state.threads ?? [],
transcript: state.transcript ?? [],
...(state.graph ? { graph: state.graph } : {}),
...(state.builds ? { builds: state.builds } : {}),
} as JsonValue;
let document = await Room.create(source);
let canonical = Room.project(document);
Expand Down
2 changes: 1 addition & 1 deletion apps/web/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ text only when people need it to act or recover.

## Layout

The workspace places Chat on the left and the document on the right when space permits. Chat sits directly on the ground with no bar of its own; the document is a raised sheet that rises to the top margin and carries two 40px bars. The title bar holds the expand control, the project and title with rename and the document actions menu, then status and presence, separated by full-height dividers. The views bar below it holds Document, Decisions, and Build on the paper, with no rule beneath. Expanding the document only widens the sheet: Chat collapses, the title stays, and a Chat button in the same place restores it. As the sheet narrows, the title truncates first, then status labels fold to glyphs, then presence keeps one face and a count. A child document zooms in place of its root: the title bar shows Back, the root as a crumb, and the child's own title and actions, and the views fold away because a child has none. Compact layouts and phones show the path in a slim stack bar instead. Document, Decisions, and Build switch the main pane; Build is available on parent documents. Local investigation proposals, approval controls, and results appear in the document flow. The Projects sidebar slides over the document without resizing it; on narrower screens, panels take their own view. The document uses a maximum prose measure of 40.625rem (650px), with 3.75rem (60px) of top padding and at least 3.5rem (56px) of padding on each side. At browser widths of 600px or less, the horizontal padding becomes 1.5rem (24px). On a phone (a primary touch pointer with a width of 600px or less, or a height of 500px or less, so landscape phones count), the workspace drops its card and the raised sheet: panes run full-bleed with the same 1.5rem (24px) gutters, so margin comment markers keep their lane, under a translucent top bar that tucks away while reading down, above a floating bottom tab bar in the thumb zone. Landscape phones use a shorter top bar and a one-line tab bar. Destinations slide sideways in tab order, and crossfade under reduced motion. Images and diagrams can extend beyond the prose measure within those gutters; a table wider than the measure grows evenly about the column's centre, up to 1.5 times the prose measure (60.9375rem, about 975px) or the gutters, whichever is narrower, and scrolls in its own lane beyond that. On a phone (a coarse pointer on a small screen), when the document is narrower than 40rem, a wide table fades whichever side still hides columns, code wraps with a hanging indent instead of scrolling, and a Mermaid diagram below its readability floor offers Expand diagram under its scroll lane, which opens it in a full-screen sheet at its authored size. A narrow document alone does not trigger these: narrow desktop windows keep the scroll lanes.
The workspace places Chat on the left and the document on the right when space permits. Chat sits directly on the ground with no bar of its own; the document is a raised sheet that rises to the top margin and carries two 40px bars. The title bar holds the expand control, the project and title with rename and the document actions menu, then status and presence, separated by full-height dividers. The views bar below it holds Document, Decisions, and Build on the paper, with no rule beneath. Expanding the document only widens the sheet: Chat collapses, the title stays, and a Chat button in the same place restores it. As the sheet narrows, the title truncates first, then status labels fold to glyphs, then presence keeps one face and a count. A child document zooms in place of its root: the title bar shows Back, the root as a crumb, and the child's own title and actions, and the views fold away because a child has none. Compact layouts and phones show the path in a slim stack bar instead. Document, Decisions, and Build switch the main pane; Build is available on parent documents. Tabs never trigger actions: Build stays off until the plan is built, and only the header's **Build plan** starts a build. Build plan sits left of the people here and is always visible to writers, inert with its reason in a tooltip until it can run. After the press the same slot becomes a quiet capsule (glyph and words such as _Building · 3 of 8_, _Built_, _3 changes to build_) that opens Build, with at most one small outline action (_Retry_, _Build_). One shared glyph (progress ring, status dot, clock or warning) appears identically in the capsule and on the Build tab; hovering either lights the other, and on the first press the glyph travels to the tab unless reduced motion is on. On a phone the capsule keeps only its glyph and Build's headline carries the words. Local investigation proposals, approval controls, and results appear in the document flow. The Projects sidebar slides over the document without resizing it; on narrower screens, panels take their own view. The document uses a maximum prose measure of 40.625rem (650px), with 3.75rem (60px) of top padding and at least 3.5rem (56px) of padding on each side. At browser widths of 600px or less, the horizontal padding becomes 1.5rem (24px). On a phone (a primary touch pointer with a width of 600px or less, or a height of 500px or less, so landscape phones count), the workspace drops its card and the raised sheet: panes run full-bleed with the same 1.5rem (24px) gutters, so margin comment markers keep their lane, under a translucent top bar that tucks away while reading down, above a floating bottom tab bar in the thumb zone. Landscape phones use a shorter top bar and a one-line tab bar. Destinations slide sideways in tab order, and crossfade under reduced motion. Images and diagrams can extend beyond the prose measure within those gutters; a table wider than the measure grows evenly about the column's centre, up to 1.5 times the prose measure (60.9375rem, about 975px) or the gutters, whichever is narrower, and scrolls in its own lane beyond that. On a phone (a coarse pointer on a small screen), when the document is narrower than 40rem, a wide table fades whichever side still hides columns, code wraps with a hanging indent instead of scrolling, and a Mermaid diagram below its readability floor offers Expand diagram under its scroll lane, which opens it in a full-screen sheet at its authored size. A narrow document alone does not trigger these: narrow desktop windows keep the scroll lanes.

Spacing starts from a 4px unit and uses the measured steps shown in the frontmatter. App chrome is dense; prose has larger margins and trailing space so the caret remains comfortable near the end of a document. Layout responds to both viewport and document container width. Safe-area insets and larger coarse-pointer targets are accounted for in the web styles.

Expand Down
Loading
Loading