From b2cf976b28103036cf3f6f9aba8238fb18010346 Mon Sep 17 00:00:00 2001 From: Yuto Nishida Date: Tue, 11 Aug 2026 08:01:42 -0700 Subject: [PATCH] [papyrus] Close the New Workstream modal at the accept boundary Creating a workstream no longer blocks the modal on the whole checkout. The server registers the workstream with silverwood's new `new basic --checkout-extent skip` (fast, validating) and responds at the accept boundary, so `POST /api/sessions` returns as soon as the create is accepted and the modal closes. The checkout is then provisioned in the background via `silverwood workstream basic checkout`; the node appears pending -> ready/failed on the canvas via the reconcile loop, and the initial terminal spawns once the checkout is ready. This preserves the sync/async distinction: a synchronous validation/precheck error still fails the register step, so the modal stays open with its inline error; an asynchronous provisioning failure surfaces as `active - basic.failed` on the canvas instead. The client modal itself is unchanged (its await-close-on-success / stay-open-on-error is already correct against the fast endpoint); only the workstream-state indicator gets an amber color for the transient `initialized-without-checkout` state. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/src/workstreamState.test.ts | 12 ++++++ .../papyrus/client/src/workstreamState.ts | 6 ++- experimental/papyrus/server/routes/api.ts | 39 ++++++++++++------- .../papyrus/server/services/silverwood.ts | 38 ++++++++++++++---- 4 files changed, 74 insertions(+), 21 deletions(-) diff --git a/experimental/papyrus/client/src/workstreamState.test.ts b/experimental/papyrus/client/src/workstreamState.test.ts index d0eb0cf..1aca24b 100644 --- a/experimental/papyrus/client/src/workstreamState.test.ts +++ b/experimental/papyrus/client/src/workstreamState.test.ts @@ -28,6 +28,18 @@ describe("workstreamStateLabel", () => { expect(color).toBe("#6B7280"); }); + test("initialized-without-checkout: base label only, amber (provisioning-adjacent)", () => { + const { label, color } = workstreamStateLabel({ + overallState: "active - basic.initialized-without-checkout", + kind: "basic", + checkoutState: "initialized-without-checkout", + connected: false, + tabs: [], + }); + expect(label).toBe("active - basic.initialized-without-checkout"); + expect(color).toBe("#FBBF24"); + }); + test("pending: base label only, amber", () => { const { label, color } = workstreamStateLabel({ overallState: "active - basic.pending", diff --git a/experimental/papyrus/client/src/workstreamState.ts b/experimental/papyrus/client/src/workstreamState.ts index c77bca0..9b49ce8 100644 --- a/experimental/papyrus/client/src/workstreamState.ts +++ b/experimental/papyrus/client/src/workstreamState.ts @@ -27,10 +27,14 @@ export function workstreamStateLabel(input: WorkstreamStateInput): { label = `${n}/${tabs.length} Connected`; } + // `initialized-without-checkout` (deferred checkout, about to provision) is grouped + // with `pending` as a not-ready-yet amber. + const provisioning = + checkoutState === "pending" || checkoutState === "initialized-without-checkout"; const color = checkoutState === "failed" ? "#EF4444" - : checkoutState === "pending" + : provisioning ? "#FBBF24" : connected ? "#22C55E" diff --git a/experimental/papyrus/server/routes/api.ts b/experimental/papyrus/server/routes/api.ts index 976c6f1..e320021 100644 --- a/experimental/papyrus/server/routes/api.ts +++ b/experimental/papyrus/server/routes/api.ts @@ -194,8 +194,11 @@ async function recordFreshSession( } } -// Create a node = create a silverwood workstream (clones its checkout), then spawn -// its Claude Code terminal. Blocks on the clone; the node comes up ready. +// Create a node = register a silverwood workstream, then respond at the accept +// boundary so the New Workstream modal closes immediately. The (possibly slow) checkout +// is provisioned in the BACKGROUND; the node appears pending → ready/failed via the +// reconcile loop, and the initial terminal spawns once the checkout is ready. Only a +// synchronous validation error keeps the modal open (a 400 below). apiRoutes.post("/sessions", async (c) => { const body = await c.req.json(); const { name, path, args, position, source } = body; @@ -231,24 +234,34 @@ apiRoutes.post("/sessions", async (c) => { if (position) await sw.setKv(ws.id, "position", position); const cwd = sw.checkoutLocation(ws) || ""; - let initialSessionId: string | undefined; - if (sw.checkoutState(ws) === "ready" && cwd) { - initialSessionId = randomUUID(); - const session = spawnTerminal({ - sessionKey: initialSessionId, - workstreamId: ws.id, - cwd, - resume: false, + + // Provision the checkout in the background — deliberately NOT awaited, so the response + // returns now (modal closes). On ready, spawn the workstream's initial terminal; a + // provisioning failure surfaces as `basic.failed` on the canvas via reconcile, not here. + const wsId = ws.id; + sw.checkout(wsId) + .then(async (ready) => { + const readyCwd = sw.checkoutLocation(ready); + if (sw.checkoutState(ready) === "ready" && readyCwd) { + const initialSessionId = randomUUID(); + const session = spawnTerminal({ + sessionKey: initialSessionId, + workstreamId: wsId, + cwd: readyCwd, + resume: false, + }); + await recordFreshSession(wsId, initialSessionId, session); + } + }) + .catch((e: any) => { + logError(`\x1b[38;5;141m[checkout]\x1b[0m ${wsId}: ${e.message}`); }); - await recordFreshSession(ws.id, initialSessionId, session); - } return c.json({ sessionId: ws.id, nodeId: ws.id, cwd, checkoutState: sw.checkoutState(ws), - initialSessionId, }); }); diff --git a/experimental/papyrus/server/services/silverwood.ts b/experimental/papyrus/server/services/silverwood.ts index ea57ace..48ed2fe 100644 --- a/experimental/papyrus/server/services/silverwood.ts +++ b/experimental/papyrus/server/services/silverwood.ts @@ -20,7 +20,9 @@ export const PAPYRUS_NS = "app.andref.papyrus"; export interface CheckoutMode { checkout_mode: string; initial_source: string; - state: "pending" | "ready" | "failed"; + // `initialized-without-checkout` = registered with the checkout deferred (papyrus + // creates with `--checkout-extent skip`, then provisions in the background). + state: "initialized-without-checkout" | "pending" | "ready" | "failed"; } // Where the checkout physically lives: which forest, and (per forest kind) where. @@ -136,11 +138,33 @@ export function create(params: { path: string[]; // chosen subcommand names, e.g. ["basic", "apfs-cow"] args: string[]; // positional values along that path, in order }): Promise { - // Blocks on provisioning (e.g. `jj git clone --colocate`); the checkout carries a - // pending→ready state machine, so the returned workstream is the finished one. - // `path`/`args` come from `new-schema` and become argv verbatim (no shell); an - // invalid path/arg is rejected by silverwood, the single validator. - return run(["new", ...params.path, ...params.args, "--name", params.name]); + // Register the workstream WITHOUT provisioning (`--checkout-extent skip`): this is + // the fast, validating phase — precheck + write the doc — and returns the workstream + // in the `initialized-without-checkout` state. It does NOT block on the (slow) clone; + // the caller provisions via `checkout()` in the background. So a synchronous + // validation/precheck error still throws here (non-zero exit), but an async + // provisioning failure does not. `--checkout-extent` is a flag on the `basic` variant, + // so it goes right after the variant segment; `path`/`args` come from `new-schema` and + // become argv verbatim (no shell) — silverwood is the single validator. + const [variant, ...rest] = params.path; + return run([ + "new", + variant, + "--checkout-extent", + "skip", + ...rest, + ...params.args, + "--name", + params.name, + ]); +} + +/// Provision the checkout of a workstream created with `create` (which defers it). +/// Blocks on provisioning (e.g. `jj git clone --colocate`); the returned workstream is +/// `ready` on success. Throws if provisioning fails (the workstream is left `failed`, +/// recoverable) or the workstream is not awaiting a checkout. Serialized per id. +export function checkout(id: string): Promise { + return serialize(id, () => run(["workstream", id, "basic", "checkout"])); } export function archive(id: string): Promise { @@ -243,7 +267,7 @@ export function checkoutLocation(ws: Workstream): string | undefined { /// The provisioning state to surface to the canvas (carried by the checkout mode). export function checkoutState( ws: Workstream, -): "ready" | "pending" | "failed" | "none" { +): "initialized-without-checkout" | "ready" | "pending" | "failed" | "none" { return ws.mode?.state ?? "none"; } -- 2.54.0