From 3ab5936b7b1092f46f69ab4fc8668425795f3ffd Mon Sep 17 00:00:00 2001 From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:42:10 +0530 Subject: [PATCH 01/15] fix(bridge): serialize checkout runtime build/teardown so sessions stay deletable (#1) --- bridge/src/agent-core.ts | 151 ++++++++++++++++++----- bridge/src/config-controller.ts | 23 +++- bridge/src/file-watcher.ts | 11 +- bridge/src/keyed-lock.ts | 36 ++++++ bridge/src/worktrees/worktree-manager.ts | 33 ++++- bridge/tests/config-controller.test.ts | 43 ++++++- bridge/tests/keyed-lock.test.ts | 79 ++++++++++++ 7 files changed, 337 insertions(+), 39 deletions(-) create mode 100644 bridge/src/keyed-lock.ts create mode 100644 bridge/tests/keyed-lock.test.ts diff --git a/bridge/src/agent-core.ts b/bridge/src/agent-core.ts index b609905d..d25a9a03 100644 --- a/bridge/src/agent-core.ts +++ b/bridge/src/agent-core.ts @@ -6,6 +6,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import { logger } from "./logger"; const log = logger.child({ component: "agent-core" }); import { TerminalManager } from "./terminal-manager"; +import { createKeyedLock } from "./keyed-lock"; import { killChildTree, processGroupSpawn } from "./terminal-session"; import { createConnState, type ConnState } from "./conn-state"; import { FileWatcher } from "./file-watcher"; @@ -96,6 +97,18 @@ interface CheckoutRuntime { gitStatusApplied: number; configuredTerminalIds: Map; started: boolean; + /** This runtime is being torn down, or already has been. Never cleared — a + * torn-down runtime is replaced, never revived. + * + * Not the mechanism that keeps a build and a teardown apart; that is + * [withCheckoutRuntimeLock]. This is for the two readers that cannot take + * that lock and would otherwise touch a checkout mid-delete: `resyncState`, + * which runs on every app handshake, and the process-wide shutdown sweep. + * Both walk the registry, and the row survives until the sweep's last line. + * + * `started` cannot carry it: that is a claim-the-slot flag set on the way IN, + * so it reads `true` for a runtime half-built, fully built, or already dead. */ + disposed: boolean; } // Tracks terminal ids that have pinged /hook-alive (a SessionStart probe an @@ -569,6 +582,7 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise { const ticket = ++runtime.gitStatusSeq; - const files = await getGitStatus(runtime.checkout.path); + let files: GitFileEntry[]; + try { + files = await getGitStatus(runtime.checkout.path); + } catch { + // `Bun.spawn` throws SYNCHRONOUSLY when cwd is gone, which is the normal + // state once the checkout has been removed under an in-flight refresh. + // Matching [refreshGitBranch], which has always caught: letting this + // propagate rejects whatever awaited it — including a session start, + // which then badges the session `failed` with an unrelated cause. + return; + } if (ticket < runtime.gitStatusApplied) return; runtime.gitStatusApplied = ticket; runtime.cachedGitFiles = files; @@ -1748,6 +1778,12 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise { + // stopWatch() has already run for a torn-down runtime, but a callback + // debounced before it can still land — and this one spawns PTYs into the + // checkout and re-registers the runtime, undoing the teardown. + if (runtime.disposed) return; if (!result.ok) { if (!result.missing) send(createMessage("config:changed", { agentRestartRequired: false, invalid: true, error: result.error, @@ -1901,7 +1953,14 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise { @@ -1919,23 +1978,66 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise { - const existing = checkoutRuntimes.runtime(checkout.id); - if (existing) { - await startCheckoutRuntime(existing); - return existing; - } - const runtimeConfig = loadConfig(undefined, checkout.path); - const spec = agentSpecForConfig(runtimeConfig); - const runtime = createCheckoutRuntime(checkout, runtimeConfig, spec); - await checkoutRuntimes.prepare(checkout, runtimeConfig, spec, runtime); - await startCheckoutRuntime(runtime); - return runtime; + /** Serializes a checkout's runtime lifecycle. Building a runtime and tearing + * one down both suspend repeatedly, and everything they touch — a recursive + * watcher, `services:` PTYs, `git` children — holds the checkout directory + * open. `deleteManaged` runs `git worktree remove` the instant teardown + * resolves, so the two interleaving is what strands a session undeletable: on + * Windows one handle opened by a resuming start aborts Git's sweep mid-tree, + * and the runtime is out of the registry by then, so no later teardown can + * find that handle to close it. Measured in the field as 131 watcher starts + * against 31 stops. */ + const withCheckoutRuntimeLock = createKeyedLock(); + + function prepareCheckoutRuntime(checkout: CheckoutRecord): Promise { + return withCheckoutRuntimeLock(checkout.id, async () => { + const existing = checkoutRuntimes.runtime(checkout.id); + if (existing) { + await startCheckoutRuntime(existing); + return existing; + } + const runtimeConfig = loadConfig(undefined, checkout.path); + const spec = agentSpecForConfig(runtimeConfig); + const runtime = createCheckoutRuntime(checkout, runtimeConfig, spec); + await checkoutRuntimes.prepare(checkout, runtimeConfig, spec, runtime); + await startCheckoutRuntime(runtime); + return runtime; + }); + } + + /** Release every holder a checkout runtime owns. Returns the watcher's close + * promise: chokidar's `close()` is asynchronous and resolves only once its + * per-directory `fs.watch()` subscriptions are gone, so a caller about to + * delete the directory has to wait on it. Everything else stops + * synchronously. */ + function stopCheckoutServices(runtime: CheckoutRuntime): Promise { + runtime.configController.stopWatch(); + const watcherClosed = runtime.fileWatcher?.stop() ?? Promise.resolve(); + runtime.uploadManager?.stop(); + runtime.portDetector?.stop(); + runtime.tunnelManager?.stop(); + if (runtime.gitBranchInterval) clearInterval(runtime.gitBranchInterval); + runtime.gitBranchInterval = null; + if (runtime.gitRefreshTimer) clearTimeout(runtime.gitRefreshTimer); + runtime.gitRefreshTimer = null; + return watcherClosed; + } + + function teardownCheckoutRuntime(checkoutId: string): Promise { + // Under the lock, so a build already in flight for this checkout finishes + // before the sweep starts and every holder it opened is visible to it. + return withCheckoutRuntimeLock(checkoutId, async () => { + const runtime = checkoutRuntimes.runtime(checkoutId); + if (!runtime || checkoutId === "main") return; + // For the readers that do NOT take the lock — `resyncState`, which runs on + // every app handshake, and the process-wide shutdown sweep. Both walk the + // registry, and the row survives until this function's last line. + runtime.disposed = true; + await sweepCheckoutRuntime(runtime, checkoutId); + }); } - async function teardownCheckoutRuntime(checkoutId: string): Promise { - const runtime = checkoutRuntimes.runtime(checkoutId); - if (!runtime || checkoutId === "main") return; + async function sweepCheckoutRuntime(runtime: CheckoutRuntime, checkoutId: string): Promise { const pending: Promise[] = []; for (const proc of runtime.runningCommands.values()) { pending.push(killChildTree(proc)); @@ -1944,16 +2046,9 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise | null = null; private lastConfig: AbConfig = {}; watch(onChange: (next: ReadResult, diff: ConfigDiff) => void): void { this.watcher?.close(); - let debounce: ReturnType | null = null; const trigger = () => { - if (debounce) clearTimeout(debounce); - debounce = setTimeout(() => { + if (this.debounce) clearTimeout(this.debounce); + this.debounce = setTimeout(() => { const r = this.read(); const next = r.ok ? r.config : ({} as AbConfig); const diff = computeDiff(this.lastConfig, next); @@ -87,6 +100,8 @@ export class ConfigController { stopWatch(): void { this.watcher?.close(); this.watcher = null; + if (this.debounce) clearTimeout(this.debounce); + this.debounce = null; } } diff --git a/bridge/src/file-watcher.ts b/bridge/src/file-watcher.ts index e1806997..ecc2c0bd 100644 --- a/bridge/src/file-watcher.ts +++ b/bridge/src/file-watcher.ts @@ -222,7 +222,13 @@ export class FileWatcher { ); } - stop(): void { + /** Returns chokidar's close promise so a caller about to delete the watched + * directory can wait the subscriptions out. Chokidar tears down one + * `fs.watch()` per directory and resolves only when the last is closed; + * dropping it leaves them open, and one live subscription is enough to abort + * a `git worktree remove` sweep. The native recursive watcher closes + * synchronously, so on macOS/Windows this resolves immediately. */ + stop(): Promise { if (this.debounceTimer) { clearTimeout(this.debounceTimer); this.debounceTimer = null; @@ -230,11 +236,12 @@ export class FileWatcher { this.pending.added.clear(); this.pending.modified.clear(); this.pending.removed.clear(); - this.watcher?.close(); + const closed = this.watcher?.close(); this.watcher = null; this.nativeWatcher?.close(); this.nativeWatcher = null; log.info("File watcher stopped for %s", this.projectId); + return Promise.resolve(closed).then(() => undefined); } private onFileAdded(filePath: string): void { diff --git a/bridge/src/keyed-lock.ts b/bridge/src/keyed-lock.ts new file mode 100644 index 00000000..fe52e86c --- /dev/null +++ b/bridge/src/keyed-lock.ts @@ -0,0 +1,36 @@ +/** + * Serializes async work per key: two operations sharing a key never overlap, + * and operations under different keys never wait on each other. + * + * The shape `WorktreeManager.withProjectLock` and `CheckoutStore.mutate` already + * use, extracted because the checkout-runtime lifecycle needs it too — see + * `withCheckoutRuntimeLock` in agent-core.ts, where building a runtime and + * tearing one down both suspend repeatedly while holding a directory open. + * + * A lock rather than a checked flag whenever the hazard is a suspension window: + * a flag has to be re-tested after EVERY `await`, so each one added later is a + * fresh hole, and an await that rejects skips its own check. Serializing makes + * the interleave impossible rather than merely detectable. + */ +export type KeyedLock = (key: string, fn: () => Promise) => Promise; + +export function createKeyedLock(): KeyedLock { + const chains = new Map>(); + return function withLock(key: string, fn: () => Promise): Promise { + const previous = chains.get(key) ?? Promise.resolve(); + // Both arms are the same function: an earlier operation that FAILED still + // ran, so the next one must still be serialized behind it rather than + // starting early — and the caller's rejection is delivered to the caller, + // never to whoever queues next. + const run = previous.then(fn, fn); + const settled = run.then(() => undefined, () => undefined); + chains.set(key, settled); + void settled.then(() => { + // Only when still the tail. A later caller has already chained onto this + // promise and replaced the entry; deleting it then would let the caller + // after THAT one skip the queue entirely. + if (chains.get(key) === settled) chains.delete(key); + }); + return run; + }; +} diff --git a/bridge/src/worktrees/worktree-manager.ts b/bridge/src/worktrees/worktree-manager.ts index 72f260eb..e901bd9f 100644 --- a/bridge/src/worktrees/worktree-manager.ts +++ b/bridge/src/worktrees/worktree-manager.ts @@ -24,6 +24,34 @@ const WORKTREE_ROOT_DIR = "wt"; * nothing writes into it and its mtime ages past this. */ const RECONCILE_GRACE_MS = 60_000; +/** Backoff for [removeWithRetries]. Each entry is the wait BEFORE that attempt, + * so the first is free and the budget is ~2.3s over five tries. Sized for the + * holder it exists to outlast — a killed PTY's grandchildren, which can outlive + * the leader because `taskkill /F /T` walks the tree from the parent down — and + * no longer: a handle nothing is closing needs the caller's error, not more + * patience. */ +const RECLAIM_BACKOFF_MS = [0, 100, 300, 700, 1200]; + +/** Recursive delete that actually retries a transient Windows sharing + * violation. `fs.rm`'s own `maxRetries`/`retryDelay` cannot be used: Bun + * accepts both options and honours NEITHER — measured on the pinned runtime + * (1.3.14), `rm` against a directory held as a live child's cwd returns in 0ms + * with EBUSY where Node retries for the full budget. The bridge runs on Bun in + * both `bun run` and `bun build --compile` form, so the options were a no-op + * everywhere it ships. */ +async function removeWithRetries(path: string): Promise { + for (const wait of RECLAIM_BACKOFF_MS) { + if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait)); + try { + await rm(path, { recursive: true, force: true }); + return; + } catch { + // Caller re-tests the directory and reports the failure — whatever holds + // it open is worth surfacing rather than retrying blindly. + } + } +} + /** How many same-stem branches to walk before giving up. A stem carries the * session's own word pair, so reaching even the low tens means something is * generating sessions in a loop — not a user naming things alike. */ @@ -373,10 +401,7 @@ export class WorktreeManager { // checkout it exists to reclaim. Tests cannot see that on their own: they // build the checkout path from `abDir` too, so both sides agree by accident. if (!pathBelow(canonical(this.worktreeRoot()), canonical(record.path))) return; - // Retries because the failure this exists to survive is a transient Windows - // sharing violation: a scanner or a dying process can hold one file for a - // few hundred milliseconds after `worktree remove` already gave up on it. - await rm(record.path, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }).catch(() => undefined); + await removeWithRetries(record.path); // The directory going away is what makes the registration prunable: Git // holds `.git/worktrees/` until nothing claims it. if (!existsSync(record.path)) await this.git(["worktree", "prune"], repoPath).catch(() => undefined); diff --git a/bridge/tests/config-controller.test.ts b/bridge/tests/config-controller.test.ts index 0b42ba47..8b26b5dc 100644 --- a/bridge/tests/config-controller.test.ts +++ b/bridge/tests/config-controller.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "bun:test"; -import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { ConfigController, computeDiff } from "../src/config-controller"; @@ -100,3 +100,44 @@ describe("ConfigController.computeDiff", () => { expect(d.servicesModified.map((s) => s.name)).toEqual(["b"]); }); }); + +describe("ConfigController.stopWatch", () => { + const settle = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + + it("clears a debounced read so nothing touches the file after the watcher closes", async () => { + const dir = tmp(); + const path = join(dir, "antgrid.yaml"); + writeFileSync(path, "agent:\n tool: claude-code\n", "utf8"); + const c = new ConfigController(path); + let changes = 0; + c.watch(() => { changes++; }); + + // Touch, then stop inside the 100ms debounce. The timer used to be a bare + // closure local that stopWatch could not reach, so it still fired — and its + // first act is to READ the watched file. For a managed checkout that file + // sits inside the directory `git worktree remove` is by then sweeping, + // which is the open handle the whole delete path exists to avoid. + writeFileSync(path, "agent:\n tool: codex\n", "utf8"); + // Long enough for fs.watch to deliver and ARM the debounce, short enough + // that it has not fired. Stopping before the event lands proves nothing: + // there would be no timer to leak. + await settle(40); + c.stopWatch(); + await settle(250); + expect(changes).toBe(0); + }); + + it("answers rather than throws when the path exists but cannot be read", () => { + const dir = tmp(); + const path = join(dir, "antgrid.yaml"); + // Passes existsSync, fails readFileSync — standing in for the real race, + // where the file is unlinked between the two while its checkout is being + // deleted. `read()` runs from a bare setTimeout in watch(), so a throw here + // is an uncaught exception on the bridge's main loop. + mkdirSync(path); + const c = new ConfigController(path); + const r = c.read(); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.missing).toBe(true); + }); +}); diff --git a/bridge/tests/keyed-lock.test.ts b/bridge/tests/keyed-lock.test.ts new file mode 100644 index 00000000..7fa23274 --- /dev/null +++ b/bridge/tests/keyed-lock.test.ts @@ -0,0 +1,79 @@ +// The mechanism that keeps a checkout's runtime build from interleaving with +// its teardown. That interleave is what strands an isolated session +// undeletable: a start resuming from a suspension arms a watcher inside the +// directory `git worktree remove` is sweeping, and the runtime is out of the +// registry by then, so nothing can ever close that handle again. +import { describe, expect, test } from "bun:test"; +import { createKeyedLock } from "../src/keyed-lock"; + +const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); + +describe("createKeyedLock", () => { + test("operations sharing a key never overlap", async () => { + const withLock = createKeyedLock(); + const order: string[] = []; + const op = (name: string, ms: number) => withLock("k", async () => { + order.push(`${name}:start`); + await tick(ms); + order.push(`${name}:end`); + }); + // Second queued while the first is mid-suspension — the delete-during-start + // shape exactly. Slowest first, so a broken lock interleaves visibly. + await Promise.all([op("build", 30), op("teardown", 0)]); + expect(order).toEqual(["build:start", "build:end", "teardown:start", "teardown:end"]); + }); + + test("a rejected operation still serializes the next one, and only its own caller sees the error", async () => { + const withLock = createKeyedLock(); + const order: string[] = []; + const failing = withLock("k", async () => { + order.push("fail:start"); + await tick(20); + order.push("fail:end"); + throw new Error("teardown exploded"); + }); + const next = withLock("k", async () => { order.push("next:start"); }); + // The failure belongs to whoever asked for it. A delete that throws must + // not reject the rebuild queued behind it. + await expect(failing).rejects.toThrow("teardown exploded"); + await next; + expect(order).toEqual(["fail:start", "fail:end", "next:start"]); + }); + + test("different keys do not wait on each other", async () => { + const withLock = createKeyedLock(); + const order: string[] = []; + await Promise.all([ + withLock("a", async () => { order.push("a:start"); await tick(30); order.push("a:end"); }), + withLock("b", async () => { order.push("b:start"); await tick(0); order.push("b:end"); }), + ]); + // b must finish inside a's window — one checkout's delete cannot be made to + // wait on another checkout's build. + expect(order).toEqual(["a:start", "b:start", "b:end", "a:end"]); + }); + + test("a queued operation is not skipped when the one ahead of it settles", async () => { + const withLock = createKeyedLock(); + const order: string[] = []; + // Three deep: the chain entry is replaced twice, so a cleanup that deletes + // the map key unconditionally lets the third start early. + const ops = ["one", "two", "three"].map((name) => + withLock("k", async () => { order.push(`${name}:start`); await tick(10); order.push(`${name}:end`); })); + await Promise.all(ops); + expect(order).toEqual([ + "one:start", "one:end", "two:start", "two:end", "three:start", "three:end", + ]); + }); + + test("the chain map does not grow once work has drained", async () => { + const withLock = createKeyedLock(); + for (let i = 0; i < 5; i++) await withLock(`k${i}`, async () => { await tick(0); }); + // Nothing is queued, so a later caller for a used key must start straight + // away rather than chain onto a settled promise that was never released. + let started = false; + const run = withLock("k0", async () => { started = true; }); + await tick(0); + expect(started).toBe(true); + await run; + }); +}); From ebbb3e81c953b1b37a09cb96b185a6df5df0ca97 Mon Sep 17 00:00:00 2001 From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:26:31 +0800 Subject: [PATCH 02/15] Run a worktree.setup block before an isolated session's agent starts (#2) --- CLAUDE.md | 4 +- app/CLAUDE.md | 5 + app/lib/models/ab_config.dart | 18 + app/lib/models/session_entry.dart | 157 ++++- app/lib/providers/new_session_action.dart | 66 +- app/lib/providers/session_setup.dart | 141 +++++ app/lib/screens/workspace_shell.dart | 9 +- app/lib/services/sessions_service.dart | 33 + app/lib/storage/cached_sessions_store.dart | 51 +- .../new_session/new_session_content.dart | 5 + .../new_session/worktree_setup_nudge.dart | 425 +++++++++++++ .../recent_session_row_widget.dart | 16 +- app/lib/widgets/session_isolation_badge.dart | 76 ++- app/lib/widgets/session_row.dart | 19 +- app/lib/widgets/session_setup_banner.dart | 374 +++++++++++ app/lib/widgets/terminal_list_view.dart | 22 +- app/lib/widgets/window_title_bar.dart | 9 +- app/test/models/session_entry_test.dart | 158 +++++ .../new_session_queued_start_test.dart | 245 ++++++++ app/test/providers/session_setup_test.dart | 159 +++++ .../widgets/session_isolation_badge_test.dart | 127 +++- .../widgets/session_setup_banner_test.dart | 370 +++++++++++ bridge/CLAUDE.md | 105 ++++ bridge/src/agent-core.ts | 230 ++++++- bridge/src/cli/worktree-setup.ts | 119 ++++ bridge/src/config.ts | 64 +- bridge/src/index.ts | 14 + bridge/src/project-core.ts | 3 + bridge/src/protocol.ts | 42 +- bridge/src/session-manager.ts | 508 ++++++++++++++- bridge/src/terminal-manager.ts | 36 +- bridge/src/worktrees/checkout-setup.ts | 574 +++++++++++++++++ bridge/src/worktrees/checkout-store.ts | 28 +- bridge/src/worktrees/checkout-types.ts | 38 ++ bridge/src/worktrees/path-guard.ts | 26 + bridge/src/worktrees/worktree-manager.ts | 8 +- .../tests/agent-core-checkout-routing.test.ts | 132 +++- .../tests/checkout-protocol-contract.test.ts | 57 ++ bridge/tests/checkout-setup.test.ts | 592 ++++++++++++++++++ bridge/tests/checkout-store.test.ts | 77 ++- bridge/tests/session-manager-worktree.test.ts | 402 +++++++++++- docs/architecture.md | 102 ++- evals/fixtures/worktree-setup.yaml | 17 + evals/tests/gate-worktree-isolation.test.ts | 250 +++++++- 44 files changed, 5782 insertions(+), 131 deletions(-) create mode 100644 app/lib/providers/session_setup.dart create mode 100644 app/lib/widgets/new_session/worktree_setup_nudge.dart create mode 100644 app/lib/widgets/session_setup_banner.dart create mode 100644 app/test/providers/new_session_queued_start_test.dart create mode 100644 app/test/providers/session_setup_test.dart create mode 100644 app/test/widgets/session_setup_banner_test.dart create mode 100644 bridge/src/cli/worktree-setup.ts create mode 100644 bridge/src/worktrees/checkout-setup.ts create mode 100644 bridge/src/worktrees/path-guard.ts create mode 100644 bridge/tests/checkout-setup.test.ts create mode 100644 evals/fixtures/worktree-setup.yaml diff --git a/CLAUDE.md b/CLAUDE.md index b985d9f8..72398cba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,8 +97,8 @@ in the CLI run. Never take an agent's `analyze_files` result as the gate. ## Conventions (MUST / NEVER) - **Zod everywhere** — all message types and config schemas use Zod v4 for runtime validation. -- **Adding a message type** requires ALL of: schema in `protocol.ts` → add to `AbMessageSchema` union → add to `KNOWN_TYPES` set → export the type → handle in the `index.ts` switch. Miss one and it silently fails. If the type reads or writes the working tree, it also belongs in `CHECKOUT_VARIABLE_MESSAGE_TYPES` — see below. -- **Checkout-scoped routing** — an isolated session runs in a managed git worktree, so anything filesystem-variable (files, tree, search, Git, commands, preview, terminals, the handler's judge cwd and destructive-path floor) must resolve from the session's checkout, never from the project path. `CHECKOUT_VARIABLE_MESSAGE_TYPES` (`bridge/src/protocol.ts`) is the authoritative set and is mirrored BY HAND as `kCheckoutVariableMessageTypes` (`app/lib/project/project_message_classification.dart`); the two drifting apart is silent. An app that doesn't advertise the `checkoutRouting` capability is refused a project holding a managed session rather than shown main's workspace beside an isolated agent. `WORKTREE_SESSIONS_SUPPORTED` (`bridge/src/worktree-capability.ts`) is the kill switch. +- **Adding a message type** requires ALL of: schema in `protocol.ts` → add to `AbMessageSchema` union → add to `KNOWN_TYPES` set → export the type → handle the `case` in `handleAbMessage` (`bridge/src/agent-core.ts` — the inbound switch; `index.ts` is only the commander CLI and routes no message types). Miss one and it silently fails. If the type reads or writes the working tree, it also belongs in `CHECKOUT_VARIABLE_MESSAGE_TYPES` — see below. +- **Checkout-scoped routing** — an isolated session runs in a managed git worktree, so anything filesystem-variable (files, tree, search, Git, commands, preview, terminals, the handler's judge cwd and destructive-path floor) must resolve from the session's checkout, never from the project path. `CHECKOUT_VARIABLE_MESSAGE_TYPES` (`bridge/src/protocol.ts`) is the authoritative set and is mirrored BY HAND as `kCheckoutVariableMessageTypes` (`app/lib/project/project_message_classification.dart`); the two drifting apart is silent. An app that doesn't advertise the `checkoutRouting` capability is refused a project holding a managed session rather than shown main's workspace beside an isolated agent. `WORKTREE_SESSIONS_SUPPORTED` (`bridge/src/worktree-capability.ts`) is the kill switch. Inbound `session:*` verbs are deliberately NOT in that set — they name a `sessionId` and the bridge resolves the checkout from the entry, so a `checkoutId` on such a frame is a second, conflicting answer to a question already settled host-side (`session:result` is in the set because it carries the checkout back OUT). `bridge/tests/checkout-protocol-contract.test.ts` pins it, `session:setup` included. - **Command execution is gated by account membership AND one machine-level remote-access switch, not by origin.** A phone is trusted the moment the bridge resolves its identity from the signed-in user's account inventory (no pairing ceremony); trust alone is NOT enough. A remote phone may drive project X iff it is account-trusted **AND** the machine's remote-access boolean is on (`remote-access-policy.ts`, the sole authorization store — `paired-phones.ts` is identity/push/last-seen only) **AND** X is in the host's project catalog (`seenProjects` in `host-server.ts`). Default is off on a fresh install; off is machine-wide and immediate. That catalog lookup plus `isSafeProjectId` are the *only* thing bounding which projectId a phone may name — nothing backs them up, so never refactor them away as redundant. Loopback/local callers are exempt by design (`currentPhoneAllowed()` in `agent-core.ts`): the desktop drives its own machine with the switch off. The `antgrid phones remove` CLI is **not** a revocation — see `docs/commands.md`. - **NEVER make encryption optional.** All agent↔app messages are encrypted after handshake; the relay never holds decryption keys. - **The repo is dual-licensed and the boundary is one-way.** `packages/antgrid-wire` and `packages/antgrid_relay_client` are Apache-2.0 and carry their own `LICENSE`; everything else Antgrid owns is ELv2 (`LICENSING.md` is the map). Apache code may be used inside an ELv2 component — **never move a file the other way**. Hoisting a shared helper out of `bridge/`, `relay/`, `web/` or `app/` into either package relicenses it permissively, and once published that cannot be undone. It compiles, CI stays green, and nothing warns you. diff --git a/app/CLAUDE.md b/app/CLAUDE.md index b353d8df..bf988ff1 100644 --- a/app/CLAUDE.md +++ b/app/CLAUDE.md @@ -12,6 +12,7 @@ app's relay layer lives outside this tree: see - `providers/recent_sessions.dart` + `providers/project_work_status.dart` — the live work-status maps, both filled imperatively by the app_shell control-plane reaper (single-writer; a per-machine `ref.watch` fan-in reproduced Riverpod's "rebuilt multiple times in the same frame" crash). `remoteSessionStatusProvider` is per SESSION and authoritative — the dot belongs to the session row, and drawer PROJECT rows carry none at all (a rollup beside the session rows only restated the loudest of them; a collapsed project therefore shows nothing, by design). The two project-level surfaces left are the collapsed MACHINE header's aggregate (`machineWorkStatusProvider`, call-to-action states only) and the title bar's focused-project pill. An entryId absent from it (older bridge, cold project) is what makes a row fall back to `remoteProjectStatusProvider`, so never default it to an empty map. Only `remoteProjectStatusProvider` is seeded from the persisted cache, and that seed reaches ONLY the two project-level surfaces — with no per-session entry `sessionRowStatus` masks on `running`, which the cache loads false, so no row shows a cold-boot dot. A per-session entry also outranks the row's `running` flag (`sessionRowStatus`): the bridge files a status only for sessions it lists as running, while `running` comes from the cache, which loads false for every session — masking on it blanked the whole Recent list after a restart. **Retire the two maps together** — a closed socket or a dead host must clear BOTH (see the reaper's `_syncLabelSubscriptions` / `_pollLocalProjectStatus`): the per-session map wins in `sessionRowStatus`, so a stale entry surviving its socket is a dot nothing can clear. - `providers/agent_catalog.dart` — `agentCatalogProvider`, registry key → `AgentDescriptor` (label, chatCapable, judgeCapable, handler observability), merged from every machine's `agent:tools.agents[]` advert over a persisted map (`antgrid.agent_catalog.v1`). **The bridge is authoritative for what an agent is called and what it can do; the app only caches what it was told**, so a newly-registered agent needs no app release — and the app ships no mirror of the bridge registry to drift. Merging across machines is sound because the descriptor is a projection of the bridge's STATIC registry; the machine-scoped question ("installed here") stays on `tools[]`. A key ABSENT from the map means nobody has described that agent: render it as unknown (disabled control + reason), never as `false` — and never fall back to an app-side list, which is the mirror this surface exists to delete. A session already RUNNING in chat mode outranks all of it (`session_mode_control.dart`): the mode on screen cannot be reported impossible. Persistence is racy by construction — the store REPLACES the whole blob and an advert can land before `_hydrate` — so a pre-hydration `merge` defers its write to `_hydrate`, which persists the union. Filled imperatively by the app_shell reaper (single-writer, same reason as the work-status maps) from `_onControlPlaneState` for remote machines and one `tools:list` per host process for the local bridge. The brand-mark map (`design/ab_agent_marks.dart`) stays app-owned — it is an ASSET with a designed monogram fallback, and `AbIcon` renders raw SVG, which is not something to accept off the wire. **Handler coverage has two sources and neither substitutes for the other**: `handlerObservableFromCatalog` (this file) is the PRE-arm answer about an AGENT and is all that exists before arming, while `HandlerSessionState.observability` off `handler:status` is the post-arm answer about a SESSION (its live mode and judge pick included) and outranks the prediction wherever both are known. Null on either side means "nobody said" — the UI claims neither, and `escalateOnly` (watched, no headless judge) must stay visibly distinct from `unsupported` (nothing reaches the handler). - `providers/cached_sessions.dart` — the read-through session cache behind every non-focused drawer row and the whole Recent list. Two rules keep a mutation from being silently dropped: a change signal routed through a `StreamProvider` must carry a monotonic seq (`CacheChange`), since `AsyncData(x) == AsyncData(x)` notifies nobody and the same project changing twice in a row is the norm (same hazard, same fix as `relayConnectionChangesProvider`); and `cachedSessionsProvider` subscribes to `CachedSessionsStore.changes` DIRECTLY, so a drawer row's freshness never depends on something else keeping `cacheChangesProvider` alive. A local project only write-throughs while it is WARM — a session started (or deleted) from mobile in a cold local project still won't appear until it's opened. +- `providers/session_setup.dart` — per-session `worktree.setup` state, behind the workspace banner (`widgets/session_setup_banner.dart`) and the isolation badge's `preparing` arm. **Setup state is runtime-only bridge state and is never cached**: `CachedSessionsStore` strips `setup` on write and clears it on load, beside `running`/`deleting`, because a cache written mid-run restores `state: "running"` with nothing alive to finish it — a row that is permanently preparing. Read it through `sessionSetupProvider`, which projects `freshSessionsStateProvider` alone, and never off a `SessionEntry` that may have come from the cache; the honest cost is that only the FOCUSED project's rows can report setup at all. `sessionSetupPhase` is the single decode site for the bridge's state vocabulary and degrades an unrecognized one the way `sessionCheckoutHealth` does. `setup` is deliberately NOT a `checkoutState` value — that field answers "is this workspace usable" and a checkout is `ready` throughout the run, which is what makes Skip meaningful. The transcript is reached with `setup.terminalId` VERBATIM (`:setup`): it resolves through the bridge's identity mapping and the bare name `setup` does not. - `services/` — 7 per-project services (`FileService`, `SessionsService`, `TerminalService`, `ConfigService`, `SearchService`, `CommandService`, `PreviewService`), each `XxxService.fromSession(session)` owned by a `ProjectSession`. They subscribe to `session.heavyStream`/`statusStream` in their constructor (so welcome-cached messages are caught), send via `session.send(...)`, and have no app-wide singleton — `xServiceProvider` returns the focused project's instance. Local and relay flows are unified: `selectedProjectIdProvider` is the single focus id; `agentTransportForProvider(id)` picks relay vs local; `projectSessionProvider(id)` wires the session + 7 services. - `project/project_session.dart` — per-project aggregate (transport + MessageRouter + ProjectStatusNotifier + services). Lifetime via `ProjectSessionRegistry`, **no `autoDispose`**. - `project/project_session_registry.dart` — warm-projects LRU with per-bucket caps (`warmCapForBucket` in `limits.dart`): desktop = local `kWarmCapLocal=10` + relay `kWarmCapRelay=30` as SEPARATE quotas (no shared budget — opening a relay socket never evicts a local agent), mobile = `kWarmCapMobile=3` for both. `touch(id, {required isLocal})` buckets each project; eviction stays within the overflowing bucket (oldest by last-focus time, `selectEvictionVictim` in `lru_policy.dart`; the just-focused project is protected). `onEvict` writes final status to cache and invalidates session+transport providers. v3: evicting a relay project closes its stream binding, not a socket — the machine's `RelayConnection` is released only when nothing on that machine needs it (control-plane reaper in `control_plane.dart`). @@ -38,6 +39,10 @@ app's relay layer lives outside this tree: see **Never start async work from a `void` callback without `detached` (`util/detached.dart`).** A tap handler, a post-frame callback and a `ref.listen` all DISCARD the future they start, so a rejection reaches `PlatformDispatcher.onError` as a FATAL with no in-app frames to point at the site — which is how a `session:*` reply dropped while the transport re-establishes (`PendingReply`'s routine 15s `TimeoutException`, already reconciled by the next `session:updated` push) shipped as a crash in 1.20668.151. `await`ing INSIDE such a callback is not a fix — the callback is the boundary, and an `async` closure passed where a `VoidCallback` is expected is the same bug wearing an await. Where the user pressed something and is owed an answer, also catch the `TimeoutException` at the call and say so in the UI; a log line alone makes a confirmed action indistinguishable from a dropped tap. +**A queued start is not a stopped session.** While `worktree.setup` runs the bridge HOLDS the session's `session:start`, so the entry reports `running: false` for the whole run with `setup.pendingStart` set. Every auto-start path must gate on `sessionStartQueued` (`providers/session_setup.dart`) or it sends a SECOND start carrying no `initialPrompt` — a start the user did not ask for, into a workspace that is not provisioned yet. `WorkspaceShellState._bootstrapSessions` and the drawer row's own tap (`session_row.dart`) are both such paths. The bridge keeps an already-queued prompt rather than letting a promptless start replace it, so the user's typing is no longer at stake, but that is a backstop and not the contract. For the same reason the create flow navigates on the SEND, not on the start reply. + +**`AbConfig` must round-trip every top-level `antgrid.yaml` key it does not model.** `ProjectSettingsScreen`'s Save re-serializes the whole config through `models/ab_config.dart`, so a key the model drops is DELETED from the user's file — `worktree` is carried verbatim as a raw map for exactly that reason, and a new block in `bridge/src/config.ts` with no home here is a data-loss bug nothing type-checks. A write also re-emits the YAML through the bridge's serializer, losing the user's comments and formatting: weigh that before adding another surface that edits the config on the user's behalf. + **Deep links are `app_links`' alone; the framework must never route one.** `AbApp` is a home-only `MaterialApp` (no `routes`, no `onGenerateRoute`, no `onUnknownRoute`), so a platform route push makes `WidgetsApp` `pushNamed` a path nothing matches and dereference its null `onUnknownRoute` — the null-check TypeError that shipped as a fatal in 1.20677.173, one per `antgrid://` link opened. Two things hold it off and BOTH must stay: `flutter_deeplinking_enabled=false` / `FlutterDeepLinkingEnabled=false` in the Android manifest and the iOS/macOS plists (the embedders' own handling defaults to ON and forwards every VIEW intent), and `PlatformRouteGuard` (`navigation/platform_route_guard.dart`), added in `main()` **before `runApp`** — observers are consulted in registration order until one returns true, so registering it later silently returns the crash. It swallows without re-dispatching, deliberately: `app_links` already delivered the same link to `handleLink`, and the auth callback's OTT is single-use. ## Design Rules (app UI) diff --git a/app/lib/models/ab_config.dart b/app/lib/models/ab_config.dart index c22f21b7..de302e89 100644 --- a/app/lib/models/ab_config.dart +++ b/app/lib/models/ab_config.dart @@ -202,6 +202,16 @@ class AbConfig { final List commands; final List ports; + /// The `worktree` block verbatim, carried rather than modelled. + /// + /// Nothing in the app renders it — a managed checkout's provisioning is the + /// bridge's job — but the settings screen round-trips the WHOLE config + /// through this model on Save, so a key that is not carried here is deleted + /// from the user's `antgrid.yaml` by an unrelated edit. Keeping it raw also + /// means the bridge can widen the block without the app silently dropping + /// whatever part of it this build predates. + final Map? worktree; + const AbConfig({ this.name, this.relayUrl, @@ -209,6 +219,7 @@ class AbConfig { this.services = const [], this.commands = const [], this.ports = const [], + this.worktree, }); factory AbConfig.fromJson(Map json) => AbConfig( @@ -230,6 +241,10 @@ class AbConfig { : AbPort.fromJson(e as Map), ) .toList(), + worktree: switch (json['worktree']) { + final Map m => m, + _ => null, + }, ); Map toJson() => { @@ -241,6 +256,7 @@ class AbConfig { if (commands.isNotEmpty) 'commands': commands.map((c) => c.toJson()).toList(), if (ports.isNotEmpty) 'ports': ports.map((p) => p.toJson()).toList(), + if (worktree != null) 'worktree': worktree, }; AbConfig copyWith({ @@ -251,6 +267,7 @@ class AbConfig { List? services, List? commands, List? ports, + Map? worktree, }) { assert( !(clearRelayUrl && relayUrl != null), @@ -263,6 +280,7 @@ class AbConfig { services: services ?? this.services, commands: commands ?? this.commands, ports: ports ?? this.ports, + worktree: worktree ?? this.worktree, ); } } diff --git a/app/lib/models/session_entry.dart b/app/lib/models/session_entry.dart index 01f5af56..5be4de4a 100644 --- a/app/lib/models/session_entry.dart +++ b/app/lib/models/session_entry.dart @@ -1,5 +1,141 @@ import 'agent_work_status.dart'; +/// Provisioning of an isolated session's own checkout (`worktree.setup` in the +/// project's `antgrid.yaml`). +/// +/// Orthogonal to [SessionEntry.checkoutState], which answers "is this workspace +/// usable" and stays `ready` for the whole run — this answers "has provisioning +/// finished". That split is what makes Skip meaningful: the tree is fine, the +/// dependencies are not there yet. +/// +/// [state] is carried as the raw wire string, matching the other bridge-owned +/// vocabularies on [SessionEntry]: the bridge may widen it, and a value this +/// build cannot name must degrade at the render site rather than be lost here. +/// Known values: `running`, `done`, `failed`, `skipped`, `interrupted`. +class SessionSetup { + final String state; + + /// 0-based, the current step while running and the last one afterwards. + final int stepIndex; + final int stepCount; + final String? stepName; + + /// The setup transcript's terminal. The only handle on that log, and the name + /// every list filters by: the setup PTY is typed neither `agent` nor + /// `service`, and the ad-hoc terminal list selects by EXCLUDING those two — + /// so untyped reads there as "a user terminal" unless it is dropped by id + /// (`terminal_list_view.dart`). + final String? terminalId; + final int? exitCode; + + /// One-line failure summary. + final String? message; + + /// A start is queued behind this run. The bridge replies `ok` to a + /// `session:start` it queues, so this — not the reply — is how the app tells + /// "queued" from "started". + final bool pendingStart; + final int startedAt; + final int? finishedAt; + + const SessionSetup({ + required this.state, + required this.stepIndex, + required this.stepCount, + required this.startedAt, + this.stepName, + this.terminalId, + this.exitCode, + this.message, + this.pendingStart = false, + this.finishedAt, + }); + + Map toJson() => { + 'state': state, + 'stepIndex': stepIndex, + 'stepCount': stepCount, + if (stepName != null) 'stepName': stepName, + if (terminalId != null) 'terminalId': terminalId, + if (exitCode != null) 'exitCode': exitCode, + if (message != null) 'message': message, + 'pendingStart': pendingStart, + 'startedAt': startedAt, + if (finishedAt != null) 'finishedAt': finishedAt, + }; + + factory SessionSetup.fromJson(Map j) => SessionSetup( + // `as String?`, like every sibling: [listFromJson] has no per-element + // guard, so one entry whose `setup` arrived without a state would throw + // the WHOLE session list away rather than degrade its own row. An empty + // state is a name no build can resolve, which is what + // [SessionSetupPhase.unknown] is for. + state: j['state'] as String? ?? '', + stepIndex: (j['stepIndex'] as num?)?.toInt() ?? 0, + stepCount: (j['stepCount'] as num?)?.toInt() ?? 0, + stepName: j['stepName'] as String?, + terminalId: j['terminalId'] as String?, + exitCode: (j['exitCode'] as num?)?.toInt(), + message: j['message'] as String?, + pendingStart: j['pendingStart'] as bool? ?? false, + startedAt: (j['startedAt'] as num?)?.toInt() ?? 0, + finishedAt: (j['finishedAt'] as num?)?.toInt(), + ); + + SessionSetup copyWith({ + String? state, + int? stepIndex, + int? stepCount, + String? stepName, + String? terminalId, + int? exitCode, + String? message, + bool? pendingStart, + int? startedAt, + int? finishedAt, + }) => SessionSetup( + state: state ?? this.state, + stepIndex: stepIndex ?? this.stepIndex, + stepCount: stepCount ?? this.stepCount, + stepName: stepName ?? this.stepName, + terminalId: terminalId ?? this.terminalId, + exitCode: exitCode ?? this.exitCode, + message: message ?? this.message, + pendingStart: pendingStart ?? this.pendingStart, + startedAt: startedAt ?? this.startedAt, + finishedAt: finishedAt ?? this.finishedAt, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SessionSetup && + other.state == state && + other.stepIndex == stepIndex && + other.stepCount == stepCount && + other.stepName == stepName && + other.terminalId == terminalId && + other.exitCode == exitCode && + other.message == message && + other.pendingStart == pendingStart && + other.startedAt == startedAt && + other.finishedAt == finishedAt; + + @override + int get hashCode => Object.hash( + state, + stepIndex, + stepCount, + stepName, + terminalId, + exitCode, + message, + pendingStart, + startedAt, + finishedAt, + ); +} + class SessionEntry { final String id; final String name; @@ -47,6 +183,11 @@ class SessionEntry { final String? checkoutBranch; final String checkoutState; + /// Null for every shared session, for a bridge predating the feature, and for + /// an isolated session whose project declares no `worktree.setup` — all three + /// mean "nothing to report", which is today's behaviour exactly. + final SessionSetup? setup; + const SessionEntry({ required this.id, required this.name, @@ -66,6 +207,7 @@ class SessionEntry { this.checkoutKind = 'main', this.checkoutBranch, this.checkoutState = 'ready', + this.setup, }); Map toJson() => { @@ -89,6 +231,7 @@ class SessionEntry { 'checkoutKind': checkoutKind, if (checkoutBranch != null) 'checkoutBranch': checkoutBranch, 'checkoutState': checkoutState, + if (setup != null) 'setup': setup!.toJson(), }; factory SessionEntry.fromJson(Map j) => SessionEntry( @@ -121,6 +264,10 @@ class SessionEntry { checkoutKind: j['checkoutKind'] as String? ?? 'main', checkoutBranch: j['checkoutBranch'] as String?, checkoutState: j['checkoutState'] as String? ?? 'ready', + setup: switch (j['setup']) { + final Map m => SessionSetup.fromJson(m), + _ => null, + }, ); /// Parse a JSON array of session maps, skipping any non-map element. Shared by @@ -137,6 +284,11 @@ class SessionEntry { bool? archived, bool? running, bool? deleting, + SessionSetup? setup, + /// Drop the provisioning state instead of carrying it over. Never pass this + /// together with [setup] — the two are contradictory answers to the same + /// field. + bool clearSetup = false, }) => SessionEntry( id: id, name: name ?? this.name, @@ -156,6 +308,7 @@ class SessionEntry { checkoutKind: checkoutKind, checkoutBranch: checkoutBranch, checkoutState: checkoutState, + setup: clearSetup ? null : (setup ?? this.setup), ); @override @@ -179,7 +332,8 @@ class SessionEntry { other.checkoutId == checkoutId && other.checkoutKind == checkoutKind && other.checkoutBranch == checkoutBranch && - other.checkoutState == checkoutState; + other.checkoutState == checkoutState && + other.setup == setup; @override int get hashCode => Object.hash( @@ -201,5 +355,6 @@ class SessionEntry { checkoutKind, checkoutBranch, checkoutState, + setup, ); } diff --git a/app/lib/providers/new_session_action.dart b/app/lib/providers/new_session_action.dart index 0ad868b5..741708bb 100644 --- a/app/lib/providers/new_session_action.dart +++ b/app/lib/providers/new_session_action.dart @@ -86,8 +86,11 @@ class ActiveSessionsBranchSwitchException implements Exception { /// /// Activates the picker-selected target project so `selectedRegistrationIdProvider` /// becomes its id, waits for the per-project [ProjectSession] (transport + -/// services) to construct, then creates and starts a fresh session in it and -/// marks it active. Finally leaves new-session mode so the workspace renders. +/// services) to construct, then creates a fresh session in it, marks it active +/// and leaves new-session mode so the workspace renders. The `session:start` +/// goes out on the way through and is reconciled after the hand-off — the +/// bridge may only have QUEUED it behind an isolated checkout's setup run, so +/// its reply is no longer worth blocking the navigation on. /// /// This replaces the old "instant create" path (a pending provider consumed by /// `_bootstrapSessions`): the New Session page now owns the explicit @@ -225,9 +228,9 @@ Future startNewSession( // An agent rejection (`ok:false`) comes back as a null result, but a // dropped/late reply completes the pending request with a TimeoutException // (a throw, not null). Guard the whole create→start block so that thrown - // case is handled like the null one — stay on the New Session page — rather - // than escaping `startNewSession` as an unhandled async error. The - // in-flight flag is still cleared by the outer `finally`. + // case is handled like the null one — the draft survives and the canvas is + // retryable — rather than escaping `startNewSession` as an unhandled async + // error. The in-flight flag is still cleared by the outer `finally`. try { final created = await svc.create( name: name.isEmpty ? null : name, @@ -239,22 +242,35 @@ Future startNewSession( baseBranch: isolated ? explicitBranch : null, ); // create failed (e.g. session cap reached); stay on the New Session page - // so the user can retry. + // so the user can retry. Only CREATE keeps the user here — once the + // session exists it is theirs, and the place to report anything further + // about it is the session itself. if (created == null) return; if (ref.read(selectedRegistrationIdProvider) != pid) return; final prompt = ref.read(newSessionPromptProvider).trim(); - final started = await svc.start( + + // 4. Send the start, then navigate on it rather than on its reply. An + // isolated session's start is QUEUED behind the checkout's setup run and + // answered `ok: true` immediately with `setup.pendingStart` set, so the + // reply cannot say whether the agent is live — every surface reads that + // off the session entry instead, and waiting here would only hold the + // canvas over a session the user is already owed. + // + // Sent BEFORE leaveNewSession, deliberately: that remounts WorkspaceShell, + // whose bootstrap immediately re-lists the sessions and auto-starts the + // one it adopts if the list says it is stopped. Issuing the start first + // puts it ahead of that list on the same stream, so the bridge answers + // with the start already accounted for instead of taking a second one + // that carries no initialPrompt. That ordering is all a SHARED session + // needs; an isolated one whose start is queued reports `running: false` + // for the whole setup run, and the bootstrap's own `sessionStartQueued` + // guard is what stops it starting over the top. + final starting = svc.start( created.id, initialPrompt: prompt.isEmpty ? null : prompt, raiseRefusal: true, ); - // start failed with no reason on the wire (an `ok:true` carrying no - // session, or an older agent's bare rejection — an unknown tool, no agent - // configured); a CODED refusal is raised past here to the composer, which - // says what it was. Either way stay on the New Session page so the user - // can retry rather than dropping into a session whose PTY never spawned. - if (started == null) return; - if (ref.read(selectedRegistrationIdProvider) != pid) return; + ref.read(activeSessionIdProvider.notifier).set(created.id); ref .read(analyticsServiceProvider) @@ -262,10 +278,6 @@ Future startNewSession( AnalyticsEvents.sessionOpened, props: {'surface': isMobilePlatform ? 'mobile' : 'desktop'}, ); - - // 4. A successful start consumes the draft. Navigation itself preserves - // drafts, so failures and a later return to this canvas remain editable. - resetNewSessionForm(ref); // Leaving the canvas REMOUNTS WorkspaceShell (AppShell swaps the whole // route), and its bootstrap re-derives the active session from the // bridge's `lastUsedAt` ranking. Name the session we just started so that @@ -275,9 +287,25 @@ Future startNewSession( // the focus the user just asked for. ref.read(pendingActiveSessionIdProvider.notifier).set(created.id); leaveNewSession(ref); + + // 5. Reconcile the reply now that the user is already in the session. A + // queued start is a SUCCESS — the entry comes back carrying + // `setup.pendingStart` — so only a bare rejection (an `ok:true` with no + // session, an older agent's unknown tool) leaves the draft intact for a + // return to this canvas; a CODED refusal still raises past here. + final started = await starting; + if (started == null) return; + if (ref.read(selectedRegistrationIdProvider) != pid) return; + // An accepted start consumes the draft. Navigation itself preserves + // drafts, so failures and a later return to this canvas remain editable. + resetNewSessionForm(ref); } on TimeoutException { // A dropped/late reply is retryable. Typed bridge failures intentionally - // reach the composer so it can show their safe display message. + // reach the composer so it can show their safe display message — though + // a START refusal now arrives after the hand-off, by which point the + // composer is unmounted and it is the workspace's OperationalErrorToaster + // that voices it (the service stamps the reason onto SessionsState.error + // before failing the pending request). } } finally { ref.read(newSessionStartInFlightProvider.notifier).set(false); diff --git a/app/lib/providers/session_setup.dart b/app/lib/providers/session_setup.dart new file mode 100644 index 00000000..128889e1 --- /dev/null +++ b/app/lib/providers/session_setup.dart @@ -0,0 +1,141 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../models/session_entry.dart'; +import '../services/sessions_service.dart'; +import 'providers.dart'; +import 'sessions.dart'; + +export '../services/sessions_service.dart' show SessionSetupAction; + +/// What a surface may claim about an isolated session's provisioning run. +/// +/// [unknown] is the answer for a `SessionSetup.state` this build cannot name, +/// and it is load-bearing rather than defensive, mirroring +/// `sessionCheckoutHealth`: the bridge owns that vocabulary and may widen it, +/// so an unrecognised value must degrade to the weakest true statement instead +/// of being read as either finished or still going. +enum SessionSetupPhase { running, done, failed, skipped, interrupted, unknown } + +SessionSetupPhase sessionSetupPhase(SessionSetup? setup) => + switch (setup?.state) { + 'running' => SessionSetupPhase.running, + 'done' => SessionSetupPhase.done, + 'failed' => SessionSetupPhase.failed, + 'skipped' => SessionSetupPhase.skipped, + 'interrupted' => SessionSetupPhase.interrupted, + _ => SessionSetupPhase.unknown, + }; + +/// Outcome of a [runSessionSetupAction]. Mirrors [SessionModeResult]: the +/// banner is the only surface these verbs have, so a refusal has to arrive with +/// something to render rather than collapsing to a bare failure. +typedef SessionSetupResult = ({bool ok, String? error}); + +/// Setup state for [sessionId], or null when the session has none — every +/// shared session, a bridge predating the feature, and an isolated session +/// whose project declares no `worktree.setup` block. +/// +/// A thin projection of the live session list and nothing more: the bridge owns +/// this state and pushes every transition on `session:updated`, so no surface +/// may hold its own copy or optimistically advance it. +/// +/// Deliberately sourced from [freshSessionsStateProvider] alone, never the +/// persisted cache — which carries no `setup` by design, since a stored +/// `running` would restore with nothing alive to finish it and paint a +/// permanent "preparing" banner (`cached_sessions_store.dart`). +/// +/// `autoDispose`, unlike most families here: those are keyed by a bounded +/// entryId or projectId, while this one is keyed by sessionId and read once per +/// rendered row of a cross-project Recent list. Kept alive, every session id +/// ever scrolled past — deleted ones included — would leave a provider behind, +/// each re-running its scan of the list on every `session:updated`. +final sessionSetupProvider = Provider.autoDispose.family(( + ref, + sessionId, +) { + final state = ref.watch(freshSessionsStateProvider); + if (state == null) return null; + for (final s in state.sessions) { + if (s.id == sessionId) return s.setup; + } + return null; +}); + +/// [sessionSetupProvider] for the session the workspace is showing. +final activeSessionSetupProvider = Provider.autoDispose((ref) { + final id = ref.watch(activeSessionIdProvider); + if (id == null) return null; + return ref.watch(sessionSetupProvider(id)); +}); + +/// Whether an agent start is waiting on [sessionId]'s setup to finish. +/// +/// Read off the entry rather than remembered from the `session:start` reply: +/// the bridge answers `ok` to a start it has only queued, so the reply cannot +/// tell the two apart and the entry is the only honest source. +final sessionStartQueuedProvider = Provider.autoDispose.family(( + ref, + sessionId, +) { + return sessionStartQueued(ref.watch(sessionSetupProvider(sessionId))); +}); + +/// [sessionStartQueuedProvider]'s predicate, for the callers holding an entry +/// straight off `session:list` rather than a provider. +/// +/// Load-bearing wherever a stopped session is auto-started: a queued session +/// reports `running: false` for the whole setup run, so `running` alone reads +/// as "needs starting" and sends a SECOND `session:start`. That one carries no +/// `initialPrompt`, and it replaces the queued one the user actually typed. +bool sessionStartQueued(SessionSetup? setup) => setup?.pendingStart ?? false; + +/// Sends `session:setup` for [sessionId] in [entryId] and reports what came +/// back. +/// +/// Acknowledges the VERB only. The run itself takes minutes and reports through +/// `session:updated`, so a caller must render from [sessionSetupProvider] and +/// never from this future — awaiting the run here would lapse the service's +/// pending-reply timeout on every project with real provisioning to do. +/// +/// Keyed on an explicit [entryId], and warmed rather than read, for the reasons +/// in [warmServiceFor]: this is a button press, and the windows where the +/// focused project's services are momentarily absent are exactly the ones the +/// button exists to recover from. +/// +/// Never throws — a refusal, a dropped reply and an unreachable project all +/// come back as `ok: false` with a line to show. The callers are `void` tap +/// handlers, where an escaping rejection would reach +/// `PlatformDispatcher.onError` as a fatal. +Future runSessionSetupAction( + ProviderContainer container, { + required String entryId, + required String sessionId, + required SessionSetupAction action, +}) async { + final service = await warmServiceFor( + container, + entryId, + (s) => s.sessionsService, + ); + if (service == null) { + return (ok: false, error: 'This project isn\'t connected right now.'); + } + try { + await service.setup(sessionId, action); + return (ok: true, error: null); + } on SessionOperationException catch (e) { + return (ok: false, error: e.message ?? e.errorCode); + } on TimeoutException { + return ( + ok: false, + error: 'The machine didn\'t answer. Setup may still be running.', + ); + } on StateError { + // The project's services were torn down under the request (a host restart, + // an LRU eviction). Nothing was necessarily lost on the bridge, so this + // says what is known rather than claiming the action failed. + return (ok: false, error: 'This project reconnected. Try again.'); + } +} diff --git a/app/lib/screens/workspace_shell.dart b/app/lib/screens/workspace_shell.dart index a651065a..1ed2950e 100644 --- a/app/lib/screens/workspace_shell.dart +++ b/app/lib/screens/workspace_shell.dart @@ -36,6 +36,7 @@ import '../providers/new_session_picker.dart' import '../providers/providers.dart'; import '../providers/relay_error_banner.dart'; import '../providers/session_search.dart'; +import '../providers/session_setup.dart'; import '../providers/sessions.dart'; import '../providers/supervisor_status.dart'; import '../providers/ui_attention_providers.dart'; @@ -57,6 +58,7 @@ import '../widgets/operational_error_toaster.dart'; import '../widgets/projects_drawer.dart'; import '../widgets/session_search_modal.dart'; import '../widgets/session_start_refusal.dart'; +import '../widgets/session_setup_banner.dart'; import '../design/widgets/pulsing_opacity.dart'; import '../widgets/resizable_pane.dart'; import '../widgets/workspace_tab_bar.dart'; @@ -590,7 +592,9 @@ class WorkspaceShellState extends ConsumerState .firstOrNull; if (desired != null) { ref.read(activeSessionIdProvider.notifier).set(desired.id); - if (!desired.running) { + // A start already queued behind an isolated checkout's setup run is + // the create flow's own, prompt and all — see [sessionStartQueued]. + if (!desired.running && !sessionStartQueued(desired.setup)) { // The cross-project half of a session-row / Recent-list tap, so a // refused start has to speak here too — otherwise the same tap reports // its failure only when the project happened to be focused already. @@ -658,7 +662,7 @@ class WorkspaceShellState extends ConsumerState orElse: () => active.first, ); ref.read(activeSessionIdProvider.notifier).set(session.id); - if (!session.running) { + if (!session.running && !sessionStartQueued(session.setup)) { await _startBestEffort(svc, session.id); if (!mounted) return; if (ref.read(selectedRegistrationIdProvider) != triggeredFor) return; @@ -1072,6 +1076,7 @@ class WorkspaceShellState extends ConsumerState const OperationalErrorToaster(), const AbBanner(), const AbHostBanner(), + const SessionSetupBanner(), Expanded( child: isMobile ? _buildMobile(surfaceChild) diff --git a/app/lib/services/sessions_service.dart b/app/lib/services/sessions_service.dart index c1181757..298c682b 100644 --- a/app/lib/services/sessions_service.dart +++ b/app/lib/services/sessions_service.dart @@ -24,6 +24,21 @@ const _kPendingReplyTimeout = Duration(seconds: 15); /// a failure that did not happen. const _kModeReplyTimeout = Duration(seconds: 25); +/// What a `session:setup` request asks of an isolated session's provisioning +/// run: [skip] releases the queued agent start and leaves setup running, +/// [cancel] kills the run, [rerun] starts a fresh one from a finished state. +/// +/// A closed enum here, unlike the bridge-owned vocabularies decoded onto +/// [SessionSetup]: this side AUTHORS the value, so a name the bridge has not +/// heard of is a bug to catch at the call, not a value to degrade. +enum SessionSetupAction { + skip, + cancel, + rerun; + + String get wire => name; +} + /// Outcome of a `session:set-mode`. The other session verbs collapse a failure /// to `null` because their callers have nothing to say about it; a mode flip /// has to snap the toggle back and name the reason, so it carries the reply's @@ -362,6 +377,24 @@ class SessionsService { return _mutate('session:unarchive', {'sessionId': id}); } + /// Acts on [id]'s `worktree.setup` run. + /// + /// The reply acknowledges the VERB, never the run. A setup takes minutes and + /// reports through `session:updated`, so awaiting completion here would lapse + /// [_kPendingReplyTimeout] on every project with real provisioning to do — + /// and the banner that issued the action is already watching that state. + /// + /// Raises [SessionOperationException] on a refusal rather than collapsing it + /// to null (a `rerun` asked for while the run is still going is the reachable + /// one): the banner is the only surface this verb has, so it has to be able + /// to name the reason. + Future setup(String id, SessionSetupAction action) { + return _mutate('session:setup', { + 'sessionId': id, + 'action': action.wire, + }, raiseRefusal: true); + } + Future setMode(String id, String mode) { final requestId = _newRequestId(); final pending = _newPending( diff --git a/app/lib/storage/cached_sessions_store.dart b/app/lib/storage/cached_sessions_store.dart index f93613f2..7dc4aa7e 100644 --- a/app/lib/storage/cached_sessions_store.dart +++ b/app/lib/storage/cached_sessions_store.dart @@ -74,15 +74,22 @@ class CachedSessionsStore { /// identical to the in-memory one. Writes are debounced; [changes] still /// emits on the next microtask so listeners can react synchronously. /// - /// `deleting` is dropped on the way IN, not just on the way to disk. The - /// cache is what every surface falls back to the moment the live stream stops - /// matching an entry, and nothing here is subscribed to the push that would - /// clear the flag — so a connection lost inside the delete window would leave - /// the row inert, unopenable and undeletable, for the rest of the app run. - /// The live list keeps carrying it; only the fallback copy is neutralised. + /// `deleting` and `setup` are dropped on the way IN, not just on the way to + /// disk. The cache is what every surface falls back to the moment the live + /// stream stops matching an entry, and nothing here is subscribed to the push + /// that would clear either — so a connection lost inside the delete window + /// would leave the row inert, unopenable and undeletable, and a rebuilt + /// ProjectSession (LRU evict, host restart, reconnect) would seed + /// `sessionSetupProvider` with a run that ended, pulsing the isolation badge + /// and pinning "Preparing workspace…" over a session nothing is provisioning. + /// The live list keeps carrying both; only the fallback copy is neutralised. Future put(String entryId, List sessions) async { final next = [ - for (final s in sessions) s.deleting ? s.copyWith(deleting: false) : s, + for (final s in sessions) + if (s.deleting || s.setup != null) + s.copyWith(deleting: false, clearSetup: true) + else + s, ]; final prev = _mem[entryId]; if (prev != null && _listsEqual(prev, next)) return; @@ -204,15 +211,17 @@ class CachedSessionsStore { for (final item in v) { if (item is Map) { try { - // `running` and `deleting` are in-memory state only — even if - // an older build persisted them, force false on load so a - // fresh launch never resurrects a stale green status dot, or - // a pending row with nothing left alive to clear it. + // `running`, `deleting` and `setup` are in-memory state only + // — even if a build that wrote this blob persisted them, + // clear them on load so a fresh launch never resurrects a + // stale green status dot, a pending row with nothing left + // alive to clear it, or a workspace forever preparing. list.add( SessionEntry.fromJson({ ...item, 'running': false, 'deleting': false, + 'setup': null, }), ); } catch (_) { @@ -275,13 +284,18 @@ class CachedSessionsStore { Future _flush() async { if (_entriesDirty) { _entriesDirty = false; - // Strip `running`, `workStatus` and `deleting` before persisting: all - // three are process-lifetime state owned by SessionsService, not durable - // metadata. A restored `running` renders sessions as live before the - // agent reports; a restored `attention` claims an agent is blocked on a - // prompt that died with the process; a restored `deleting` is worse - // still, because a delete interrupted by the process dying leaves nothing - // behind that could ever clear it — the row comes back permanently inert. + // Strip `running`, `workStatus`, `deleting` and `setup` before + // persisting: all four are process-lifetime state owned by + // SessionsService, not durable metadata. A restored `running` renders + // sessions as live before the agent reports; a restored `attention` + // claims an agent is blocked on a prompt that died with the process; a + // restored `deleting` is worse still, because a delete interrupted by the + // process dying leaves nothing behind that could ever clear it — the row + // comes back permanently inert. `setup` is the same trap and the bridge + // treats it the same way (it is runtime-only there too, and a `running` + // setup state is deliberately never written to `checkouts.json`): a + // cache written mid-provisioning would restore a session as forever + // preparing. final encoded = jsonEncode({ 'version': 1, 'entries': _mem.map( @@ -292,6 +306,7 @@ class CachedSessionsStore { final j = {...s.toJson(), 'running': false}; j.remove('workStatus'); j.remove('deleting'); + j.remove('setup'); return j; }).toList(), ), diff --git a/app/lib/widgets/new_session/new_session_content.dart b/app/lib/widgets/new_session/new_session_content.dart index 12c8927b..47259efb 100644 --- a/app/lib/widgets/new_session/new_session_content.dart +++ b/app/lib/widgets/new_session/new_session_content.dart @@ -19,6 +19,7 @@ import '../session_search_modal.dart'; import 'new_session_composer.dart'; import 'picker_sources.dart'; import 'remote_access_nudge_banner.dart'; +import 'worktree_setup_nudge.dart'; /// Canvas for the New Session page: recent sessions fill the space above a /// bottom-anchored composer (chip row + prompt input). The composer is the @@ -106,6 +107,10 @@ class NewSessionContent extends ConsumerWidget { // mobile's fills RecentSessionsTab's empty slot below. // Neither is mounted here any more. const RemoteAccessNudgeBanner(), + // Self-gating too: it renders only while the + // composer's isolation toggle is on for a target that + // can actually make worktrees. + const WorktreeSetupNudge(), // Recents fill the canvas. RefreshIndicator keeps the // old pull-to-refresh contract (inventory + viewed // machine advert). diff --git a/app/lib/widgets/new_session/worktree_setup_nudge.dart b/app/lib/widgets/new_session/worktree_setup_nudge.dart new file mode 100644 index 00000000..a954c364 --- /dev/null +++ b/app/lib/widgets/new_session/worktree_setup_nudge.dart @@ -0,0 +1,425 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../config/storage_scope.dart'; +import '../../design/ab_colors.dart'; +import '../../design/ab_icons.dart'; +import '../../design/ab_tokens.dart'; +import '../../design/widgets/ab_button.dart'; +import '../../design/widgets/ab_icon.dart'; +import '../../design/widgets/ab_icon_button.dart'; +import '../../design/widgets/ab_snack_bar.dart'; +import '../../models/ab_config.dart'; +import '../../models/file_tree_models.dart'; +import '../../project/project_session.dart'; +import '../../project/project_session_registry.dart'; +import '../../providers/new_session_picker.dart'; +import '../../providers/providers.dart'; +import '../../services/file_service.dart'; +import '../../util/detached.dart'; + +/// Copy sources a starter block seeds when the project shows evidence of env +/// files. Their untracked siblings are exactly what a fresh worktree lacks. +const Set _envTemplateNames = { + '.env.example', + '.env.sample', + '.env.template', + 'env.example', +}; + +/// Root lockfile → the install command that reproduces it. Ordered: a repo +/// carrying two lockfiles is answered by the first match, which puts the +/// package managers that write a lockfile of their own ahead of npm's. +const List<(String, String)> _lockfileInstallCommands = [ + ('bun.lock', 'bun install'), + ('bun.lockb', 'bun install'), + ('pnpm-lock.yaml', 'pnpm install'), + ('yarn.lock', 'yarn install'), + ('package-lock.json', 'npm install'), + ('pubspec.lock', 'flutter pub get'), + ('Cargo.lock', 'cargo fetch'), + ('uv.lock', 'uv sync'), + ('poetry.lock', 'poetry install'), + ('Gemfile.lock', 'bundle install'), + ('composer.lock', 'composer install'), + ('go.sum', 'go mod download'), +]; + +/// Projects whose worktree-setup nudge has been answered — dismissed, or +/// configured from here. +/// +/// Persisted, and keyed per project rather than per install: the nudge explains +/// one fact about one project's checkout, so a user who has answered it for +/// this repo should never see it again for this repo, and a second repo with +/// the same gap still deserves the warning. +class WorktreeSetupNudgeSeen extends AsyncNotifier> { + static final _key = scopedStorageKey('antgrid.worktree_setup_nudge.v1'); + + /// Cacheless [SharedPreferencesAsync] rather than a `WithCache` store: those + /// are opened in `main()` and injected through a throwing override, and this + /// key has exactly one reader and one writer, both on a cold user-driven path. + @override + Future> build() async { + final stored = await SharedPreferencesAsync().getStringList(_key); + return stored?.toSet() ?? const {}; + } + + Future markSeen(String entryId) async { + final current = state.value ?? const {}; + if (current.contains(entryId)) return; + final next = {...current, entryId}; + state = AsyncData(next); + await SharedPreferencesAsync().setStringList(_key, next.toList()); + } +} + +final worktreeSetupNudgeSeenProvider = + AsyncNotifierProvider>( + WorktreeSetupNudgeSeen.new, + ); + +/// Whether [entryId]'s `antgrid.yaml` already declares a `worktree.setup` +/// block, or null when that cannot be answered from here. +/// +/// Answered from the project's own `ConfigService`, which re-reads the config +/// on every establishment and holds it — so a warm project costs nothing on +/// the wire. Nothing here ever ASKS: the New Session canvas renders from cache +/// and connects only on an explicit action, and a cold project answering null +/// is the intended outcome, not a gap. The nudge then shows redundantly at +/// worst, and the write path corrects itself when it finds a block already +/// there. +final _worktreeSetupDeclaredProvider = StreamProvider.autoDispose + .family((ref, entryId) async* { + if (!ref.watch(projectSessionRegistryProvider).contains(entryId)) { + yield null; + return; + } + final session = ref.watch(projectSessionProvider(entryId)).value; + if (session == null) { + yield null; + return; + } + final config = session.configService; + yield _declaresSetup(config.currentState.config); + await for (final state in config.stateStream) { + yield _declaresSetup(state.config); + } + }); + +/// Null where [config] is not known yet — "no config in hand" and "a config +/// with no setup block" drive opposite answers and must not collapse. +bool? _declaresSetup(AbConfig? config) => + config == null ? null : config.worktree?['setup'] != null; + +/// One-time warning that an isolated session starts from a tree holding only +/// tracked files, offered on the New Session canvas the moment the user opts +/// into one. +/// +/// Mounted unconditionally and self-gating, like the remote-access nudge +/// beside it, so the call site stays one stable line. +/// +/// The gate is deliberately asymmetric: the nudge hides on PROOF that the +/// project already declares setup steps and shows on the absence of proof. +/// Only a warm project holds that proof, and the canvas will not warm one to +/// ask; showing a redundant warning once costs a dismissal, while suppressing +/// a real one costs a broken checkout the user gets no explanation for. +class WorktreeSetupNudge extends ConsumerWidget { + const WorktreeSetupNudge({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Isolation is an ephemeral opt-in, so this tracks the toggle rather than + // the project: nothing is claimed about a shared session, whose working + // tree is the one the user is already looking at. + if (!ref.watch(newSessionIsolatedProvider)) return const SizedBox.shrink(); + // A target whose bridge cannot make worktrees at all never reaches the + // create path this warns about. + if (!ref.watch(newSessionIsolationReadyProvider)) { + return const SizedBox.shrink(); + } + final target = ref.watch(selectedTargetProjectProvider); + if (target == null) return const SizedBox.shrink(); + + // Null covers both the first frames of the async read and a storage + // failure: an unanswerable "has this been dismissed" must not flash the + // nudge at a user who already dismissed it. + final seen = ref.watch(worktreeSetupNudgeSeenProvider).value; + if (seen == null || seen.contains(target.id)) { + return const SizedBox.shrink(); + } + if (ref.watch(_worktreeSetupDeclaredProvider(target.id)).value == true) { + return const SizedBox.shrink(); + } + + final t = context.antgrid; + final entryId = target.id; + return Padding( + padding: const EdgeInsets.fromLTRB( + AbTokens.space16, + AbTokens.space12, + AbTokens.space16, + 0, + ), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AbTokens.space12, + vertical: AbTokens.space8, + ), + decoration: BoxDecoration( + color: t.bgSurface, + border: Border.all(color: t.borderSubtle), + borderRadius: BorderRadius.circular(AbTokens.radius8), + ), + child: Row( + children: [ + AbIcon(AbIcons.info, size: 13, color: t.textMuted), + const SizedBox(width: AbTokens.space8), + Expanded( + child: Text( + 'Isolated sessions start from a clean checkout — ignored files ' + "like .env and node_modules aren't there.", + style: AbTokens.sansStyle( + fontSize: AbTokens.fontXs, + color: t.textSecondary, + ), + ), + ), + const SizedBox(width: AbTokens.space10), + AbButton( + label: 'Add setup steps', + compact: true, + // The container, not `ref`: the write outlives this widget by + // design — it retires the nudge on success, and a layout change + // or a target switch can tear the canvas down mid-flight. + onTap: () => detached( + 'NEW_SESSION', + 'write worktree.setup starter block', + () => _applyStarterSetup(context, ref.container, entryId), + ), + ), + const SizedBox(width: AbTokens.space4), + AbIconButton( + icon: AbIcons.close, + tone: AbIconButtonTone.muted, + tooltip: "Dismiss — won't ask again for this project", + onTap: () => detached( + 'NEW_SESSION', + 'dismiss worktree.setup nudge', + () => ref + .read(worktreeSetupNudgeSeenProvider.notifier) + .markSeen(entryId), + ), + ), + ], + ), + ), + ); + } +} + +/// Seeds a starter `worktree.setup` block from the main checkout's tracked +/// files and writes it into [entryId]'s `antgrid.yaml`. +/// +/// Warms the project rather than reading it: this is an explicit button press, +/// and the windows where the canvas holds no live session for the target — a +/// remote machine it has never dialled, a project evicted from the warm set — +/// are exactly the ones the button has to work in. +Future _applyStarterSetup( + BuildContext context, + ProviderContainer container, + String entryId, +) async { + void say(String message) { + if (context.mounted) showAbSnackBar(context, message); + } + + final session = await warmServiceFor( + container, + entryId, + (s) => s, + // Past warmServiceFor's default: a remote target may be a cold machine + // whose socket and E2E handshake still have to come up. + timeout: const Duration(seconds: 30), + ); + if (session == null) { + say("Couldn't reach this project. Open it and try again."); + return; + } + + // The MAIN checkout's config service, which is what `ProjectSession` exposes + // directly: the block describes how a managed checkout is provisioned, so it + // belongs in the project's own `antgrid.yaml` and never in a worktree's copy, + // which is thrown away with the session. + final configService = session.configService; + final AbConfig config; + try { + final read = await configService.read(); + if (read != null) { + config = read; + } else { + // A read the bridge ANSWERED with no usable config. With a raw body or a + // parse error the file exists and does not parse — refuse, rather than + // replace a file the user still has to fix by hand. Without either there + // is no `antgrid.yaml` at all, and writing one is what this action is for. + final state = configService.currentState; + if (state.rawOnError != null || state.error != null) { + say("Couldn't read this project's antgrid.yaml."); + return; + } + config = const AbConfig(); + } + } on TimeoutException { + say("The machine didn't answer. Try again."); + return; + } on StateError { + // The config service was torn down under the request (an LRU eviction, a + // host restart) or a settings screen superseded the read. + say('This project reconnected. Try again.'); + return; + } + + if (_declaresSetup(config) == true) { + // Configured elsewhere — another device, an editor — between the nudge + // rendering and this press. Retire it rather than overwrite that. + await container + .read(worktreeSetupNudgeSeenProvider.notifier) + .markSeen(entryId); + say('This project already declares worktree setup steps.'); + return; + } + + final setup = buildStarterWorktreeSetup( + await _awaitTreeRoot(session.fileService), + ); + final List? errors; + try { + errors = await configService.save( + config.copyWith(worktree: {...?config.worktree, 'setup': setup}), + ); + } on TimeoutException { + say("The machine didn't answer. antgrid.yaml may be unchanged."); + return; + } on StateError { + say('This project reconnected. antgrid.yaml may be unchanged.'); + return; + } + if (errors != null) { + say( + errors.isEmpty + ? 'Could not update antgrid.yaml.' + : 'Could not update antgrid.yaml: ${errors.join(', ')}', + ); + return; + } + await container + .read(worktreeSetupNudgeSeenProvider.notifier) + .markSeen(entryId); + final steps = (setup['steps'] as List).length; + // Both lines say "commit" on purpose: a managed checkout is cut with + // `git worktree add ... ` and the runner resolves the block + // from the config physically inside that checkout, so an uncommitted edit + // in the main working tree reaches no isolated session at all. Without + // this the nudge retires itself behind a block that silently never runs. + say( + steps == 0 + ? 'Added an empty worktree.setup block to antgrid.yaml — fill in the ' + 'steps your checkout needs, then commit it.' + : 'Added $steps setup ${steps == 1 ? 'step' : 'steps'} to ' + 'antgrid.yaml. Commit it on your base branch — an isolated ' + 'checkout only sees committed config.', + ); +} + +/// A starter `worktree.setup` value seeded from what [root] actually shows. +/// +/// Both signals are read off the TRACKED tree, which is all the bridge sends: +/// its file tree honours `.gitignore`, so the very files this feature exists +/// for — `.env`, `node_modules` — are the ones it cannot see. What it can see +/// stands in for them: +/// - a lockfile at the root names the package manager, hence the install step; +/// - a tracked `.env.example` names a directory that almost certainly holds +/// an untracked `.env` beside it, hence the copy list. +/// +/// A `copy` source that turns out not to exist is a warning on the bridge, not +/// a failure, so an over-generous list costs a line in the transcript while a +/// missed one costs a broken checkout. +/// +/// Never returns null: an undetectable project still gets the block, empty, as +/// a place to write its own steps. +@visibleForTesting +Map buildStarterWorktreeSetup(FileNode? root) { + final steps = >[]; + final copies = _envCopySources(root); + if (copies.isNotEmpty) { + steps.add({'name': 'Copy env files', 'copy': copies}); + } + final install = _installCommand(root); + if (install != null) { + steps.add({'name': 'Install dependencies', 'run': install}); + } + return {'steps': steps}; +} + +List _envCopySources(FileNode? root) { + final sources = {}; + void walk(FileNode node) { + if (node.type == FileNodeType.file) { + if (_envTemplateNames.contains(node.name.toLowerCase())) { + final slash = node.path.lastIndexOf('/'); + sources.add( + slash < 0 ? '.env' : '${node.path.substring(0, slash)}/.env', + ); + } + return; + } + for (final child in node.children) { + walk(child); + } + } + + if (root != null) walk(root); + final ordered = sources.toList()..sort(); + return ordered; +} + +String? _installCommand(FileNode? root) { + if (root == null) return null; + final topLevel = { + for (final child in root.children) + if (child.type == FileNodeType.file) child.name, + }; + for (final (lockfile, command) in _lockfileInstallCommands) { + if (topLevel.contains(lockfile)) return command; + } + return null; +} + +/// The main checkout's file tree, waiting out its hydration when the project +/// was warmed a moment ago and the snapshot is still in flight. +Future _awaitTreeRoot(FileService service) async { + final root = service.currentState.root; + if (root != null) return root; + final completer = Completer(); + final sub = service.stateStream.listen( + (state) { + if (state.root != null && !completer.isCompleted) { + completer.complete(state.root); + } + }, + onError: (Object _) {}, + onDone: () { + if (!completer.isCompleted) completer.complete(null); + }, + ); + try { + return await completer.future.timeout( + const Duration(seconds: 10), + onTimeout: () => null, + ); + } finally { + await sub.cancel(); + } +} diff --git a/app/lib/widgets/recent_sessions/recent_session_row_widget.dart b/app/lib/widgets/recent_sessions/recent_session_row_widget.dart index 86ddf5a0..fc5c3a5c 100644 --- a/app/lib/widgets/recent_sessions/recent_session_row_widget.dart +++ b/app/lib/widgets/recent_sessions/recent_session_row_widget.dart @@ -13,12 +13,14 @@ import '../../design/widgets/ab_loading.dart'; import '../../design/widgets/ab_tap_target.dart'; import '../../design/widgets/ab_tooltip.dart'; import '../../models/recent_session_row.dart'; +import '../../models/session_entry.dart'; import '../../providers/agent_catalog.dart'; import '../../providers/new_session_picker.dart'; import '../../providers/now_ticker.dart'; import '../../providers/project_work_status.dart'; import '../../providers/recent_sessions.dart'; import '../../providers/session_delete_pending.dart'; +import '../../providers/session_setup.dart'; import '../../services/control_plane_client.dart'; import '../../services/session_delete_policy.dart'; import '../../services/sessions_service.dart' show SessionOperationException; @@ -100,6 +102,10 @@ class _RecentSessionRowWidgetState running: row.session.running, )), ); + // The live list's answer, never `row.session.setup`: every Recent row but + // the focused project's is served from the persisted cache, which carries + // no setup state at all. + final setup = ref.watch(sessionSetupProvider(row.session.id)); void onTap() { // The navigator's own context, not this row's: a host that dismisses // itself on open — the search popup, the search modal — unmounts this row @@ -135,6 +141,7 @@ class _RecentSessionRowWidgetState ? _MobileLayout( row: row, status: status, + setup: setup, agentLabel: agentLabel, relTime: relTime, rowBg: rowBg, @@ -144,6 +151,7 @@ class _RecentSessionRowWidgetState : _DesktopLayout( row: row, status: status, + setup: setup, agentLabel: agentLabel, relTime: relTime, rowBg: rowBg, @@ -264,6 +272,7 @@ class _DesktopLayout extends StatelessWidget { const _DesktopLayout({ required this.row, required this.status, + required this.setup, required this.agentLabel, required this.relTime, required this.rowBg, @@ -274,6 +283,7 @@ class _DesktopLayout extends StatelessWidget { final RecentSessionRow row; final AgentWorkStatus status; + final SessionSetup? setup; final String agentLabel; final String relTime; final Color rowBg; @@ -312,7 +322,7 @@ class _DesktopLayout extends StatelessWidget { // Non-flex, so the badges are measured before the name and a // long name ellipsizes around them rather than pushing them off // the row. - SessionIsolationBadge(session: row.session), + SessionIsolationBadge(session: row.session, setup: setup), SessionDeletingBadge(deleting: deleting), const SizedBox(width: AbTokens.space12), ], @@ -384,6 +394,7 @@ class _MobileLayout extends StatelessWidget { const _MobileLayout({ required this.row, required this.status, + required this.setup, required this.agentLabel, required this.relTime, required this.rowBg, @@ -393,6 +404,7 @@ class _MobileLayout extends StatelessWidget { final RecentSessionRow row; final AgentWorkStatus status; + final SessionSetup? setup; final String agentLabel; final String relTime; final Color rowBg; @@ -423,7 +435,7 @@ class _MobileLayout extends StatelessWidget { ), const SizedBox(width: AbTokens.space12), Expanded(child: _SessionName(name: row.session.name)), - SessionIsolationBadge(session: row.session), + SessionIsolationBadge(session: row.session, setup: setup), SessionDeletingBadge(deleting: deleting), const SizedBox(width: AbTokens.space8), // Only a custom launch command belongs on this line: an agent diff --git a/app/lib/widgets/session_isolation_badge.dart b/app/lib/widgets/session_isolation_badge.dart index f8e9e967..e7028f51 100644 --- a/app/lib/widgets/session_isolation_badge.dart +++ b/app/lib/widgets/session_isolation_badge.dart @@ -5,7 +5,10 @@ import '../design/ab_icons.dart'; import '../design/ab_tokens.dart'; import '../design/widgets/ab_icon.dart'; import '../design/widgets/ab_tooltip.dart'; +import '../design/widgets/pulsing_opacity.dart'; import '../models/session_entry.dart'; +import '../providers/session_setup.dart' + show SessionSetupPhase, sessionSetupPhase; /// Whether this session runs somewhere other than the project root. /// @@ -39,33 +42,68 @@ SessionCheckoutHealth sessionCheckoutHealth(String? state) => switch (state) { /// names no mechanism: the same badge stands for every non-`main` kind, so a /// word like "worktree" would be a guess about which backend this session runs. /// +/// A provisioning run in flight pulses that same glyph rather than adding a +/// second marker: the rows this sits in have no room for two, and "isolated" +/// and "not ready yet" are the identity and the state of one workspace. +/// /// A glyph rather than a word: it sits beside a session NAME in rows that are /// already tight, and the name is what the user scans for. The tooltip — hover /// on a pointer, tap on touch — carries the whole explanation, so nothing the /// badge means depends on reading the icon. class SessionIsolationBadge extends StatelessWidget { - const SessionIsolationBadge({super.key, required this.session}); + const SessionIsolationBadge({super.key, required this.session, this.setup}); final SessionEntry session; + /// This session's provisioning run, or null where the caller has no + /// trustworthy account of one. + /// + /// Taken as a parameter rather than read off [session] because a row is + /// served from the persisted session cache as often as from the live list, + /// and the cache deliberately carries no `setup` at all — a stored `running` + /// would restore with nothing alive to finish it, so the badge would pulse + /// forever on a session whose setup ended before the app was last closed + /// (`cached_sessions_store.dart`). `sessionSetupProvider` is the one honest + /// source; a call site that cannot reach it passes nothing and the badge + /// simply omits the arm. + final SessionSetup? setup; + @override Widget build(BuildContext context) { if (!sessionIsIsolated(session)) return const SizedBox.shrink(); - final (Color? color, String tip) = switch (sessionCheckoutHealth( - session.checkoutState, - )) { - SessionCheckoutHealth.ready => ( - null, - 'Isolated session — its own workspace, separate from your main tree.', - ), - SessionCheckoutHealth.unavailable => ( - context.antgrid.warning, - 'This isolated session\'s workspace is unavailable.', - ), - // The most conservative claim available: it stays true whatever the state - // turns out to mean. - SessionCheckoutHealth.unknown => (null, 'Isolated session.'), - }; + final health = sessionCheckoutHealth(session.checkoutState); + // Setup only ever runs against a checkout the bridge calls `ready`, so a + // workspace it cannot reach is both the more urgent claim and the more + // certain one — it outranks the run happening inside it. + final preparing = + health != SessionCheckoutHealth.unavailable && + sessionSetupPhase(setup) == SessionSetupPhase.running; + final (Color? color, String tip) = preparing + // Accent, matching the transcript's own in-progress glyph: the pulse + // has to swing against the row's background to read as motion, and the + // resting muted gray barely moves under a fade. + ? (context.antgrid.accent, 'Preparing this session\'s workspace…') + : switch (health) { + SessionCheckoutHealth.ready => ( + null, + 'Isolated session — its own workspace, separate from your main tree.', + ), + SessionCheckoutHealth.unavailable => ( + context.antgrid.warning, + 'This isolated session\'s workspace is unavailable.', + ), + // The most conservative claim available: it stays true whatever the + // state turns out to mean. + SessionCheckoutHealth.unknown => (null, 'Isolated session.'), + }; + Widget glyph = AbIcon( + AbIcons.isolated, + size: _glyphSize, + color: color ?? context.antgrid.textMuted, + ); + // Fade alone, no `minScale`: this glyph is sized to the text beside it, so + // a pulse that resized it would move the line under the reader. + if (preparing) glyph = PulsingOpacity(child: glyph); // The badge owns its own leading gap so a call site can drop it into a row // without reserving space for a widget that usually renders nothing. return Padding( @@ -73,11 +111,7 @@ class SessionIsolationBadge extends StatelessWidget { child: AbTooltip( message: tip, triggerMode: TooltipTriggerMode.tap, - child: AbIcon( - AbIcons.isolated, - size: _glyphSize, - color: color ?? context.antgrid.textMuted, - ), + child: glyph, ), ); } diff --git a/app/lib/widgets/session_row.dart b/app/lib/widgets/session_row.dart index 06a4abe2..01a179b4 100644 --- a/app/lib/widgets/session_row.dart +++ b/app/lib/widgets/session_row.dart @@ -26,6 +26,7 @@ import '../providers/open_checkout.dart'; import '../providers/project_work_status.dart'; import '../providers/providers.dart'; import '../providers/session_delete_pending.dart'; +import '../providers/session_setup.dart'; import '../providers/sessions.dart'; import '../providers/ui_attention_providers.dart'; import '../services/control_plane_client.dart'; @@ -270,7 +271,13 @@ class _SessionRowState extends ConsumerState { overflow: TextOverflow.ellipsis, ), ), - SessionIsolationBadge(session: session), + SessionIsolationBadge( + session: session, + // The live list's answer, never `session.setup`: a row + // for a project that isn't focused is served from the + // persisted cache, which carries no setup state at all. + setup: ref.watch(sessionSetupProvider(session.id)), + ), SessionDeletingBadge(deleting: deleting), ], ), @@ -363,7 +370,15 @@ class _SessionRowState extends ConsumerState { if (svc == null) return; if (ref.read(selectedRegistrationIdProvider) != liveId) return; ref.read(activeSessionIdProvider.notifier).set(session.id); - if (!session.running) { + // A tap is an auto-start path, so it gates like the workspace bootstrap + // does: the start queued behind an isolated checkout's setup run is the + // create flow's own, prompt and all, and a bare re-start here is a second + // one the user never asked for. Read live where the list has landed, and + // fall back to the row's own copy where it has not. + final queued = sessionStartQueued( + ref.read(sessionSetupProvider(session.id)) ?? session.setup, + ); + if (!session.running && !queued) { // The two failures end differently, and that is the whole point of // catching them separately: a refusal is the bridge's answer that this // session did NOT start, while a timeout is no answer at all. diff --git a/app/lib/widgets/session_setup_banner.dart b/app/lib/widgets/session_setup_banner.dart new file mode 100644 index 00000000..a1a799fb --- /dev/null +++ b/app/lib/widgets/session_setup_banner.dart @@ -0,0 +1,374 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../design/ab_colors.dart'; +import '../design/ab_icons.dart'; +import '../design/ab_tokens.dart'; +import '../design/widgets/ab_button.dart'; +import '../design/widgets/ab_empty_state.dart'; +import '../design/widgets/ab_icon_button.dart'; +import '../design/widgets/ab_inline_banner.dart'; +import '../design/widgets/ab_progress_rule.dart'; +import '../design/widgets/ab_snack_bar.dart'; +import '../models/session_entry.dart'; +import '../models/terminal_models.dart'; +import '../providers/agent_transport.dart'; +import '../providers/providers.dart'; +import '../providers/session_setup.dart'; +import '../providers/sessions.dart'; +import '../util/detached.dart'; +import 'terminal_view_wrapper.dart'; + +/// How often the collapsed strip re-reads the setup terminal's tail. +/// +/// Sampled on a timer rather than by listening to the terminal controller: +/// a controller with no listener skips its snapshot rebuild on every byte +/// (`_refreshSnapshot`'s `hasListeners` guard), so subscribing from a strip +/// that shows ONE line would make the whole provisioning run pay a full +/// formatter pass per output frame. Polling puts a ceiling on that cost that +/// does not move with how chatty the install is. +const Duration _kTailPollInterval = Duration(milliseconds: 750); + +/// Tail sampling stops at the newest line the strip can show; a `bun install` +/// progress bar redraws a single row far wider than the strip. +const int _kTailMaxChars = 240; + +/// Expanded-log height. Capped against the viewport as well, so the log never +/// swallows a phone screen the agent is also on. +const double _kLogHeight = 220.0; +const double _kLogMaxViewportFraction = 0.35; + +/// Provisioning state for the workspace the active session runs in. +/// +/// Renders nothing for every shared session and every bridge that does not +/// report `setup`, so the common path costs one provider read and a +/// zero-sized box. +/// +/// Persistent by design in its failure states. A setup failure means the agent +/// is working in a half-provisioned tree — the thing that explains every +/// confusing build error it is about to hit — and a toast would be gone by the +/// time the user comes back from another session, leaving them with no account +/// of why the tree is broken. +class SessionSetupBanner extends ConsumerStatefulWidget { + const SessionSetupBanner({super.key}); + + @override + ConsumerState createState() => _SessionSetupBannerState(); +} + +class _SessionSetupBannerState extends ConsumerState { + Timer? _tailTimer; + String? _tailTerminalId; + String? _tail; + + /// The run [_tail] was sampled from. A rerun resets the transcript, so a + /// carried-over line would describe work that is no longer happening. + String? _runKey; + + /// Dismissal is per RUN, not per session: a rerun of a setup the user + /// dismissed is a new answer to the same question and has to be shown. + String? _dismissedRunKey; + + /// The log is expanded per session, so switching sessions collapses it + /// rather than opening a terminal for a workspace the user just left. + String? _expandedSessionId; + + /// The session a `session:setup` verb is in flight for, keyed like + /// [_expandedSessionId] rather than held as a bare bool: this State + /// survives a session switch, so a single flag would disable whichever + /// session happened to be on screen when a slow answer arrived. + String? _actingSessionId; + + @override + void dispose() { + _tailTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final sessionId = ref.watch(activeSessionIdProvider); + final setup = ref.watch(activeSessionSetupProvider); + final phase = sessionSetupPhase(setup); + // `unknown` is a state this build cannot name — say nothing rather than + // guess at either "still going" or "finished". + if (sessionId == null || + setup == null || + phase == SessionSetupPhase.unknown) { + _syncTail(null, null); + return const SizedBox.shrink(); + } + + final runKey = '$sessionId|${setup.startedAt}'; + if (_dismissedRunKey == runKey) { + _syncTail(null, null); + return const SizedBox.shrink(); + } + + final running = phase == SessionSetupPhase.running; + final expanded = _expandedSessionId == sessionId; + final terminalId = setup.terminalId; + // While the log is open the tail is on screen in full; sampling it twice + // would only pay the formatter again for a line the user is already + // reading. + _syncTail(running && !expanded ? terminalId : null, runKey); + + final colors = context.antgrid; + final tone = switch (phase) { + SessionSetupPhase.failed || + SessionSetupPhase.interrupted => colors.warning, + SessionSetupPhase.running => colors.textSecondary, + _ => colors.textMuted, + }; + final tail = _runKey == runKey ? _tail : null; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + AbInlineBanner( + text: _headline(setup, phase), + color: tone, + trailing: _buildActions(sessionId, runKey, phase, expanded), + ), + if (running) + AbProgressRule( + // 0-based index: the fraction is the work already behind the + // current step, which is the only part that is actually done. + fraction: setup.stepCount > 0 + ? setup.stepIndex / setup.stepCount + : null, + ), + if (tail != null) _buildTail(context, tail), + if (expanded) _buildLog(context, terminalId), + ], + ); + } + + String _headline(SessionSetup setup, SessionSetupPhase phase) { + final step = setup.stepCount > 0 + ? '${setup.stepIndex + 1} of ${setup.stepCount}' + : null; + final name = setup.stepName; + final where = [ + ?step, + if (name != null && name.isNotEmpty) name, + ].join(' · '); + final message = setup.message; + return switch (phase) { + SessionSetupPhase.running => + where.isEmpty ? 'Preparing workspace…' : 'Preparing workspace — $where', + SessionSetupPhase.done => 'Workspace ready', + SessionSetupPhase.failed => switch ((message, where)) { + (final String m, _) when m.isNotEmpty => 'Setup failed — $m', + (_, final String w) when w.isNotEmpty => 'Setup failed at $w', + _ => 'Setup failed', + }, + SessionSetupPhase.interrupted => "Setup didn't finish", + SessionSetupPhase.skipped => 'Setup skipped', + SessionSetupPhase.unknown => 'Preparing workspace…', + }; + } + + Widget _buildActions( + String sessionId, + String runKey, + SessionSetupPhase phase, + bool expanded, + ) { + final action = switch (phase) { + // Skip releases the queued agent start and leaves the run going — the + // "the deps are already cached" case, which is the common one. + SessionSetupPhase.running => ( + label: 'Skip', + verb: SessionSetupAction.skip, + ), + SessionSetupPhase.failed => ( + label: 'Run setup again', + verb: SessionSetupAction.rerun, + ), + SessionSetupPhase.interrupted || SessionSetupPhase.skipped => ( + label: 'Run setup', + verb: SessionSetupAction.rerun, + ), + _ => null, + }; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (action != null) ...[ + const SizedBox(width: AbTokens.space8), + AbButton( + label: action.label, + compact: true, + onTap: _actingSessionId == sessionId + ? null + : () => _act(sessionId, action.verb), + ), + ], + const SizedBox(width: AbTokens.space4), + AbIconButton( + icon: expanded ? AbIcons.chevronDown : AbIcons.chevronRight, + tooltip: expanded ? 'Hide setup log' : 'View setup log', + onTap: () => + setState(() => _expandedSessionId = expanded ? null : sessionId), + ), + // A run still going has nothing to dismiss to — the banner is the only + // account of why the agent has not started yet. + if (phase != SessionSetupPhase.running) + AbIconButton( + icon: AbIcons.close, + tooltip: 'Dismiss', + onTap: () => setState(() => _dismissedRunKey = runKey), + ), + ], + ); + } + + /// The newest output line, in mono. A named step alone leaves a four-minute + /// install looking hung; the line that keeps changing is what says otherwise. + Widget _buildTail(BuildContext context, String tail) { + final colors = context.antgrid; + return Container( + decoration: BoxDecoration( + color: colors.bgElevated, + border: Border(bottom: BorderSide(color: colors.borderSubtle)), + ), + padding: const EdgeInsets.symmetric( + horizontal: AbTokens.space12, + vertical: AbTokens.space6, + ), + width: double.infinity, + child: Text( + tail, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AbTokens.monoStyle( + fontSize: AbTokens.fontXxs, + color: colors.textMuted, + ), + ), + ); + } + + /// The setup transcript, mounted on the run's own PTY. Reachable during the + /// run and after it — a successful setup's log is still the record of what + /// the workspace was built from. + Widget _buildLog(BuildContext context, String? terminalId) { + final colors = context.antgrid; + final terminalService = serviceWhenReady(ref, terminalServiceProvider); + final tabs = + ref.watch(terminalStateProvider).value?.tabs ?? + const {}; + final tab = terminalId == null ? null : tabs[terminalId]; + final height = math.min( + _kLogHeight, + MediaQuery.sizeOf(context).height * _kLogMaxViewportFraction, + ); + return Container( + height: height, + decoration: BoxDecoration( + color: colors.bgDeepest, + border: Border(bottom: BorderSide(color: colors.borderSubtle)), + ), + child: tab == null || terminalService == null + // A reconnect recovers the transcript from the bridge, so this is a + // window rather than a dead end — it must not read as one. + ? const AbEmptyState.compact(title: 'Setup log not available yet') + : TerminalViewWrapper(tab: tab, terminalService: terminalService), + ); + } + + void _act(String sessionId, SessionSetupAction verb) { + // Captured before the first await: the shell rebuilds this banner away on + // every session switch, and a `ref` read after that throws. + final container = ref.container; + final entryId = container.read(selectedRegistrationIdProvider); + if (entryId == null) return; + setState(() => _actingSessionId = sessionId); + detached( + 'SessionSetupBanner', + 'session:setup ${verb.wire} failed', + () async { + try { + final result = await runSessionSetupAction( + container, + entryId: entryId, + sessionId: sessionId, + action: verb, + ); + if (!mounted || result.ok) return; + // The user pressed something and is owed an answer: nothing else on + // screen changes when a setup verb is refused, so a log line alone + // would make a refusal indistinguishable from a dropped tap. Only + // while that session is still the one on screen, though — `detached` + // has logged it either way, and a refusal narrated over a DIFFERENT + // session's banner reads as that session having failed. + if (container.read(activeSessionIdProvider) != sessionId) return; + showAbSnackBar(context, '${_failureCopy(verb)} — ${result.error}'); + } finally { + if (mounted && _actingSessionId == sessionId) { + setState(() => _actingSessionId = null); + } + } + }, + ); + } + + String _failureCopy(SessionSetupAction verb) => switch (verb) { + SessionSetupAction.skip => "Couldn't skip setup", + SessionSetupAction.cancel => "Couldn't stop setup", + SessionSetupAction.rerun => "Couldn't start setup", + }; + + /// Starts, retargets or stops the tail sampler. Called from `build`, which + /// only ever schedules a timer here — the sample itself lands on a later + /// frame. + void _syncTail(String? terminalId, String? runKey) { + if (runKey != _runKey) { + _runKey = runKey; + _tail = null; + } + if (terminalId == null || terminalId != _tailTerminalId) { + _tailTimer?.cancel(); + _tailTimer = null; + _tailTerminalId = terminalId; + if (terminalId == null) { + // Dropped with the sampler, not just on a run change: `runKey` is + // stable across a run's own settle, so clearing only there would + // freeze the last polled line under "Workspace ready" forever — and + // render it again directly above the transcript the user just + // expanded, which is the duplicate this sampler exists to avoid. + _tail = null; + return; + } + } + if (_tailTimer != null) return; + _tailTimer = Timer.periodic( + _kTailPollInterval, + (_) => _sampleTail(terminalId), + ); + } + + void _sampleTail(String terminalId) { + if (!mounted) return; + final tabs = ref.read(terminalStateProvider).value?.tabs; + final tab = tabs?[terminalId]; + if (tab == null) return; + String? newest; + final lines = tab.ghostty.lines; + for (var i = lines.length - 1; i >= 0; i--) { + final line = lines[i].trim(); + if (line.isNotEmpty) { + newest = line.length > _kTailMaxChars + ? line.substring(line.length - _kTailMaxChars) + : line; + break; + } + } + if (newest == null || newest == _tail) return; + setState(() => _tail = newest); + } +} diff --git a/app/lib/widgets/terminal_list_view.dart b/app/lib/widgets/terminal_list_view.dart index a191d3ed..6a656b7c 100644 --- a/app/lib/widgets/terminal_list_view.dart +++ b/app/lib/widgets/terminal_list_view.dart @@ -12,9 +12,11 @@ import '../design/widgets/ab_list_row.dart'; import '../design/widgets/ab_loading.dart'; import '../design/widgets/ab_status_dot.dart'; import '../design/widgets/ab_toolbar.dart'; +import '../models/session_entry.dart'; import '../models/terminal_models.dart'; import '../navigation/back_intent.dart'; import '../providers/providers.dart'; +import '../providers/sessions.dart'; import '../providers/visible_surface.dart'; import '../services/terminal_service.dart'; import 'terminal_detail_view.dart'; @@ -41,14 +43,32 @@ class _TerminalListViewState extends ConsumerState { String? _pinnedTerminalId; String? _pushedTerminalId; + /// The PTYs carrying a checkout's `worktree.setup` transcript. + /// + /// Excluded from the list below because the ad-hoc filter selects by + /// EXCLUSION — a terminal typed neither `agent` nor `service` is "a user + /// terminal", and a setup transcript carries no type at all. Left in, a + /// provisioning log would list as an interactive tab the user can type into + /// and close, killing a live `bun install` mid-install. Named off + /// `SessionSetup.terminalId`, the bridge's own answer for which PTY carries + /// the transcript, rather than pattern-matched off the id. + Set get _setupTerminalIds { + final sessions = + ref.watch(freshSessionsStateProvider)?.sessions ?? + const []; + return {for (final s in sessions) ?s.setup?.terminalId}; + } + List get _adHocTerminals { final terminalState = ref.watch(terminalStateProvider); + final setupIds = _setupTerminalIds; return terminalState.value?.tabs.values .where( (t) => t.terminalId != 'agent' && t.type != 'agent' && - t.type != 'service', + t.type != 'service' && + !setupIds.contains(t.terminalId), ) .toList() ?? []; diff --git a/app/lib/widgets/window_title_bar.dart b/app/lib/widgets/window_title_bar.dart index 065b59ce..5f0b849c 100644 --- a/app/lib/widgets/window_title_bar.dart +++ b/app/lib/widgets/window_title_bar.dart @@ -19,6 +19,7 @@ import '../navigation/nav_controller.dart'; import '../providers/agent_transport.dart'; import '../providers/providers.dart'; import '../providers/recent_sessions.dart'; +import '../providers/session_setup.dart'; import '../providers/sessions.dart'; import '../providers/ui_attention_providers.dart'; import '../utils/platform_utils.dart'; @@ -496,7 +497,13 @@ class TitleBarBreadcrumb extends ConsumerWidget { : EditableSessionLeaf(session: active), ), ), - if (active != null) SessionIsolationBadge(session: active), + if (active != null) + SessionIsolationBadge( + session: active, + // The live list's answer, never `active.setup`: the entry can come + // from the persisted cache, which carries no setup state at all. + setup: ref.watch(activeSessionSetupProvider), + ), if (gitBranch != null) ...[ const SizedBox(width: AbTokens.space8), // Bounded, not Flexible: the breadcrumb is the only child that should diff --git a/app/test/models/session_entry_test.dart b/app/test/models/session_entry_test.dart index 3afff954..263672e7 100644 --- a/app/test/models/session_entry_test.dart +++ b/app/test/models/session_entry_test.dart @@ -188,4 +188,162 @@ void main() { expect(flagged.hashCode, isNot(plain.hashCode)); }); }); + + group('setup', () { + Map base() => { + 'id': 'a', + 'name': 'n', + 'createdAt': 1, + 'lastUsedAt': 1, + 'archived': false, + 'running': false, + }; + + // The absent case carries the whole compatibility claim: every shared + // session, every bridge predating the feature, and every disk-only source + // say nothing about setup, and all three must decode to the entry this + // build already produced. + test('an entry with no setup key is the entry that has none', () { + final e = SessionEntry.fromJson(base()); + expect(e.setup, isNull); + expect(e.toJson().containsKey('setup'), isFalse); + expect( + e, + const SessionEntry( + id: 'a', + name: 'n', + createdAt: 1, + lastUsedAt: 1, + archived: false, + running: false, + ), + ); + }); + + test('a setup that is not an object decodes to null, never throws', () { + expect(SessionEntry.fromJson({...base(), 'setup': null}).setup, isNull); + expect( + SessionEntry.fromJson({...base(), 'setup': 'running'}).setup, + isNull, + ); + }); + + test('a running run decodes every field and round-trips', () { + final e = SessionEntry.fromJson({ + ...base(), + 'checkoutId': 'worktree-1', + 'checkoutKind': 'managed-worktree', + 'setup': { + 'state': 'running', + 'stepIndex': 1, + 'stepCount': 4, + 'stepName': 'Install dependencies', + 'terminalId': 'worktree-1:setup', + 'pendingStart': true, + 'startedAt': 1700, + }, + }); + final s = e.setup!; + expect(s.state, 'running'); + expect(s.stepIndex, 1); + expect(s.stepCount, 4); + expect(s.stepName, 'Install dependencies'); + // Verbatim, including the `:setup` suffix: the bridge resolves this id + // through an identity mapping, so a bare "setup" reaches no terminal. + expect(s.terminalId, 'worktree-1:setup'); + expect(s.pendingStart, isTrue); + expect(s.startedAt, 1700); + expect(s.exitCode, isNull); + expect(s.finishedAt, isNull); + expect(SessionEntry.fromJson(e.toJson()), e); + }); + + test('a failed run carries its exit code and one-line reason', () { + final e = SessionEntry.fromJson({ + ...base(), + 'setup': { + 'state': 'failed', + 'stepIndex': 2, + 'stepCount': 4, + 'stepName': 'Generate Prisma client', + 'exitCode': 7, + 'message': 'Generate Prisma client exited 7', + 'startedAt': 1700, + 'finishedAt': 1900, + }, + }); + final s = e.setup!; + expect(s.exitCode, 7); + expect(s.message, 'Generate Prisma client exited 7'); + expect(s.finishedAt, 1900); + expect(s.pendingStart, isFalse); + expect(SessionEntry.fromJson(e.toJson()), e); + }); + + // The bridge owns this vocabulary and may widen it. An unknown value is + // carried through for the render site to degrade — dropping it here would + // make "a state this build can't name" indistinguishable from "no setup". + test('a state this build cannot name survives the decode', () { + final e = SessionEntry.fromJson({ + ...base(), + 'setup': {'state': 'restoring', 'startedAt': 1}, + }); + expect(e.setup?.state, 'restoring'); + expect(e.toJson()['setup'], containsPair('state', 'restoring')); + }); + + // Absence has to be false: the flag says an agent start is WAITING, and a + // wrong `true` would leave a surface explaining a queue that isn't there. + test( + 'pendingStart and the counters default when the bridge omits them', + () { + final s = SessionEntry.fromJson({ + ...base(), + 'setup': {'state': 'done', 'startedAt': 5}, + }).setup!; + expect(s.pendingStart, isFalse); + expect(s.stepIndex, 0); + expect(s.stepCount, 0); + }, + ); + + // Without this the transition is invisible to SessionsState's equality and + // the no-op dedup in _handleUpdated drops every progress push. + test('two entries differing only in setup are not equal', () { + final plain = SessionEntry.fromJson(base()); + final preparing = SessionEntry.fromJson({ + ...base(), + 'setup': {'state': 'running', 'startedAt': 1}, + }); + final later = SessionEntry.fromJson({ + ...base(), + 'setup': { + 'state': 'running', + 'stepIndex': 1, + 'stepCount': 4, + 'startedAt': 1, + }, + }); + expect(preparing, isNot(plain)); + expect(preparing.hashCode, isNot(plain.hashCode)); + expect(later, isNot(preparing)); + expect(later.hashCode, isNot(preparing.hashCode)); + }); + + test('copyWith carries the run forward and replaces it', () { + final entry = SessionEntry.fromJson({ + ...base(), + 'setup': {'state': 'running', 'stepCount': 2, 'startedAt': 1}, + }); + expect(entry.copyWith(running: true).setup, entry.setup); + const done = SessionSetup( + state: 'done', + stepIndex: 1, + stepCount: 2, + startedAt: 1, + finishedAt: 9, + ); + expect(entry.copyWith(setup: done).setup, done); + }); + }); } diff --git a/app/test/providers/new_session_queued_start_test.dart b/app/test/providers/new_session_queued_start_test.dart new file mode 100644 index 00000000..5ddea2a9 --- /dev/null +++ b/app/test/providers/new_session_queued_start_test.dart @@ -0,0 +1,245 @@ +// The New Session canvas hands the user their session on the CREATE, not on the +// start. An isolated session's `session:start` is queued behind the checkout's +// `worktree.setup` run and answered `ok: true` immediately with +// `setup.pendingStart` set, so its reply says nothing about whether the agent +// is live — waiting on it would hold the canvas over a session the user is +// already owed, for as long as the install takes. +import 'package:antgrid/models/session_entry.dart'; +import 'package:antgrid/providers/agent_transport.dart'; +import 'package:antgrid/providers/new_session_action.dart'; +import 'package:antgrid/providers/new_session_picker.dart'; +import 'package:antgrid/providers/session_setup.dart'; +import 'package:antgrid/providers/sessions.dart'; +import 'package:antgrid/providers/ui_attention_providers.dart'; +import 'package:antgrid/services/sessions_service.dart'; +import 'package:antgrid/test_helpers/fake_agent_transport.dart'; +import 'package:antgrid/widgets/new_session/picker_sources.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/prefs_test_mock.dart'; +import '../helpers/test_store_overrides.dart'; + +const _projectId = 'P'; + +/// Answers `session:create` immediately and holds `session:start` until the +/// test releases it — which is what lets the assertions look at the app in the +/// window the whole change is about: the start is on the wire, its reply is not. +class _QueuedStartTransport extends FakeAgentTransport { + _QueuedStartTransport({this.createOk = true, this.startEntry}); + + final bool createOk; + + /// The entry the bridge returns for the start, or null for a bare rejection + /// (`ok: true` with no session — an older agent refusing the tool). + final Map? startEntry; + + Map? _pendingStartRequest; + + bool get startSent => _pendingStartRequest != null; + + void releaseStart() { + final req = _pendingStartRequest!; + emit('session:result', { + 'requestId': req['requestId'], + 'ok': true, + if (startEntry != null) 'session': startEntry, + }); + } + + @override + Future send( + Map message, { + String channel = 'control', + }) async { + await super.send(message, channel: channel); + switch (message['type']) { + case 'session:create': + emit('session:result', { + 'requestId': message['requestId'], + 'ok': createOk, + if (createOk) 'session': _created, + if (!createOk) 'error': 'Session limit reached', + }); + case 'session:start': + _pendingStartRequest = message; + } + } +} + +Map get _created => { + 'id': 'B', + 'name': 'new one', + 'createdAt': 1000, + 'lastUsedAt': 1000, + 'archived': false, + 'running': false, + 'checkoutId': 'worktree-1', + 'checkoutKind': 'managed-worktree', + 'checkoutBranch': 'antgrid/session-B', + 'checkoutState': 'ready', + 'setup': { + 'state': 'running', + 'stepIndex': 0, + 'stepCount': 4, + 'stepName': 'Copy env files', + 'terminalId': 'worktree-1:setup', + 'pendingStart': true, + 'startedAt': 1000, + }, +}; + +Future _openCanvas(_QueuedStartTransport transport) async { + useInMemoryPrefs(); + final stores = await buildTestStoreOverrides(); + addTearDown(stores.close); + + final container = ProviderContainer( + overrides: [ + ...stores.overrides, + agentTransportForProvider.overrideWith((ref, id) async => transport), + // The catalog gate for the isolation toggle. Its real source dials the + // target for a branch listing, which a provider test has no machine for. + newSessionIsolationReadyProvider.overrideWithValue(true), + // Left to itself this reaches for the local host process to list tools. + newSessionChatCapableToolsProvider.overrideWith((ref) async => null), + ], + ); + addTearDown(container.dispose); + + enterNewSession(container); + container + .read(selectedTargetProjectProvider.notifier) + .set( + const PickerProject( + id: _projectId, + name: 'p', + detail: '/tmp/p', + isLocal: true, + ), + ); + container.read(newSessionIsolatedProvider.notifier).set(true); + container.read(newSessionNameProvider.notifier).set('new one'); + container.read(newSessionPromptProvider.notifier).set('fix the build'); + return container; +} + +/// Lets the microtasks of [startNewSession] run without completing it — the +/// start reply is what it is still waiting on. +Future _settle() async { + for (var i = 0; i < 20; i++) { + await Future.delayed(Duration.zero); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('the session is handed over on the create, not on the start', () async { + final transport = _QueuedStartTransport(startEntry: _created); + final container = await _openCanvas(transport); + + final start = startNewSession(container); + await _settle(); + + // The start is on the wire and unanswered — and the user is already in the + // session, with the canvas behind them. + expect(transport.startSent, isTrue); + expect(container.read(activeSessionIdProvider), 'B'); + // The remount's bootstrap re-derives focus from `lastUsedAt`, which records + // activity rather than intent; this names the session the user asked for. + expect(container.read(pendingActiveSessionIdProvider), 'B'); + expect( + container.read(workbenchSurfaceProvider), + WorkbenchSurface.workspace, + ); + + transport.releaseStart(); + await start; + }); + + // The start goes out BEFORE the canvas closes: leaving remounts WorkspaceShell, + // whose bootstrap re-lists the sessions and starts the one it adopts. Issuing + // the start first puts it ahead of that list on the same stream. + test('the start precedes the navigation on the wire', () async { + final transport = _QueuedStartTransport(startEntry: _created); + final container = await _openCanvas(transport); + + final start = startNewSession(container); + await _settle(); + + final types = transport.sent.map((m) => m['type']).toList(); + expect( + types.indexOf('session:create'), + lessThan(types.indexOf('session:start')), + ); + expect( + transport.sent.firstWhere( + (m) => m['type'] == 'session:start', + )['initialPrompt'], + 'fix the build', + ); + + transport.releaseStart(); + await start; + }); + + test('a queued start consumes the draft like a live one', () async { + final transport = _QueuedStartTransport(startEntry: _created); + final container = await _openCanvas(transport); + + final start = startNewSession(container); + await _settle(); + transport.releaseStart(); + await start; + + // The entry the bridge answered with carries `pendingStart`, so the agent + // is NOT live — and that is still a success: the session exists and is the + // user's, so nothing is left on the canvas to retry. + expect(sessionStartQueued(SessionEntry.fromJson(_created).setup), isTrue); + expect(container.read(newSessionPromptProvider), ''); + expect(container.read(newSessionNameProvider), ''); + expect(container.read(selectedTargetProjectProvider), isNull); + }); + + // The contrast case: `ok: true` with no session is a bare rejection, and the + // draft has to survive it so the canvas is retryable. + test('a start that comes back with no session keeps the draft', () async { + final transport = _QueuedStartTransport(startEntry: null); + final container = await _openCanvas(transport); + + final start = startNewSession(container); + await _settle(); + transport.releaseStart(); + await start; + + expect(container.read(newSessionPromptProvider), 'fix the build'); + expect(container.read(newSessionNameProvider), 'new one'); + // Navigation already happened — once the session exists it is the user's, + // and the place to report anything further about it is the session itself. + expect(container.read(activeSessionIdProvider), 'B'); + }); + + // Only CREATE keeps the user here. This is the behaviour the change had to + // preserve, and it is the one the navigate-early path could most easily lose. + test('a create failure leaves the user on the canvas', () async { + final transport = _QueuedStartTransport(createOk: false); + final container = await _openCanvas(transport); + + // A coded refusal still raises past here — the composer renders it. What + // matters is that nothing was navigated or consumed on the way out. + await expectLater( + startNewSession(container), + throwsA(isA()), + ); + + expect(transport.sent.any((m) => m['type'] == 'session:start'), isFalse); + expect(container.read(activeSessionIdProvider), isNull); + expect(container.read(pendingActiveSessionIdProvider), isNull); + expect( + container.read(workbenchSurfaceProvider), + WorkbenchSurface.newSession, + ); + expect(container.read(newSessionPromptProvider), 'fix the build'); + }); +} diff --git a/app/test/providers/session_setup_test.dart b/app/test/providers/session_setup_test.dart new file mode 100644 index 00000000..d6156fa9 --- /dev/null +++ b/app/test/providers/session_setup_test.dart @@ -0,0 +1,159 @@ +// The app's only reading of an isolated session's provisioning run. Everything +// the feature renders — the workspace banner, the drawer/Recent badge, the +// bootstrap's double-start guard — is derived here, so the two things that make +// it honest are pinned: an unnameable state degrades rather than being guessed +// at, and the projection reads the LIVE list and never the persisted cache. +import 'package:antgrid/models/session_entry.dart'; +import 'package:antgrid/providers/agent_transport.dart'; +import 'package:antgrid/providers/cached_sessions.dart'; +import 'package:antgrid/providers/session_setup.dart'; +import 'package:antgrid/providers/sessions.dart'; +import 'package:antgrid/services/sessions_service.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _projectId = 'P'; + +SessionSetup _setup(String state, {bool pendingStart = false}) => SessionSetup( + state: state, + stepIndex: 1, + stepCount: 4, + startedAt: 1, + pendingStart: pendingStart, +); + +SessionEntry _entry(String id, {SessionSetup? setup}) => SessionEntry( + id: id, + name: id, + createdAt: 0, + lastUsedAt: 0, + archived: false, + running: false, + checkoutId: 'worktree-1', + checkoutKind: 'managed-worktree', + setup: setup, +); + +ProviderContainer _container({ + List? live, + List cached = const [], +}) { + final container = ProviderContainer( + overrides: [ + selectedRegistrationIdProvider.overrideWith((ref) => _projectId), + freshSessionsStateProvider.overrideWithValue( + live == null + ? null + : SessionsState(projectId: _projectId, sessions: live), + ), + cachedSessionsProvider(_projectId).overrideWith((ref) => cached), + ], + ); + addTearDown(container.dispose); + return container; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('sessionSetupPhase', () { + test('names every state the bridge declares', () { + expect(sessionSetupPhase(_setup('running')), SessionSetupPhase.running); + expect(sessionSetupPhase(_setup('done')), SessionSetupPhase.done); + expect(sessionSetupPhase(_setup('failed')), SessionSetupPhase.failed); + expect(sessionSetupPhase(_setup('skipped')), SessionSetupPhase.skipped); + expect( + sessionSetupPhase(_setup('interrupted')), + SessionSetupPhase.interrupted, + ); + }); + + // The bridge owns this vocabulary and may widen it, so a value this build + // cannot name has to degrade to the weakest true statement rather than be + // read as either finished or still going. + test('degrades a state it cannot name, and a session with none', () { + expect(sessionSetupPhase(_setup('restoring')), SessionSetupPhase.unknown); + expect(sessionSetupPhase(null), SessionSetupPhase.unknown); + }); + }); + + group('sessionSetupProvider', () { + test('projects the live entry', () { + final c = _container(live: [_entry('a', setup: _setup('running'))]); + expect(c.read(sessionSetupProvider('a'))?.state, 'running'); + expect(c.read(sessionSetupProvider('missing')), isNull); + }); + + // The cache deliberately carries no `setup`, but nothing stops a row it + // serves from claiming one. Sourcing this from the live list alone is what + // stops a restored `running` painting a banner nothing is left to finish. + test('never answers from the persisted cache', () { + final c = _container( + live: null, + cached: [_entry('a', setup: _setup('running'))], + ); + + // The cached row really does carry a run — and the projection still says + // nothing, because there is no live list behind it to finish one. + expect( + c.read(cachedSessionsProvider(_projectId)).single.setup, + isNotNull, + ); + expect(c.read(sessionSetupProvider('a')), isNull); + }); + + test('follows the active session', () { + final c = _container( + live: [ + _entry('a', setup: _setup('running')), + _entry('b', setup: _setup('failed')), + ], + ); + + expect(c.read(activeSessionSetupProvider), isNull); + c.read(activeSessionIdProvider.notifier).set('b'); + expect(c.read(activeSessionSetupProvider)?.state, 'failed'); + }); + }); + + group('sessionStartQueued', () { + // Load-bearing wherever a stopped session is auto-started: a queued session + // reports `running: false` for the whole run, so `running` alone reads as + // "needs starting" and sends a second start carrying no initialPrompt — + // which replaces the one the user actually typed. + test('is true only while a start is waiting behind the run', () { + expect(sessionStartQueued(_setup('running', pendingStart: true)), isTrue); + expect(sessionStartQueued(_setup('running')), isFalse); + expect(sessionStartQueued(_setup('done')), isFalse); + expect(sessionStartQueued(null), isFalse); + }); + + test('reads through the provider for a live session', () { + final c = _container( + live: [_entry('a', setup: _setup('running', pendingStart: true))], + ); + + expect(c.read(sessionStartQueuedProvider('a')), isTrue); + expect(c.read(sessionStartQueuedProvider('missing')), isFalse); + }); + }); + + // The callers are `void` tap handlers, where an escaping rejection reaches + // PlatformDispatcher.onError as a fatal. + test( + 'runSessionSetupAction reports an unreachable project, never throws', + () async { + final c = _container(live: const []); + + final result = await runSessionSetupAction( + c, + entryId: 'nothing-warm-here', + sessionId: 'a', + action: SessionSetupAction.skip, + ); + + expect(result.ok, isFalse); + expect(result.error, isNotEmpty); + }, + ); +} diff --git a/app/test/widgets/session_isolation_badge_test.dart b/app/test/widgets/session_isolation_badge_test.dart index 7587ca9a..650d2bc3 100644 --- a/app/test/widgets/session_isolation_badge_test.dart +++ b/app/test/widgets/session_isolation_badge_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:antgrid/design/ab_icons.dart'; import 'package:antgrid/design/theme_presets.dart'; import 'package:antgrid/design/widgets/ab_icon.dart'; +import 'package:antgrid/design/widgets/pulsing_opacity.dart'; import 'package:antgrid/models/session_entry.dart'; import 'package:antgrid/widgets/session_isolation_badge.dart'; @@ -20,12 +21,17 @@ SessionEntry _session({ checkoutState: checkoutState, ); -Widget _wrap(SessionEntry session) => MaterialApp( +SessionSetup _setup(String state) => + SessionSetup(state: state, stepIndex: 1, stepCount: 4, startedAt: 1); + +Widget _wrap(SessionEntry session, {SessionSetup? setup}) => MaterialApp( theme: ThemeData.dark().copyWith( extensions: >[kDefaultPalette], ), home: Scaffold( - body: Center(child: SessionIsolationBadge(session: session)), + body: Center( + child: SessionIsolationBadge(session: session, setup: setup), + ), ), ); @@ -108,4 +114,121 @@ void main() { findsOneWidget, ); }); + + // The `preparing` arm. Every test below pumps a FIXED duration rather than + // settling: PulsingOpacity repeats forever, so `pumpAndSettle` never returns + // once this arm is on screen. + group('preparing', () { + Color glyphColor(WidgetTester tester) => + tester.widget(_badgeGlyph()).color!; + + testWidgets('a run in flight pulses the isolation glyph in accent', ( + tester, + ) async { + await tester.pumpWidget( + _wrap( + _session(checkoutKind: 'managed-worktree'), + setup: _setup('running'), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + + expect(_badgeGlyph(), findsOneWidget); + expect(find.byType(PulsingOpacity), findsOneWidget); + expect(glyphColor(tester), kDefaultPalette.accent); + expect( + find.byTooltip('Preparing this session\'s workspace…'), + findsOneWidget, + ); + // Fade only: the glyph is sized to the text beside it, so a size pulse + // would move the line under the reader. + expect( + tester.widget(find.byType(PulsingOpacity)).minScale, + isNull, + ); + }); + + // Setup only ever runs against a checkout the bridge calls `ready`, so an + // unreachable workspace is both the more urgent claim and the more certain + // one. It has to win even while a run is still reporting itself. + testWidgets('an unavailable workspace outranks a run in flight', ( + tester, + ) async { + await tester.pumpWidget( + _wrap( + _session(checkoutKind: 'managed-worktree', checkoutState: 'missing'), + setup: _setup('running'), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byType(PulsingOpacity), findsNothing); + expect(glyphColor(tester), kDefaultPalette.warning); + expect( + find.byTooltip('This isolated session\'s workspace is unavailable.'), + findsOneWidget, + ); + }); + + testWidgets('a finished run leaves the resting badge untouched', ( + tester, + ) async { + for (final state in ['done', 'failed', 'skipped', 'interrupted']) { + await tester.pumpWidget( + _wrap( + _session(checkoutKind: 'managed-worktree'), + setup: _setup(state), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + expect( + find.byType(PulsingOpacity), + findsNothing, + reason: 'setup state "$state" is not a run in flight', + ); + expect( + find.byTooltip( + 'Isolated session — its own workspace, separate from your main tree.', + ), + findsOneWidget, + ); + } + }); + + // The bridge may widen this vocabulary; an unnameable state must not be + // read as "still going" any more than as "finished". + testWidgets('a setup state this build cannot name does not pulse', ( + tester, + ) async { + await tester.pumpWidget( + _wrap( + _session(checkoutKind: 'managed-worktree'), + setup: _setup('restoring'), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.byType(PulsingOpacity), findsNothing); + }); + + // A call site with no honest account of the run passes nothing — the + // cache carries no `setup`, and a stored `running` would pulse forever. + testWidgets('a caller that passes no setup renders the resting badge', ( + tester, + ) async { + await tester.pumpWidget( + _wrap(_session(checkoutKind: 'managed-worktree')), + ); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.byType(PulsingOpacity), findsNothing); + }); + + testWidgets('a shared session never pulses, whatever it reports', ( + tester, + ) async { + await tester.pumpWidget(_wrap(_session(), setup: _setup('running'))); + await tester.pump(const Duration(milliseconds: 100)); + expect(_badgeGlyph(), findsNothing); + expect(find.byType(PulsingOpacity), findsNothing); + }); + }); } diff --git a/app/test/widgets/session_setup_banner_test.dart b/app/test/widgets/session_setup_banner_test.dart new file mode 100644 index 00000000..4ba1645e --- /dev/null +++ b/app/test/widgets/session_setup_banner_test.dart @@ -0,0 +1,370 @@ +// The workspace's account of an isolated session's provisioning run. It is the +// only surface that explains why an agent has not started yet, and — on a +// failure — the only account of why the tree the agent IS working in is +// half-provisioned, so every state it can reach is pinned here. +// +// None of the running-state tests may `pumpAndSettle`: a live run arms a +// periodic tail sampler that never stops on its own, so a settle would hang the +// suite rather than fail it. +import 'package:antgrid/design/theme_presets.dart'; +import 'package:antgrid/design/widgets/ab_inline_banner.dart'; +import 'package:antgrid/design/widgets/ab_progress_rule.dart'; +import 'package:antgrid/models/session_entry.dart'; +import 'package:antgrid/project/project_session.dart'; +import 'package:antgrid/project/project_session_registry.dart'; +import 'package:antgrid/providers/agent_transport.dart'; +import 'package:antgrid/providers/sessions.dart'; +import 'package:antgrid/providers/value_controller.dart'; +import 'package:antgrid/services/sessions_service.dart'; +import 'package:antgrid/storage/cached_sessions_store.dart'; +import 'package:antgrid/test_helpers/fake_agent_transport.dart'; +import 'package:antgrid/widgets/session_setup_banner.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +// Riverpod 3 keeps `Override` out of the main barrel. +import 'package:flutter_riverpod/misc.dart' show Override; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/prefs_test_mock.dart'; + +const _projectId = 'P'; +const _sessionId = 's1'; + +SessionSetup _setup( + String state, { + int stepIndex = 1, + int stepCount = 4, + String? stepName = 'Install dependencies', + String? message, + bool pendingStart = false, + int startedAt = 1700, +}) => SessionSetup( + state: state, + stepIndex: stepIndex, + stepCount: stepCount, + stepName: stepName, + terminalId: 'worktree-1:setup', + message: message, + pendingStart: pendingStart, + startedAt: startedAt, +); + +SessionEntry _entry(SessionSetup? setup) => SessionEntry( + id: _sessionId, + name: 'Fix auth bug', + createdAt: 0, + lastUsedAt: 0, + archived: false, + running: false, + checkoutId: 'worktree-1', + checkoutKind: 'managed-worktree', + setup: setup, +); + +/// Mounts the banner alone over a hand-seeded session list. The banner is a +/// pure projection of that list, so nothing here needs a live stream. +Future pumpBanner( + WidgetTester tester, + SessionSetup? setup, { + List extraOverrides = const [], +}) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + activeSessionIdProvider.overrideWith( + () => ValueController(_sessionId), + ), + freshSessionsStateProvider.overrideWithValue( + SessionsState(projectId: _projectId, sessions: [_entry(setup)]), + ), + ...extraOverrides, + ], + child: MaterialApp( + theme: ThemeData.dark().copyWith( + extensions: >[kDefaultPalette], + ), + home: const Scaffold(body: SessionSetupBanner()), + ), + ), + ); + await tester.pump(); +} + +/// Disposes the banner so its tail sampler is cancelled before the test ends. +Future unmount(WidgetTester tester) => + tester.pumpWidget(const SizedBox.shrink()); + +AbInlineBanner _banner(WidgetTester tester) => + tester.widget(find.byType(AbInlineBanner)); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(useInMemoryPrefs); + + // The common path: every shared session and every bridge that reports no + // setup at all must cost a provider read and nothing on screen. + testWidgets('a session with no setup renders nothing', (tester) async { + await pumpBanner(tester, null); + expect(find.byType(AbInlineBanner), findsNothing); + expect(find.byType(AbProgressRule), findsNothing); + }); + + // The bridge owns this vocabulary and may widen it. Saying nothing is the + // only honest answer for a state that could equally mean "still going". + testWidgets('a state this build cannot name renders nothing', (tester) async { + await pumpBanner(tester, _setup('restoring')); + expect(find.byType(AbInlineBanner), findsNothing); + }); + + group('running', () { + testWidgets('names the step it is on and how far through it is', ( + tester, + ) async { + await pumpBanner(tester, _setup('running', pendingStart: true)); + + expect( + find.text('Preparing workspace — 2 of 4 · Install dependencies'), + findsOneWidget, + ); + // 0-based index: the fraction is the work already BEHIND the current + // step, which is the only part actually done. + expect( + tester.widget(find.byType(AbProgressRule)).fraction, + 0.25, + ); + expect(_banner(tester).color, kDefaultPalette.textSecondary); + await unmount(tester); + }); + + // Skip is the common case ("the deps are cached") and it is the only thing + // on screen that explains why the agent has not started, so it must be + // reachable for the whole run. + testWidgets('offers Skip and the log, and refuses to be dismissed', ( + tester, + ) async { + await pumpBanner(tester, _setup('running', pendingStart: true)); + + expect(find.text('Skip'), findsOneWidget); + expect(find.byTooltip('View setup log'), findsOneWidget); + expect(find.byTooltip('Dismiss'), findsNothing); + await unmount(tester); + }); + + // A project whose setup block is empty still runs, and dividing by its zero + // step count would render a NaN-wide fill. + testWidgets('a run with no named steps reads as indeterminate', ( + tester, + ) async { + await pumpBanner( + tester, + _setup('running', stepIndex: 0, stepCount: 0, stepName: null), + ); + + expect(find.text('Preparing workspace…'), findsOneWidget); + expect( + tester.widget(find.byType(AbProgressRule)).fraction, + isNull, + ); + await unmount(tester); + }); + + // Skip releases the queued start; it does NOT stop the run. The banner has + // to keep reporting, or a user who skipped is left with an install still + // holding the tree and nothing on screen saying so. + testWidgets('a skip already issued leaves the run reporting', ( + tester, + ) async { + await pumpBanner(tester, _setup('running', pendingStart: false)); + + expect( + find.text('Preparing workspace — 2 of 4 · Install dependencies'), + findsOneWidget, + ); + expect(find.byType(AbProgressRule), findsOneWidget); + expect(find.byTooltip('Dismiss'), findsNothing); + await unmount(tester); + }); + }); + + group('terminal states', () { + testWidgets('a failure persists, warns, and offers a rerun', ( + tester, + ) async { + await pumpBanner( + tester, + _setup('failed', stepIndex: 2, message: 'bun install exited 1'), + ); + + expect(find.text('Setup failed — bun install exited 1'), findsOneWidget); + expect(_banner(tester).color, kDefaultPalette.warning); + expect(find.text('Run setup again'), findsOneWidget); + // View log and dismiss both stay: the log is the record of what broke, + // and a finished run is something the user is allowed to put away. + expect(find.byTooltip('View setup log'), findsOneWidget); + expect(find.byTooltip('Dismiss'), findsOneWidget); + // A failure never rides the progress rule — there is no progress left. + expect(find.byType(AbProgressRule), findsNothing); + }); + + // A failure the bridge could not summarise still has to name where it got + // to, or the log is the only way to find out anything at all. + testWidgets('a failure with no summary names the step it died on', ( + tester, + ) async { + await pumpBanner(tester, _setup('failed', stepIndex: 2)); + expect( + find.text('Setup failed at 3 of 4 · Install dependencies'), + findsOneWidget, + ); + }); + + // Every isolated session predating this feature reports `interrupted` on + // the first launch after it ships. It must read as an offer, not an error. + testWidgets('an interrupted run offers to run setup', (tester) async { + await pumpBanner(tester, _setup('interrupted')); + + expect(find.text("Setup didn't finish"), findsOneWidget); + expect(_banner(tester).color, kDefaultPalette.warning); + expect(find.text('Run setup'), findsOneWidget); + }); + + testWidgets('a skipped run offers to run setup', (tester) async { + await pumpBanner(tester, _setup('skipped')); + + expect(find.text('Setup skipped'), findsOneWidget); + expect(find.text('Run setup'), findsOneWidget); + }); + + // The successful run's log stays reachable — it is the record of what the + // workspace was built from, and the setup PTY is in no terminal list. + testWidgets('a finished run says so and keeps its log', (tester) async { + await pumpBanner(tester, _setup('done', stepIndex: 3)); + + expect(find.text('Workspace ready'), findsOneWidget); + expect(find.byTooltip('View setup log'), findsOneWidget); + expect(find.byType(AbProgressRule), findsNothing); + }); + }); + + // Dismissal is keyed on the RUN, not the session: a rerun is a new answer to + // the same question, and inheriting the old dismissal would hide it. + testWidgets('a dismissal is spent by the next run', (tester) async { + await pumpBanner(tester, _setup('failed', message: 'boom')); + await tester.tap(find.byTooltip('Dismiss')); + await tester.pump(); + expect(find.byType(AbInlineBanner), findsNothing); + + await pumpBanner(tester, _setup('failed', message: 'boom')); + expect(find.byType(AbInlineBanner), findsNothing); + + await pumpBanner(tester, _setup('running', startedAt: 9999)); + expect(find.byType(AbInlineBanner), findsOneWidget); + await unmount(tester); + }); + + group('actions', () { + /// A real per-project session over a fake wire, so a tap is asserted where + /// it actually lands: on the `session:setup` frame. + Future pumpWired( + WidgetTester tester, + SessionSetup setup, + ) async { + final transport = FakeAgentTransport(); + final cache = await CachedSessionsStore.open(); + final session = ProjectSession( + projectId: _projectId, + transport: transport, + mode: ProjectSessionMode.local, + cachedSessionsStore: cache, + onClose: () async => await transport.dispose(), + ); + addTearDown(session.close); + + await pumpBanner( + tester, + setup, + extraOverrides: [ + selectedRegistrationIdProvider.overrideWith((ref) => _projectId), + projectSessionProvider( + _projectId, + ).overrideWith((ref) async => session), + ], + ); + return transport; + } + + testWidgets('Skip sends session:setup for this session', (tester) async { + final transport = await pumpWired( + tester, + _setup('running', pendingStart: true), + ); + + await tester.tap(find.text('Skip')); + await tester.pump(); + await tester.pump(); + + final sent = transport.sent.firstWhere( + (m) => m['type'] == 'session:setup', + ); + expect(sent['sessionId'], _sessionId); + expect(sent['action'], 'skip'); + + // Answer the request so nothing is left waiting on the reply timeout. + transport.emit('session:result', { + 'requestId': sent['requestId'], + 'ok': true, + 'session': _entry(_setup('running')).toJson(), + }); + await tester.pump(); + await unmount(tester); + }); + + testWidgets('a rerun asks the bridge to rerun', (tester) async { + final transport = await pumpWired(tester, _setup('failed')); + + await tester.tap(find.text('Run setup again')); + await tester.pump(); + await tester.pump(); + + final sent = transport.sent.firstWhere( + (m) => m['type'] == 'session:setup', + ); + expect(sent['action'], 'rerun'); + + transport.emit('session:result', { + 'requestId': sent['requestId'], + 'ok': true, + 'session': _entry(_setup('running')).toJson(), + }); + await tester.pump(); + }); + + // The user pressed something and is owed an answer: nothing else on screen + // moves when a setup verb is refused, so a refusal that only reached the + // log would be indistinguishable from a dropped tap. + testWidgets('a refusal is named rather than swallowed', (tester) async { + final transport = await pumpWired(tester, _setup('interrupted')); + + await tester.tap(find.text('Run setup')); + await tester.pump(); + await tester.pump(); + + final sent = transport.sent.firstWhere( + (m) => m['type'] == 'session:setup', + ); + transport.emit('session:result', { + 'requestId': sent['requestId'], + 'ok': false, + 'error': 'Setup is already running', + }); + await tester.pump(); + await tester.pump(); + + expect( + find.text("Couldn't start setup — Setup is already running"), + findsOneWidget, + ); + }); + }); +} diff --git a/bridge/CLAUDE.md b/bridge/CLAUDE.md index 6af47628..6e069316 100644 --- a/bridge/CLAUDE.md +++ b/bridge/CLAUDE.md @@ -302,3 +302,108 @@ first for every project on the machine, Git-backed or not. in the project at once. - **`baseRef` is retained without a reader on purpose** — see its comment in `worktrees/checkout-types.ts`. + +### Checkout setup (`worktree.setup`) + +The config schema, the variable table and the `ANTGRID_*` contract are in +`docs/architecture.md`. This is the host-side lifecycle: `CheckoutSetupRunner` +(`worktrees/checkout-setup.ts`) resolves the checkout's own block into a plan +file and runs the whole thing in ONE PTY — the bridge re-invoked under the +hidden `worktree-setup` subcommand (`cli/worktree-setup.ts`), because the +shipped bridge is a compiled single-file executable and `process.execPath` plus +a subcommand is the only self-invocation that works, the same shape +`resolveHookCommand` relies on. One PTY per step would reset the scrollback of +the step that actually failed. + +- **Setup runs before the checkout's `services`, and the deferral is the point.** + `prepareCheckoutRuntime(checkout, { deferServices: true })` holds back the + `services` block ALONE — watchers, port detection and tunnels still start — and + `startDeferredServices` is the only thing that clears it (the config watcher + also refuses to spawn a service added while a checkout is deferred, or a setup + step editing `antgrid.yaml` would start what the deferral is holding). + Auto-starting `bun run dev` against an empty `node_modules` is a guaranteed + failure the user then has to read past. Every terminal state releases it, so + `runCheckoutSetup` MUST report exactly one of `done`/`failed`/`skipped` for + every run it is handed — a runner that returned nothing would leave that + checkout with no services at all. The deferral and the run are taken + TOGETHER or not at all, gated on `checkoutDeclaresSetup` at create time: a + checkout with no block gets neither, because deferring for a run that never + starts strands the dev server and stamping the `done` such a run reports + banners "Workspace ready" — durably, and on every launch after it — on a + project that never opted in. An EMPTY `steps` list counts as no block, the + same answer `begin()` gives it: the app's own nudge writes exactly that for a + project it cannot fingerprint (`buildStarterWorktreeSetup`), so reading it as + a declaration would reintroduce the banner through the app's writer. The run starts strictly + AFTER `createWorktree` has flushed, emitted and re-announced (the create reply + must go out well inside the app's 15 s pending-reply timeout), and it is never + awaited. +- **A `running` setup state is never persisted.** `checkouts.json` carries + `setupState` only for the durable outcomes (`DURABLE_SETUP_STATES`, + `worktrees/checkout-types.ts`); `running` and `interrupted` can never be + written. `interrupted` is DERIVED on load from a marker's absence, so a bridge + that died mid-run comes back "Setup didn't finish" instead of a row that is + permanently preparing and unfixable — the same trap `deleting`'s comment in + `protocol.ts` was written to avoid. A marker's absence alone is not enough, + though: every checkout cut before the project declared a setup block carries + none either, so the derivation is gated on the checkout still DECLARING one + (`checkoutDeclaresSetup`), or an upgrade banners "Setup didn't finish" on every + isolated session the user already had. A checkout with no `checkouts.json` + row at all is the same answer, not a stronger one: a truncated or unreadable + store must not banner a whole project with a "Run setup" button `rerunSetup` + can only answer `WORKTREE_MISSING`. A rerun clears the marker BEFORE it starts, + for the same reason. There is deliberately no auto-rerun on launch: a setup + step can be expensive or destructive and the user did not ask for one on this + launch. +- **The setup PTY must stay registered in the runtime's `configuredTerminalIds` + or a Windows delete breaks.** That map is what `teardownCheckoutRuntime` sweeps + with `killAndAwaitTree` before `git worktree remove`, and a live `bun install` + holding the checkout as its cwd is exactly the open handle Windows refuses to + delete around. It is registered identity-mapped (`:setup` → itself, + since the app is handed the full id) and off the runner's OWN reported + terminalId, so a checkout that spawned nothing registers nothing. + A finished run is no longer IN `runs`, so + `handleExit` is not a reliable "is this a setup PTY" test: `killAndAwaitTree` + resolves on `killProcessTree` + `pty.kill()` returning, which is strictly + before node-pty dispatches `onExit`, so `finish()` has already dropped the + entry by the time the exit lands on every kill path (cancel, timeout, a rerun + over a live run, delete). `agent-core`'s own `setupTerminalIds` set is what + still knows, and is what the exit handler must consult. + `deleteManaged` additionally cancels a live run and AWAITS the kill on both of + its branches — after the dirty/unpushed preflight, since a refusal the user can + still answer must not have destroyed the run first — and never refuses a delete + on account of setup. The PTY carries no `type` (typing it `service` would put a + provisioning log in the services list), which is NOT the same as being + hidden: the app's ad-hoc terminal list selects by EXCLUDING `agent` and + `service`, so untyped reads there as "a user terminal". It is kept out by + name instead — `terminal_list_view.dart` drops every id any session's + `setup.terminalId` claims — or the user gets an interactive tab over a live + `bun install` and a close button that kills it. Its owner row in + `terminalOwners` also SURVIVES its exit, unlike every other terminal's: + `sendStatus` routes a terminal by that row, so dropping it would advertise + the finished transcript on main and prune it from the checkout bundle the + banner's "View setup log" reads — exactly when the run has failed. It is the + one terminal spawned with `retainScrollbackOnExit`: the failing step's output is read after the run at + least as often as during it, and `TerminalManager.forget` in teardown is what + gives that retention a definite end. +- **`suppressOscTitle` must never be set on the setup PTY.** Step transitions + ride OSC 2 titles (`formatSetupStepMarker`), and that flag suppresses the + `onTitle` callback itself — the channel being used. The other half is + `onTerminalTitle` feeding `setupRunner.handleTitle` and RETURNING before the + namer fallback; without that guard the session namer reads setup progress as a + conversation title. `suppressOscNotifications` IS set: provisioning must never + raise an attention signal. Coarse transitions ride the immediate + `notifyObservers()` path, never the debounced activity emit, or the banner lags + a step behind; live output stays on the setup terminal's own `terminal:output`. +- **The start gate lives on the bridge, in memory.** A `session:start` arriving + while setup runs records `pendingStart` (with its `initialPrompt`) and replies + `ok: true`. A start carrying NO prompt never clears one already queued — the + app gates its auto-start paths on `sessionStartQueued`, and this is the + backstop for the path that forgets to, since the prompt the user typed has + no other copy. `archive` drops a queued start outright: the agent must not + launch into a session the user has already put away — the entry carries `setup.pendingStart`, so the reply is honest. + A user who creates a session on a phone and locks the screen must come back to + a running agent, which is why the queue is not the app's. The prompt is + never persisted: a restart legitimately drops it and the session sits stopped + with a Start affordance. `session:setup` (`skip` releases the gate and leaves + the run going, `cancel` kills the tree, `rerun` starts fresh from a settled + state) is the only verb over it. diff --git a/bridge/src/agent-core.ts b/bridge/src/agent-core.ts index d25a9a03..9b4b7c56 100644 --- a/bridge/src/agent-core.ts +++ b/bridge/src/agent-core.ts @@ -36,7 +36,8 @@ import { WorktreeManager } from "./worktrees/worktree-manager"; import { CheckoutStore } from "./worktrees/checkout-store"; import { resolveProject } from "./worktrees/project-resolver"; import { CheckoutRuntimeRegistry } from "./worktrees/checkout-runtime-registry"; -import type { CheckoutRecord } from "./worktrees/checkout-types"; +import type { CheckoutRecord, CheckoutSetupProgress } from "./worktrees/checkout-types"; +import { CheckoutSetupRunner } from "./worktrees/checkout-setup"; import { SessionNamer } from "./session-namer"; import { antigravityCliHome } from "./agents/antigravity/title"; import { AntigravityTitleWatcher } from "./agents/antigravity/title-watcher"; @@ -96,6 +97,11 @@ interface CheckoutRuntime { gitStatusSeq: number; gitStatusApplied: number; configuredTerminalIds: Map; + /** True while the `services` block is held back for a `worktree.setup` run. + * [startDeferredServices] is the only thing that clears it — a service + * started against a worktree whose dependencies are still installing fails + * before the user has seen the session. */ + servicesDeferred: boolean; started: boolean; /** This runtime is being torn down, or already has been. Never cleared — a * torn-down runtime is replaced, never revived. @@ -258,6 +264,11 @@ export interface AgentCore { * session at all). Pre-handshake this answers true — nothing is isolated * yet, so no guard should be narrowed away. */ isMainCheckoutSession(id: string): boolean; + /** [client]'s socket closed — it stops vouching for whatever it had on + * screen. Mirrors the work reduction's own `clientGone`: without it a + * desktop that quit, or a phone that dropped off the relay, would keep one + * session permanently "on screen" and mute its setup push forever. */ + noteClientGone(client: InboundSource): void; } export interface BuildAgentCoreOptions { @@ -554,6 +565,14 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise(); + // What each client last said is on screen (`session:focus`), dropped when it + // declares it can render nothing here (`client:focus-state`) or when its + // socket goes away (`noteClientGone`) — the app restates its focus on + // resume. Read only by the setup push: a run whose + // banner the user is watching must not also buzz their phone. The work-status + // read state keeps its own copy in ProjectCore; this one exists because a + // core has no way back into that reduction. + const focusedSessionByClient = new Map(); function createCheckoutRuntime( checkout: CheckoutRecord, @@ -581,6 +600,7 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise { const checkoutId = s.get(verb.sessionId)?.checkoutId ?? "main"; try { @@ -1189,6 +1211,7 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise { + if (!manager) throw new Error("terminal manager is not ready"); + return manager.spawn(spawnConfig); + }, + killAndAwaitTree: (terminalId) => manager?.killAndAwaitTree(terminalId) ?? Promise.resolve(), + }, + }); + + /** Put the setup transcript under the checkout's runtime so + * [teardownCheckoutRuntime] reaches it with killAndAwaitTree before `git + * worktree remove` runs: on Windows a live `bun install` holding the + * checkout as its cwd aborts that sweep mid-tree and strands the session + * undeletable. Mapped to itself rather than namespaced, because the session + * entry hands the app this exact id — translating it on the way out would + * point the app's snapshot request at a terminal nobody has. */ + /** Setup transcripts this process has spawned, live or finished. + * + * Separate from `CheckoutSetupRunner.runs`, which is emptied by `finish()` + * — and `finish()` runs BEFORE the PTY's exit on every kill path, because + * `killAndAwaitTree` resolves on `killProcessTree` + `pty.kill()` returning, + * which is strictly earlier than node-pty dispatching `onExit`. So a + * cancelled, timed-out or rerun-over run reaches `onTerminalExited` with + * `handleExit` already answering false, and only this set still knows what + * the terminal was. Emptied with the rest of the checkout in + * `sweepCheckoutRuntime`. */ + const setupTerminalIds = new Set(); + + function registerSetupTerminal(checkoutId: string, terminalId: string): void { + checkoutRuntimes.runtime(checkoutId)?.configuredTerminalIds.set(terminalId, terminalId); + terminalOwners.set(terminalId, { checkoutId, externalId: terminalId }); + setupTerminalIds.add(terminalId); + } + + /** Below this a run finished while the user was still on the create flow, and + * a push would be noise on every project whose setup is a cache hit. */ + const SETUP_PUSH_MIN_MS = 20_000; + + /** One push per run, and only for a run nobody watched to the end. A cancel + * says nothing: the user is the one who ended it. */ + function notifySetupSettled(sessionId: string, progress: CheckoutSetupProgress, elapsedMs: number): void { + if (progress.state !== "done" && progress.state !== "failed") return; + if (elapsedMs < SETUP_PUSH_MIN_MS) return; + if (isSessionOnScreen(sessionId)) return; + const name = sessions?.get(sessionId)?.name; + sendNotifying(createMessage("notification:push", { + notificationType: progress.state === "done" ? "task_complete" : "error", + message: progress.state === "done" + ? "Workspace is ready." + : progress.message ?? "Workspace setup failed.", + sessionId, + ...(name ? { sessionTitle: name } : {}), + projectId: project.id, + })); + } + + function isSessionOnScreen(sessionId: string): boolean { + for (const focused of focusedSessionByClient.values()) { + if (focused === sessionId) return true; + } + return false; + } + async function refreshGitBranch(runtime: CheckoutRuntime = mainRuntime): Promise { try { const proc = Bun.spawn(["git", "rev-parse", "--abbrev-ref", "HEAD"], { @@ -1838,9 +1933,13 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise { + async function startCheckoutRuntime( + runtime: CheckoutRuntime, + opts?: { deferServices?: boolean }, + ): Promise { if (runtime.started || !manager) return; runtime.started = true; + runtime.servicesDeferred = opts?.deferServices ?? false; const runtimeId = runtime.checkout.id; const send = (msg: AbMessage) => sendFromRuntime(runtime, msg); const pd = new PortDetector({ @@ -1917,6 +2016,10 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise candidate.name === changed.name); if (!service || !manager) continue; + // A setup step that edits antgrid.yaml must not spawn what the deferral + // is holding back; [startDeferredServices] starts the whole block from + // the config this callback is about to assign. + if (runtime.servicesDeferred) continue; const terminalId = internalTerminalId(runtime, service.name); if (diff.servicesModified.some((candidate) => candidate.name === changed.name)) { manager.kill(terminalId); @@ -1941,18 +2044,7 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise { + const runtime = checkoutRuntimes.runtime(checkoutId); + if (!runtime?.servicesDeferred) return; + runtime.servicesDeferred = false; + startCheckoutServices(runtime); + // `agent:status` is what carries services[].running to the app, and the + // checkout's last push happened while they were still held back. + sendStatus(runtime); + } + /** Serializes a checkout's runtime lifecycle. Building a runtime and tearing * one down both suspend repeatedly, and everything they touch — a recursive * watcher, `services:` PTYs, `git` children — holds the checkout directory @@ -1989,18 +2112,21 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise { + function prepareCheckoutRuntime( + checkout: CheckoutRecord, + opts?: { deferServices?: boolean }, + ): Promise { return withCheckoutRuntimeLock(checkout.id, async () => { const existing = checkoutRuntimes.runtime(checkout.id); if (existing) { - await startCheckoutRuntime(existing); + await startCheckoutRuntime(existing, opts); return existing; } const runtimeConfig = loadConfig(undefined, checkout.path); const spec = agentSpecForConfig(runtimeConfig); const runtime = createCheckoutRuntime(checkout, runtimeConfig, spec); await checkoutRuntimes.prepare(checkout, runtimeConfig, spec, runtime); - await startCheckoutRuntime(runtime); + await startCheckoutRuntime(runtime, opts); return runtime; }); } @@ -2043,7 +2169,8 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise terminalOwner(id).runtime.portDetector?.feed(id, data), onTerminalExited: (id) => { terminalOwner(id).runtime.portDetector?.removeTerminal(id); + // A setup transcript belongs to no session, so its exit settles the run + // and takes none of the session-scoped cleanup below. Its owner row + // stays, too: the scrollback is retained on purpose, and `sendStatus` + // routes a terminal by that row — dropping it would advertise the + // finished transcript on MAIN and prune it from the checkout bundle the + // banner's "View setup log" reads, exactly when the run has failed. + // Released with the rest of the checkout in `teardownCheckoutRuntime`. + if (setupRunner.handleExit(id) || setupTerminalIds.has(id)) return; sessions?.noteExited(id); // Drop buffered title state so a stale title from this run can't leak // into a restarted same-id session (start() reuses the entry id). @@ -2121,6 +2266,10 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise { + // Every title on a live setup transcript is a step marker, so it goes to + // the runner and NOWHERE else — the namer would otherwise read setup + // progress as the session's conversation name. + if (setupRunner.handleTitle(id, title)) return; // A non-session PTY (a config `terminals:` slot) is attributable to no // agent, so its raw title passes through untouched. const session = sessions?.get(id); @@ -2250,7 +2399,37 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise { await prepareCheckoutRuntime(checkout); }, + prepareCheckoutRuntime: async (checkout, prepareOpts) => { + await prepareCheckoutRuntime(checkout, prepareOpts); + }, + startDeferredServices, + runCheckoutSetup: (checkout, sessionId, onProgress) => { + const startedAt = Date.now(); + setupRunner.start(checkout, sessionId, (progress) => { + // Before the report, and off the runner's own id rather than a + // computed one: a checkout with no setup block never spawns a PTY, + // and registering an id nothing runs under makes every delete warn. + if (progress.terminalId) registerSetupTerminal(checkout.id, progress.terminalId); + // Reported before the push so the entry it reads — the session title + // included — is the one the session manager has just settled. + onProgress(progress); + notifySetupSettled(sessionId, progress, Date.now() - startedAt); + }); + }, + cancelCheckoutSetup: (checkoutId) => setupRunner.cancel(checkoutId), + checkoutDeclaresSetup: (checkout) => { + // A config that will not parse cannot say there is nothing to run, so + // the doubt is reported as "declares" and the user gets the banner. + try { + const setup = setupRunner.resolveSetup(checkout); + // An EMPTY `steps` list is a declaration of nothing, and the same + // answer `begin()` gives it. The nudge writes exactly that block for + // a project it cannot fingerprint (`buildStarterWorktreeSetup`), so + // reading it as "declares" would defer services for a run that only + // ever reports `done`, and stamp that durably. + return !!setup && setup.steps.length > 0; + } catch { return true; } + }, announceCheckoutRuntime: (checkoutId) => { const runtime = checkoutRuntimes.runtime(checkoutId); if (!runtime) return; @@ -2847,5 +3026,8 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise { + let plan: SetupPlanFile; + try { + plan = SetupPlanFileSchema.parse(JSON.parse(readFileSync(opts.plan, "utf8"))); + } catch (err) { + // Nothing has been parsed, so the result path is unknown and the parent + // falls back to "ended without reporting a result". Say why here anyway — + // this text is the only thing in the transcript. + process.stdout.write(`antgrid setup: unreadable plan ${opts.plan}: ${(err as Error).message}\n`); + return 1; + } + + const count = plan.steps.length; + process.stdout.write(`Preparing workspace — ${count} step${count === 1 ? "" : "s"}\n`); + + for (let index = 0; index < count; index++) { + const step = plan.steps[index]!; + // The marker leads the step so the banner names what is ABOUT to run; a + // long install would otherwise sit under the previous step's name. + process.stdout.write(formatSetupStepMarker(index, count, step.name)); + process.stdout.write(`\n→ [${index + 1}/${count}] ${step.name}\n`); + + const failure = step.run !== undefined ? runStep(plan, step) : copyStep(step); + if (failure !== null) { + process.stdout.write(`\n✖ ${failure.message}\n`); + writeSetupResult(plan.resultPath, { + exitCode: failure.exitCode, + stepIndex: index, + stepName: step.name, + message: failure.message, + }); + return failure.exitCode; + } + } + + process.stdout.write("\n✔ Workspace ready\n"); + writeSetupResult(plan.resultPath, { + exitCode: 0, + stepIndex: Math.max(count - 1, 0), + stepName: plan.steps[count - 1]?.name, + }); + return 0; +} + +interface StepFailure { + exitCode: number; + message: string; +} + +/** + * A missing source is a warning, not a failure: not every developer has every + * env file, and `scripts/worktree.ts:copyEnv` — the hand-rolled provisioner + * this replaces — has always behaved this way. + */ +function copyStep(step: SetupPlanStep): StepFailure | null { + for (const entry of step.copy ?? []) { + if (!existsSync(entry.from)) { + process.stdout.write(` ! ${entry.rel} not found in the main checkout — skipping\n`); + continue; + } + try { + mkdirSync(dirname(entry.to), { recursive: true }); + copyFileSync(entry.from, entry.to); + } catch (err) { + return { exitCode: 1, message: `${step.name}: could not copy ${entry.rel} — ${(err as Error).message}` }; + } + process.stdout.write(` copied ${entry.rel}\n`); + } + return null; +} + +/** + * `shell: true` matches the `commands` / `services` blocks: a setup line is + * written as shell, and it comes from the checkout's own `antgrid.yaml`, which + * is the same trust class as those. + * + * Synchronous on purpose — the run is strictly sequential and stdio is + * inherited, so interleaving would only scramble the transcript. + */ +function runStep(plan: SetupPlanFile, step: SetupPlanStep): StepFailure | null { + process.stdout.write(` $ ${step.run} (in ${step.workingDir})\n`); + const result = spawnSync(step.run!, { + cwd: step.workingDir, + env: { ...process.env, ...plan.env, ...step.env }, + stdio: "inherit", + shell: true, + }); + if (result.error) { + return { exitCode: 1, message: `${step.name}: ${result.error.message}` }; + } + if (result.signal) { + return { exitCode: 1, message: `${step.name} was terminated (${result.signal})` }; + } + const code = result.status ?? 1; + if (code !== 0) { + return { exitCode: code, message: `${step.name} failed (exit ${code})` }; + } + return null; +} diff --git a/bridge/src/config.ts b/bridge/src/config.ts index 2a57fd39..39542870 100644 --- a/bridge/src/config.ts +++ b/bridge/src/config.ts @@ -44,6 +44,39 @@ export const PortEntrySchema = z.union([ }).strict(), ]); +/** One provisioning step for a freshly cut managed worktree. `copy` and `run` + * are mutually exclusive so a step has exactly one meaning in the progress + * line; `name` is required because that line is the entire point of a named + * list. `copy` sources resolve against the main project path and land at the + * same relative path inside the checkout. */ +export const WorktreeSetupStepSchema = z.object({ + name: z.string().min(1), + copy: z.array(z.string()).optional(), + run: z.string().optional(), + workingDir: z.string().optional(), + env: z.record(z.string(), z.string()).optional(), +}).strict().superRefine((value, ctx) => { + if (value.copy && value.run) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["run"], message: "a step carries either copy or run, not both" }); + } + if (!value.copy && !value.run) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["name"], message: "a step needs copy or run" }); + } +}); + +export const WorktreeSetupSchema = z.object({ + steps: z.array(WorktreeSetupStepSchema), + /** Budget for the whole run, not per step. */ + timeoutMs: z.number().int().positive().optional(), + /** `block` is reserved, not accepted: a setup that can wedge a session behind + * it needs an escape hatch the v1 UI does not have. */ + onFailure: z.enum(["warn"]).optional(), +}).strict(); + +export const WorktreeBlockSchema = z.object({ + setup: WorktreeSetupSchema.optional(), +}).strict(); + export const AbConfigSchema = z.object({ name: z.string().optional(), relayUrl: z.string().optional(), @@ -51,12 +84,16 @@ export const AbConfigSchema = z.object({ services: z.array(ServiceSchema).optional(), commands: z.array(CommandSchema).optional(), ports: z.array(PortEntrySchema).optional(), + worktree: WorktreeBlockSchema.optional(), }).strict(); export type OnDetect = z.infer; export type AgentBlock = z.infer; export type ServiceConfig = z.infer; export type CommandConfig = z.infer; +export type WorktreeSetupStep = z.infer; +export type WorktreeSetup = z.infer; +export type WorktreeBlock = z.infer; export interface PortConfig { port: number; name?: string; @@ -69,6 +106,7 @@ export interface AbConfig { services?: ServiceConfig[]; commands?: CommandConfig[]; ports?: PortConfig[]; + worktree?: WorktreeBlock; } const DEFAULT_CONFIG: AbConfig = {}; @@ -81,16 +119,32 @@ function normalizePorts(raw: z.infer["ports"]): PortConfi }); } -export function resolveVariables(raw: string, context: { projectPath?: string }): string { +/** Everything a `${...}` reference can name. Only `projectPath` is known at + * config-load time; the checkout/session members are supplied by callers that + * resolve a value against a specific managed worktree, so an unset one leaves + * the reference untouched rather than interpolating a wrong path. */ +export interface ResolveContext { + projectPath?: string; + checkoutPath?: string; + checkoutBranch?: string; + baseBranch?: string; + sessionId?: string; +} + +export function resolveVariables(raw: string, context: ResolveContext): string { return raw.replace(/\$\{([^}]+)\}/g, (match, expr: string) => { if (expr.startsWith("env.")) return process.env[expr.slice(4)] ?? match; if (expr === "project.path" && context.projectPath) return context.projectPath; + if (expr === "checkout.path" && context.checkoutPath) return context.checkoutPath; + if (expr === "checkout.branch" && context.checkoutBranch) return context.checkoutBranch; + if (expr === "base.branch" && context.baseBranch) return context.baseBranch; + if (expr === "session.id" && context.sessionId) return context.sessionId; return match; }); } function resolveItem }>( - item: T, ctx: { projectPath?: string }, + item: T, ctx: ResolveContext, ): T { const r = { ...item }; if (r.workingDir) r.workingDir = resolveVariables(r.workingDir, ctx); @@ -102,8 +156,12 @@ function resolveItem resolveItem(s, ctx)), diff --git a/bridge/src/index.ts b/bridge/src/index.ts index b2cd75bb..d40b20cc 100644 --- a/bridge/src/index.ts +++ b/bridge/src/index.ts @@ -62,6 +62,20 @@ program process.exit(0); }); +// Internal executor for a managed checkout's `worktree.setup`: the bridge +// re-invokes ITSELF under the setup PTY. The shipped bridge is a compiled +// single-file executable and cannot `bun run` a script, so a subcommand is the +// only self-invocation that works — the same shape `resolveHookCommand` relies +// on. Hidden: nothing about it is user-facing. +program + .command("worktree-setup", { hidden: true }) + .description("Run a resolved worktree setup plan") + .requiredOption("--plan ", "Path to the resolved setup plan JSON") + .action(async (opts: { plan: string }) => { + const { runWorktreeSetupCli } = await import("./cli/worktree-setup"); + process.exit(await runWorktreeSetupCli({ plan: opts.plan })); + }); + // Single default action — reads bootstrap payload from stdin, branches on mode. program .option("--verbose", "Alias for --log-level debug") diff --git a/bridge/src/project-core.ts b/bridge/src/project-core.ts index 2455a8ec..1c17f119 100644 --- a/bridge/src/project-core.ts +++ b/bridge/src/project-core.ts @@ -229,6 +229,9 @@ export class ProjectCore { * {@link clientGone}. */ noteClientGone(client: InboundSource): void { this.commitWork(clientGone(this._work, client)); + // The core keeps its own copy of what each client has on screen (the setup + // push reads it); a stale entry there mutes that push for good. + this.core?.noteClientGone(client); } /** The user pressed a bare Esc into [sessionId]'s PTY — close its turn now diff --git a/bridge/src/protocol.ts b/bridge/src/protocol.ts index 62ce5bc2..c5a525c0 100644 --- a/bridge/src/protocol.ts +++ b/bridge/src/protocol.ts @@ -1176,6 +1176,31 @@ const SessionEntrySchema = z.object({ checkoutKind: z.enum(["main", "managed-worktree", "external-worktree"]).default("main"), checkoutBranch: z.string().nullable().optional(), checkoutState: z.enum(["ready", "missing", "failed"]).default("ready"), + // Provisioning of this session's own checkout (`worktree.setup`). Orthogonal + // to `checkoutState`, deliberately: that answers "is this workspace usable", + // this one "has provisioning finished" — a checkout is `ready` while setup is + // still running, which is exactly what makes Skip meaningful. Folding it into + // the checkoutState vocabulary would make the isolation badge claim the + // workspace is broken in the common case. + // Optional with no default: an older app ignores the key and sees exactly + // today's behaviour. `running` never reaches disk — see checkout-store.ts. + setup: z.object({ + state: z.enum(["running", "done", "failed", "skipped", "interrupted"]), + // 0-based, the current step while running and the last one afterwards. + stepIndex: z.number().int().nonnegative(), + stepCount: z.number().int().nonnegative(), + stepName: z.string().optional(), + // The setup transcript's terminal, replayable via terminal:snapshot:request. + terminalId: z.string().optional(), + exitCode: z.number().int().optional(), + // One-line failure summary. + message: z.string().optional(), + // A session:start is queued behind this run. The app reads it to tell + // "queued" from "started" — the start reply is ok either way. + pendingStart: z.boolean().default(false), + startedAt: z.number(), + finishedAt: z.number().optional(), + }).optional(), }); const SessionListMessage = BaseMessage.extend({ @@ -1260,6 +1285,19 @@ const SessionSetModeMessage = BaseMessage.extend({ mode: z.enum(["terminal", "chat"]), }); +// Skip releases the queued start immediately and lets setup keep running; +// cancel kills the run; rerun starts a fresh one from a terminal state. +// Deliberately NOT in CHECKOUT_VARIABLE_MESSAGE_TYPES: every `session:*` verb +// routes by sessionId on the project stream and the bridge resolves the +// checkout from the session entry, so a checkoutId on the wire here would be a +// second, conflicting answer to a question already settled bridge-side. +const SessionSetupMessage = BaseMessage.extend({ + type: z.literal("session:setup"), + requestId: z.string(), + sessionId: z.string(), + action: z.enum(["skip", "cancel", "rerun"]), +}); + // App→agent: this session is what the user is looking at, sent on every focus // change. Fire-and-forget (no requestId, no reply) — it feeds the work-status // read state (`sessionFocus` in work-status.ts), which is advisory, so a @@ -1788,6 +1826,7 @@ export const AbMessageSchema = z.discriminatedUnion("type", [ SessionUnarchiveMessage, SessionDeleteMessage, SessionSetModeMessage, + SessionSetupMessage, SessionFocusMessage, SessionResultMessage, SessionUpdatedMessage, @@ -1925,6 +1964,7 @@ export type SessionArchive = z.infer; export type SessionUnarchive = z.infer; export type SessionDelete = z.infer; export type SessionSetMode = z.infer; +export type SessionSetup = z.infer; export type SessionFocus = z.infer; export type SessionResult = z.infer; export type SessionUpdated = z.infer; @@ -2067,7 +2107,7 @@ const KNOWN_TYPES = new Set([ "session:list", "session:list:result", "session:create", "session:start", "session:stop", "session:rename", "session:archive", "session:unarchive", - "session:delete", "session:set-mode", "session:focus", + "session:delete", "session:set-mode", "session:setup", "session:focus", "session:result", "session:updated", "client:focus-state", "terminal:snapshot:request", "terminal:snapshot", diff --git a/bridge/src/session-manager.ts b/bridge/src/session-manager.ts index f9a3d263..9edfc6c5 100644 --- a/bridge/src/session-manager.ts +++ b/bridge/src/session-manager.ts @@ -21,12 +21,17 @@ import type { AbMessage, SessionEntry } from "./protocol"; import { CHECKOUT_KINDS, CHECKOUT_STATES, + DURABLE_SETUP_STATES, isIsolatedCheckoutKind, isManagedCheckoutKind, type CheckoutKind, type CheckoutRecord, + type CheckoutSetupProgress, type CheckoutState, + type DurableSetupState, + type SetupState, } from "./worktrees/checkout-types"; +import { CheckoutStore } from "./worktrees/checkout-store"; import { WorktreeError, type WorktreeManager } from "./worktrees/worktree-manager"; import { logWorktreeEvent, worktreeErrorCode } from "./worktrees/worktree-log"; @@ -117,8 +122,36 @@ export interface SessionManagerOpts { * means "not a repository", so a caller that cannot answer fails closed. */ isGitRepository?: () => Promise; worktreeManager?: WorktreeManager; - /** Construct the checkout-scoped runtime before a session can commit. */ - prepareCheckoutRuntime?: (checkout: CheckoutRecord) => Promise; + /** Construct the checkout-scoped runtime before a session can commit. + * `deferServices` still starts watchers, port detection and tunnels but + * holds the `services` block: auto-starting `bun run dev` against a worktree + * whose `node_modules` has not been provisioned yet is a guaranteed failure + * the user then has to read past. `startDeferredServices` releases them. */ + prepareCheckoutRuntime?: (checkout: CheckoutRecord, opts?: { deferServices?: boolean }) => Promise; + /** Release the `services` a `deferServices` preparation held back. */ + startDeferredServices?: (checkoutId: string) => Promise; + /** Kick off `worktree.setup` for a freshly cut managed checkout. Returns void + * rather than a promise on purpose: createWorktree must have replied and + * announced before this runs, and must never wait on it. Coarse transitions + * come back through `onProgress`; the live transcript is the setup + * terminal's own output. */ + runCheckoutSetup?: ( + checkout: CheckoutRecord, + sessionId: string, + onProgress: (progress: CheckoutSetupProgress) => void, + ) => void; + /** Kill a running setup's process tree and await it. Awaited before a managed + * checkout is removed: on Windows a live `bun install` holding the worktree + * as its cwd makes `git worktree remove` fail. */ + cancelCheckoutSetup?: (checkoutId: string) => Promise; + /** Whether this checkout has a `worktree.setup` block AT ALL, answered from + * its own antgrid.yaml. Read once per managed checkout on load, and only to + * keep "died mid-run" apart from "never had a run": every checkout cut before + * this feature shipped carries no marker either, and reporting those as + * `interrupted` puts a "Setup didn't finish" banner on every isolated session + * the user already had. Unanswerable (an unreadable config) reads as true, so + * the doubt surfaces rather than hides. */ + checkoutDeclaresSetup?: (checkout: CheckoutRecord) => boolean; /** Re-push the checkout's workspace state AFTER the session is announced. * `prepareCheckoutRuntime` emits it too, but nothing replays a push frame * and at that point no app knows the checkout exists — so its subscriber @@ -310,6 +343,46 @@ export const RESTART_FAILED_ERROR = "failed to restart session after mode switch // coalesce the session:updated emit so the drawer re-sort doesn't thrash. const ACTIVITY_EMIT_DEBOUNCE_MS = 750; +/** A session's `worktree.setup` run. Runtime-only, deliberately absent from + * PersistedEntry: only the OUTCOME reaches disk (checkouts.json), so a bridge + * that dies mid-run comes back reporting `interrupted` rather than a run that + * nothing on this launch is alive to finish. */ +interface SetupRuntime { + /** Tells this run apart from the one a `rerun` replaced, and from the dying + * report of a run a cancel already killed: neither may overwrite the state + * the user has since been shown. Zero for a state recovered from disk, which + * has no runner behind it at all. */ + runId: number; + state: SetupState; + stepIndex: number; + stepCount: number; + stepName?: string; + terminalId?: string; + exitCode?: number; + message?: string; + startedAt: number; + finishedAt?: number; + /** A start held behind this run. Never persisted — `initialPrompt` is + * one-shot launch state everywhere else too, and a bridge restart + * legitimately drops it (the session then sits stopped with a Start + * affordance). */ + pendingStart?: { initialPrompt?: string }; + /** The prompt the last queued start carried, kept after that start fired so a + * rerun can re-arm it. Under `onFailure: warn` every exit from `running` + * fires the queue, so a failed run has already spent its prompt on a tree + * the agent could not build in — carrying it forward is what makes + * re-run-setup → restart-agent not a retype. */ + lastQueuedPrompt?: string; + /** Starts no longer queue behind this run. Separate from `state` because a + * skipped run KEEPS RUNNING and keeps reporting — releasing the gate is the + * whole of what Skip does. */ + gateReleased: boolean; + /** This run's checkout was prepared with `deferServices`, so its `services:` + * block is this run's to release when it ends. A rerun never holds them: + * the first run already let them go. */ + holdsServices: boolean; +} + export class SessionManager { private entries = new Map(); private observers = new Set<() => void>(); @@ -349,6 +422,12 @@ export class SessionManager { // something new; a stale `true` is harmless because start() re-runs the real // pre-flight and falls back to a fresh start. private resumableCache = new Map(); + /** Live and recovered `worktree.setup` state, keyed by session id. Runtime + * only — it reaches the wire through toWire and never sessions.json, which + * is also why every mutation here emits with notifyObservers() rather than + * changed(). */ + private readonly setups = new Map(); + private nextSetupRunId = 1; constructor(private opts: SessionManagerOpts) { this.dir = join(opts.storeDir, "agents", opts.projectId); @@ -357,6 +436,7 @@ export class SessionManager { this.projectPath = opts.projectPath; this.agentSpec = opts.agentSpec; this.load(); + void this.recoverSetupStates(); } /** @@ -595,7 +675,20 @@ export class SessionManager { sessionName: undefined, // new session won't have a name baseBranch: spec.baseBranch, }); - await this.opts.prepareCheckoutRuntime?.(checkout); + // Services are held back until setup finishes: auto-starting `bun run dev` + // against a worktree whose `node_modules` has not been provisioned yet is + // a guaranteed failure the user then has to read past. Watchers, port + // detection and tunnels still come up now. + // Asked of the CHECKOUT, not the project: the worktree is cut from a + // commit, so the block that governs this session is the one on its own + // branch. A checkout that declares none must take neither half of the + // lifecycle — deferring services for a run that will never release them + // strands the dev server, and stamping the `done` such a run reports + // banners "Workspace ready" on a project that never opted in, on this + // launch and (through recoverSetupStates) on every launch after it. + const declaresSetup = !!this.opts.runCheckoutSetup + && this.opts.checkoutDeclaresSetup?.(checkout) !== false; + await this.opts.prepareCheckoutRuntime?.(checkout, { deferServices: declaresSetup }); runtimePrepared = true; const checkoutSpec = await this.opts.resolveAgentSpec?.(checkout.id) ?? this.agentSpec; this.assertSafeWorkingDir(checkout.path, checkoutSpec.workingDir); @@ -607,6 +700,10 @@ export class SessionManager { // checkout path (`cachedGitBranch`), which is what the UI renders. entry.checkoutBranch = checkout.branch; entry.checkoutState = "ready"; + // Seeded before the commit so the entry the create reply carries already + // says `running` — the app must never see an isolated session that looks + // provisioned for the frame before the first progress lands. + const setup = declaresSetup ? this.beginSetup(entry.id, true) : undefined; this.entries.set(entry.id, entry); await this.flushNowOrThrow(); this.notifyObservers(); @@ -614,9 +711,17 @@ export class SessionManager { // bundle from the session list, so anything pushed earlier lands with no // subscriber. Never fatal — the session itself is already committed. this.reannounceCheckout(checkout.id); + // Last, and never awaited: the create reply is what the app is waiting on + // (15 s), and a setup run takes minutes. The sub-millisecond gap after the + // announce is deliberate — a runner frame sent before it has no subscriber. + if (setup) { + this.opts.runCheckoutSetup?.(checkout, entry.id, + (progress) => this.onSetupProgress(entry.id, setup.runId, progress)); + } return this.toWire(entry); } catch (error) { this.entries.delete(entry.id); + this.setups.delete(entry.id); if (runtimePrepared && checkout) { try { await this.opts.teardownCheckoutRuntime?.(checkout.id); } catch { /* rollback continues */ } } @@ -729,6 +834,12 @@ export class SessionManager { const entry = this.entries.get(id); if (!entry) throw new Error(`session not found: ${id}`); if (this.tm.has(id)) this.tm.kill(id); + // The kill above cannot reach a start that has not happened yet: a session + // archived while its checkout is still provisioning would otherwise launch + // its agent the moment setup settled. The prompt stays in + // `lastQueuedPrompt`, so unarchive then rerun still re-arms it. + const queued = this.setups.get(id); + if (queued) queued.pendingStart = undefined; entry.archived = true; this.changed(); } @@ -752,6 +863,7 @@ export class SessionManager { if (this.tm.has(id)) this.tm.kill(id); this.entries.delete(id); this.resumableCache.delete(id); + this.setups.delete(id); this.changed(); return true; } @@ -792,6 +904,7 @@ export class SessionManager { if (this.tm.has(entry.id)) this.tm.kill(entry.id); this.markDeleting(entry); try { + await this.cancelSetupForDelete(entry); // Still torn down even though there is no worktree left to unlock: the // checkout's `services:` PTYs, watcher and port detector outlive it, and // once this row is gone nothing on the machine can name that checkoutId @@ -803,6 +916,7 @@ export class SessionManager { } this.entries.delete(entry.id); this.resumableCache.delete(entry.id); + this.setups.delete(entry.id); this.clearDeleting(entry.id); // In a finally: the row is already gone from memory and the flag already // cleared, so a flush that throws must not leave the app holding the @@ -837,6 +951,12 @@ export class SessionManager { // pending state on the way to a dialog. this.markDeleting(entry); try { + // Cancelled, never refused: a user deleting a session does not want to be + // told to wait out a `bun install`. Placed past the two preflight + // refusals, which destroy nothing and are still answerable — but before + // everything that does, because a live setup process holding the checkout + // as its cwd is what makes `git worktree remove` fail on Windows. + await this.cancelSetupForDelete(entry); if (!await this.stopAndAwait(entry.id)) { throw new WorktreeError("WORKTREE_DELETE_FAILED", "The session did not stop before its worktree could be removed."); } @@ -867,10 +987,16 @@ export class SessionManager { } } catch (error) { if (this.clearDeleting(entry.id)) this.notifyObservers(); + // The session survived its delete, so the setup this flow cancelled is + // still holding its `services:` back with nothing else able to release + // them: `settleSetup` is gated on a `running` state the cancel moved, and + // a rerun mints a run that holds nothing. + await this.releaseSetupHold(entry); throw error; } this.entries.delete(entry.id); this.resumableCache.delete(entry.id); + this.setups.delete(entry.id); this.clearDeleting(entry.id); // See the sibling tail above: the emit is owed even when the flush fails. try { @@ -979,6 +1105,27 @@ export class SessionManager { "This isolated session is being deleted.", )); } + // Queued, not refused: the workspace this agent would launch into is still + // being provisioned. The reply stays `ok` because the entry it carries says + // `pendingStart`, which is how the app tells "queued" from "started" — and + // the queue lives here rather than in the app so a user who locks their + // phone comes back to a running agent. Skip, cancel and completion all + // release the gate and fire this. + // + // Never for a session that is ALREADY running: such a start is the + // reconnect re-announce path (see startNow / reannounceCheckout), and + // queuing it would hold that app's checkout view until the run ends + // instead of re-pushing the state it is waiting for. + const gate = entry && !this.isRunning(entry) ? this.setupGate(entry.id) : undefined; + if (gate) { + // A prompt already queued survives a start that carries none: every + // ungated auto-start path (a row tap, the workspace bootstrap) sends a + // bare `session:start`, and letting one overwrite the create flow's + // prompt loses the only copy the user typed. + gate.pendingStart = { initialPrompt: initialPrompt ?? gate.pendingStart?.initialPrompt }; + this.notifyObservers(); + return; + } if (!entry || entry.checkoutId === "main") return this.startNow(id, initialPrompt); return this.startCheckout(id, initialPrompt, entry.checkoutId); } @@ -1062,6 +1209,340 @@ export class SessionManager { } } + // --- worktree.setup --- + + /** + * Answer the user's `session:setup` verb. + * + * Only two things refuse: an unknown session, and a rerun of a run that is + * still going (a second runner would fight the first for the checkout). + * Everything else is a no-op rather than an error — the app can only send + * these from a view that may be a frame behind the state it is acting on, and + * a cancel that lands just after the run finished asked for the state it + * already has. + */ + async applySetupAction(id: string, action: "skip" | "cancel" | "rerun"): Promise { + const entry = this.entries.get(id); + if (!entry) throw new Error(`session not found: ${id}`); + const setup = this.setups.get(id); + if (action === "rerun") return this.rerunSetup(entry, setup); + if (!setup || setup.state !== "running") return; + if (action === "skip") { + // The run itself is untouched: the banner keeps reporting it, and the + // services it holds back are still its to release when it ends. Skip + // answers "I know the deps are cached", not "stop". + setup.gateReleased = true; + this.notifyObservers(); + this.firePendingStart(id); + return; + } + await this.cancelSetupRun(entry, setup); + } + + /** This session's run while it is still holding starts back, else undefined. */ + private setupGate(sessionId: string): SetupRuntime | undefined { + const setup = this.setups.get(sessionId); + return setup?.state === "running" && !setup.gateReleased ? setup : undefined; + } + + /** Register a fresh run's state. The runner is started separately and + * strictly later; `terminalId` stays unset until the runner reports the PTY + * it actually spawned, so the app never offers a log it cannot replay. */ + private beginSetup( + sessionId: string, + holdsServices: boolean, + pendingStart?: { initialPrompt?: string }, + ): SetupRuntime { + const setup: SetupRuntime = { + runId: this.nextSetupRunId++, + state: "running", + stepIndex: 0, + stepCount: 0, + startedAt: Date.now(), + pendingStart, + // Survives across reruns so a second one can still re-arm the start. + lastQueuedPrompt: pendingStart?.initialPrompt ?? this.setups.get(sessionId)?.lastQueuedPrompt, + gateReleased: false, + holdsServices, + }; + this.setups.set(sessionId, setup); + return setup; + } + + /** A coarse transition from the setup runner: step boundaries and terminal + * states only. Emitted through the IMMEDIATE notifyObservers path, never the + * debounced activity emit — the banner reads "step 2 of 4" from this, and a + * 750 ms coalesce leaves it a step behind. Live output never comes this way; + * it rides the setup terminal's own `terminal:output`. */ + private onSetupProgress(sessionId: string, runId: number, progress: CheckoutSetupProgress): void { + const entry = this.entries.get(sessionId); + const setup = this.setups.get(sessionId); + // The session was deleted, a rerun replaced this run, or a cancel already + // settled it: a killed process's dying report must not reopen a state the + // user has been shown, nor land on its own successor. + if (!entry || !setup || setup.runId !== runId || setup.state !== "running") return; + setup.state = progress.state; + setup.stepIndex = progress.stepIndex; + setup.stepCount = progress.stepCount; + setup.stepName = progress.stepName; + setup.exitCode = progress.exitCode; + setup.message = progress.message; + // Kept when a later report omits it: the transcript stays reachable after + // the run ends, which is the point of the expandable log. + if (progress.terminalId !== undefined) setup.terminalId = progress.terminalId; + if (progress.state === "running") { + this.notifyObservers(); + return; + } + setup.finishedAt = Date.now(); + setup.gateReleased = true; + // Before the tail, not after: the badge and banner must settle on the same + // tick the run ended, while the disk write behind them takes as long as it + // takes. + this.notifyObservers(); + void this.settleSetup(entry, setup); + } + + /** The tail of a finished run: release the services it held back, fire the + * start queued behind it, then stamp the durable marker. In that order — the + * queued agent should find its dev server already coming up, and the disk + * write is the only part nothing is waiting on. */ + private async settleSetup(entry: PersistedEntry, setup: SetupRuntime): Promise { + if (setup.holdsServices) { + setup.holdsServices = false; + try { + await this.opts.startDeferredServices?.(entry.checkoutId); + } catch (err) { + log.warn(`deferred services for checkout ${entry.checkoutId} failed to start: ${err}`); + } + } + this.firePendingStart(entry.id); + await this.stampSetupMarker(entry.checkoutId, setup); + } + + /** Launch the start held behind a run. The gate must already be open — start() + * re-enters it and would queue the start straight back. */ + private firePendingStart(sessionId: string): void { + const setup = this.setups.get(sessionId); + const pending = setup?.pendingStart; + if (!setup || !pending) return; + setup.pendingStart = undefined; + // Only overwritten by a prompt that exists: `lastQueuedPrompt` is what a + // rerun re-arms from, and a promptless start must not empty it. + if (pending.initialPrompt !== undefined) setup.lastQueuedPrompt = pending.initialPrompt; + this.notifyObservers(); + // There is no requestId left to fail: the app was told `ok` the moment the + // start was queued, so a spawn that throws corrects itself through the + // entry's `running` rather than through a reply. + const failed = (err: unknown): void => log.warn(`queued start for session ${sessionId} failed: ${err}`); + try { + const started = this.start(sessionId, pending.initialPrompt); + if (started) started.catch(failed); + } catch (err) { + failed(err); + } + } + + /** Kill a run the user cancelled and settle it as `skipped`. */ + private async cancelSetupRun(entry: PersistedEntry, setup: SetupRuntime): Promise { + // Marked BEFORE the kill, and rather than from the killed run's own dying + // report — that one says `failed`, and a cancel the user asked for is not a + // failure. The ordering is what keeps it: the runner reports back from + // inside `killSetupTree`, and a state still reading `running` would let + // `onSetupProgress` settle the run as failed and run this tail twice. + setup.state = "skipped"; + setup.exitCode = undefined; + setup.message = undefined; + setup.finishedAt = Date.now(); + setup.gateReleased = true; + this.notifyObservers(); + await this.killSetupTree(entry.checkoutId); + // Strictly after the kill: a still-live `bun install` holds the checkout as + // its cwd, and the services released below start inside it. + await this.settleSetup(entry, setup); + } + + /** Kill a live run because the session is going away. Quiet on purpose: no + * durable marker (the checkout is being reclaimed), no deferred services + * (there will be no runtime to serve them) and no queued start (there will be + * no session to start). */ + private async cancelSetupForDelete(entry: PersistedEntry): Promise { + const setup = this.setups.get(entry.id); + if (!setup || setup.state !== "running") return; + // Settled without an emit — the delete flow's own emits cover it. Settled + // BEFORE the kill, because the runner reports back from inside it: a state + // still reading `running` sends the killed run's dying report straight + // through `onSetupProgress` into `settleSetup`, which starts the checkout's + // `services:` inside a worktree `git worktree remove` is about to take and + // stamps a durable marker for a checkout being reclaimed. + setup.state = "skipped"; + setup.finishedAt = Date.now(); + setup.gateReleased = true; + // Dropped rather than fired: there is no session to start. It must not + // SURVIVE either — a delete Git refuses leaves this row alive, and an entry + // still reporting `pendingStart` is one the app's bootstrap never + // auto-starts again. The prompt stays in `lastQueuedPrompt` for a rerun. + setup.pendingStart = undefined; + await this.killSetupTree(entry.checkoutId); + } + + /** Hand back the `services:` a settled run is still holding. `settleSetup` is + * the usual releaser; this is for the run that never reaches it — cancelled + * for a delete Git then refused, which leaves the session, its runtime and + * its deferral alive with nothing left able to clear them. */ + private async releaseSetupHold(entry: PersistedEntry): Promise { + const setup = this.setups.get(entry.id); + if (!setup?.holdsServices) return; + setup.holdsServices = false; + try { + await this.opts.startDeferredServices?.(entry.checkoutId); + } catch (err) { + log.warn(`deferred services for checkout ${entry.checkoutId} failed to start: ${err}`); + } + } + + /** Awaited, never fatal: until the kill has walked the tree a `bun install` + * still holds the checkout as its cwd, which is what makes `git worktree + * remove` fail on Windows. A cancel that could not land must not become a + * refusal — the caller is either deleting the session or has already been + * told the run is over. */ + private async killSetupTree(checkoutId: string): Promise { + try { + await this.opts.cancelCheckoutSetup?.(checkoutId); + } catch (err) { + log.warn(`cancelling setup for checkout ${checkoutId} failed: ${err}`); + } + } + + /** Start a fresh run against the current config, resetting the transcript. */ + private async rerunSetup(entry: PersistedEntry, previous: SetupRuntime | undefined): Promise { + if (previous?.state === "running") throw new Error("workspace setup is already running"); + if (!isManagedCheckoutKind(entry.checkoutKind)) { + throw new Error(`session has no managed workspace to set up: ${entry.id}`); + } + if (this.deleting.has(entry.id)) { + throw new WorktreeError("WORKTREE_DELETE_IN_PROGRESS", "This isolated session is being deleted."); + } + const run = this.opts.runCheckoutSetup; + if (!run) throw new Error("this bridge cannot run workspace setup"); + // `resolveCheckout`, not the manager's bare `recordFor`: it is the wrapper + // that warms the checkout runtime, and without a warm one + // `registerSetupTerminal` no-ops — the transcript then routes to main and + // its retained scrollback outlives every sweep able to release it. A rerun + // off a recovered `interrupted` state is exactly that cold case. + const checkout = await this.opts.resolveCheckout?.(entry.checkoutId) + ?? await this.opts.worktreeManager?.recordFor(this.opts.projectId, entry.checkoutId); + if (!checkout) throw new WorktreeError("WORKTREE_MISSING", "The isolated worktree is no longer available."); + // Cleared BEFORE the run rather than overwritten after it: a bridge that + // dies mid-rerun must come back `interrupted`, and the previous run's `done` + // would claim otherwise. + await this.stampSetupMarker(entry.checkoutId, undefined); + // The prompt carries over so re-run-setup → restart-agent does not make the + // user retype it. The gate does not: a fresh run gates afresh, and the + // release the user gave the previous one said nothing about this one. An + // agent already launched keeps running — the gate only holds new starts. + // Only when the agent is stopped: a live agent already received this prompt, + // and re-arming would deliver it twice. + const requeue = previous?.lastQueuedPrompt !== undefined && !this.isRunning(entry) + ? { initialPrompt: previous.lastQueuedPrompt } + : undefined; + const setup = this.beginSetup(entry.id, false, requeue); + this.notifyObservers(); + run(checkout, entry.id, (progress) => this.onSetupProgress(entry.id, setup.runId, progress)); + } + + /** Record how a run ENDED, in the project's checkouts.json. `running` can + * never be written and neither can `interrupted`, which is only ever derived + * from a marker's absence: a bridge that dies mid-run must come back + * interrupted rather than permanently preparing. Passing no outcome clears + * the marker, which is how a rerun says the last one no longer describes this + * checkout. */ + private async stampSetupMarker( + checkoutId: string, + outcome?: { state: SetupState; finishedAt?: number; exitCode?: number }, + ): Promise { + const durable: DurableSetupState | undefined = DURABLE_SETUP_STATES.find((state) => state === outcome?.state); + if (outcome && !durable) return; + try { + // update(), never get()-then-put(): the row may be reclaimed while the run + // is finishing, and a read-modify-write spanning two lock acquisitions + // would resurrect a checkout whose directory Git has already removed. + await this.checkoutStore().update(checkoutId, (record) => ({ + ...record, + setupState: durable, + setupFinishedAt: durable ? (outcome?.finishedAt ?? Date.now()) : undefined, + setupExitCode: durable ? outcome?.exitCode : undefined, + })); + } catch (err) { + log.warn(`could not record the setup outcome for checkout ${checkoutId}: ${err}`); + } + } + + /** Report where setup left off for the managed checkouts this bridge just + * inherited. A marker is the outcome of a finished run; its absence means the + * bridge died mid-run, which is `interrupted`. Deliberately NOT an automatic + * rerun: a setup step can be expensive or destructive and the user did not + * ask for one on this launch. + * + * Fire-and-forget off the constructor — the read is async and the session + * list is usable without it, so it lands as one extra session:updated instead + * of blocking every project's load. */ + private async recoverSetupStates(): Promise { + const managed = Array.from(this.entries.values()) + .filter((entry) => isManagedCheckoutKind(entry.checkoutKind)); + if (managed.length === 0) return; + let records: CheckoutRecord[]; + try { + records = await this.checkoutStore().list(); + } catch (err) { + log.warn("checkout setup markers unreadable: %s", err); + return; + } + const byCheckout = new Map(records.map((record) => [record.id, record])); + let recovered = false; + for (const entry of managed) { + // A run started while this read was in flight owns the slot. + if (this.setups.has(entry.id)) continue; + const record = byCheckout.get(entry.checkoutId); + // No marker AND nothing to have run: this checkout predates the project's + // setup block (or the feature itself), so there was never a run to + // interrupt. Reporting one would banner every isolated session that + // existed before the upgrade. + // No record at all is the same answer: an unreadable or truncated + // checkouts.json must not banner every isolated session in the project as + // `interrupted`, with a "Run setup" button `rerunSetup` can only answer + // with WORKTREE_MISSING. + if (!record) continue; + if (!record.setupState && this.opts.checkoutDeclaresSetup?.(record) === false) continue; + this.setups.set(entry.id, { + // No runner behind a recovered state, so no report may ever land on it. + runId: 0, + state: record?.setupState ?? "interrupted", + // The step counts died with the run; only its outcome was durable. + stepIndex: 0, + stepCount: 0, + exitCode: record?.setupExitCode, + // No terminal either: the transcript died with the PTY that wrote it, + // so the app must not offer a log it cannot replay. + startedAt: entry.createdAt, + finishedAt: record?.setupFinishedAt, + gateReleased: true, + holdsServices: false, + }); + recovered = true; + } + if (recovered) this.notifyObservers(); + } + + /** The project's durable checkout metadata. Minted per call rather than held: + * CheckoutStore serializes its read-modify-write against every other holder + * of the same path through a static, path-keyed lock, so a cached instance + * would buy nothing and would only race WorktreeManager's own. `storeDir` is + * the ~/.antgrid root the manager derives its store from too. */ + private checkoutStore(): CheckoutStore { + return new CheckoutStore(this.opts.storeDir, this.opts.projectId); + } + private startNow(id: string, initialPrompt?: string, checkoutPath = this.projectPath, sessionAgentSpec = this.agentSpec): void { const entry = this.entries.get(id); if (!entry) throw new Error(`session not found: ${id}`); @@ -1500,6 +1981,27 @@ export class SessionManager { checkoutKind: e.checkoutKind, checkoutBranch: e.checkoutBranch, checkoutState: e.checkoutState, + setup: this.setupWire(e.id), + }; + } + + /** The wire view of a session's `worktree.setup` run, absent for a session + * that has none. `pendingStart` is derived rather than carried: the queue + * itself lives in memory here, and the entry only reports that it exists. */ + private setupWire(sessionId: string): SessionEntry["setup"] { + const setup = this.setups.get(sessionId); + if (!setup) return undefined; + return { + state: setup.state, + stepIndex: setup.stepIndex, + stepCount: setup.stepCount, + stepName: setup.stepName, + terminalId: setup.terminalId, + exitCode: setup.exitCode, + message: setup.message, + pendingStart: setup.pendingStart !== undefined, + startedAt: setup.startedAt, + finishedAt: setup.finishedAt, }; } diff --git a/bridge/src/terminal-manager.ts b/bridge/src/terminal-manager.ts index 9641ec87..e9b5abf5 100644 --- a/bridge/src/terminal-manager.ts +++ b/bridge/src/terminal-manager.ts @@ -19,6 +19,13 @@ export interface TerminalSpawnConfig { suppressOscNotifications?: boolean; suppressOscTitle?: boolean; hookAliveProbeAgent?: string; + /** Keep this terminal's scrollback replayable after the process exits. + * For a transcript whose whole value is what it said — a `worktree.setup` + * run, where the log of the step that failed is the only explanation the + * user gets, and they read it after the run, not during. Everything else + * drops its buffer on exit so a long-lived host does not accumulate the + * output of terminals nobody can reattach to. `forget` releases it. */ + retainScrollbackOnExit?: boolean; } interface StoppedTerminalInfo { @@ -43,6 +50,9 @@ export class TerminalManager { private terminalTypes = new Map(); /** Metadata for exited terminals so they remain visible in status. */ private stoppedTerminals = new Map(); + /** Terminals whose scrollback survives their own exit — see + * `retainScrollbackOnExit`. */ + private retainScrollback = new Set(); private sendMessage: (msg: AbMessage) => void; private callbacks: TerminalManagerCallbacks; private connState: ConnState; @@ -88,6 +98,8 @@ export class TerminalManager { // Clear from stopped list since we're re-spawning this.stoppedTerminals.delete(terminalId); + if (config.retainScrollbackOnExit) this.retainScrollback.add(terminalId); + else this.retainScrollback.delete(terminalId); const scrollback = new ScrollbackBuffer(); this.scrollbacks.set(terminalId, scrollback); @@ -156,8 +168,10 @@ export class TerminalManager { rows: session.rows, }); this.sessions.delete(terminalId); - this.scrollbacks.delete(terminalId); - this.modeTrackers.delete(terminalId); + if (!this.retainScrollback.has(terminalId)) { + this.scrollbacks.delete(terminalId); + this.modeTrackers.delete(terminalId); + } this.connState.clearTerminal(terminalId); this.callbacks.onTerminalExited?.(terminalId); return; @@ -223,10 +237,28 @@ export class TerminalManager { } this.sessions.clear(); this.scrollbacks.clear(); + this.retainScrollback.clear(); this.terminalTypes.clear(); this.stoppedTerminals.clear(); } + /** + * Drop everything remembered about a terminal that will never come back. + * + * The counterpart to `retainScrollbackOnExit`: retention has no expiry of its + * own, so the site that knows the terminal's owner is gone — a checkout being + * torn down — has to say so. Also clears the retention flag, so an exit that + * lands after this call takes the ordinary drop-on-exit path instead of + * re-retaining a buffer nobody can reach. + */ + forget(terminalId: string): void { + this.retainScrollback.delete(terminalId); + this.scrollbacks.delete(terminalId); + this.modeTrackers.delete(terminalId); + this.stoppedTerminals.delete(terminalId); + this.terminalTypes.delete(terminalId); + } + async killAllGracefully(timeoutMs = 5000): Promise { const all = [...this.sessions.values()]; const count = all.length; diff --git a/bridge/src/worktrees/checkout-setup.ts b/bridge/src/worktrees/checkout-setup.ts new file mode 100644 index 00000000..db9ba312 --- /dev/null +++ b/bridge/src/worktrees/checkout-setup.ts @@ -0,0 +1,574 @@ +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, parse, resolve } from "node:path"; +import { z } from "zod"; +import { resolveAbDir } from "../antgrid-dir"; +import { findConfigFile, loadConfig, resolveVariables, type ResolveContext, type WorktreeSetup } from "../config"; +import { logger } from "../logger"; +import type { TerminalManager } from "../terminal-manager"; +import type { CheckoutRecord, CheckoutSetupProgress } from "./checkout-types"; +import { pathBelow } from "./path-guard"; + +const log = logger.child({ component: "checkout-setup" }); + +/** Budget for the WHOLE run, not per step. Ten minutes is what a cold + * `bun install` plus a Prisma generate costs on a slow laptop; anything past + * it is a wedged step, not a slow one. */ +export const DEFAULT_SETUP_TIMEOUT_MS = 600_000; + +/** How the child announces step boundaries, riding the PTY's OSC 2 title so it + * travels the same path the agent's own titles do. */ +export const SETUP_OSC_PREFIX = "antgrid-setup:"; + +const SETUP_MARKER_RE = /^antgrid-setup:(\d+)\/(\d+):([\s\S]*)$/; + +/** The setup transcript's terminal id. Namespaced exactly like a configured + * slot (`:`) so teardown's `configuredTerminalIds` sweep and + * the app's per-checkout routing both already understand it. */ +export function setupTerminalId(checkoutId: string): string { + return `${checkoutId}:setup`; +} + +/** Control bytes would terminate the OSC string early and split the marker + * across two titles, so a step name carrying one is scrubbed rather than + * refused — the name is cosmetic and the progress line must survive it. */ +function oscSafe(value: string): string { + return value.replace(/[\u0000-\u001f\u007f]/g, " "); +} + +export function formatSetupStepMarker(index: number, count: number, name: string): string { + return `\x1b]2;${SETUP_OSC_PREFIX}${index}/${count}:${oscSafe(name)}\x07`; +} + +export interface SetupStepMarker { + index: number; + count: number; + name: string; +} + +export function parseSetupStepMarker(title: string): SetupStepMarker | null { + const m = SETUP_MARKER_RE.exec(title); + if (!m) return null; + return { index: Number(m[1]), count: Number(m[2]), name: m[3] }; +} + +/** One copy pair, both sides already absolute and already proven to sit inside + * their own root. `rel` exists only so the transcript can name the file the + * way the config did. */ +export const SetupPlanCopySchema = z.object({ + rel: z.string(), + from: z.string(), + to: z.string(), +}).strict(); + +export const SetupPlanStepSchema = z.object({ + name: z.string(), + copy: z.array(SetupPlanCopySchema).optional(), + run: z.string().optional(), + /** Absolute; the child never re-resolves it against its own cwd. */ + workingDir: z.string(), + env: z.record(z.string(), z.string()).optional(), +}).strict(); + +/** + * The fully resolved hand-off from runner to child. Every path in it is + * absolute and every `${...}` is already interpolated: the child re-reads + * nothing from `antgrid.yaml`, so the path guards below cannot be bypassed by + * a config that changes between plan and run. + */ +export const SetupPlanFileSchema = z.object({ + version: z.literal(1), + checkoutId: z.string(), + checkoutPath: z.string(), + projectPath: z.string(), + sessionId: z.string(), + /** Where the child writes its outcome. The PTY exit callback carries no exit + * code, so this file is the only channel for one. */ + resultPath: z.string(), + /** The `ANTGRID_*` contract, applied to every `run` step's environment. */ + env: z.record(z.string(), z.string()), + steps: z.array(SetupPlanStepSchema), +}).strict(); + +export type SetupPlanCopy = z.infer; +export type SetupPlanStep = z.infer; +export type SetupPlanFile = z.infer; + +export const SetupResultFileSchema = z.object({ + exitCode: z.number().int(), + stepIndex: z.number().int().nonnegative().optional(), + stepName: z.string().optional(), + message: z.string().optional(), +}).strict(); + +export type SetupResultFile = z.infer; + +/** A `worktree.setup` block that cannot be turned into a plan. Distinct from a + * step that fails at runtime: nothing is spawned and the transcript stays + * empty, so the message is all the user gets. */ +export class SetupConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "SetupConfigError"; + } +} + +/** Only what the runner asks of the terminal layer, so a test can stand in for + * it without building a PTY. */ +export type SetupTerminalHost = Pick; + +export interface CheckoutSetupRunnerOptions { + /** The MAIN project root. `copy` sources resolve against it and never + * against the checkout — the whole point is pulling in files the worktree + * does not have. */ + projectPath: string; + terminals: SetupTerminalHost; + /** Where plan/result files are staged. Outside the checkout on purpose: the + * worktree is the user's branch and a stray JSON file there shows up as an + * untracked change. */ + planDir?: string; + /** Test seams for the self-spawn. Production reads `process.execPath` and + * the same `ANTGRID_BRIDGE_COMPILED` flag `resolveHookCommand` uses. */ + execPath?: string; + entrypoint?: string; + compiled?: boolean; +} + +interface ActiveRun { + checkoutId: string; + terminalId: string; + stepCount: number; + stepIndex: number; + stepName?: string; + planPath: string; + resultPath: string; + timeoutMs: number; + timer: ReturnType | null; + /** Why WE killed the tree, when we did. Absent means the child exited on its + * own and its exit code decides the outcome. */ + killed: "cancelled" | "timeout" | null; + finished: boolean; + onProgress: (progress: CheckoutSetupProgress) => void; +} + +/** Collapse to something that fits a one-line banner. */ +function oneLine(value: string): string { + const flat = value.replace(/\s+/g, " ").trim(); + return flat.length > 200 ? `${flat.slice(0, 199)}…` : flat; +} + +/** + * Runs a managed checkout's `worktree.setup` block in one PTY and reports + * coarse transitions back to the session manager. + * + * The transcript is a real terminal rather than synthetic output because a + * four-minute `bun install` is mostly colour and progress bars, and those only + * exist when the child believes it has a TTY. What that PTY runs is the bridge + * itself under a hidden subcommand — the shipped bridge is a compiled + * single-file executable, so `process.execPath` plus a subcommand is the only + * self-invocation that works, and it is the same shape `resolveHookCommand` + * already relies on. + */ +export class CheckoutSetupRunner { + private readonly runs = new Map(); + private readonly planDir: string; + + constructor(private readonly opts: CheckoutSetupRunnerOptions) { + this.planDir = opts.planDir ?? join(resolveAbDir(), "setup"); + } + + /** + * The checkout's own `worktree.setup`, or null when it has none. + * + * `findConfigFile` falls back to `/antgrid.yaml`, and a + * machine-global setup block would then run for every project's worktrees + * without anyone having asked — so a block is honoured only when the file it + * came from physically lives in the checkout. + * + * @throws SetupConfigError when the checkout's antgrid.yaml does not parse. + */ + resolveSetup(checkout: CheckoutRecord): WorktreeSetup | null { + const found = findConfigFile(checkout.path); + if (!found || resolve(found) !== resolve(join(checkout.path, "antgrid.yaml"))) return null; + let setup: WorktreeSetup | undefined; + try { + setup = loadConfig(undefined, checkout.path).worktree?.setup; + } catch (err) { + throw new SetupConfigError(oneLine((err as Error).message)); + } + return setup ?? null; + } + + /** True while a setup run for this checkout is live. */ + isRunning(checkoutId: string): boolean { + return this.runs.has(setupTerminalId(checkoutId)); + } + + /** True when this PTY is a live setup transcript, so its output and titles + * belong to the runner rather than to the session namer. */ + owns(terminalId: string): boolean { + return this.runs.has(terminalId); + } + + /** + * Kick off setup. Returns immediately and never throws: `createWorktree` has + * already replied to the client by the time this runs, so the only way a + * failure can reach anyone is through `onProgress`. + * + * A checkout with no `worktree.setup` reports `done` with `stepCount: 0` + * rather than staying silent — a caller that stamped `running` before + * calling has to be released either way. + */ + start( + checkout: CheckoutRecord, + sessionId: string, + onProgress: (progress: CheckoutSetupProgress) => void, + ): void { + void this.begin(checkout, sessionId, onProgress).catch((err) => { + log.warn(`setup for checkout ${checkout.id} could not start: ${(err as Error).message}`); + onProgress({ + state: "failed", + stepIndex: 0, + stepCount: 0, + message: oneLine((err as Error).message), + }); + }); + } + + /** + * Kill the run's process tree and wait for it to be gone. Awaited before + * `git worktree remove`: on Windows a live `bun install` holding the checkout + * as its cwd makes the remove fail. + */ + async cancel(checkoutId: string): Promise { + const run = this.runs.get(setupTerminalId(checkoutId)); + if (!run) return; + await this.killRun(run, "cancelled"); + } + + /** + * Feed a PTY title. Returns true when the terminal is a setup transcript, in + * which case the caller must NOT fall through to the session namer — every + * title on this PTY is progress, not a conversation name. + */ + handleTitle(terminalId: string, title: string): boolean { + const run = this.runs.get(terminalId); + if (!run) return false; + const marker = parseSetupStepMarker(title); + if (!marker) return true; + run.stepIndex = marker.index; + run.stepName = marker.name; + run.onProgress({ + state: "running", + stepIndex: run.stepIndex, + stepCount: run.stepCount, + stepName: run.stepName, + terminalId, + }); + return true; + } + + /** Feed a PTY exit. Returns true when the runner owned the terminal. */ + handleExit(terminalId: string): boolean { + const run = this.runs.get(terminalId); + if (!run) return false; + this.finish(run); + return true; + } + + private async begin( + checkout: CheckoutRecord, + sessionId: string, + onProgress: (progress: CheckoutSetupProgress) => void, + ): Promise { + const terminalId = setupTerminalId(checkout.id); + // A rerun issued while the previous attempt is still alive would otherwise + // leave two children writing the same result file. + const previous = this.runs.get(terminalId); + if (previous) await this.killRun(previous, "cancelled"); + + const setup = this.resolveSetup(checkout); + if (!setup || setup.steps.length === 0) { + onProgress({ state: "done", stepIndex: 0, stepCount: 0 }); + return; + } + + const env = setupEnv(this.opts.projectPath, checkout, sessionId); + const planPath = join(this.planDir, `${checkout.id}.plan.json`); + const resultPath = join(this.planDir, `${checkout.id}.result.json`); + const plan = this.buildPlan(setup, checkout, sessionId, resultPath, env); + + mkdirSync(this.planDir, { recursive: true }); + rmSync(resultPath, { force: true }); + writeFileSync(planPath, JSON.stringify(plan, null, 2), "utf8"); + + const run: ActiveRun = { + checkoutId: checkout.id, + terminalId, + stepCount: plan.steps.length, + stepIndex: 0, + stepName: plan.steps[0]?.name, + planPath, + resultPath, + timeoutMs: clampTimeout(setup.timeoutMs ?? DEFAULT_SETUP_TIMEOUT_MS), + timer: null, + killed: null, + finished: false, + onProgress, + }; + // Registered before the spawn: a title can land on the very first chunk. + this.runs.set(terminalId, run); + // `.catch` for the same reason `start()` has one: bridge/src/index.ts turns + // an unhandled rejection into a whole-host shutdown, and everything + // `killRun` reaches — the PTY kill, `onProgress`, the push seal — can throw. + run.timer = setTimeout(() => { + void this.killRun(run, "timeout").catch((err) => { + log.warn(`setup timeout handling for ${run.checkoutId} failed: ${(err as Error).message}`); + }); + }, run.timeoutMs); + onProgress({ + state: "running", + stepIndex: 0, + stepCount: run.stepCount, + stepName: run.stepName, + terminalId, + }); + + const command = this.selfCommand(planPath); + try { + this.opts.terminals.spawn({ + terminalId, + name: "setup", + command: command.binary, + args: command.args, + cwd: checkout.path, + env, + // No `type`: this is neither an agent nor a service, and typing it + // `service` would put a provisioning log in the services list. + // + // `suppressOscTitle` is deliberately NOT set — that flag suppresses the + // onTitle callback itself, which is the channel every step transition + // travels on. + suppressOscNotifications: true, + // The log is read AFTER the run at least as often as during it: a + // failed step's output is the only explanation the banner's one-liner + // does not carry, and the user expands it once the banner turns red. + retainScrollbackOnExit: true, + }); + } catch (err) { + this.runs.delete(terminalId); + if (run.timer) clearTimeout(run.timer); + this.cleanupFiles(run); + throw err; + } + } + + private buildPlan( + setup: WorktreeSetup, + checkout: CheckoutRecord, + sessionId: string, + resultPath: string, + env: Record, + ): SetupPlanFile { + const projectPath = resolve(this.opts.projectPath); + const checkoutPath = resolve(checkout.path); + const ctx: ResolveContext = { + projectPath, + checkoutPath, + checkoutBranch: checkout.branch ?? undefined, + baseBranch: checkout.baseRef ?? undefined, + sessionId, + }; + + const steps: SetupPlanStep[] = setup.steps.map((step) => { + const workingDirRaw = step.workingDir ? resolveVariables(step.workingDir, ctx) : undefined; + return { + name: step.name, + ...(step.copy ? { copy: step.copy.map((entry) => planCopy(entry, ctx, step.name, projectPath, checkoutPath)) } : {}), + ...(step.run ? { run: resolveVariables(step.run, ctx) } : {}), + workingDir: planWorkingDir(workingDirRaw, step.name, checkoutPath), + ...(step.env + ? { env: Object.fromEntries(Object.entries(step.env).map(([k, v]) => [k, resolveVariables(v, ctx)])) } + : {}), + }; + }); + + return { + version: 1, + checkoutId: checkout.id, + checkoutPath, + projectPath, + sessionId, + resultPath, + env, + steps, + }; + } + + private selfCommand(planPath: string): { binary: string; args: string[] } { + const binary = this.opts.execPath ?? process.execPath; + const compiled = this.opts.compiled ?? process.env.ANTGRID_BRIDGE_COMPILED === "1"; + // Uncompiled, `process.execPath` is bun itself, so the entrypoint has to be + // named before the subcommand — same split `resolveHookCommand` makes. + const preargs = compiled ? [] : [this.opts.entrypoint ?? Bun.main]; + return { binary, args: [...preargs, "worktree-setup", "--plan", planPath] }; + } + + private async killRun(run: ActiveRun, reason: "cancelled" | "timeout"): Promise { + if (run.finished) return; + run.killed = reason; + if (run.timer) { clearTimeout(run.timer); run.timer = null; } + await this.opts.terminals.killAndAwaitTree(run.terminalId); + // The PTY's exit normally lands on handleExit; finish here too so a + // terminal layer that never reports one cannot leave the run pending. + this.finish(run); + } + + private finish(run: ActiveRun): void { + if (run.finished) return; + run.finished = true; + if (run.timer) { clearTimeout(run.timer); run.timer = null; } + this.runs.delete(run.terminalId); + + const result = readResult(run.resultPath); + this.cleanupFiles(run); + + const stepIndex = result?.stepIndex ?? run.stepIndex; + const stepName = result?.stepName ?? run.stepName; + const base = { + stepIndex, + stepCount: run.stepCount, + stepName, + terminalId: run.terminalId, + }; + + if (run.killed === "cancelled") { + run.onProgress({ ...base, state: "skipped", message: "Setup cancelled" }); + return; + } + if (run.killed === "timeout") { + run.onProgress({ + ...base, + state: "failed", + message: `Setup timed out after ${Math.round(run.timeoutMs / 1000)}s`, + }); + return; + } + // No result file means the child died before it could write one — a crash, + // not a clean non-zero exit, so there is no code to report either. + if (!result) { + run.onProgress({ ...base, state: "failed", message: "Setup ended without reporting a result" }); + return; + } + if (result.exitCode === 0) { + run.onProgress({ ...base, state: "done", exitCode: 0 }); + return; + } + run.onProgress({ + ...base, + state: "failed", + exitCode: result.exitCode, + message: result.message ?? `Setup failed with exit code ${result.exitCode}`, + }); + } + + private cleanupFiles(run: ActiveRun): void { + try { + rmSync(run.planPath, { force: true }); + rmSync(run.resultPath, { force: true }); + } catch { + // Stale staging files are harmless; the next run overwrites them. + } + } +} + +/** The `ANTGRID_*` contract every `run` step is promised. */ +function setupEnv(projectPath: string, checkout: CheckoutRecord, sessionId: string): Record { + return { + ANTGRID_PROJECT_PATH: resolve(projectPath), + ANTGRID_CHECKOUT_PATH: resolve(checkout.path), + ANTGRID_CHECKOUT_BRANCH: checkout.branch ?? "", + ANTGRID_BASE_BRANCH: checkout.baseRef ?? "", + ANTGRID_SESSION_ID: sessionId, + ANTGRID_SETUP: "1", + }; +} + +/** + * Turn one `copy:` entry into an absolute source/destination pair. + * + * The source is read from the MAIN project and the destination keeps the same + * relative path inside the checkout, so both sides must be proven to stay under + * their own root: `copy: ["../../.ssh/id_ed25519"]` would otherwise read + * outside the project and write outside the worktree, and a checkout's + * `antgrid.yaml` is branch-supplied content. An escape is a config error and + * refuses the whole run rather than skipping the entry — a setup that silently + * dropped a step would be worse than one that says why it will not start. + */ +function planCopy( + raw: string, + ctx: ResolveContext, + stepName: string, + projectPath: string, + checkoutPath: string, +): SetupPlanCopy { + const rel = resolveVariables(raw, ctx); + // `parse().root`, not `isAbsolute()`: the drive-RELATIVE spelling `C:foo` is + // not absolute, yet `resolve()` anchors it to that drive's own cwd rather + // than to the base handed in, so it lands somewhere unrelated to either root. + if (parse(rel).root !== "") { + throw new SetupConfigError(`step "${stepName}": copy path "${rel}" must be relative to the project root`); + } + const from = resolve(projectPath, rel); + const to = resolve(checkoutPath, rel); + if (!pathBelow(projectPath, from) || !pathBelow(checkoutPath, to)) { + throw new SetupConfigError(`step "${stepName}": copy path "${rel}" escapes the project root`); + } + return { rel, from, to }; +} + +/** + * A step's cwd, proven to stay inside the checkout it is provisioning. + * + * `resolve()` DISCARDS its base when the second argument is absolute, so an + * unguarded `workingDir: "${project.path}"` would run the step in the main tree + * and report a green banner over a worktree nothing was installed into. The + * root itself is allowed here (unlike `copy`, where equality means "overwrite + * the whole tree"): `workingDir: "."` is the default spelled out. + */ +function planWorkingDir(raw: string | undefined, stepName: string, checkoutPath: string): string { + if (!raw) return checkoutPath; + const dir = resolve(checkoutPath, raw); + if (dir !== checkoutPath && !pathBelow(checkoutPath, dir)) { + throw new SetupConfigError(`step "${stepName}": workingDir "${raw}" escapes the checkout`); + } + return dir; +} + +/** `setTimeout` silently collapses a delay above the 32-bit signed ceiling to + * 1 ms, so an over-large `timeoutMs` would kill the run on its first tick and + * report it as a timeout. Clamped rather than refused: the intent of a huge + * budget is "do not time out", and the ceiling is ~24.8 days. */ +function clampTimeout(ms: number): number { + return Math.min(ms, 0x7fffffff); +} + +function readResult(path: string): SetupResultFile | null { + let raw: string; + try { + raw = readFileSync(path, "utf8"); + } catch { + return null; + } + try { + return SetupResultFileSchema.parse(JSON.parse(raw)); + } catch (err) { + log.warn(`unreadable setup result at ${path}: ${(err as Error).message}`); + return null; + } +} + +/** Exported for the child, which writes the file this runner reads. */ +export function writeSetupResult(path: string, result: SetupResultFile): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(result), "utf8"); +} diff --git a/bridge/src/worktrees/checkout-store.ts b/bridge/src/worktrees/checkout-store.ts index 3478a527..11151836 100644 --- a/bridge/src/worktrees/checkout-store.ts +++ b/bridge/src/worktrees/checkout-store.ts @@ -1,7 +1,7 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { z } from "zod"; -import { CHECKOUT_KINDS, type CheckoutRecord } from "./checkout-types"; +import { CHECKOUT_KINDS, DURABLE_SETUP_STATES, type CheckoutRecord } from "./checkout-types"; const RecordSchema = z.object({ id: z.string().min(1), @@ -13,6 +13,9 @@ const RecordSchema = z.object({ managed: z.boolean(), sessionId: z.string().nullable(), createdAt: z.number().finite(), + setupState: z.enum(DURABLE_SETUP_STATES).optional(), + setupFinishedAt: z.number().finite().optional(), + setupExitCode: z.number().int().optional(), }); const FileSchema = z.object({ version: z.literal(1), checkouts: z.array(z.unknown()) }); @@ -77,6 +80,29 @@ export class CheckoutStore { [...records.filter((item) => item.id !== record.id), RecordSchema.parse(record)]); } + /** + * Rewrite one row from its current value, under the same lock the write takes. + * + * The only safe way to annotate a row a caller does not own outright: a + * `get()` followed by a `put()` spans two lock acquisitions, so a `remove()` + * landing between them is undone — the put RESURRECTS a checkout whose + * directory Git has already deleted. Returns false when the row is gone, which + * is the annotation quietly dropping rather than a failure. + */ + async update(id: string, patch: (record: CheckoutRecord) => CheckoutRecord): Promise { + let applied = false; + await this.mutate((records) => { + const current = records.find((item) => item.id === id); + if (!current) return null; + const next = RecordSchema.parse(patch(current)); + if (next.id !== id) throw new Error("checkout id mismatch"); + if (next.projectId !== this.projectId) throw new Error("checkout projectId mismatch"); + applied = true; + return [...records.filter((item) => item.id !== id), next]; + }); + return applied; + } + async remove(id: string): Promise { let removed = false; await this.mutate((records) => { diff --git a/bridge/src/worktrees/checkout-types.ts b/bridge/src/worktrees/checkout-types.ts index e8615089..8d5483c9 100644 --- a/bridge/src/worktrees/checkout-types.ts +++ b/bridge/src/worktrees/checkout-types.ts @@ -4,6 +4,37 @@ export type CheckoutKind = (typeof CHECKOUT_KINDS)[number]; export const CHECKOUT_STATES = ["ready", "missing", "failed"] as const; export type CheckoutState = (typeof CHECKOUT_STATES)[number]; +/** How far `worktree.setup` has got for a managed checkout. Deliberately a + * separate vocabulary from CHECKOUT_STATES: that one answers "is this + * workspace usable" and stays `ready` throughout a setup run, while this + * answers "has provisioning finished". `interrupted` is what a checkout with a + * durable marker missing and no live runner reports after a bridge restart. */ +export const SETUP_STATES = ["running", "done", "failed", "skipped", "interrupted"] as const; +export type SetupState = (typeof SETUP_STATES)[number]; + +/** The subset that may be written to checkouts.json. `running` is absent by + * design — a bridge that dies mid-setup would otherwise leave a row that is + * permanently preparing, with nothing alive to ever clear it. */ +export const DURABLE_SETUP_STATES = ["done", "failed", "skipped"] as const; +export type DurableSetupState = (typeof DURABLE_SETUP_STATES)[number]; + +/** A coarse transition reported by the setup runner: step boundaries and + * terminal states only. Live output never travels this way — it rides the + * setup terminal's own `terminal:output`, which already has batching, + * scrollback and focus gating. `startedAt` / `finishedAt` / `pendingStart` on + * the wire entry are the session manager's to stamp, not the runner's. */ +export interface CheckoutSetupProgress { + state: SetupState; + /** 0-based, the current step while running and the last one afterwards. */ + stepIndex: number; + stepCount: number; + stepName?: string; + terminalId?: string; + exitCode?: number; + /** One-line failure summary. */ + message?: string; +} + /** Antgrid created this checkout's directory and is the only thing that may * remove it — so a session that owns one must reclaim it on delete. */ export function isManagedCheckoutKind(kind: CheckoutKind): boolean { @@ -36,4 +67,11 @@ export interface CheckoutRecord { managed: boolean; sessionId: string | null; createdAt: number; + /** How `worktree.setup` last ENDED for this checkout, absent until it has. + * A managed checkout with no marker and no live runner is `interrupted`, not + * `running`: the enum has no running member on purpose, so a bridge killed + * mid-setup can never leave a row that is permanently preparing. */ + setupState?: DurableSetupState; + setupFinishedAt?: number; + setupExitCode?: number; } diff --git a/bridge/src/worktrees/path-guard.ts b/bridge/src/worktrees/path-guard.ts new file mode 100644 index 00000000..cdf86afe --- /dev/null +++ b/bridge/src/worktrees/path-guard.ts @@ -0,0 +1,26 @@ +import { isAbsolute, relative, sep } from "node:path"; + +/** + * True when `target` is a strict descendant of `root`. + * + * The guard every path Antgrid derives from client- or branch-supplied text + * must pass before it is read, written or removed. Equality is deliberately + * NOT below: a caller that accepted the root itself would let `copy: ["."]` + * overwrite a whole tree, and every caller here means "somewhere inside". + * + * Lives on its own because both the worktree lifecycle and the setup runner + * need it and a second copy drifting from this one is exactly the class of bug + * the check exists to prevent. + */ +export function pathBelow(root: string, target: string): boolean { + const rel = relative(root, target); + // An ABSOLUTE relative path is `relative()` reporting that no walk connects + // the two: on Windows that is every cross-root pair — another drive letter, + // a drive-relative spelling like `C:foo` that resolved elsewhere, or a UNC + // share — and the `..` tests below all pass for one, so an escape would read + // as "inside". Measured: `relative("D:\proj", "C:\Windows")` is + // `"C:\Windows"`. Comparing parsed roots instead would reject a pair that + // differs only in drive-letter case; this does not. + if (rel === "" || isAbsolute(rel)) return false; + return rel !== ".." && !rel.startsWith(`..${sep}`) && !rel.includes(`${sep}..${sep}`); +} diff --git a/bridge/src/worktrees/worktree-manager.ts b/bridge/src/worktrees/worktree-manager.ts index e901bd9f..787d25e6 100644 --- a/bridge/src/worktrees/worktree-manager.ts +++ b/bridge/src/worktrees/worktree-manager.ts @@ -1,12 +1,13 @@ import { existsSync, realpathSync, statSync } from "node:fs"; import { readdir, rm, rmdir } from "node:fs/promises"; -import { join, relative, resolve, sep } from "node:path"; +import { join, resolve } from "node:path"; import { resolveAbDir } from "../antgrid-dir"; import { branchSlug, checkoutDirName, projectRootName, sessionWords } from "./checkout-names"; import { readCheckoutOwner, sameRepository } from "./checkout-owner"; import { CheckoutStore } from "./checkout-store"; import { isManagedCheckoutKind, type CheckoutRecord } from "./checkout-types"; import { parseWorktreeList } from "./git-worktree-list"; +import { pathBelow } from "./path-guard"; import { runGit, type GitRunner } from "./project-resolver"; import { logGitFailure, logWorktreeEvent, worktreeErrorCode } from "./worktree-log"; @@ -138,11 +139,6 @@ function canonical(path: string): string { try { return realpathSync.native(path); } catch { return resolve(path); } } -function pathBelow(root: string, target: string): boolean { - const rel = relative(root, target); - return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !rel.includes(`${sep}..${sep}`); -} - /** The sole owner of managed Git worktree lifecycle. Paths are derived here, * never accepted from a client or SessionManager. */ export class WorktreeManager { diff --git a/bridge/tests/agent-core-checkout-routing.test.ts b/bridge/tests/agent-core-checkout-routing.test.ts index b12a6cec..6a00a486 100644 --- a/bridge/tests/agent-core-checkout-routing.test.ts +++ b/bridge/tests/agent-core-checkout-routing.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join, relative } from "node:path"; import { tmpdir } from "node:os"; import { buildAgentCore, type AgentCore } from "../src/agent-core"; @@ -420,3 +420,133 @@ test("main is unaffected while another checkout's delete is in flight", async () expect(frame).toMatchObject({ type: "file:content", content: "main\n" }); expect(sent.filter((m) => m.type === "control:result" && m.checkoutId === "main")).toEqual([]); }); + +/** A committed antgrid.yaml the managed checkout will read as its own. */ +function commitConfig(body: string): Promise { + writeFileSync(join(root, "antgrid.yaml"), body); + return initRepo(); +} + +function frameIndex(sent: AbMessage[], predicate: (message: AbMessage) => boolean): number { + const index = sent.findIndex(predicate); + if (index < 0) throw new Error("frame never arrived"); + return index; +} + +test("a managed checkout's services wait for worktree.setup to finish", async () => { + // Auto-starting a service against a worktree whose node_modules has not been + // provisioned yet is a guaranteed failure the user then has to read past, so + // the block is held from `prepareCheckoutRuntime` until setup reaches ANY + // terminal state — `onFailure: warn` means a failed run still gets its + // dev server. + await commitConfig([ + "name: checkout-routing", + "agent:", + " tool: claude-code", + "services:", + " - name: svc", + " command: git --version", + "worktree:", + " setup:", + " steps:", + " - name: Install dependencies", + " run: git --version", + ].join("\n")); + const { bus, sent } = await bootCore(); + const session = await createSession(bus, sent, "Isolated", "worktree"); + const checkoutId = session.checkoutId; + // The create reply is what the app waits 15 s for, and it already carries the + // truth: this workspace is still being provisioned. + expect(session.setup).toMatchObject({ state: "running", pendingStart: false }); + + const isServiceFrame = (message: AbMessage) => + "terminalId" in message && message.terminalId === "svc" + && "checkoutId" in message && message.checkoutId === checkoutId; + const isSettled = (message: AbMessage) => + message.type === "session:updated" + && message.sessions.some((entry) => + entry.id === session.id && entry.setup !== undefined && entry.setup.state !== "running"); + + await waitFor(sent, isServiceFrame, 20000); + // Ordering rather than a snapshot: an undeferred block spawns inside + // prepareCheckoutRuntime, which runs BEFORE the entry is committed — so a + // regression puts the service ahead of the create reply, not merely early. + const createReply = frameIndex(sent, (message) => + message.type === "session:result" && message.session?.id === session.id); + expect(frameIndex(sent, isServiceFrame)).toBeGreaterThan(createReply); + expect(frameIndex(sent, isServiceFrame)).toBeGreaterThan(frameIndex(sent, isSettled)); + // Main's own slot is untouched by the deferral: only the checkout being + // provisioned waits. + expect(sent.some((message) => + "terminalId" in message && message.terminalId === "svc" + && "checkoutId" in message && message.checkoutId === "main")).toBe(true); + // Releasing the services is only half the job: `agent:status` is what carries + // services[].running to the app, and this checkout's last push happened while + // they were still held back. + await waitFor(sent, (message) => + message.type === "agent:status" + && "checkoutId" in message && message.checkoutId === checkoutId + && (message.services ?? []).some((service) => service.name === "svc" && service.running), 20000); +}, 20000); + +/** Point the setup child at a stub that emits one step marker and lingers. + * + * The runner spawns `process.execPath` under a hidden subcommand, which is the + * only self-invocation a compiled single-file bridge supports — so a stub in + * that slot is the only way to put a REAL OSC 2 title on a real setup PTY. + * POSIX-only: the equivalent needs a `.cmd` that can emit a bare ESC, which + * `cmd.exe` has no portable spelling for. The parsing itself is covered + * platform-independently in checkout-setup.test.ts. + */ +function withSetupStub(marker: string, fn: () => Promise): Promise { + const stub = join(root, "setup-stub.sh"); + writeFileSync(stub, `#!/bin/sh\nprintf '\\033]2;${marker}\\007'\nsleep 1\n`); + chmodSync(stub, 0o755); + const real = process.execPath; + process.execPath = stub; + return fn().finally(() => { process.execPath = real; }); +} + +test.skipIf(process.platform === "win32")( + "a setup terminal's OSC title becomes step progress and never a session name", + async () => { + await commitConfig([ + "name: checkout-routing", + "agent:", + " tool: claude-code", + "worktree:", + " setup:", + " steps:", + " - name: Copy env files", + " copy: [\"antgrid.yaml\"]", + " - name: Install dependencies", + " run: git --version", + ].join("\n")); + const { bus, sent } = await bootCore(); + const session = await withSetupStub( + "antgrid-setup:1/2:Install dependencies", + () => createSession(bus, sent, "Isolated", "worktree"), + ); + + // The runner seeds step 0 before the child says anything; the marker is what + // moves it. `suppressOscTitle` on that spawn would suppress the onTitle + // callback itself and this transition would never arrive. + expect(session.setup).toMatchObject({ + state: "running", stepIndex: 0, stepCount: 2, terminalId: `${session.checkoutId}:setup`, + }); + const advanced = await waitFor(sent, (message) => + message.type === "session:updated" + && message.sessions.some((entry) => entry.id === session.id && entry.setup?.stepIndex === 1), + 20000, + ); + if (advanced.type !== "session:updated") throw new Error("no session list"); + const entry = advanced.sessions.find((candidate) => candidate.id === session.id)!; + expect(entry.setup).toMatchObject({ + state: "running", stepIndex: 1, stepCount: 2, stepName: "Install dependencies", + }); + // The interception happens BEFORE the namer fallback: a step marker read as + // a conversation title would rename the session to "Install dependencies". + expect(entry.name).toBe("Isolated"); + }, + 20000, +); diff --git a/bridge/tests/checkout-protocol-contract.test.ts b/bridge/tests/checkout-protocol-contract.test.ts index 06baceb8..e2fcaecf 100644 --- a/bridge/tests/checkout-protocol-contract.test.ts +++ b/bridge/tests/checkout-protocol-contract.test.ts @@ -3,6 +3,16 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { CHECKOUT_VARIABLE_MESSAGE_TYPES, createMessage, parseMessage } from "../src/protocol"; +/** The text of one top-level `const NAME = ...;` declaration, so a registration + * assertion pins the block it means rather than any later occurrence. */ +function sourceBlock(source: string, declaration: string): string { + const start = source.indexOf(declaration); + if (start < 0) throw new Error(`no declaration ${declaration}`); + const end = source.indexOf("\n]);", start); + if (end < 0) throw new Error(`unterminated declaration ${declaration}`); + return source.slice(start, end); +} + function checkoutScopedSchemaTypes(source: string): Set { const types = new Set(); for (const block of source.split("const ").slice(1)) { @@ -23,6 +33,53 @@ describe("checkout protocol contract", () => { .toEqual([...CHECKOUT_VARIABLE_MESSAGE_TYPES].sort()); }); + test("session:setup is deliberately NOT checkout-variable", () => { + // Do not "fix" this by adding it to the set. Every `session:*` verb routes + // by sessionId on the project stream and the bridge resolves the checkout + // from the session entry, so a checkoutId on this frame would be a second, + // conflicting answer to a question already settled bridge-side. The only + // `session:*` member of the set is `session:result`, which carries the + // checkout back OUT. + expect([...CHECKOUT_VARIABLE_MESSAGE_TYPES]).not.toContain("session:setup"); + expect([...CHECKOUT_VARIABLE_MESSAGE_TYPES].filter((type) => type.startsWith("session:"))) + .toEqual(["session:result"]); + }); + + test("session:setup is wired at all five registration points", () => { + // Miss one and the type silently fails: it parses but nothing answers, or it + // answers but never parses. The list is the checklist in CLAUDE.md. + const protocol = readFileSync(join(import.meta.dir, "../src/protocol.ts"), "utf8"); + // 1. the schema, 2. the AbMessageSchema union, 3. the export. + expect(protocol).toContain('type: z.literal("session:setup")'); + expect(sourceBlock(protocol, "export const AbMessageSchema")).toContain("SessionSetupMessage,"); + expect(protocol).toContain("export type SessionSetup ="); + + // 5. the handler. CLAUDE.md still calls it "the index.ts switch"; the inbound + // switch itself now lives in agent-core.ts. + const core = readFileSync(join(import.meta.dir, "../src/agent-core.ts"), "utf8"); + expect(core).toContain('case "session:setup"'); + + // 4. KNOWN_TYPES and the union, proven by behaviour rather than by grep: + // parseMessage refuses a type either one has not heard of. + const parsed = parseMessage(JSON.stringify(createMessage("session:setup", { + requestId: "r1", sessionId: "s1", action: "skip", + }))); + expect(parsed).toMatchObject({ type: "session:setup", sessionId: "s1", action: "skip" }); + }); + + test("session:setup accepts only the three actions the bridge implements", () => { + for (const action of ["skip", "cancel", "rerun"]) { + expect(parseMessage(JSON.stringify({ + ...createMessage("session:setup", { requestId: "r1", sessionId: "s1", action: "skip" }), + action, + }))).not.toBeNull(); + } + expect(parseMessage(JSON.stringify({ + ...createMessage("session:setup", { requestId: "r1", sessionId: "s1", action: "skip" }), + action: "start", + }))).toBeNull(); + }); + test("legacy fields default to main while explicit ids survive parsing", () => { const legacy = createMessage("terminal:input", { terminalId: "t", data: "x" }); expect(legacy.checkoutId).toBe("main"); diff --git a/bridge/tests/checkout-setup.test.ts b/bridge/tests/checkout-setup.test.ts new file mode 100644 index 00000000..b3141459 --- /dev/null +++ b/bridge/tests/checkout-setup.test.ts @@ -0,0 +1,592 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import type { TerminalSpawnConfig } from "../src/terminal-manager"; +import { + CheckoutSetupRunner, + SetupPlanFileSchema, + formatSetupStepMarker, + parseSetupStepMarker, + setupTerminalId, + type SetupPlanFile, +} from "../src/worktrees/checkout-setup"; +import { runWorktreeSetupCli } from "../src/cli/worktree-setup"; +import type { CheckoutRecord, CheckoutSetupProgress } from "../src/worktrees/checkout-types"; + +let root: string; +let projectPath: string; +let checkoutPath: string; +let planDir: string; +let previousAbDir: string | undefined; + +beforeEach(() => { + previousAbDir = process.env.ANTGRID_DIR; + root = mkdtempSync(join(tmpdir(), "antgrid-setup-")); + // An ANTGRID_DIR of its own, or a developer's real ~/.antgrid/antgrid.yaml + // decides what the global-fallback cases below see. + process.env.ANTGRID_DIR = join(root, "state"); + projectPath = join(root, "project"); + checkoutPath = join(root, "worktree"); + planDir = join(root, "plans"); + mkdirSync(projectPath, { recursive: true }); + mkdirSync(checkoutPath, { recursive: true }); +}); + +afterEach(() => { + if (previousAbDir === undefined) delete process.env.ANTGRID_DIR; + else process.env.ANTGRID_DIR = previousAbDir; + rmSync(root, { recursive: true, force: true }); +}); + +const CHECKOUT: CheckoutRecord = { + id: "checkout-1", + projectId: "p", + kind: "managed-worktree", + path: "", + branch: "antgrid/tidy-otter", + baseRef: "release/2.1", + managed: true, + sessionId: "session-1", + createdAt: 1, +}; + +function checkout(): CheckoutRecord { + return { ...CHECKOUT, path: checkoutPath }; +} + +/** Stands in for the TerminalManager: the runner only ever spawns and tree-kills, + * and a real PTY would make every case here depend on a shell. */ +function fakeTerminals() { + const spawns: TerminalSpawnConfig[] = []; + const killed: string[] = []; + return { + spawns, + killed, + spawn: (config: TerminalSpawnConfig): string => { + spawns.push(config); + // The runner always names the setup terminal itself; an unnamed spawn is a bug worth failing on. + if (!config.terminalId) throw new Error("setup spawn must carry an explicit terminalId"); + return config.terminalId; + }, + killAndAwaitTree: async (terminalId: string): Promise => { killed.push(terminalId); }, + }; +} + +function writeCheckoutConfig(body: string): void { + writeFileSync(join(checkoutPath, "antgrid.yaml"), body); +} + +function newRunner(terminals = fakeTerminals()) { + const runner = new CheckoutSetupRunner({ + projectPath, + terminals, + planDir, + execPath: "/fake/bun", + entrypoint: "/fake/src/index.ts", + compiled: false, + }); + return { runner, terminals }; +} + +/** `start` is fire-and-forget; give its (synchronous) body a turn to land. */ +async function settle(): Promise { + await new Promise((r) => setTimeout(r, 0)); +} + +function readPlan(): SetupPlanFile { + return SetupPlanFileSchema.parse( + JSON.parse(readFileSync(join(planDir, `${CHECKOUT.id}.plan.json`), "utf8")), + ); +} + +describe("CheckoutSetupRunner plan resolution", () => { + test("interpolates every checkout variable against the real worktree", async () => { + // The whole reason `worktree` is excluded from config.ts's eager resolveAll: + // that pass interpolates ${project.path} against process.cwd(), which for a + // checkout's own antgrid.yaml is the MAIN tree. + writeCheckoutConfig([ + "worktree:", + " setup:", + " steps:", + " - name: Report", + " run: echo ${project.path} ${checkout.path} ${checkout.branch} ${base.branch} ${session.id} ${env.ANTGRID_SETUP_FIXTURE}", + " workingDir: sub", + " env:", + " BRANCH: ${checkout.branch}", + ].join("\n")); + const { runner } = newRunner(); + process.env.ANTGRID_SETUP_FIXTURE = "from-env"; + try { + runner.start(checkout(), "session-1", () => {}); + await settle(); + } finally { + delete process.env.ANTGRID_SETUP_FIXTURE; + } + + const step = readPlan().steps[0]!; + expect(step.run).toBe( + `echo ${resolve(projectPath)} ${resolve(checkoutPath)} antgrid/tidy-otter release/2.1 session-1 from-env`, + ); + // workingDir resolves against the CHECKOUT, never the project: a relative + // dir in a setup block names a directory in the tree being provisioned. + expect(step.workingDir).toBe(resolve(checkoutPath, "sub")); + expect(step.env).toEqual({ BRANCH: "antgrid/tidy-otter" }); + }); + + test("a copy step reads from the project and writes the same relative path into the checkout", async () => { + writeCheckoutConfig([ + "worktree:", + " setup:", + " steps:", + " - name: Copy env files", + " copy: [\"web/.env\"]", + ].join("\n")); + const { runner } = newRunner(); + runner.start(checkout(), "session-1", () => {}); + await settle(); + + expect(readPlan().steps[0]!.copy).toEqual([{ + rel: "web/.env", + from: resolve(projectPath, "web/.env"), + to: resolve(checkoutPath, "web/.env"), + }]); + }); + + test("the plan hands the child absolute paths and the ANTGRID_* contract", async () => { + writeCheckoutConfig("worktree:\n setup:\n steps:\n - name: Install\n run: bun install\n"); + const { runner, terminals } = newRunner(); + runner.start(checkout(), "session-1", () => {}); + await settle(); + + const plan = readPlan(); + expect(plan).toMatchObject({ + version: 1, + checkoutId: "checkout-1", + checkoutPath: resolve(checkoutPath), + projectPath: resolve(projectPath), + sessionId: "session-1", + env: { + ANTGRID_PROJECT_PATH: resolve(projectPath), + ANTGRID_CHECKOUT_PATH: resolve(checkoutPath), + ANTGRID_CHECKOUT_BRANCH: "antgrid/tidy-otter", + ANTGRID_BASE_BRANCH: "release/2.1", + ANTGRID_SESSION_ID: "session-1", + ANTGRID_SETUP: "1", + }, + }); + + const spawn = terminals.spawns[0]!; + expect(spawn.terminalId).toBe(setupTerminalId("checkout-1")); + expect(spawn.cwd).toBe(checkoutPath); + expect(spawn.command).toBe("/fake/bun"); + // Uncompiled, execPath is bun itself, so the entrypoint leads the subcommand. + expect(spawn.args).toEqual([ + "/fake/src/index.ts", "worktree-setup", "--plan", join(planDir, "checkout-1.plan.json"), + ]); + // `suppressOscTitle` would suppress the onTitle callback itself, which is + // the channel every step transition travels on. + expect(spawn.suppressOscTitle).toBeUndefined(); + expect(spawn.suppressOscNotifications).toBe(true); + // Neither an agent nor a service: typing it would put a provisioning log in + // the services list. + expect(spawn.type).toBeUndefined(); + expect(spawn.retainScrollbackOnExit).toBe(true); + }); + + test("a compiled bridge invokes its own subcommand with no entrypoint", async () => { + writeCheckoutConfig("worktree:\n setup:\n steps:\n - name: Install\n run: bun install\n"); + const terminals = fakeTerminals(); + const runner = new CheckoutSetupRunner({ + projectPath, terminals, planDir, execPath: "/opt/antgrid/antgrid", compiled: true, + }); + runner.start(checkout(), "session-1", () => {}); + await settle(); + expect(terminals.spawns[0]!.args).toEqual([ + "worktree-setup", "--plan", join(planDir, "checkout-1.plan.json"), + ]); + }); + + test("a checkout with no setup block reports done without spawning", async () => { + // The caller stamped `running` before calling and has to be released either + // way — silence would leave the session preparing forever. + const { runner, terminals } = newRunner(); + const progress: CheckoutSetupProgress[] = []; + runner.start(checkout(), "session-1", (p) => progress.push(p)); + await settle(); + expect(progress).toEqual([{ state: "done", stepIndex: 0, stepCount: 0 }]); + expect(terminals.spawns).toEqual([]); + }); + + test("a machine-global antgrid.yaml never provisions someone else's worktree", async () => { + // findConfigFile falls back to /antgrid.yaml, and a global setup + // block would then run for every project's worktrees without anyone asking. + mkdirSync(process.env.ANTGRID_DIR!, { recursive: true }); + writeFileSync( + join(process.env.ANTGRID_DIR!, "antgrid.yaml"), + "worktree:\n setup:\n steps:\n - name: Global\n run: echo global\n", + ); + const { runner, terminals } = newRunner(); + expect(runner.resolveSetup(checkout())).toBeNull(); + runner.start(checkout(), "session-1", () => {}); + await settle(); + expect(terminals.spawns).toEqual([]); + }); + + test("an unparseable checkout config fails the run instead of throwing at the caller", async () => { + writeCheckoutConfig("worktree:\n setup:\n steps:\n - name: Both\n run: echo hi\n copy: [\".env\"]\n"); + const { runner, terminals } = newRunner(); + const progress: CheckoutSetupProgress[] = []; + runner.start(checkout(), "session-1", (p) => progress.push(p)); + await settle(); + expect(progress).toHaveLength(1); + expect(progress[0]!.state).toBe("failed"); + expect(progress[0]!.message).toContain("either copy or run"); + expect(terminals.spawns).toEqual([]); + }); +}); + +describe("CheckoutSetupRunner copy path guard", () => { + /** Both roots are checked against the SAME relative path, so an escape refuses + * the run whichever side you read it from: the source would read outside the + * main project and the destination would write outside the worktree. A + * checkout's antgrid.yaml is branch-supplied content, so neither is theoretical. */ + async function refusedCopy(entry: string): Promise { + writeCheckoutConfig([ + "worktree:", + " setup:", + " steps:", + " - name: Copy env files", + ` copy: [${JSON.stringify(entry)}]`, + ].join("\n")); + const { runner, terminals } = newRunner(); + const progress: CheckoutSetupProgress[] = []; + runner.start(checkout(), "session-1", (p) => progress.push(p)); + await settle(); + // Refused at plan time: nothing spawns, so neither root is ever touched. + expect(terminals.spawns).toEqual([]); + expect(progress).toHaveLength(1); + return progress[0]!; + } + + test("refuses a parent-directory escape", async () => { + const failure = await refusedCopy("../../.ssh/id_ed25519"); + expect(failure.state).toBe("failed"); + expect(failure.message).toContain("escapes the project root"); + }); + + test("refuses an interior .. that climbs back out", async () => { + const failure = await refusedCopy("web/../../secrets.env"); + expect(failure.state).toBe("failed"); + expect(failure.message).toContain("escapes the project root"); + }); + + test("refuses the root itself, which would copy a whole tree over another", async () => { + const failure = await refusedCopy("."); + expect(failure.state).toBe("failed"); + expect(failure.message).toContain("escapes the project root"); + }); + + test("refuses an absolute source outright", async () => { + const failure = await refusedCopy(process.platform === "win32" ? "C:/secrets/.env" : "/etc/shadow"); + expect(failure.state).toBe("failed"); + expect(failure.message).toContain("must be relative to the project root"); + }); + + test("refuses an escape that only appears after interpolation", async () => { + // The guard runs on the RESOLVED path: checking the raw string would let + // `${env.X}` smuggle the `..` past it. + process.env.ANTGRID_SETUP_ESCAPE = "../.."; + try { + const failure = await refusedCopy("${env.ANTGRID_SETUP_ESCAPE}/secrets.env"); + expect(failure.state).toBe("failed"); + expect(failure.message).toContain("escapes the project root"); + } finally { + delete process.env.ANTGRID_SETUP_ESCAPE; + } + }); +}); + +describe("CheckoutSetupRunner step markers", () => { + test("a marker round-trips through the OSC title it rides", () => { + const title = formatSetupStepMarker(2, 4, "Install dependencies"); + expect(title.startsWith("\x1b]2;")).toBe(true); + expect(title.endsWith("\x07")).toBe(true); + // The parser sees the TITLE, which is what the PTY hands the callback — + // the escape wrapper never reaches it. + expect(parseSetupStepMarker(title.slice(4, -1))) + .toEqual({ index: 2, count: 4, name: "Install dependencies" }); + }); + + test("a control byte in a step name is scrubbed rather than splitting the marker", () => { + const title = formatSetupStepMarker(0, 1, "Install\x07dependencies"); + expect(parseSetupStepMarker(title.slice(4, -1))) + .toEqual({ index: 0, count: 1, name: "Install dependencies" }); + }); + + test("an ordinary title is not a marker", () => { + expect(parseSetupStepMarker("bun install")).toBeNull(); + }); + + test("a marker becomes a running transition and a foreign title is left alone", async () => { + writeCheckoutConfig([ + "worktree:", + " setup:", + " steps:", + " - name: Copy env files", + " copy: [\".env\"]", + " - name: Install dependencies", + " run: bun install", + ].join("\n")); + const { runner } = newRunner(); + const progress: CheckoutSetupProgress[] = []; + runner.start(checkout(), "session-1", (p) => progress.push(p)); + await settle(); + const terminalId = setupTerminalId("checkout-1"); + + // The seed transition carries the terminal id, which is how the app learns + // which log to replay. + expect(progress).toEqual([{ + state: "running", stepIndex: 0, stepCount: 2, stepName: "Copy env files", terminalId, + }]); + + expect(runner.handleTitle(terminalId, "antgrid-setup:1/2:Install dependencies")).toBe(true); + expect(progress[1]).toEqual({ + state: "running", stepIndex: 1, stepCount: 2, stepName: "Install dependencies", terminalId, + }); + + // Owned but not a marker: swallowed all the same, or the session namer would + // read a setup step's shell prompt as the conversation's title. + expect(runner.handleTitle(terminalId, "bun install")).toBe(true); + expect(progress).toHaveLength(2); + + // A PTY the runner does not own must fall straight through to the namer. + expect(runner.handleTitle("some-session", "antgrid-setup:1/2:Install")).toBe(false); + }); + + test("the last marker's step survives into the terminal state", async () => { + writeCheckoutConfig("worktree:\n setup:\n steps:\n - name: One\n run: echo one\n - name: Two\n run: echo two\n"); + const { runner } = newRunner(); + const progress: CheckoutSetupProgress[] = []; + runner.start(checkout(), "session-1", (p) => progress.push(p)); + await settle(); + const terminalId = setupTerminalId("checkout-1"); + runner.handleTitle(terminalId, "antgrid-setup:1/2:Two"); + + writeFileSync( + join(planDir, "checkout-1.result.json"), + JSON.stringify({ exitCode: 4, stepIndex: 1, stepName: "Two", message: "Two failed (exit 4)" }), + ); + expect(runner.handleExit(terminalId)).toBe(true); + expect(progress.at(-1)).toEqual({ + state: "failed", stepIndex: 1, stepCount: 2, stepName: "Two", terminalId, + exitCode: 4, message: "Two failed (exit 4)", + }); + // The staging files are the runner's, not the user's — and a stale result + // would be read as the next run's outcome. + expect(existsSync(join(planDir, "checkout-1.result.json"))).toBe(false); + expect(existsSync(join(planDir, "checkout-1.plan.json"))).toBe(false); + }); + + test("a child that dies without a result is a failure, not a success", async () => { + writeCheckoutConfig("worktree:\n setup:\n steps:\n - name: One\n run: echo one\n"); + const { runner } = newRunner(); + const progress: CheckoutSetupProgress[] = []; + runner.start(checkout(), "session-1", (p) => progress.push(p)); + await settle(); + runner.handleExit(setupTerminalId("checkout-1")); + expect(progress.at(-1)).toMatchObject({ state: "failed", message: "Setup ended without reporting a result" }); + }); + + test("an exit for a terminal the runner never owned is ignored", () => { + const { runner } = newRunner(); + expect(runner.handleExit("some-session")).toBe(false); + }); +}); + +describe("CheckoutSetupRunner cancel and timeout", () => { + test("cancel kills the tree and settles as skipped, not failed", async () => { + writeCheckoutConfig("worktree:\n setup:\n steps:\n - name: Install\n run: bun install\n"); + const { runner, terminals } = newRunner(); + const progress: CheckoutSetupProgress[] = []; + runner.start(checkout(), "session-1", (p) => progress.push(p)); + await settle(); + expect(runner.isRunning("checkout-1")).toBe(true); + + await runner.cancel("checkout-1"); + // Awaited before `git worktree remove` runs: on Windows a live `bun install` + // holding the checkout as its cwd makes the remove fail. + expect(terminals.killed).toEqual([setupTerminalId("checkout-1")]); + expect(progress.at(-1)).toMatchObject({ state: "skipped", message: "Setup cancelled" }); + expect(runner.isRunning("checkout-1")).toBe(false); + expect(runner.owns(setupTerminalId("checkout-1"))).toBe(false); + }); + + test("the PTY's own exit after a cancel cannot reopen the run", async () => { + writeCheckoutConfig("worktree:\n setup:\n steps:\n - name: Install\n run: bun install\n"); + const { runner } = newRunner(); + const progress: CheckoutSetupProgress[] = []; + runner.start(checkout(), "session-1", (p) => progress.push(p)); + await settle(); + await runner.cancel("checkout-1"); + const settled = progress.length; + expect(runner.handleExit(setupTerminalId("checkout-1"))).toBe(false); + expect(progress).toHaveLength(settled); + }); + + test("cancelling a checkout with nothing running is a no-op", async () => { + const { runner, terminals } = newRunner(); + await runner.cancel("checkout-1"); + expect(terminals.killed).toEqual([]); + }); + + test("the timeout budgets the whole run and reports which step wedged", async () => { + writeCheckoutConfig([ + "worktree:", + " setup:", + " timeoutMs: 20", + " steps:", + " - name: Install dependencies", + " run: bun install", + ].join("\n")); + const { runner, terminals } = newRunner(); + const progress: CheckoutSetupProgress[] = []; + runner.start(checkout(), "session-1", (p) => progress.push(p)); + await settle(); + + const deadline = Date.now() + 3000; + while (runner.isRunning("checkout-1") && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 5)); + } + expect(terminals.killed).toEqual([setupTerminalId("checkout-1")]); + expect(progress.at(-1)).toMatchObject({ + state: "failed", + stepName: "Install dependencies", + }); + expect(progress.at(-1)!.message).toContain("timed out"); + }); + + test("a rerun kills the run it replaces before spawning its own", async () => { + writeCheckoutConfig("worktree:\n setup:\n steps:\n - name: Install\n run: bun install\n"); + const { runner, terminals } = newRunner(); + runner.start(checkout(), "session-1", () => {}); + await settle(); + const secondProgress: CheckoutSetupProgress[] = []; + runner.start(checkout(), "session-1", (p) => secondProgress.push(p)); + await settle(); + + // Two children writing the same result file is the failure this prevents. + expect(terminals.killed).toEqual([setupTerminalId("checkout-1")]); + expect(terminals.spawns).toHaveLength(2); + expect(secondProgress[0]).toMatchObject({ state: "running", stepCount: 1 }); + }); +}); + +describe("worktree-setup CLI", () => { + function capture(): { lines: () => string; restore: () => void } { + let buffer = ""; + const spy = spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => { + buffer += String(chunk); + return true; + }) as typeof process.stdout.write); + return { lines: () => buffer, restore: () => spy.mockRestore() }; + } + + function writePlan(steps: SetupPlanFile["steps"]): string { + mkdirSync(planDir, { recursive: true }); + const path = join(planDir, "plan.json"); + const plan: SetupPlanFile = { + version: 1, + checkoutId: "checkout-1", + checkoutPath: resolve(checkoutPath), + projectPath: resolve(projectPath), + sessionId: "session-1", + resultPath: join(planDir, "result.json"), + env: { ANTGRID_SETUP: "1" }, + steps, + }; + writeFileSync(path, JSON.stringify(plan)); + return path; + } + + function result(): unknown { + return JSON.parse(readFileSync(join(planDir, "result.json"), "utf8")); + } + + async function run(planPath: string): Promise<{ code: number; out: string }> { + const captured = capture(); + try { + return { code: await runWorktreeSetupCli({ plan: planPath }), out: captured.lines() }; + } finally { + captured.restore(); + } + } + + test("a missing copy source warns and the run carries on", async () => { + // Not every developer has every env file, and the hand-rolled provisioner + // this replaces (scripts/worktree.ts:copyEnv) has always skipped them. + writeFileSync(join(projectPath, "present.env"), "KEY=value\n"); + const planPath = writePlan([{ + name: "Copy env files", + copy: [ + { rel: "absent.env", from: join(projectPath, "absent.env"), to: join(checkoutPath, "absent.env") }, + { rel: "present.env", from: join(projectPath, "present.env"), to: join(checkoutPath, "present.env") }, + ], + workingDir: checkoutPath, + }]); + + const { code, out } = await run(planPath); + expect(code).toBe(0); + expect(out).toContain("absent.env not found in the main checkout"); + // The entry AFTER the missing one still runs: a skip must not abandon the step. + expect(readFileSync(join(checkoutPath, "present.env"), "utf8")).toBe("KEY=value\n"); + expect(existsSync(join(checkoutPath, "absent.env"))).toBe(false); + expect(result()).toMatchObject({ exitCode: 0, stepName: "Copy env files" }); + }); + + test("a copy creates the directories its destination needs", async () => { + mkdirSync(join(projectPath, "web"), { recursive: true }); + writeFileSync(join(projectPath, "web", ".env"), "DB=1\n"); + const planPath = writePlan([{ + name: "Copy env files", + copy: [{ rel: "web/.env", from: join(projectPath, "web", ".env"), to: join(checkoutPath, "web", ".env") }], + workingDir: checkoutPath, + }]); + expect((await run(planPath)).code).toBe(0); + expect(readFileSync(join(checkoutPath, "web", ".env"), "utf8")).toBe("DB=1\n"); + }); + + test("each step announces itself with an OSC marker before it runs", async () => { + const planPath = writePlan([ + { name: "First", copy: [], workingDir: checkoutPath }, + { name: "Second", copy: [], workingDir: checkoutPath }, + ]); + const { code, out } = await run(planPath); + expect(code).toBe(0); + // Leading the step, not trailing it: a long install would otherwise sit + // under the previous step's name in the banner. + expect(out).toContain(formatSetupStepMarker(0, 2, "First")); + expect(out).toContain(formatSetupStepMarker(1, 2, "Second")); + expect(out.indexOf(formatSetupStepMarker(0, 2, "First"))) + .toBeLessThan(out.indexOf(formatSetupStepMarker(1, 2, "Second"))); + }); + + test("a failing run step stops the run and names itself in the result", async () => { + const planPath = writePlan([ + { name: "Fails", run: "exit 3", workingDir: checkoutPath }, + { name: "Never runs", copy: [], workingDir: checkoutPath }, + ]); + const { code, out } = await run(planPath); + expect(code).toBe(3); + expect(out).not.toContain(formatSetupStepMarker(1, 2, "Never runs")); + expect(result()).toEqual({ + exitCode: 3, stepIndex: 0, stepName: "Fails", message: "Fails failed (exit 3)", + }); + }); + + test("an unreadable plan says so in the transcript rather than dying silently", async () => { + const { code, out } = await run(join(planDir, "does-not-exist.json")); + expect(code).toBe(1); + expect(out).toContain("unreadable plan"); + }); +}); diff --git a/bridge/tests/checkout-store.test.ts b/bridge/tests/checkout-store.test.ts index 582a5dad..e82adc2d 100644 --- a/bridge/tests/checkout-store.test.ts +++ b/bridge/tests/checkout-store.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { CheckoutStore } from "../src/worktrees/checkout-store"; @@ -47,6 +47,81 @@ describe("CheckoutStore", () => { expect((await store.list()).map((record) => record.id)).toEqual(["valid"]); }); + test("round-trips the worktree.setup outcome and clears it again", async () => { + // The whole durable surface of `worktree.setup`: how a run ENDED. A rerun + // clears the marker before it spawns, so the write-back of `undefined` is + // as load-bearing as the write of an outcome — a bridge that died mid-rerun + // must come back `interrupted`, not wearing the previous run's `done`. + const store = new CheckoutStore(dir, "project-a"); + const base = { + id: "checkout-a", projectId: "project-a", kind: "managed-worktree" as const, + path: "C:/safe/worktree", branch: "antgrid/session-a", baseRef: "main", + managed: true, sessionId: "session-a", createdAt: 1, + }; + await store.put({ ...base, setupState: "failed", setupFinishedAt: 1_700_000_000_000, setupExitCode: 3 }); + expect(await store.get("checkout-a")).toMatchObject({ + setupState: "failed", setupFinishedAt: 1_700_000_000_000, setupExitCode: 3, + }); + + await store.put(base); + const cleared = await store.get("checkout-a"); + expect(cleared?.setupState).toBeUndefined(); + expect(cleared?.setupFinishedAt).toBeUndefined(); + expect(cleared?.setupExitCode).toBeUndefined(); + }); + + test("update annotates in place and never resurrects a removed row", async () => { + // The setup marker lands on a row the delete flow may already have + // reclaimed. A get()-then-put() spans two lock acquisitions, so the put + // would write the row back with the worktree it names already gone. + const store = new CheckoutStore(dir, "project-a"); + const base = { + id: "checkout-a", projectId: "project-a", kind: "managed-worktree" as const, + path: "C:/safe/worktree", branch: "antgrid/session-a", baseRef: "main", + managed: true, sessionId: "session-a", createdAt: 1, + }; + await store.put(base); + expect(await store.update("checkout-a", (record) => ({ ...record, setupState: "done" }))).toBe(true); + expect((await store.get("checkout-a"))?.setupState).toBe("done"); + + expect(await store.remove("checkout-a")).toBe(true); + expect(await store.update("checkout-a", (record) => ({ ...record, setupState: "failed" }))).toBe(false); + expect(await store.list()).toEqual([]); + }); + + test("rejects a running setup state, which must never reach disk", async () => { + // `running` is absent from the durable enum on purpose: a bridge that dies + // mid-setup would otherwise leave a row that is permanently preparing with + // nothing alive to ever clear it. Absence is what `interrupted` is derived + // from. + const store = new CheckoutStore(dir, "project-a"); + await expect(store.put({ + id: "checkout-a", projectId: "project-a", kind: "managed-worktree", + path: "C:/safe/worktree", branch: null, baseRef: null, + managed: true, sessionId: null, createdAt: 1, + setupState: "running" as never, + })).rejects.toThrow(); + }); + + test("a checkouts.json written before setup markers existed still parses", async () => { + // The three fields are optional so an existing file stays valid across the + // upgrade — a stricter schema would make every pre-existing worktree read + // as a corrupt row and get swept as an orphan. + mkdirSync(join(dir, "agents", "project-a"), { recursive: true }); + writeFileSync(join(dir, "agents", "project-a", "checkouts.json"), JSON.stringify({ + version: 1, + checkouts: [{ + id: "legacy", projectId: "project-a", kind: "managed-worktree", path: "C:/safe", + branch: "antgrid/legacy", baseRef: null, managed: true, sessionId: "s", createdAt: 1, + }], + })); + const store = new CheckoutStore(dir, "project-a"); + expect(await store.read()).toMatchObject({ healthy: true }); + const legacy = await store.get("legacy"); + expect(legacy?.branch).toBe("antgrid/legacy"); + expect(legacy?.setupState).toBeUndefined(); + }); + test("read() separates an absent file from one it could not fully understand", async () => { // The distinction is what stands between reconciliation's orphan sweep and // force-deleting a live worktree whose row it simply could not see. diff --git a/bridge/tests/session-manager-worktree.test.ts b/bridge/tests/session-manager-worktree.test.ts index 7f1a2975..b33da73d 100644 --- a/bridge/tests/session-manager-worktree.test.ts +++ b/bridge/tests/session-manager-worktree.test.ts @@ -3,16 +3,17 @@ import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { SessionManager } from "../src/session-manager"; -import type { CheckoutRecord } from "../src/worktrees/checkout-types"; +import { CheckoutStore } from "../src/worktrees/checkout-store"; +import type { CheckoutRecord, CheckoutSetupProgress } from "../src/worktrees/checkout-types"; import type { WorktreeManager } from "../src/worktrees/worktree-manager"; function fakeTerminal() { const running = new Set(); - const spawns: Array<{ terminalId: string; cwd?: string }> = []; + const spawns: Array<{ terminalId: string; cwd?: string; args?: string[] }> = []; return { - spawn: (opts: { terminalId: string; cwd?: string }) => { + spawn: (opts: { terminalId: string; cwd?: string; args?: string[] }) => { running.add(opts.terminalId); - spawns.push({ terminalId: opts.terminalId, cwd: opts.cwd }); + spawns.push({ terminalId: opts.terminalId, cwd: opts.cwd, args: opts.args }); return opts.terminalId; }, kill: (id: string) => running.delete(id), @@ -415,3 +416,396 @@ describe("isolated session checkout state", () => { }); }); }); + +describe("isolated session worktree.setup", () => { + const CHECKOUT_ID = "checkout-1"; + + function record(dir: string): CheckoutRecord { + return { + id: CHECKOUT_ID, projectId: "p", kind: "managed-worktree", path: join(dir, "wt"), + branch: "antgrid/session-1", baseRef: "main", managed: true, sessionId: null, createdAt: 1, + }; + } + + /** A setup runner the test drives by hand. The real one reports from a PTY on + * its own schedule; every case here is about what the manager does at a + * transition, so the transition has to be the test's to place. */ + function harness(dir: string, opts: { removeCheckout?: () => void; declaresSetup?: boolean } = {}) { + const worktree = join(dir, "wt"); + // startCheckout stats the checkout before spawning — a record says nothing + // about the disk. + mkdirSync(worktree, { recursive: true }); + const terminal = fakeTerminal(); + const runs: Array<{ + checkoutId: string; + sessionId: string; + report: (progress: CheckoutSetupProgress) => void; + }> = []; + const order: string[] = []; + const cancelled: string[] = []; + const servicesStarted: string[] = []; + const deferred: boolean[] = []; + const manager = { + prepareForSession: async (args: { sessionId: string }): Promise => ({ + ...record(dir), sessionId: args.sessionId, + }), + rollbackPrepared: async () => {}, + recordFor: async () => record(dir), + inspect: async () => ({ exists: true, registered: true, dirty: false, unpushedCommits: false, locked: false }), + remove: async () => { order.push("remove"); opts.removeCheckout?.(); }, + } as unknown as WorktreeManager; + const sm = new SessionManager({ + projectId: "p", storeDir: dir, projectPath: dir, terminalManager: terminal as any, + agentSpec: { command: "claude", name: "claude-code" }, sendMessage: () => {}, + worktreeSessionsSupported: true, isGitRepository: async () => true, worktreeManager: manager, + prepareCheckoutRuntime: async (_checkout, prepareOpts) => { + deferred.push(prepareOpts?.deferServices === true); + }, + teardownCheckoutRuntime: async () => { order.push("teardown"); }, + startDeferredServices: async (checkoutId) => { servicesStarted.push(checkoutId); }, + runCheckoutSetup: (checkout, sessionId, onProgress) => { + runs.push({ checkoutId: checkout.id, sessionId, report: onProgress }); + }, + cancelCheckoutSetup: async (checkoutId) => { order.push("cancel-setup"); cancelled.push(checkoutId); }, + checkoutDeclaresSetup: () => opts.declaresSetup ?? true, + resolveCheckout: async () => ({ ...record(dir), sessionId: "s" }), + resolveAgentSpec: async () => ({ command: "claude", name: "claude-code" }), + }); + return { sm, terminal, runs, order, cancelled, servicesStarted, deferred }; + } + + async function withDir(fn: (dir: string) => Promise): Promise { + const dir = mkdtempSync(join(tmpdir(), "antgrid-worktree-setup-")); + try { await fn(dir); } finally { rmSync(dir, { recursive: true, force: true }); } + } + + /** The queued start reaches the PTY through an async re-entry into start(). */ + async function waitForTerminal(terminal: ReturnType, id: string): Promise { + for (let i = 0; i < 200 && !terminal.has(id); i++) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + expect(terminal.has(id)).toBe(true); + } + + /** The marker is written off the settle path, behind the store's own write + * lock and a real file write, so its arrival is polled rather than assumed. */ + async function markerSettles(store: CheckoutStore, expected: string): Promise { + for (let i = 0; i < 200; i++) { + if ((await store.get(CHECKOUT_ID))?.setupState === expected) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + expect((await store.get(CHECKOUT_ID))?.setupState).toBe(expected as never); + } + + /** Seed the durable metadata the marker writes land on. Without a row, + * stampSetupMarker has nothing to annotate and returns silently. */ + async function seedCheckout(dir: string, extra: Partial = {}): Promise { + await new CheckoutStore(dir, "p").put({ ...record(dir), ...extra }); + } + + it("holds the agent behind a running setup and says so on the entry", async () => { + await withDir(async (dir) => { + const { sm, terminal, runs, deferred } = harness(dir); + const created = await sm.create("Isolated", { isolation: "worktree" }); + // The services block is held with it: `bun run dev` against a worktree + // with no node_modules fails before the user has seen the session. + expect(deferred).toEqual([true]); + expect(runs).toHaveLength(1); + expect(created.setup).toMatchObject({ state: "running", stepIndex: 0, pendingStart: false }); + + await sm.start(created.id, "fix the flaky test"); + // Queued, not refused — and the entry carries the truth, which is how the + // app tells "queued" from "started" behind an ok reply. + expect(sm.get(created.id)?.setup?.pendingStart).toBe(true); + expect(terminal.has(created.id)).toBe(false); + }); + }); + + it("skip releases the gate while the run itself keeps going", async () => { + await withDir(async (dir) => { + const { sm, terminal, runs, cancelled, servicesStarted } = harness(dir); + const created = await sm.create("Isolated", { isolation: "worktree" }); + await sm.start(created.id, "fix the flaky test"); + + await sm.applySetupAction(created.id, "skip"); + await waitForTerminal(terminal, created.id); + // "I know the deps are cached", not "stop": nothing is killed, the banner + // keeps reporting, and the services stay this run's to release. + expect(cancelled).toEqual([]); + expect(servicesStarted).toEqual([]); + expect(sm.get(created.id)?.setup).toMatchObject({ state: "running", pendingStart: false }); + + runs[0]!.report({ state: "done", stepIndex: 1, stepCount: 2, terminalId: `${CHECKOUT_ID}:setup` }); + expect(sm.get(created.id)?.setup?.state).toBe("done"); + }); + }); + + it("a start issued after skip is not queued again", async () => { + await withDir(async (dir) => { + const { sm, terminal } = harness(dir); + const created = await sm.create("Isolated", { isolation: "worktree" }); + await sm.applySetupAction(created.id, "skip"); + await sm.start(created.id); + await waitForTerminal(terminal, created.id); + expect(sm.get(created.id)?.setup?.pendingStart).toBe(false); + }); + }); + + it("cancel kills the run, marks it skipped and fires the queued start", async () => { + await withDir(async (dir) => { + await seedCheckout(dir); + const { sm, terminal, cancelled, servicesStarted } = harness(dir); + const created = await sm.create("Isolated", { isolation: "worktree" }); + await sm.start(created.id, "fix the flaky test"); + + await sm.applySetupAction(created.id, "cancel"); + expect(cancelled).toEqual([CHECKOUT_ID]); + // Skipped, not failed: the user ended this run and a red banner would be + // reporting their own choice back at them. + expect(sm.get(created.id)?.setup).toMatchObject({ state: "skipped", pendingStart: false }); + expect(sm.get(created.id)?.setup?.exitCode).toBeUndefined(); + await waitForTerminal(terminal, created.id); + expect(servicesStarted).toEqual([CHECKOUT_ID]); + await markerSettles(new CheckoutStore(dir, "p"), "skipped"); + }); + }); + + it("a failed run still releases the services and the queued agent", async () => { + await withDir(async (dir) => { + // onFailure is `warn`: a half-provisioned tree gets its session anyway, + // with a persistent banner rather than a refusal. + await seedCheckout(dir); + const { sm, terminal, runs, servicesStarted } = harness(dir); + const created = await sm.create("Isolated", { isolation: "worktree" }); + await sm.start(created.id, "fix the flaky test"); + + runs[0]!.report({ + state: "failed", stepIndex: 1, stepCount: 2, stepName: "Install dependencies", + exitCode: 1, message: "Install dependencies failed (exit 1)", + }); + await waitForTerminal(terminal, created.id); + expect(servicesStarted).toEqual([CHECKOUT_ID]); + expect(sm.get(created.id)?.setup).toMatchObject({ + state: "failed", exitCode: 1, message: "Install dependencies failed (exit 1)", pendingStart: false, + }); + const store = new CheckoutStore(dir, "p"); + await markerSettles(store, "failed"); + expect(await store.get(CHECKOUT_ID)).toMatchObject({ setupState: "failed", setupExitCode: 1 }); + }); + }); + + it("the dying report of a cancelled run cannot reopen the state the user was shown", async () => { + await withDir(async (dir) => { + const { sm, runs } = harness(dir); + const created = await sm.create("Isolated", { isolation: "worktree" }); + await sm.applySetupAction(created.id, "cancel"); + // The killed child reports `failed` on its way out; the user asked for the + // kill, so that report is not the answer they get. + runs[0]!.report({ state: "failed", stepIndex: 0, stepCount: 2, message: "Setup timed out after 600s" }); + expect(sm.get(created.id)?.setup).toMatchObject({ state: "skipped", message: undefined }); + }); + }); + + it("reruns from every terminal state and refuses only while one is live", async () => { + await withDir(async (dir) => { + await seedCheckout(dir); + const { sm, runs } = harness(dir); + const created = await sm.create("Isolated", { isolation: "worktree" }); + const store = new CheckoutStore(dir, "p"); + + // A rerun while the previous attempt is still alive would leave two + // runners fighting over the same checkout. + await expect(sm.applySetupAction(created.id, "rerun")).rejects.toThrow(/already running/); + + const terminals: Array = [ + { state: "done", stepIndex: 1, stepCount: 2 }, + { state: "failed", stepIndex: 0, stepCount: 2, exitCode: 2 }, + { state: "skipped", stepIndex: 0, stepCount: 2 }, + ]; + for (const [index, outcome] of terminals.entries()) { + runs.at(-1)!.report(outcome); + expect(sm.get(created.id)?.setup?.state).toBe(outcome.state); + // Waited for, not assumed: the clear below only means anything once the + // outcome it replaces has actually reached the file. + await markerSettles(store, outcome.state); + await sm.applySetupAction(created.id, "rerun"); + expect(runs).toHaveLength(index + 2); + expect(sm.get(created.id)?.setup).toMatchObject({ state: "running", stepIndex: 0, stepCount: 0 }); + // Cleared BEFORE the run, never overwritten after it: a bridge that dies + // mid-rerun must come back `interrupted`, and the previous outcome would + // claim otherwise. + expect((await store.get(CHECKOUT_ID))?.setupState).toBeUndefined(); + } + }); + }); + + it("a rerun gates afresh and honours the start queued behind it", async () => { + await withDir(async (dir) => { + await seedCheckout(dir); + const { sm, terminal, runs } = harness(dir); + const created = await sm.create("Isolated", { isolation: "worktree" }); + runs[0]!.report({ state: "failed", stepIndex: 0, stepCount: 1, exitCode: 1 }); + await sm.applySetupAction(created.id, "rerun"); + + // The release the user gave the first run said nothing about this one. + await sm.start(created.id, "fix the flaky test"); + expect(sm.get(created.id)?.setup?.pendingStart).toBe(true); + expect(terminal.has(created.id)).toBe(false); + + // Re-run-setup then restart-agent must not make the user retype it. + runs[1]!.report({ state: "done", stepIndex: 0, stepCount: 1 }); + await waitForTerminal(terminal, created.id); + expect(sm.get(created.id)?.setup?.pendingStart).toBe(false); + }); + }); + + it("carries the prompt a failed run already spent into the rerun", async () => { + await withDir(async (dir) => { + await seedCheckout(dir); + const { sm, terminal, runs } = harness(dir); + const created = await sm.create("Isolated", { isolation: "worktree" }); + await sm.start(created.id, "fix the flaky test"); + + // onFailure is `warn`, so the queued start fires into the half-provisioned + // tree and the prompt is spent on a build the agent cannot run. + runs[0]!.report({ state: "failed", stepIndex: 0, stepCount: 1, exitCode: 1 }); + await waitForTerminal(terminal, created.id); + sm.stop(created.id); + + await sm.applySetupAction(created.id, "rerun"); + // Re-armed without the app sending start again: the user fixed the setup, + // not the prompt. + expect(sm.get(created.id)?.setup?.pendingStart).toBe(true); + + runs[1]!.report({ state: "done", stepIndex: 1, stepCount: 1 }); + await waitForTerminal(terminal, created.id); + expect(terminal.spawns.at(-1)?.args?.join(" ")).toContain("fix the flaky test"); + }); + }); + + it("does not re-arm a rerun behind an agent that is already running", async () => { + await withDir(async (dir) => { + await seedCheckout(dir); + const { sm, terminal, runs } = harness(dir); + const created = await sm.create("Isolated", { isolation: "worktree" }); + await sm.start(created.id, "fix the flaky test"); + runs[0]!.report({ state: "failed", stepIndex: 0, stepCount: 1, exitCode: 1 }); + await waitForTerminal(terminal, created.id); + + // The live agent already received this prompt; re-arming would deliver it + // a second time the moment the rerun finished. + await sm.applySetupAction(created.id, "rerun"); + expect(sm.get(created.id)?.setup?.pendingStart).toBe(false); + }); + }); + + it("refuses a rerun for a session that has no managed workspace", async () => { + await withDir(async (dir) => { + const { sm } = harness(dir); + const shared = await sm.create("Shared"); + await expect(sm.applySetupAction(shared.id, "rerun")).rejects.toThrow(/no managed workspace/); + await expect(sm.applySetupAction("nope", "skip")).rejects.toThrow(/session not found/); + }); + }); + + it("skip and cancel land as no-ops once the run is over", async () => { + await withDir(async (dir) => { + // The app can only send these from a view that may be a frame behind the + // state it is acting on; a cancel that lands late asked for what it has. + await seedCheckout(dir); + const { sm, runs, cancelled } = harness(dir); + const created = await sm.create("Isolated", { isolation: "worktree" }); + runs[0]!.report({ state: "done", stepIndex: 1, stepCount: 2 }); + await sm.applySetupAction(created.id, "skip"); + await sm.applySetupAction(created.id, "cancel"); + expect(cancelled).toEqual([]); + expect(sm.get(created.id)?.setup?.state).toBe("done"); + }); + }); + + it("reports interrupted after a restart and never reruns on its own", async () => { + await withDir(async (dir) => { + await seedCheckout(dir); + const first = harness(dir); + const created = await first.sm.create("Isolated", { isolation: "worktree" }); + // No marker was ever stamped: this bridge died mid-run. + + const second = harness(dir); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(second.sm.get(created.id)?.setup).toMatchObject({ + state: "interrupted", stepIndex: 0, stepCount: 0, + }); + // A setup step can be expensive or destructive and the user did not ask + // for one on this launch. + expect(second.runs).toEqual([]); + expect(second.terminal.has(created.id)).toBe(false); + // No transcript either — it died with the PTY that wrote it, so the app + // must not be offered a log it cannot replay. + expect(second.sm.get(created.id)?.setup?.terminalId).toBeUndefined(); + // ...and the gate is open: a recovered state has no runner to wait for. + await second.sm.start(created.id); + await waitForTerminal(second.terminal, created.id); + }); + }); + + it("reports nothing for a checkout that never had a block to run", async () => { + await withDir(async (dir) => { + await seedCheckout(dir); + const first = harness(dir); + const created = await first.sm.create("Isolated", { isolation: "worktree" }); + + // A marker's absence alone does not mean a run died: every checkout cut + // before the project declared a setup block carries none either, and + // reporting those as interrupted banners every isolated session an + // upgrade inherits. + const second = harness(dir, { declaresSetup: false }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(second.sm.get(created.id)?.setup).toBeUndefined(); + }); + }); + + it("recovers the durable outcome of a run that did finish", async () => { + await withDir(async (dir) => { + await seedCheckout(dir, { setupState: "failed", setupFinishedAt: 42, setupExitCode: 7 }); + const first = harness(dir); + const created = await first.sm.create("Isolated", { isolation: "worktree" }); + first.runs[0]!.report({ state: "failed", stepIndex: 0, stepCount: 1, exitCode: 7 }); + + const second = harness(dir); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(second.sm.get(created.id)?.setup).toMatchObject({ + state: "failed", exitCode: 7, finishedAt: 42, + }); + }); + }); + + it("deleting a session cancels its live setup instead of refusing", async () => { + await withDir(async (dir) => { + await seedCheckout(dir); + const { sm, order, cancelled } = harness(dir); + const created = await sm.create("Isolated", { isolation: "worktree" }); + expect(await sm.delete(created.id)).toBe(true); + expect(cancelled).toEqual([CHECKOUT_ID]); + // Before the teardown and the removal, not after: a live `bun install` + // holding the checkout as its cwd is what makes `git worktree remove` fail + // on Windows. A user deleting a session does not want to be told to wait + // it out. + expect(order).toEqual(["cancel-setup", "teardown", "remove"]); + expect(sm.list()).toHaveLength(0); + }); + }); + + it("a delete's cancel writes no durable marker and starts no services", async () => { + await withDir(async (dir) => { + // The checkout is being reclaimed: there is no runtime left to serve the + // services and no row left for a marker to describe. + await seedCheckout(dir); + const { sm, servicesStarted } = harness(dir); + const created = await sm.create("Isolated", { isolation: "worktree" }); + await sm.start(created.id, "fix the flaky test"); + await sm.delete(created.id); + expect(servicesStarted).toEqual([]); + expect((await new CheckoutStore(dir, "p").get(CHECKOUT_ID))?.setupState).toBeUndefined(); + }); + }); +}); diff --git a/docs/architecture.md b/docs/architecture.md index b20a762c..48e048b7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,15 +52,93 @@ Other dirs: `docs/` (design notes), `scripts/dev.ts` (fallback dev runner), ## Configuration (`antgrid.yaml`) -Flat file, no project wrapper. Top-level keys: `terminals` (long-running, -ordered startup), `commands` (on-demand), `proxies` (port tunneling, optional -`browser:` preview), `layout`, `exclude`. - -The agent uses `process.cwd()` as the project path and derives `projectId` from -`agent.name`. That path is the project root; a session bound to a managed checkout -resolves its working directory from the checkout instead (see Checkout-scoped routing). -The file is located at `./antgrid.yaml` or -`/antgrid.yaml` (`resolveAbDir()` in `antgrid-dir.ts` — `~/.antgrid` -by default, `~/.antgrid-dev` for a local dev build; see `hostDir()` in -`host_discovery.dart`). `getProjectConfig()` synthesizes a `ProjectConfig` for -FileWatcher/PortScanner/TunnelManager. +Flat file, no project wrapper, and the schema is **strict** — an unknown +top-level key fails the load rather than being ignored. `AbConfigSchema` +(`bridge/src/config.ts`) is the source of truth; the keys are `name`, +`relayUrl`, `agent`, `services` (long-running, started with the checkout unless +`autoStart: false`), `commands` (on-demand), `ports` (dev-port detection and +preview tunneling), and `worktree` (below). + +The file is `./antgrid.yaml` or `/antgrid.yaml` (`findConfigFile`; +`resolveAbDir()` in `antgrid-dir.ts` — `~/.antgrid` by default, +`~/.antgrid-dev` for a local dev build; see `hostDir()` in +`host_discovery.dart`). The bridge's folder is the project root: `projectId` is +`computeProjectId(folder)` — a hash of the realpath'd path, case-folded on +Windows/macOS — while the display name is `name`, falling back to the folder's +basename (`projectName`). A session bound to a managed checkout resolves its +working directory from the checkout instead (see Checkout-scoped routing), and +`prepareCheckoutRuntime` (`bridge/src/agent-core.ts`) builds that checkout's own +FileWatcher / PortDetector / TunnelManager from the config found there. + +`${env.VAR}` and `${project.path}` interpolate in `services` and `commands`, +eagerly at load time against `process.cwd()`. + +### `worktree.setup` + +Provisioning for a freshly cut managed worktree. `git worktree add` gives a tree +of tracked files at the base commit — no `node_modules`, no `.env`, no generated +client — so without this block the first isolated session lands in a broken +build, and the checkout's `services` would auto-start into it. + +```yaml +worktree: + setup: + steps: + - name: Copy env files + copy: [".env", "web/.env"] + - name: Install dependencies + run: bun install + - name: Generate Prisma client + run: bun run --filter antgrid-web prisma:generate + workingDir: . + env: + CI: "1" + timeoutMs: 600000 # the whole run, not per step (default 10 min) + onFailure: warn # the only value v1 accepts +``` + +- A step carries **either** `copy` **or** `run`, never both, and `name` is + required — that name is what the progress line renders, which is the entire + point of a named list. +- `copy` sources are read from the **main project** and land at the same + relative path inside the checkout: the point is pulling in the files the + worktree does not have. Both sides must stay under their own root (`pathBelow`, + `bridge/src/worktrees/path-guard.ts`) and an absolute entry is refused — a + checkout's `antgrid.yaml` is branch-supplied content, so + `copy: ["../../.ssh/id_ed25519"]` would otherwise read outside the project and + write outside the worktree. An escape refuses the whole run rather than + skipping the entry. A **missing source is a warning**, written into the + transcript, and the step continues: not every developer has every env file. +- `run` is a shell line (`shell: true`) — the same trust class as `services` and + `commands`, which already run branch-supplied commands on checkout prep. +- `onFailure: warn` is the only accepted value; the enum reserves `block` for a + version whose UI has an escape hatch from a session wedged behind setup. A + failed run never blocks the agent — it leaves a persistent banner. +- The block is honoured **only** from an `antgrid.yaml` that physically lives in + the checkout. `findConfigFile` falls back to `/antgrid.yaml`, and + a machine-global setup block would otherwise run for every project's + worktrees with nobody having asked for it. +- `worktree` is excluded from the eager interpolation pass and resolved lazily + per run by `CheckoutSetupRunner` (`bridge/src/worktrees/checkout-setup.ts`), + because the eager context is `process.cwd()` — the MAIN root — which would + bake main's paths into a checkout's own steps. + +Variables, resolved against the checkout the run belongs to: + +| Variable | Value | +|---|---| +| `${project.path}` | main project root | +| `${checkout.path}` | this managed worktree | +| `${checkout.branch}` | the `antgrid/*` branch Antgrid created | +| `${base.branch}` | what the worktree was cut from (`CheckoutRecord.baseRef`) | +| `${session.id}` | the owning session id | +| `${env.X}` | the bridge process's environment | + +Every `run` step also gets `ANTGRID_PROJECT_PATH`, `ANTGRID_CHECKOUT_PATH`, +`ANTGRID_CHECKOUT_BRANCH`, `ANTGRID_BASE_BRANCH`, `ANTGRID_SESSION_ID` and +`ANTGRID_SETUP=1` in its environment — a branch or base that does not exist is +the empty string, never an absent key. A step's own `env:` wins over that +contract, which wins over the inherited environment. + +Host-side lifecycle — the one PTY the run lives in, the deferred `services`, the +start gate and what survives a restart — is in `bridge/CLAUDE.md`. diff --git a/evals/fixtures/worktree-setup.yaml b/evals/fixtures/worktree-setup.yaml new file mode 100644 index 00000000..6a3a173a --- /dev/null +++ b/evals/fixtures/worktree-setup.yaml @@ -0,0 +1,17 @@ +name: eval-agent +relayUrl: __RELAY_URL__ + +# A freshly cut worktree holds tracked files at the base commit and nothing +# else, so the copy step carries an untracked env file across and the two run +# steps stand in for a slow `bun install`: the hold is the window the gate has +# to be observable in, and the sentinel it writes afterwards is the only +# on-disk witness of "setup has finished". +worktree: + setup: + steps: + - name: Copy env files + copy: [".env.eval", ".env.absent"] + - name: Hold the gate + run: node -e "setTimeout(() => {}, __SETUP_HOLD_MS__)" + - name: Write the sentinel + run: node -e "require('fs').writeFileSync(require('path').join(process.env.ANTGRID_PROJECT_PATH, 'setup-done-' + process.env.ANTGRID_SESSION_ID), 'ok')" diff --git a/evals/tests/gate-worktree-isolation.test.ts b/evals/tests/gate-worktree-isolation.test.ts index e4287781..e0f0fb9b 100644 --- a/evals/tests/gate-worktree-isolation.test.ts +++ b/evals/tests/gate-worktree-isolation.test.ts @@ -9,10 +9,18 @@ // always advertises `checkoutRouting`, so the refusal branch is unreachable // from here. `bridge/tests/worktree-remote-security.test.ts` pins that. import { expect, test } from "bun:test"; +import { existsSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { setupTestEnv } from "../helpers/harness"; import { createMessage, type AbMessage, type SessionEntry } from "../../bridge/src/protocol"; import { bindFirstProject } from "../support/stream"; +/** The slice of the harness's app client these rows drive. */ +interface StreamApp { + sendOnStream(id: string, m: AbMessage): void; + waitFor(p: (m: any) => boolean, t?: number): Promise; +} + async function git(cwd: string, args: string[]): Promise { const proc = Bun.spawn(["git", ...args], { cwd, stdout: "ignore", stderr: "pipe" }); if (await proc.exited !== 0) throw new Error(await new Response(proc.stderr).text()); @@ -28,9 +36,10 @@ async function initRepo(dir: string): Promise { } async function createIsolated( - app: { sendOnStream(id: string, m: AbMessage): void; waitFor(p: (m: any) => boolean, t?: number): Promise }, + app: StreamApp, streamId: string, name: string, + command?: string, ): Promise { const requestId = `create-${name}`; const replyP = app.waitFor( @@ -38,7 +47,7 @@ async function createIsolated( 15_000, ); app.sendOnStream(streamId, createMessage("session:create", { - requestId, name, isolation: "worktree", + requestId, name, isolation: "worktree", command, })); const reply = await replyP; expect(reply.ok).toBe(true); @@ -147,3 +156,240 @@ test("concurrent in-flight requests across checkouts stay correctly attributed", await env.teardown(); } }, 120_000); + +// --- worktree.setup: the start gate, end to end --- +// +// The bridge unit tests drive `CheckoutSetupRunner` against a stand-in terminal +// host. What only shows up here is the whole chain in one piece: a real PTY +// running the bridge's own hidden `worktree-setup` subcommand, its OSC step +// markers travelling back through `onTerminalTitle`, and the resulting `setup` +// block reaching the app on `session:list` over the sealed project stream. + +/** How long the fixture's middle step holds the run open. Every assertion below + * waits for that step to be REPORTED before it starts spending the budget, so + * this is slack for a round trip, not a deadline the test races. */ +const SETUP_HOLD_MS = 10_000; + +/** Mirrors evals/fixtures/worktree-setup.yaml. */ +const SETUP_STEP_COUNT = 3; +const SETUP_HOLD_STEP = "Hold the gate"; + +/** A bare executable with no arguments: bun-pty serializes argv POSIX-style and + * cmd.exe re-parses it, so a quoted launch line (`node -e "…"`) is not a + * portable way to ask for a long-lived PTY. A bare `node` REPL holds the + * terminal open on every platform. */ +const IDLE_AGENT_COMMAND = "node"; + +/** Written AFTER the initial commit on purpose: untracked in main, absent from + * a fresh worktree, and therefore exactly the class of file `copy:` exists to + * carry across. */ +const COPIED_ENV_FILE = ".env.eval"; + +async function prepareSetupProject(dir: string): Promise { + await initRepo(dir); + writeFileSync(join(dir, COPIED_ENV_FILE), "EVAL_SETUP_COPIED=1\n"); +} + +/** The fixture's last step writes this into the MAIN project (via the + * `ANTGRID_PROJECT_PATH` contract), which is the one path both the setup child + * and this test can name. Its existence is the only on-disk witness that a run + * reached the end. */ +function sentinelPath(projectDir: string, sessionId: string): string { + return join(projectDir, `setup-done-${sessionId}`); +} + +let nextSetupRequest = 1; + +async function listSessions(app: StreamApp, streamId: string): Promise { + const requestId = `setup-list-${nextSetupRequest++}`; + const replyP = app.waitFor( + (m: any) => m.type === "session:list:result" && m.requestId === requestId, + 10_000, + ); + app.sendOnStream(streamId, createMessage("session:list", { requestId } as never)); + return (await replyP).sessions as SessionEntry[]; +} + +async function readSession(app: StreamApp, streamId: string, id: string): Promise { + const entry = (await listSessions(app, streamId)).find((s) => s.id === id); + if (!entry) throw new Error(`session ${id} is not in the list`); + return entry; +} + +/** Poll the list until `predicate` holds, and answer with the sample that + * satisfied it. Polled rather than driven off `session:updated`: these rows + * assert what was true at the instant the agent appeared, and a push that + * coalesced two transitions would move that instant. */ +async function waitForSession( + app: StreamApp, + streamId: string, + id: string, + predicate: (entry: SessionEntry) => boolean, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + let last: SessionEntry | undefined; + while (Date.now() < deadline) { + last = await readSession(app, streamId, id); + if (predicate(last)) return last; + await Bun.sleep(250); + } + throw new Error( + `session ${id} never matched within ${timeoutMs}ms ` + + `(running=${last?.running}, setup=${JSON.stringify(last?.setup)})`, + ); +} + +async function startSession(app: StreamApp, streamId: string, sessionId: string): Promise { + const requestId = `setup-start-${nextSetupRequest++}`; + const replyP = app.waitFor( + (m: any) => m.type === "session:result" && m.requestId === requestId, + 15_000, + ); + app.sendOnStream(streamId, createMessage("session:start", { requestId, sessionId })); + return replyP; +} + +async function setupAction( + app: StreamApp, + streamId: string, + sessionId: string, + action: "skip" | "cancel" | "rerun", +): Promise { + const requestId = `setup-${action}-${nextSetupRequest++}`; + const replyP = app.waitFor( + (m: any) => m.type === "session:result" && m.requestId === requestId, + 15_000, + ); + app.sendOnStream(streamId, createMessage("session:setup", { requestId, sessionId, action })); + return replyP; +} + +async function readInCheckout( + app: StreamApp, + streamId: string, + projectId: string, + checkoutId: string, + path: string, +): Promise { + const replyP = app.waitFor( + (m: any) => m.type === "file:content" && m.checkoutId === checkoutId && m.path === path, + 15_000, + ); + app.sendOnStream(streamId, createMessage("file:read", { projectId, path, checkoutId })); + return replyP; +} + +test("an isolated session's agent waits for worktree.setup to finish", async () => { + const env = await setupTestEnv({ + fixtureName: "worktree-setup", + replacements: { "__SETUP_HOLD_MS__": String(SETUP_HOLD_MS) }, + prepareProject: prepareSetupProject, + }); + try { + const { streamId } = await bindFirstProject(env.app, env.projectId); + const session = await createIsolated(env.app, streamId, "gated", IDLE_AGENT_COMMAND); + + // The create reply already carries a live run: an isolated session must + // never look provisioned for the frame before the first progress lands. + expect(session.setup?.state).toBe("running"); + expect(session.running).toBe(false); + + const sentinel = sentinelPath(env.projectDir, session.id); + expect(existsSync(sentinel)).toBe(false); + + // Queued, not refused. The reply is `ok` and the entry it carries says + // `pendingStart` — that flag is the only thing telling the app "queued" + // apart from "started". + const queued = await startSession(env.app, streamId, session.id); + expect(queued.ok).toBe(true); + expect(queued.session?.running).toBe(false); + expect(queued.session?.setup?.pendingStart).toBe(true); + + // Mid-run, on the step the fixture holds open. Naming the step at all + // proves the OSC marker made it out of the child's PTY, through + // `onTerminalTitle`, and onto the wire — that channel has no other witness. + const holding = await waitForSession(env.app, streamId, session.id, + (e) => e.setup?.stepName === SETUP_HOLD_STEP, 30_000); + expect(holding.setup?.stepIndex).toBe(1); + expect(holding.setup?.stepCount).toBe(SETUP_STEP_COUNT); + expect(holding.setup?.terminalId).toBe(`${session.checkoutId}:setup`); + // The gate itself: still queued, and the run has not reached its last step. + expect(holding.running).toBe(false); + expect(holding.setup?.pendingStart).toBe(true); + expect(existsSync(sentinel)).toBe(false); + + // The queued start fires only once the run is over, so the first sample + // that sees the agent must also see the sentinel the last step wrote. + const started = await waitForSession(env.app, streamId, session.id, + (e) => e.running, 60_000); + expect(existsSync(sentinel)).toBe(true); + expect(started.setup?.state).toBe("done"); + expect(started.setup?.exitCode).toBe(0); + expect(started.setup?.pendingStart).toBe(false); + // A `ready` checkout throughout: setup answers "has provisioning finished", + // not "is this workspace usable". + expect(started.checkoutState).toBe("ready"); + + // The copy step read from the main project and wrote at the same relative + // path inside the checkout. `.env.absent` alongside it is missing on + // purpose and did not fail the run — see the `done` above. + const copied = await readInCheckout( + env.app, streamId, env.projectId, session.checkoutId, COPIED_ENV_FILE); + expect(copied.error).toBeFalsy(); + expect(copied.content).toContain("EVAL_SETUP_COPIED=1"); + + await env.app.disconnect(); + } finally { + await env.teardown(); + } +}, 180_000); + +test("Skip launches the queued agent before worktree.setup has finished", async () => { + const env = await setupTestEnv({ + fixtureName: "worktree-setup", + replacements: { "__SETUP_HOLD_MS__": String(SETUP_HOLD_MS) }, + prepareProject: prepareSetupProject, + }); + try { + const { streamId } = await bindFirstProject(env.app, env.projectId); + const session = await createIsolated(env.app, streamId, "skipped", IDLE_AGENT_COMMAND); + const sentinel = sentinelPath(env.projectDir, session.id); + + const queued = await startSession(env.app, streamId, session.id); + expect(queued.ok).toBe(true); + expect(queued.session?.setup?.pendingStart).toBe(true); + + // Skip from inside the hold, so the rest of the row has the whole remaining + // hold as margin — the claim is an ordering one and must not rest on the + // child being slow. + await waitForSession(env.app, streamId, session.id, + (e) => e.setup?.stepName === SETUP_HOLD_STEP, 30_000); + expect(existsSync(sentinel)).toBe(false); + + const skipped = await setupAction(env.app, streamId, session.id, "skip"); + expect(skipped.ok).toBe(true); + + // The agent is up while the run it was queued behind is still going: skip + // releases the gate and nothing else. + const started = await waitForSession(env.app, streamId, session.id, + (e) => e.running, 20_000); + expect(existsSync(sentinel)).toBe(false); + expect(started.setup?.state).toBe("running"); + expect(started.setup?.pendingStart).toBe(false); + + // Cancel rather than leaving the hold to outlive the test: the setup child + // is a grandchild of the bridge, and killing the bridge does not reach it + // on POSIX. It also pins the other half of the contract — a cancelled run + // settles as `skipped`, never as a failure, and never reaches its last step. + const cancelled = await setupAction(env.app, streamId, session.id, "cancel"); + expect(cancelled.ok).toBe(true); + expect(cancelled.session?.setup?.state).toBe("skipped"); + expect(cancelled.session?.running).toBe(true); + expect(existsSync(sentinel)).toBe(false); + + await env.app.disconnect(); + } finally { + await env.teardown(); + } +}, 180_000); From 57f9a5ebf89728765ab3d63ee0e142a005df067e Mon Sep 17 00:00:00 2001 From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:27:04 +0800 Subject: [PATCH 03/15] chore: pin Flutter 3.47.1 across CI, and settle the native_prebuilt question (#3) --- .github/actions/setup-android/action.yml | 2 +- .github/workflows/build-desktop.yml | 2 +- .../workflows/build-ghostty-ios-prebuilt.yml | 2 +- .github/workflows/deploy-ios.yml | 2 +- DEVELOPMENT.md | 2 +- app/ios/Podfile.lock | 2 +- app/macos/Podfile.lock | 2 +- docs/dart-terminal-fork-release.md | 68 ++++++++++++++++--- scripts/setup-cloud.sh | 2 +- 9 files changed, 67 insertions(+), 17 deletions(-) diff --git a/.github/actions/setup-android/action.yml b/.github/actions/setup-android/action.yml index c51da79f..4d99bd13 100644 --- a/.github/actions/setup-android/action.yml +++ b/.github/actions/setup-android/action.yml @@ -16,7 +16,7 @@ runs: - uses: subosito/flutter-action@v2 with: channel: stable - flutter-version: '3.47.0' + flutter-version: '3.47.1' cache: true # cache the Flutter SDK between runs - uses: oven-sh/setup-bun@v2 diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index bd1ed349..478a1a61 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -75,7 +75,7 @@ env: # and a cache is readable only from its own ref or the DEFAULT branch, so every # release wrote 1.8 GB into a scope the next release could not read. The pub # cache is the opposite trade (~158 MB, ~12s) and is worth its bytes. - FLUTTER_VERSION: '3.47.0' + FLUTTER_VERSION: '3.47.1' LICENSE_API_URL: 'https://app.antgrid.ai' # The only hand-maintained part of the version. Bump for a deliberate product # major; the rest of the string is stamped from the clock and the run counter. diff --git a/.github/workflows/build-ghostty-ios-prebuilt.yml b/.github/workflows/build-ghostty-ios-prebuilt.yml index 608d9423..febab35e 100644 --- a/.github/workflows/build-ghostty-ios-prebuilt.yml +++ b/.github/workflows/build-ghostty-ios-prebuilt.yml @@ -40,7 +40,7 @@ jobs: - uses: subosito/flutter-action@v2 with: channel: stable - flutter-version: '3.47.0' # keep in lockstep with deploy-ios + flutter-version: '3.47.1' # keep in lockstep with deploy-ios cache: true - name: Flutter pub get diff --git a/.github/workflows/deploy-ios.yml b/.github/workflows/deploy-ios.yml index 2bf49f21..735a2ad2 100644 --- a/.github/workflows/deploy-ios.yml +++ b/.github/workflows/deploy-ios.yml @@ -118,7 +118,7 @@ jobs: - uses: subosito/flutter-action@v2 with: channel: stable - flutter-version: '3.47.0' # keep in lockstep with setup-android/build-desktop + flutter-version: '3.47.1' # keep in lockstep with setup-android/build-desktop cache: true # Required whenever the iOS prebuilt download comes up empty: portable_pty's diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 501b3047..aaf28b1b 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -107,7 +107,7 @@ before anything imports the database layer**, including `npm run setup` itself. The SDK constraint is `sdk: ^3.11.0` (Dart 3.11 or newer, below 4.0) in `app/pubspec.yaml` and both packages under `packages/`. CI pins Flutter -**3.47.0 stable**, repeated across the workflows and `scripts/setup-cloud.sh` +**3.47.1 stable**, repeated across the workflows and `scripts/setup-cloud.sh` and kept in lockstep — grep `flutter-version` and `FLUTTER_VERSION` to find every copy before bumping. Any stable Flutter whose bundled Dart satisfies `^3.11.0` should work; if you hit something odd, match the CI pin before filing diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock index 72a2c45c..005d975c 100644 --- a/app/ios/Podfile.lock +++ b/app/ios/Podfile.lock @@ -25,7 +25,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/super_native_extensions/ios" SPEC CHECKSUMS: - Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47 irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486 push: 91373ae39c5341c6de6adefa3fda7f7287d646bf super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4 diff --git a/app/macos/Podfile.lock b/app/macos/Podfile.lock index a03aba70..a0c9e44e 100644 --- a/app/macos/Podfile.lock +++ b/app/macos/Podfile.lock @@ -37,7 +37,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: auto_updater_macos: 3a42f1a06be6981f1a18be37e6e7bf86aa732118 - FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + FlutterMacOS: c232990155153907050900a2e175c7773903ba4e irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba push: 91373ae39c5341c6de6adefa3fda7f7287d646bf Sparkle: 39dbdf28637f6056fbf5ee6494bdbfae54f49f41 diff --git a/docs/dart-terminal-fork-release.md b/docs/dart-terminal-fork-release.md index 8f9acf31..d72b4f7e 100644 --- a/docs/dart-terminal-fork-release.md +++ b/docs/dart-terminal-fork-release.md @@ -476,11 +476,12 @@ the generated bindings directly.) ## Open questions — settle before trusting the cutover -Three of the four are now settled and documented in the sections above: the local +All four are now settled and documented in the sections above: the local `.prebuilt/` override survives (stage 2 of the resolver chain, ahead of the download); the source fallback fires only when all three resolvers come back -empty; and Flutter's `hooks_runner` 1.1.1 does drive a `hooks` 2.x hook. What is -left: +empty; and Flutter's `hooks_runner` 1.1.1 does drive a `hooks` 2.x hook. The two +kept below are the ones whose *reasoning* stays load-bearing — re-read them before +touching the iOS link mode or bumping `native_prebuilt` again: 1. **Does `portable_pty`'s iOS link mode still need Antgrid's `artifacts.dart` patch?** **Settled — the static payload does not resolve, and it broke the @@ -501,18 +502,67 @@ left: Reproduced and verified locally on a Mac by simulator build with a cold `.dart_tool/hooks_runner`: the pre-fix fork fails with the exact CI error, the fixed fork builds and bundles `portable_pty_rs.framework` with an - `@rpath` install name. **Untested on a real device** — the App Store path is - only exercised by the next TestFlight run. + `@rpath` install name. + + **The App Store path is confirmed too**, by run 32847826408 — a + `workflow_dispatch` of `deploy-ios` against the fork at `a4f96a`. It archived + `ai.radhaai.antgrid` into a 221.1 MB `Runner.xcarchive`, exported an App Store + IPA and uploaded it to TestFlight. The `has invalid output … link mode + "static"` failure that broke run 31215469869 does not recur on the real + signing-and-archive path, so the dynamic payload resolves there and not only + on the simulator. The related simplification does *not* land yet: `deploy-ios.yml`'s `aarch64-apple-ios` toolchain step must stay. A dynamic iOS slot falls back to compiling the crate whenever the download is unavailable, which is what the local verification actually did. -2. **`native_prebuilt` 0.4.0 is out; the fork pins `^0.3.2`.** Every resolution - rule recorded in this doc was read from 0.3.2. Check the changelog before - bumping, and re-verify the resolver chain if it moves — the local-override - precedence is what both store workarounds rest on. +2. **`native_prebuilt` 0.4.0 — settled; the fork is on `^0.4.0` as of `7a76de4`.** + Every resolution rule recorded in this doc was read from 0.3.2 and **still + holds by construction, not by re-measurement**: `diff -rq` across the two + versions' `lib/` reports exactly one changed file, + `lib/src/binary/binary_inspector.dart`. The resolver chain and the + local-override precedence both store workarounds rest on are byte-identical. + + What 0.4.0 adds is architecture validation — ELF `e_machine`, Mach-O `cputype`, + throwing `BinaryArchitectureException` on mismatch. **It does not cover the + committed iOS override**, which is the one artifact a human places by hand. + `inspector.inspect()` has exactly two call sites, both in + `cache/artifact_installer.dart` (the post-extract path and the cached-file + path), so it guards downloads and the shared cache. The `inspector` field + lives on `SharedCacheResolver`, which forwards it to `DefaultArtifactInstaller`; + `LocalPrebuiltResolver` takes only a `directoryName`, and its `resolve()` + hashes the candidate purely to populate `ResolvedFile.hash` — it validates + nothing and can reject nothing. `scripts/check-ghostty-ios-abi.sh` is still the + only gate on that file. + + Note *where* that leaves the validation, because it is close to inverted: + both call sites run `inspect()` only after the bytes have already matched + `artifact.payloadSha256`. On the download path the hash pins the exact bytes, + so the architecture check is a second opinion about a file already known to be + the intended one. The local override is the one artifact with no manifest hash + to check against — the only place the check would carry real signal — and it + is precisely where it never runs. + + A related trap on the cached-file path: `BinaryArchitectureException` and + `BinaryFormatException` are two independent `final class … implements + Exception` declarations, with no subtyping between them. The installer's + self-heal catches `on BinaryFormatException` and deletes the offending cache + entry, so a malformed file is cleaned up and refetched — but an *architecture* + mismatch escapes that catch, propagates out of the lock, and leaves the file in + place, so every later build fails identically until the shared cache is cleared + by hand. Reaching it requires a payload whose hash matches the manifest while + being built for the wrong target, i.e. a fork-side packaging mistake — exactly + the case this validation exists to catch, and the one it handles worst. + + One more sharp edge if a payload ever goes universal, slightly worse than the + field mix-up alone: `_isMachO` accepts the fat magics + (`0xCAFEBABE`/`0xCAFEBABF`), but `_validateMachOArchitecture` infers endianness + by testing only for `0xFEEDFACE`/`0xFEEDFACF`. A fat header is big-endian by + definition and matches neither, so it takes the little-endian branch and reads + bytes 4–7 — `nfat_arch` in a fat header, not `cputype` — byte-swapped. A + two-slice universal dylib therefore reports `cputype` `0x02000000` and is + rejected as an architecture mismatch. Every slot ships thin today. ## Cutover checklist diff --git a/scripts/setup-cloud.sh b/scripts/setup-cloud.sh index 6dbba36c..51c27ee5 100644 --- a/scripts/setup-cloud.sh +++ b/scripts/setup-cloud.sh @@ -26,7 +26,7 @@ set -uo pipefail # Pin to the toolchain floor documented in CLAUDE.md (Gradle/AGP minimums and # the KGP 2.2.20 invariant assume this). Keep in lockstep with the # FLUTTER_VERSION in .github/workflows/. -FLUTTER_VERSION="3.47.0" +FLUTTER_VERSION="3.47.1" FLUTTER_HOME="/opt/flutter" BUN_MIN="1.3.14" # CLAUDE.md relay floor: below this, APNs fails TLS/ALPN NOTES="/opt/antgrid-setup-notes.txt" # outside the repo, so the snapshot keeps it From 6c3213e6906721a1ed69e277f0a300fdc33b82c3 Mon Sep 17 00:00:00 2001 From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:35:41 +0530 Subject: [PATCH 04/15] fix: close review findings on the worktree.setup path Twelve defects found reviewing the setup work after it merged, plus one found while fixing them. Grouped by what breaks. Setup runs that outlive their own bookkeeping: - `stop()` never cleared the setup gate's `pendingStart` and returned early when no PTY existed, so stopping a session queued behind setup was a total no-op: the agent still launched, with the original prompt, when setup settled. `archive()` handles exactly this at the sibling line. - `settleSetup`'s tail was not guarded by run identity. `cancelSetupRun` suspends on `killSetupTree` for as long as a real install tree takes to reap on Windows; a rerun arriving in that window passes the only guard (state already moved to "skipped"), clears the marker and mints run #2 -- and the stale settle then stamps run #1's outcome over it and can fire run #2's queued start early. - `begin()` emitted the `running` progress carrying `terminalId` BEFORE `terminals.spawn()`, so a spawn that threw left `setup.terminalId` pointing at a PTY nobody created. The emit now follows a successful spawn. State that survives what should end it: - `sweepCheckoutRuntime` calls `manager.forget()` after `killAndAwaitTree` resolves, which is strictly before node-pty dispatches the exit -- so the exit re-created the `stoppedTerminals` row the sweep had just deleted, and with the owner row gone too the corpse was attributed to main and advertised there for the life of the process. `forget()` now tombstones a terminal whose exit is still owed, and the exit handler honours it. - `recoverSetupStates` re-seeded the durable `done` marker on every launch, so "Workspace ready" came back for every isolated session that ever ran setup, once per launch, with no action to take and dismissal in memory only. - The "checkout record is gone" delete branch cancelled the run but never released the setup hold on failure, stranding a deferred `services:` block with nothing able to release it -- that worktree's dev server never starts again. The sibling catch on the main delete path already did this. Things that were never bounded: - `rerunSetup` required only that a `CheckoutRecord` exist, never that the directory did. A rerun against a checkout removed out of band reported `done` with zero steps and stamped a durable success over an empty tree. - `setupTerminalId()` mints `:setup`, byte-identical to what `internalTerminalId()` computes for a `services:` or terminal slot named `setup` -- and service names are an unconstrained string. The two shared one TerminalManager slot: an app-sent `terminal:stop` reaped the live install, and a slot spawning into it destroyed the retained provisioning transcript "View setup log" still points at. The computed id is now suffixed on collision; the external id the app sees is unchanged. - `parseSetupStepMarker` fed unbounded digit runs from any OSC-2 title on the setup PTY straight into wire state, so a long enough run yielded `Infinity` -- which `SessionEntry.setup.stepIndex` declares as `z.number().int().nonnegative()` and `JSON.stringify` emits as `null`. The setup PTY runs arbitrary shell from the checkout's own antgrid.yaml. - `copyStep` used `copyFileSync`, so any directory in a `copy:` list failed the whole run with a raw EISDIR -- nothing in the schema or the docs restricts `copy` to files. Now `cpSync` with `recursive` off `statSync`. Two more: - The resolved plan -- every `run` line and `env` value with `${env.*}` already expanded -- was written as plaintext JSON with default permissions into a shared directory, and a force-kill leaves it there indefinitely. Plan dir is now 0700 and the file 0600. - Both `void this.settleSetup(...)` and `void this.recoverSetupStates()` were fire-and-forget with no `.catch`, in a process whose `unhandledRejection` handler shuts the whole host down. The rejection surface is empty only by coincidence today. `checkout-setup.ts` guards its own `void` call with a comment naming this escalation. - `_dismissedRunKey` was a single nullable slot on a State shared by every session in the workspace, so dismissing one session's banner un-dismissed every other. Now a Set, matching `_expandedSessionId`/`_actingSessionId`, whose doc comments say why a single slot misbehaves across a switch. Not fixed here, deliberately: - `focusedSessionByClient` is keyed by `InboundSource`, which has two values, so every remote client collapses into one slot -- a second phone steals the suppression and the completion push lands on the wrong one. A correct fix needs per-client identity the wire does not carry. - An in-app project switch clears no focus entry, so a stale entry mutes the push for exactly the long run it exists for. Same missing identity. - `rerunSetup` warms the checkout through `resolveCheckout`, which prepares the runtime with no `deferServices` -- so a rerun starts `services:` into an unprovisioned worktree and runs the install underneath it. The coherent fix changes a helper shared by three call sites and alters the `holdsServices` contract; that is a behaviour decision, not a mechanical one. --- app/lib/widgets/session_setup_banner.dart | 9 +++-- bridge/src/agent-core.ts | 11 +++++- bridge/src/cli/worktree-setup.ts | 9 ++++- bridge/src/session-manager.ts | 48 ++++++++++++++++++++--- bridge/src/terminal-manager.ts | 24 ++++++++++++ bridge/src/worktrees/checkout-setup.ts | 37 ++++++++++++----- 6 files changed, 115 insertions(+), 23 deletions(-) diff --git a/app/lib/widgets/session_setup_banner.dart b/app/lib/widgets/session_setup_banner.dart index a1a799fb..58473c9b 100644 --- a/app/lib/widgets/session_setup_banner.dart +++ b/app/lib/widgets/session_setup_banner.dart @@ -70,7 +70,10 @@ class _SessionSetupBannerState extends ConsumerState { /// Dismissal is per RUN, not per session: a rerun of a setup the user /// dismissed is a new answer to the same question and has to be shown. - String? _dismissedRunKey; + /// A set rather than one slot, keyed like [_expandedSessionId] and + /// [_actingSessionId]: this State survives a session switch, so a single + /// slot would un-dismiss whichever banner the user dismissed first. + final Set _dismissedRunKeys = {}; /// The log is expanded per session, so switching sessions collapses it /// rather than opening a terminal for a workspace the user just left. @@ -103,7 +106,7 @@ class _SessionSetupBannerState extends ConsumerState { } final runKey = '$sessionId|${setup.startedAt}'; - if (_dismissedRunKey == runKey) { + if (_dismissedRunKeys.contains(runKey)) { _syncTail(null, null); return const SizedBox.shrink(); } @@ -221,7 +224,7 @@ class _SessionSetupBannerState extends ConsumerState { AbIconButton( icon: AbIcons.close, tooltip: 'Dismiss', - onTap: () => setState(() => _dismissedRunKey = runKey), + onTap: () => setState(() => _dismissedRunKeys.add(runKey)), ), ], ); diff --git a/bridge/src/agent-core.ts b/bridge/src/agent-core.ts index 9b4b7c56..6e2673f4 100644 --- a/bridge/src/agent-core.ts +++ b/bridge/src/agent-core.ts @@ -37,7 +37,7 @@ import { CheckoutStore } from "./worktrees/checkout-store"; import { resolveProject } from "./worktrees/project-resolver"; import { CheckoutRuntimeRegistry } from "./worktrees/checkout-runtime-registry"; import type { CheckoutRecord, CheckoutSetupProgress } from "./worktrees/checkout-types"; -import { CheckoutSetupRunner } from "./worktrees/checkout-setup"; +import { CheckoutSetupRunner, setupTerminalId } from "./worktrees/checkout-setup"; import { SessionNamer } from "./session-namer"; import { antigravityCliHome } from "./agents/antigravity/title"; import { AntigravityTitleWatcher } from "./agents/antigravity/title-watcher"; @@ -627,8 +627,15 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise:setup` (see `setupTerminalId`), + // and `services:`/terminal slot names are unconstrained — a slot literally + // named `setup` would otherwise land in the same TerminalManager slot and + // reap the live run, or take over its retained transcript once it exits. + const namespaced = computed === setupTerminalId(runtime.checkout.id) + ? `${computed}:slot` + : computed; runtime.configuredTerminalIds.set(terminalId, namespaced); // Re-recorded on every call, not just the first: terminal exit drops the // owner row, and a restarted slot reuses the same namespaced id. diff --git a/bridge/src/cli/worktree-setup.ts b/bridge/src/cli/worktree-setup.ts index 704d3520..92312201 100644 --- a/bridge/src/cli/worktree-setup.ts +++ b/bridge/src/cli/worktree-setup.ts @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; import { dirname } from "node:path"; import { SetupPlanFileSchema, @@ -80,7 +80,12 @@ function copyStep(step: SetupPlanStep): StepFailure | null { } try { mkdirSync(dirname(entry.to), { recursive: true }); - copyFileSync(entry.from, entry.to); + // `copy` is an unconstrained list of paths and nothing in the schema or + // the docs restricts it to files, so a directory (`certs/`, `.vscode/`) + // has to work rather than abort the whole run on EISDIR. + cpSync(entry.from, entry.to, { + recursive: statSync(entry.from).isDirectory(), + }); } catch (err) { return { exitCode: 1, message: `${step.name}: could not copy ${entry.rel} — ${(err as Error).message}` }; } diff --git a/bridge/src/session-manager.ts b/bridge/src/session-manager.ts index 9edfc6c5..8bf7d5ec 100644 --- a/bridge/src/session-manager.ts +++ b/bridge/src/session-manager.ts @@ -436,7 +436,9 @@ export class SessionManager { this.projectPath = opts.projectPath; this.agentSpec = opts.agentSpec; this.load(); - void this.recoverSetupStates(); + void this.recoverSetupStates().catch((err) => { + log.warn("recovering checkout setup states failed: %s", err); + }); } /** @@ -912,6 +914,10 @@ export class SessionManager { await this.opts.teardownCheckoutRuntime?.(entry.checkoutId); } catch (error) { if (this.clearDeleting(entry.id)) this.notifyObservers(); + // The session survived its delete, so the setup this flow cancelled is + // still holding its `services:` back with nothing else able to release + // them — same reason as the sibling catch on the main delete path. + await this.releaseSetupHold(entry); throw error; } this.entries.delete(entry.id); @@ -1300,7 +1306,9 @@ export class SessionManager { // tick the run ended, while the disk write behind them takes as long as it // takes. this.notifyObservers(); - void this.settleSetup(entry, setup); + void this.settleSetup(entry, setup).catch((err) => { + log.warn(`settling setup for session ${entry.id} failed: ${err}`); + }); } /** The tail of a finished run: release the services it held back, fire the @@ -1316,6 +1324,11 @@ export class SessionManager { log.warn(`deferred services for checkout ${entry.checkoutId} failed to start: ${err}`); } } + // Everything below belongs to THIS run. `cancelSetupRun` settles only + // after awaiting a kill, and a `rerun` arriving during that wait mints a + // new run whose deliberate marker clear this stamp would overwrite — + // leaving a durable outcome recorded over a run that is still going. + if (this.setups.get(entry.id) !== setup) return; this.firePendingStart(entry.id); await this.stampSetupMarker(entry.checkoutId, setup); } @@ -1433,6 +1446,13 @@ export class SessionManager { const checkout = await this.opts.resolveCheckout?.(entry.checkoutId) ?? await this.opts.worktreeManager?.recordFor(this.opts.projectId, entry.checkoutId); if (!checkout) throw new WorktreeError("WORKTREE_MISSING", "The isolated worktree is no longer available."); + // The record outliving the directory is the case that matters: a worktree + // removed out of band leaves `resolveSetup` unable to find the checkout's + // own antgrid.yaml, and a run with no steps reports `done` — stamping a + // durable success over a checkout that has no files in it at all. + if (!existsSync(checkout.path)) { + throw new WorktreeError("WORKTREE_MISSING", "The isolated worktree is no longer available."); + } // Cleared BEFORE the run rather than overwritten after it: a bridge that // dies mid-rerun must come back `interrupted`, and the previous run's `done` // would claim otherwise. @@ -1514,18 +1534,24 @@ export class SessionManager { // with WORKTREE_MISSING. if (!record) continue; if (!record.setupState && this.opts.checkoutDeclaresSetup?.(record) === false) continue; + // `done` is deliberately NOT re-seeded. A finished run offers no action, + // and `startedAt` can only fall back to the session's creation time — so + // a recovered success re-announces "Workspace ready" for every isolated + // session on every launch, with only an in-memory dismissal against it. + // Absent state is the correct report for a checkout already provisioned. + if (record.setupState === "done") continue; this.setups.set(entry.id, { // No runner behind a recovered state, so no report may ever land on it. runId: 0, - state: record?.setupState ?? "interrupted", + state: record.setupState ?? "interrupted", // The step counts died with the run; only its outcome was durable. stepIndex: 0, stepCount: 0, - exitCode: record?.setupExitCode, + exitCode: record.setupExitCode, // No terminal either: the transcript died with the PTY that wrote it, // so the app must not offer a log it cannot replay. startedAt: entry.createdAt, - finishedAt: record?.setupFinishedAt, + finishedAt: record.setupFinishedAt, gateReleased: true, holdsServices: false, }); @@ -1729,13 +1755,23 @@ export class SessionManager { * caller ignores it, exactly as before. */ stop(id: string): void | Promise { const entry = this.entries.get(id); + // Cleared for the same reason archive() clears it: the kill below cannot + // reach a start that has not happened yet, so a session stopped while its + // checkout is still provisioning would launch its agent anyway the moment + // setup settled. The prompt stays in `lastQueuedPrompt` for a rerun. + const queued = this.setups.get(id); + const unqueued = queued?.pendingStart !== undefined; + if (queued) queued.pendingStart = undefined; if (entry?.mode === "chat") { this.runningChat.delete(id); const torndown = this.opts.onStopChat?.(id); this.changed(); return torndown; } - if (!this.tm.has(id)) return; + if (!this.tm.has(id)) { + if (unqueued) this.changed(); + return; + } this.tm.kill(id); this.changed(); } diff --git a/bridge/src/terminal-manager.ts b/bridge/src/terminal-manager.ts index e9b5abf5..29ad855e 100644 --- a/bridge/src/terminal-manager.ts +++ b/bridge/src/terminal-manager.ts @@ -53,6 +53,14 @@ export class TerminalManager { /** Terminals whose scrollback survives their own exit — see * `retainScrollbackOnExit`. */ private retainScrollback = new Set(); + /** Terminals `forget()` dropped while their PTY was still live. `forget` is + * called from a checkout teardown that has already awaited + * `killAndAwaitTree`, which resolves when the tree is reaped — strictly + * before node-pty dispatches the exit. Without a tombstone that later exit + * re-creates the `stoppedTerminals` row the sweep just deleted, and with the + * owner row gone too `terminalOwner()` attributes the corpse to main and + * advertises it there forever. */ + private forgotten = new Set(); private sendMessage: (msg: AbMessage) => void; private callbacks: TerminalManagerCallbacks; private connState: ConnState; @@ -100,6 +108,7 @@ export class TerminalManager { this.stoppedTerminals.delete(terminalId); if (config.retainScrollbackOnExit) this.retainScrollback.add(terminalId); else this.retainScrollback.delete(terminalId); + this.forgotten.delete(terminalId); const scrollback = new ScrollbackBuffer(); this.scrollbacks.set(terminalId, scrollback); @@ -159,6 +168,17 @@ export class TerminalManager { // terminal is dead, and nothing later corrects it. const current = this.sessions.get(terminalId); if (current !== undefined && current !== session) return; + // Forgotten while still live: the owner row is already gone, so the + // exit frame would be stamped with main's checkout and the + // bookkeeping below would resurrect the very rows `forget` deleted. + if (this.forgotten.delete(terminalId)) { + this.sessions.delete(terminalId); + this.scrollbacks.delete(terminalId); + this.modeTrackers.delete(terminalId); + this.retainScrollback.delete(terminalId); + this.connState.clearTerminal(terminalId); + return; + } this.sendMessage(msg); // Preserve metadata so the tab stays visible in status this.stoppedTerminals.set(terminalId, { @@ -238,6 +258,7 @@ export class TerminalManager { this.sessions.clear(); this.scrollbacks.clear(); this.retainScrollback.clear(); + this.forgotten.clear(); this.terminalTypes.clear(); this.stoppedTerminals.clear(); } @@ -252,6 +273,9 @@ export class TerminalManager { * re-retaining a buffer nobody can reach. */ forget(terminalId: string): void { + // Only when an exit is still owed — a terminal that already exited has no + // callback left to tombstone, and an unconsumed one would leak. + if (this.sessions.has(terminalId)) this.forgotten.add(terminalId); this.retainScrollback.delete(terminalId); this.scrollbacks.delete(terminalId); this.modeTrackers.delete(terminalId); diff --git a/bridge/src/worktrees/checkout-setup.ts b/bridge/src/worktrees/checkout-setup.ts index db9ba312..565d9046 100644 --- a/bridge/src/worktrees/checkout-setup.ts +++ b/bridge/src/worktrees/checkout-setup.ts @@ -48,7 +48,14 @@ export interface SetupStepMarker { export function parseSetupStepMarker(title: string): SetupStepMarker | null { const m = SETUP_MARKER_RE.exec(title); if (!m) return null; - return { index: Number(m[1]), count: Number(m[2]), name: m[3] }; + const index = Number(m[1]); + const count = Number(m[2]); + // The setup PTY runs arbitrary shell from the checkout's own antgrid.yaml, so + // any command it invokes can write this title. An unbounded digit run parses + // to `Infinity`, which `SessionEntry.setup.stepIndex` declares as an int and + // JSON.stringify emits as `null` — a wire value the app cannot decode. + if (!Number.isSafeInteger(index) || !Number.isSafeInteger(count)) return null; + return { index, count, name: m[3] }; } /** One copy pair, both sides already absolute and already proven to sit inside @@ -297,9 +304,13 @@ export class CheckoutSetupRunner { const resultPath = join(this.planDir, `${checkout.id}.result.json`); const plan = this.buildPlan(setup, checkout, sessionId, resultPath, env); - mkdirSync(this.planDir, { recursive: true }); + // Owner-only, both of them: `buildPlan` has already expanded `${env.*}` + // into every `run` line and `step.env` value, so the plan is a plaintext + // copy of whatever secrets the config referenced. A default 0644 in a + // shared home directory publishes them to every local account. + mkdirSync(this.planDir, { recursive: true, mode: 0o700 }); rmSync(resultPath, { force: true }); - writeFileSync(planPath, JSON.stringify(plan, null, 2), "utf8"); + writeFileSync(planPath, JSON.stringify(plan, null, 2), { encoding: "utf8", mode: 0o600 }); const run: ActiveRun = { checkoutId: checkout.id, @@ -325,13 +336,6 @@ export class CheckoutSetupRunner { log.warn(`setup timeout handling for ${run.checkoutId} failed: ${(err as Error).message}`); }); }, run.timeoutMs); - onProgress({ - state: "running", - stepIndex: 0, - stepCount: run.stepCount, - stepName: run.stepName, - terminalId, - }); const command = this.selfCommand(planPath); try { @@ -360,6 +364,19 @@ export class CheckoutSetupRunner { this.cleanupFiles(run); throw err; } + // Emitted only once the PTY exists. This is the report that registers + // `terminalId` as a setup terminal, and a spawn that threw would otherwise + // leave the wire pointing `setup.terminalId` at a PTY nobody created — the + // `failed` report that follows carries no terminalId to correct it with. + // Still ahead of any output: `spawn` is synchronous and node-pty dispatches + // its first chunk on a later turn. + onProgress({ + state: "running", + stepIndex: 0, + stepCount: run.stepCount, + stepName: run.stepName, + terminalId, + }); } private buildPlan( From 08b6f8eadaaa7713309ea2c581a31bbdc95ef631 Mon Sep 17 00:00:00 2001 From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:32:58 +0800 Subject: [PATCH 05/15] ci: offset build numbers past the old repo, and gate build-desktop on RELEASE_REPO (#7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_number is scoped to one workflow in one repository and restarts at 1 here, but the stores remember every build number they have ever accepted. This repo's counters sit at 4 and 5 against high-water marks of 190 (Play) and 120 (App Store Connect), so arming publishing without an offset submits versionCodes far below what Play already holds and every upload is rejected. vars.BUILD_NUMBER_OFFSET was already set to 1000 here and nothing read it. deploy-ios, deploy-android and build-desktop now stamp run_number + offset, validating the offset is a non-negative integer first. Ported from the origin repo so the three files are byte-identical to it apart from the Flutter pin. build-desktop also gains the RELEASE_REPO gate the other publishing workflows already had. It writes to antgrid-releases and the Microsoft Store, neither scoped to a repo, so both repos holding RELEASES_TOKEN could publish to the same destinations from a v* tag. The gate goes on the version job because every other job needs it and none uses always()/!cancelled(), so the skip propagates — unlike deploy-ios's gate job, whose empty output satisfies a != 'false' test. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MBugxfV5xQPq3F94WWqDP9 --- .github/workflows/build-desktop.yml | 45 +++++++++++++++++++++------- .github/workflows/deploy-android.yml | 33 ++++++++++++++++---- .github/workflows/deploy-ios.yml | 33 ++++++++++++++++---- 3 files changed, 89 insertions(+), 22 deletions(-) diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 478a1a61..9b372e24 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -86,11 +86,17 @@ jobs: # CalVer-ish: .., e.g. 1.20662.412. # - days-since-epoch is UTC integer division, so it has no leading-zero or # DST hazard and increases exactly once a day, forever. - # - run_number discriminates several releases within the same day. + # - the build number discriminates several releases within the same day. # The commit and the build time are NOT encoded here (a hex sha is not a legal # version component in semver, App Store, or MSIX) — they ride alongside as # dart-defines. version: + # Only the repo that owns the release destinations publishes. This workflow + # writes to `antgrid-releases` and the Microsoft Store, neither of which is + # scoped to a repo, so an ungated second repo in this org republishes over + # the first's releases with its own counter. Every downstream job needs + # `version`, so gating here disarms the whole workflow. + if: vars.RELEASE_REPO == 'true' runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -98,6 +104,7 @@ jobs: outputs: name: ${{ steps.compute.outputs.name }} build_name: ${{ steps.compute.outputs.build_name }} + build_number: ${{ steps.compute.outputs.build_number }} msix: ${{ steps.compute.outputs.msix }} tag: ${{ steps.compute.outputs.tag }} sha: ${{ steps.compute.outputs.sha }} @@ -110,9 +117,26 @@ jobs: IS_RELEASE: ${{ inputs.release }} MODE: ${{ inputs.build_mode || 'release' }} PLATFORM: ${{ inputs.platform || 'all' }} + BUILD_NUMBER_OFFSET: ${{ vars.BUILD_NUMBER_OFFSET }} run: | set -euo pipefail + # run_number is scoped to ONE workflow in ONE repository and restarts + # at 1 in a fresh repo, while Sparkle compares against the + # CFBundleVersion baked into the INSTALLED app -- so a repo move walks + # the counter back under what is already in the field. The guard in + # the macOS job turns that into a hard failure rather than a dead + # update affordance, but the offset is what stops it happening. + # + # Unset means 0, and unset is correct on the repo that owns the + # original counter -- that is what keeps both repos from consuming the + # same offset range while publishing still runs from the old one. + offset="${BUILD_NUMBER_OFFSET:-0}" + case "$offset" in + *[!0-9]*|'') echo "::error::BUILD_NUMBER_OFFSET must be a non-negative integer, got '$offset'"; exit 1 ;; + esac + build_number=$(( GITHUB_RUN_NUMBER + offset )) + if [ "$GITHUB_REF_TYPE" = tag ]; then # A hand-pushed v* tag stays authoritative: the tag IS the version. name="${GITHUB_REF_NAME#v}" @@ -127,7 +151,7 @@ jobs: echo "::error::release=true needs platform=all — publish requires the macOS and Linux artifacts" exit 1 fi - name="${VERSION_MAJOR}.$(( $(date -u +%s) / 86400 )).${GITHUB_RUN_NUMBER}" + name="${VERSION_MAJOR}.$(( $(date -u +%s) / 86400 )).${build_number}" fi # MSIX takes four 16-bit integers with the revision pinned to 0, and it @@ -150,6 +174,7 @@ jobs: # full string for the tag, the release title, and APP_VERSION. { echo "name=$name" + echo "build_number=$build_number" echo "build_name=$core" echo "msix=$core.0" echo "tag=v$name" @@ -271,7 +296,7 @@ jobs: run: | flutter build macos --${{ env.BUILD_MODE }} \ --build-name=${{ needs.version.outputs.build_name }} \ - --build-number=${{ github.run_number }} \ + --build-number=${{ needs.version.outputs.build_number }} \ --dart-define=SENTRY_DSN=${{ secrets.SENTRY_DSN }} \ --dart-define=APP_VERSION=${{ needs.version.outputs.name }} \ --dart-define=GIT_SHA=${{ needs.version.outputs.sha }} \ @@ -449,7 +474,7 @@ jobs: # release-notes link at a tag that does not exist. TAG: ${{ needs.version.outputs.tag }} VERSION: ${{ needs.version.outputs.name }} - BUILD: ${{ github.run_number }} + BUILD: ${{ needs.version.outputs.build_number }} SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} # Sparkle tools release used for sign_update only (the framework the # app embeds comes from the auto_updater pod, independently). @@ -457,10 +482,10 @@ jobs: run: | set -euo pipefail # The app's own detection compares the version triple, but Sparkle's - # install compares CFBundleVersion (--build-number, i.e. run_number) - # — a release whose triple rises while the build number does not - # lights the in-app row and then has Sparkle report "up to date": a - # permanently dead affordance. + # install compares CFBundleVersion (--build-number, i.e. the + # offset run counter) — a release whose triple rises while the + # build number does not lights the in-app row and then has Sparkle + # report "up to date": a permanently dead affordance. # Enforce a strictly increasing build against the currently # published appcast. 404 = first release / earlier appcasts skipped # as unverifiable (nothing to compare); any OTHER fetch failure or @@ -624,7 +649,7 @@ jobs: - name: Build Windows app id: build working-directory: app - run: flutter build windows --${{ env.BUILD_MODE }} --build-name=${{ needs.version.outputs.build_name }} --build-number=${{ github.run_number }} --dart-define=SENTRY_DSN=${{ secrets.SENTRY_DSN }} --dart-define=APP_VERSION=${{ needs.version.outputs.name }} --dart-define=GIT_SHA=${{ needs.version.outputs.sha }} --dart-define=BUILD_TIME=${{ needs.version.outputs.built }} + run: flutter build windows --${{ env.BUILD_MODE }} --build-name=${{ needs.version.outputs.build_name }} --build-number=${{ needs.version.outputs.build_number }} --dart-define=SENTRY_DSN=${{ secrets.SENTRY_DSN }} --dart-define=APP_VERSION=${{ needs.version.outputs.name }} --dart-define=GIT_SHA=${{ needs.version.outputs.sha }} --dart-define=BUILD_TIME=${{ needs.version.outputs.built }} - name: Copy bridge into output folder if: ${{ steps.build.outcome == 'success' }} @@ -830,7 +855,7 @@ jobs: run: | flutter build linux --${{ env.BUILD_MODE }} \ --build-name=${{ needs.version.outputs.build_name }} \ - --build-number=${{ github.run_number }} \ + --build-number=${{ needs.version.outputs.build_number }} \ --dart-define=SENTRY_DSN=${{ secrets.SENTRY_DSN }} \ --dart-define=APP_VERSION=${{ needs.version.outputs.name }} \ --dart-define=GIT_SHA=${{ needs.version.outputs.sha }} \ diff --git a/.github/workflows/deploy-android.yml b/.github/workflows/deploy-android.yml index 8cb6e54c..bbc0ea3c 100644 --- a/.github/workflows/deploy-android.yml +++ b/.github/workflows/deploy-android.yml @@ -101,26 +101,47 @@ jobs: id: version env: VERSION_MAJOR: '1' + BUILD_NUMBER_OFFSET: ${{ vars.BUILD_NUMBER_OFFSET }} run: | set -euo pipefail + + # run_number is scoped to ONE workflow in ONE repository and restarts + # at 1 in a fresh repo, but the stores remember every build number + # they have ever ACCEPTED for the product -- internal-track and + # TestFlight uploads included, so "not released publicly yet" does not + # reset it. Moving repos therefore walks the counter back under + # numbers already taken and every upload is rejected. The offset lifts + # the new repo's sequence clear of the old one's high-water mark. + # + # Unset means 0, and unset is correct on the repo that owns the + # original counter -- that is what keeps both repos from consuming the + # same offset range while publishing still runs from the old one. + offset="${BUILD_NUMBER_OFFSET:-0}" + case "$offset" in + *[!0-9]*|'') echo "::error::BUILD_NUMBER_OFFSET must be a non-negative integer, got '$offset'"; exit 1 ;; + esac + build_number=$(( GITHUB_RUN_NUMBER + offset )) + { - echo "name=${VERSION_MAJOR}.$(( $(date -u +%s) / 86400 )).${GITHUB_RUN_NUMBER}" + echo "name=${VERSION_MAJOR}.$(( $(date -u +%s) / 86400 )).${build_number}" + echo "build_number=${build_number}" echo "sha=${GITHUB_SHA:0:6}" echo "built=$(date -u +%Y-%m-%dT%H:%M:%SZ)" } >> "$GITHUB_OUTPUT" # Override pubspec's static +1 versionCode: Play rejects any upload whose # versionCode was already used, and every CI build would otherwise stamp 1. - # github.run_number is monotonic per workflow, so each publish gets a fresh, - # increasing code. It must stay this workflow's OWN run_number — sourcing it - # from another workflow's counter would restart the sequence below codes - # Play has already seen and every upload would be rejected. + # The stamped build number is monotonic, so each publish gets a fresh, + # increasing code. It must stay THIS workflow's own run_number plus the + # repo-wide offset — sourcing the counter from another workflow, or + # dropping the offset, restarts the sequence below codes Play has already + # seen and every upload is rejected. - name: Build signed release AAB working-directory: app run: | flutter build appbundle --release \ --build-name=${{ steps.version.outputs.name }} \ - --build-number=${{ github.run_number }} \ + --build-number=${{ steps.version.outputs.build_number }} \ --dart-define=SENTRY_DSN=${{ secrets.SENTRY_DSN }} \ --dart-define=APP_VERSION=${{ steps.version.outputs.name }} \ --dart-define=GIT_SHA=${{ steps.version.outputs.sha }} \ diff --git a/.github/workflows/deploy-ios.yml b/.github/workflows/deploy-ios.yml index 735a2ad2..fa4554c8 100644 --- a/.github/workflows/deploy-ios.yml +++ b/.github/workflows/deploy-ios.yml @@ -219,25 +219,46 @@ jobs: id: version env: VERSION_MAJOR: '1' + BUILD_NUMBER_OFFSET: ${{ vars.BUILD_NUMBER_OFFSET }} run: | set -euo pipefail + + # run_number is scoped to ONE workflow in ONE repository and restarts + # at 1 in a fresh repo, but the stores remember every build number + # they have ever ACCEPTED for the product -- internal-track and + # TestFlight uploads included, so "not released publicly yet" does not + # reset it. Moving repos therefore walks the counter back under + # numbers already taken and every upload is rejected. The offset lifts + # the new repo's sequence clear of the old one's high-water mark. + # + # Unset means 0, and unset is correct on the repo that owns the + # original counter -- that is what keeps both repos from consuming the + # same offset range while publishing still runs from the old one. + offset="${BUILD_NUMBER_OFFSET:-0}" + case "$offset" in + *[!0-9]*|'') echo "::error::BUILD_NUMBER_OFFSET must be a non-negative integer, got '$offset'"; exit 1 ;; + esac + build_number=$(( GITHUB_RUN_NUMBER + offset )) + { - echo "name=${VERSION_MAJOR}.$(( $(date -u +%s) / 86400 )).${GITHUB_RUN_NUMBER}" + echo "name=${VERSION_MAJOR}.$(( $(date -u +%s) / 86400 )).${build_number}" + echo "build_number=${build_number}" echo "sha=${GITHUB_SHA:0:6}" echo "built=$(date -u +%Y-%m-%dT%H:%M:%SZ)" } >> "$GITHUB_OUTPUT" - # github.run_number is monotonic per workflow, so each upload gets a fresh, + # The stamped build number is monotonic, so each upload gets a fresh, # increasing CFBundleVersion — App Store Connect rejects a duplicate build - # number, exactly like Play rejects a reused versionCode. It must stay this - # workflow's OWN run_number: sourcing it from another workflow's counter - # would restart the sequence below build numbers ASC has already accepted. + # number, exactly like Play rejects a reused versionCode. It must stay THIS + # workflow's own run_number plus the repo-wide offset: sourcing the counter + # from another workflow, or dropping the offset, restarts the sequence + # below build numbers ASC has already accepted. - name: Build signed release IPA working-directory: app run: | flutter build ipa --release \ --build-name=${{ steps.version.outputs.name }} \ - --build-number=${{ github.run_number }} \ + --build-number=${{ steps.version.outputs.build_number }} \ --dart-define=SENTRY_DSN=${{ secrets.SENTRY_DSN }} \ --dart-define=APP_VERSION=${{ steps.version.outputs.name }} \ --dart-define=GIT_SHA=${{ steps.version.outputs.sha }} \ From 4ee100e88068d3b8b01e12652ecf6d62561b3417 Mon Sep 17 00:00:00 2001 From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:27:15 +0530 Subject: [PATCH 06/15] fix: answer state.snapshot with a freshly recomputed per-checkout status A relay app builds its per-checkout terminal tabs from the frames its state.snapshot request is answered with, and from nothing else: terminal:started is not a replay type, and a stream attach runs no resyncState -- only a loopback owner connect does. The handler served the bus cache verbatim, and nothing republishes a checkout's status when a PTY spawns (session:start does not, startCheckoutRuntime's call runs before the spawn, the git poll fires only on a branch change), so the answer could predate every terminal in it. Replayed, that terminal-less status DELETES the tabs the app has -- the isolated session sits on 'waiting for agent...' until an unrelated PTY forces a republish. Recompute each live checkout's status before dispatching the RPC, mirroring the machine control plane's own state.snapshot intercept in host-server.ts. An unchanged payload is a no-op, since the bus dedups on it. Three smaller routing fixes in the same path: resyncState replayed scrollback under an already-externalised terminal id, so sendTerminalFrame's own owner lookup missed and stamped an isolated checkout's replay as main's; terminal:snapshot:request had no wrong-checkout guard (its tree and preview siblings do), so every runtime answered one request and the app applied whichever reply landed last; and the app's seq cutoffs now clear on transport re-establish, since a PTY that exits and respawns across a disconnect restarts its counter at 1 under a cutoff that would filter its entire output. The status-tier scoping moves from inside ProjectStatusNotifier to the stream ProjectSession hands it, so the notifier stays a plain reducer and matches every other per-checkout consumer. --- app/lib/project/project_session.dart | 12 +- app/lib/project/project_status.dart | 41 ++- app/lib/services/terminal_service.dart | 61 ++++- app/test/project/project_session_test.dart | 51 ++++ app/test/services/terminal_service_test.dart | 41 +++ bridge/src/agent-core.ts | 28 +- bridge/tests/agent-core-status-cache.test.ts | 253 +++++++++++++++++++ 7 files changed, 475 insertions(+), 12 deletions(-) create mode 100644 bridge/tests/agent-core-status-cache.test.ts diff --git a/app/lib/project/project_session.dart b/app/lib/project/project_session.dart index 54251618..70b55dc9 100644 --- a/app/lib/project/project_session.dart +++ b/app/lib/project/project_session.dart @@ -86,7 +86,17 @@ class ProjectSession { ? baseProjectId(projectId) : projectId { _router = MessageRouter(transport: transport); - status = ProjectStatusNotifier(_router.status); + // Main's SLICE, not the whole tier. This notifier is the PROJECT's + // status, and an isolated session's worktree runs its own copy of + // antgrid.yaml — same service names, its own ports, its own config + // validity. Fed the raw tier, it folded every checkout's frame in as the + // project's own, last writer wins, and then cached and persisted that. + // Scoping at the stream rather than inside the notifier keeps it uniform + // with every other per-checkout consumer, and a frame carrying no + // checkoutId still lands here — `checkoutIdForEnvelope` answers 'main' + // for it, which is what keeps `agent:hello` (about the agent, not a tree) + // flowing. + status = ProjectStatusNotifier(checkoutStatusStream('main')); _mainCheckoutServices = CheckoutServices(this, 'main'); _checkoutServices['main'] = _mainCheckoutServices; sessionsService = SessionsService.fromSession( diff --git a/app/lib/project/project_status.dart b/app/lib/project/project_status.dart index 35870efb..9ef369e2 100644 --- a/app/lib/project/project_status.dart +++ b/app/lib/project/project_status.dart @@ -139,6 +139,13 @@ class ProjectStatusNotifier extends ValueNotifier { 'command:done', }; + /// [statusStream] must already be scoped to ONE checkout — `ProjectSession` + /// hands it main's slice. This is the PROJECT's status, and an isolated + /// session's worktree runs its own copy of antgrid.yaml (same service names, + /// its own ports, its own config validity), so a raw status tier would fold + /// every checkout's answer in here as the project's own, last writer wins, + /// and then cache and persist it. The reducers below deliberately do no + /// filtering of their own. ProjectStatusNotifier(Stream> statusStream) : super(const ProjectStatus.empty()) { _sub = statusStream.listen(_apply); @@ -193,20 +200,36 @@ class ProjectStatusNotifier extends ValueNotifier { final parsed = parseAbMessage(envelope); if (parsed is AgentStatusMessage) { - next = next.copyWith( - services: parsed.services, - lastUpdatedAt: DateTime.now(), - ); + // Guarded on a genuine change, like the config branch above: the bump + // alone makes `next != value` (lastUpdatedAt is part of ==), so an + // unchanged status would still notify every listener — and the agent now + // publishes one on every PTY start and exit. + if (!listEquals(next.services, parsed.services)) { + next = next.copyWith( + services: parsed.services, + lastUpdatedAt: DateTime.now(), + ); + } } else if (parsed is AgentHello) { next = next.copyWith(agentHello: parsed, lastUpdatedAt: DateTime.now()); } else if (parsed is PortsUpdateMessage) { final ports = parsed.ports.map((p) => p.port).toList(growable: false); - next = next.copyWith(detectedPorts: ports, lastUpdatedAt: DateTime.now()); + if (!listEquals(next.detectedPorts, ports)) { + next = next.copyWith( + detectedPorts: ports, + lastUpdatedAt: DateTime.now(), + ); + } } else if (parsed is CommandDoneMessage) { - next = next.copyWith( - clearActiveCommandName: true, - lastUpdatedAt: DateTime.now(), - ); + // `command:run` is checkout-scoped; the stream this reads is already + // narrowed to one checkout, so a worktree's command finishing cannot + // clear the name of the one still running in the project. + if (next.activeCommandName != null) { + next = next.copyWith( + clearActiveCommandName: true, + lastUpdatedAt: DateTime.now(), + ); + } } if (next != value) value = next; diff --git a/app/lib/services/terminal_service.dart b/app/lib/services/terminal_service.dart index f90dfa41..fb6384b5 100644 --- a/app/lib/services/terminal_service.dart +++ b/app/lib/services/terminal_service.dart @@ -70,8 +70,26 @@ class TerminalService { // git:branches, git:checkout-result. Routed through the focus-gated // router status stream so all dispatch goes through one path. _statusSub = session.checkoutStatusStream(checkoutId).listen(_onStatusJson); + + // Tier-3 re-drive. A seq cutoff is only meaningful against the PTY + // generation it was taken from, and the agent's counter is per PTY: it is + // deleted on exit (`ConnState.clearTerminal`), so a same-id respawn starts + // again at 1. A disconnect is exactly the window in which a terminal can + // exit and respawn unwitnessed — neither `terminal:exited` nor + // `terminal:started` arrives — and nothing on the wire distinguishes the + // new run from the old, so a surviving cutoff sits above every seq the new + // PTY will ever emit and filters its entire output. The tab then renders + // blank behind a live process, with no user action that clears it. Dropped + // wholesale rather than reasoned about per tab: losing a still-valid cutoff + // costs a few duplicated lines on the next snapshot, keeping a stale one + // costs the pane. + session.hydrateCheckout(checkoutId, _seqCutoffHydratorKey, _dropSeqCutoffs); } + static const _seqCutoffHydratorKey = 'terminal:seq-cutoffs'; + + Future _dropSeqCutoffs() async => _snapshotSeq.clear(); + void _setState(TerminalState state) { if (_disposed) return; // Focusing a terminal — by ANY path (list tap, pinned/pushed view, agent @@ -95,7 +113,9 @@ class TerminalService { final parsed = parseAbMessage(json); if (parsed == null) return; if (parsed is TerminalSnapshotMessage) { - _snapshotSeq[parsed.terminalId] = parsed.seq; + // Arming is _applySnapshot's job, not this one's: it bails on a tab that + // vanished between request and reply, and a cutoff armed for scrollback + // nothing rendered filters the live output of the tab that replaces it. _applySnapshot(parsed); return; } @@ -119,6 +139,7 @@ class TerminalService { void _applySnapshot(TerminalSnapshotMessage msg) { final tab = _state.tabs[msg.terminalId]; if (tab == null) return; + _snapshotSeq[msg.terminalId] = msg.seq; // Deliberately NOT `clear()`. That resets the engine, and a reset takes // the guest's MODES with it — alt screen, bracketed paste, focus events, // mouse tracking, synchronised output. A fullscreen TUI sets those once at @@ -256,6 +277,11 @@ class TerminalService { void _handleTerminalExited(TerminalExitedMessage msg) { _settlePendingTerminal(msg.terminalId); _canceledPendingTerminalIds.remove(msg.terminalId); + // The agent's seq counter is per PTY, not per terminal id: it is deleted on + // exit (`ConnState.clearTerminal`), so a same-id respawn restarts at 1. + // A cutoff kept from the previous run sits above every seq the next one + // emits, and would filter its entire output as already-snapshotted. + _snapshotSeq.remove(msg.terminalId); final tab = _state.tabs[msg.terminalId]; if (tab == null) return; @@ -272,6 +298,7 @@ class TerminalService { // Services list is now mirrored into ProjectStatus by ProjectStatusNotifier; // consumers read it from projectStatusProvider. final newTabs = {}; + final discovered = []; for (final info in msg.terminals) { if (_canceledPendingTerminalIds.contains(info.terminalId)) { @@ -325,6 +352,18 @@ class TerminalService { type: info.type, driverClientId: info.driverClientId, ); + // Only for a tab this frame is the FIRST word of, unlike + // _handleTerminalStarted's unconditional pull: a relay app builds its + // tabs from the replayed agent:status rather than from the live started + // frame it never receives, so without this the tab arrives and stays + // blank. Requested after _setState below, not here — _applySnapshot + // drops a reply for a tab it cannot find. + // Not gated on `running`: a terminal whose scrollback the agent RETAINS + // past its own exit — a `worktree.setup` transcript, which the "View + // setup log" action reads after the run — is always stopped by the + // time a client that missed it first sees it, and this is the only pull + // that would ever reach it. + discovered.add(info.terminalId); } } @@ -351,9 +390,25 @@ class TerminalService { layout: msg.layout ?? _state.layout, commands: msg.commands ?? _state.commands, gitBranch: msg.git?.branch ?? _state.gitBranch, + // Carried, not defaulted: a status frame says nothing about an + // in-flight branch list or a checkout error, and rebuilding without + // them empties an open branch picker and swallows the failure toast. + gitBranches: _state.gitBranches, + gitBranchesLoading: _state.gitBranchesLoading, + gitBranchesError: _state.gitBranchesError, + gitCheckoutError: _state.gitCheckoutError, needsFirstRun: msg.needsFirstRun, ), ); + // A tab can leave the status without ever exiting — a service dropped + // from antgrid.yaml, a slot renamed. Its cutoff would otherwise outlive it + // and filter the first bytes of whatever later claims the same id. + _snapshotSeq.removeWhere((id, _) => !newTabs.containsKey(id)); + for (final terminalId in discovered) { + // Only the tabs that survived the rebuild: one dropped along the way has + // nowhere for the reply to land. + if (newTabs.containsKey(terminalId)) _requestTerminalSnapshot(terminalId); + } } TerminalTab _createTab({ @@ -708,6 +763,10 @@ class TerminalService { Future dispose() async { if (_disposed) return; _disposed = true; + // Same reason PreviewService deregisters its own: the registry is the + // TRANSPORT's, which outlives this service, so a hydrator left behind + // keeps clearing a dead checkout's cutoffs on every reconnect forever. + session.unhydrateCheckout(checkoutId, _seqCutoffHydratorKey); // Resolve any in-flight git action cleanly so its tier-2 timeout timer is // cancelled instead of outliving the service. _branchesLatch?.settle(); diff --git a/app/test/project/project_session_test.dart b/app/test/project/project_session_test.dart index 44e8767f..efe455fe 100644 --- a/app/test/project/project_session_test.dart +++ b/app/test/project/project_session_test.dart @@ -238,6 +238,57 @@ void main() { }, ); + // The project's status is MAIN's slice of the status tier, never the whole + // tier. An isolated session's worktree runs its own copy of antgrid.yaml — + // same service names, its own ports, its own config validity — and folding + // any of that in here shows, caches and persists the worktree's answer as + // the project's own. Scoped at the stream rather than inside the notifier, + // so it stays a plain reducer and matches every other per-checkout + // consumer; a frame carrying no checkoutId still lands here, which is what + // keeps `agent:hello` (about the agent, not a tree) flowing. + test('status folds main only, and an unstamped frame counts as main', () async { + final t = FakeAgentTransport(); + final cache = await CachedSessionsStore.open(); + final session = ProjectSession( + projectId: 'p1', + transport: t, + mode: ProjectSessionMode.relay, + cachedSessionsStore: cache, + onClose: () async { + await t.dispose(); + }, + ); + + t.emit('agent:status', { + 'projectId': 'p1', + 'checkoutId': 'wt-abc123', + 'terminals': >[], + 'services': [ + {'id': 'dev', 'name': 'dev', 'running': true, 'command': 'x'}, + ], + }); + await Future.delayed(Duration.zero); + expect(session.status.value.services, isEmpty); + + // No checkoutId at all: the agent describes itself, not a working tree. + t.emit('agent:hello', {'version': '1.0.0', 'flags': []}); + await Future.delayed(Duration.zero); + expect(session.status.value.agentHello, isNotNull); + + t.emit('agent:status', { + 'projectId': 'p1', + 'checkoutId': 'main', + 'terminals': >[], + 'services': [ + {'id': 'dev', 'name': 'dev', 'running': true, 'command': 'x'}, + ], + }); + await Future.delayed(Duration.zero); + expect(session.status.value.services, hasLength(1)); + + await session.close(); + }); + test('close invokes onClose exactly once (idempotent)', () async { var closeCount = 0; final t = FakeAgentTransport(); diff --git a/app/test/services/terminal_service_test.dart b/app/test/services/terminal_service_test.dart index 63258704..d74f19a4 100644 --- a/app/test/services/terminal_service_test.dart +++ b/app/test/services/terminal_service_test.dart @@ -331,5 +331,46 @@ void main() { await session.close(); }, ); + + }); + + // A relay app builds its tabs from the replayed agent:status, never from the + // `terminal:started` it was not connected for, so this pull is the only way + // it ever gets a tab's scrollback. Gating it on `running` made a terminal the + // agent RETAINS past its own exit — a `worktree.setup` transcript, which the + // banner's "View setup log" reads after the run — permanently unreachable: + // it is always stopped by the time such a client first sees it. + test('a stopped terminal discovered in the status is still snapshotted', () async { + final t = FakeAgentTransport(); + final session = await newSession(t); + final svc = TerminalService.fromSession(session); + + t.emit('agent:status', { + 'projectId': 'p', + 'terminals': [ + { + 'id': 'wt-1:setup', + 'terminalId': 'wt-1:setup', + 'name': 'setup', + 'running': false, + }, + ], + }); + await Future.delayed(Duration.zero); + + expect(svc.currentState.tabs, contains('wt-1:setup')); + // `isNotEmpty`, not a count: the session builds its own main-checkout + // TerminalService, so both it and the one under test answer this frame. + expect( + t.sent.where( + (m) => + m['type'] == 'terminal:snapshot:request' && + m['terminalId'] == 'wt-1:setup', + ), + isNotEmpty, + ); + + await svc.dispose(); + await session.close(); }); } diff --git a/bridge/src/agent-core.ts b/bridge/src/agent-core.ts index 9b4b7c56..0c7960db 100644 --- a/bridge/src/agent-core.ts +++ b/bridge/src/agent-core.ts @@ -1247,6 +1247,12 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise bus.publish(res, channel)); return; diff --git a/bridge/tests/agent-core-status-cache.test.ts b/bridge/tests/agent-core-status-cache.test.ts new file mode 100644 index 00000000..03f5b265 --- /dev/null +++ b/bridge/tests/agent-core-status-cache.test.ts @@ -0,0 +1,253 @@ +// A relay app rebuilds its per-checkout terminal tabs from ONE thing: the +// frames its `state.snapshot` request is answered with. `terminal:started` is +// not a replay type and a stream attach runs no `resyncState` — only a loopback +// owner connect does — so nothing else ever tells it a PTY exists. Every test +// here therefore asserts through a real snapshot request rather than reading +// the bus cache behind its back: the gap between the two IS the bug. Served +// verbatim, the cached agent:status can be arbitrarily older than the pull, and +// a replayed terminal-less status DELETES the tabs the app already has. All of +// this is invisible to a loopback app, which resyncState feeds regardless. +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { buildAgentCore, type AgentCore } from "../src/agent-core"; +import { MessageBus } from "../src/message-bus"; +import { createMessage, type AbMessage, type SessionEntry } from "../src/protocol"; +import { setLogLevel } from "../src/logger"; + +setLogLevel("error"); + +let root: string; +let previousAbDir: string | undefined; +let core: AgentCore | null; + +beforeEach(() => { + previousAbDir = process.env.ANTGRID_DIR; + root = mkdtempSync(join(tmpdir(), "antgrid-status-cache-")); + process.env.ANTGRID_DIR = join(root, "state"); + writeFileSync(join(root, "antgrid.yaml"), "name: status-cache\n"); + // Outlives the test; the PTY is reaped by core.shutdown(). + writeFileSync(join(root, "keepalive.js"), "setTimeout(() => {}, 600000);\n"); +}); + +afterEach(async () => { + await core?.shutdown(); + core = null; + if (previousAbDir === undefined) delete process.env.ANTGRID_DIR; + else process.env.ANTGRID_DIR = previousAbDir; + rmSync(root, { recursive: true, force: true }); +}); + +// Every test below raises its budget to TEST_MS, because bun's 5s per-test +// default is not enough for a cold `git worktree add` plus an agent-core boot +// on a Windows CI runner. WAIT_MS stays under it so a hang reports WHICH wait +// timed out instead of an anonymous "test timed out after 5000ms". +const WAIT_MS = 20_000; +const TEST_MS = 30_000; + +async function waitFor( + predicate: () => boolean, + what: string, + timeoutMs = WAIT_MS, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error(`timed out waiting for ${what}`); +} + +async function git(args: string[]): Promise { + const proc = Bun.spawn(["git", ...args], { cwd: root, stdout: "ignore", stderr: "pipe" }); + if (await proc.exited !== 0) throw new Error(await new Response(proc.stderr).text()); +} + +async function initRepo(): Promise { + await git(["init"]); + await git(["config", "user.email", "test@antgrid.local"]); + await git(["config", "user.name", "Antgrid Test"]); + await git(["add", "."]); + await git(["commit", "-m", "initial"]); +} + +async function bootCore(): Promise<{ bus: MessageBus; sent: AbMessage[] }> { + core = await buildAgentCore({ + folder: root, + mode: "local", + worktreeSessionsSupported: true, + identity: { deviceId: "agent", deviceName: "agent", createdAt: new Date().toISOString() }, + }); + const bus = new MessageBus(); + const sent: AbMessage[] = []; + bus.subscribe({ deliver: (message) => sent.push(message) }); + core.attachTransport(bus); + core.onHandshakeComplete(); + await waitFor(() => sent.some((m) => m.type === "agent:status"), "the first agent:status"); + return { bus, sent }; +} + +/** Exactly what a (re)connecting app is handed for [checkoutId] — the frames a + * real `state.snapshot` request answers with, never a peek at the cache behind + * it, which is precisely the difference between a loopback app and a relay + * one. Null when the answer names no status for that checkout at all: an + * assertion that some id is ABSENT would pass just as happily against an empty + * answer, so the negative cases below have to tell the two apart. + * + * Dispatched as loopback only to clear the mobile-access gate, which stands in + * front of the whole inbound handler; the recompute under test sits behind it + * and reads no source. */ +async function pullStatus( + bus: MessageBus, + sent: AbMessage[], + checkoutId: string, +): Promise<{ terminalId: string; running: boolean }[] | null> { + const requestId = crypto.randomUUID(); + bus.dispatchInbound(createMessage("request", { + requestId, method: "state.snapshot", params: { types: ["*"] }, + }), "control", "loopback"); + await waitFor( + () => sent.some((m) => m.type === "response" && m.requestId === requestId), + `the state.snapshot response for ${checkoutId}`, + ); + const res = sent.find((m) => m.type === "response" && m.requestId === requestId); + if (res?.type !== "response" || !res.ok) { + throw new Error(`state.snapshot failed: ${JSON.stringify(res)}`); + } + const { frames } = res.result as { frames: AbMessage[] }; + for (const frame of frames) { + if (frame.type !== "agent:status" || frame.checkoutId !== checkoutId) continue; + return frame.terminals.map((t) => ({ terminalId: t.terminalId, running: t.running })); + } + return null; +} + +async function pullRunning( + bus: MessageBus, + sent: AbMessage[], + checkoutId: string, +): Promise { + return ((await pullStatus(bus, sent, checkoutId)) ?? []) + .filter((t) => t.running) + .map((t) => t.terminalId); +} + +/** The negative half of the per-checkout contract, with the "no status at all" + * escape hatch closed. */ +async function expectPulledWithout( + bus: MessageBus, + sent: AbMessage[], + checkoutId: string, + terminalId: string, +): Promise { + const pulled = await pullStatus(bus, sent, checkoutId); + expect(pulled).not.toBeNull(); + expect(pulled!.map((t) => t.terminalId)).not.toContain(terminalId); +} + +async function startSession( + bus: MessageBus, + sent: AbMessage[], + name: string, + isolation?: "worktree", +): Promise { + const createId = crypto.randomUUID(); + bus.dispatchInbound(createMessage("session:create", { + requestId: createId, name, command: "node keepalive.js", + ...(isolation ? { isolation } : {}), + }), "control", "loopback"); + await waitFor( + () => sent.some((m) => m.type === "session:result" && m.requestId === createId), + "the session:create result", + ); + const created = sent.find( + (m) => m.type === "session:result" && m.requestId === createId, + ); + if (created?.type !== "session:result" || !created.ok || !created.session) { + throw new Error(`session:create failed: ${JSON.stringify(created)}`); + } + const session = created.session; + + const startId = crypto.randomUUID(); + bus.dispatchInbound(createMessage("session:start", { + requestId: startId, sessionId: session.id, + }), "control", "loopback"); + await waitFor( + () => sent.some((m) => m.type === "terminal:started" && m.terminalId === session.id), + "the session PTY to start", + ); + return session; +} + +// The whole bug in one line: nothing republishes a checkout's status when a PTY +// spawns — `session:start` never calls it, `startCheckoutRuntime`'s call runs +// BEFORE the spawn, the git poll fires only on a branch change — so a pull that +// does not recompute is answered with a status that predates the terminal. +test("a PTY started after connect is named by the next snapshot pull", async () => { + const { bus, sent } = await bootCore(); + const session = await startSession(bus, sent, "Main"); + + expect(await pullRunning(bus, sent, "main")).toContain(session.id); +}, TEST_MS); + +test("an isolated session's PTY is pulled for ITS checkout, never for main", async () => { + await initRepo(); + const { bus, sent } = await bootCore(); + const session = await startSession(bus, sent, "Isolated", "worktree"); + expect(session.checkoutId).not.toBe("main"); + + expect(await pullRunning(bus, sent, session.checkoutId)).toContain(session.id); + await expectPulledWithout(bus, sent, "main", session.id); +}, TEST_MS); + +test("an ad-hoc terminal is pulled for its own checkout", async () => { + await initRepo(); + const { bus, sent } = await bootCore(); + const session = await startSession(bus, sent, "Isolated", "worktree"); + const terminalId = "adhoc-1"; + + bus.dispatchInbound(createMessage("terminal:start", { + terminalId, command: "node", args: ["keepalive.js"], checkoutId: session.checkoutId, + }), "control", "loopback"); + await waitFor( + () => sent.some((m) => m.type === "terminal:started" && m.terminalId === terminalId), + "the ad-hoc terminal to start", + ); + + expect(await pullRunning(bus, sent, session.checkoutId)).toContain(terminalId); + await expectPulledWithout(bus, sent, "main", terminalId); +}, TEST_MS); + +// Two neighbouring defects are deliberately NOT pinned here, because this +// change does not fix them and a test that asserts today's behaviour would +// cement them. Both are about ATTRIBUTION rather than freshness, so the pull +// recomputing changes nothing for either: +// +// - An exited ad-hoc terminal leaves its own checkout. `onTerminalExited` +// drops the `terminalOwners` row, so the namespaced id then resolves +// through `terminalOwner`'s "main" default: the stopped tab disappears +// from the checkout the user is looking at and reappears in main's status +// under `:`. +// - A deleted session's PTY is never forgotten. `manager.forget()` is called +// only from `teardownCheckoutRuntime`, over `configuredTerminalIds` — +// which `internalTerminalId` returns early for a session id, so it never +// holds one. The row survives in `stoppedTerminals` for the life of the +// process with both its session entry and its owner row gone, and lands in +// main's status as a tab nothing can close. + +test("a stopped session stops being pulled as running", async () => { + const { bus, sent } = await bootCore(); + const session = await startSession(bus, sent, "Main"); + expect(await pullRunning(bus, sent, "main")).toContain(session.id); + + bus.dispatchInbound(createMessage("session:stop", { + requestId: crypto.randomUUID(), sessionId: session.id, + }), "control", "loopback"); + await waitFor( + () => sent.some((m) => m.type === "terminal:exited" && m.terminalId === session.id), + "the session PTY to exit", + ); + + expect(await pullRunning(bus, sent, "main")).not.toContain(session.id); +}, TEST_MS); From 33b91c0eb7886a7b0a58806fc2943262dd9332dd Mon Sep 17 00:00:00 2001 From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com> Date: Wed, 26 Aug 2026 01:05:35 +0800 Subject: [PATCH 07/15] fix: a terminal's owner row dies with the manager's memory of it, not with the PTY (#9) --- bridge/src/agent-core.ts | 22 +- bridge/src/session-manager.ts | 27 +- bridge/src/terminal-manager.ts | 5 + bridge/tests/session-delete-in-flight.test.ts | 1 + bridge/tests/session-manager-augment.test.ts | 2 +- bridge/tests/session-manager-autoname.test.ts | 1 + bridge/tests/session-manager-resume.test.ts | 1 + bridge/tests/session-manager-worktree.test.ts | 1 + bridge/tests/session-manager.test.ts | 2 + bridge/tests/terminal-owner-lifetime.test.ts | 275 ++++++++++++++++++ 10 files changed, 320 insertions(+), 17 deletions(-) create mode 100644 bridge/tests/terminal-owner-lifetime.test.ts diff --git a/bridge/src/agent-core.ts b/bridge/src/agent-core.ts index 5863eb7a..3bc88e66 100644 --- a/bridge/src/agent-core.ts +++ b/bridge/src/agent-core.ts @@ -2203,13 +2203,9 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise terminalOwners.delete(id)); + }, + // The owner row outlives the PTY and dies with the manager's own + // knowledge of the terminal, never with the process. `sendStatus` routes + // every row `getStatus()` reports through `terminalOwner`, and a stopped + // terminal is reported until it is forgotten — so a row released at exit + // leaves its corpse resolving through the "main" default: the tab + // disappears from the checkout the user is looking at and reappears in + // the primary workspace under its namespaced internal id. + onTerminalForgotten: (id) => { + terminalOwners.delete(id); + setupTerminalIds.delete(id); }, // A notification (osc9/osc777) means the session did something worth // surfacing — float it up the drawer. No-ops for non-session terminals. diff --git a/bridge/src/session-manager.ts b/bridge/src/session-manager.ts index 8bf7d5ec..86a65444 100644 --- a/bridge/src/session-manager.ts +++ b/bridge/src/session-manager.ts @@ -863,11 +863,26 @@ export class SessionManager { return this.deleteManaged(entry, options); } if (this.tm.has(id)) this.tm.kill(id); + this.dropSession(id); + this.changed(); + return true; + } + + /** Everything a delete must release for one session, the TerminalManager's + * own memory of its PTY included. That last part is what nothing did: a + * session terminal is namespaced by nothing, so its whole attribution is the + * owner row agent-core writes, and `forget` is the only signal that releases + * it. Left in `stoppedTerminals` with its entry gone, the corpse resolves + * through `terminalOwner`'s "main" default and is advertised in the primary + * workspace as a stopped agent tab, for the life of the process, with + * nothing on any surface able to close it. Safe to call before the PTY's + * exit lands: `forget` tombstones a still-live terminal so the exit cannot + * re-create the rows it just dropped. */ + private dropSession(id: string): void { + this.tm.forget(id); this.entries.delete(id); this.resumableCache.delete(id); this.setups.delete(id); - this.changed(); - return true; } /** Flag a session's delete as in flight and announce it. Emits without @@ -920,9 +935,7 @@ export class SessionManager { await this.releaseSetupHold(entry); throw error; } - this.entries.delete(entry.id); - this.resumableCache.delete(entry.id); - this.setups.delete(entry.id); + this.dropSession(entry.id); this.clearDeleting(entry.id); // In a finally: the row is already gone from memory and the flag already // cleared, so a flush that throws must not leave the app holding the @@ -1000,9 +1013,7 @@ export class SessionManager { await this.releaseSetupHold(entry); throw error; } - this.entries.delete(entry.id); - this.resumableCache.delete(entry.id); - this.setups.delete(entry.id); + this.dropSession(entry.id); this.clearDeleting(entry.id); // See the sibling tail above: the emit is owed even when the flush fails. try { diff --git a/bridge/src/terminal-manager.ts b/bridge/src/terminal-manager.ts index 29ad855e..428e5438 100644 --- a/bridge/src/terminal-manager.ts +++ b/bridge/src/terminal-manager.ts @@ -40,6 +40,10 @@ export interface TerminalManagerCallbacks { onTerminalExited?: (terminalId: string) => void; onTerminalNotification?: (terminalId: string) => void; onTerminalTitle?: (terminalId: string, title: string) => void; + /** This terminal is gone for good — not exited, FORGOTTEN: nothing will name + * it again and `getStatus` will never report it. The one signal an owner of + * per-terminal state outside this class can key its own release on. */ + onTerminalForgotten?: (terminalId: string) => void; } export class TerminalManager { @@ -281,6 +285,7 @@ export class TerminalManager { this.modeTrackers.delete(terminalId); this.stoppedTerminals.delete(terminalId); this.terminalTypes.delete(terminalId); + this.callbacks.onTerminalForgotten?.(terminalId); } async killAllGracefully(timeoutMs = 5000): Promise { diff --git a/bridge/tests/session-delete-in-flight.test.ts b/bridge/tests/session-delete-in-flight.test.ts index 7699d671..ccefd22e 100644 --- a/bridge/tests/session-delete-in-flight.test.ts +++ b/bridge/tests/session-delete-in-flight.test.ts @@ -16,6 +16,7 @@ function fakeTerminal(alwaysRunning = false) { // A no-op kill is how a wedged PTY is modelled: `stopAndAwait` keeps waiting // on an exit that never comes and times out. kill: (id: string) => { if (!alwaysRunning) running.delete(id); }, + forget: (id: string) => { running.delete(id); }, treeKilled: () => Promise.resolve(), has: (id: string) => alwaysRunning || running.has(id), }; diff --git a/bridge/tests/session-manager-augment.test.ts b/bridge/tests/session-manager-augment.test.ts index 04e04245..e4a89f9c 100644 --- a/bridge/tests/session-manager-augment.test.ts +++ b/bridge/tests/session-manager-augment.test.ts @@ -10,7 +10,7 @@ afterEach(() => { for (const d of dirs.splice(0)) try { rmSync(d, { recursive: t function captureTm() { const spawns: any[] = []; - return { spawns, has: () => false, kill: () => {}, treeKilled: () => Promise.resolve(), spawn: (cfg: any) => { spawns.push(cfg); return cfg.terminalId; } } as any; + return { spawns, has: () => false, kill: () => {}, forget: () => {}, treeKilled: () => Promise.resolve(), spawn: (cfg: any) => { spawns.push(cfg); return cfg.terminalId; } } as any; } test("starting a codex tool session injects the notify -c override", () => { diff --git a/bridge/tests/session-manager-autoname.test.ts b/bridge/tests/session-manager-autoname.test.ts index dee0a19d..5c59d7ee 100644 --- a/bridge/tests/session-manager-autoname.test.ts +++ b/bridge/tests/session-manager-autoname.test.ts @@ -9,6 +9,7 @@ function makeTm() { return { has: (id: string) => live.has(id), kill: (id: string) => { live.delete(id); }, + forget: (id: string) => { live.delete(id); }, treeKilled: () => Promise.resolve(), spawn: (cfg: any) => { live.add(cfg.terminalId); return cfg.terminalId; }, __live: live, diff --git a/bridge/tests/session-manager-resume.test.ts b/bridge/tests/session-manager-resume.test.ts index 9c22b92b..c16d5ba7 100644 --- a/bridge/tests/session-manager-resume.test.ts +++ b/bridge/tests/session-manager-resume.test.ts @@ -10,6 +10,7 @@ function makeTm() { return { has: (id: string) => live.has(id), kill: (id: string) => { live.delete(id); }, + forget: (id: string) => { live.delete(id); }, treeKilled: () => Promise.resolve(), spawn: (cfg: any) => { live.add(cfg.terminalId); spawns.push(cfg); return cfg.terminalId; }, __spawns: spawns, diff --git a/bridge/tests/session-manager-worktree.test.ts b/bridge/tests/session-manager-worktree.test.ts index b33da73d..f2875498 100644 --- a/bridge/tests/session-manager-worktree.test.ts +++ b/bridge/tests/session-manager-worktree.test.ts @@ -17,6 +17,7 @@ function fakeTerminal() { return opts.terminalId; }, kill: (id: string) => running.delete(id), + forget: (id: string) => running.delete(id), treeKilled: () => Promise.resolve(), has: (id: string) => running.has(id), spawns, diff --git a/bridge/tests/session-manager.test.ts b/bridge/tests/session-manager.test.ts index 73b17c61..a99acb2f 100644 --- a/bridge/tests/session-manager.test.ts +++ b/bridge/tests/session-manager.test.ts @@ -26,6 +26,7 @@ function makeFakeTerm() { return cfg.terminalId!; }, kill: (id: string) => { spawned.delete(id); }, + forget: (id: string) => { spawned.delete(id); }, treeKilled: () => Promise.resolve(), has: (id: string) => spawned.has(id), }; @@ -38,6 +39,7 @@ function makeLingeringTerm() { return { spawn: (cfg: { terminalId?: string }) => { live.add(cfg.terminalId!); return cfg.terminalId!; }, kill: (_id: string) => {}, + forget: (_id: string) => {}, treeKilled: () => Promise.resolve(), has: (id: string) => live.has(id), exit: (id: string) => { live.delete(id); }, diff --git a/bridge/tests/terminal-owner-lifetime.test.ts b/bridge/tests/terminal-owner-lifetime.test.ts new file mode 100644 index 00000000..c3e8881d --- /dev/null +++ b/bridge/tests/terminal-owner-lifetime.test.ts @@ -0,0 +1,275 @@ +// `sendStatus` routes every row `manager.getStatus()` reports through +// `terminalOwner`, and a STOPPED terminal is reported until it is forgotten — +// not until it exits. So the owner row has to outlive the PTY by exactly as +// long as the manager's memory of it does. Released any earlier, the corpse +// resolves through `terminalOwner`'s "main" default and is advertised in the +// primary workspace instead of the checkout it belongs to. +// +// Every assertion below reads the CACHED status — the frame `state.snapshot` +// serves a reconnecting app — after forcing a fresh publish, rather than the +// live frames the test happened to see. Attribution is only observable in what +// a later client is handed. +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { buildAgentCore, type AgentCore } from "../src/agent-core"; +import { MessageBus } from "../src/message-bus"; +import { createMessage, type AbMessage, type SessionEntry } from "../src/protocol"; +import { setLogLevel } from "../src/logger"; + +setLogLevel("error"); + +let root: string; +let previousAbDir: string | undefined; +let core: AgentCore | null; + +beforeEach(() => { + previousAbDir = process.env.ANTGRID_DIR; + root = mkdtempSync(join(tmpdir(), "antgrid-owner-lifetime-")); + process.env.ANTGRID_DIR = join(root, "state"); + writeFileSync(join(root, "antgrid.yaml"), "name: owner-lifetime\n"); + // Outlives the test; the PTY is reaped by core.shutdown(). + writeFileSync(join(root, "keepalive.js"), "setTimeout(() => {}, 600000);\n"); +}); + +afterEach(async () => { + await core?.shutdown(); + core = null; + if (previousAbDir === undefined) delete process.env.ANTGRID_DIR; + else process.env.ANTGRID_DIR = previousAbDir; + rmSync(root, { recursive: true, force: true }); +}); + +// A cold `git worktree add` plus an agent-core boot does not fit bun's 5s +// per-test default on a Windows CI runner. WAIT_MS stays under TEST_MS so a +// hang names the wait that timed out. +const WAIT_MS = 20_000; +const SETTLE_MS = 5_000; +const TEST_MS = 30_000; + +async function waitFor( + predicate: () => boolean, + what: string, + timeoutMs = WAIT_MS, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error(`timed out waiting for ${what}`); +} + +async function git(args: string[]): Promise { + const proc = Bun.spawn(["git", ...args], { cwd: root, stdout: "ignore", stderr: "pipe" }); + if (await proc.exited !== 0) throw new Error(await new Response(proc.stderr).text()); +} + +async function initRepo(): Promise { + await git(["init"]); + await git(["config", "user.email", "test@antgrid.local"]); + await git(["config", "user.name", "Antgrid Test"]); + await git(["add", "."]); + await git(["commit", "-m", "initial"]); +} + +async function bootCore(): Promise<{ bus: MessageBus; sent: AbMessage[] }> { + core = await buildAgentCore({ + folder: root, + mode: "local", + worktreeSessionsSupported: true, + identity: { deviceId: "agent", deviceName: "agent", createdAt: new Date().toISOString() }, + }); + const bus = new MessageBus(); + const sent: AbMessage[] = []; + bus.subscribe({ deliver: (message) => sent.push(message) }); + core.attachTransport(bus); + core.onHandshakeComplete(); + await waitFor(() => sent.some((m) => m.type === "agent:status"), "the first agent:status"); + return { bus, sent }; +} + +/** The terminals a reconnecting app would be handed for [checkoutId]. Null when + * the checkout has no cached status at all — an assertion that some id is + * ABSENT would pass just as happily against an empty answer, so the negative + * cases have to tell the two apart. */ +function cachedStatus( + bus: MessageBus, + checkoutId: string, +): { terminalId: string; running: boolean }[] | null { + for (const frame of bus.getSnapshot(["agent:status"])) { + if (frame.type !== "agent:status" || frame.checkoutId !== checkoutId) continue; + return frame.terminals.map((t) => ({ terminalId: t.terminalId, running: t.running })); + } + return null; +} + +function statusCount(sent: AbMessage[], checkoutId: string): number { + return sent.filter((m) => m.type === "agent:status" && m.checkoutId === checkoutId).length; +} + +/** Re-drive the owner connect and wait for every named checkout to publish a + * fresh status — which is literally the scenario under test: what a client + * arriving AFTER the exit or the delete is told. `resyncState` forces its + * sends past the bus's payload dedup, so a new frame is proof of a new + * publish and not of a changed payload. */ +async function reconnectApp( + sent: AbMessage[], + ...checkoutIds: string[] +): Promise { + const before = checkoutIds.map((id) => statusCount(sent, id)); + core!.onHandshakeComplete(); + await Promise.all(checkoutIds.map((id, i) => waitFor( + () => statusCount(sent, id) > before[i]!, + `a fresh agent:status for ${id}`, + ))); +} + +/** A delete kills the PTY but cannot reap it: `forget` TOMBSTONES a still-live + * session rather than evicting it, so the row leaves `getStatus()` only once + * the exit lands, and the exit itself is then suppressed — there is no frame + * to wait on. So reconnect until the phantom is gone, and leave the verdict to + * the caller's assertion: a row that is still there when this gives up is the + * regression, and naming it beats a timeout message. */ +async function settleDelete( + bus: MessageBus, + sent: AbMessage[], + checkoutId: string, + terminalId: string, +): Promise { + const deadline = Date.now() + SETTLE_MS; + do { + await reconnectApp(sent, checkoutId); + const cached = cachedStatus(bus, checkoutId) ?? []; + if (!cached.some((t) => t.terminalId === terminalId)) return; + } while (Date.now() < deadline); +} + +function expectCachedWithout(bus: MessageBus, checkoutId: string, terminalId: string): void { + const cached = cachedStatus(bus, checkoutId); + expect(cached).not.toBeNull(); + expect(cached!.map((t) => t.terminalId)).not.toContain(terminalId); +} + +async function startSession( + bus: MessageBus, + sent: AbMessage[], + name: string, + opts: { isolation?: "worktree"; command?: string } = {}, +): Promise { + const createId = crypto.randomUUID(); + bus.dispatchInbound(createMessage("session:create", { + requestId: createId, name, command: opts.command ?? "node keepalive.js", + ...(opts.isolation ? { isolation: opts.isolation } : {}), + }), "control", "loopback"); + await waitFor( + () => sent.some((m) => m.type === "session:result" && m.requestId === createId), + "the session:create result", + ); + const created = sent.find((m) => m.type === "session:result" && m.requestId === createId); + if (created?.type !== "session:result" || !created.ok || !created.session) { + throw new Error(`session:create failed: ${JSON.stringify(created)}`); + } + const session = created.session; + + const startId = crypto.randomUUID(); + bus.dispatchInbound(createMessage("session:start", { + requestId: startId, sessionId: session.id, + }), "control", "loopback"); + await waitFor( + () => sent.some((m) => m.type === "terminal:started" && m.terminalId === session.id), + "the session PTY to start", + ); + return session; +} + +async function deleteSession( + bus: MessageBus, + sent: AbMessage[], + sessionId: string, + removeCheckout: boolean, +): Promise { + const deleteId = crypto.randomUUID(); + bus.dispatchInbound(createMessage("session:delete", { + requestId: deleteId, sessionId, force: true, ...(removeCheckout ? { removeCheckout } : {}), + }), "control", "loopback"); + await waitFor( + () => sent.some((m) => m.type === "session:result" && m.requestId === deleteId), + "the session:delete result", + ); + const res = sent.find((m) => m.type === "session:result" && m.requestId === deleteId); + if (res?.type !== "session:result" || !res.ok) { + throw new Error(`session:delete failed: ${JSON.stringify(res)}`); + } +} + +// The row's whole job. An ad-hoc terminal in a non-main checkout runs under a +// namespaced `:` id, so nothing but the owner row can say +// which checkout it belongs to: the session store has never heard of it, and +// the id itself is not consulted. +test("an exited terminal stays in ITS checkout's status and never lands in main's", async () => { + await initRepo(); + const { bus, sent } = await bootCore(); + const session = await startSession(bus, sent, "Isolated", { isolation: "worktree" }); + const terminalId = "adhoc-exits"; + + bus.dispatchInbound(createMessage("terminal:start", { + terminalId, command: "node", args: ["-e", "0"], checkoutId: session.checkoutId, + }), "control", "loopback"); + await waitFor( + () => sent.some((m) => m.type === "terminal:exited" && m.terminalId === terminalId), + "the ad-hoc terminal to exit", + ); + await reconnectApp(sent, "main", session.checkoutId); + + const isolated = cachedStatus(bus, session.checkoutId); + expect(isolated).not.toBeNull(); + expect(isolated!.find((t) => t.terminalId === terminalId)).toEqual({ + terminalId, + running: false, + }); + expectCachedWithout(bus, "main", terminalId); + // The namespaced internal id is what a released row actually surfaces, so it + // is the shape worth naming: `terminalOwner`'s fallback answers main for the + // runtime AND hands back the raw id as the external one. + expectCachedWithout(bus, "main", `${session.checkoutId}:${terminalId}`); +}, TEST_MS); + +// A session PTY is namespaced by nothing — `internalTerminalId` returns early +// on a session id — so `forget` is the only thing that can end its life in +// `getStatus()`. Left there with its entry gone, it resolves through the same +// "main" default and renders as an agent-typed stopped tab in the primary +// workspace that no surface can close. +test("a deleted isolated session leaves no phantom tab in main's status", async () => { + await initRepo(); + const { bus, sent } = await bootCore(); + const session = await startSession(bus, sent, "Isolated", { isolation: "worktree" }); + expect(session.checkoutId).not.toBe("main"); + + await deleteSession(bus, sent, session.id, true); + await settleDelete(bus, sent, "main", session.id); + + expectCachedWithout(bus, "main", session.id); +}, TEST_MS); + +// The same defect with no isolation involved at all, on the OTHER delete path: +// `delete`'s shared branch, not `deleteManaged`. The session PTY exits on its +// own here rather than being killed — `tm.kill` only signals, and a conpty child +// can outlive the whole test — so the row under test is unambiguously the +// stopped-terminal corpse the delete has to release, not a live session. +test("a deleted MAIN session leaves no phantom tab either", async () => { + const { bus, sent } = await bootCore(); + const session = await startSession(bus, sent, "Main", { command: "node -e 0" }); + await waitFor( + () => sent.some((m) => m.type === "terminal:exited" && m.terminalId === session.id), + "the session PTY to exit on its own", + ); + await reconnectApp(sent, "main"); + expect((cachedStatus(bus, "main") ?? []).map((t) => t.terminalId)).toContain(session.id); + + await deleteSession(bus, sent, session.id, false); + await settleDelete(bus, sent, "main", session.id); + + expectCachedWithout(bus, "main", session.id); +}, TEST_MS); From dcbfa0a438b5c08220db58c3a8b9604683bc26eb Mon Sep 17 00:00:00 2001 From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:00:30 +0800 Subject: [PATCH 08/15] Make a new-session start a visible, cancellable operation (#10) * feat: make a new-session start a visible, cancellable operation Starting a session was a single in-flight boolean: the form went quiet, and every non-throwing bail-out - a form edit mid-flight, a refused create, a reply that never came - ended the start with nothing on screen to say so. The start now publishes a phase model (newSessionStartProgressProvider): the composer locks its controls and shows the stage it has reached, Send becomes Stop while the start is still abandonable, and Recents grows an optimistic STARTING row that becomes the real one. Esc is owned by a running start rather than leaving the composer behind it. Every bail-out records a NewSessionStartAbortReason, so a start that produces no session still says why. An abort also carries the branch the start already checked out. The checkout runs first and is the one step the app cannot undo, so it is orthogonal to the reason: a Stop, a form edit and a refused create all leave the folder somewhere the user did not ask for, and the snackbar names it. Three smaller things the same surface needed: Recents scrolls back to the row a start adds instead of shoving the list down under the user; a touch start drops the prompt focus, so the soft keyboard does not spring back over the snackbar explaining the abort; and the composer's trailing slot is re-keyed per child, so an ending start does not play the Enter hint's fade-out over the status line's opacity. Reconciliation ordering is this repo's, not the branch this was written on: session:start still goes out before leaveNewSession and is awaited after the hand-off, because a queued isolated start cannot answer for the agent. The hand-off is now gated on the user still standing on the canvas - TerminalScreen watches activeSessionIdProvider, so setting it is itself the yank - and the reply's outcome reaches an unmounted composer as an abort nobody reads, which is cheaper than deriving the reason from which await threw. * fix: close the review findings on the start-progress path Nine fixes from a max-effort review of #10. A double "Start cancelled." snackbar on every Stop: the finally's "has an abort already been recorded?" guard read the abort provider back, but the composer's listener CONSUMES the abort synchronously before startNewSession resumes, so the guard always saw null and recorded a second one. Latched locally instead. A StateError out of a fire-and-forget _submit(): the ActiveSessionsBranchSwitchException arm read ref through _endedByCancel before checking mounted, and a mid-start resize disposes the composer. The mounted check moves first. Pull-to-refresh went inert for short recents lists: ScrollView only defaults to AlwaysScrollableScrollPhysics while it has no controller, so adding the scroll controller silently dropped it. Stated explicitly, matching the empty branch. "Waking ..." could never paint: connecting was published before the activation call with no await in between, so it superseded activating in the same synchronous turn. It now fires from a caller-supplied callback at the point project:start is accepted, which keeps the phase write out of the shared helper the drawer also calls. A detection resolving mid-start was dropped rather than deferred, and for a LOCAL target nothing re-delivers it - the form stayed parked on an agent the machine does not have. The snap is extracted and re-run when the start ends. end() also clears the recorded checkout, which begin() alone could not. One test asserted nothing: the frozen-prompt case ran on the default android platform, where the start's own listener drops prompt focus, so Enter never reached the guard it was testing. Pinned to windows. --- app/lib/providers/new_session_action.dart | 241 ++++++++-- app/lib/providers/new_session_picker.dart | 12 +- app/lib/providers/new_session_start.dart | 368 ++++++++++++++ app/lib/widgets/new_session/branch_menu.dart | 8 +- .../widgets/new_session/environment_menu.dart | 38 +- .../new_session/new_session_composer.dart | 451 +++++++++++++++--- .../new_session/new_session_content.dart | 16 +- app/lib/widgets/new_session/project_menu.dart | 10 +- .../recent_sessions/recent_sessions_tab.dart | 50 ++ .../recent_sessions/starting_session_row.dart | 258 ++++++++++ .../providers/new_session_action_test.dart | 375 +++++++++++++++ app/test/screens/new_session_focus_test.dart | 8 +- .../widgets/new_session_composer_test.dart | 423 ++++++++++++++++ .../recent_sessions_starting_row_test.dart | 196 ++++++++ .../widgets/recent_sessions_tab_test.dart | 78 +++ 15 files changed, 2416 insertions(+), 116 deletions(-) create mode 100644 app/lib/providers/new_session_start.dart create mode 100644 app/lib/widgets/recent_sessions/starting_session_row.dart create mode 100644 app/test/widgets/recent_sessions_starting_row_test.dart diff --git a/app/lib/providers/new_session_action.dart b/app/lib/providers/new_session_action.dart index 741708bb..bb4796da 100644 --- a/app/lib/providers/new_session_action.dart +++ b/app/lib/providers/new_session_action.dart @@ -22,11 +22,13 @@ import 'analytics.dart'; import 'cached_sessions.dart'; import 'control_plane.dart'; import 'new_session_picker.dart'; +import 'new_session_start.dart'; import 'projects.dart'; import 'provider_retry.dart'; import 'providers.dart'; import 'recent_agents.dart'; import 'sessions.dart'; +import 'ui_attention_providers.dart'; /// Thrown when activating a remote project is refused by the retired /// concurrent-remote-agent cap (`SESSION_LIMIT_EXCEEDED`, surfaced by the host @@ -101,7 +103,10 @@ class ActiveSessionsBranchSwitchException implements Exception { /// remote one resolves its machine (cached recent, else the account inventory) /// and lets the connection supervisor bring that machine's socket up. /// -/// Throws on activation/create failure; callers surface the error. +/// Throws on activation/create failure; callers surface the error. Every +/// non-throwing bail-out instead records a [NewSessionStartAbortReason], and +/// each stage publishes itself to [newSessionStartProgressProvider] — a start +/// that ends with nothing to show must still be able to say why. Future startNewSession( ProviderContainer ref, { bool allowActiveSessions = false, @@ -113,6 +118,7 @@ Future startNewSession( ? selection.branch : null; final isolated = ref.read(newSessionIsolatedProvider); + final start = ref.read(newSessionStartProgressProvider.notifier); bool intentIsCurrent() { final currentTarget = ref.read(selectedTargetProjectProvider); final currentSelection = ref.read(newSessionBranchSelectionProvider); @@ -122,15 +128,51 @@ Future startNewSession( : null; return currentTarget?.id == target.id && ref.read(newSessionIsolatedProvider) == isolated && - currentBranch == explicitBranch; + currentBranch == explicitBranch && + !ref.read(newSessionStartCancelRequestedProvider); + } + + // A Stop press and a form edit both fail intentIsCurrent; only the reason + // handed to the composer tells the user which one ended the start. + NewSessionStartAbortReason bailReason() => + ref.read(newSessionStartCancelRequestedProvider) + ? NewSessionStartAbortReason.cancelled + : NewSessionStartAbortReason.intentChanged; + + // Latched here rather than read back off newSessionStartAbortProvider: the + // composer's listener CONSUMES an abort the instant it lands (synchronously, + // before this function resumes), so the provider cannot tell "nothing was + // recorded" from "recorded, and already said" — and the `finally` below + // would answer a Stop that was reported a second time. + var aborted = false; + void abort(NewSessionStartAbortReason reason) { + aborted = true; + start.abort(reason); } // This gate is deliberately checked before doing a shared checkout. An old // bridge strips unknown fields, so sending worktree intent without this // catalog capability would silently create a shared session. - if (isolated && !ref.read(newSessionIsolationReadyProvider)) return; + if (isolated && !ref.read(newSessionIsolationReadyProvider)) { + abort(NewSessionStartAbortReason.isolationUnavailable); + return; + } - ref.read(newSessionStartInFlightProvider.notifier).set(true); + final name = ref.read(newSessionNameProvider).trim(); + start.begin( + // The checkout runs first when there is one, so the status line must open + // on it rather than flashing the activation copy for a frame. + phase: (!isolated && explicitBranch != null) + ? NewSessionStartPhase.switchingBranch + : NewSessionStartPhase.activating, + targetId: target.id, + targetName: target.name, + deviceName: target.isLocal ? '' : _machineLabelFor(ref, target), + agentLabel: _agentLabelFor(ref), + isolated: isolated, + title: _startTitle(ref, name), + branch: explicitBranch, + ); try { // 0. If an explicit branch was selected, perform git checkout BEFORE target activation if (!isolated && explicitBranch != null) { @@ -185,22 +227,33 @@ Future startNewSession( rethrow; } + // The one step of a start that outlives it. Recorded before the next + // checkpoint can bail, so whatever ends this start says the tree moved. + start.markBranchSwitched(explicitBranch); + // Re-verify selected target and branch selection after await - if (!intentIsCurrent()) return; + if (!intentIsCurrent()) { + abort(bailReason()); + return; + } } - final name = ref.read(newSessionNameProvider).trim(); - // 1. Activate the target so `selectedRegistrationIdProvider` points at it. + start.advance(NewSessionStartPhase.activating); final pid = await _activateTargetProject(ref, target); - if (!intentIsCurrent()) return; + if (!intentIsCurrent()) { + abort(bailReason()); + return; + } // 2. Wait for the per-project ProjectSession (transport + services) to finish // constructing before reading any per-project service façade — otherwise the // sync `ref.read(sessionsServiceProvider)` below races the async factory. + start.advance(NewSessionStartPhase.preparing); await ref.read(projectSessionProvider(pid).future); if (ref.read(selectedRegistrationIdProvider) != pid || !intentIsCurrent()) { + abort(bailReason()); return; } @@ -230,8 +283,9 @@ Future startNewSession( // (a throw, not null). Guard the whole create→start block so that thrown // case is handled like the null one — the draft survives and the canvas is // retryable — rather than escaping `startNewSession` as an unhandled async - // error. The in-flight flag is still cleared by the outer `finally`. + // error. Progress is still cleared by the outer `finally`. try { + start.advance(NewSessionStartPhase.creating); final created = await svc.create( name: name.isEmpty ? null : name, tool: tool, @@ -245,9 +299,19 @@ Future startNewSession( // so the user can retry. Only CREATE keeps the user here — once the // session exists it is theirs, and the place to report anything further // about it is the session itself. - if (created == null) return; - if (ref.read(selectedRegistrationIdProvider) != pid) return; + if (created == null) { + abort(NewSessionStartAbortReason.createRefused); + return; + } + // NOT intentChanged: create already landed, so a session exists on the + // bridge that this bail leaves unstarted — saying "nothing was created" + // here would be a lie the user cannot check. + if (ref.read(selectedRegistrationIdProvider) != pid) { + abort(NewSessionStartAbortReason.abandonedAfterCreate); + return; + } final prompt = ref.read(newSessionPromptProvider).trim(); + start.advance(NewSessionStartPhase.launching); // 4. Send the start, then navigate on it rather than on its reply. An // isolated session's start is QUEUED behind the checkout's setup run and @@ -271,22 +335,31 @@ Future startNewSession( raiseRefusal: true, ); - ref.read(activeSessionIdProvider.notifier).set(created.id); - ref - .read(analyticsServiceProvider) - ?.track( - AnalyticsEvents.sessionOpened, - props: {'surface': isMobilePlatform ? 'mobile' : 'desktop'}, - ); - // Leaving the canvas REMOUNTS WorkspaceShell (AppShell swaps the whole - // route), and its bootstrap re-derives the active session from the - // bridge's `lastUsedAt` ranking. Name the session we just started so that - // bootstrap adopts it instead of re-deriving: `lastUsedAt` measures - // ACTIVITY, so a keystroke or an agent notification in another session - // between `session:start` and the list reply outranks this one and steals - // the focus the user just asked for. - ref.read(pendingActiveSessionIdProvider.notifier).set(created.id); - leaveNewSession(ref); + // A start survives the user walking away from the canvas, so only steal + // the focus of someone still standing on it — otherwise the session they + // navigated to would be yanked away by work they already left behind. + // Read here rather than after the reply, because this IS the hand-off: + // TerminalScreen WATCHES activeSessionIdProvider, so setting it is itself + // the yank, and by the time the reply lands the user has either been + // moved or deliberately left behind. + if (ref.read(workbenchSurfaceProvider) == WorkbenchSurface.newSession) { + ref.read(activeSessionIdProvider.notifier).set(created.id); + ref + .read(analyticsServiceProvider) + ?.track( + AnalyticsEvents.sessionOpened, + props: {'surface': isMobilePlatform ? 'mobile' : 'desktop'}, + ); + // Leaving the canvas REMOUNTS WorkspaceShell (AppShell swaps the whole + // route), and its bootstrap re-derives the active session from the + // bridge's `lastUsedAt` ranking. Name the session we just started so + // that bootstrap adopts it instead of re-deriving: `lastUsedAt` + // measures ACTIVITY, so a keystroke or an agent notification in another + // session between `session:start` and the list reply outranks this one + // and steals the focus the user just asked for. + ref.read(pendingActiveSessionIdProvider.notifier).set(created.id); + leaveNewSession(ref); + } // 5. Reconcile the reply now that the user is already in the session. A // queued start is a SUCCESS — the entry comes back carrying @@ -294,8 +367,17 @@ Future startNewSession( // session, an older agent's unknown tool) leaves the draft intact for a // return to this canvas; a CODED refusal still raises past here. final started = await starting; - if (started == null) return; - if (ref.read(selectedRegistrationIdProvider) != pid) return; + if (started == null) { + abort(NewSessionStartAbortReason.startRefused); + return; + } + // The session IS running, so this is not a refusal — but it belongs to a + // project the user has since left. Say where it went instead of clearing + // the draft as if they had landed in it. + if (ref.read(selectedRegistrationIdProvider) != pid) { + abort(NewSessionStartAbortReason.startedAfterSwitch); + return; + } // An accepted start consumes the draft. Navigation itself preserves // drafts, so failures and a later return to this canvas remain editable. resetNewSessionForm(ref); @@ -306,12 +388,89 @@ Future startNewSession( // composer is unmounted and it is the workspace's OperationalErrorToaster // that voices it (the service stamps the reason onto SessionsState.error // before failing the pending request). + // + // The abort is what a CREATE timeout is owed: that one is still on the + // canvas, it is the longest wait this flow has, and ending it without a + // word is the silent vanish the whole progress model exists to remove. A + // start timeout records one too and nobody is left to read it, which is + // cheaper than deciding the reason from which await threw. + abort(NewSessionStartAbortReason.replyTimedOut); } } finally { - ref.read(newSessionStartInFlightProvider.notifier).set(false); + // A Stop press the flow never reached a checkpoint to observe — because a + // throw unwound past every one of them — still ended this start at the + // user's request. Record it here or it dies with the progress it lived on, + // and the composer's catch arms report a failure the user pre-empted. + if (!aborted && ref.read(newSessionStartCancelRequestedProvider)) { + abort(NewSessionStartAbortReason.cancelled); + } + start.end(); } } +/// Machine label for the start status line. Mirrors `_remoteMachineLabel` in +/// `recent_session_row.dart` — hostMachineName → pairing label → inventory +/// machineName → displayName — so the status line and the Recents row it +/// becomes name the same machine the same way. The recents pass runs first +/// because an offline machine the inventory has not listed still has a row +/// there. +/// +/// Ends on the bare uuid rather than a friendlier guess: an unnamed machine is +/// better shown as its id than folded onto another machine's name. +String _machineLabelFor(ProviderContainer ref, PickerProject target) { + final uuid = target.machineUuid ?? baseDeviceUuid(target.id); + String? clean(String? s) => + (s != null && s.trim().isNotEmpty) ? s.trim() : null; + + if (ref.exists(recentAgentsProvider)) { + for (final recent in ref.read(recentAgentsProvider)) { + if (baseDeviceUuid(recent.agentDeviceId) != uuid) continue; + // The MATCH ends the recents pass, name or no name — the same machine + // must not be labelled from the recents here and from the inventory in + // `_remoteMachineLabel`, or the status line and the Recents row it turns + // into would name it two different ways. + return clean(recent.hostMachineName) ?? clean(recent.agentLabel) ?? uuid; + } + } + final inventory = ref.exists(accountAgentsProvider) + ? ref.read(accountAgentsProvider).value + : null; + if (inventory != null) { + for (final agent in inventory) { + if (agent.deviceUuid != uuid) continue; + return clean(agent.machineName) ?? agent.displayName; + } + } + return uuid; +} + +/// Human agent name for the status line: the target's advertised tool list +/// first, then the persisted catalog, then the bridge's registry key — showing +/// `kilo` is honest, naming it after some other agent is not. +/// +/// Every source is read ONLY where something already holds it. Naming a label +/// is not a reason to CREATE one of these: the tool list probes the target (and +/// spawns the local host to do it) and the catalog hydrates off disk, neither +/// of which a start should trigger. The composer keeps both alive for the whole +/// New Session surface, so in practice they answer. +String _agentLabelFor(ProviderContainer ref) => newSessionAgentLabel( + ref.read(newSessionAgentProvider), + ref.exists(newSessionDetectedToolsProvider) + ? ref.read(newSessionDetectedToolsProvider).value + : null, + ref.exists(agentCatalogProvider) ? ref.read(agentCatalogProvider) : null, +); + +/// What the optimistic Recents row shows where a real session shows its title. +/// The prompt stands in for an unnamed session because that is what the bridge +/// will name it from anyway. +String _startTitle(ProviderContainer ref, String name) { + if (name.isNotEmpty) return name; + final prompt = ref.read(newSessionPromptProvider).trim(); + if (prompt.isEmpty) return 'New session'; + return prompt.split('\n').first; +} + /// Test seam over the private [_activateTargetProject]: the production drill-in /// flow runs through [startNewSession], but that also creates+starts a session /// (a full [ProjectSession]); this exposes the activation step alone so its @@ -346,10 +505,27 @@ Future _activateTargetProject( } // Remote target: machineUuid + projectId are populated (Tasks 1–3). + // + // The phase is published from a callback THIS caller supplies rather than by + // the activation helper itself: that helper is shared with the drawer's + // remote-row tap, navigation stays unlocked during a start, and a write from + // there would let an unrelated tap fast-forward this start's phase (`advance` + // refuses rewinds, not foreign forward jumps). Only this caller is a New + // Session start. + // + // It fires where `connecting` actually begins — after `project:start` is on + // the wire — because everything before it (resolving the machine, opening its + // control-plane socket, the promote itself) is `activating`, and publishing + // `connecting` up here instead superseded `activating` in the same + // synchronous turn: no frame ever painted it, so "Waking …" was + // copy the user could not see. return openRemoteProjectForActivation( ref, machineUuid: target.machineUuid!, projectId: target.projectId!, + onAwaitingRunning: () => ref + .read(newSessionStartProgressProvider.notifier) + .advance(NewSessionStartPhase.connecting), ); } @@ -370,10 +546,14 @@ Future _activateTargetProject( /// uuid, but the SELECTED target + returned id are the per-project compound. The /// transport for regId opens its own project socket; the machine keypair /// (baseDeviceUuid(regId) == machineUuid) signs its handshake. +/// [onAwaitingRunning] fires once `project:start` has been accepted and the +/// only thing left is the host's advert — the boundary a caller narrating this +/// wait needs, and null for callers that narrate nothing. Future openRemoteProjectForActivation( ProviderContainer ref, { required String machineUuid, required String projectId, + void Function()? onAwaitingRunning, }) async { final regId = RemoteProject( machineUuid: machineUuid, @@ -430,6 +610,7 @@ Future openRemoteProjectForActivation( cpClient.currentState.lastError, ); } + onAwaitingRunning?.call(); final ok = await awaitProjectRunning(cpClient, projectId); if (!ok) { // Distinguish a legacy relay's retired session cap from a generic transient diff --git a/app/lib/providers/new_session_picker.dart b/app/lib/providers/new_session_picker.dart index 5eb6e14a..05804197 100644 --- a/app/lib/providers/new_session_picker.dart +++ b/app/lib/providers/new_session_picker.dart @@ -29,6 +29,10 @@ import 'sessions.dart'; import 'ui_attention_providers.dart'; import 'value_controller.dart'; +/// [newSessionStartInFlightProvider] is derived from the start-progress model +/// and lives beside it, but its readers have always imported it from here. +export 'new_session_start.dart' show newSessionStartInFlightProvider; + /// Bare device uuid of a `machine:` source id; null for 'local' or the /// 'machine:none' placeholder. Single source of truth for the source-id prefix /// minted by [buildPickerSources] (`machine:$uuid` / `machine:none`). @@ -600,13 +604,6 @@ final newSessionSupportsChatProvider = Provider.autoDispose((ref) { final newSessionAgentTouchedProvider = NotifierProvider, bool>(() => ValueController(false)); -/// True while [startNewSession] is mid-flight creating+starting a session. -/// `_bootstrapSessions` (workspace_shell) checks this and skips its -/// empty->New-Session route so the two paths don't fight over -/// [workbenchSurfaceProvider]. -final newSessionStartInFlightProvider = - NotifierProvider, bool>(() => ValueController(false)); - /// The composer's prompt text. Non-empty text becomes the session's first /// message (chat) or launch argv (terminal) via `session:start.initialPrompt`. final newSessionPromptProvider = @@ -789,7 +786,6 @@ void resetNewSessionForm(ProviderContainer ref) { ref.read(selectedTargetProjectProvider.notifier).set(null); ref.read(selectedSourceIdProvider.notifier).set('local'); ref.read(newSessionAgentTouchedProvider.notifier).set(false); - ref.read(newSessionStartInFlightProvider.notifier).set(false); ref.read(newSessionPromptProvider.notifier).set(''); ref.read(newSessionBranchSelectionProvider.notifier).set(null); ref.read(newSessionIsolatedProvider.notifier).set(false); diff --git a/app/lib/providers/new_session_start.dart b/app/lib/providers/new_session_start.dart new file mode 100644 index 00000000..ba9eb59d --- /dev/null +++ b/app/lib/providers/new_session_start.dart @@ -0,0 +1,368 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'value_controller.dart'; + +/// The stages `startNewSession` walks, in the order it walks them. +/// +/// Declaration order is load-bearing: [NewSessionStartProgress.isCancellable] +/// compares indices against [creating]. +enum NewSessionStartPhase { + /// Optional git checkout, before the target is touched. + switchingBranch, + + /// Resolving + activating the target project (`project:start` when remote). + activating, + + /// Waiting for the machine to report the project running. + connecting, + + /// Waiting for the per-project transport + service façades to construct. + preparing, + + /// `session:create` is on the wire. + creating, + + /// `session:start` is on the wire. + launching, +} + +/// Why a start ended without producing a session. Every non-throwing bail-out +/// in `startNewSession` carries one, so the composer can say what happened +/// instead of the operation vanishing silently. +enum NewSessionStartAbortReason { + /// The user pressed Stop while the start was still cancellable. + cancelled, + + /// Target, branch or isolation changed mid-flight, so the session that would + /// have been created is no longer the one the form describes. + intentChanged, + + /// `session:create` came back without a session. + createRefused, + + /// `session:start` came back without a session and without a coded refusal + /// (a coded one is raised to the composer instead). + startRefused, + + /// Isolated was requested but the target's catalog does not advertise + /// worktree sessions — an old bridge would silently make a shared session. + isolationUnavailable, + + /// The active project changed between `session:create` and `session:start`, + /// so the session exists on the bridge but was never launched. Distinct from + /// [intentChanged], which only covers bail-outs that put nothing on the wire. + abandonedAfterCreate, + + /// The active project changed while `session:start` was on the wire, so the + /// session is running but the app never focused it. The one outcome where + /// the user is owed the session's whereabouts rather than a reason it + /// doesn't exist. + startedAfterSwitch, + + /// A `session:create`/`session:start` reply never arrived. The bridge may + /// hold a session either way, which is the whole reason this is its own + /// reason and not a refusal. + replyTimedOut, +} + +/// One start's outcome: why it produced no session, plus what it nonetheless +/// left behind on the machine. +/// +/// [branchSwitchedTo] is orthogonal to [reason] rather than a reason of its +/// own: the checkout runs FIRST and is the one step of a start the app cannot +/// undo, so every later abort — a Stop, a form edit, a refused create, a reply +/// that never came — ends with the folder on a branch the user did not ask to +/// be left on. Folding it into the reason enum would need a parallel value for +/// each of them. +class NewSessionStartAbort { + final NewSessionStartAbortReason reason; + + /// Branch this start checked out before it ended, or null when it moved + /// nothing. Set only for the shared-checkout path — an isolated start passes + /// its base branch to `session:create` and never touches the project's tree. + final String? branchSwitchedTo; + + const NewSessionStartAbort(this.reason, {this.branchSwitchedTo}); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is NewSessionStartAbort && + other.reason == reason && + other.branchSwitchedTo == branchSwitchedTo; + + @override + int get hashCode => Object.hash(reason, branchSwitchedTo); + + @override + String toString() => branchSwitchedTo == null + ? 'NewSessionStartAbort($reason)' + : 'NewSessionStartAbort($reason, branchSwitchedTo: $branchSwitchedTo)'; +} + +/// Snapshot of one in-flight start: the phase it has reached plus the intent it +/// was launched with. +/// +/// The intent is CAPTURED here rather than re-read from the form providers, so +/// the status line and the optimistic Recents row keep describing the session +/// actually being started even after the user edits the form behind them. +class NewSessionStartProgress { + /// Stage currently running. + final NewSessionStartPhase phase; + + /// Picker target id (local projectId or remote registrationId). + final String targetId; + + /// Project name, for the row subtitle. + final String targetName; + + /// Machine the project lives on. Empty for a local target — there is no + /// machine to wake, and [phaseLabel] words the activating phase accordingly. + final String deviceName; + + /// Human agent name ("Claude Code"), not the tool key. + final String agentLabel; + + /// Explicitly selected branch, null when the start takes the checkout as-is. + final String? branch; + + /// Whether the session was asked for in a managed worktree. + final bool isolated; + + /// Session name, else the leading prompt text — what the optimistic row shows + /// where a real session shows its title. + final String title; + + /// Set by [NewSessionStartController.requestCancel]. `startNewSession` + /// observes it at its intent checkpoints and aborts at the next one. + final bool cancelRequested; + + const NewSessionStartProgress({ + required this.phase, + required this.targetId, + required this.targetName, + required this.deviceName, + required this.agentLabel, + required this.isolated, + required this.title, + this.branch, + this.cancelRequested = false, + }); + + /// Whether Stop is still offered. Once `session:create` is on the wire, + /// abandoning the start would orphan a created-but-unstarted session on the + /// bridge, so from [NewSessionStartPhase.creating] on the user waits it out + /// (bounded by the two 15s reply timeouts). + bool get isCancellable => phase.index < NewSessionStartPhase.creating.index; + + /// Only the two fields a running start ever revises. A general `copyWith` + /// over the captured intent would be a way to make the snapshot stop + /// describing the session actually being started — and its `branch ??` + /// arm could never clear a branch anyway. + NewSessionStartProgress copyWith({ + NewSessionStartPhase? phase, + bool? cancelRequested, + }) => NewSessionStartProgress( + phase: phase ?? this.phase, + targetId: targetId, + targetName: targetName, + deviceName: deviceName, + agentLabel: agentLabel, + branch: branch, + isolated: isolated, + title: title, + cancelRequested: cancelRequested ?? this.cancelRequested, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is NewSessionStartProgress && + other.phase == phase && + other.targetId == targetId && + other.targetName == targetName && + other.deviceName == deviceName && + other.agentLabel == agentLabel && + other.branch == branch && + other.isolated == isolated && + other.title == title && + other.cancelRequested == cancelRequested; + + @override + int get hashCode => Object.hash( + phase, + targetId, + targetName, + deviceName, + agentLabel, + branch, + isolated, + title, + cancelRequested, + ); + + @override + String toString() => + 'NewSessionStartProgress($phase, $targetId, cancelRequested: ' + '$cancelRequested)'; +} + +/// The in-flight start, or null when idle. +/// +/// Lives in a provider rather than composer state because `NewSessionScreen` +/// rebuilds three different trees around the compact breakpoint: a resize mid +/// start disposes the composer, and widget-local state would take the lock with +/// it while the start kept running. +final newSessionStartProgressProvider = + NotifierProvider( + NewSessionStartController.new, + ); + +/// How the last start ended, or null if none has since it was consumed. +/// +/// Separate from [newSessionStartProgressProvider] because it must OUTLIVE the +/// progress it describes: `startNewSession` clears progress in its `finally`, +/// and the composer only gets to message the outcome after the await returns. +final newSessionStartAbortProvider = + NotifierProvider< + ValueController, + NewSessionStartAbort? + >(() => ValueController(null)); + +class NewSessionStartController extends Notifier { + @override + NewSessionStartProgress? build() => null; + + /// Branch the running start has already checked out, or null. Held here + /// rather than on [NewSessionStartProgress] because nothing renders it — it + /// exists only to be folded into whatever abort comes next. + String? _branchSwitchedTo; + + /// Arm a start at [phase] with the intent it was launched with. Clears any + /// abort reason left unconsumed by a previous attempt. + void begin({ + required NewSessionStartPhase phase, + required String targetId, + required String targetName, + required String deviceName, + required String agentLabel, + required bool isolated, + required String title, + String? branch, + }) { + ref.read(newSessionStartAbortProvider.notifier).set(null); + _branchSwitchedTo = null; + state = NewSessionStartProgress( + phase: phase, + targetId: targetId, + targetName: targetName, + deviceName: deviceName, + agentLabel: agentLabel, + isolated: isolated, + title: title, + branch: branch, + ); + } + + /// Move to [phase]. A no-op when idle, so a stage that completes after [end] + /// cannot resurrect the lock — and monotonic, so no publisher can rewind a + /// start already past [NewSessionStartPhase.creating] and re-offer a Stop the + /// flow can no longer honour. Every publisher is inside `startNewSession`; + /// keep it that way, because monotonicity refuses rewinds and cannot refuse a + /// foreign forward jump. + void advance(NewSessionStartPhase phase) { + final current = state; + if (current == null || phase.index <= current.phase.index) return; + state = current.copyWith(phase: phase); + } + + /// Ask the running start to stop. Returns false — and changes nothing — when + /// idle or past the cancel boundary + /// (see [NewSessionStartProgress.isCancellable]). + bool requestCancel() { + final current = state; + if (current == null || !current.isCancellable) return false; + if (current.cancelRequested) return true; + state = current.copyWith(cancelRequested: true); + return true; + } + + /// Note that this start has moved the project's working tree to [branch]. + /// Called the moment the checkout returns, because from then on no outcome + /// of this start can honestly claim it left the folder alone. + void markBranchSwitched(String branch) => _branchSwitchedTo = branch; + + /// Record why this start produced no session. Does NOT clear the progress — + /// `startNewSession` still runs its `finally`, which calls [end]. + void abort(NewSessionStartAbortReason reason) { + ref + .read(newSessionStartAbortProvider.notifier) + .set(NewSessionStartAbort(reason, branchSwitchedTo: _branchSwitchedTo)); + } + + /// Read the pending outcome and clear it, so one abort is messaged once. + NewSessionStartAbort? takeAbort() { + final abort = ref.read(newSessionStartAbortProvider); + if (abort != null) { + ref.read(newSessionStartAbortProvider.notifier).set(null); + } + return abort; + } + + /// Disarm. Idempotent. + /// + /// Drops the checkout too, not only [begin] — `startNewSession` records the + /// isolation-unavailable abort BEFORE it arms, so a branch left standing here + /// would be appended to a start that touched no working tree. + void end() { + state = null; + _branchSwitchedTo = null; + } +} + +/// Whether the user has asked the running start to stop. `startNewSession` +/// folds this into its intent checkpoints. +final newSessionStartCancelRequestedProvider = Provider( + (ref) => ref.watch( + newSessionStartProgressProvider.select( + (p) => p != null && p.cancelRequested, + ), + ), +); + +/// True while a start is in flight. Derived from +/// [newSessionStartProgressProvider], so nothing has to keep a second flag in +/// step with it. +/// +/// Re-exported by `new_session_picker.dart`, which is where its long-standing +/// readers import it from. +final newSessionStartInFlightProvider = Provider( + (ref) => ref.watch(newSessionStartProgressProvider.select((p) => p != null)), +); + +/// The one place New Session phase copy lives — chrome prose, so sans at every +/// call site. +String phaseLabel(NewSessionStartProgress p) { + if (p.cancelRequested) return 'Cancelling...'; + switch (p.phase) { + case NewSessionStartPhase.switchingBranch: + final branch = p.branch; + return branch == null ? 'Switching branch...' : 'Switching to $branch...'; + case NewSessionStartPhase.activating: + // A local target has no machine to wake; the work is the same, the story + // isn't. + return p.deviceName.isEmpty + ? 'Opening ${p.targetName}...' + : 'Waking ${p.deviceName}...'; + case NewSessionStartPhase.connecting: + return 'Starting project...'; + case NewSessionStartPhase.preparing: + return 'Preparing workspace...'; + case NewSessionStartPhase.creating: + return 'Creating session...'; + case NewSessionStartPhase.launching: + return p.agentLabel.isEmpty + ? 'Launching agent...' + : 'Launching ${p.agentLabel}...'; + } +} diff --git a/app/lib/widgets/new_session/branch_menu.dart b/app/lib/widgets/new_session/branch_menu.dart index edb8f292..dc063d73 100644 --- a/app/lib/widgets/new_session/branch_menu.dart +++ b/app/lib/widgets/new_session/branch_menu.dart @@ -12,7 +12,12 @@ import '../../providers/new_session_picker.dart'; import 'environment_menu.dart'; class BranchChip extends ConsumerWidget { - const BranchChip({super.key}); + const BranchChip({super.key, this.enabled = true}); + + /// See [EnvironmentChip.enabled] — the composer owns the frozen state. This + /// is not the same as the local `disabled` below, which only reflects a + /// branch catalog the chip has nothing to open a panel over. + final bool enabled; @override Widget build(BuildContext context, WidgetRef ref) { @@ -53,6 +58,7 @@ class BranchChip extends ConsumerWidget { return ComposerChip( icon: AbIcons.gitBranch, label: label, + enabled: enabled, onTap: (ctx) { if (disabled) return; final anchor = abMenuAnchorRect(ctx); diff --git a/app/lib/widgets/new_session/environment_menu.dart b/app/lib/widgets/new_session/environment_menu.dart index a0ed3775..7db1d4a6 100644 --- a/app/lib/widgets/new_session/environment_menu.dart +++ b/app/lib/widgets/new_session/environment_menu.dart @@ -18,7 +18,12 @@ import 'picker_sources.dart'; /// rail tabs wrote, so control-plane keep-alive (controlPlaneAliveTargetsProvider) /// and target validation keep working unchanged. class EnvironmentChip extends ConsumerWidget { - const EnvironmentChip({super.key}); + const EnvironmentChip({super.key, this.enabled = true}); + + /// Passed down by the composer rather than read from the start-progress + /// provider here: one owner decides when the context row is frozen, and the + /// chip stays mountable on its own in tests. + final bool enabled; @override Widget build(BuildContext context, WidgetRef ref) { @@ -30,6 +35,7 @@ class EnvironmentChip extends ConsumerWidget { return ComposerChip( icon: icon, label: label, + enabled: enabled, onTap: (ctx) async { final anchor = abMenuAnchorRect(ctx); if (anchor == null) return; @@ -140,6 +146,7 @@ class ComposerChip extends StatelessWidget { required this.label, required this.onTap, this.attention = false, + this.enabled = true, }); final String icon; @@ -149,14 +156,24 @@ class ComposerChip extends StatelessWidget { /// Accent styling for "needs a pick" states (e.g. "Select project…"). final bool attention; + /// False freezes the chip: [onTap] is ignored and the frame drops to the + /// disabled palette. Same treatment as [ComposerToggleChip]'s null + /// [ComposerToggleChip.onChanged] — the context row must read as one control + /// set, so a row locked mid-operation cannot have two dialects of "dead". + final bool enabled; + @override Widget build(BuildContext context) { return MouseRegion( - cursor: SystemMouseCursors.click, + cursor: enabled ? SystemMouseCursors.click : SystemMouseCursors.basic, child: Builder( builder: (ctx) { final p = ctx.antgrid; - final fg = attention ? p.accent : p.textPrimary; + final fg = !enabled + ? p.textDisabled + : attention + ? p.accent + : p.textPrimary; final labelStyle = AbTokens.monoStyle( fontSize: AbTokens.fontSm, color: fg, @@ -182,7 +199,11 @@ class ComposerChip extends StatelessWidget { ), decoration: BoxDecoration( border: Border.all( - color: attention ? p.accent : p.borderDefault, + color: !enabled + ? p.borderSubtle + : attention + ? p.accent + : p.borderDefault, ), borderRadius: AbTokens.borderRadius3, ), @@ -215,7 +236,7 @@ class ComposerChip extends StatelessWidget { AbIcon( AbIcons.chevronDown, size: _chipChevronSize, - color: p.textMuted, + color: enabled ? p.textMuted : p.textDisabled, ), ], ], @@ -224,7 +245,12 @@ class ComposerChip extends StatelessWidget { ); return GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () => onTap(ctx), + // Stays live while disabled so the chip keeps swallowing taps + // rather than letting them reach the row beneath it. + onTap: () { + if (!enabled) return; + onTap(ctx); + }, child: _chipInSlot(constraints.maxWidth, body), ); }, diff --git a/app/lib/widgets/new_session/new_session_composer.dart b/app/lib/widgets/new_session/new_session_composer.dart index 1550701a..40bb0f48 100644 --- a/app/lib/widgets/new_session/new_session_composer.dart +++ b/app/lib/widgets/new_session/new_session_composer.dart @@ -14,9 +14,11 @@ import '../../design/widgets/ab_icon.dart'; import '../../design/widgets/ab_cross_fade.dart'; import '../../design/widgets/ab_icon_button.dart'; import '../../design/widgets/ab_kbd.dart'; +import '../../design/widgets/ab_loading.dart'; import '../../design/widgets/ab_menu.dart'; import '../../design/widgets/ab_snack_bar.dart'; import '../../design/widgets/ab_text_field.dart'; +import '../../design/widgets/ab_tooltip.dart'; // The send key moved to the design system (shared with the transcript // composer); re-exported so existing importers keep resolving it from here. @@ -26,6 +28,7 @@ import '../../models/agent_descriptor.dart'; import '../../providers/agent_catalog.dart'; import '../../providers/new_session_action.dart'; import '../../providers/new_session_picker.dart'; +import '../../providers/new_session_start.dart'; import '../../screens/upgrade_screen.dart'; import '../../services/sessions_service.dart' show SessionOperationException; import '../../utils/platform_utils.dart'; @@ -55,15 +58,68 @@ bool newSessionCanStart({ (!isCustom || customCmd.trim().isNotEmpty) && (!isolated || isolationReady); +/// What to say when a start ended without producing a session. +/// +/// A completed checkout is appended to every reason rather than replacing any +/// of them: it is a second fact about the same outcome, and the one the user +/// cannot see from the form they are looking at. Without it a Stop during the +/// checkout reads as "Start cancelled." over a working tree that has moved +/// under every session in the folder. +String _abortCopy(NewSessionStartAbort abort) { + final switched = abort.branchSwitchedTo; + final left = switched == null + ? '' + : ' The folder was already switched to "$switched".'; + return '${_abortReasonCopy(abort.reason)}$left'; +} + +/// [NewSessionStartAbortReason.cancelled] is the user's own doing, so it reads +/// as a confirmation; the rest describe something that happened TO the start, +/// and each names what was and wasn't left behind on the machine. +String _abortReasonCopy(NewSessionStartAbortReason reason) => switch (reason) { + NewSessionStartAbortReason.cancelled => 'Start cancelled.', + NewSessionStartAbortReason.intentChanged => + 'The setup changed while the session was starting, so nothing was ' + 'created. Start again when the form says what you want.', + NewSessionStartAbortReason.createRefused => 'Could not create the session.', + NewSessionStartAbortReason.startRefused => + 'The session was created but did not start.', + NewSessionStartAbortReason.isolationUnavailable => + "This machine can't create isolated sessions — check that its Antgrid is " + 'up to date.', + NewSessionStartAbortReason.abandonedAfterCreate => + 'You switched projects while the session was starting, so it was created ' + 'but never launched.', + NewSessionStartAbortReason.startedAfterSwitch => + 'You switched projects while the session was starting. It launched — find ' + 'it in the project you started it from.', + NewSessionStartAbortReason.replyTimedOut => + "The machine didn't answer in time. Check that project's sessions before " + 'starting another one.', +}; + /// Minimum row slack (hint intrinsic width + trailing gap, with margin) /// before the Enter-to-start hint is worth rendering at all. const double _enterHintMinWidth = 96; +/// The same floor for the phase status line, set lower because the two are not +/// worth the same: the hint restates a shortcut, while the phase copy is the +/// only account of a wait that can run 30s. An ellipsized stage name beats no +/// stage name, so this only has to leave room for the dot and a few characters. +const double _statusLineMinWidth = 44; + /// Bottom-row width below which the controls degrade: the agent selector's /// slot absorbs the row slack (ellipsizing its label) instead of the desktop /// Enter-hint slot. Degradation order is hint → label; the labels never drop. const double _composerRowRoomyMinWidth = 460; +/// Flex the phase status line claims while a start runs, against the agent +/// selector's 1. A phone's bottom row cannot seat both at their intrinsic +/// widths, and a locked selector is the one of the two with nothing left to +/// say — it sheds its label to the glyph the way a narrow context row's chips +/// do, rather than pushing the phase off the line. +const int _statusSlotFlex = 3; + /// Share of the context row a single picker label may claim before it starts /// ellipsizing. Two of them cap out together at well under the full row, which /// is what keeps the fixed-width isolation chip and a readable stub of the @@ -104,7 +160,6 @@ class NewSessionComposer extends ConsumerStatefulWidget { class _NewSessionComposerState extends ConsumerState { late final TextEditingController _prompt; late final FocusNode _promptFocus; - bool _starting = false; bool _hovered = false; bool _promptFocused = false; @@ -142,6 +197,26 @@ class _NewSessionComposerState extends ConsumerState { setState(() => _promptFocused = _promptFocus.hasFocus); } + /// Point an untouched agent pick at a tool the target actually has + /// installed. Custom and user-touched picks are left alone + /// ([firstInstalledAgent] keeps them). + /// + /// Never while a start is on the wire: `startNewSession` reads the agent + /// AFTER its awaits, so a detection landing mid-start would relabel a chip + /// the user was told is locked and launch a tool the status line never + /// named. The in-flight listener re-runs this once the start ends, because + /// the suppressed detection is dropped rather than queued. + void _snapToInstalledAgent() { + if (ref.read(newSessionStartInFlightProvider)) return; + if (ref.read(newSessionAgentTouchedProvider)) return; + final detected = ref.read(newSessionDetectedToolsProvider).value; + if (detected == null || detected.isEmpty) return; + final current = ref.read(newSessionAgentProvider); + ref + .read(newSessionAgentProvider.notifier) + .set(firstInstalledAgent(detected, current)); + } + /// Terminal is the default for EVERY agent — the mode all of them can run, /// and the one chat (still alpha) has to be chosen over deliberately. A /// chat-capable agent no longer opts the session into chat behind the user. @@ -177,6 +252,12 @@ class _NewSessionComposerState extends ConsumerState { key != LogicalKeyboardKey.numpadEnter) { return KeyEventResult.ignored; } + // Swallowed, not ignored, while a start runs: the field is read-only by + // then, but this handler writes the controller directly, so read-only + // alone would still let Shift+Enter edit a prompt already on the wire. + if (ref.read(newSessionStartInFlightProvider)) { + return KeyEventResult.handled; + } if (HardwareKeyboard.instance.isShiftPressed) { _insertPromptNewline(); return KeyEventResult.handled; @@ -206,7 +287,7 @@ class _NewSessionComposerState extends ConsumerState { /// [newSessionCanStart] with the reactive `canSend` in `build` so the two /// can't diverge. bool get _canStart => newSessionCanStart( - starting: _starting, + starting: ref.read(newSessionStartInFlightProvider), hasValidTarget: ref.read(newSessionHasValidTargetProvider), isCustom: ref.read(newSessionAgentProvider) == const CustomAgent(), customCmd: ref.read(newSessionCustomCmdProvider), @@ -214,10 +295,24 @@ class _NewSessionComposerState extends ConsumerState { isolationReady: ref.read(newSessionIsolationReadyProvider), ); + /// The reason [_reportAbort] last consumed. The abort reason is consume-once + /// and the listener below can take it while `_submit` is still unwinding, so + /// the arms that have to know whether the user pressed Stop cannot ask the + /// provider alone. + NewSessionStartAbort? _reportedAbort; + + /// Whether this start ended because the user asked it to. Both sources are + /// consulted because either one may hold the answer depending on whether the + /// listener has run yet. + bool get _endedByCancel => + _reportedAbort?.reason == NewSessionStartAbortReason.cancelled || + ref.read(newSessionStartAbortProvider)?.reason == + NewSessionStartAbortReason.cancelled; + /// Ported verbatim from `_SessionFooterState.build`'s Start button `onTap`. Future _submit() async { if (!_canStart) return; - setState(() => _starting = true); + _reportedAbort = null; try { var allowActiveSessions = false; while (true) { @@ -231,7 +326,14 @@ class _NewSessionComposerState extends ConsumerState { if (allowActiveSessions) { rethrow; } + // Mounted FIRST: `_endedByCancel` reads `ref`, and a WidgetRef read + // on a State that a mid-start resize already disposed throws out of a + // fire-and-forget `_submit()` with nowhere to land. if (!mounted) return; + // A Stop press the throw outran already ended this start. Asking the + // user to confirm a working-tree switch they just cancelled would act + // on the opposite of what they last said. + if (_endedByCancel) return; final confirm = await AbConfirmDialog.show( context: context, title: 'Switch branch?', @@ -258,7 +360,7 @@ class _NewSessionComposerState extends ConsumerState { } on SessionLimitExceededException catch (e) { // A legacy relay's retired cap, not a transient failure — retrying won't // clear it, so say what will and show the plan the account is on. - if (mounted) { + if (mounted && !_endedByCancel) { showAbSnackBar(context, e.userMessage); await openUpgrade(context, ref.container); } @@ -267,7 +369,7 @@ class _NewSessionComposerState extends ConsumerState { // coded arms replace it where its wording names something the reader // can't act on; either beats the raw exception the generic arm prints. // No navigation — the user stays here with the form intact. - if (mounted) { + if (mounted && !_endedByCancel) { showAbSnackBar( context, sessionRefusalCopy( @@ -279,7 +381,9 @@ class _NewSessionComposerState extends ConsumerState { ); } } catch (e) { - if (mounted) { + // A start the user stopped reports the cancel and nothing else: the + // failure it raced is not an outcome they asked about. + if (mounted && !_endedByCancel) { showAbSnackBar( context, 'Failed to start session: $e', @@ -287,10 +391,43 @@ class _NewSessionComposerState extends ConsumerState { ); } } finally { - if (mounted) setState(() => _starting = false); + // In a `finally` so the early returns above — an unconfirmed branch + // switch, a form that drifted while the dialog was up — report like every + // other end of a start rather than leaving the dot to just stop. + _reportAbort(); } } + /// Say why a start ended without a session, exactly once. + /// + /// Driven from a listener as well as from `_submit`, because a start outlives + /// the composer that began it: the New Session screen builds a different tree + /// either side of the compact breakpoint, so the `_submit` continuation can + /// resolve on a State that a resize already disposed. Whichever composer is + /// mounted when the reason lands is the one that says it; `takeAbort` + /// CONSUMES, so the other call is a no-op rather than a second snackbar. + void _reportAbort() { + // Mounted first: reading `ref` on a disposed State throws, and consuming + // the reason there would swallow the only record of why the start ended. + if (!mounted) return; + final abort = ref + .read(newSessionStartProgressProvider.notifier) + .takeAbort(); + if (abort == null) return; + _reportedAbort = abort; + showAbSnackBar( + context, + _abortCopy(abort), + // A cancel the user asked for is a confirmation, not something to read — + // unless it also has to account for a checkout it could not take back. + duration: + abort.reason == NewSessionStartAbortReason.cancelled && + abort.branchSwitchedTo == null + ? null + : const Duration(seconds: 8), + ); + } + @override Widget build(BuildContext context) { // Picking a project (local folder or remote) hands focus straight to the @@ -330,14 +467,8 @@ class _NewSessionComposerState extends ConsumerState { // When detection resolves, snap the (untouched) default to an installed // tool so the default is actually runnable. Custom and user-touched // picks are left alone ([firstInstalledAgent] keeps them). - ref.listen(newSessionDetectedToolsProvider, (_, next) { - final detected = next.value; - if (detected == null || detected.isEmpty) return; - if (ref.read(newSessionAgentTouchedProvider)) return; - final current = ref.read(newSessionAgentProvider); - ref - .read(newSessionAgentProvider.notifier) - .set(firstInstalledAgent(detected, current)); + ref.listen(newSessionDetectedToolsProvider, (_, _) { + _snapToInstalledAgent(); }); // Keep the controller in sync with external writes to the prompt // provider (e.g. resetNewSessionForm clearing it on exit). Guarded on @@ -346,6 +477,30 @@ class _NewSessionComposerState extends ConsumerState { ref.listen(newSessionPromptProvider, (_, next) { if (_prompt.text != next) _prompt.text = next; }); + // The reason lands while `startNewSession` is still unwinding, which is + // before the `_submit` that began the start resumes — and that `_submit` + // may belong to a composer a mid-start resize already disposed. Listening + // here means whichever composer is mounted reports it. + ref.listen(newSessionStartAbortProvider, (_, next) { + if (next != null) _reportAbort(); + }); + // Flipping the prompt to readOnly closes the platform input connection on + // a touch platform (EditableText._shouldCreateInputConnection), so the soft + // keyboard collapses when the start begins and — because nothing dropped + // focus — springs back up the moment the field is writable again, over a + // form the user was not typing in and over the snackbar explaining why the + // start ended. Dropping focus makes the close deliberate and one-way; the + // user taps back in to retry. Desktop keeps focus, where Enter-to-start is + // the retry. + ref.listen(newSessionStartInFlightProvider, (previous, next) { + if (next && previous != true && isMobilePlatform) _promptFocus.unfocus(); + // Re-run the snap the start suppressed. A detection that resolved + // mid-start was dropped, not queued, and nothing re-delivers it: the + // provider only re-emits on a control-plane push, which a start against a + // LOCAL target never makes — so without this the form stays parked on an + // agent the machine does not have. + if (!next && previous == true) _snapToInstalledAgent(); + }); final agent = ref.watch(newSessionAgentProvider); final customCmd = ref.watch(newSessionCustomCmdProvider); @@ -358,11 +513,17 @@ class _NewSessionComposerState extends ConsumerState { final supportsChat = ref.watch(newSessionSupportsChatProvider); final isolated = ref.watch(newSessionIsolatedProvider); final isolationReady = ref.watch(newSessionIsolationReadyProvider); + // The in-flight start, watched rather than held in this State: the New + // Session screen builds a different tree either side of the compact + // breakpoint, so a resize mid-start disposes this widget — and a local + // flag would unlock the form while the start it was guarding ran on. + final progress = ref.watch(newSessionStartProgressProvider); + final starting = progress != null; // Reactive form of `_canStart`, built from the values already watched // above so the button reacts to every one of them; both go through // [newSessionCanStart] so the watch and read paths stay in lockstep. final canSend = newSessionCanStart( - starting: _starting, + starting: starting, hasValidTarget: hasValidTarget, isCustom: isCustom, customCmd: customCmd, @@ -462,19 +623,22 @@ class _NewSessionComposerState extends ConsumerState { children: [ ConstrainedBox( constraints: capBox, - child: const EnvironmentChip(), + child: EnvironmentChip(enabled: !starting), ), const SizedBox(width: AbTokens.space6), ConstrainedBox( constraints: capBox, - child: ProjectChip(onOpenFolder: widget.onOpenFolder), + child: ProjectChip( + onOpenFolder: widget.onOpenFolder, + enabled: !starting, + ), ), const SizedBox(width: AbTokens.space6), - const Flexible(child: BranchChip()), + Flexible(child: BranchChip(enabled: !starting)), const SizedBox(width: AbTokens.space6), ConstrainedBox( constraints: BoxConstraints(maxWidth: isolationChipMax), - child: const _IsolationChip(), + child: _IsolationChip(enabled: !starting), ), ], ); @@ -519,6 +683,7 @@ class _NewSessionComposerState extends ConsumerState { controller: _prompt, focusNode: _promptFocus, enabled: !isCustom, + readOnly: starting, hintText: isCustom ? 'Starts a terminal session — set the command in ⚙' : 'Describe a task or ask a question', @@ -544,29 +709,44 @@ class _NewSessionComposerState extends ConsumerState { // so its label ellipsizes (see _composerRowRoomyMinWidth). final roomy = rowConstraints.maxWidth >= _composerRowRoomyMinWidth; + // The Enter hint is desktop-only (a soft keyboard has no + // meaningful Enter-to-send) but the phase line is not: a + // cold remote start is a 30s wait, and a phone is where it + // is most often watched. So the slot is present whenever + // either of the two has something to say. + final showTrailingSlot = + starting || (roomy && !isMobilePlatform); return Row( children: [ - _ModeSelector(supportsChat: supportsChat), + _ModeSelector( + supportsChat: supportsChat, + enabled: !starting, + ), const SizedBox(width: AbTokens.space8), Builder( builder: (gearContext) => AbIconButton( key: const Key('new-session-gear-button'), icon: AbIcons.settings, - onTap: () => _openGear(gearContext), + onTap: starting ? null : () => _openGear(gearContext), ), ), - if (roomy && !isMobilePlatform) ...[ - // Hardware-Enter hint — desktop only (soft keyboards - // have no meaningful Enter-to-send). Fades rather - // than pops so the row doesn't reflow as readiness - // changes. It lives in the row's slack (not after a - // Spacer) so a narrow pane drops it instead of - // overflowing — the invisible hint still occupies - // layout space. + if (showTrailingSlot) ...[ + // The slot fades on READINESS (focus, canSend) so the + // row doesn't reflow; the hint-to-phase swap inside it + // is a hard cut, because AbCrossFade animates one + // child's opacity and not a change of child (which is + // also why the fade below is re-keyed per child). It + // lives in the row's slack (not after a Spacer) so a + // narrow pane drops its content instead of overflowing + // — the invisible slot still occupies layout space. Expanded( + flex: starting ? _statusSlotFlex : 1, child: LayoutBuilder( builder: (context, constraints) { - if (constraints.maxWidth < _enterHintMinWidth) { + if (constraints.maxWidth < + (starting + ? _statusLineMinWidth + : _enterHintMinWidth)) { return const SizedBox.shrink(); } return Align( @@ -576,33 +756,96 @@ class _NewSessionComposerState extends ConsumerState { right: AbTokens.space12, ), child: AbCrossFade( + // Re-keyed on WHICH of the two the slot + // holds. AbCrossFade fades one child, so + // without this the swap and the visibility + // change land on the same frame and it is + // the newly-substituted child that plays + // the other one's animation: a start ending + // with the prompt unfocused faded the Enter + // hint out, text the user never had. A new + // key mounts a fresh tween already settled + // at its target, so each child appears and + // leaves on its own terms. The trade is + // that the status line now appears at once + // rather than fading in — the better half + // of it, on the frame Send was pressed. + key: ValueKey(progress == null), duration: AbTokens.motionDefault, - visible: _promptFocused && canSend, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const AbKbd('⏎'), - const SizedBox(width: AbTokens.space6), - Text( - 'to start', - style: AbTokens.sansStyle( - fontSize: AbTokens.fontXs, - color: p.textMuted, + visible: + starting || (_promptFocused && canSend), + child: progress == null + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + const AbKbd('⏎'), + const SizedBox( + width: AbTokens.space6, + ), + Text( + 'to start', + style: AbTokens.sansStyle( + fontSize: AbTokens.fontXs, + color: p.textMuted, + ), + ), + ], + ) + : Row( + key: const Key( + 'new-session-status-line', + ), + mainAxisSize: MainAxisSize.min, + children: [ + AbLoadingDot( + size: AbTokens.dotSizeMd, + color: p.textMuted, + ), + const SizedBox( + width: AbTokens.space6, + ), + Flexible( + child: Text( + phaseLabel(progress), + maxLines: 1, + softWrap: false, + overflow: + TextOverflow.ellipsis, + style: AbTokens.sansStyle( + fontSize: AbTokens.fontXs, + color: p.textSecondary, + ), + ), + ), + ], ), - ), - ], - ), ), ), ); }, ), ), - const _AgentSelector(), + if (starting) + // A bounded slot rather than an intrinsic child: the + // status line took the larger share, and the selector + // has to be able to shed its label to its glyph + // rather than push the row into an overflow. Expanded + // + Align, not a loose Flexible — a Flexible the chip + // underfills leaves its slack AFTER the last child, + // unpinning the send key from the row's right edge. + const Expanded( + child: Align( + alignment: Alignment.centerRight, + child: _AgentSelector(enabled: false), + ), + ) + else + const _AgentSelector(), ] else - // No hint slot: hand the slack to the selector so a - // long agent label ellipsizes inside it (ComposerChip - // needs a bounded width) instead of overflowing. + // No trailing slot: hand the slack to the selector so + // a long agent label ellipsizes inside it + // (ComposerChip needs a bounded width) instead of + // overflowing. const Expanded( child: Align( alignment: Alignment.centerRight, @@ -610,11 +853,44 @@ class _NewSessionComposerState extends ConsumerState { ), ), const SizedBox(width: AbTokens.space8), - ComposerSendButton( - key: const Key('new-session-send-button'), - busy: _starting, - onTap: canSend ? _submit : null, - ), + // Stop is the same key in the same slot, so the send + // affordance keeps its test key across both; the outer + // key is what tells the two variants apart. Both carry a + // label: one glyph in one position means start or cancel + // depending on state, and ComposerSendButton paints no + // text to tell a screen reader or a hover which it is. + if (progress != null && progress.isCancellable) + KeyedSubtree( + key: const Key('new-session-stop-button'), + child: Semantics( + button: true, + label: 'Stop starting session', + child: AbTooltip( + message: 'Stop starting session', + child: ComposerSendButton( + key: const Key('new-session-send-button'), + icon: AbIcons.stop, + color: p.error, + onTap: () => ref + .read( + newSessionStartProgressProvider.notifier, + ) + .requestCancel(), + ), + ), + ), + ) + else + Semantics( + button: true, + enabled: canSend, + label: 'Start session', + child: ComposerSendButton( + key: const Key('new-session-send-button'), + busy: starting, + onTap: canSend ? _submit : null, + ), + ), ], ); }, @@ -649,6 +925,7 @@ class _PromptField extends StatelessWidget { required this.controller, required this.focusNode, required this.enabled, + required this.readOnly, required this.hintText, required this.onChanged, }); @@ -656,6 +933,12 @@ class _PromptField extends StatelessWidget { final TextEditingController controller; final FocusNode focusNode; final bool enabled; + + /// Frozen but undimmed, unlike [enabled]: a prompt already on the wire is + /// still the thing the user is waiting on, so it has to stay readable — and + /// "busy" must not look like the custom-agent "this field is not yours". + final bool readOnly; + final String hintText; final ValueChanged onChanged; @@ -666,6 +949,10 @@ class _PromptField extends StatelessWidget { controller: controller, focusNode: focusNode, enabled: enabled, + readOnly: readOnly, + // A caret blinking in a field that cannot take the keystroke invites + // exactly the edit this lock exists to refuse. + showCursor: !readOnly, maxLines: null, minLines: 3, onChanged: onChanged, @@ -703,7 +990,11 @@ class _PromptField extends StatelessWidget { /// backend would ride in on, and a user who picked "worktree" could not be told /// afterwards that they had picked something else. class _IsolationChip extends ConsumerWidget { - const _IsolationChip(); + const _IsolationChip({this.enabled = true}); + + /// See [ComposerChip.enabled] — handed down by the composer rather than read + /// from the start model here, so one owner decides when the row is frozen. + final bool enabled; @override Widget build(BuildContext context, WidgetRef ref) { @@ -722,7 +1013,12 @@ class _IsolationChip extends ConsumerWidget { key: const Key('new-session-worktree-chip'), label: 'isolated', value: ref.watch(newSessionIsolatedProvider), - tooltip: ready + // The frozen arm comes FIRST: a chip dead only because a start is + // running must not blame the project's Git or the machine's bridge, and + // must not send the reader off to update anything. + tooltip: !enabled + ? 'Locked while the session starts' + : ready ? 'Give this session its own branch and workspace, separate from ' 'your main tree' : catalog.isLoading @@ -736,7 +1032,7 @@ class _IsolationChip extends ConsumerWidget { // may neither promise an update works nor read as permanent. : 'This machine can\'t create isolated sessions — check that its ' 'Antgrid is up to date', - onChanged: ready + onChanged: enabled && ready ? (next) => ref.read(newSessionIsolatedProvider.notifier).set(next) : null, ); @@ -752,12 +1048,15 @@ class _IsolationChip extends ConsumerWidget { /// greyed WITH its reason instead of vanishing, so a missing option and an /// unsupported one never look alike. class _ModeSelector extends ConsumerWidget { - const _ModeSelector({required this.supportsChat}); + const _ModeSelector({required this.supportsChat, this.enabled = true}); /// Null when neither the target machine nor the persisted catalog has /// described the selected agent — a third state, not a `false`. final bool? supportsChat; + /// See [ComposerChip.enabled]. + final bool enabled; + @override Widget build(BuildContext context, WidgetRef ref) { final mode = ref.watch(newSessionModeProvider); @@ -792,6 +1091,7 @@ class _ModeSelector extends ConsumerWidget { icon: isChat ? AbIcons.comment : AbIcons.terminal, label: isChat ? 'Chat' : 'Terminal', alpha: isChat, + enabled: enabled, onTap: (ctx) async { final anchor = abMenuAnchorRect(ctx); if (anchor == null) return; @@ -839,6 +1139,7 @@ class _ModeChip extends StatelessWidget { required this.label, required this.alpha, required this.onTap, + this.enabled = true, }); final String icon; @@ -851,35 +1152,47 @@ class _ModeChip extends StatelessWidget { final void Function(BuildContext anchorContext) onTap; + /// See [ComposerChip.enabled] — same treatment, so the composer's two chip + /// shapes cannot grow two dialects of "dead". + final bool enabled; + @override Widget build(BuildContext context) { return MouseRegion( - cursor: SystemMouseCursors.click, + cursor: enabled ? SystemMouseCursors.click : SystemMouseCursors.basic, child: Builder( builder: (ctx) { final p = ctx.antgrid; + final fg = enabled ? p.textPrimary : p.textDisabled; return GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () => onTap(ctx), + // Stays live while disabled so the chip keeps swallowing taps + // rather than letting them reach the row beneath it. + onTap: () { + if (!enabled) return; + onTap(ctx); + }, child: Container( padding: const EdgeInsets.symmetric( horizontal: AbTokens.space8, vertical: AbTokens.space4, ), decoration: BoxDecoration( - border: Border.all(color: p.borderDefault), + border: Border.all( + color: enabled ? p.borderDefault : p.borderSubtle, + ), borderRadius: AbTokens.borderRadius3, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - AbIcon(icon, size: 12, color: p.textPrimary), + AbIcon(icon, size: 12, color: fg), const SizedBox(width: AbTokens.space6), Text( label, style: AbTokens.sansStyle( fontSize: AbTokens.fontSm, - color: p.textPrimary, + color: fg, ), ), if (alpha) ...[ @@ -887,7 +1200,11 @@ class _ModeChip extends StatelessWidget { AbChip.system(label: 'Alpha', color: p.warning), ], const SizedBox(width: AbTokens.space6), - AbIcon(AbIcons.chevronDown, size: 10, color: p.textMuted), + AbIcon( + AbIcons.chevronDown, + size: 10, + color: enabled ? p.textMuted : p.textDisabled, + ), ], ), ), @@ -903,7 +1220,10 @@ class _ModeChip extends StatelessWidget { /// a [ComposerChip] here to match the environment/project chips it sits /// beside). class _AgentSelector extends ConsumerWidget { - const _AgentSelector(); + const _AgentSelector({this.enabled = true}); + + /// See [ComposerChip.enabled]. + final bool enabled; @override Widget build(BuildContext context, WidgetRef ref) { @@ -918,6 +1238,7 @@ class _AgentSelector extends ConsumerWidget { key: const Key('new-session-agent-selector'), icon: AbIcons.terminal, label: newSessionAgentLabel(agent, detected, catalog), + enabled: enabled, onTap: (ctx) async { final anchor = abMenuAnchorRect(ctx); if (anchor == null) return; diff --git a/app/lib/widgets/new_session/new_session_content.dart b/app/lib/widgets/new_session/new_session_content.dart index 47259efb..379e9c13 100644 --- a/app/lib/widgets/new_session/new_session_content.dart +++ b/app/lib/widgets/new_session/new_session_content.dart @@ -10,6 +10,7 @@ import '../../design/widgets/ab_icon_button.dart'; import '../../design/widgets/ab_separator.dart'; import '../../design/widgets/ab_snack_bar.dart'; import '../../providers/new_session_picker.dart'; +import '../../providers/new_session_start.dart'; import '../../providers/projects.dart'; import '../../providers/recent_sessions.dart'; import '../open_folder_button.dart'; @@ -59,8 +60,19 @@ class NewSessionContent extends ConsumerWidget { final showTopBar = onOpenDrawer != null || showSearchButton; return CallbackShortcuts( bindings: { - const SingleActivator(LogicalKeyboardKey.escape): () => - leaveNewSession(ref.container), + const SingleActivator(LogicalKeyboardKey.escape): () { + // A running start owns Esc. Leaving would unmount the composer while + // `startNewSession` kept going, so the session would launch with the + // Stop the user just pressed Esc for still on screen a moment ago and + // nothing left to report the outcome to. Past the cancel boundary + // `requestCancel` refuses and Esc does nothing — a start that can no + // longer be stopped must not be walked away from either. + if (ref.read(newSessionStartInFlightProvider)) { + ref.read(newSessionStartProgressProvider.notifier).requestCancel(); + return; + } + leaveNewSession(ref.container); + }, }, child: Focus( autofocus: true, diff --git a/app/lib/widgets/new_session/project_menu.dart b/app/lib/widgets/new_session/project_menu.dart index 4671aa75..b10cbc8f 100644 --- a/app/lib/widgets/new_session/project_menu.dart +++ b/app/lib/widgets/new_session/project_menu.dart @@ -26,10 +26,17 @@ import 'picker_sources.dart'; /// so rows appear the moment the advert lands (the reason showAbPanel /// exists rather than showAbMenu's static entries). class ProjectChip extends ConsumerWidget { - const ProjectChip({super.key, required this.onOpenFolder}); + const ProjectChip({ + super.key, + required this.onOpenFolder, + this.enabled = true, + }); final VoidCallback onOpenFolder; + /// See [EnvironmentChip.enabled] — the composer owns the frozen state. + final bool enabled; + @override Widget build(BuildContext context, WidgetRef ref) { final target = ref.watch(selectedTargetProjectProvider); @@ -38,6 +45,7 @@ class ProjectChip extends ConsumerWidget { icon: AbIcons.folder, label: valid ? target!.name : 'Select project…', attention: !valid, + enabled: enabled, onTap: (ctx) async { final anchor = abMenuAnchorRect(ctx); if (anchor == null) return; diff --git a/app/lib/widgets/recent_sessions/recent_sessions_tab.dart b/app/lib/widgets/recent_sessions/recent_sessions_tab.dart index fb6af6c6..bfa36407 100644 --- a/app/lib/widgets/recent_sessions/recent_sessions_tab.dart +++ b/app/lib/widgets/recent_sessions/recent_sessions_tab.dart @@ -16,11 +16,13 @@ import '../../providers/project_work_status.dart'; import '../../providers/recent_sessions.dart'; import '../../providers/supervisor_status.dart'; import '../../services/control_plane_client.dart'; +import '../../util/detached.dart'; import '../../utils/platform_utils.dart'; import '../ab_status_helpers.dart'; import '../first_run_checklist.dart'; import 'recent_session_row_widget.dart'; import 'recent_sessions_summary.dart'; +import 'starting_session_row.dart'; class _SessionGroup { const _SessionGroup({ @@ -70,8 +72,41 @@ class RecentSessionsTab extends ConsumerStatefulWidget { } class _RecentSessionsTabState extends ConsumerState { + /// Owned here, and handed to whichever of the two branches builds, so a + /// start can bring the list back to the top. + final ScrollController _scroll = ScrollController(); + + @override + void dispose() { + _scroll.dispose(); + super.dispose(); + } + + /// [StartingSessionRow] is the first sliver in both branches, so for a + /// scrolled-down user it grows and collapses ABOVE the viewport: the + /// viewport keeps its offset, so every visible row is shoved down by the + /// row's height when a start begins and back up when it ends — while the + /// row itself, the entire point of it, is never on screen. + /// + /// Riding the user's own Send back to the top resolves both halves: the + /// shift becomes a motion they caused, and it lands them where the session + /// being started — and the real row it turns into — actually appear. + void _revealStartingRow() { + if (!_scroll.hasClients || _scroll.offset <= 0) return; + detached('recents', 'scroll to starting row', () async { + await _scroll.animateTo( + 0, + duration: AbTokens.motionDefault, + curve: Curves.easeOutCubic, + ); + }); + } + @override Widget build(BuildContext context) { + ref.listen(newSessionStartInFlightProvider, (previous, next) { + if (next && previous != true) _revealStartingRow(); + }); final rows = ref.watch(recentSessionsProvider); if (rows.isEmpty) { @@ -94,8 +129,13 @@ class _RecentSessionsTabState extends ConsumerState { // is a bare Center with no Scrollable of its own, so pull-to-refresh // would be inert here without this wrapper. return CustomScrollView( + controller: _scroll, physics: const AlwaysScrollableScrollPhysics(), slivers: [ + // Mounted in this branch too: the first session a user ever starts + // is started from an empty list, which is exactly when an + // unaccounted-for 30s wait is least explicable. + const SliverToBoxAdapter(child: StartingSessionRow()), SliverFillRemaining( hasScrollBody: false, child: showChecklist @@ -130,9 +170,19 @@ class _RecentSessionsTabState extends ConsumerState { final groups = _groupSessions(rows, groupBy, statusFor); return CustomScrollView( + controller: _scroll, + // Stated, not inherited: `ScrollView` only defaults to + // AlwaysScrollableScrollPhysics while it has NO controller, so handing it + // one would otherwise leave a list shorter than the viewport refusing the + // drag — and the ancestor RefreshIndicator (new_session_content.dart) + // inert for exactly the users with the fewest recent sessions. + physics: const AlwaysScrollableScrollPhysics(), slivers: [ if (widget.showHeader) const SliverToBoxAdapter(child: _SessionsHeader()), + // Above the groups, not inside one: the session does not exist yet, so + // it belongs to no machine, project or status bucket. + const SliverToBoxAdapter(child: StartingSessionRow()), for (var i = 0; i < groups.length; i++) ...[ SliverToBoxAdapter( child: _GroupHeader( diff --git a/app/lib/widgets/recent_sessions/starting_session_row.dart b/app/lib/widgets/recent_sessions/starting_session_row.dart new file mode 100644 index 00000000..bf5e9f38 --- /dev/null +++ b/app/lib/widgets/recent_sessions/starting_session_row.dart @@ -0,0 +1,258 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../constants/breakpoints.dart'; +import '../../design/ab_colors.dart'; +import '../../design/ab_tokens.dart'; +import '../../design/widgets/ab_chip.dart'; +import '../../design/widgets/ab_loading.dart'; +import '../../providers/new_session_start.dart'; + +/// Finds the row while a start is in flight; absent when idle. +const Key startingSessionRowKey = Key('starting-session-row'); + +/// The phase copy, where a real Recent row prints its timestamp. +const Key startingSessionPhaseKey = Key('starting-session-phase'); + +/// Side of the leading glyph box, and the project line's indent under the +/// title on mobile — both mirror `recent_session_row_widget.dart` so this row +/// and the real one it becomes share a name column. +const double _leadingSize = 18; +const double _mobileProjectIndent = _leadingSize + AbTokens.space12; + +/// The real row's project column width, so the project text wraps and +/// ellipsizes at the same point either side of the moment the session lands. +const double _railProjectWidth = 220; + +/// Width of the slot the phase copy occupies, where a real row puts its time. +/// Fixed, and wider than that time slot: the copy changes length as the start +/// advances, and a slot that sized to it would resize the title column on +/// every phase. +/// +/// Being wider is why the project column sits further left here than on the +/// real row — the columns share a width, not an x. Matching the time slot +/// instead would buy that alignment for one frame at the cost of truncating +/// every phase name for the whole start. +const double _railPhaseWidth = 150; + +/// The session being started right now, standing in for the Recent row it will +/// become. +/// +/// Renders nothing when no start is in flight, so both of the Recent tab's +/// branches mount it unconditionally — including the empty-recents one, which +/// is the first session a user ever starts and the moment this row matters +/// most. +/// +/// Existing only here — not as a synthetic entry in the cache — keeps a +/// session that does not exist yet out of grouping, the per-row status map, the +/// summary counts and search. +/// +/// Read-only and not tappable: there is nothing to open yet, and the composer +/// owns the single Stop affordance. +class StartingSessionRow extends ConsumerWidget { + const StartingSessionRow({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final progress = ref.watch(newSessionStartProgressProvider); + if (progress == null) return const SizedBox.shrink(); + + final isMobile = MediaQuery.sizeOf(context).width < kCompactBreakpoint; + return Padding( + key: startingSessionRowKey, + padding: const EdgeInsets.symmetric( + horizontal: AbTokens.space16, + vertical: AbTokens.space8, + ), + child: LayoutBuilder( + builder: (context, constraints) { + // Same fallback width as the real row: below it the desktop rail's + // fixed project + phase columns no longer fit beside a name. + final compact = isMobile || constraints.maxWidth < 560; + return compact + ? _MobileLayout(progress: progress) + : _DesktopLayout(progress: progress); + }, + ), + ); + } +} + +class _DesktopLayout extends StatelessWidget { + const _DesktopLayout({required this.progress}); + + final NewSessionStartProgress progress; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const _StartingMark(), + const SizedBox(width: AbTokens.space12), + Expanded( + child: Row( + children: [ + Flexible(child: _SessionTitle(title: progress.title)), + const _StartingBadge(), + const SizedBox(width: AbTokens.space12), + ], + ), + ), + SizedBox( + width: _railProjectWidth, + child: _ProjectLabel(name: _projectDisplayText(progress)), + ), + const SizedBox(width: AbTokens.space12), + SizedBox( + width: _railPhaseWidth, + child: _PhaseLabel(label: phaseLabel(progress)), + ), + ], + ); + } +} + +class _MobileLayout extends StatelessWidget { + const _MobileLayout({required this.progress}); + + final NewSessionStartProgress progress; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const _StartingMark(), + const SizedBox(width: AbTokens.space12), + Expanded(child: _SessionTitle(title: progress.title)), + const _StartingBadge(), + const SizedBox(width: AbTokens.space8), + // Capped rather than fixed: a phone has no room to reserve the + // rail's full slot, and the title is the line that may give. + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _railPhaseWidth), + child: _PhaseLabel(label: phaseLabel(progress)), + ), + ], + ), + const SizedBox(height: AbTokens.space2), + Padding( + padding: const EdgeInsets.only(left: _mobileProjectIndent), + child: _ProjectLabel(name: _projectDisplayText(progress)), + ), + ], + ); + } +} + +/// The leading slot, centred in the same box as every other row's glyph so the +/// name column keeps starting at the same x. +/// +/// Muted rather than [AbLoadingDot]'s accent default: the rows below carry real +/// accent status marks, and the composer's dot for this same start is muted +/// too — one operation must not pulse in two colours on one screen. +class _StartingMark extends StatelessWidget { + const _StartingMark(); + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: _leadingSize, + child: Center( + child: AbLoadingDot( + size: AbTokens.dotSizeMd, + color: context.antgrid.textMuted, + ), + ), + ); + } +} + +/// Shaped like `SessionDeletingBadge`: neutral, not an accent or an error +/// colour, because this is a normal operation in progress. Owns its leading +/// gap so the title measures against the badge, not against reserved space. +class _StartingBadge extends StatelessWidget { + const _StartingBadge(); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.only(left: AbTokens.space6), + child: AbChip.system(label: 'STARTING'), + ); + } +} + +class _SessionTitle extends StatelessWidget { + const _SessionTitle({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + final text = title.trim(); + return Text( + // A start carries either a session name or the leading prompt text; the + // fallback only covers a title that is all whitespace. + text.isEmpty ? 'New session' : text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AbTokens.sansStyle( + fontSize: AbTokens.fontMd, + color: context.antgrid.textPrimary, + fontWeight: FontWeight.w600, + ), + ); + } +} + +/// Device-prefixed like the real row — a remote start must still say which +/// machine it is waking. An empty [NewSessionStartProgress.deviceName] is the +/// local target. +String _projectDisplayText(NewSessionStartProgress progress) { + if (progress.deviceName.isEmpty) return progress.targetName; + return '${progress.deviceName} · ${progress.targetName}'; +} + +class _ProjectLabel extends StatelessWidget { + const _ProjectLabel({required this.name}); + + final String name; + + @override + Widget build(BuildContext context) { + return Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AbTokens.monoStyle( + fontSize: AbTokens.fontSm, + color: context.antgrid.textMuted, + ), + ); + } +} + +/// Sans, unlike the mono timestamp it stands in for: this is chrome prose +/// about what the machine is doing, not row data. +class _PhaseLabel extends StatelessWidget { + const _PhaseLabel({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Text( + label, + key: startingSessionPhaseKey, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AbTokens.sansStyle( + fontSize: AbTokens.fontXs, + color: context.antgrid.textMuted, + ), + ); + } +} diff --git a/app/test/providers/new_session_action_test.dart b/app/test/providers/new_session_action_test.dart index c3fa749e..88673e07 100644 --- a/app/test/providers/new_session_action_test.dart +++ b/app/test/providers/new_session_action_test.dart @@ -1,12 +1,21 @@ import 'package:antgrid/models/git_branch.dart'; +import 'package:antgrid/models/session_entry.dart'; import 'package:antgrid/models/session_target.dart'; +import 'package:antgrid/project/project_session.dart'; +import 'package:antgrid/project/project_session_registry.dart'; import 'package:antgrid/providers/agent_transport.dart'; import 'package:antgrid/providers/control_plane.dart'; import 'package:antgrid/providers/new_session_action.dart'; import 'package:antgrid/providers/new_session_picker.dart'; +import 'package:antgrid/providers/new_session_start.dart'; +import 'package:antgrid/providers/providers.dart'; import 'package:antgrid/providers/recent_agents.dart'; +import 'package:antgrid/providers/sessions.dart'; +import 'package:antgrid/providers/ui_attention_providers.dart'; import 'package:antgrid/providers/value_controller.dart'; import 'package:antgrid/services/control_plane_client.dart'; +import 'package:antgrid/services/sessions_service.dart'; +import 'package:antgrid/storage/cached_sessions_store.dart'; import 'package:antgrid/storage/recent_agents_store.dart'; import 'package:antgrid/test_helpers/fake_agent_transport.dart'; import 'package:antgrid/widgets/new_session/picker_sources.dart'; @@ -320,6 +329,372 @@ void main() { ); }); }); + + group('start progress', () { + const localTarget = PickerProject( + id: _projectId, + name: 'p1', + detail: '/tmp/p1', + isLocal: true, + ); + + /// One local start, wired end to end: a real [ProjectSession] over a fake + /// transport, and a [SessionsService] whose two wire calls are answered + /// locally. Everything else is the production [startNewSession]. + Future<_Harness> harness({ + Future Function(ProviderContainer container)? onCreate, + Future Function(ProviderContainer container)? onStart, + Future Function(ProviderContainer container)? onPrepare, + }) async { + useInMemoryPrefs(); + final stores = await buildTestStoreOverrides(); + addTearDown(stores.close); + + final transport = FakeAgentTransport(); + addTearDown(transport.dispose); + final cache = await CachedSessionsStore.open(); + final session = ProjectSession( + projectId: _projectId, + transport: transport, + mode: ProjectSessionMode.local, + cachedSessionsStore: cache, + onClose: transport.dispose, + ); + addTearDown(session.close); + + late ProviderContainer container; + final service = _StubSessionsService( + session, + cache, + onCreate: () async => onCreate?.call(container), + onStart: () async => onStart?.call(container), + ); + + container = ProviderContainer( + overrides: [ + ...stores.overrides, + selectedTargetProjectProvider.overrideWith( + () => ValueController(localTarget), + ), + // Reading this resolves through the host controller, which would + // spawn a real bridge just to answer a label question. + newSessionChatCapableToolsProvider.overrideWith((ref) async => null), + projectSessionFactoryProvider.overrideWithValue(( + Ref ref, + String projectId, + ) async { + await onPrepare?.call(container); + return session; + }), + sessionsServiceProvider.overrideWithValue(service), + ], + ); + addTearDown(container.dispose); + + final phases = []; + container.listen(newSessionStartProgressProvider, (_, next) { + if (next != null && (phases.isEmpty || phases.last != next.phase)) { + phases.add(next.phase); + } + }, fireImmediately: true); + + return _Harness(container, service, phases); + } + + test('walks the phases in order and disarms itself at the end', () async { + final h = await harness(); + h.container.read(newSessionPromptProvider.notifier).set('fix the bug'); + // Send was pressed on the canvas: without this the surface defaults to + // `workspace` and the start returns early at the walked-away guard, + // which is a different path from the clean success this test names. + h.container + .read(workbenchSurfaceProvider.notifier) + .set(WorkbenchSurface.newSession); + + await startNewSession(h.container); + + // `connecting` belongs to the remote activation alone, and + // `switchingBranch` to an explicit non-isolated branch — a plain local + // start walks the rest, in this order. + expect(h.phases, [ + NewSessionStartPhase.activating, + NewSessionStartPhase.preparing, + NewSessionStartPhase.creating, + NewSessionStartPhase.launching, + ]); + expect(h.service.created, 1); + expect(h.service.started, ['s-new']); + expect(h.container.read(newSessionStartInFlightProvider), isFalse); + expect(h.container.read(newSessionStartAbortProvider), isNull); + expect(h.container.read(activeSessionIdProvider), 's-new'); + }); + + test( + 'Stop before session:create aborts, and says it was cancelled', + () async { + final h = await harness( + onPrepare: (container) async { + // Off the provider's synchronous build turn: Riverpod forbids a + // provider writing to another while it is still building, and the + // real Stop press arrives from a tap, not from this factory. + await Future.delayed(Duration.zero); + container + .read(newSessionStartProgressProvider.notifier) + .requestCancel(); + }, + ); + + await startNewSession(h.container); + + expect( + h.container.read(newSessionStartAbortProvider)?.reason, + NewSessionStartAbortReason.cancelled, + ); + // The point of stopping early: nothing was put on the wire. + expect(h.service.created, 0); + expect(h.service.started, isEmpty); + expect(h.container.read(newSessionStartInFlightProvider), isFalse); + }, + ); + + test( + 'Stop from session:create on is refused, and the start runs on', + () async { + var refusedCancel = false; + final h = await harness( + onCreate: (container) async { + // Abandoning here would orphan a created-but-unstarted session on the + // bridge, so the request is refused rather than deferred. + refusedCancel = !container + .read(newSessionStartProgressProvider.notifier) + .requestCancel(); + }, + ); + // Send was pressed on the canvas, so the success is allowed to focus + // the new session — without this the surface defaults to `workspace` + // and this would be testing the walked-away path instead. + h.container + .read(workbenchSurfaceProvider.notifier) + .set(WorkbenchSurface.newSession); + + await startNewSession(h.container); + + expect(refusedCancel, isTrue); + expect(h.service.started, ['s-new']); + expect(h.container.read(newSessionStartAbortProvider), isNull); + expect(h.container.read(activeSessionIdProvider), 's-new'); + }, + ); + + test( + 'a success while the user is elsewhere does not pull them back', + () async { + final h = await harness( + onStart: (container) async { + container + .read(workbenchSurfaceProvider.notifier) + .set(WorkbenchSurface.appSettings); + }, + ); + h.container + .read(workbenchSurfaceProvider.notifier) + .set(WorkbenchSurface.newSession); + + await startNewSession(h.container); + + // The session was created and started, but nothing retargets the user: + // TerminalScreen WATCHES activeSessionIdProvider, so writing it is + // itself the yank this guard exists to prevent — the surface being left + // alone is not enough on its own. + expect(h.container.read(activeSessionIdProvider), isNull); + expect(h.service.started, ['s-new']); + expect( + h.container.read(workbenchSurfaceProvider), + WorkbenchSurface.appSettings, + ); + expect(h.container.read(pendingActiveSessionIdProvider), isNull); + }, + ); + + test( + 'a project switch after session:create says the session was orphaned', + () async { + final h = await harness( + onCreate: (container) async { + container + .read(selectedTargetProvider.notifier) + .set(const LocalProject('other')); + }, + ); + + await startNewSession(h.container); + + // create landed and start never went out, so the generic + // `intentChanged` copy ("nothing was created") would be a lie the user + // cannot check against the bridge. + expect( + h.container.read(newSessionStartAbortProvider)?.reason, + NewSessionStartAbortReason.abandonedAfterCreate, + ); + expect(h.service.created, 1); + expect(h.service.started, isEmpty); + }, + ); + + test('a success with the user still on the canvas navigates', () async { + final h = await harness(); + h.container + .read(workbenchSurfaceProvider.notifier) + .set(WorkbenchSurface.newSession); + + await startNewSession(h.container); + + expect( + h.container.read(workbenchSurfaceProvider), + WorkbenchSurface.workspace, + ); + expect(h.container.read(pendingActiveSessionIdProvider), 's-new'); + }); + }); + + group('start progress controller', () { + test('advance never rewinds a start already past that phase', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final start = container.read(newSessionStartProgressProvider.notifier); + start.begin( + phase: NewSessionStartPhase.activating, + targetId: _projectId, + targetName: 'p1', + deviceName: '', + agentLabel: 'Claude Code', + isolated: false, + title: 'fix the bug', + ); + start.advance(NewSessionStartPhase.creating); + + start.advance(NewSessionStartPhase.connecting); + + final progress = container.read(newSessionStartProgressProvider)!; + expect(progress.phase, NewSessionStartPhase.creating); + // The point of the guard: a rewind past the boundary would re-offer a + // Stop the flow can no longer honour. + expect(progress.isCancellable, isFalse); + }); + + test('a completed checkout rides on every later abort', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final start = container.read(newSessionStartProgressProvider.notifier); + start.begin( + phase: NewSessionStartPhase.switchingBranch, + targetId: _projectId, + targetName: 'p1', + deviceName: '', + agentLabel: 'Claude Code', + isolated: false, + title: 'fix the bug', + branch: 'feature/x', + ); + start.markBranchSwitched('feature/x'); + + start.abort(NewSessionStartAbortReason.cancelled); + + // "Start cancelled." alone would be a lie: the checkout landed on the + // bridge and moved the tree under every session in the folder. + final abort = container.read(newSessionStartAbortProvider)!; + expect(abort.reason, NewSessionStartAbortReason.cancelled); + expect(abort.branchSwitchedTo, 'feature/x'); + }); + + test('the next start does not inherit the previous checkout', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final start = container.read(newSessionStartProgressProvider.notifier); + void arm() => start.begin( + phase: NewSessionStartPhase.activating, + targetId: _projectId, + targetName: 'p1', + deviceName: '', + agentLabel: 'Claude Code', + isolated: false, + title: 'fix the bug', + ); + arm(); + start.markBranchSwitched('feature/x'); + start.abort(NewSessionStartAbortReason.cancelled); + start.end(); + + arm(); + start.abort(NewSessionStartAbortReason.cancelled); + + expect( + container.read(newSessionStartAbortProvider)!.branchSwitchedTo, + isNull, + ); + }); + }); +} + +class _Harness { + _Harness(this.container, this.service, this.phases); + + final ProviderContainer container; + final _StubSessionsService service; + final List phases; +} + +/// Answers `session:create` / `session:start` in process, and gives a test a +/// hook that runs INSIDE each call — the only place from which the cancel +/// boundary can be probed while the start is genuinely at that stage. +class _StubSessionsService extends SessionsService { + _StubSessionsService( + super.session, + CachedSessionsStore cache, { + required this.onCreate, + required this.onStart, + }) : super.fromSession(cache: cache); + + final Future Function() onCreate; + final Future Function() onStart; + + int created = 0; + final started = []; + + @override + Future create({ + String? name, + String? tool, + String? command, + String? args, + String? mode, + String isolation = 'shared', + String? baseBranch, + }) async { + await onCreate(); + created++; + return _entry('s-new'); + } + + @override + Future start( + String id, { + String? initialPrompt, + bool raiseRefusal = false, + }) async { + await onStart(); + started.add(id); + return _entry(id); + } + + static SessionEntry _entry(String id) => SessionEntry( + id: id, + name: 'fix the bug', + createdAt: 0, + lastUsedAt: 0, + archived: false, + running: true, + ); } class _SeededRecentAgentsNotifier extends RecentAgentsNotifier { diff --git a/app/test/screens/new_session_focus_test.dart b/app/test/screens/new_session_focus_test.dart index f707c5d4..ff77cf44 100644 --- a/app/test/screens/new_session_focus_test.dart +++ b/app/test/screens/new_session_focus_test.dart @@ -130,7 +130,6 @@ void main() { final created = await svc.create(name: 'new one', tool: 'claude-code'); expect(created, isNotNull); expect(await svc.start(created!.id, initialPrompt: 'hi'), isNotNull); - c.read(activeSessionIdProvider.notifier).set(created.id); return created; } @@ -143,8 +142,10 @@ void main() { final created = await startFrom(c); // The tail of `startNewSession`, in order: the draft is consumed, the new - // session is named for the remount, and only then does the canvas close. + // session is focused, it is named for the remount, and only then does the + // canvas close. resetNewSessionForm(c); + c.read(activeSessionIdProvider.notifier).set(created.id); c.read(pendingActiveSessionIdProvider.notifier).set(created.id); leaveNewSession(c); @@ -169,8 +170,9 @@ void main() { ..sessions.add(_oldSession()); final c = await openProjectThenNewSession(tester, t); - await startFrom(c); + final created = await startFrom(c); resetNewSessionForm(c); + c.read(activeSessionIdProvider.notifier).set(created.id); leaveNewSession(c); for (var i = 0; i < 10; i++) { diff --git a/app/test/widgets/new_session_composer_test.dart b/app/test/widgets/new_session_composer_test.dart index 4379c490..666be3b9 100644 --- a/app/test/widgets/new_session_composer_test.dart +++ b/app/test/widgets/new_session_composer_test.dart @@ -5,15 +5,18 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/misc.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:antgrid/design/widgets/ab_cross_fade.dart'; import 'package:antgrid/design/ab_theme.dart'; import 'package:antgrid/models/agent_descriptor.dart'; import 'package:antgrid/models/git_branch.dart'; import 'package:antgrid/providers/agent_catalog.dart'; import 'package:antgrid/providers/new_session_action.dart'; import 'package:antgrid/providers/new_session_picker.dart'; +import 'package:antgrid/providers/new_session_start.dart'; import 'package:antgrid/providers/value_controller.dart'; import 'package:antgrid/services/sessions_service.dart' show SessionOperationException; +import 'package:antgrid/utils/platform_utils.dart'; import 'package:antgrid/widgets/ab_status_helpers.dart' show friendlyErrorCopy; import 'package:antgrid/widgets/new_session/branch_menu.dart'; import 'package:antgrid/widgets/new_session/environment_menu.dart'; @@ -1047,4 +1050,424 @@ void main() { await drainSnackBar(tester); }); }); + + group('start lock', () { + void begin( + ProviderContainer container, { + NewSessionStartPhase phase = NewSessionStartPhase.activating, + String? branch, + }) { + container + .read(newSessionStartProgressProvider.notifier) + .begin( + phase: phase, + targetId: _project.id, + targetName: _project.name, + deviceName: 'mac-studio', + agentLabel: 'Claude Code', + isolated: false, + title: 'fix the bug', + branch: branch, + ); + } + + void advance(ProviderContainer container, NewSessionStartPhase phase) => + container.read(newSessionStartProgressProvider.notifier).advance(phase); + + ProviderContainer containerOf(WidgetTester tester) => + ProviderScope.containerOf( + tester.element(find.byType(NewSessionComposer)), + ); + + /// Never `pumpAndSettle` while a start is armed: the status line and the + /// busy send button both run a repeating `AbLoadingDot` controller. + Future settle(WidgetTester tester) => + tester.pump(const Duration(milliseconds: 400)); + + String statusText(WidgetTester tester) => tester + .widget( + find.descendant( + of: find.byKey(const Key('new-session-status-line')), + matching: find.byType(Text), + ), + ) + .data!; + + Finder sendButton() => find.byKey(const Key('new-session-send-button')); + Finder stopButton() => find.byKey(const Key('new-session-stop-button')); + + testWidgets('every control in the form refuses taps', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + + await tester.pumpWidget( + _host( + overrides: _baseOverrides(target: _project, worktreeSupported: true), + ), + ); + await tester.pumpAndSettle(); + + final container = containerOf(tester); + begin(container); + await settle(tester); + + // Real taps, because a control that only LOOKS dead still opens its + // panel — which is the state the form shipped in. + Future tapDead(Finder target) async { + await tester.tap(target, warnIfMissed: false); + await settle(tester); + } + + await tapDead(find.byType(EnvironmentChip)); + expect(find.text('Machines'), findsNothing); + + await tapDead(find.byType(ProjectChip)); + expect(find.text('Open folder…'), findsNothing); + + await tapDead(find.byType(BranchChip)); + expect(find.text('Search branches…'), findsNothing); + + await tapDead(find.byKey(const Key('new-session-worktree-chip'))); + expect(container.read(newSessionIsolatedProvider), isFalse); + + await tapDead(find.byKey(const Key('new-session-mode-chip'))); + expect(find.byKey(const Key('new-session-mode-chat')), findsNothing); + expect(container.read(newSessionModeProvider), 'terminal'); + + await tapDead(find.byKey(const Key('new-session-gear-button'))); + expect(find.text('Session settings'), findsNothing); + + await tapDead(find.byKey(const Key('new-session-agent-selector'))); + expect(find.text('Codex'), findsNothing); + expect(container.read(newSessionAgentProvider), kDefaultSessionAgent); + + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('the prompt is frozen and Enter cannot resubmit', ( + tester, + ) async { + // Desktop, or this asserts nothing: on a mobile platform the start's own + // listener drops prompt focus, so the Enter below never reaches + // `_onPromptKeyEvent` and passes with the in-flight guard deleted. + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + var submitCount = 0; + await tester.pumpWidget( + _host( + overrides: _baseOverrides(target: _project), + submit: (ref, {allowActiveSessions = false}) async { + submitCount++; + }, + ), + ); + await tester.pumpAndSettle(); + + final container = containerOf(tester); + await tester.enterText( + find.byKey(const Key('new-session-prompt-field')), + 'fix the bug', + ); + await tester.pump(); + + begin(container); + await settle(tester); + + final field = tester.widget( + find.byKey(const Key('new-session-prompt-field')), + ); + // Frozen, not disabled: the prompt already on the wire is the thing the + // user is waiting on, so it stays legible and undimmed. + expect(field.readOnly, isTrue); + expect(field.enabled, isTrue); + expect(field.showCursor, isFalse); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await settle(tester); + + expect(submitCount, 0); + expect(container.read(newSessionPromptProvider), 'fix the bug'); + + // Shift+Enter writes the controller directly, so read-only alone would + // still let it edit a prompt that is already being started with. + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await settle(tester); + + expect(container.read(newSessionPromptProvider), 'fix the bug'); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('send becomes Stop, then a plain busy button past the ' + 'cancel boundary', (tester) async { + await tester.pumpWidget( + _host(overrides: _baseOverrides(target: _project)), + ); + await tester.pumpAndSettle(); + + final container = containerOf(tester); + expect(stopButton(), findsNothing); + expect(tester.widget(sendButton()).busy, isFalse); + + begin(container); + await settle(tester); + + expect(stopButton(), findsOneWidget); + await tester.tap(sendButton()); + await settle(tester); + + expect(container.read(newSessionStartCancelRequestedProvider), isTrue); + expect(statusText(tester), 'Cancelling...'); + + // Past `creating` the bridge already holds a session, so abandoning the + // start would orphan it: the affordance goes away rather than lying. + advance(container, NewSessionStartPhase.creating); + await settle(tester); + + expect(stopButton(), findsNothing); + final busy = tester.widget(sendButton()); + expect(busy.busy, isTrue); + expect(busy.onTap, isNull); + expect( + container + .read(newSessionStartProgressProvider.notifier) + .requestCancel(), + isFalse, + ); + + container.read(newSessionStartProgressProvider.notifier).end(); + await tester.pumpAndSettle(); + + expect(stopButton(), findsNothing); + expect(find.byKey(const Key('new-session-status-line')), findsNothing); + expect(tester.widget(sendButton()).busy, isFalse); + }); + + testWidgets('the status line names the stage the start has reached', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + + await tester.pumpWidget( + _host(overrides: _baseOverrides(target: _project)), + ); + await tester.pumpAndSettle(); + + final container = containerOf(tester); + begin( + container, + phase: NewSessionStartPhase.switchingBranch, + branch: 'dev', + ); + await settle(tester); + + expect(find.byKey(const Key('new-session-status-line')), findsOneWidget); + expect(statusText(tester), 'Switching to dev...'); + + const expected = { + NewSessionStartPhase.activating: 'Waking mac-studio...', + NewSessionStartPhase.connecting: 'Starting project...', + NewSessionStartPhase.preparing: 'Preparing workspace...', + NewSessionStartPhase.creating: 'Creating session...', + NewSessionStartPhase.launching: 'Launching Claude Code...', + }; + for (final entry in expected.entries) { + advance(container, entry.key); + await settle(tester); + expect(statusText(tester), entry.value); + } + + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('the status line is shown on mobile too', (tester) async { + // The Enter hint is desktop-only, but a cold remote start is a 30s wait + // and a phone is where it is most often watched. + await tester.pumpWidget( + _host(overrides: _baseOverrides(target: _project)), + ); + await tester.pumpAndSettle(); + + expect(isMobilePlatform, isTrue); + expect(find.byKey(const Key('new-session-status-line')), findsNothing); + + final container = containerOf(tester); + begin(container, phase: NewSessionStartPhase.connecting); + await settle(tester); + + expect(find.byKey(const Key('new-session-status-line')), findsOneWidget); + expect(statusText(tester), 'Starting project...'); + expect(stopButton(), findsOneWidget); + }); + + /// Mounts the composer with the prompt focused and hands back its node. + Future focusedPrompt(WidgetTester tester) async { + await tester.pumpWidget( + _host(overrides: _baseOverrides(target: _project)), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('new-session-prompt-field'))); + await tester.pumpAndSettle(); + final node = tester + .widget(find.byKey(const Key('new-session-prompt-field'))) + .focusNode!; + expect(node.hasFocus, isTrue); + return node; + } + + testWidgets('a touch start drops the prompt focus', (tester) async { + // Flipping the prompt to readOnly closes the platform input connection on + // a touch platform, so the soft keyboard collapses on Send and — with + // focus still on the field — springs back the instant the start ends, + // over a form the user was not typing in and over the snackbar saying + // why. Dropping focus makes that close deliberate and one-way. + expect(isMobilePlatform, isTrue); + final node = await focusedPrompt(tester); + + begin(containerOf(tester)); + await settle(tester); + + expect(node.hasFocus, isFalse); + }); + + testWidgets('a desktop start keeps it', (tester) async { + // No soft keyboard to collapse, and Enter-to-start is how a retry after + // an abort is typed — taking focus away would cost a click every time. + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final node = await focusedPrompt(tester); + + begin(containerOf(tester)); + await settle(tester); + + expect(node.hasFocus, isTrue); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('an ended start never fades the Enter hint out in its place', ( + tester, + ) async { + // Desktop-roomy is the one shape where the slot survives a start ending + // (a phone, or a narrow pane, drops it wholesale). With the prompt + // unfocused the slot turns invisible on the same frame as the phase line + // is replaced by the Enter hint, so unless the two children animate + // separately the fade-out plays on the hint — text that was never on + // screen, dissolving out of a slot the user was reading a stage name in. + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + tester.view.physicalSize = const Size(1400, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + double slotOpacity() => tester + .widget( + find.descendant( + of: find.byType(AbCrossFade), + matching: find.byType(Opacity), + ), + ) + .opacity; + + await tester.pumpWidget( + _host(overrides: _baseOverrides(target: _project)), + ); + await tester.pumpAndSettle(); + expect(slotOpacity(), 0); + + final container = containerOf(tester); + begin(container); + await tester.pump(); + await settle(tester); + expect(find.byKey(const Key('new-session-status-line')), findsOneWidget); + expect(slotOpacity(), 1); + + container.read(newSessionStartProgressProvider.notifier).end(); + await tester.pump(); + + // The frame the swap lands on, and every frame a fade would have run + // through: the hint is already gone, not on its way out. + expect(find.byKey(const Key('new-session-status-line')), findsNothing); + expect(slotOpacity(), 0); + await tester.pump(const Duration(milliseconds: 60)); + expect(slotOpacity(), 0); + + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('a remount mid-start hands back a form that is still locked', ( + tester, + ) async { + // The lock lives in a provider, not in this State: the New Session screen + // builds a different tree either side of the compact breakpoint, so a + // resize mid-start disposes the composer — and a widget-local flag came + // back cleared while the start it was guarding ran on. + await tester.pumpWidget( + ProviderScope( + overrides: _baseOverrides(target: _project, worktreeSupported: true), + child: MaterialApp( + theme: buildAbTheme(), + home: Scaffold( + body: Align( + alignment: Alignment.bottomCenter, + child: Consumer( + builder: (context, ref, _) => ref.watch(_composerVisible) + ? NewSessionComposer( + onOpenFolder: () {}, + submit: (_, {allowActiveSessions = false}) async {}, + ) + : const SizedBox.shrink(), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final container = containerOf(tester); + final before = tester.state(find.byType(NewSessionComposer)); + + begin(container); + await settle(tester); + expect(stopButton(), findsOneWidget); + + container.read(_composerVisible.notifier).set(false); + await settle(tester); + expect(find.byType(NewSessionComposer), findsNothing); + + container.read(_composerVisible.notifier).set(true); + await settle(tester); + + // A genuine remount, not a rebuild: the old State was disposed. + final after = tester.state(find.byType(NewSessionComposer)); + expect(identical(before, after), isFalse); + + expect(stopButton(), findsOneWidget); + expect(statusText(tester), 'Waking mac-studio...'); + expect( + tester + .widget( + find.byKey(const Key('new-session-prompt-field')), + ) + .readOnly, + isTrue, + ); + + await tester.tap( + find.byKey(const Key('new-session-worktree-chip')), + warnIfMissed: false, + ); + await settle(tester); + expect(container.read(newSessionIsolatedProvider), isFalse); + + advance(container, NewSessionStartPhase.creating); + await settle(tester); + + final send = tester.widget(sendButton()); + expect(send.onTap, isNull); + expect(send.busy, isTrue); + }); + }); } diff --git a/app/test/widgets/recent_sessions_starting_row_test.dart b/app/test/widgets/recent_sessions_starting_row_test.dart new file mode 100644 index 00000000..4d8f108b --- /dev/null +++ b/app/test/widgets/recent_sessions_starting_row_test.dart @@ -0,0 +1,196 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:antgrid/design/ab_theme.dart'; +import 'package:antgrid/models/recent_session_row.dart'; +import 'package:antgrid/models/session_entry.dart'; +import 'package:antgrid/providers/new_session_start.dart'; +import 'package:antgrid/providers/recent_sessions.dart'; +import 'package:antgrid/widgets/recent_sessions/recent_sessions_tab.dart'; +import 'package:antgrid/widgets/recent_sessions/starting_session_row.dart'; + +/// The row renders nothing until a start is armed, and a zero-extent sliver +/// child is skipped by the default finders — so the container has to be built +/// here rather than looked up off an element that does not exist yet. +ProviderContainer _container(List overrides) { + final container = ProviderContainer(overrides: overrides); + addTearDown(container.dispose); + return container; +} + +Widget _host(ProviderContainer container, Widget child) { + return UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: buildAbTheme(), + home: Scaffold(body: child), + ), + ); +} + +void _begin( + ProviderContainer container, { + NewSessionStartPhase phase = NewSessionStartPhase.activating, + String deviceName = 'mac-studio', + String title = 'Fix the parser', +}) { + container + .read(newSessionStartProgressProvider.notifier) + .begin( + phase: phase, + targetId: 'M.p1', + targetName: 'antgrid', + deviceName: deviceName, + agentLabel: 'Claude Code', + isolated: false, + title: title, + ); +} + +String _phaseText(WidgetTester tester) => + tester.widget(find.byKey(startingSessionPhaseKey)).data!; + +RecentSessionRow _recentRow() => RecentSessionRow( + session: const SessionEntry( + id: 's1', + name: 'Fix auth bug', + createdAt: 0, + lastUsedAt: 1, + archived: false, + running: false, + ), + origin: const RecentOrigin( + isLocal: true, + registrationId: 'p', + projectId: 'p', + machineUuid: null, + projectName: 'antgrid', + deviceName: 'This device', + ), +); + +void main() { + testWidgets( + 'appears while a start runs, tracks the phase, and leaves on end', + (tester) async { + final container = _container(const []); + await tester.pumpWidget(_host(container, const StartingSessionRow())); + await tester.pump(); + + expect(find.byKey(startingSessionRowKey), findsNothing); + + _begin(container); + // Never pumpAndSettle: the row's AbLoadingDot animates forever. + await tester.pump(); + + expect(find.byKey(startingSessionRowKey), findsOneWidget); + expect(find.text('STARTING'), findsOneWidget); + expect(find.text('Fix the parser'), findsOneWidget); + expect(find.text('mac-studio · antgrid'), findsOneWidget); + expect(_phaseText(tester), 'Waking mac-studio...'); + + container + .read(newSessionStartProgressProvider.notifier) + .advance(NewSessionStartPhase.preparing); + await tester.pump(); + expect(_phaseText(tester), 'Preparing workspace...'); + + container.read(newSessionStartProgressProvider.notifier).end(); + await tester.pump(); + expect(find.byKey(startingSessionRowKey), findsNothing); + }, + ); + + testWidgets('a local start names the project alone', (tester) async { + final container = _container(const []); + await tester.pumpWidget(_host(container, const StartingSessionRow())); + // An empty deviceName is the local signal: there is no machine to wake, so + // neither the subtitle nor the phase copy may invent one. + _begin(container, deviceName: ''); + await tester.pump(); + + expect(find.text('antgrid'), findsOneWidget); + expect(_phaseText(tester), 'Opening antgrid...'); + }); + + testWidgets('is not tappable', (tester) async { + final container = _container(const []); + await tester.pumpWidget(_host(container, const StartingSessionRow())); + _begin(container); + await tester.pump(); + + // The composer owns the only Stop; a row for a session that does not exist + // yet has nothing to open. + final row = find.byKey(startingSessionRowKey); + expect( + find.descendant(of: row, matching: find.byType(GestureDetector)), + findsNothing, + ); + expect( + find.descendant(of: row, matching: find.byType(InkWell)), + findsNothing, + ); + + await tester.tap(row, warnIfMissed: false); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(find.byKey(startingSessionRowKey), findsOneWidget); + }); + + testWidgets('mounts in the empty-recents branch', (tester) async { + // The first session a user ever starts is started from an empty list — the + // branch that returns before the groups ever render. + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + // Torn down rather than reset at the end of the body: a failing `expect` + // would otherwise leak the override into the next test AND surface as + // flutter_test's "foundation debug variable was changed" instead of the + // assertion that actually failed. + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final container = _container([ + recentSessionsProvider.overrideWithValue(const []), + ]); + await tester.pumpWidget(_host(container, const RecentSessionsTab())); + await tester.pump(); + + expect(find.textContaining('No recent sessions'), findsOneWidget); + expect(find.byKey(startingSessionRowKey), findsNothing); + + _begin(container, phase: NewSessionStartPhase.creating); + await tester.pump(); + + expect(find.byKey(startingSessionRowKey), findsOneWidget); + expect(_phaseText(tester), 'Creating session...'); + expect(find.textContaining('No recent sessions'), findsOneWidget); + + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('sits above the first group when recents exist', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + tester.view.physicalSize = const Size(1400, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + final container = _container([ + recentSessionsProvider.overrideWithValue([_recentRow()]), + ]); + await tester.pumpWidget(_host(container, const RecentSessionsTab())); + await tester.pump(); + + _begin(container); + await tester.pump(); + + expect(find.byKey(startingSessionRowKey), findsOneWidget); + expect( + tester.getTopLeft(find.byKey(startingSessionRowKey)).dy, + lessThan(tester.getTopLeft(find.text('Fix auth bug')).dy), + ); + + debugDefaultTargetPlatformOverride = null; + }); +} diff --git a/app/test/widgets/recent_sessions_tab_test.dart b/app/test/widgets/recent_sessions_tab_test.dart index a6c669e8..e36fc91b 100644 --- a/app/test/widgets/recent_sessions_tab_test.dart +++ b/app/test/widgets/recent_sessions_tab_test.dart @@ -8,11 +8,13 @@ import 'package:antgrid/models/session_entry.dart'; import 'package:antgrid/providers/account_agents.dart'; import 'package:antgrid/providers/first_run.dart'; import 'package:antgrid/providers/new_session_picker.dart'; +import 'package:antgrid/providers/new_session_start.dart'; import 'package:antgrid/providers/recent_sessions.dart'; import 'package:antgrid/services/control_plane_client.dart'; import 'package:antgrid/storage/first_run_store.dart'; import 'package:antgrid/widgets/new_session/picker_sources.dart'; import 'package:antgrid/widgets/recent_sessions/recent_sessions_tab.dart'; +import 'package:antgrid/widgets/recent_sessions/starting_session_row.dart'; import 'package:antgrid/widgets/session_search_field.dart'; import '../helpers/prefs_test_mock.dart'; @@ -374,6 +376,82 @@ void main() { debugDefaultTargetPlatformOverride = null; }); + testWidgets('a start scrolls the list back to the row it adds', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + // Torn down as well as reset below, so a failing `expect` surfaces as + // itself rather than as "a foundation debug variable was changed". + addTearDown(() => debugDefaultTargetPlatformOverride = null); + tester.view.physicalSize = const Size(1400, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + final rows = [ + for (var i = 0; i < 40; i++) + RecentSessionRow( + session: SessionEntry( + id: 's$i', + name: 'Session $i', + createdAt: 0, + lastUsedAt: 40 - i, + archived: false, + running: false, + ), + origin: const RecentOrigin( + isLocal: true, + registrationId: 'p', + projectId: 'p', + machineUuid: null, + projectName: 'antgrid', + deviceName: 'This device', + ), + ), + ]; + final container = ProviderContainer( + overrides: [recentSessionsProvider.overrideWithValue(rows)], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: _wrap(const RecentSessionsTab()), + ), + ); + await tester.pump(); + + final scrollable = find.descendant( + of: find.byType(RecentSessionsTab), + matching: find.byType(Scrollable), + ); + tester.state(scrollable).position.jumpTo(600); + await tester.pump(); + expect(tester.state(scrollable).position.pixels, 600); + + container + .read(newSessionStartProgressProvider.notifier) + .begin( + phase: NewSessionStartPhase.activating, + targetId: 'p', + targetName: 'antgrid', + deviceName: '', + agentLabel: 'Claude Code', + isolated: false, + title: 'fix the bug', + ); + // Not pumpAndSettle: the placeholder row's loading dot repeats forever, so + // the tree never settles while a start is armed. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Otherwise the placeholder grows above the viewport: every visible row + // shoved down by its height, and the row the shove was for never seen. + expect(tester.state(scrollable).position.pixels, 0); + expect(find.byKey(startingSessionRowKey), findsOneWidget); + + debugDefaultTargetPlatformOverride = null; + }); + testWidgets('desktop empty state names the pick-a-project step when no ' 'target is selected', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.macOS; From 098e22baf1f3ec0a291d92c8d2086fafb9082221 Mon Sep 17 00:00:00 2001 From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:00:08 +0800 Subject: [PATCH 09/15] ci(ios): SHA-pin dtolnay/rust-toolchain (#13) `@stable` is a branch in that repo, not a tag. The action selects the Rust channel from the ref it was called with, so the reference is mutable in a way even a tag is not: whatever is pushed to `stable` runs on the next iOS deploy, in a job holding the App Store Connect key and the distribution cert. Pinned to the stable branch's current tip. Two things this deliberately does not do: it does not freeze the Rust version -- rustup still resolves the stable channel at run time -- and it is not the start of a repo-wide pinning pass, which was considered and declined as more maintenance than it is worth here. `toolchain: stable` is now explicit. Each channel branch carries its own action.yml default, and on master the input is required with no default, so a future re-pin onto a master SHA would otherwise fail to parse the toolchain. --- .github/workflows/deploy-ios.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-ios.yml b/.github/workflows/deploy-ios.yml index fa4554c8..11dd96ab 100644 --- a/.github/workflows/deploy-ios.yml +++ b/.github/workflows/deploy-ios.yml @@ -125,8 +125,14 @@ jobs: # hook then compiles the crate, and the runner's preinstalled Rust lacks the # device target. Keep it — a missing or stale iOS asset turns into a build # failure rather than a source build without it. - - uses: dtolnay/rust-toolchain@stable + # SHA-pinned because `@stable` is a BRANCH — this action picks the channel + # from its ref, so the reference is mutable in a way even a tag is not. The + # pin freezes the action, not Rust; rustup still resolves stable at run + # time. `toolchain` is explicit so a re-pin onto a master SHA, where the + # input is required with no default, does not break the build. + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: + toolchain: stable targets: aarch64-apple-ios - name: Flutter pub get From b016402e07c727a0097c8948a94fdac91266ad9a Mon Sep 17 00:00:00 2001 From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:00:32 +0800 Subject: [PATCH 10/15] A promotional grant must not block account deletion (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * web: a promotional grant must not block account deletion ensureDefaultSubscription hands every new account the pro_yearly plan while checkout is disabled, and hasRenewingPaidSubscription exempted only the free plan — so every account was refused deletion and pointed at a subscription page with nothing to cancel. The grant rides a paid plan's row, so the slug test cannot tell it apart from a purchase; check promotional first. The existing tests all seeded real purchases, which is why this went unnoticed — the new one goes through provisionProductAccountForUser, the path a real signup takes. * web: the account page offers deletion under the promotional grant account-page.test.ts pinned the old behaviour as an "accepted limitation during the promo". It was acceptable only while nobody needed to delete: the grant renews nothing, no checkout sold it, and no surface can cancel it, so the limitation was a permanent block on every account rather than a temporary one. Flipped to assert the control is offered, with the reasoning recorded where the old expectation was. --- web/src/services/account.ts | 11 +++++++++-- web/tests/routes/account-page.test.ts | 19 +++++++++++------- web/tests/services/account-delete.test.ts | 24 ++++++++++++++++++++++- 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/web/src/services/account.ts b/web/src/services/account.ts index cfdaa358..744af824 100644 --- a/web/src/services/account.ts +++ b/web/src/services/account.ts @@ -23,8 +23,8 @@ import { PLAN_SLUG_FREE } from "../models/plan.js"; export type DeleteAccountResult = "deleted" | "blocked_subscription" | "blocked_team"; /** Block deletion only while a *paid* subscription will still auto-renew. A free - * plan, or a paid plan already pending cancellation, has no future charge and - * does not block. + * plan, a promotional grant, or a paid plan already pending cancellation has no + * future charge and does not block. * * Scoped to the account the user OWNS, not the one they bill against. A member * inherits their owner's renewing subscription and cannot cancel it, so @@ -35,6 +35,13 @@ export async function hasRenewingPaidSubscription(db: DB, userId: string): Promi if (!owned) return false; const sub = await activeSubscriptionForAccount(db, owned.id); if (!sub) return false; + // An unpurchased grant renews nothing and bills nobody, so it is not a reason + // to refuse. Checked BEFORE the slug test because the grant rides a paid + // plan's row — `ensureDefaultSubscription` hands every new account + // `pro_yearly` while checkout is disabled, so the slug alone cannot tell it + // apart from a real purchase, and every account would be refused deletion + // with nothing to cancel. + if (sub.promotional) return false; const plan = await db.plan.findUnique({ where: { id: sub.planId } }); if (!plan || plan.slug === PLAN_SLUG_FREE) return false; return !isPendingCancellation(sub); diff --git a/web/tests/routes/account-page.test.ts b/web/tests/routes/account-page.test.ts index 52fac121..3374ee87 100644 --- a/web/tests/routes/account-page.test.ts +++ b/web/tests/routes/account-page.test.ts @@ -20,8 +20,9 @@ describe("GET /account", () => { const { cookie } = await createTestSession(pg.db, user.id); // GET /account re-provisions on every visit (provisionProductAccountForUser), // which upgrades a lingering free row back to the promotional grant — so - // genuinely-free is not reachable here. A subscription pending - // cancellation is the reachable "not blocked" state instead. + // genuinely-free is not reachable here. Pending cancellation stands in, and + // is worth keeping distinct from the promo grant the test above covers: + // this one proves a real purchase stops blocking once it is winding down. const account = await provisionProductAccountForUser(pg.db, user.id); await pg.db.subscription.updateMany({ where: { accountId: account.id, status: "active" }, @@ -38,19 +39,23 @@ describe("GET /account", () => { expect(html).toContain('name="confirm"'); }); - test("blocks deletion for an active promotional pro grant", async () => { + test("offers deletion under the promotional pro grant", async () => { const { app } = buildTestApp(pg.db, pg.url); const user = await createTestUser(pg.db, "iris@example.com"); const { cookie } = await createTestSession(pg.db, user.id); // No explicit subscription fixture — a fresh account defaults to the - // promotional pro grant, which is treated like a real paid plan for - // deletion purposes (accepted limitation during the promo). + // promotional pro grant. It was once treated as a paid plan here, which + // was survivable only for as long as nobody needed to delete: the grant + // renews nothing, no checkout sold it, and no surface can cancel it, so + // every account was permanently undeletable and sent to a pricing page + // reading "Coming soon". Deletion has to stay reachable — see + // hasRenewingPaidSubscription. const res = await app.request("/account", { headers: { cookie } }); expect(res.status).toBe(200); const html = await res.text(); - expect(html).toContain("You have an active subscription"); - expect(html).not.toContain('name="confirm"'); + expect(html).not.toContain("You have an active subscription"); + expect(html).toContain('name="confirm"'); }); test("an owner whose team still has members is told so, not offered the control", async () => { diff --git a/web/tests/services/account-delete.test.ts b/web/tests/services/account-delete.test.ts index 02691730..9a7518eb 100644 --- a/web/tests/services/account-delete.test.ts +++ b/web/tests/services/account-delete.test.ts @@ -5,7 +5,7 @@ import { createTestUser, createTestSession, createTestSubscription, createTestDe import { createAuth } from "../../src/auth/better-auth.js"; import { createEmailSender } from "../../src/auth/email.js"; import { deleteUserAccount } from "../../src/services/account.js"; -import { ensureFreeSubscription } from "../../src/models/subscription.js"; +import { ensureFreeSubscription, provisionProductAccountForUser } from "../../src/models/subscription.js"; import { ensureProductAccount } from "../../src/models/product-account.js"; import { applySubscriptionEvent } from "../../src/billing/reducer.js"; @@ -96,6 +96,28 @@ describe("deleteUserAccount", () => { expect(u.email).toBe("bob@example.com"); }); + test("the promotional grant every new account gets does not block deletion", async () => { + const auth = authFor(pg.db, pg.url); + const user = await createTestUser(pg.db, "promo@example.com"); + // The production path, not a hand-built row: this is what provisioning + // hands every new user while checkout is disabled. + const account = await provisionProductAccountForUser(pg.db, user.id); + + // Non-vacuity guard. The grant rides a PAID plan, which is the only reason + // this case is interesting — were it ever switched to the free plan, the + // slug test would carry it and this test would prove nothing. + const granted = await pg.db.subscription.findFirstOrThrow({ + where: { accountId: account.id, status: "active" }, + }); + expect(granted.promotional).toBe(true); + expect(granted.tier).toBe("pro"); + + const result = await deleteUserAccount(pg.db, { baseUrl: undefined, secret: undefined }, auth, { + userId: user.id, headers: new Headers(), + }); + expect(result).toBe("deleted"); + }); + test("pending-cancel paid sub is fully cancelled and provider ids nulled on deletion", async () => { const auth = authFor(pg.db, pg.url); const fakeRelay = { baseUrl: undefined, secret: undefined }; From cfa201868d4056f8924bc07c59609b66f1b3fd21 Mon Sep 17 00:00:00 2001 From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:33:40 +0800 Subject: [PATCH 11/15] Offline demo mode so the app is reviewable without a desktop (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * app: an offline demo mode so the app is reviewable without a desktop Antgrid is unusable on a phone without a computer running the bridge, which makes it unreviewable for an app store: a reviewer signs up, lands in a shell with no machines, and has nothing to exercise. This adds a sample project reachable from the sign-in screen with no account, no keychain and no socket. The demo drives the REAL workspace — DemoTransport is an AgentTransport over canned wire frames, so the sample project flows through the real router, services and widgets rather than a parallel set of fakes. Anything it cannot honestly do refuses in one fixed sentence. Isolation is held by call-site gates rather than a boundary, so it leaks by default: every persistence store, host-spawn path, analytics sink and first-run latch checks demoModeProvider for itself. The host-spawn class is the sharp one — a LocalProject target arms ensureHost() and the demo's target is one. * demo mode fixes * app: sign-in's five identical buttons become three tiers Step 1 stacked five full-width buttons of identical weight, four of them opening with the same word, and spent no accent at all. Continue looked exactly like Continue with a password, and scanning the column gave you Continue four times over with the distinguishing word last in every label. Three tiers now carry three visual classes, so they are told apart before they are read: an accent-filled Continue (the only fill on the screen), the three auth methods as one bordered group, and the demo as an outlined card. The demo left the credential stack because it is not a way through this screen - it leaves the account behind entirely. As the only left-aligned, two-line, outlined thing here it stays as prominent as the button was, which the mobile case needs: this screen is the whole app until an account exists. Its caveat now rides inside the card rather than floating under a button that has already been read. The password cell is a peer of the two providers, not the link a wall-flattening pass would make it. _startOAuth records the TYPED address rather than the one the user authenticates as, so the hint can land wrong, and this is the only path that reaches step 2 - and the link it carries - whatever the hint says. The method group borrows AbSegmented's construction (outer border, ClipRRect, 1px dividers, inset focus rings) but is deliberately not one: these cells fire actions, and a selected state would promise a choice that persists. _SignInButton gained a primary variant, applied to the single primary action on every other phase so the accent reads as a language rather than a one-off. Its corners moved to radius5 - the token's documented value for buttons and inputs - so it matches the field above it. --- app/CLAUDE.md | 1 + app/lib/analytics/analytics_service.dart | 11 + app/lib/demo/demo_identity.dart | 74 ++ app/lib/demo/demo_script.dart | 112 +++ app/lib/demo/demo_transport.dart | 736 ++++++++++++++++++ .../fixtures/demo_transcript_fixtures.dart | 323 ++++++++ .../fixtures/demo_workspace_fixtures.dart | 518 ++++++++++++ app/lib/design/ab_icons.dart | 4 + app/lib/main.dart | 60 +- app/lib/navigation/back_intent.dart | 7 + app/lib/navigation/nav_controller.dart | 6 + app/lib/navigation/root_navigator.dart | 16 + app/lib/project/project_session_registry.dart | 8 +- app/lib/providers/account_agents.dart | 9 + app/lib/providers/agent_transport.dart | 31 +- app/lib/providers/analytics.dart | 2 + app/lib/providers/demo_mode.dart | 106 +++ app/lib/providers/drawer_entries.dart | 21 + app/lib/providers/first_run.dart | 9 + app/lib/providers/focused_tools.dart | 10 + app/lib/providers/host_status.dart | 11 +- app/lib/providers/new_session_action.dart | 20 +- app/lib/providers/new_session_picker.dart | 50 ++ app/lib/providers/projects.dart | 7 +- app/lib/providers/providers.dart | 22 +- app/lib/providers/recent_sessions.dart | 38 + app/lib/providers/registry_eviction.dart | 19 +- app/lib/providers/remote_access_nudge.dart | 7 + app/lib/screens/demo_home.dart | 120 +++ app/lib/screens/preview_screen.dart | 12 + app/lib/screens/sign_in_screen.dart | 387 ++++++++- app/lib/screens/workspace_shell.dart | 19 +- .../services/local_notification_service.dart | 16 + app/lib/storage/cached_sessions_store.dart | 8 + app/lib/storage/drawer_collapsed_store.dart | 8 +- app/lib/storage/project_store.dart | 11 +- app/lib/storage/recent_ports_store.dart | 3 + app/lib/widgets/agent_panel.dart | 7 + app/lib/widgets/agent_transcript_view.dart | 7 +- app/lib/widgets/demo_frame.dart | 111 +++ app/lib/widgets/drawer_entry_row.dart | 2 + app/lib/widgets/first_run_checklist.dart | 42 +- app/lib/widgets/new_session/project_menu.dart | 24 +- app/lib/widgets/projects_drawer.dart | 38 +- .../recent_sessions/recent_sessions_tab.dart | 27 +- app/lib/widgets/session_row.dart | 10 +- app/test/demo/demo_entry_points_test.dart | 183 +++++ app/test/demo/demo_fixture_contract_test.dart | 346 ++++++++ app/test/demo/demo_frame_test.dart | 99 +++ app/test/demo/demo_isolation_test.dart | 437 +++++++++++ app/test/demo/demo_mode_gate_test.dart | 115 +++ app/test/demo/demo_transport_test.dart | 369 +++++++++ app/test/helpers/demo_harness.dart | 98 +++ app/test/screens/sign_in_screen_test.dart | 56 +- app/test/widget_test.dart | 3 + 55 files changed, 4705 insertions(+), 91 deletions(-) create mode 100644 app/lib/demo/demo_identity.dart create mode 100644 app/lib/demo/demo_script.dart create mode 100644 app/lib/demo/demo_transport.dart create mode 100644 app/lib/demo/fixtures/demo_transcript_fixtures.dart create mode 100644 app/lib/demo/fixtures/demo_workspace_fixtures.dart create mode 100644 app/lib/navigation/root_navigator.dart create mode 100644 app/lib/providers/demo_mode.dart create mode 100644 app/lib/screens/demo_home.dart create mode 100644 app/lib/widgets/demo_frame.dart create mode 100644 app/test/demo/demo_entry_points_test.dart create mode 100644 app/test/demo/demo_fixture_contract_test.dart create mode 100644 app/test/demo/demo_frame_test.dart create mode 100644 app/test/demo/demo_isolation_test.dart create mode 100644 app/test/demo/demo_mode_gate_test.dart create mode 100644 app/test/demo/demo_transport_test.dart create mode 100644 app/test/helpers/demo_harness.dart diff --git a/app/CLAUDE.md b/app/CLAUDE.md index bf988ff1..292aa8d3 100644 --- a/app/CLAUDE.md +++ b/app/CLAUDE.md @@ -19,6 +19,7 @@ app's relay layer lives outside this tree: see - `screens/` — terminal, file explorer, preview, scanner (QR coordinate import — not a rendezvous). `workspace_shell.dart` = responsive layout, split at `kCompactBreakpoint` (mobile PageView swipe; desktop/tablet rail + resizable split — same layout for both). Signed-in user (email + tier pill) renders via `AccountFooter` in the drawer. `widgets/window_title_bar.dart` mounts above the route only for a non-touch (mouse) desktop at `>= kMediumBreakpoint` (`app_shell.dart` — `isMobilePlatform`, i.e. Android/iOS, skips it at ANY width, same rationale as `_defaultPanelMode` below) and owns the nav/chips/window-controls row plus the two pane toggles at its outer edges — the projects drawer on the left and the context panel on the right — which WorkspaceShell publishes through `sidebarControlProvider` / `contextPanelControlProvider` because the bar mounts above its route (hiding either pane takes its own affordances with it, so these controls are the only way back). `new_session_screen.dart`'s `NewSessionScreen` publishes its own `sidebarControlProvider` the same way (there is no context panel on that route, so it alone), backed by the same `sidebarHidden` app setting — the drawer's hidden/shown state and its title-bar toggle are shared across both routes, not WorkspaceShell-only. `_PanelMode.contextExpanded` renders no collapsed agent stub of its own (unlike `contextHidden`'s full-width agent panel, expanded has no equivalent affordance) — the title bar's context-panel toggle is the only way back from it, same as from `contextHidden`. Two DIFFERENT no-title-bar cases, two DIFFERENT recoveries, both in `WorkspaceShellState._buildDesktop`: a narrow non-touch desktop window (`kCompactBreakpoint..kMediumBreakpoint`) forces the drawer permanently visible (ignores `sidebarHidden`) and reveals the context panel by restoring `_PanelMode.normal`; a touch tablet (any width) routes to `_buildTabletTouch` instead, where BOTH the sidebar and the context panel are docked panes like the mouse desktop's — the sidebar open by default (matching the mouse desktop's own always-on rail), the context pane closed by default unlike `_PanelMode.normal`: a session opens on the agent alone plus the sidebar, and the context pane appears only once the user swipes or picks a view from its popup (the popup itself starts open regardless of pane state, so it needs no tap to reach) — both hand-rolled and ALWAYS-mounted, deliberately NOT real `Scaffold.drawer`/`endDrawer`s, since Flutter's `DrawerController` drops its child from the tree entirely while closed, which would dispose `WorkspacePanel`'s `PreviewScreen` WebView and terminal on every swipe-close of the context pane (the same class of regression the `_agentPanelKey`/`_contextPanelKey` GlobalKeys below exist to prevent). Neither pane's own width ever changes during its open/close animation (only an `AnimatedSlide` offset does, so neither is ever laid out at an intermediate width) while the agent pane's reserved space on both sides animates via ONE `AnimatedPadding` on the same duration/curve as both slides, so all three move in lockstep. Both cases' context-panel reveal funnels through `_openContextPanel`, so `WorkspaceMenuButton` (in `AgentBar`) and `revealHandlerTab` never need to know which one is live; on the tablet, that button shows the SAME popup as desktop regardless of pane state (it starts open independently of the pane) — a tap only ever shows/hides that popup, on every platform, never a side-effecting shortcut on the pane itself — see the button's own doc. `AgentBar` also grows a leading "Projects" button, touch platform only, opening the sidebar pane the same way, alongside the swipe (one raw-pointer fling dispatcher for all four pane actions, `_onTabletFlingDown`/`Up`, mirroring mobile's own fling-not-edge-drag drawer gesture — edge-anchored drags are not available to us, since Android's system back owns both edges) as the discoverable opener. **Only the sidebar answers to a fling, and only on its own half of the screen** (split at the agent pane's midpoint): the context pane takes no swipe in either direction — it opens from `WorkspaceMenuButton`'s popup and closes from its tab bar's close button, since a pane a swipe could also OPEN made every sideways drag over the agent a coin flip. A fling never reaches across the window either (the far-pane fallback that used to close the sidebar from the right edge is gone), so a gesture with nothing to do on its own side does nothing. **Nothing arbitrates with that gesture any more** — the git rows' swipe tray, the workspace tab strip and the code viewers all live in the context pane, whose half now moves no pane at all, so the whole claim-flag mechanism they fed (`util/swipe_row_arbitration.dart`) is deleted. The dispatcher still watches raw pointers, so it fires under a descendant that already won the arena: putting a swipeable widget beside the agent, or giving the context pane a fling again, brings that arbitration question back with it. `new_session_screen.dart`'s touch branch mirrors this same sidebar treatment (`_tabletSidebarOpen`) at any width `>= kCompactBreakpoint`, falling back to a real swiped-in `Scaffold.drawer` only at phone width, same as `WorkspaceShell`'s own three-way split. The session's breadcrumb/branch/agent-mark/mode/handler cluster belongs to `AgentBar` (`widgets/agent_panel.dart`), which mirrors `WorkspaceTabBar` across the divider; in the panel modes that mount no agent bar (`agentBarMountedProvider`), the title bar takes back only the mode control (`SessionModeControl`) — never the name (whose duplicate flashed a project id one row up before the name settled), and never the agent mark or handler shield, which the title bar carries no fallback for at all. The bar's elastic middle carries `SessionSearchField`; it is also the window drag target, so nothing may fill that gap edge to edge. - **The session search is a POPUP, not a filter over anything on screen** (`widgets/session_search_field.dart` + `providers/session_search.dart`). It answers into its own `OverlayPortal` from any route, which is why it neither narrows the drawer (project rows the user is already looking at) nor the Recent list. Results come from `sessionSearchResultsProvider` over Recent's rows — the one flat view spanning machines and projects — which reads the persisted session cache and NEVER the wire: a keystroke must not dial a machine. The popup opens on FOCUS (so Ctrl+K alone reveals it, resting on the recent list) and closes on its barrier, on Escape-when-empty, or on a row being taken (`RecentSessionRowWidget.onOpened`); blur must NOT close it, because the rows are focusable. **Desktop and mobile are deliberately DIFFERENT surfaces, not one responsive widget** — and a touch tablet is mobile's surface at ANY width, and a narrow non-touch desktop window is too (`< kMediumBreakpoint`), because neither has a title bar to host a field in. Desktop is `SessionSearchField` — an always-open title-bar field whose popup measures its width and max height off the field (a pointer pattern). Everything else gets `SessionSearchButton` + `showSessionSearch()` (`widgets/session_search_modal.dart`) — an icon opening a full-screen `Dialog.fullscreen`, which is what both Material and iOS prescribe: a phone (or any title-bar-less window) has no row to spare for a permanent text box, and an anchored popup loses most of itself to the keyboard. On the New Session/Recent screen the button sits in `NewSessionContent`'s `_TopBar`; `new_session_screen.dart` routes any touch platform through the SAME branch as phone width (`isMobile || isMobilePlatform`) for the full hamburger+button bar, reserving the search-only variant (`showSearchButton`, no hamburger — the drawer there is a persistent pane, not a slide-in) for a narrow non-touch desktop window. Inside a session, `WorkspaceShellState._focusSessionSearch` opens the same modal for Ctrl/⌘-K whenever `isMobilePlatform` or the window is below `kMediumBreakpoint`. A dialog ROUTE, not an overlay, so system back closes it. Only the shared `SessionSearchResults` (`widgets/session_search_results.dart`) is common, so the surfaces can never answer differently. Mobile/tablet clear the query on close (nothing survives to show a stale one); desktop keeps it. - `models/` — Dart mirrors of agent protocol types. +- `demo/` — the offline sample project reachable with no account (`kDemoEntryLabel`). `providers/demo_mode.dart`'s `demoModeProvider` is the single switch, in memory only; while it is on `agentTransportForProvider` hands back a `DemoTransport` — a real `AgentTransport` over canned wire frames, so the demo renders through the real `MessageRouter`, the real per-project services and the real widgets rather than a parallel set of fakes — and `screens/demo_home.dart` replaces `AppShell` as the root route (AppShell's `initState` dials the relay and reads the keychain). `demo/fixtures/` are RAW wire envelopes on purpose: a frame whose shape `parseAbMessage` rejects is dropped silently and only shows up as an empty surface, which is what `test/demo/demo_fixture_contract_test.dart` walks every one of them for. **The invariant is that nothing about the demo reaches disk, spawns a bridge host, or phones home, and no real state is written under it** — and it is held by scattered call-site gates, not by a boundary: every persistence store, host-spawn path, analytics sink and first-run latch has to check `demoModeProvider` (or `isDemoProjectId`/`isDemoEntryId` from `demo/demo_identity.dart`) for itself, so anything added later leaks by default. Prefer gating inside the STORE over gating at each caller, and pin the new gate in `test/demo/demo_isolation_test.dart`. The host-spawn class is the one that keeps recurring, because a `LocalProject` target is what arms it and the demo's target IS one: every `ensureHost()` caller reachable from the workspace or the New Session canvas needs its own gate, and each swallows its failure, so the value it returns cannot tell a gate from a spawn — the spy test there asserts the controller was never read. - `design/` — design system (see Design Rules at the bottom of this file). - **No app-side pairing.** `PairingService`/`PhoneIdentity` and the whole Dart pair surface (`pair_sign`/`pair_verify`/`account_membership_sign`, the `Pair*`/`Grant*` message classes, `RelayService.requestPair`/`grantRevoke`/`unpair`) are deleted — admission is account trust. The relay/bridge/wire TS pair machinery is deleted too; the Dart client still tolerates the old frame types (parses to null, never fatal) in case an un-upgraded relay is still speaking them. - `recent_agents.dart` — `RecentAgent` rows in `SharedPreferences` (`antgrid.recent_agents.v4`, `dev.`-prefixed in a local dev build per `storage_scope.dart`) are a pure COORDINATES cache — `relayUrl` + the `ed25519Pub` to pin the machine against, public values only, never key material — and drive the reconnect list. Each key bump drops stale rows rather than migrating (pre-release). diff --git a/app/lib/analytics/analytics_service.dart b/app/lib/analytics/analytics_service.dart index 53e66011..ee74c6d7 100644 --- a/app/lib/analytics/analytics_service.dart +++ b/app/lib/analytics/analytics_service.dart @@ -11,6 +11,7 @@ class AnalyticsService { required String platform, required String appVersion, required bool Function() enabled, + bool Function()? paused, DateTime Function()? now, this.batchSize = 10, }) : _client = client, @@ -21,8 +22,11 @@ class AnalyticsService { _platform = platform, _appVersion = appVersion, _enabled = enabled, + _paused = paused ?? _never, _now = now ?? DateTime.now; + static bool _never() => false; + final http.Client _client; final String _plausibleUrl; final String _plausibleDomain; @@ -31,6 +35,9 @@ class AnalyticsService { final String _platform; final String _appVersion; final bool Function() _enabled; + + /// A temporary hold, distinct from [_enabled]: see [flush]. + final bool Function() _paused; final DateTime Function() _now; final int batchSize; @@ -83,6 +90,10 @@ class AnalyticsService { } Future flush() async { + // A pause is not an opt-out. The queue holds the user's OWN events from + // before they entered the sample project, which they had consented to + // send; hold them until the pause lifts rather than dropping them. + if (_paused()) return; // Honor a runtime opt-out: track() stops enqueuing once disabled, but the // pause-lifecycle flush would otherwise still transmit events queued while // telemetry was on. Drop them instead. diff --git a/app/lib/demo/demo_identity.dart b/app/lib/demo/demo_identity.dart new file mode 100644 index 00000000..1570857d --- /dev/null +++ b/app/lib/demo/demo_identity.dart @@ -0,0 +1,74 @@ +import '../models/ab_project.dart'; +import '../util/device_id.dart'; + +/// Identity of the built-in offline demo workspace. +/// +/// Demo mode drives the REAL workspace UI from canned frames, so the demo +/// project flows through every provider a live project does. These constants +/// are the single discriminator those providers gate on — anything that would +/// persist, phone home, or reach the keychain checks [isDemoProjectId] first. + +/// Project id the demo transport is registered under. +/// +/// Not a legal bridge project id (a real one is a hashed absolute path), so it +/// can never collide with a machine the user actually owns. +const String kDemoProjectId = 'antgrid-demo'; + +/// Human name shown wherever the workspace names its project. Carries the +/// "(sample)" suffix so a screenshot of the demo is self-labelling even without +/// the surrounding banner. +const String kDemoDisplayName = 'demo-shop (sample)'; + +bool isDemoProjectId(String? projectId) => projectId == kDemoProjectId; + +/// Cache/registry keys are the project id for a local session, so the two +/// predicates coincide today. Kept separate because a store keyed by the relay +/// `registrationId` would need the compound match, and callers should not have +/// to know which kind of key they hold. +/// +/// Split with [baseProjectId], not `endsWith`: a compound id is +/// `.` split at the FIRST dot, so a suffix test would +/// disagree with the rest of the app on any id whose deviceUuid contains one. +bool isDemoEntryId(String? entryId) => + entryId != null && baseProjectId(entryId) == kDemoProjectId; + +/// Machine-readable refusal for anything the demo cannot honestly do, and the +/// one sentence every surface says when it declines. Lives here rather than in +/// `demo_transport.dart` because the refusal is not only a wire answer: an +/// affordance the UI resolves without ever asking the transport (the preview's +/// port list, say) must decline in the same words. +const String kDemoRefusalCode = 'E_DEMO_UNSUPPORTED'; +const String kDemoRefusalText = + 'This is the sample project — connect a machine to do that for real.'; + +/// Label on every affordance that opens the demo (sign-in, the desktop setup +/// checklist, the Recent tab's empty state). One definition because it is the +/// same promise in each place, and a reviewer meeting two wordings would read +/// them as two different things. +const String kDemoEntryLabel = 'Explore a sample project'; + +/// Folder the sample project claims to live in. Shown as the drawer row's +/// subtitle and in the picker's detail column; never touched on disk. +const String kDemoFolder = '~/code/demo-shop'; + +/// The sample project as the rest of the app expects to receive it. +/// +/// One definition, because two surfaces list projects from independent sources +/// (the drawer's [DrawerEntry] merge and the New Session picker's rail) and a +/// demo that named itself differently in each would look like two projects. +/// Built fresh per call rather than held as a const: [AbProject] carries +/// mutable fields, and a shared instance would let one surface's write reach +/// the other. +AbProject demoProject() => AbProject( + projectId: kDemoProjectId, + folder: kDemoFolder, + displayName: kDemoDisplayName, + // Never persisted (the demo is exempt from every store), so the fields a real + // project carries for host routing have nothing to say here. + hostDeviceUuid: null, + hostMachineName: '', + // Now, not the epoch: the New Session picker copies this straight into + // `PickerProject.lastActiveAt` and renders it, where a zero read as the + // sample project having last been opened in 1970. + lastOpenedAt: DateTime.now(), +); diff --git a/app/lib/demo/demo_script.dart b/app/lib/demo/demo_script.dart new file mode 100644 index 00000000..7dc03934 --- /dev/null +++ b/app/lib/demo/demo_script.dart @@ -0,0 +1,112 @@ +/// Timed playback for the offline demo. +/// +/// A beat is a frame plus the delay after connect at which it is dispatched. +/// The opening script is FINITE and short: a loop would keep a timer alive for +/// as long as the demo is open and make the terminal look like it is still +/// doing work minutes after the user stopped watching. +library; + +import 'fixtures/demo_workspace_fixtures.dart'; + +typedef DemoBeat = ({Duration at, String channel, Map frame}); + +DemoBeat _control(int ms, Map frame) => + (at: Duration(milliseconds: ms), channel: 'control', frame: frame); + +Map _terminalOutput(String data) => { + 'type': 'terminal:output', + 'checkoutId': 'main', + 'terminalId': kDemoTerminalId, + 'data': data, +}; + +/// Plays once when the demo transport connects: a dev server starting, then the +/// port and preview URL it exposes. Ends deliberately at a shell prompt so the +/// terminal reads as idle rather than truncated. +final List kDemoScript = [ + _control(800, _terminalOutput('\r\n${kDemoShellPrompt}bun run dev\r\n')), + _control(1700, _terminalOutput('\r\n VITE ready in 412 ms\r\n')), + _control(2500, _terminalOutput(' Local: $kDemoPreviewUrlString/\r\n')), + _control(3100, kDemoPortsUpdate), + _control(3600, kDemoPreviewUrl), + _control(4600, _terminalOutput('\r\n$kDemoShellPrompt')), +]; + +/// What the demo answers a typed prompt with. +/// +/// The text says plainly that nothing ran, because the alternative — a canned +/// answer that reads like a real one — is the exact confusion the demo banner +/// exists to prevent. It still arrives as deltas so the streaming transcript, +/// the working indicator and the stop button all behave as they do live. +const List _kReplyChunks = [ + 'Nothing ran — this is the built-in sample project, ', + 'so your message stayed on this device.\n\n', + 'Connect Antgrid on your computer and this pane drives the real agent: ', + 'your prompt goes to Claude Code, Codex or whichever CLI that project uses, ', + 'and its reply streams back here exactly like this one.', +]; + +/// Beats for one canned reply turn. The caller supplies the ids so the reply +/// lands in the session the user actually typed into. +List demoPromptReplyBeats({ + required String sessionId, + required String turnId, + required String promptText, +}) { + Map envelope(String type, Map body) => + { + 'type': type, + 'sessionId': sessionId, + 'turnId': turnId, + ...body, + }; + + final beats = [ + _control(0, envelope('agent:turn-start', const {})), + _control( + 0, + envelope('agent:item-added', { + 'item': { + 'itemId': '$turnId-user', + 'kind': 'message', + 'role': 'user', + 'text': promptText, + }, + }), + ), + _control( + 600, + envelope('agent:item-added', { + 'item': { + 'itemId': '$turnId-answer', + 'kind': 'message', + 'role': 'assistant', + 'text': '', + }, + }), + ), + ]; + + var ms = 900; + for (final chunk in _kReplyChunks) { + beats.add( + _control( + ms, + envelope('agent:item-delta', { + 'itemId': '$turnId-answer', + 'textChunk': chunk, + }), + ), + ); + ms += 260; + } + beats.add( + _control( + ms, + envelope('agent:turn-end', const { + 'stopReason': 'end_turn', + }), + ), + ); + return beats; +} diff --git a/app/lib/demo/demo_transport.dart b/app/lib/demo/demo_transport.dart new file mode 100644 index 00000000..d224cb3a --- /dev/null +++ b/app/lib/demo/demo_transport.dart @@ -0,0 +1,736 @@ +import 'dart:async'; + +import 'package:antgrid_relay_client/antgrid_relay_client.dart'; +import 'package:flutter/foundation.dart'; + +import 'demo_identity.dart'; +import 'demo_script.dart'; +import 'fixtures/demo_transcript_fixtures.dart'; +import 'fixtures/demo_workspace_fixtures.dart'; + +/// Round trip the demo pretends to pay. Long enough that a reply lands after +/// the caller has registered its pending entry and short enough to feel local. +const Duration _kReplyDelay = Duration(milliseconds: 40); + +/// An [AgentTransport] backed entirely by canned frames. +/// +/// Deliberately a transport rather than a parallel set of fake services: the +/// demo then renders through the real [MessageRouter], the real per-checkout +/// service bundle and the real widgets, so what a reviewer sees is the product +/// and not a mock of it. It opens no socket, reads no keychain and resolves no +/// host — [connect] is pure local work. +class DemoTransport extends BufferedAgentTransport { + DemoTransport({DateTime? now}) : _now = now ?? DateTime.now(); + + /// Anchor for every relative time in the fixtures, so a session row and the + /// transcript it opens agree on when the conversation happened. + final DateTime _now; + + final List<_PendingBeat> _queue = <_PendingBeat>[]; + Timer? _timer; + bool _disposed = false; + int _frameSeq = 0; + int _turnSeq = 0; + + /// Insertion order, and the tie-break [_rearm] sorts on. Beats enqueued + /// together share one `due`, and Dart's sort is stable only below its + /// insertion-sort threshold — past it a quicksort is free to dispatch + /// `command:done` ahead of the `command:output` it terminates. + int _beatSeq = 0; + + /// Session-scoped model/effort/mode picks. The composer is deliberately + /// non-optimistic: it renders only what a capabilities frame echoes back, so + /// a pick nothing echoes snaps the pill straight back and reads as dead. + final Map> _configPicks = + >{}; + + /// Built once: the fixtures are pure functions of [_now], and the transcript + /// RPC runs per session on hydrate and again on every redrive — so a + /// per-call build would render both transcripts in full to index one of them. + late final Map>> _transcripts = + demoTranscripts(_now); + late final List> _entries = demoSessionEntries(_now); + + /// Split once, for the same reason as [_transcripts]: the file explorer sends + /// a `file:search` per keystroke with no debounce, and the bodies are `const`. + late final Map> _fileLines = { + for (final e in kDemoFileContents.entries) e.key: e.value.split('\n'), + }; + + @override + bool get isLocal => true; + + @override + Future connect() async { + // Idempotent: `connect` is a public contract method, and a second call + // would append the whole opening snapshot to `snapshotCache` again while + // `setState` de-dupes and hides the doubling — every later subscriber then + // replays each durable frame twice. + if (_disposed || isEstablished) return; + for (final frame in _openingFrames()) { + snapshotCache.add(InboundMessage('control', _stamp(frame))); + } + setState(TransportState.connected); + // Born established, exactly like a local session: no handshake, so this is + // the only replay any hydrator registered later will need. + redriveHydrators(); + _enqueue(kDemoScript); + } + + @override + Future send( + Map message, { + String channel = 'control', + }) async { + if (_disposed) return; + switch (message['type']) { + case 'request': + _answerRpc(message); + return; + case 'agent:prompt': + _playPromptReply( + message['sessionId'] as String? ?? kDemoSessionCheckoutId, + message['text'] as String? ?? '', + ); + return; + case 'agent:cancel': + _cancelTurn( + message['sessionId'] as String?, + message['turnId'] as String?, + ); + return; + } + _enqueueAll(_kReplyDelay, _repliesFor(message)); + } + + /// Dispatches every queued beat immediately. Tests use it instead of pumping + /// the script's real delays; nothing in the app calls it. + @visibleForTesting + void drainScript() { + _timer?.cancel(); + _timer = null; + final due = List<_PendingBeat>.of(_queue); + _queue.clear(); + for (final beat in due) { + dispatchDecoded(_stamp(beat.frame), beat.channel); + } + } + + @override + Future dispose() async { + if (_disposed) return; + _disposed = true; + _timer?.cancel(); + _timer = null; + _queue.clear(); + failAllPending(); + clearHydrators(); + snapshotCache.clear(); + await outbound.close(); + await stateController.close(); + await droppedFrameController.close(); + } + + // ── canned state ── + + /// What the bridge would have replayed in its connect-time snapshot, plus the + /// per-session capability frames the composer needs before its first paint. + List> _openingFrames() => >[ + ...kDemoDurableFrames, + kDemoTerminalStarted, + kDemoTerminalSnapshot, + kDemoGitBranches, + // Read through [_configPicks] rather than [demoCapabilities] directly, so + // an answer built after the user has picked a model or mode carries the + // pick. `snapshotCache` is written once at connect, so today only the + // `state.snapshot` answer below can be that late one. + _capabilitiesFor(kDemoSessionCheckoutId), + _capabilitiesFor(kDemoSessionCartId), + ]; + + /// [demoCapabilities] with whatever this session has since been set to. + Map _capabilitiesFor(String sessionId) { + final picks = _configPicks[sessionId]; + final frame = demoCapabilities(sessionId); + if (picks == null || picks.isEmpty) return frame; + const fields = { + 'model': 'currentModelId', + 'effort': 'currentEffortId', + 'mode': 'currentModeId', + }; + final out = Map.of(frame); + picks.forEach((key, value) { + final field = fields[key]; + if (field != null) out[field] = value; + }); + return out; + } + + /// Adds the envelope fields every consumer expects. Spread last so a fixture + /// that carries its own `timestamp` — a replayed transcript turn — keeps it. + Map _stamp(Map frame) => { + 'id': 'demo-${_frameSeq++}', + 'timestamp': DateTime.now().millisecondsSinceEpoch, + ...frame, + }; + + // ── RPC ── + + void _answerRpc(Map message) { + final requestId = message['requestId'] as String?; + if (requestId == null) return; + final params = + (message['params'] as Map?)?.cast() ?? + const {}; + + Map result; + switch (message['method'] as String?) { + case 'state.snapshot': + // Stamped, like the connect-time replay of the very same maps: the two + // paths serve identical frames, and one of them handing back no `id` + // and no `timestamp` is a divergence no test would catch. + result = { + 'frames': _openingFrames().map(_stamp).toList(), + }; + case 'session.transcriptSnapshot': + final sessionId = params['sessionId'] as String?; + result = { + 'frames': _transcripts[sessionId] ?? const [], + }; + case 'sessions.list': + result = {'sessions': _entries}; + default: + _enqueueAll(_kReplyDelay, >[ + { + 'type': 'response', + 'requestId': requestId, + 'ok': false, + 'error': { + 'code': kDemoRefusalCode, + 'message': kDemoRefusalText, + }, + }, + ]); + return; + } + _enqueueAll(_kReplyDelay, >[ + { + 'type': 'response', + 'requestId': requestId, + 'ok': true, + 'result': result, + }, + ]); + } + + // ── message replies ── + + /// The frames a real bridge would answer [message] with. + /// + /// Every verb the app can send is either answered or deliberately silent + /// (fire-and-forget ones the bridge never replies to). A verb that falls + /// through to nothing while its caller waits is the one failure mode a demo + /// cannot recover from on its own, so the mutating session verbs answer with + /// a refusal rather than silence. + List> _repliesFor(Map message) { + final type = message['type'] as String?; + final requestId = message['requestId'] as String?; + switch (type) { + case 'session:list': + return >[ + demoSessionsListResult(requestId: requestId ?? '', entries: _entries), + ]; + + // Already running in the fixtures, so this is a no-op that still has to + // answer: the bootstrap awaits the reply before it focuses a session. + case 'session:start': + return >[ + _sessionResult( + requestId, + ok: true, + entry: _entryFor(message['sessionId'] as String?), + ), + ]; + + // Stop refuses rather than joining the no-op above. An `ok` carrying the + // fixture's unchanged `running: true`, with no `session:updated` behind + // it, leaves the row claiming success while nothing moves — the silent + // dead end this switch exists to avoid. + case 'session:stop': + case 'session:create': + case 'session:delete': + case 'session:rename': + case 'session:archive': + case 'session:unarchive': + case 'session:set-mode': + return >[_sessionResult(requestId, ok: false)]; + + case 'file:read': + return >[_fileContent(message['path'] as String?)]; + + case 'file:tree:snapshot:request': + return >[ + { + 'type': 'file:tree:snapshot', + 'checkoutId': 'main', + 'seq': 1, + 'tree': kDemoTreeRoot, + }, + ]; + + case 'preview:snapshot:request': + return >[kDemoPreviewSnapshot, kDemoPortsUpdate]; + + case 'terminal:snapshot:request': + return >[ + _terminalSnapshot(message['terminalId'] as String?), + ]; + + case 'terminal:start': + return >[ + _terminalStarted(message['terminalId'] as String?), + ]; + + case 'terminal:input': + return _terminalEcho( + message['terminalId'] as String?, + message['data'] as String?, + ); + + // The composer's pills echo the bridge and nothing else, so a pick this + // transport swallows springs back to the old label. A session-scoped + // selection costs the demo nothing to honour, unlike the verbs that + // would have to touch a machine. + case 'agent:set-config': + final sessionId = message['sessionId'] as String?; + final key = message['key'] as String?; + final value = message['value'] as String?; + if (sessionId == null || key == null || value == null) { + return const >[]; + } + (_configPicks[sessionId] ??= {})[key] = value; + return >[_capabilitiesFor(sessionId)]; + + case 'config:read': + return >[ + { + 'type': 'config:read-result', + 'checkoutId': 'main', + 'ok': true, + // ConfigService reads a missing `config` on an `ok` reply as a + // valid EMPTY one, which showed Project Settings an unconfigured + // project beside a workspace the rest of the demo presents as + // fully set up — and then refused to let the reviewer fix it. + 'config': kDemoConfig, + }, + ]; + + case 'config:write': + return >[ + { + 'type': 'config:write-result', + 'checkoutId': 'main', + 'ok': false, + 'errors': [kDemoRefusalText], + }, + ]; + + case 'config:detect-tools': + return >[ + { + 'type': 'config:detect-tools-result', + 'checkoutId': 'main', + 'tools': >[], + }, + ]; + + case 'git:diff': + return >[_gitDiff(message['path'] as String?)]; + + case 'git:list-branches': + return >[kDemoGitBranches]; + + case 'git:checkout': + return >[ + { + 'type': 'git:checkout-result', + 'projectId': kDemoProjectId, + 'checkoutId': 'main', + 'branch': message['branch'] as String? ?? kDemoBranch, + 'success': false, + 'error': kDemoRefusalText, + }, + ]; + + case 'git:commit': + return >[_gitFailure('git:commit-result')]; + + case 'git:discard': + return >[ + _gitFailure('git:discard-result', files: message['files']), + ]; + + case 'git:stage': + return >[ + _gitFailure('git:stage-result', files: message['files']), + ]; + + case 'git:unstage': + return >[ + _gitFailure('git:unstage-result', files: message['files']), + ]; + + case 'file:search': + return _search( + query: message['query'] as String? ?? '', + requestId: requestId ?? '', + caseSensitive: message['caseSensitive'] == true, + regex: message['regex'] == true, + wholeWord: message['wholeWord'] == true, + ); + + case 'command:run': + final name = message['commandName'] as String? ?? 'command'; + return >[ + { + 'type': 'command:output', + 'projectId': kDemoProjectId, + 'checkoutId': 'main', + 'commandName': name, + 'data': '$kDemoRefusalText\n', + }, + { + 'type': 'command:done', + 'projectId': kDemoProjectId, + 'checkoutId': 'main', + 'commandName': name, + 'exitCode': 1, + }, + ]; + + case 'file:upload-start': + return >[ + { + 'type': 'file:upload-result', + 'checkoutId': 'main', + 'requestId': requestId ?? '', + 'ok': false, + 'error': kDemoRefusalCode, + 'message': kDemoRefusalText, + }, + ]; + + default: + // Fire-and-forget verbs a bridge answers with nothing: focus + // declarations, resize, stop, cancel-adjacent chatter. + // + // `agent:session-action` (the "Revert conversation" button, which + // renders on every user message in the canned transcripts) lands here + // too, and is the one arrival that is NOT fire-and-forget: a real + // bridge answers it with `agent:snapshot`/`agent:transcript-replay`. + // Nothing the demo can emit reports the refusal — the app consumes no + // `agent:error` — so silence is the least dishonest answer, and this + // note is here so the catch-all is not mistaken for coverage. + return const >[]; + } + } + + Map _sessionResult( + String? requestId, { + required bool ok, + Map? entry, + }) => { + 'type': 'session:result', + 'checkoutId': 'main', + 'requestId': requestId ?? '', + 'ok': ok, + 'session': ?entry, + if (!ok) ...{ + 'error': kDemoRefusalText, + 'errorCode': kDemoRefusalCode, + }, + }; + + Map? _entryFor(String? sessionId) { + for (final entry in _entries) { + if (entry['id'] == sessionId) return entry; + } + return null; + } + + Map _fileContent(String? path) { + // The refusal rides in as CONTENT, never in the envelope's `error` field: + // `classifyAbMessage` coerces any error-bearing frame to the STATUS tier, + // and FileService only handles `file:content` on the HEAVY one — so an + // honest error here would leave the viewer spinning until the user gave up + // rather than saying why the file is empty. + final body = + (path == null ? null : kDemoFileContents[path]) ?? + '$kDemoRefusalText\n\nThis file is not part of the sample project.\n'; + return { + 'type': 'file:content', + 'projectId': kDemoProjectId, + 'checkoutId': 'main', + 'path': path ?? '', + 'content': body, + 'size': body.length, + 'encoding': 'utf8', + }; + } + + Map _gitDiff(String? path) { + final diff = path == null ? null : kDemoGitDiffContent[path]; + // Counts read back out of the `git:status` fixture rather than restated + // here. The Git panel draws them twice, one directly above the other — on + // the file row and in the diff header — and `GitDiffContentMessage` + // defaults a missing count to 0, which renders as no stat at all beside a + // row that just claimed +24 -3. + final stat = path == null ? null : _gitStatusEntry(path); + return { + 'type': 'git:diff-content', + 'projectId': kDemoProjectId, + 'checkoutId': 'main', + 'path': path ?? '', + 'diff': ?diff, + 'additions': ?stat?['additions'], + 'deletions': ?stat?['deletions'], + if (diff == null) 'error': 'Not part of the sample project', + }; + } + + static Map? _gitStatusEntry(String path) { + for (final frame in kDemoDurableFrames) { + if (frame['type'] != 'git:status') continue; + for (final f in (frame['files'] as List).cast>()) { + if (f['path'] == path) return f; + } + } + return null; + } + + Map _gitFailure(String type, {Object? files}) => + { + 'type': type, + 'projectId': kDemoProjectId, + 'checkoutId': 'main', + 'success': false, + if (files is List) 'files': files.whereType().toList(), + 'error': kDemoRefusalText, + }; + + /// Confirms the terminal the caller asked to start, never the sample one: + /// `TerminalService` settles its pending tab by id, so a constant reply left + /// a user-created tab to expire into `exited` 15s later AND re-snapshotted + /// the sample terminal, erasing everything the script had played into it. + Map _terminalStarted(String? terminalId) { + if (terminalId == null || terminalId == kDemoTerminalId) { + return kDemoTerminalStarted; + } + return { + ...kDemoTerminalStarted, + 'terminalId': terminalId, + // Not the sample terminal's 'agent' — this one is the user's own shell, + // unless it is the `dev` service being restarted from the Services tab. + // Answering null there would retype the tab and drop it into the ad-hoc + // Terminals list, which filters on exactly this field. + 'terminalType': terminalId == kDemoServiceTerminalId ? 'service' : null, + }; + } + + /// Scrollback for [terminalId]. A tab the demo has no history for gets a + /// bare prompt rather than the sample terminal's, which `_applySnapshot` + /// erases the target buffer to write. + Map _terminalSnapshot(String? terminalId) { + if (terminalId == null || terminalId == kDemoTerminalId) { + return kDemoTerminalSnapshot; + } + if (terminalId == kDemoServiceTerminalId) return kDemoServiceSnapshot; + return { + ...kDemoTerminalSnapshot, + 'terminalId': terminalId, + 'scrollback': kDemoShellPrompt, + }; + } + + /// Echoes typed bytes so the terminal feels attached, then says plainly that + /// nothing ran when the user presses Enter. + List> _terminalEcho(String? terminalId, String? data) { + if (data == null || data.isEmpty) return const >[]; + final isEnter = data.contains('\r') || data.contains('\n'); + return >[ + { + 'type': 'terminal:output', + 'checkoutId': 'main', + // The tab the bytes came from: a constant here typed the user's + // keystrokes into a terminal they were not looking at. + 'terminalId': terminalId ?? kDemoTerminalId, + 'data': isEnter ? '\r\n$kDemoRefusalText\r\n$kDemoShellPrompt' : data, + }, + ]; + } + + /// A real search over the sample file bodies — cheaper than canning + /// per-query results, and it keeps the result count honest for whatever the + /// user types. + /// + /// Honours every flag the panel can set. The regex and whole-word toggles + /// render unconditionally, so ignoring them answered a working pattern with + /// a confident zero; and one `indexOf` per line under-counted every line + /// that matched twice, in the totals as well as the list. + List> _search({ + required String query, + required String requestId, + required bool caseSensitive, + required bool regex, + required bool wholeWord, + }) { + final matches = >[]; + final files = {}; + RegExp? pattern; + if (query.isNotEmpty) { + final escaped = regex ? query : RegExp.escape(query); + try { + pattern = RegExp( + wholeWord ? '\\b(?:$escaped)\\b' : escaped, + caseSensitive: caseSensitive, + ); + } on FormatException { + // Settled as an empty result rather than reported through the done + // frame's `error` field, for the reason [_fileContent] documents: + // `classifyAbMessage` coerces any error-bearing frame to the STATUS + // tier, and SearchService subscribes to the HEAVY one alone — an + // honest error here never arrives, so the panel spins until the idle + // guard gives up on it 12s later and blames the agent. + } + } + if (pattern != null) { + for (final entry in _fileLines.entries) { + final lines = entry.value; + for (var i = 0; i < lines.length; i++) { + for (final m in pattern.allMatches(lines[i])) { + // A pattern that can match nothing ('a*') would otherwise report a + // hit at every column of every line. + if (m.end == m.start) continue; + files.add(entry.key); + matches.add({ + 'path': entry.key, + 'line': i + 1, + 'column': m.start + 1, + 'lineContent': lines[i], + 'contextBefore': [], + 'contextAfter': [], + }); + } + } + } + } + return >[ + { + 'type': 'file:search-result', + 'projectId': kDemoProjectId, + 'checkoutId': 'main', + 'requestId': requestId, + 'matches': matches, + }, + { + 'type': 'file:search-done', + 'projectId': kDemoProjectId, + 'checkoutId': 'main', + 'requestId': requestId, + 'totalMatches': matches.length, + 'totalFiles': files.length, + 'duration': 4, + 'engine': 'demo', + }, + ]; + } + + // ── playback ── + + void _enqueue(List beats) { + if (_disposed) return; + final now = DateTime.now(); + for (final beat in beats) { + _queue.add( + _PendingBeat(now.add(beat.at), _beatSeq++, beat.channel, beat.frame), + ); + } + _rearm(); + } + + void _enqueueAll(Duration at, List> frames) { + if (_disposed || frames.isEmpty) return; + final due = DateTime.now().add(at); + for (final frame in frames) { + _queue.add(_PendingBeat(due, _beatSeq++, 'control', frame)); + } + _rearm(); + } + + /// Chained single-shot timers, never a periodic one: playback is finite, and + /// a periodic timer would keep firing after the last beat for as long as the + /// demo stays open. + void _rearm() { + _timer?.cancel(); + _timer = null; + if (_queue.isEmpty) return; + _queue.sort((a, b) { + final byDue = a.due.compareTo(b.due); + // Enqueue order breaks the tie. `_enqueueAll` stamps every frame of one + // reply with a single `due`, and those pairs (`file:search-result` then + // `file:search-done`, `command:output` then `command:done`) carry their + // meaning in the order alone. + return byDue != 0 ? byDue : a.seq.compareTo(b.seq); + }); + final wait = _queue.first.due.difference(DateTime.now()); + _timer = Timer(wait.isNegative ? Duration.zero : wait, _fire); + } + + void _fire() { + _timer = null; + if (_disposed) return; + final now = DateTime.now(); + while (_queue.isNotEmpty && !_queue.first.due.isAfter(now)) { + final beat = _queue.removeAt(0); + dispatchDecoded(_stamp(beat.frame), beat.channel); + } + _rearm(); + } + + /// Plays a canned reply into [sessionId]. Built here rather than in the + /// script file because the beats have to carry the text the user just typed. + void _playPromptReply(String sessionId, String text) { + _enqueue( + demoPromptReplyBeats( + sessionId: sessionId, + turnId: 'demo-live-turn-${_turnSeq++}', + promptText: text, + ), + ); + } + + /// Stops a reply mid-stream. Dropping the queued beats is the load-bearing + /// half: closing the turn while its deltas are still scheduled would keep + /// writing into a transcript the user has already stopped. + void _cancelTurn(String? sessionId, String? turnId) { + if (sessionId == null || turnId == null) return; + _queue.removeWhere((beat) => beat.frame['turnId'] == turnId); + _rearm(); + _enqueueAll(_kReplyDelay, >[ + { + 'type': 'agent:turn-end', + 'sessionId': sessionId, + 'turnId': turnId, + 'stopReason': 'cancelled', + }, + ]); + } +} + +class _PendingBeat { + _PendingBeat(this.due, this.seq, this.channel, this.frame); + final DateTime due; + final int seq; + final String channel; + final Map frame; +} diff --git a/app/lib/demo/fixtures/demo_transcript_fixtures.dart b/app/lib/demo/fixtures/demo_transcript_fixtures.dart new file mode 100644 index 00000000..da285736 --- /dev/null +++ b/app/lib/demo/fixtures/demo_transcript_fixtures.dart @@ -0,0 +1,323 @@ +/// Canned session list and chat transcripts for the offline demo. +/// +/// Times are built relative to a caller-supplied `now` rather than baked in: a +/// hard-coded epoch would render every demo turn as years old, and the session +/// rows sort and label themselves off `lastUsedAt`. +library; + +import '../demo_identity.dart'; +import 'demo_workspace_fixtures.dart'; + +const String kDemoSessionCheckoutName = 'Add checkout validation'; +const String kDemoSessionCartName = 'Fix flaky cart test'; + +const String _kTurnOne = 'demo-turn-1'; +const String _kCartTurnOne = 'demo-cart-turn-1'; + +/// Both rows are `running: true` so the workspace bootstrap has nothing to +/// start — a demo that had to answer `session:start` before it could show a +/// transcript would spend its first seconds on a spinner. +List> demoSessionEntries(DateTime now) { + final createdCheckout = now.subtract(const Duration(hours: 3)); + final createdCart = now.subtract(const Duration(days: 1)); + return >[ + { + 'id': kDemoSessionCheckoutId, + 'name': kDemoSessionCheckoutName, + 'createdAt': createdCheckout.millisecondsSinceEpoch, + 'lastUsedAt': now + .subtract(const Duration(minutes: 4)) + .millisecondsSinceEpoch, + 'archived': false, + 'running': true, + 'tool': kDemoAgentTool, + 'mode': 'chat', + 'agentSessionResumable': true, + 'agentSessionId': kDemoSessionCheckoutId, + 'checkoutId': 'main', + 'checkoutKind': 'main', + 'checkoutState': 'ready', + }, + { + 'id': kDemoSessionCartId, + 'name': kDemoSessionCartName, + 'createdAt': createdCart.millisecondsSinceEpoch, + 'lastUsedAt': now + .subtract(const Duration(hours: 20)) + .millisecondsSinceEpoch, + 'archived': false, + 'running': true, + 'tool': kDemoAgentTool, + 'mode': 'chat', + 'agentSessionResumable': true, + 'agentSessionId': kDemoSessionCartId, + 'checkoutId': 'main', + 'checkoutKind': 'main', + 'checkoutState': 'ready', + }, + ]; +} + +/// Takes the entries rather than a clock: [DemoTransport] memoizes one list for +/// the transport's whole life, and `session:list` is a tier-3 hydrator that runs +/// on every establishment and every refresh. +Map demoSessionsListResult({ + required String requestId, + required List> entries, +}) => { + 'type': 'session:list:result', + 'projectId': kDemoProjectId, + 'requestId': requestId, + 'sessions': entries, +}; + +/// Capabilities the chat composer reads: model/mode pickers and slash commands +/// all render from this one frame. +Map demoCapabilities(String sessionId) => { + 'type': 'agent:capabilities', + 'sessionId': sessionId, + 'ready': true, + 'commands': >[ + { + 'id': 'review', + 'name': 'review', + 'description': 'Review the working tree', + }, + { + 'id': 'test', + 'name': 'test', + 'description': 'Run the test suite', + 'argHint': '[path]', + }, + ], + 'modes': >[ + { + 'id': 'default', + 'name': 'Default', + 'description': 'Ask before editing', + }, + { + 'id': 'auto', + 'name': 'Auto', + 'description': 'Edit without asking', + }, + ], + 'models': >[ + { + 'id': 'demo-model', + 'name': 'Sample model', + 'provider': 'demo', + 'efforts': ['low', 'high'], + 'defaultEffort': 'high', + }, + ], + 'currentModelId': 'demo-model', + 'currentModeId': 'default', + 'currentEffortId': 'high', +}; + +/// The settled transcript served for [kDemoSessionCheckoutId]. +/// +/// Each frame carries its own `timestamp` because the reducer dates items from +/// the envelope, not from arrival — that is what makes a replayed turn read as +/// history instead of as something that just happened. +List> demoCheckoutTranscript(DateTime now) { + final t0 = now.subtract(const Duration(minutes: 6)); + int at(int seconds) => + t0.add(Duration(seconds: seconds)).millisecondsSinceEpoch; + + Map frame( + String type, + int seconds, + Map body, + ) => { + 'type': type, + 'timestamp': at(seconds), + 'sessionId': kDemoSessionCheckoutId, + 'turnId': _kTurnOne, + ...body, + }; + + Map item(int seconds, Map body) => + frame('agent:item-added', seconds, {'item': body}); + + return >[ + frame('agent:turn-start', 0, const {}), + item(0, const { + 'itemId': 'demo-item-user', + 'kind': 'message', + 'role': 'user', + 'text': + 'The checkout endpoint accepts empty carts and malformed emails. ' + 'Add validation and a test.', + }), + item(2, const { + 'itemId': 'demo-item-reasoning', + 'kind': 'reasoning', + 'text': + 'validateCheckout is a stub that returns immediately, so nothing ' + 'guards the cart or the email. The cart total helper counts lines ' + 'instead of summing them, which would make a zero-price order look ' + 'valid.', + }), + item(4, const { + 'itemId': 'demo-item-plan', + 'kind': 'plan', + 'title': 'Plan', + 'entries': >[ + { + 'text': 'Read src/checkout.ts', + 'status': 'completed', + }, + { + 'text': 'Add cart, email and total guards', + 'status': 'completed', + }, + {'text': 'Cover both refusals', 'status': 'completed'}, + ], + }), + item(6, const { + 'itemId': 'demo-item-read', + 'kind': 'tool_call', + 'toolKind': 'read', + 'title': 'Read src/checkout.ts', + 'status': 'completed', + 'content': >[ + { + 'type': 'text', + 'text': + 'export function validateCheckout(input: CheckoutInput): ' + 'void {\n return;\n}', + }, + ], + }), + item(11, { + 'itemId': 'demo-item-edit', + 'kind': 'tool_call', + 'toolKind': 'edit', + 'title': 'Edit src/checkout.ts', + 'status': 'completed', + 'content': >[ + { + 'type': 'diff', + 'path': 'src/checkout.ts', + 'oldText': + 'export function validateCheckout(input: CheckoutInput): void {\n' + ' return;\n' + '}\n', + 'newText': + 'export function validateCheckout(input: CheckoutInput): void {\n' + " if (input.cart.lines.length === 0) {\n" + " throw new CheckoutError('cart', 'Cart is empty');\n" + ' }\n' + " if (!input.email.includes('@')) {\n" + " throw new CheckoutError('email', 'Enter a valid email address');\n" + ' }\n' + ' if (cartTotal(input.cart) <= 0) {\n' + " throw new CheckoutError('cart', 'Order total must be positive');\n" + ' }\n' + '}\n', + }, + ], + }), + item(19, const { + 'itemId': 'demo-item-test', + 'kind': 'tool_call', + 'toolKind': 'terminal', + 'title': 'bun test', + 'status': 'completed', + 'content': >[ + { + 'type': 'terminal', + 'data': + 'bun test v1.3.14\n\ntests/checkout.test.ts:\n' + ' (pass) rejects an empty cart\n' + ' (pass) rejects a malformed email\n\n' + ' 3 pass\n 0 fail\n', + }, + ], + }), + item(24, const { + 'itemId': 'demo-item-answer', + 'kind': 'message', + 'role': 'assistant', + 'text': + 'Checkout now refuses an empty cart, a malformed email and a ' + 'non-positive total, each as a typed `CheckoutError` naming the ' + 'offending field. `cartTotal` sums line totals instead of counting ' + 'lines, so a zero-price order no longer passes. Added ' + '`tests/checkout.test.ts` covering both refusals — 3 pass, 0 fail.', + }), + frame('agent:usage', 25, const { + 'itemId': 'demo-item-answer', + 'total': { + 'totalTokens': 19680, + 'inputTokens': 18420, + 'outputTokens': 1260, + }, + 'last': { + 'totalTokens': 9880, + 'inputTokens': 9240, + 'outputTokens': 640, + }, + 'contextWindow': 200000, + }), + frame('agent:turn-end', 25, const { + 'stopReason': 'end_turn', + }), + ]; +} + +/// The second session opens on a single settled turn — enough to show that the +/// switcher lands somewhere real, without a second full conversation to read. +List> demoCartTranscript(DateTime now) { + final t0 = now.subtract(const Duration(hours: 20)); + int at(int seconds) => + t0.add(Duration(seconds: seconds)).millisecondsSinceEpoch; + + Map frame( + String type, + int seconds, + Map body, + ) => { + 'type': type, + 'timestamp': at(seconds), + 'sessionId': kDemoSessionCartId, + 'turnId': _kCartTurnOne, + ...body, + }; + + return >[ + frame('agent:turn-start', 0, const {}), + frame('agent:item-added', 0, const { + 'item': { + 'itemId': 'demo-cart-item-user', + 'kind': 'message', + 'role': 'user', + 'text': 'tests/cart.test.ts fails about one run in five. Why?', + }, + }), + frame('agent:item-added', 8, const { + 'item': { + 'itemId': 'demo-cart-item-answer', + 'kind': 'message', + 'role': 'assistant', + 'text': + 'The suite seeds the cart from a module-level object that an ' + 'earlier test mutates, so the totals depend on file order. Give ' + 'each test its own cart and the flake goes away.', + }, + }), + frame('agent:turn-end', 8, const { + 'stopReason': 'end_turn', + }), + ]; +} + +/// Transcript frames keyed by session id, for the `session.transcriptSnapshot` +/// RPC. +Map>> demoTranscripts(DateTime now) => + >>{ + kDemoSessionCheckoutId: demoCheckoutTranscript(now), + kDemoSessionCartId: demoCartTranscript(now), + }; diff --git a/app/lib/demo/fixtures/demo_workspace_fixtures.dart b/app/lib/demo/fixtures/demo_workspace_fixtures.dart new file mode 100644 index 00000000..8e995823 --- /dev/null +++ b/app/lib/demo/fixtures/demo_workspace_fixtures.dart @@ -0,0 +1,518 @@ +/// Canned workspace frames for the offline demo. +/// +/// These are raw wire envelopes, not view models: every one is dispatched +/// through the real [MessageRouter] and parsed by `parseAbMessage`, so the demo +/// exercises the same reducers a live bridge drives. Keep each map inside the +/// shape its `parseAbMessage` case requires — a frame that fails to parse is +/// dropped silently and the surface it feeds just stays empty. +/// +/// `id`/`timestamp` are deliberately absent: `DemoTransport` stamps them at +/// dispatch so relative times read as "now" rather than 1970. +library; + +import '../demo_identity.dart'; + +const String kDemoBranch = 'feature/checkout-validation'; + +/// Shared by the wire frame below and the New Session picker's branch catalog, +/// which cannot go to a bridge for the sample project's branches. +const List kDemoBranches = [ + kDemoBranch, + 'main', + 'fix/cart-totals', +]; +const String kDemoTerminalId = 'demo-terminal'; + +/// The `dev` service's log terminal. `ServicesListView` opens a service's logs +/// by looking its `id` up in the terminal tabs, so the service and the terminal +/// have to be the same id or "View logs" resolves to nothing and bounces back. +/// Typed `service`, which is what keeps it out of the ad-hoc Terminals list +/// (`terminal_list_view.dart` filters on exactly that). +const String kDemoServiceTerminalId = 'dev'; + +/// Session the demo opens on — its transcript is complete. +const String kDemoSessionCheckoutId = 'demo-session-checkout'; + +/// Second row in the list, so the session switcher has somewhere to go. +const String kDemoSessionCartId = 'demo-session-cart'; + +const String kDemoAgentTool = 'claude'; + +const int kDemoPreviewPort = 5173; +const String kDemoPreviewUrlString = 'http://localhost:5173'; + +/// Latest-wins frames the bridge would have replayed in its connect-time +/// snapshot. Order matters only for readability — every one is idempotent. +const List> kDemoDurableFrames = >[ + { + 'type': 'agent:hello', + 'tool': kDemoAgentTool, + 'command': 'claude', + 'version': 'demo', + 'flags': [], + }, + { + 'type': 'agent:status', + 'projectId': kDemoProjectId, + 'checkoutId': 'main', + 'agent': {'name': kDemoDisplayName, 'version': 'demo'}, + 'git': {'branch': kDemoBranch}, + 'terminals': >[ + { + 'terminalId': kDemoTerminalId, + 'name': 'agent', + 'running': true, + 'shell': 'zsh', + 'cols': 96, + 'rows': 30, + 'type': 'agent', + }, + { + 'terminalId': kDemoServiceTerminalId, + 'name': 'dev', + 'running': true, + 'shell': 'bun', + 'cols': 96, + 'rows': 30, + 'type': 'service', + }, + ], + // Kept in step with [kDemoConfig]'s `services`, the same way `commands` + // below is: the sample antgrid.yaml is served verbatim to Project Settings, + // so an empty list here reads as "No services declared" one tab away from a + // settings page listing `dev`, a terminal running `bun run dev`, a detected + // vite port and a preview pointing at it. + 'services': >[ + { + 'id': kDemoServiceTerminalId, + 'name': 'dev', + 'running': true, + 'command': 'bun run dev', + }, + ], + // What the command tray renders from. Kept in step with [kDemoConfig]'s + // `commands`, which is the same list as the sample antgrid.yaml declares. + 'commands': >[ + {'name': 'test', 'confirm': false}, + {'name': 'lint', 'confirm': false}, + ], + }, + { + 'type': 'git:status', + 'projectId': kDemoProjectId, + 'checkoutId': 'main', + 'files': >[ + { + 'path': 'src/checkout.ts', + 'status': 'M', + 'staged': false, + 'additions': 24, + 'deletions': 3, + }, + { + 'path': 'src/cart.ts', + 'status': 'M', + 'staged': true, + 'additions': 6, + 'deletions': 1, + }, + { + 'path': 'tests/checkout.test.ts', + // 'U', not 'A': the bridge files untracked files as 'U' and reserves + // 'A' for the STAGED set (`bridge/src/git.ts`), and the Git panel's + // discard confirmation reads exactly that pairing to decide between + // "Discard all changes to…" and "Permanently delete the new file…". + 'status': 'U', + 'staged': false, + 'additions': 41, + 'deletions': 0, + }, + ], + }, + { + 'type': 'tree:full', + 'projectId': kDemoProjectId, + 'checkoutId': 'main', + 'seq': 1, + 'root': kDemoTreeRoot, + }, +]; + +/// The `antgrid.yaml` Project Settings shows for the sample project. +/// +/// Answering `config:read` with no payload is NOT equivalent: `ConfigService` +/// reads a missing `config` on an `ok` reply as a valid EMPTY one, which showed +/// an unconfigured project beside a workspace the rest of the demo presents as +/// fully set up. +const Map kDemoConfig = { + 'name': kDemoDisplayName, + 'agent': {'tool': kDemoAgentTool, 'command': 'claude'}, + 'services': >[ + { + 'name': 'dev', + 'command': 'bun', + 'args': ['run', 'dev'], + 'autoStart': true, + }, + ], + 'commands': >[ + { + 'name': 'test', + 'command': 'bun', + 'args': ['test'], + 'description': 'Run the test suite', + }, + { + 'name': 'lint', + 'command': 'bun', + 'args': ['run', 'lint'], + 'description': 'Lint the working tree', + }, + ], + 'ports': >[ + { + 'port': kDemoPreviewPort, + 'name': 'Preview', + 'onDetect': 'openPreview', + }, + ], +}; + +const Map kDemoTreeRoot = { + 'name': 'demo-shop', + 'path': '', + 'type': 'directory', + 'children': >[ + { + 'name': 'src', + 'path': 'src', + 'type': 'directory', + 'children': >[ + { + 'name': 'cart.ts', + 'path': 'src/cart.ts', + 'type': 'file', + 'size': 812, + 'extension': 'ts', + }, + { + 'name': 'checkout.ts', + 'path': 'src/checkout.ts', + 'type': 'file', + 'size': 1436, + 'extension': 'ts', + }, + { + 'name': 'index.ts', + 'path': 'src/index.ts', + 'type': 'file', + 'size': 344, + 'extension': 'ts', + }, + ], + }, + { + 'name': 'tests', + 'path': 'tests', + 'type': 'directory', + 'children': >[ + { + 'name': 'cart.test.ts', + 'path': 'tests/cart.test.ts', + 'type': 'file', + 'size': 640, + 'extension': 'ts', + }, + { + 'name': 'checkout.test.ts', + 'path': 'tests/checkout.test.ts', + 'type': 'file', + 'size': 1102, + 'extension': 'ts', + }, + ], + }, + { + 'name': 'README.md', + 'path': 'README.md', + 'type': 'file', + 'size': 287, + 'extension': 'md', + }, + { + 'name': 'package.json', + 'path': 'package.json', + 'type': 'file', + 'size': 412, + 'extension': 'json', + }, + ], +}; + +/// Bodies served for `file:read`. A path outside this map answers with an +/// `error`, which is what the bridge does for an unreadable file — the viewer +/// already renders that state. +const Map kDemoFileContents = { + 'src/checkout.ts': ''' +import { Cart, cartTotal } from './cart'; + +export type CheckoutInput = { + cart: Cart; + email: string; + couponCode?: string; +}; + +export class CheckoutError extends Error { + constructor(readonly field: string, message: string) { + super(message); + } +} + +export function validateCheckout(input: CheckoutInput): void { + if (input.cart.lines.length === 0) { + throw new CheckoutError('cart', 'Cart is empty'); + } + if (!input.email.includes('@')) { + throw new CheckoutError('email', 'Enter a valid email address'); + } + if (cartTotal(input.cart) <= 0) { + throw new CheckoutError('cart', 'Order total must be positive'); + } +} +''', + 'src/cart.ts': ''' +export type CartLine = { + sku: string; + quantity: number; + unitPriceCents: number; +}; + +export type Cart = { + lines: CartLine[]; +}; + +export function cartTotal(cart: Cart): number { + return cart.lines.reduce( + (sum, line) => sum + line.quantity * line.unitPriceCents, + 0, + ); +} +''', + 'src/index.ts': ''' +import { validateCheckout } from './checkout'; + +export { validateCheckout }; +export { cartTotal } from './cart'; +''', + 'tests/checkout.test.ts': ''' +import { expect, test } from 'bun:test'; +import { validateCheckout } from '../src/checkout'; + +const cart = { lines: [{ sku: 'mug', quantity: 1, unitPriceCents: 1200 }] }; + +test('rejects an empty cart', () => { + expect(() => + validateCheckout({ cart: { lines: [] }, email: 'a@example.com' }), + ).toThrow('Cart is empty'); +}); + +test('rejects a malformed email', () => { + expect(() => validateCheckout({ cart, email: 'nope' })).toThrow( + 'Enter a valid email address', + ); +}); +''', + 'tests/cart.test.ts': ''' +import { expect, test } from 'bun:test'; +import { cartTotal } from '../src/cart'; + +test('sums line totals', () => { + const cart = { + lines: [ + { sku: 'mug', quantity: 2, unitPriceCents: 1200 }, + { sku: 'tee', quantity: 1, unitPriceCents: 2400 }, + ], + }; + expect(cartTotal(cart)).toBe(4800); +}); +''', + 'README.md': ''' +# demo-shop + +Sample project bundled with Antgrid so the app has something to show before +you connect a machine. Nothing here runs — the files, git status and terminal +output are canned. +''', + 'package.json': ''' +{ + "name": "demo-shop", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "test": "bun test" + } +} +''', +}; + +/// Unified diffs served for `git:diff`, keyed by path. +const Map kDemoGitDiffContent = { + 'src/checkout.ts': ''' +@@ -1,10 +1,24 @@ +-import { Cart } from './cart'; ++import { Cart, cartTotal } from './cart'; + + export type CheckoutInput = { + cart: Cart; + email: string; ++ couponCode?: string; + }; + ++export class CheckoutError extends Error { ++ constructor(readonly field: string, message: string) { ++ super(message); ++ } ++} ++ + export function validateCheckout(input: CheckoutInput): void { +- return; ++ if (input.cart.lines.length === 0) { ++ throw new CheckoutError('cart', 'Cart is empty'); ++ } ++ if (!input.email.includes('@')) { ++ throw new CheckoutError('email', 'Enter a valid email address'); ++ } + } +''', + 'src/cart.ts': ''' +@@ -10,7 +10,12 @@ + export function cartTotal(cart: Cart): number { +- return cart.lines.length; ++ return cart.lines.reduce( ++ (sum, line) => sum + line.quantity * line.unitPriceCents, ++ 0, ++ ); + } +''', + 'tests/checkout.test.ts': ''' +@@ -0,0 +1,20 @@ ++import { expect, test } from 'bun:test'; ++import { validateCheckout } from '../src/checkout'; ++ ++test('rejects an empty cart', () => { ++ expect(() => ++ validateCheckout({ cart: { lines: [] }, email: 'a@example.com' }), ++ ).toThrow('Cart is empty'); ++}); +''', +}; + +const Map kDemoGitBranches = { + 'type': 'git:branches', + 'projectId': kDemoProjectId, + 'checkoutId': 'main', + 'current': kDemoBranch, + 'branches': kDemoBranches, +}; + +const Map kDemoTerminalStarted = { + 'type': 'terminal:started', + 'checkoutId': 'main', + 'terminalId': kDemoTerminalId, + 'shell': 'zsh', + 'cols': 96, + 'rows': 30, + 'terminalType': 'agent', +}; + +/// The sample shell's prompt. Shared with the transport's live echo, which +/// draws the prompt again after every Enter — two spellings made the terminal +/// look like it had swapped shells the moment the user typed into it. +const String kDemoShellPrompt = 'demo-shop \$ '; + +const Map kDemoTerminalSnapshot = { + 'type': 'terminal:snapshot', + 'checkoutId': 'main', + 'terminalId': kDemoTerminalId, + 'seq': 1, + // CRLF, not the bare LF a `'''` block carries: this goes straight into the + // VTE, where LF without LNM is index-only — it drops a row without returning + // the carriage, so the canned output rendered as a staircase. The live + // script's `terminal:output` frames spell it out for the same reason. + 'scrollback': + '${kDemoShellPrompt}bun test\r\n' + 'bun test v1.3.14\r\n' + '\r\n' + 'tests/cart.test.ts:\r\n' + ' (pass) sums line totals [1.20ms]\r\n' + '\r\n' + 'tests/checkout.test.ts:\r\n' + ' (pass) rejects an empty cart [0.84ms]\r\n' + ' (pass) rejects a malformed email [0.61ms]\r\n' + '\r\n' + ' 3 pass\r\n' + ' 0 fail\r\n' + 'Ran 3 tests across 2 files. [42.00ms]\r\n' + '\r\n' + '$kDemoShellPrompt', +}; + +/// Logs behind the `dev` service's "View logs". A service terminal has no +/// prompt — it is one long-running process — so this ends mid-stream rather +/// than on [kDemoShellPrompt]. CRLF for the reason [kDemoTerminalSnapshot] +/// gives. +const Map kDemoServiceSnapshot = { + 'type': 'terminal:snapshot', + 'checkoutId': 'main', + 'terminalId': kDemoServiceTerminalId, + 'seq': 1, + 'scrollback': + '\$ bun run dev\r\n' + '\r\n' + ' VITE v5.4.8 ready in 412 ms\r\n' + '\r\n' + ' ➜ Local: $kDemoPreviewUrlString/\r\n' + ' ➜ press h + enter to show help\r\n' + '\r\n' + ' 8:41:02 AM [vite] hmr update /src/cart.ts\r\n' + ' 8:41:19 AM [vite] hmr update /src/checkout.ts\r\n', +}; + +const Map kDemoPortsUpdate = { + 'type': 'ports:update', + 'projectId': kDemoProjectId, + 'checkoutId': 'main', + 'ports': >[ + { + 'port': kDemoPreviewPort, + 'pid': 4211, + 'processName': 'vite', + 'label': 'demo-shop dev', + 'scheme': 'http', + }, + ], +}; + +const Map kDemoPreviewUrl = { + 'type': 'preview:url', + 'projectId': kDemoProjectId, + 'checkoutId': 'main', + 'port': kDemoPreviewPort, + 'url': kDemoPreviewUrlString, + 'label': 'demo-shop dev', + 'scheme': 'http', +}; + +const Map kDemoPreviewSnapshot = { + 'type': 'preview:snapshot', + 'checkoutId': 'main', + 'urls': >[ + { + 'port': kDemoPreviewPort, + 'url': kDemoPreviewUrlString, + 'label': 'demo-shop dev', + 'scheme': 'http', + }, + ], +}; diff --git a/app/lib/design/ab_icons.dart b/app/lib/design/ab_icons.dart index 417d509a..b272f43c 100644 --- a/app/lib/design/ab_icons.dart +++ b/app/lib/design/ab_icons.dart @@ -100,4 +100,8 @@ abstract final class AbIcons { // off from the main line" without naming a backend — the marker stands for // every non-`main` checkout kind, not for worktrees specifically. static const isolated = Codicon.repo_forked; + // Sign-in method glyphs. `key` rather than `lock`: the cell offers a + // credential the user supplies, not a state of being secured. + static const password = Codicon.key; + static const github = Codicon.github; } diff --git a/app/lib/main.dart b/app/lib/main.dart index 56defbdf..652b75f2 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -29,6 +29,7 @@ import 'providers/analytics.dart'; import 'providers/auth.dart'; import 'providers/cached_sessions.dart'; import 'providers/collapsed_drawer.dart'; +import 'providers/demo_mode.dart'; import 'providers/device_revocation.dart'; import 'providers/drawer_order.dart'; import 'providers/first_run.dart'; @@ -44,7 +45,9 @@ import 'navigation/nav_console.dart'; import 'navigation/nav_controller.dart'; import 'navigation/nav_serialization.dart'; import 'navigation/platform_route_guard.dart'; +import 'navigation/root_navigator.dart'; import 'screens/app_shell.dart'; +import 'screens/demo_home.dart'; import 'screens/device_cap_dialog.dart'; import 'screens/sign_in_screen.dart'; import 'services/devices_api.dart' show DeviceCapInfo; @@ -60,6 +63,7 @@ import 'storage/recent_ports_store.dart'; import 'update/update_gate.dart'; import 'util/ab_log.dart'; import 'widgets/auth_splash.dart'; +import 'widgets/demo_frame.dart'; import 'window/window_chrome.dart'; /// Push is Android (FCM) and iOS (APNs) only — desktop has no transport. @@ -160,7 +164,11 @@ Future main() async { installId: installId, platform: platform, appVersion: BuildInfo.version, - enabled: () => container.read(appSettingsServiceProvider).telemetryEnabled, + enabled: () => telemetryAllowed(container), + // NOT folded into `enabled`: that predicate also decides whether a queued + // batch is DROPPED, and the demo must not throw away the real events the + // user queued before entering it. + paused: () => container.read(demoModeProvider), plausibleUrl: AppEnvironment.plausibleUrl, plausibleDomain: AppEnvironment.plausibleDomain, eventsApiUrl: AppEnvironment.eventsApiUrl, @@ -272,6 +280,13 @@ Future main() async { // Navigation deep links (antgrid://nav/...) apply a location directly. final navLoc = navLocationFromUri(uri); if (navLoc != null) { + // A link names a REAL place — a machine, a project, a session — and the + // user asked for it, so it wins over the sample project rather than being + // dropped. Leaving first is what makes it whole: the demo's target is + // cleared, its transport disposed and its nav history reset, so the link + // does not land under a banner saying nothing is connected while the + // drawer still lists only the demo. + if (container.read(demoModeProvider)) exitDemoMode(container); container.read(navControllerProvider.notifier).applyDeepLink(navLoc); return; } @@ -315,6 +330,24 @@ class _TelemetryLifecycleObserver extends WidgetsBindingObserver { } } +/// Whether an analytics event may leave the device. +/// +/// The single chokepoint for ANALYTICS while the demo is on: the sample project +/// is reachable with no account behind it, so there is nobody to attribute an +/// event to and nothing there but canned data anyway. Gating here rather than +/// per call site covers the widget-level `track(...)` calls too, which the +/// per-session sink never sees. +/// +/// Crash reporting is deliberately NOT gated with it. `initCrashReporting` +/// wraps `runApp`, so its decision is made before a demo can be entered — and a +/// crash under the sample project is the one report worth having, since it is +/// exactly what a store reviewer would hit. It answers to the user's own +/// telemetry setting alone, demo or not. +@visibleForTesting +bool telemetryAllowed(ProviderContainer container) => + !container.read(demoModeProvider) && + container.read(appSettingsServiceProvider).telemetryEnabled; + /// System-bar overlay style for [palette]: transparent bars (the app draws /// edge-to-edge on mobile, see main()) with icon brightness flipped off the /// background luminance so bar icons stay legible on light presets too. @@ -389,6 +422,7 @@ class AbApp extends ConsumerWidget { const home = UpdateGate(child: _AppHome()); return MaterialApp( title: 'Antgrid', + navigatorKey: ref.watch(rootNavigatorKeyProvider), debugShowCheckedModeBanner: false, theme: theme, darkTheme: theme, @@ -453,7 +487,9 @@ class AbApp extends ConsumerWidget { // which is also what lets it drive navigation from wherever the // app currently is. It renders nothing unless the driver entry // point enabled it. - child: AbTextDensity(child: NavConsole(child: child!)), + child: AbTextDensity( + child: NavConsole(child: DemoFrame(child: child!)), + ), ), ), ); @@ -463,6 +499,19 @@ class AbApp extends ConsumerWidget { } } +/// [deviceCapProvider], held back while the demo is on. +/// +/// The cap dialog's remedy is revoking one of the user's REAL account devices, +/// which the demo may never do — and it opens on the root navigator, so from +/// inside the demo it lands over the sample project, under the banner saying +/// nothing is connected. Held back rather than dropped: the cap value survives +/// until the dialog itself clears it, so this provider goes null → cap on the +/// build that leaves the demo and the listener's edge fires there instead. +final _pendingDeviceCapProvider = Provider((ref) { + if (ref.watch(demoModeProvider)) return null; + return ref.watch(deviceCapProvider); +}); + /// Root route: splash while auth is unknown; sign-in gate on mobile; /// [AppShell] otherwise. Pricing is reached from app settings only. class _AppHome extends ConsumerWidget { @@ -475,12 +524,17 @@ class _AppHome extends ConsumerWidget { // free-a-slot dialog rather than failing silently. The cap kind travels on // DeviceCapInfo, so the dialog picks device-cap vs worker-cap copy itself. // Edge-trigger (null → non-null) so it shows once per rejection. - ref.listen(deviceCapProvider, (prev, next) { + ref.listen(_pendingDeviceCapProvider, (prev, next) { if (prev == null && next != null) { showDeviceCapDialog(context, ref, next); } }); + // Above every account gate below, because the demo has no account: it is + // reached from the sign-in screen itself, which is the only surface a + // reviewer with no desktop and no credentials ever sees. + if (ref.watch(demoModeProvider)) return const DemoHome(); + // A revocation forces the sign-in screen on EVERY platform. Desktop is // otherwise ungated (it drives its own machine locally and only offers // sign-in from the drawer), but a revoked device has no credentials left — diff --git a/app/lib/navigation/back_intent.dart b/app/lib/navigation/back_intent.dart index 5bd19b2c..815cfb14 100644 --- a/app/lib/navigation/back_intent.dart +++ b/app/lib/navigation/back_intent.dart @@ -40,6 +40,13 @@ abstract final class BackPriority { // close the file, not the surface out from under it. static const int workspaceSurface = 700; static const int mobileAgentPage = 400; + + /// Last registered handler, below every surface the demo can open: a back + /// press with anything still to unwind must unwind it, and only an exhausted + /// one leaves the sample project. Registered by `DemoHome` alone, so the + /// exit gate below it is unreachable while the demo is on — a reviewer's + /// back press returns them to sign-in instead of closing the app. + static const int demoExit = 100; } /// How long the "press back again" arm stays valid. diff --git a/app/lib/navigation/nav_controller.dart b/app/lib/navigation/nav_controller.dart index 9eb180b0..8f7d4981 100644 --- a/app/lib/navigation/nav_controller.dart +++ b/app/lib/navigation/nav_controller.dart @@ -92,6 +92,12 @@ class NavController extends Notifier { cur.sessionId == loc.sessionId; } + /// Drop the whole history, at both edges of a scope that is entered and left + /// rather than navigated through. The demo is the one such scope: its entries + /// name a project that does not exist outside it, and the real app's entries + /// name machines that must not be reachable from inside it. + void reset() => state = const NavState(); + void back() { if (!state.canBack) return; final prev = state.past.last; diff --git a/app/lib/navigation/root_navigator.dart b/app/lib/navigation/root_navigator.dart new file mode 100644 index 00000000..3a706b9e --- /dev/null +++ b/app/lib/navigation/root_navigator.dart @@ -0,0 +1,16 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Key on the app's one Navigator, handed to `MaterialApp.navigatorKey`. +/// +/// Provider-owned for the same reason `sessionSearchFocusProvider` is: the +/// Navigator mounts above every route, and the code that needs to drive it +/// holds a [ProviderContainer] rather than a `BuildContext` — `enterDemoMode` +/// and `exitDemoMode` are called from callbacks whose widget is already being +/// popped out from under them. +/// +/// A provider rather than a top-level global so each test container gets its +/// own key; two live containers sharing one would attach it twice. +final rootNavigatorKeyProvider = Provider>( + (ref) => GlobalKey(), +); diff --git a/app/lib/project/project_session_registry.dart b/app/lib/project/project_session_registry.dart index 0b915c8b..a20710c0 100644 --- a/app/lib/project/project_session_registry.dart +++ b/app/lib/project/project_session_registry.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../demo/demo_identity.dart'; import '../providers/agent_transport.dart'; import '../providers/analytics.dart'; import '../providers/cached_sessions.dart'; @@ -267,7 +268,12 @@ Future defaultProjectSessionFactory( transport: transport, mode: mode, cachedSessionsStore: cache, - analytics: ref.read(analyticsServiceProvider), + // No sink for the sample project: every event it would emit describes + // canned data, and the demo is reachable with no account behind it to + // attribute anything to. + analytics: isDemoProjectId(projectId) + ? null + : ref.read(analyticsServiceProvider), onClose: () async { // Transport teardown is handled by the agentTransportForProvider's // own ref.onDispose, fired when the registry invalidates it. diff --git a/app/lib/providers/account_agents.dart b/app/lib/providers/account_agents.dart index 971ae8e3..8e6102ac 100644 --- a/app/lib/providers/account_agents.dart +++ b/app/lib/providers/account_agents.dart @@ -10,6 +10,15 @@ final accountAgentsApiProvider = Provider((ref) { ); }); +/// The account's machine inventory. +/// +/// NOT demo-gated here, deliberately, even though every UI reader is: a real +/// machine can stay warm behind the demo on desktop, and `ConnectionSupervisor` +/// resolves its dial coordinates through this provider — answering it empty +/// would silently demote that machine to its cached `RecentAgent` pin for the +/// demo's whole lifetime, which is a dead dial for any machine that has since +/// moved relay or re-provisioned its key. Readers that must not reach the +/// keychain from inside the demo gate themselves; see `demo/demo_identity.dart`. final accountAgentsProvider = FutureProvider>((ref) async { final api = ref.watch(accountAgentsApiProvider); return api.listAgents(); diff --git a/app/lib/providers/agent_transport.dart b/app/lib/providers/agent_transport.dart index 13b50200..9751589b 100644 --- a/app/lib/providers/agent_transport.dart +++ b/app/lib/providers/agent_transport.dart @@ -9,6 +9,8 @@ import 'package:antgrid_relay_client/antgrid_relay_client.dart'; import '../analytics/events.dart'; import '../connection/connection_supervisor.dart'; import '../connection/relay_mechanisms.dart'; +import '../demo/demo_identity.dart'; +import '../demo/demo_transport.dart'; import '../launcher/local_agent_launcher.dart'; import '../models/ab_message.dart'; import '../models/ab_project.dart'; @@ -20,12 +22,14 @@ import '../services/keychain_device_store.dart'; import '../services/license_token_minter.dart'; import '../storage/recent_agents_store.dart'; import '../util/ab_log.dart'; +import '../util/detached.dart'; import '../util/device_id.dart'; import 'account_agents.dart'; import 'agent_coordinates.dart'; import 'analytics.dart'; import 'auth.dart'; import 'connection_identity.dart'; +import 'demo_mode.dart'; import 'device_provisioning.dart'; import 'projects.dart'; import 'provider_retry.dart'; @@ -107,6 +111,25 @@ final agentTransportForProvider = FutureProvider.family ref, projectId, ) async { + // First, so nothing below can reach the network for the sample project: the + // demo id is reserved and never names a real machine, and both branches below + // read the keychain (device identity, session cookie) on their way out. + if (isDemoProjectId(projectId)) { + // Watched, not read: leaving the demo rebuilds this entry, which disposes + // the transport through the onDispose below and resolves to null. The + // sample project's transport lifetime IS the flag. + if (!ref.watch(demoModeProvider)) return null; + final demo = DemoTransport(); + ref.onDispose( + () => detached( + 'AgentTransport', + 'demo transport dispose failed', + demo.dispose, + ), + ); + await demo.connect(); + return demo; + } // Relay first: if this id corresponds to a paired remote agent, build // the relay stream transport and DO NOT watch the projects list (a watch there // would respawn the relay transport on every `projectsProvider.upsert`). @@ -356,7 +379,13 @@ Future _buildRelayTransportFor( await transport.connect(); // Detach only THIS stream on teardown; the machine connection's lifetime is // governed by the control-plane reaper / registry eviction, not here. - ref.onDispose(() => unawaited(transport.dispose())); + ref.onDispose( + () => detached( + 'AgentTransport', + 'stream transport dispose failed', + transport.dispose, + ), + ); return transport; } diff --git a/app/lib/providers/analytics.dart b/app/lib/providers/analytics.dart index be4a7c1b..ac6797ab 100644 --- a/app/lib/providers/analytics.dart +++ b/app/lib/providers/analytics.dart @@ -14,6 +14,7 @@ AnalyticsService buildAnalyticsService({ required String platform, required String appVersion, required bool Function() enabled, + required bool Function() paused, required String plausibleUrl, required String plausibleDomain, required String eventsApiUrl, @@ -26,4 +27,5 @@ AnalyticsService buildAnalyticsService({ platform: platform, appVersion: appVersion, enabled: enabled, + paused: paused, ); diff --git a/app/lib/providers/demo_mode.dart b/app/lib/providers/demo_mode.dart new file mode 100644 index 00000000..3c4cd18d --- /dev/null +++ b/app/lib/providers/demo_mode.dart @@ -0,0 +1,106 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../demo/demo_identity.dart'; +import '../models/session_target.dart'; +import '../navigation/nav_controller.dart'; +import '../navigation/nav_location.dart'; +import '../navigation/root_navigator.dart'; +import '../project/project_session_registry.dart'; +import 'agent_transport.dart'; +import 'new_session_picker.dart'; +import 'ui_attention_providers.dart'; +import 'value_controller.dart'; + +/// Whether the offline sample project is on screen. +/// +/// In-memory only, deliberately: nothing about the demo may survive a relaunch, +/// so a reviewer or tester who force-quits comes back to the real app rather +/// than to canned data they might mistake for their machine. It is also the +/// LIFETIME of the demo transport — `agentTransportForProvider` watches this +/// flag for the demo id, so flipping it false disposes the transport instead of +/// leaving a stale one warm. +final demoModeProvider = NotifierProvider, bool>( + () => ValueController(false), +); + +/// Drops every route pushed above the app's root. +/// +/// Both edges of the demo need this and neither gets it from Flutter: toggling +/// [demoModeProvider] swaps `DemoFrame`'s child slot, which REPARENTS the app's +/// Navigator rather than tearing it down — `WidgetsApp` gives that Navigator a +/// GlobalKey, so routes pushed over it survive the move with their state +/// intact. Without this a dialog opened over the sample project would be left +/// standing over the real app, and vice versa. +/// +/// Lives here rather than at each call site because the three entry points do +/// not all sit on a route that would pop itself — today two of them mount on +/// the New Session surface, which is what has been hiding the gap. +/// +/// A null `currentState` (a container-only test, or a call before the first +/// frame) is a no-op by design. +void _popToRoot(ProviderContainer ref) { + ref.read(rootNavigatorKeyProvider).currentState?.popUntil((r) => r.isFirst); +} + +/// Focuses the sample project and turns the demo on. +/// +/// Takes the container, not a `WidgetRef`: [_popToRoot] below pops the route +/// the caller was on, so the widget that called this is gone by the time the +/// rest of this function runs. +void enterDemoMode(ProviderContainer ref) { + _popToRoot(ref); + // FIRST, before the focus below. Every gate in the app is written as "if the + // demo is on, refuse" while the thing that ARMS the host-spawn paths is a + // focused `LocalProject` — which the next statement makes the demo into. In + // between, the container would hold a local target with the guard still + // false, and a synchronous read of that chain (`focusedMachineToolsProvider` + // reads the target first and the flag second, then calls `ensureHost()`) + // would spawn the real bridge from inside the sample project. Riverpod + // happens to coalesce these writes into one rebuild today; ordering makes it + // not depend on that. + ref.read(demoModeProvider.notifier).set(true); + ref + .read(selectedTargetProvider.notifier) + .set(const LocalProject(kDemoProjectId)); + // The workspace is the demo: whatever surface the user left behind (the New + // Session canvas is reachable before a project exists) must not be what the + // sample project opens on. + ref.read(workbenchSurfaceProvider.notifier).set(WorkbenchSurface.workspace); + // The New Session composer's draft target is not surface state and survives + // the switch. Left pointing at a real project it would keep resolving that + // project's branches — which means spawning the bridge host — from inside + // the demo. + ref.read(selectedTargetProjectProvider.notifier).set(null); + // Nav history is per-scope here, not global. A real project's entry left in + // it would be applied by a back press INSIDE the demo — focusing a machine + // under a banner that says nothing is connected — and seeding the demo's own + // `current` is what gives its first navigation somewhere to go back to. + ref.read(navControllerProvider.notifier) + ..reset() + ..commit( + const NavLocation( + target: LocalProject(kDemoProjectId), + surface: WorkbenchSurface.workspace, + ), + ); +} + +/// Leaves the demo and drops everything it built. +/// +/// Mirrors `ProjectsController.cancelActiveAgent`: clear the focus, then evict — +/// eviction invalidates the session and transport family entries, so re-entering +/// replays the script from the top instead of resuming a half-played one. +void exitDemoMode(ProviderContainer ref) { + _popToRoot(ref); + ref.read(demoModeProvider.notifier).set(false); + ref.read(selectedTargetProvider.notifier).set(null); + // Same reason as on the way in, mirrored: a draft still naming the sample + // project would ask the real host for its branches. + ref.read(selectedTargetProjectProvider.notifier).set(null); + // Every focus change inside the demo committed a NavLocation naming the + // sample project. Those entries outlive it, and the first back press after + // leaving would apply one — focusing a project the drawer no longer lists + // and no transport can resolve. + ref.read(navControllerProvider.notifier).reset(); + ref.read(projectSessionRegistryProvider.notifier).forceEvict(kDemoProjectId); +} diff --git a/app/lib/providers/drawer_entries.dart b/app/lib/providers/drawer_entries.dart index 5231da75..3a93d0de 100644 --- a/app/lib/providers/drawer_entries.dart +++ b/app/lib/providers/drawer_entries.dart @@ -1,11 +1,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../demo/demo_identity.dart'; import '../models/drawer_entry.dart'; import '../util/device_id.dart'; import '../models/ab_project.dart'; import '../services/account_agents_api.dart'; import '../storage/recent_agents_store.dart'; import 'account_agents.dart'; +import 'demo_mode.dart'; import 'device_provisioning.dart'; import 'drawer_order.dart'; import 'projects.dart'; @@ -84,6 +86,14 @@ List applyDrawerOrder( /// is treated as empty so the drawer still renders immediately with local + /// recent data. final drawerEntriesProvider = Provider>((ref) { + // The sample project is the whole drawer while the demo is on, and none of + // the real sources below are watched: `accountAgentsProvider` reads the + // session cookie out of the keychain and fetches /account/agents, and every + // remote row it would produce dials that machine's relay socket. Returning + // early is also what keeps the header from reading "PROJECTS · 0 / No + // projects yet" over a workspace that is plainly showing one. + if (ref.watch(demoModeProvider)) return demoDrawerEntries(); + final locals = ref.watch(projectsProvider); final remotes = ref.watch(recentAgentsProvider); final inventory = ref.watch(accountAgentsProvider).value ?? const []; @@ -99,3 +109,14 @@ final drawerEntriesProvider = Provider>((ref) { order, ); }); + +/// The drawer's contents while the demo is on: the sample project and nothing +/// else. +/// +/// A [LocalProjectEntry] over an in-memory [AbProject] rather than a fourth +/// [DrawerEntry] subclass — the demo's whole premise is that the real UI +/// renders it, and a local entry is exactly what it is: `machineUuid` null, so +/// the row expands into its own sessions instead of a machine's project advert +/// (which would open a control-plane socket). +List demoDrawerEntries() => + List.unmodifiable([LocalProjectEntry(demoProject())]); diff --git a/app/lib/providers/first_run.dart b/app/lib/providers/first_run.dart index dca3c2b2..7f8b7999 100644 --- a/app/lib/providers/first_run.dart +++ b/app/lib/providers/first_run.dart @@ -7,6 +7,7 @@ import '../storage/first_run_store.dart'; import 'account_agents.dart'; import 'agent_transport.dart'; import 'auth.dart'; +import 'demo_mode.dart'; import 'device_provisioning.dart'; import 'now_ticker.dart'; import 'projects.dart'; @@ -46,6 +47,14 @@ class FirstRunController extends Notifier { // same pattern as CollapsedDrawerIdsNotifier._persist (collapsed_drawer.dart). void _commit(FirstRunState next) { state = next; + // The single write, so no first-run latch can reach disk from inside the + // demo whichever of the seven mutators is called — `FirstRunStore` itself + // takes no project id to key a gate off, unlike the other demo-gated + // stores. In-memory state still moves, so a control the demo does render + // still responds; it just does not outlive the process. The checklist + // widget's own guard is the complement, not a duplicate: it stops a latch + // from hiding the checklist for the rest of THIS session. + if (ref.read(demoModeProvider)) return; unawaited(_store.write(next)); } diff --git a/app/lib/providers/focused_tools.dart b/app/lib/providers/focused_tools.dart index f6ab6a33..f574c54c 100644 --- a/app/lib/providers/focused_tools.dart +++ b/app/lib/providers/focused_tools.dart @@ -5,6 +5,7 @@ import '../util/device_id.dart'; import 'agent_catalog.dart'; import 'agent_transport.dart'; import 'control_plane.dart'; +import 'demo_mode.dart'; import 'new_session_picker.dart'; /// The `agent:tools` advert for the machine hosting the FOCUSED project. @@ -40,6 +41,15 @@ final focusedMachineToolsProvider = FutureProvider((ref) async { final target = ref.watch(selectedTargetProvider); if (target == null) return const FocusedTools(); + // The EARLIEST host spawn in the app: `enterDemoMode` points + // [selectedTargetProvider] at the sample project, which is a `LocalProject`, + // and the session mark and mode control watch this from the workspace the + // demo opens on — so the real bridge started before the user had touched + // anything. Empty is the value this provider already resolves to when the + // host cannot answer, and both consumers fall back to + // `sessionAgentDisplayLabel` for it, so the demo reads the same as it did. + if (ref.watch(demoModeProvider)) return const FocusedTools(); + if (target.isLocal) { try { final host = await ref.watch(hostControllerProvider).ensureHost(); diff --git a/app/lib/providers/host_status.dart b/app/lib/providers/host_status.dart index f105b28a..e8382387 100644 --- a/app/lib/providers/host_status.dart +++ b/app/lib/providers/host_status.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../demo/demo_identity.dart'; import '../launcher/host_controller.dart'; import '../project/project_session_registry.dart'; import '../util/ab_log.dart'; @@ -50,9 +51,17 @@ final hostRestartRebindProvider = Provider((ref) { // return below on purpose: it is stale whether or not a project is open. ref.invalidate(hostControlClientProvider); + // `DemoTransport` reports itself local (that is what keeps the sample + // project out of the relay bucket and its push registration), so the demo + // id lands in this list — but it holds no loopback socket and never spoke + // to the dead process. Re-binding it would tear the transport down and + // replay the canned script from beat 0, mid-read, for a host event that has + // nothing to do with it. final open = ref .read(projectSessionRegistryProvider.notifier) - .localOpenProjects(); + .localOpenProjects() + .where((id) => !isDemoEntryId(id)) + .toList(); if (open.isEmpty) return; AbLog.info( 'HostRestartRebind', diff --git a/app/lib/providers/new_session_action.dart b/app/lib/providers/new_session_action.dart index bb4796da..4cc0f4ba 100644 --- a/app/lib/providers/new_session_action.dart +++ b/app/lib/providers/new_session_action.dart @@ -21,6 +21,7 @@ import 'agent_transport.dart'; import 'analytics.dart'; import 'cached_sessions.dart'; import 'control_plane.dart'; +import 'demo_mode.dart'; import 'new_session_picker.dart'; import 'new_session_start.dart'; import 'projects.dart'; @@ -158,11 +159,21 @@ Future startNewSession( return; } + // Never in the demo. The sample project's branch menu is fixture data + // (`newSessionBranchCatalogProvider`), so picking one of its branches is + // ordinary demo navigation — but the checkout's local arm is an `ensureHost()` + // caller and the demo's target IS a `LocalProject`, so it would spawn the real + // bridge and check a branch out in whatever directory the fixture names. + // Skipping costs the user nothing: the create step further down answers with + // the demo's own refusal either way. + final willCheckoutBranch = + !isolated && explicitBranch != null && !ref.read(demoModeProvider); + final name = ref.read(newSessionNameProvider).trim(); start.begin( // The checkout runs first when there is one, so the status line must open // on it rather than flashing the activation copy for a frame. - phase: (!isolated && explicitBranch != null) + phase: willCheckoutBranch ? NewSessionStartPhase.switchingBranch : NewSessionStartPhase.activating, targetId: target.id, @@ -175,7 +186,7 @@ Future startNewSession( ); try { // 0. If an explicit branch was selected, perform git checkout BEFORE target activation - if (!isolated && explicitBranch != null) { + if (willCheckoutBranch) { try { if (target.isLocal) { final host = await ref.read(hostControllerProvider).ensureHost(); @@ -298,7 +309,10 @@ Future startNewSession( // create failed (e.g. session cap reached); stay on the New Session page // so the user can retry. Only CREATE keeps the user here — once the // session exists it is theirs, and the place to report anything further - // about it is the session itself. + // about it is the session itself. A refusal carrying a reason — the + // sample project's included — never reaches here: `SessionsService.create` + // fails its completer with a `SessionOperationException`, which the + // composer's own catch renders. if (created == null) { abort(NewSessionStartAbortReason.createRefused); return; diff --git a/app/lib/providers/new_session_picker.dart b/app/lib/providers/new_session_picker.dart index 05804197..08ede3c5 100644 --- a/app/lib/providers/new_session_picker.dart +++ b/app/lib/providers/new_session_picker.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../demo/demo_identity.dart'; +import '../demo/fixtures/demo_workspace_fixtures.dart'; import '../models/ab_project.dart'; import '../models/agent_descriptor.dart'; import '../models/branch_remote_status.dart'; @@ -22,6 +24,7 @@ import 'account_agents.dart'; import 'agent_catalog.dart'; import 'agent_transport.dart'; import 'control_plane.dart'; +import 'demo_mode.dart'; import 'device_provisioning.dart'; import 'projects.dart'; import 'recent_agents.dart'; @@ -212,6 +215,19 @@ List buildRemoteProjectRows( /// Rail sources for the New Session canvas, fed from the same three sources the /// dashboard merges (local projects, recent agents, account inventory). final pickerSourcesProvider = Provider>((ref) { + // Demo: one Local source holding the sample project, and none of the real + // sources watched — `accountAgentsProvider` reads the keychain and fetches + // /account/agents. `includeLocal` is deliberately overridden: mobile hides + // the Local rail because it has no folders to open, but the sample project is + // the only thing the demo has to pick. + if (ref.watch(demoModeProvider)) { + return buildPickerSources( + localProjects: [demoProject()], + recents: const [], + inventory: const [], + ); + } + final locals = ref.watch(projectsProvider); final recents = ref.watch(recentAgentsProvider); final inventory = @@ -301,6 +317,14 @@ final newSessionDetectedToolsProvider = FutureProvider>(( final target = ref.watch(selectedTargetProjectProvider); if (target == null) return const {}; + // Before the local arm, same as [newSessionBranchCatalogProvider]: that arm + // spawns the real bridge host, and the demo's own agent is canned. The + // failure is silent — the surrounding catch swallows it — so nothing on + // screen would say the sample project had just started a bridge. + if (ref.watch(demoModeProvider)) { + return const {kDemoAgentTool: null}; + } + if (target.isLocal) { try { final host = await ref.watch(hostControllerProvider).ensureHost(); @@ -357,6 +381,11 @@ final newSessionChatCapableToolsProvider = final target = ref.watch(selectedTargetProjectProvider); if (target == null) return null; + // Same host-spawn hazard as [newSessionDetectedToolsProvider] above. + if (ref.watch(demoModeProvider)) { + return const {kDemoAgentTool}; + } + if (target.isLocal) { try { final host = await ref.watch(hostControllerProvider).ensureHost(); @@ -630,6 +659,23 @@ final newSessionBranchCatalogProvider = final target = ref.watch(selectedTargetProjectProvider); if (target == null) return null; + // Before the local branch: `ensureHost()` SPAWNS the real bridge host, and + // the sample project is presented under a banner saying nothing is + // connected. Its branches are canned like the rest of it. + if (ref.watch(demoModeProvider)) { + return const GitBranchCatalog( + isRepository: true, + current: kDemoBranch, + branches: kDemoBranches, + // Stated, not defaulted. `worktreeSessionsSupported` is what + // `newSessionIsolationReadyProvider` reads, and the isolated chip's + // disabled tooltip is a claim about the user's OWN machine — "check + // that its Antgrid is up to date" — which the demo is in no position + // to make about a bridge it never spoke to. + worktreeSessionsSupported: true, + ); + } + if (target.isLocal) { final host = await ref.watch(hostControllerProvider).ensureHost(); final client = HostControlClient( @@ -698,6 +744,10 @@ final newSessionBranchRemoteStatusProvider = FutureProvider.autoDispose ) async { final target = ref.watch(selectedTargetProjectProvider); if (target == null || target.id != key.targetId) return null; + // Before the debounce timer, not just before the request: the sample + // project's branches are fixtures with no remote behind them, and the + // local arm below would spawn the bridge host to ask about one. + if (ref.watch(demoModeProvider)) return null; // Settles only after the user stops moving through branches. Disposal during // the wait — composer closed, start pressed, branch changed again — cancels diff --git a/app/lib/providers/projects.dart b/app/lib/providers/projects.dart index 901759d0..e46ea2ac 100644 --- a/app/lib/providers/projects.dart +++ b/app/lib/providers/projects.dart @@ -25,7 +25,12 @@ class ProjectsNotifier extends Notifier> { } Future upsert(AbProject p) async { - await _store.upsert(p); + // `list()` is a full JSON decode that publishes a fresh List, which is + // never `==` the old one — so every `projectsProvider` watcher rebuilds. + // Skipped when the store refused the write (the sample project), whose + // drawer row goes through here on every tap: it is the demo's primary + // navigation gesture. + if (!await _store.upsert(p)) return; state = _store.list(); } diff --git a/app/lib/providers/providers.dart b/app/lib/providers/providers.dart index b33b7ac5..d5de9079 100644 --- a/app/lib/providers/providers.dart +++ b/app/lib/providers/providers.dart @@ -10,6 +10,7 @@ import 'package:antgrid_relay_client/antgrid_relay_client.dart'; import '../config/storage_scope.dart'; import '../connection/supervisor_state.dart'; +import '../demo/demo_identity.dart'; import '../models/ab_message.dart' show CommandInfo, NotificationPushMessage, TerminalNotificationMessage; import '../models/session_target.dart'; @@ -490,6 +491,13 @@ final projectPreferencesProvider = StreamProvider((ref) { return const Stream.empty(); } + // PreferencesService keys ONE file by projectId, so binding the sample + // project here would leave a demo panel layout on disk beside the user's real + // projects. Defaults, delivered once, are all the demo needs. + if (isDemoEntryId(projectId)) { + return Stream.value(const ProjectPreferences()); + } + // Source the FileService from the (non-throwing) session, not the façade: // re-evaluates once the async ProjectSession resolves, and never registers a // listener on the throwing façade (see [focusedSessionOrNull]). @@ -586,9 +594,17 @@ final fileTreeStateProvider = StreamProvider((ref) { // long-lived watcher of the focused FileService (kept alive by the workspace // shell), and it sources the service safely. Previously the binding hung off // fileServiceProvider, but nothing may `watch` that throwing façade anymore. - ref - .watch(_prefsBindingProvider) - .bind(service, ref.read(preferencesServiceProvider)); + // + // The sample project is exempt: PreferencesService still points at the last + // REAL project (nothing rebinds it for the demo), so binding here would + // debounce-write the demo's expanded paths and selection into that project's + // preferences.json. The binding's own `projectId == null` guard does not + // catch it — the id is non-null and simply belongs to someone else. + if (!isDemoEntryId(ref.watch(selectedRegistrationIdProvider))) { + ref + .watch(_prefsBindingProvider) + .bind(service, ref.read(preferencesServiceProvider)); + } return seededStream(() => service.currentState, service.stateStream); // retry: a tree-load error must surface to the screen's error state, not spin // in Riverpod 3's default retry loop (which would leave the UI on "loading"). diff --git a/app/lib/providers/recent_sessions.dart b/app/lib/providers/recent_sessions.dart index 4e50b0ac..806791f4 100644 --- a/app/lib/providers/recent_sessions.dart +++ b/app/lib/providers/recent_sessions.dart @@ -4,6 +4,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../demo/demo_identity.dart'; import '../design/widgets/ab_snack_bar.dart'; import '../models/recent_session_row.dart'; import '../services/account_agents_api.dart'; @@ -19,6 +20,7 @@ import 'account_agents.dart'; import 'cached_sessions.dart'; import 'chat_composer_drafts.dart'; import 'control_plane.dart'; +import 'demo_mode.dart'; import 'device_provisioning.dart'; import 'projects.dart'; import 'recent_agents.dart'; @@ -267,6 +269,13 @@ class MachineAdvertisedProjectsController /// metadata source (projects / recent agents / inventory / local uuid / /// [remoteProjectLabelsProvider]) updates. final recentSessionsProvider = Provider>((ref) { + // Early for the same reason `drawerEntriesProvider` is: every source below is + // the user's REAL machines. The session cache holds their history, and + // `accountAgentsProvider` reads the session cookie out of the keychain to + // fetch /account/agents — so reaching any of them here would list real + // projects under a banner promising nothing is connected. + if (ref.watch(demoModeProvider)) return _demoRecentSessions(ref); + // Re-derive on any cache mutation. We don't care WHICH key changed — the // whole list is cheap to rebuild and a global re-sort is required anyway. ref.watch(cacheChangesProvider); @@ -302,6 +311,29 @@ final recentSessionsProvider = Provider>((ref) { ); }); +/// Recent while the demo is on: the sample project's own sessions, nothing else. +/// +/// Sourced from the LIVE state rather than the cache because the cache refuses +/// the demo on every write path (see [CachedSessionsStore]) — it holds nothing +/// of the demo's and never will. `locals` carries [demoProject] so the rows name +/// themselves "demo-shop (sample)"; without it every key falls through to the +/// unmatched-cache-key branch, which labels a row with the raw project id. +List _demoRecentSessions(Ref ref) { + final fresh = ref.watch(freshSessionsStateProvider); + return buildRecentSessions( + cached: { + if (fresh != null && isDemoEntryId(fresh.projectId)) + fresh.projectId: fresh.sessions, + }, + locals: [demoProject()], + remotes: const [], + inventory: const [], + // No inventory to name the machine from, and asking for one would be the + // keychain read this whole branch exists to avoid. + localDeviceLabel: _localDeviceLabel(const [], null), + ); +} + /// Label for THIS device. The inventory row keyed by [localUuid] carries the /// friendly machine name; fall back to a generic label when none is found. String _localDeviceLabel(List inventory, String? localUuid) { @@ -576,6 +608,12 @@ Future pullToRefreshRecentSessions( WidgetRef ref, { Iterable extraMachineUuids = const [], }) async { + // The drawer's equivalent gesture bails the same way: the first thing + // `refreshMachineInventoryAndControlPlanes` does is invalidate and re-await + // `accountAgentsProvider`, which reads the keychain cookie and fetches + // /account/agents — a demo phoning home, and discarding the real inventory + // the user had cached before entering it. + if (ref.read(demoModeProvider)) return; final rows = ref.read(recentSessionsProvider); final uuids = { ...extraMachineUuids, diff --git a/app/lib/providers/registry_eviction.dart b/app/lib/providers/registry_eviction.dart index 91b67a50..ae22093f 100644 --- a/app/lib/providers/registry_eviction.dart +++ b/app/lib/providers/registry_eviction.dart @@ -1,8 +1,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../demo/demo_identity.dart'; import '../project/project_session_registry.dart'; import '../project/project_status_cache.dart'; import 'agent_transport.dart'; +import 'demo_mode.dart'; import 'drawer_entries.dart'; /// The registry's eviction callback: snapshot the evicted project's final @@ -22,10 +24,19 @@ Future snapshotAndInvalidateOnEvict( String projectId, ) async { final session = ref.read(projectSessionProvider(projectId)).value; - final stillListed = ref - .read(drawerEntriesProvider) - .any((e) => e.id == projectId); - if (session != null && stillListed) { + // While the demo is on, [drawerEntriesProvider] short-circuits to the sample + // project alone, so it cannot answer "does this project still exist" about a + // real one — every real id would read as deleted and lose the final status + // this callback exists to keep. Deletion is a real-app action, and the demo + // id itself is refused outright below, so the guard simply does not apply + // there. + final stillListed = + ref.read(demoModeProvider) || + ref.read(drawerEntriesProvider).any((e) => e.id == projectId); + // The demo gate does not lean on `stillListed` being false for the sample + // project: nothing about the demo may reach disk, whatever a later change + // does to drawer entries. + if (session != null && stillListed && !isDemoEntryId(projectId)) { await cache.write(projectId, session.status.value); } // Invalidate BOTH the session and the transport family entry so the WS is diff --git a/app/lib/providers/remote_access_nudge.dart b/app/lib/providers/remote_access_nudge.dart index a53a3903..dfcf4087 100644 --- a/app/lib/providers/remote_access_nudge.dart +++ b/app/lib/providers/remote_access_nudge.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../utils/platform_utils.dart'; import 'auth.dart'; +import 'demo_mode.dart'; import 'first_run.dart'; import 'remote_access.dart'; @@ -35,6 +36,12 @@ final remoteAccessNudgeProvider = Provider.autoDispose(( // that provider's chain (hostControlClientProvider → ensureHost) spawns the // local bridge host, which must never happen from a phone. if (isMobilePlatform) return null; + // Same order, second reason: besides the host spawn, the banner's action is + // `confirmAndEnableRemoteAccess` — the machine-wide grant. Offering that + // beside canned data invites a reviewer to open their own machine up from + // inside a sample project (the guard `agent_panel.dart` makes for the title + // bar's version of the same control). + if (ref.watch(demoModeProvider)) return null; // The checklist has the floor: its "Connect your phone" step covers the same // ground, and it now sits in the sidebar for the whole session — so a banner // saying the same thing on the canvas would be a second voice, permanently. diff --git a/app/lib/screens/demo_home.dart b/app/lib/screens/demo_home.dart new file mode 100644 index 00000000..c9faf50b --- /dev/null +++ b/app/lib/screens/demo_home.dart @@ -0,0 +1,120 @@ +import 'dart:ui' show AppExitResponse; + +import 'package:flutter/material.dart' show Scaffold; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../navigation/back_intent.dart'; +import '../navigation/nav_controller.dart'; +import '../providers/agent_transport.dart'; +import '../providers/demo_mode.dart'; +import '../providers/providers.dart'; +import '../providers/ui_attention_providers.dart'; +import 'new_session_screen.dart'; +import 'workspace_shell.dart'; + +/// Root route while the demo is on. +/// +/// Deliberately NOT [AppShell]: its `initState` kicks the eager relay dials and +/// the device-revoked check, which read the keychain and open sockets — the two +/// things the demo may never do. The "sample data" strip and, on desktop, the +/// window chrome AppShell would have drawn come from `DemoFrame` in the app +/// builder. +/// +/// What it does have to reproduce is `AppShell._buildAgentRouting`'s route +/// switch, minus the control-plane reaper (the demo opens no control plane). +/// All three arms matter: without the surface branch the drawer's "New Session" +/// button — its primary action, two taps from the first screen — sets a surface +/// nothing reads and appears dead; without the no-project branch a surface that +/// deselects the sample project (a blocking error's Back, which clears +/// `selectedTargetProvider`) leaves WorkspaceShell on its boot spinner with +/// nothing left to resolve; and without the [Scaffold] the mouse-desktop layout +/// is a bare `Row` with no [Material] ancestor, which every `InkWell` in the +/// drawer throws on. +class DemoHome extends ConsumerStatefulWidget { + const DemoHome({super.key}); + + @override + ConsumerState createState() => _DemoHomeState(); +} + +class _DemoHomeState extends ConsumerState { + late final AppLifecycleListener _lifecycleListener; + + @override + void initState() { + super.initState(); + // The demo replaces AppShell as the root route, so it inherits AppShell's + // duty to the REAL app underneath it: preference writes are debounced, and + // a project the user was in before entering the demo can still have one + // pending. Nothing else flushes on the way out of the process. + _lifecycleListener = AppLifecycleListener( + onExitRequested: () async { + await ref.read(preferencesServiceProvider).flush(); + return AppExitResponse.exit; + }, + ); + } + + @override + void dispose() { + _lifecycleListener.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final id = ref.watch(selectedRegistrationIdProvider); + // The other two surfaces (appSettings, remoteDevices) are WorkspaceShell's + // own overlay children, so they need no branch here. + final surface = ref.watch(workbenchSurfaceProvider); + // Same keep-alive chain AppShell holds for the same reason: this screen is + // the only host that survives the New Session ↔ workspace swap, and an + // unwatched binder goes stale and then flushes from inside the next + // mount's build(), which throws. See the comment at AppShell's watch site. + ref.watch(agentFocusBinderProvider); + // `resolveBackIntent` runs the handler registry BEFORE its project/session + // history step, so an always-active handler here would leave the demo on + // the first press instead of stepping back through it — the opposite of + // what [BackPriority.demoExit]'s "below every surface" rank promises. + final canStepBack = ref.watch( + navControllerProvider.select((s) => s.canBack), + ); + final Widget body; + if (id == null || surface == WorkbenchSurface.newSession) { + body = const NewSessionScreen(); + // Landing trap, identical to AppShell's: with no focused project the New + // Session screen renders while the surface may still read `workspace`, and + // any flow that focuses one mid-flight flips this route to WorkspaceShell, + // unmounting the widgets that own the in-flight flow. + if (id == null && + (surface == WorkbenchSurface.workspace || + surface == WorkbenchSurface.remoteDevices)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final s = ref.read(workbenchSurfaceProvider); + if (ref.read(selectedRegistrationIdProvider) == null && + (s == WorkbenchSurface.workspace || + s == WorkbenchSurface.remoteDevices)) { + ref + .read(workbenchSurfaceProvider.notifier) + .set(WorkbenchSurface.newSession); + } + }); + } + } else { + body = const Scaffold(body: WorkspaceShell()); + } + return AppBackScope( + child: BackHandler( + priority: BackPriority.demoExit, + active: !canStepBack, + onBack: () { + exitDemoMode(ref.container); + return true; + }, + child: body, + ), + ); + } +} diff --git a/app/lib/screens/preview_screen.dart b/app/lib/screens/preview_screen.dart index ce5bc45e..e15ddf75 100644 --- a/app/lib/screens/preview_screen.dart +++ b/app/lib/screens/preview_screen.dart @@ -16,7 +16,9 @@ import '../design/widgets/ab_toolbar.dart'; import '../design/widgets/ab_url_field.dart'; import '../models/preview_models.dart'; import '../navigation/back_intent.dart'; +import '../demo/demo_identity.dart'; import '../providers/analytics.dart'; +import '../providers/demo_mode.dart'; import '../services/preview_service.dart'; import '../providers/agent_transport.dart'; import '../providers/providers.dart'; @@ -212,6 +214,16 @@ class _PreviewScreenState extends ConsumerState { /// quick-picks, and the port list. On a relay-mode port conflict, confirms /// before falling back to a different local port. Future _openPort(int port, String scheme) async { + // The sample project advertises the ports a real dev server would, because + // an empty preview tab is not what the product looks like — but the demo + // transport reports itself LOCAL, so opening one would point a real webview + // at a localhost port nothing is listening on and render a browser error + // page inside the demo. Decline in the same words every other demo refusal + // uses instead. + if (ref.read(demoModeProvider)) { + showAbSnackBar(context, kDemoRefusalText); + return; + } // Pin the project this open belongs to. The provider re-reads below are // always the currently-focused service (never disposed at the synchronous // moment of read), but focus can move across the dialog await — so we diff --git a/app/lib/screens/sign_in_screen.dart b/app/lib/screens/sign_in_screen.dart index 331dd9d2..05c1eac2 100644 --- a/app/lib/screens/sign_in_screen.dart +++ b/app/lib/screens/sign_in_screen.dart @@ -3,11 +3,13 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show TextInput; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../demo/demo_identity.dart'; import '../design/ab_colors.dart'; import '../design/ab_icons.dart'; import '../design/ab_tokens.dart'; import '../design/widgets/ab_brand_mark.dart'; import '../design/widgets/ab_focus_ring.dart'; +import '../design/widgets/ab_icon.dart'; import '../design/widgets/ab_icon_button.dart'; import '../design/widgets/ab_loading.dart'; import '../design/widgets/ab_password_field.dart'; @@ -16,6 +18,7 @@ import '../project/limits.dart'; import '../analytics/events.dart'; import '../providers/analytics.dart'; import '../providers/auth.dart'; +import '../providers/demo_mode.dart'; import '../providers/device_revocation.dart'; import '../providers/subscription.dart'; import '../services/auth_service.dart'; @@ -774,10 +777,16 @@ class _SignInScreenState extends ConsumerState { /// their own account that only this device's memory can answer for them. /// /// Two tiers, and the split is what the hint is allowed to decide. Continue - /// is the fast path and the only thing that reads the hint; every button - /// below the divider names its own method and ignores it, so a hint that is + /// is the fast path and the only thing that reads the hint; every cell below + /// the divider names its own method and ignores it, so a hint that is /// missing or wrong costs a tap rather than the account. None of them asks /// the server anything. + /// + /// Three visual classes, one per tier, so the tiers are told apart before + /// they are read: the accent-filled Continue, the bordered method group, and + /// the outlined demo card. Every one of these was a full-width button of the + /// same weight once, and five of them stacked read as a wall rather than a + /// hierarchy. Widget _emailStepBody(BuildContext context, bool canPop) { final busy = _phase == _Phase.submitting; return Column( @@ -808,31 +817,59 @@ class _SignInScreenState extends ConsumerState { _SignInButton( label: busy ? 'Continuing…' : 'Continue', onPressed: busy ? null : _continue, + variant: _SignInButtonVariant.primary, ), - const SizedBox(height: AbTokens.space8), + const SizedBox(height: AbTokens.space12), const _OrDivider(), - const SizedBox(height: AbTokens.space16), - _SignInButton( - label: 'Continue with GitHub', - onPressed: busy ? null : () => _startOAuth('github'), - ), - const SizedBox(height: AbTokens.space8), - _SignInButton( - label: 'Continue with Google', - onPressed: busy ? null : () => _startOAuth('google'), - ), - const SizedBox(height: AbTokens.space8), - // Unconditional, never keyed on what the store recalls: visibility that - // tracked the hint would flicker as the address is typed and would tell - // anyone watching the screen which addresses this device remembers. - _SignInButton( - label: 'Continue with a password', - onPressed: busy ? null : _useMyPassword, + const SizedBox(height: AbTokens.space12), + // One bordered group rather than three stacked buttons: these are three + // answers to a single question — how to prove the address is yours — + // and [AbSegmented]'s construction is how this app already asks a small + // closed set where the alternatives must stay visible. Not AbSegmented + // itself: a cell here fires an action, and a selected state would + // promise a choice that persists. + // + // The password cell is a peer, not a footnote. `_startOAuth` records + // the TYPED address rather than the one the user authenticates as, so + // the hint can land wrong, and this cell is the only thing that reaches + // step 2 — and the link it carries — whatever the hint says. + _AuthMethodRow( + methods: [ + _AuthMethodSpec( + icon: AbIcons.github, + label: 'GitHub', + onTap: busy ? null : () => _startOAuth('github'), + ), + _AuthMethodSpec( + icon: _googleMark, + label: 'Google', + onTap: busy ? null : () => _startOAuth('google'), + ), + // Unconditional, never keyed on what the store recalls: visibility + // that tracked the hint would flicker as the address is typed and + // would tell anyone watching the screen which addresses this device + // remembers. + _AuthMethodSpec( + icon: AbIcons.password, + label: 'Password', + onTap: busy ? null : _useMyPassword, + ), + ], ), + const SizedBox(height: AbTokens.space24), + // A card, not a fifth button, because it is not a way through this + // screen — it leaves the account behind entirely, and the two lines it + // needs never fitted on a centred button label anyway. + // + // Unguarded unlike the link below it: on mobile this screen is the + // whole app until an account exists, so for an App Store reviewer — or + // a tester whose desktop is somewhere else — it is the only affordance + // here that leads anywhere at all. + _DemoCard(onTap: busy ? null : _enterDemo), // The only muted thing on the screen, and the only one that leaves the // flow rather than choosing a way through it. if (canPop && !isMobilePlatform) ...[ - const SizedBox(height: AbTokens.space16), + const SizedBox(height: AbTokens.space12), _MutedLink( label: 'Continue without signing in', onTap: () => Navigator.of(context).maybePop(), @@ -842,6 +879,14 @@ class _SignInScreenState extends ConsumerState { ); } + /// Leaves sign-in for the offline demo. + /// + /// On desktop this screen is a pushed route and the demo replaces the root's + /// content, so it has to come off the stack first or the demo renders + /// underneath it. `enterDemoMode` does that for every entry point; `ref` is + /// read here, before the call, because the pop leaves it defunct. + void _enterDemo() => enterDemoMode(ref.container); + /// Step 2. The address is settled, so it reads as text rather than an input; /// "change" is the only way back. Every exit stays open — reset, and the /// magic link, which is also the answer for an account whose password was @@ -889,6 +934,7 @@ class _SignInScreenState extends ConsumerState { _SignInButton( label: busy ? 'Signing in…' : 'Sign in', onPressed: busy ? null : _signInWithPassword, + variant: _SignInButtonVariant.primary, ), const SizedBox(height: AbTokens.space8), _MutedLink( @@ -962,6 +1008,7 @@ class _SignInScreenState extends ConsumerState { _goToStep(_Step.password); unawaited(_signInWithPassword()); }, + variant: _SignInButtonVariant.primary, ), const SizedBox(height: AbTokens.space8), _MutedLink( @@ -1001,6 +1048,7 @@ class _SignInScreenState extends ConsumerState { _SignInButton( label: 'Back to sign in', onPressed: () => _goToStep(_Step.password), + variant: _SignInButtonVariant.primary, ), ], ); @@ -1067,7 +1115,11 @@ class _SignInScreenState extends ConsumerState { ), ), const SizedBox(height: AbTokens.space16), - _SignInButton(label: 'Send a new link', onPressed: _sendLink), + _SignInButton( + label: 'Send a new link', + onPressed: _sendLink, + variant: _SignInButtonVariant.primary, + ), const SizedBox(height: AbTokens.space8), _MutedLink(label: 'Use a different email', onTap: _backToForm), ], @@ -1094,7 +1146,11 @@ class _SignInScreenState extends ConsumerState { ), ), const SizedBox(height: AbTokens.space16), - _SignInButton(label: 'Use a different email', onPressed: _backToForm), + _SignInButton( + label: 'Use a different email', + onPressed: _backToForm, + variant: _SignInButtonVariant.primary, + ), ], ); } @@ -1198,10 +1254,27 @@ class _MutedLinkState extends State<_MutedLink> { } } +/// Emphasis for [_SignInButton], mirroring [AbButtonVariant] so there is one +/// mental model for "this is the way through" across the app. +enum _SignInButtonVariant { + /// Surface fill, 1px border. Every secondary action on the screen. + normal, + + /// Accent fill. At most ONE per phase — the accent is what tells the primary + /// action apart from its neighbours, and a second one spends that for + /// nothing. + primary, +} + class _SignInButton extends StatefulWidget { - const _SignInButton({required this.label, required this.onPressed}); + const _SignInButton({ + required this.label, + required this.onPressed, + this.variant = _SignInButtonVariant.normal, + }); final String label; final VoidCallback? onPressed; + final _SignInButtonVariant variant; @override State<_SignInButton> createState() => _SignInButtonState(); @@ -1215,17 +1288,24 @@ class _SignInButtonState extends State<_SignInButton> { Widget build(BuildContext context) { final antgrid = context.antgrid; final enabled = widget.onPressed != null; + final isPrimary = widget.variant == _SignInButtonVariant.primary; final visual = Container( padding: const EdgeInsets.symmetric(vertical: AbTokens.space10), decoration: BoxDecoration( - color: _hovered ? antgrid.bgElevated : antgrid.bgSurface, - border: Border.all(color: antgrid.borderDefault), - borderRadius: AbTokens.borderRadius, + color: isPrimary + ? (_hovered ? antgrid.accentHighlight : antgrid.accent) + : (_hovered ? antgrid.bgElevated : antgrid.bgSurface), + border: Border.all( + color: isPrimary ? antgrid.accent : antgrid.borderDefault, + ), + borderRadius: AbTokens.borderRadius5, ), child: Text( widget.label, textAlign: TextAlign.center, - style: AbTokens.sansStyle(color: antgrid.textPrimary), + style: AbTokens.sansStyle( + color: isPrimary ? antgrid.accentForeground : antgrid.textPrimary, + ), ), ); if (!enabled) return Opacity(opacity: 0.4, child: visual); @@ -1249,10 +1329,259 @@ class _SignInButtonState extends State<_SignInButton> { onTap: widget.onPressed, child: AbFocusRing( focused: _focused, - borderRadius: AbTokens.borderRadius, + borderRadius: AbTokens.borderRadius5, child: visual, ), ), ); } } + +/// Simple Icons `google` (CC0), inlined as a `currentColor` SVG string for the +/// same reason [AbAgentMarks] inlines its marks: it renders through [AbIcon] on +/// the same path as every other glyph, with one tinting rule and no asset +/// manifest to keep in sync. It lives here rather than in [AbIcons] because +/// that file is the choke point for UI *affordance* icons and this is a +/// third-party brand mark — the same line [AbAgentMarks] draws. GitHub needs no +/// equivalent; Codicons ship one. +const String _googleMark = + ''; + +/// One way to prove the address is yours, as rendered by [_AuthMethodRow]. +class _AuthMethodSpec { + const _AuthMethodSpec({ + required this.icon, + required this.label, + required this.onTap, + }); + + /// Iconify SVG: an [AbIcons] constant, or an inlined brand mark. + final String icon; + final String label; + + /// Null disables the cell. The whole row disables together — only + /// [_Phase.submitting] ever does it — so the group dims as one object. + final VoidCallback? onTap; +} + +/// The step-1 method group: one bordered box, one cell per method. +/// +/// Built like [AbSegmented] — outer border, [ClipRRect], 1px dividers stretched +/// by [IntrinsicHeight], inset focus rings — because it has to read as a single +/// control answering a single question. Deliberately NOT an [AbSegmented]: a +/// cell here fires an action, and a selected state would promise a choice that +/// persists. +class _AuthMethodRow extends StatelessWidget { + const _AuthMethodRow({required this.methods}); + + final List<_AuthMethodSpec> methods; + + @override + Widget build(BuildContext context) { + final antgrid = context.antgrid; + return Container( + decoration: BoxDecoration( + border: Border.all(color: antgrid.borderDefault), + borderRadius: AbTokens.borderRadius5, + ), + child: ClipRRect( + borderRadius: AbTokens.borderRadius5, + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < methods.length; i++) ...[ + if (i > 0) Container(width: 1, color: antgrid.borderDefault), + Expanded(child: _AuthMethodCell(spec: methods[i])), + ], + ], + ), + ), + ), + ); + } +} + +class _AuthMethodCell extends StatefulWidget { + const _AuthMethodCell({required this.spec}); + + final _AuthMethodSpec spec; + + @override + State<_AuthMethodCell> createState() => _AuthMethodCellState(); +} + +class _AuthMethodCellState extends State<_AuthMethodCell> { + bool _hovered = false; + bool _focused = false; + + @override + Widget build(BuildContext context) { + final antgrid = context.antgrid; + final onTap = widget.spec.onTap; + final fg = _hovered ? antgrid.textPrimary : antgrid.textSecondary; + + final visual = AnimatedContainer( + duration: AbTokens.motionDefault, + curve: Curves.easeOut, + color: _hovered ? antgrid.bgElevated : antgrid.bgSurface, + padding: const EdgeInsets.symmetric(vertical: AbTokens.space10), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AbIcon(widget.spec.icon, size: 16, color: fg), + const SizedBox(height: AbTokens.space6), + Text( + widget.spec.label, + style: AbTokens.sansStyle(fontSize: AbTokens.fontXs, color: fg), + ), + ], + ), + ); + + if (onTap == null) return Opacity(opacity: 0.4, child: visual); + return Semantics( + button: true, + child: FocusableActionDetector( + mouseCursor: SystemMouseCursors.click, + onShowHoverHighlight: (v) { + if (_hovered != v) setState(() => _hovered = v); + }, + onShowFocusHighlight: (v) { + if (_focused != v) setState(() => _focused = v); + }, + actions: { + ActivateIntent: CallbackAction( + onInvoke: (_) { + onTap(); + return null; + }, + ), + }, + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: AbFocusRing( + focused: _focused, + // The cell sits under the group's ClipRRect; the default outset + // ring would be clipped away entirely. + inset: true, + borderRadius: AbTokens.borderRadius5, + child: visual, + ), + ), + ), + ); + } +} + +/// The offline demo, filed as a destination rather than a credential. +/// +/// Outlined on the page ground instead of filled like the controls above it, so +/// at rest it is the one element on the screen that does not look like a button +/// — which is what lets it stay prominent without competing with Continue. It +/// is also the only left-aligned, two-line thing here, so the caveat travels +/// with the offer instead of floating under it as an orphan line. +class _DemoCard extends StatefulWidget { + const _DemoCard({required this.onTap}); + + final VoidCallback? onTap; + + @override + State<_DemoCard> createState() => _DemoCardState(); +} + +class _DemoCardState extends State<_DemoCard> { + bool _hovered = false; + bool _focused = false; + + @override + Widget build(BuildContext context) { + final antgrid = context.antgrid; + final onTap = widget.onTap; + + final visual = AnimatedContainer( + duration: AbTokens.motionDefault, + curve: Curves.easeOut, + padding: const EdgeInsets.symmetric( + horizontal: AbTokens.space12, + vertical: AbTokens.space10, + ), + decoration: BoxDecoration( + color: _hovered ? antgrid.bgSurface : antgrid.bgDeep, + border: Border.all(color: antgrid.borderDefault), + borderRadius: AbTokens.borderRadius5, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + kDemoEntryLabel, + style: AbTokens.sansStyle( + fontSize: AbTokens.fontMd, + color: antgrid.textPrimary, + ), + ), + const SizedBox(height: AbTokens.space2), + Text( + 'No account needed. Sample data, nothing is connected.', + style: AbTokens.sansStyle( + fontSize: AbTokens.fontXs, + color: antgrid.textMuted, + ), + ), + ], + ), + ), + const SizedBox(width: AbTokens.space8), + AbIcon( + AbIcons.send, + size: 14, + color: _hovered ? antgrid.textSecondary : antgrid.textMuted, + ), + ], + ), + ); + + if (onTap == null) return Opacity(opacity: 0.4, child: visual); + return Semantics( + button: true, + child: FocusableActionDetector( + mouseCursor: SystemMouseCursors.click, + onShowHoverHighlight: (v) { + if (_hovered != v) setState(() => _hovered = v); + }, + onShowFocusHighlight: (v) { + if (_focused != v) setState(() => _focused = v); + }, + actions: { + ActivateIntent: CallbackAction( + onInvoke: (_) { + onTap(); + return null; + }, + ), + }, + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: AbFocusRing( + focused: _focused, + borderRadius: AbTokens.borderRadius5, + child: visual, + ), + ), + ), + ); + } +} diff --git a/app/lib/screens/workspace_shell.dart b/app/lib/screens/workspace_shell.dart index 1ed2950e..ee19b7d8 100644 --- a/app/lib/screens/workspace_shell.dart +++ b/app/lib/screens/workspace_shell.dart @@ -31,6 +31,7 @@ import '../models/preferences_models.dart'; import '../models/session_entry.dart'; import '../project/project_session_registry.dart'; import '../providers/agent_transport.dart'; +import '../providers/demo_mode.dart'; import '../providers/new_session_picker.dart' show newSessionStartInFlightProvider; import '../providers/providers.dart'; @@ -256,10 +257,18 @@ class WorkspaceShellState extends ConsumerState void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + // The demo has no agent that can notify anyone, and both calls below have a + // visible cost on iOS: `DarwinInitializationSettings` defaults to + // requesting alert permission, so `init()` raises the OS prompt — asked, in + // the demo's case, on behalf of nothing. A reviewer meeting an unexplained + // permission dialog inside a sample project is exactly the reading we are + // trying not to invite. + final demo = ref.read(demoModeProvider); // Fire-and-forget: async + self-degrading. - _osNotifications.init(); - if (defaultTargetPlatform == TargetPlatform.android || - defaultTargetPlatform == TargetPlatform.iOS) { + if (!demo) _osNotifications.init(); + if (!demo && + (defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS)) { _unsubscribeForegroundPush = Push.instance.addOnMessage((m) async { try { final decoded = await decodePush( @@ -508,6 +517,10 @@ class WorkspaceShellState extends ConsumerState } void _updatePrefs() { + // `projectPreferencesProvider` skips the demo, so `PreferencesService` is + // still bound to the LAST REAL project — a split drag or tab switch inside + // the sample project would save the demo's layout over that project's. + if (ref.read(demoModeProvider)) return; final service = ref.read(preferencesServiceProvider); service.update( service.current.copyWith( diff --git a/app/lib/services/local_notification_service.dart b/app/lib/services/local_notification_service.dart index eb6e1a5f..2071384e 100644 --- a/app/lib/services/local_notification_service.dart +++ b/app/lib/services/local_notification_service.dart @@ -5,7 +5,23 @@ import '../util/ab_log.dart'; /// Thin wrapper over flutter_local_notifications. Used only for FOREGROUND /// OS notifications when the app is backgrounded; the caller decides when to /// invoke based on AppLifecycleState. Degrades silently if unavailable. +/// +/// One instance per isolate, because [_ready] has to outlive the widget that +/// initialized it. `WorkspaceShell` constructs this in `initState` and a surface +/// swap or project switch remounts the whole shell — and the demo's mount +/// deliberately SKIPS [init] (its `DarwinInitializationSettings` would raise the +/// iOS alert-permission prompt on behalf of a sample project). Per-instance +/// readiness would therefore make [show] a no-op for the demo's whole lifetime, +/// silently dropping the handler escalations that still fan out from the user's +/// other warm projects. class LocalNotificationService { + LocalNotificationService._(); + + static final LocalNotificationService _instance = + LocalNotificationService._(); + + factory LocalNotificationService() => _instance; + final FlutterLocalNotificationsPlugin _plugin = FlutterLocalNotificationsPlugin(); bool _ready = false; diff --git a/app/lib/storage/cached_sessions_store.dart b/app/lib/storage/cached_sessions_store.dart index 7dc4aa7e..03ff0ecb 100644 --- a/app/lib/storage/cached_sessions_store.dart +++ b/app/lib/storage/cached_sessions_store.dart @@ -6,6 +6,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'scoped_prefs.dart'; import '../config/storage_scope.dart'; +import '../demo/demo_identity.dart'; import '../models/session_entry.dart'; /// SharedPreferences-backed cache of `List` keyed by drawer entry @@ -16,6 +17,10 @@ import '../models/session_entry.dart'; /// Writes are debounced to coalesce rapid `session:updated` bursts (the agent /// emits two frames per mutation — sync `changed()` + async PTY `noteExited`). /// Tests can force a flush via [flushNow]. +/// +/// Every writer drops the demo entry on the way in: the sample project is +/// canned data that must not outlive the demo, and a cached list would surface +/// in Recent and the drawer beside the user's real machines. class CachedSessionsStore { static final _key = scopedStorageKey('antgrid.session_cache.v1'); // Labels churn far more often than the session list itself (every live @@ -84,6 +89,7 @@ class CachedSessionsStore { /// and pinning "Preparing workspace…" over a session nothing is provisioning. /// The live list keeps carrying both; only the fallback copy is neutralised. Future put(String entryId, List sessions) async { + if (isDemoEntryId(entryId)) return; final next = [ for (final s in sessions) if (s.deleting || s.setup != null) @@ -142,6 +148,7 @@ class CachedSessionsStore { /// label update alone shouldn't force every listener to re-derive its /// session list. void putLabel(String entryId, String label) { + if (isDemoEntryId(entryId)) return; if (_labels[entryId] == label) return; _labels[entryId] = label; _labelsDirty = true; @@ -160,6 +167,7 @@ class CachedSessionsStore { /// boot can seed the status map before the first advert arrives. No-ops if /// unchanged; does not emit on [changes]. void putStatus(String entryId, String status) { + if (isDemoEntryId(entryId)) return; if (_statuses[entryId] == status) return; _statuses[entryId] = status; _statusesDirty = true; diff --git a/app/lib/storage/drawer_collapsed_store.dart b/app/lib/storage/drawer_collapsed_store.dart index af37e619..40ea4c09 100644 --- a/app/lib/storage/drawer_collapsed_store.dart +++ b/app/lib/storage/drawer_collapsed_store.dart @@ -2,6 +2,8 @@ import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; +import '../demo/demo_identity.dart'; + import '../config/storage_scope.dart'; import 'scoped_prefs.dart'; @@ -28,6 +30,10 @@ class DrawerCollapsedStore { } Future write(Set ids) async { - await _prefs.setString(_key, jsonEncode(ids.toList())); + // The sample project's row collapses like any other, but its id names + // nothing the real app can resolve and nothing prunes this set — so it + // must not be what survives the demo. + final persisted = ids.where((id) => !isDemoEntryId(id)).toList(); + await _prefs.setString(_key, jsonEncode(persisted)); } } diff --git a/app/lib/storage/project_store.dart b/app/lib/storage/project_store.dart index de4bd6b3..7fb2cea3 100644 --- a/app/lib/storage/project_store.dart +++ b/app/lib/storage/project_store.dart @@ -5,6 +5,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'scoped_prefs.dart'; import '../config/storage_scope.dart'; +import '../demo/demo_identity.dart'; import '../models/ab_project.dart'; /// SharedPreferences-backed persistence for the user's opened-project list. @@ -30,7 +31,14 @@ class ProjectStore { .toList(); } - Future upsert(AbProject p) async { + /// Returns false when the write was refused, so a caller that would re-read + /// the list afterwards can skip a decode that cannot have changed anything. + Future upsert(AbProject p) async { + // The sample project reaches every path a real one does — opening its + // drawer row records a focus, and that records an open. Persisted, it + // outlives the demo as a row that names no folder and can never be opened + // again. Refused here rather than at each caller: this is the one write. + if (isDemoProjectId(p.projectId)) return false; final all = list(); final i = all.indexWhere((x) => x.projectId == p.projectId); if (i >= 0) { @@ -39,6 +47,7 @@ class ProjectStore { all.add(p); } await _write(all); + return true; } Future remove(String projectId) async { diff --git a/app/lib/storage/recent_ports_store.dart b/app/lib/storage/recent_ports_store.dart index 934f5855..d08f97f0 100644 --- a/app/lib/storage/recent_ports_store.dart +++ b/app/lib/storage/recent_ports_store.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; import '../config/storage_scope.dart'; +import '../demo/demo_identity.dart'; import 'scoped_prefs.dart'; /// A remembered preview target: a port plus the scheme it was last opened with. @@ -87,6 +88,8 @@ class RecentPortsStore { /// existing entry for the same port (any scheme) is replaced and moved to the /// front. No-ops on out-of-range ports. Future add(String projectId, int port, String scheme) async { + // Nothing the demo does may reach disk; its ports are canned. + if (isDemoProjectId(projectId)) return; if (port < 1 || port > 65535) return; final all = _readAll(); final ports = List.from(all[projectId] ?? const []) diff --git a/app/lib/widgets/agent_panel.dart b/app/lib/widgets/agent_panel.dart index 2de105d3..f6fc1dd4 100644 --- a/app/lib/widgets/agent_panel.dart +++ b/app/lib/widgets/agent_panel.dart @@ -18,6 +18,7 @@ import '../design/widgets/ab_tooltip.dart'; import '../models/handler_state.dart'; import '../models/session_entry.dart'; import '../providers/agent_transport.dart'; +import '../providers/demo_mode.dart'; import '../providers/device_provisioning.dart'; import '../providers/first_run.dart'; import '../providers/handler_discovery.dart'; @@ -229,6 +230,12 @@ class AgentBar extends ConsumerWidget { /// directly in tests without re-implementing it. List titleBarProjectActions(WidgetRef ref) { if (isMobilePlatform) return const []; + // The demo has no machine to make reachable, and [RemoteAccessControl] is a + // LIVE switch over the real machine-wide policy — offering it beside canned + // data invites a reviewer to grant their machine from inside a sample. The + // guard also precedes the uuid read, which mints an anonymous host identity + // on desktop purely so this affordance can render. + if (ref.watch(demoModeProvider)) return const []; final localUuid = ref.watch(localDeviceUuidProvider).value; final selectedId = ref.watch(selectedRegistrationIdProvider); final projects = ref.watch(projectsProvider); diff --git a/app/lib/widgets/agent_transcript_view.dart b/app/lib/widgets/agent_transcript_view.dart index de59e382..762c64a7 100644 --- a/app/lib/widgets/agent_transcript_view.dart +++ b/app/lib/widgets/agent_transcript_view.dart @@ -30,6 +30,7 @@ import '../models/file_tree_models.dart'; import '../providers/agent_transport.dart'; import '../providers/capability_catalog.dart'; import '../providers/chat_composer_drafts.dart'; +import '../providers/demo_mode.dart'; import '../providers/providers.dart'; import '../providers/sessions.dart'; import '../services/agent_session_service.dart'; @@ -774,7 +775,11 @@ class _AgentTranscriptViewState extends ConsumerState { .firstWhereOrNull((s) => s.id == widget.sessionId) ?.tool; CapabilityCatalog? cachedCatalog; - if (toolKey != null && toolKey.isNotEmpty) { + // The demo's tool is a real one ('claude') and its target keys as local, so + // this cache entry is the SAME one the user's own local Claude sessions + // read — writing 'Sample model' into it poisons the real model picker, and + // reading it back shows the demo a machine's models. + if (toolKey != null && toolKey.isNotEmpty && !ref.watch(demoModeProvider)) { final cacheKey = capabilityCacheKey( capabilitySourceKey(ref.watch(selectedTargetProvider)), toolKey, diff --git a/app/lib/widgets/demo_frame.dart b/app/lib/widgets/demo_frame.dart new file mode 100644 index 00000000..fc0756fe --- /dev/null +++ b/app/lib/widgets/demo_frame.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart' show Theme; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../constants/breakpoints.dart'; +import '../design/ab_colors.dart'; +import '../design/ab_icons.dart'; +import '../design/ab_tokens.dart'; +import '../design/widgets/ab_button.dart'; +import '../design/widgets/ab_icon.dart'; +import '../design/widgets/ab_inline_banner.dart'; +import '../design/widgets/ab_window_controls.dart'; +import '../providers/demo_mode.dart'; +import '../utils/platform_utils.dart'; +import '../window/window_capabilities.dart'; +import 'window_title_bar.dart'; + +/// The strip that says "this is not your machine", wrapped around every route +/// while the demo is on. +/// +/// Mounted from `MaterialApp.builder` rather than from a screen so it survives +/// every route the demo can reach (settings, new session, a pushed terminal) — +/// there is no surface where the sample data can be seen without it. +class DemoFrame extends ConsumerWidget { + const DemoFrame({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Toggling this REPARENTS the app's Navigator (the builder's `child`) under + // the Column; it does not tear it down. `WidgetsApp` gives that Navigator a + // GlobalKey, which is precisely what carries a subtree — routes and all — + // across a move with its state intact. So nothing here unwinds a dialog + // opened over the sample project: `enterDemoMode`/`exitDemoMode` pop the + // stack on both edges, and that is the only thing standing between a demo + // modal and the real app underneath it. + if (!ref.watch(demoModeProvider)) return child; + // The same three-way rule AppShell applies (`_buildRoot`), not a second one + // that only happens to agree at phone width. The demo mounts WorkspaceShell + // and NewSessionScreen, both of which publish their pane toggles through + // `sidebarControlProvider`/`contextPanelControlProvider` for a bar mounted + // ABOVE the route to render — and those toggles are the only way back from + // a hidden drawer or a hidden/expanded context panel. A chrome-only bar at + // desktop width therefore drops a reviewer whose `sidebarHidden` setting is + // already on into a demo with no project drawer and nothing to restore it. + final narrow = MediaQuery.sizeOf(context).width < kMediumBreakpoint; + final showTitleBar = !isMobilePlatform && (appOwnsWindowChrome || !narrow); + // Everything below is a SIBLING of the app's Navigator, which owns the only + // Overlay in the tree — so the caption buttons' tooltips, which are + // `OverlayPortal`s and throw at BUILD time rather than on hover, have none. + // Wrapping the whole frame instead of just the bar: an Overlay sized to the + // bar would clip the tooltip it exists to host, since a tooltip on a title + // bar opens downward into the routes below. + return Overlay.wrap( + child: Column( + children: [ + // The demo mounts WorkspaceShell alone, not AppShell, so nothing else + // draws the bar the OS one was hidden for — without this the demo + // window has no drag region and no close button. Above the strip, since + // AppKit positions the macOS traffic lights in window coordinates and + // they do not move with Flutter layout. + if (showTitleBar) + WindowTitleBar( + child: narrow + ? const Row(children: [Spacer(), AbWindowControls()]) + : const WindowTitleBarContents(), + ), + const SafeArea(bottom: false, child: _DemoBanner()), + Expanded( + // The strip already consumed the status-bar inset; each route's own + // SafeArea would otherwise inset past it a second time. + child: MediaQuery.removePadding( + context: context, + removeTop: true, + child: child, + ), + ), + ], + ), + ); + } +} + +class _DemoBanner extends ConsumerWidget { + const _DemoBanner(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colors = context.antgrid; + return DefaultTextStyle( + // Above every route the ambient style is WidgetsApp's red-on-yellow error + // style, which `Text.style` merges with rather than replaces. + style: Theme.of(context).textTheme.bodyMedium ?? const TextStyle(), + child: AbInlineBanner( + text: 'Demo — sample data, not a real machine. Nothing is connected.', + color: colors.warning, + trailing: AbButton( + label: 'Exit demo', + compact: true, + leading: AbIcon( + AbIcons.close, + size: AbTokens.iconButtonGlyph, + color: colors.textSecondary, + ), + onTap: () => exitDemoMode(ref.container), + ), + ), + ); + } +} diff --git a/app/lib/widgets/drawer_entry_row.dart b/app/lib/widgets/drawer_entry_row.dart index b2c18ae4..976108ad 100644 --- a/app/lib/widgets/drawer_entry_row.dart +++ b/app/lib/widgets/drawer_entry_row.dart @@ -511,6 +511,8 @@ Future activateDrawerEntryById( bool ok; switch (entry) { case LocalProjectEntry e: + // The sample project falls out inside ProjectStore.upsert, which is the + // choke point every writer shares — nothing here needs to know. e.project.lastOpenedAt = DateTime.now(); await ref.read(projectsProvider.notifier).upsert(e.project); selectProject(ref, e.id); diff --git a/app/lib/widgets/first_run_checklist.dart b/app/lib/widgets/first_run_checklist.dart index fa518d90..67c5b076 100644 --- a/app/lib/widgets/first_run_checklist.dart +++ b/app/lib/widgets/first_run_checklist.dart @@ -2,13 +2,16 @@ import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../demo/demo_identity.dart'; import '../design/ab_colors.dart'; import '../design/ab_icons.dart'; import '../design/ab_tokens.dart'; +import '../design/widgets/ab_button.dart'; import '../design/widgets/ab_disclosure_chevron.dart'; import '../design/widgets/ab_icon.dart'; import '../design/widgets/ab_icon_button.dart'; import '../design/widgets/ab_list_row.dart'; +import '../providers/demo_mode.dart'; import '../providers/first_run.dart'; import '../utils/platform_utils.dart'; @@ -16,6 +19,11 @@ import '../utils/platform_utils.dart'; /// because provider state must never be written during build. Idempotent: the /// notifier's own no-op guards make a double-fired microtask harmless. void _latchAndMaybeComplete(WidgetRef ref, List steps) { + // The demo answers several of these steps with canned data — Recent lists + // sample sessions, the picker lists a sample project — and the latch is + // permanent. A reviewer who opens the sample project must not come back to a + // checklist claiming they already started a session on a real machine. + if (ref.read(demoModeProvider)) return; final state = ref.read(firstRunProvider); final newlyDone = { for (final s in steps) @@ -122,7 +130,12 @@ const _stepIndent = /// which would otherwise outlive the checklist by the whole life of the /// install. Keep this as the single expression both sides read. bool desktopSetupSectionVisible(WidgetRef ref) => - !isMobilePlatform && ref.watch(firstRunChecklistVisibleProvider); + !isMobilePlatform && + // Its steps are about the user's own machine and its actions leave for + // surfaces the demo has no account behind — including the button that + // opens this very demo. + !ref.watch(demoModeProvider) && + ref.watch(firstRunChecklistVisibleProvider); /// Self-gates (mobile / dismissed / completed ⇒ shrink), so the call site stays /// a single stable line — or [desktopSetupSectionVisible] where the host has @@ -230,14 +243,33 @@ class FirstRunSetupSection extends ConsumerWidget { /// dismisses it). Successor to the static connect-machine guide: same centered /// skeleton, but the rows check themselves off from live signals. /// +/// Whether [MobileFirstRunChecklist] will render anything right now. +/// +/// The mobile half of [desktopSetupSectionVisible], demo gate and all: its +/// steps describe the user's own machine, `mobileFirstRunStepsProvider` reaches +/// the keychain and `/account/agents` to answer them, its dismiss is a +/// permanent on-disk latch, and its closing action opens the very demo it would +/// be rendering inside. Hoisted so a host can skip the chrome it would wrap +/// around nothing, same as the desktop predicate. +bool mobileFirstRunChecklistVisible(WidgetRef ref) => + isMobilePlatform && + !ref.watch(demoModeProvider) && + ref.watch(firstRunChecklistVisibleProvider); + /// Stays on the canvas rather than following the desktop checklist into the /// drawer: on mobile that drawer is a slide-in behind a hamburger, and /// onboarding a user has to go looking for is not onboarding. +/// +/// Self-gates on [mobileFirstRunChecklistVisible], so the call site stays a +/// single stable line. class MobileFirstRunChecklist extends ConsumerWidget { const MobileFirstRunChecklist({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { + // Before the steps are watched, not after: answering them is what costs the + // account fetch this gate exists to keep out of the demo. + if (!mobileFirstRunChecklistVisible(ref)) return const SizedBox.shrink(); final steps = ref.watch(mobileFirstRunStepsProvider); _latchAndMaybeComplete(ref, steps); final t = context.antgrid; @@ -299,6 +331,14 @@ class MobileFirstRunChecklist extends ConsumerWidget { ), textAlign: TextAlign.center, ), + const SizedBox(height: AbTokens.space16), + // Every step above needs a desktop the user may not be near. This + // is the one thing they can do from the phone alone, so the app + // is never a dead end while the checklist is open. + AbButton( + label: kDemoEntryLabel, + onTap: () => enterDemoMode(ref.container), + ), ], ), ), diff --git a/app/lib/widgets/new_session/project_menu.dart b/app/lib/widgets/new_session/project_menu.dart index b10cbc8f..df4f4e55 100644 --- a/app/lib/widgets/new_session/project_menu.dart +++ b/app/lib/widgets/new_session/project_menu.dart @@ -7,6 +7,7 @@ import '../../design/ab_tokens.dart'; import '../../design/widgets/ab_chip.dart'; import '../../design/widgets/ab_menu.dart'; import '../../providers/control_plane.dart'; +import '../../providers/demo_mode.dart'; import '../../providers/new_session_picker.dart'; import '../../providers/now_ticker.dart'; import '../../util/relative_time.dart'; @@ -88,15 +89,20 @@ class ProjectPanel extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ PanelSectionHeader(source.label), - PanelRow( - icon: AbIcons.newFolder, - label: 'Open folder…', - selected: false, - onTap: () { - Navigator.of(context).pop(); - onOpenFolder(); - }, - ), + // Hidden in the demo: this runs the OS folder picker and upserts what + // it finds into ProjectStore, which registers a REAL project — and + // mints a host device uuid to hold it — from inside a sample project + // that promises nothing is connected. + if (!ref.watch(demoModeProvider)) + PanelRow( + icon: AbIcons.newFolder, + label: 'Open folder…', + selected: false, + onTap: () { + Navigator.of(context).pop(); + onOpenFolder(); + }, + ), if (source.projects.isEmpty) const PanelHint('No local projects yet') else diff --git a/app/lib/widgets/projects_drawer.dart b/app/lib/widgets/projects_drawer.dart index 54b863de..3d4d9bea 100644 --- a/app/lib/widgets/projects_drawer.dart +++ b/app/lib/widgets/projects_drawer.dart @@ -21,6 +21,7 @@ import '../models/drawer_entry.dart'; import '../models/session_target.dart'; import '../providers/account_agents.dart'; import '../providers/control_plane.dart'; +import '../providers/demo_mode.dart'; import '../providers/drawer_entries.dart'; import '../providers/drawer_expansion.dart'; import '../providers/drawer_order.dart'; @@ -89,7 +90,12 @@ class ProjectsDrawer extends ConsumerWidget { // restarts (see update_row.dart). The account footer is declared last // so it is the last BUDGETED slot to give up a pixel; only the // unbudgeted header outranks it. - pinned: const [UpdateRow(), _Footer()], + // Neither belongs to a machine-less demo: the footer's account row + // fetches the user, the subscription and the pricing catalogue, and + // the update row's only action leaves for the store. + pinned: ref.watch(demoModeProvider) + ? const [] + : const [UpdateRow(), _Footer()], ), ), ); @@ -201,7 +207,12 @@ class _GroupLabel extends ConsumerWidget { // inventory load (the only load-once FutureProvider); local projects and // QR-paired recents are store-reactive. Riverpod preserves the prior value // during the reload, so the list never blanks. - final refreshing = ref.watch(accountAgentsProvider).isLoading; + // Demo: the sample project refreshes from nothing, and the watch itself is + // what would fetch /account/agents. Neither the flag nor the button. + final demo = ref.watch(demoModeProvider); + final refreshing = demo + ? false + : ref.watch(accountAgentsProvider).isLoading; return Padding( padding: const EdgeInsets.fromLTRB( AbTokens.drawerGutter, @@ -232,14 +243,15 @@ class _GroupLabel extends ConsumerWidget { ), ), const Spacer(), - AbIconButton( - icon: AbIcons.refresh, - tone: AbIconButtonTone.muted, - tooltip: 'Refresh', - // Disabled while an inventory fetch is in flight so a double-tap - // can't stack redundant /account/agents requests. - onTap: refreshing ? null : () => refreshDrawer(ref), - ), + if (!demo) + AbIconButton( + icon: AbIcons.refresh, + tone: AbIconButtonTone.muted, + tooltip: 'Refresh', + // Disabled while an inventory fetch is in flight so a + // double-tap can't stack redundant /account/agents requests. + onTap: refreshing ? null : () => refreshDrawer(ref), + ), ], ), ), @@ -262,6 +274,9 @@ class _Body extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + // Demo: the gesture has nothing to pull from, and leaving it wired would + // put a spinner on a surface that must never reach the network. + if (ref.watch(demoModeProvider)) return _list(context, ref); // Pull-to-refresh wraps every branch (incl. the empty state) so the gesture // is available whether or not projects are listed. return RefreshIndicator( @@ -347,6 +362,9 @@ class _Body extends ConsumerWidget { /// reply lands. Shared by the pull gesture and the PROJECTS refresh button so /// the two affordances stay in lockstep. Future refreshDrawer(WidgetRef ref) async { + // The demo hides both affordances that call this; the guard is here so a + // third caller cannot reintroduce the inventory fetch by accident. + if (ref.read(demoModeProvider)) return; unawaited(_refreshFocusedSessions(ref)); await refreshMachineInventoryAndControlPlanes( RefreshRef.of(ref), diff --git a/app/lib/widgets/recent_sessions/recent_sessions_tab.dart b/app/lib/widgets/recent_sessions/recent_sessions_tab.dart index bfa36407..3623e589 100644 --- a/app/lib/widgets/recent_sessions/recent_sessions_tab.dart +++ b/app/lib/widgets/recent_sessions/recent_sessions_tab.dart @@ -2,22 +2,23 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../connection/supervisor_state.dart'; +import '../../demo/demo_identity.dart'; import '../../design/ab_colors.dart'; import '../../design/ab_status_tone.dart'; import '../../design/ab_tokens.dart'; +import '../../design/widgets/ab_button.dart'; import '../../design/widgets/ab_empty_state.dart'; import '../../design/widgets/ab_section_header.dart'; import '../../design/widgets/ab_separator.dart'; import '../../design/widgets/ab_status_dot.dart'; import '../../models/recent_session_row.dart'; -import '../../providers/first_run.dart'; +import '../../providers/demo_mode.dart'; import '../../providers/new_session_picker.dart'; import '../../providers/project_work_status.dart'; import '../../providers/recent_sessions.dart'; import '../../providers/supervisor_status.dart'; import '../../services/control_plane_client.dart'; import '../../util/detached.dart'; -import '../../utils/platform_utils.dart'; import '../ab_status_helpers.dart'; import '../first_run_checklist.dart'; import 'recent_session_row_widget.dart'; @@ -116,14 +117,18 @@ class _RecentSessionsTabState extends ConsumerState { // the first-run checklist replaces the generic empty state until it is // completed or dismissed — it stays through the later steps (Remote, // open a project) even once a machine exists in the inventory. - // `isMobilePlatform` first is load-bearing: desktop short-circuits - // before touching the first-run chain (its checklist lives on the New - // Session canvas instead). - final showChecklist = - isMobilePlatform && ref.watch(firstRunChecklistVisibleProvider); + // Its own predicate (desktop short-circuits before touching the first-run + // chain, and the demo before reaching the account) — never re-derived + // here, or the checklist and the chrome around it disagree. + final showChecklist = mobileFirstRunChecklistVisible(ref); // "Describe a task below" is a lie while nothing is picked — Send stays // disabled without a valid target — so name the actual next step. final hasTarget = ref.watch(newSessionHasValidTargetProvider); + // This list goes momentarily empty inside the demo too (before the + // fixture session:list lands, and after a mobile background evicts the + // warm demo), and offering the way in from inside is worse than offering + // nothing. + final offerDemo = !hasTarget && !ref.watch(demoModeProvider); // A scrollable empty state so the ancestor RefreshIndicator (in // new_session_content.dart) always has a gesture target: AbEmptyState // is a bare Center with no Scrollable of its own, so pull-to-refresh @@ -145,6 +150,14 @@ class _RecentSessionsTabState extends ConsumerState { subtitle: hasTarget ? 'Describe a task below to start your first session.' : 'Pick a project, then describe a task below.', + // Nothing picked means every path into a session is still + // dead — offer the one that needs no machine at all. + action: offerDemo + ? AbButton( + label: kDemoEntryLabel, + onTap: () => enterDemoMode(ref.container), + ) + : null, ), ), ], diff --git a/app/lib/widgets/session_row.dart b/app/lib/widgets/session_row.dart index 01a179b4..87932479 100644 --- a/app/lib/widgets/session_row.dart +++ b/app/lib/widgets/session_row.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../demo/demo_identity.dart'; import '../design/ab_colors.dart'; import '../design/ab_icons.dart'; import '../design/ab_status_tone.dart'; @@ -579,7 +580,14 @@ class _SessionMenu extends ConsumerWidget { // hosting it, so for a relay project the window would open somewhere the // user is not sitting. A failed probe degrades to no rows rather than a // menu that never opens. - final targets = ref.read(entryIsRelayProvider(entryId)) + // + // The demo is excluded for a second reason: both rows resolve their path + // over the loopback control plane (`openCheckoutIn`/`copyCheckoutPath` read + // `hostControlClientProvider`), which is an `ensureHost()` caller — and the + // sample project has no checkout for it to answer about anyway. Not + // `entryIsRelayProvider`'s job: the demo transport reports itself local. + final targets = + ref.read(entryIsRelayProvider(entryId)) || isDemoEntryId(entryId) ? const [] : await ref .read(externalOpenTargetsProvider.future) diff --git a/app/test/demo/demo_entry_points_test.dart b/app/test/demo/demo_entry_points_test.dart new file mode 100644 index 00000000..ee17ceef --- /dev/null +++ b/app/test/demo/demo_entry_points_test.dart @@ -0,0 +1,183 @@ +// The demo exists because a reviewer with no account, and a tester whose +// desktop isn't set up yet, both land on a screen where nothing works. These +// tests pin the three doors into it and the one way back out; a door that +// stops rendering is the whole rejection again. +import 'package:antgrid/demo/demo_identity.dart'; +import 'package:antgrid/design/theme_presets.dart'; +import 'package:antgrid/models/session_target.dart'; +import 'package:antgrid/providers/account_agents.dart'; +import 'package:antgrid/providers/agent_transport.dart'; +import 'package:antgrid/providers/auth.dart'; +import 'package:antgrid/providers/demo_mode.dart'; +import 'package:antgrid/providers/first_run.dart'; +import 'package:antgrid/providers/new_session_picker.dart'; +import 'package:antgrid/providers/recent_sessions.dart'; +import 'package:antgrid/screens/demo_home.dart'; +import 'package:antgrid/screens/sign_in_screen.dart'; +import 'package:antgrid/storage/first_run_store.dart'; +import 'package:antgrid/widgets/new_session/picker_sources.dart'; +import 'package:antgrid/widgets/recent_sessions/recent_sessions_tab.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart' show Override; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/demo_harness.dart'; +import '../helpers/prefs_test_mock.dart'; + +const String _kDemoLink = 'Explore a sample project'; +const String _kDemoBanner = + 'Demo — sample data, not a real machine. Nothing is connected.'; + +/// The empty Recent list, pumped on its own: the two affordances there sit +/// behind provider state the full app would have to be talked into, and this +/// is the same harness `first_run_checklist_test.dart` uses for the surface. +Widget _wrapRecent(List overrides) => ProviderScope( + overrides: overrides, + child: MaterialApp( + theme: ThemeData.dark().copyWith( + extensions: >[kDefaultPalette], + ), + home: const Scaffold(body: RecentSessionsTab()), + ), +); + +ProviderContainer _containerOf(WidgetTester tester) => + ProviderScope.containerOf(tester.element(find.byType(RecentSessionsTab))); + +// No `debugDefaultTargetPlatformOverride` anywhere below, unlike the other +// mobile-shaped suites: under FLUTTER_TEST `defaultTargetPlatform` already +// reports android, and setting the override trips the binding's end-of-body +// invariant unless every test restores it by hand. +void main() { + group('the sign-in screen', () { + testWidgets('offers the sample project with no account', (tester) async { + final container = await pumpDemoApp(tester); + + expect(find.byType(SignInScreen), findsOneWidget); + expect(find.text(_kDemoLink), findsOneWidget); + expect(container.read(demoModeProvider), isFalse); + }); + + testWidgets('tapping it opens the demo instead of the workspace', ( + tester, + ) async { + final container = await pumpDemoApp(tester); + await tester.ensureVisible(find.text(_kDemoLink)); + await tester.tap(find.text(_kDemoLink)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(container.read(demoModeProvider), isTrue); + expect(find.byType(DemoHome), findsOneWidget); + expect(find.byType(SignInScreen), findsNothing); + // The door the reviewer used must not have signed anybody in on the way. + expect(container.read(currentUserProvider).value, isNull); + expect( + container.read(selectedTargetProvider), + const LocalProject(kDemoProjectId), + ); + }); + }); + + group('the empty Recent list', () { + testWidgets('offers the sample project from the first-run checklist', ( + tester, + ) async { + useInMemoryPrefs(); + final store = await FirstRunStore.open(); + + await tester.pumpWidget( + _wrapRecent([ + firstRunStoreProvider.overrideWithValue(store), + recentSessionsProvider.overrideWith((_) => const []), + pickerSourcesProvider.overrideWithValue(const []), + accountAgentsProvider.overrideWith((_) async => const []), + ]), + ); + await tester.pump(); + + // Every other step on that checklist needs the desktop the tester + // doesn't have in front of them. + expect(find.text('Connect a machine'), findsOneWidget); + expect(find.text(_kDemoLink), findsOneWidget); + + final container = _containerOf(tester); + await tester.ensureVisible(find.text(_kDemoLink)); + await tester.tap(find.text(_kDemoLink)); + await tester.pump(); + + expect(container.read(demoModeProvider), isTrue); + }); + + testWidgets( + 'offers it from the plain empty state while nothing is picked', + (tester) async { + useInMemoryPrefs(); + + await tester.pumpWidget( + _wrapRecent([ + recentSessionsProvider.overrideWith((_) => const []), + firstRunChecklistVisibleProvider.overrideWithValue(false), + newSessionHasValidTargetProvider.overrideWithValue(false), + ]), + ); + await tester.pump(); + + expect(find.text('No recent sessions'), findsOneWidget); + expect(find.text(_kDemoLink), findsOneWidget); + + final container = _containerOf(tester); + await tester.ensureVisible(find.text(_kDemoLink)); + await tester.tap(find.text(_kDemoLink)); + await tester.pump(); + + expect(container.read(demoModeProvider), isTrue); + }, + ); + + testWidgets('withholds it once a real project is picked', (tester) async { + useInMemoryPrefs(); + + await tester.pumpWidget( + _wrapRecent([ + recentSessionsProvider.overrideWith((_) => const []), + firstRunChecklistVisibleProvider.overrideWithValue(false), + newSessionHasValidTargetProvider.overrideWithValue(true), + ]), + ); + await tester.pump(); + + expect(find.text('No recent sessions'), findsOneWidget); + expect(find.text(_kDemoLink), findsNothing); + }); + }); + + group('inside the demo', () { + testWidgets('the sample data is labelled on every route', (tester) async { + await pumpDemoApp(tester, enterDemo: true); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byType(DemoHome), findsOneWidget); + expect(find.text(_kDemoBanner), findsOneWidget); + expect(find.text('Exit demo'), findsOneWidget); + }); + + testWidgets('Exit demo hands the app back', (tester) async { + final container = await pumpDemoApp(tester, enterDemo: true); + await tester.pump(const Duration(milliseconds: 100)); + + await tester.tap(find.text('Exit demo')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(container.read(demoModeProvider), isFalse); + expect(find.byType(DemoHome), findsNothing); + expect(find.text(_kDemoBanner), findsNothing); + // Back to the reviewer's real starting point, with the door still open. + expect(find.byType(SignInScreen), findsOneWidget); + expect(find.text(_kDemoLink), findsOneWidget); + expect(container.read(selectedTargetProvider), isNull); + }); + }); +} diff --git a/app/test/demo/demo_fixture_contract_test.dart b/app/test/demo/demo_fixture_contract_test.dart new file mode 100644 index 00000000..67af129e --- /dev/null +++ b/app/test/demo/demo_fixture_contract_test.dart @@ -0,0 +1,346 @@ +// The demo's frames are hand-written maps, not values produced by a codec, so +// the one thing that can silently break it is a key the real parser doesn't +// recognise: `parseAbMessage` returns null, the router drops the frame, and the +// surface it fed just renders empty. Nothing else in the app would notice. +// +// This walks every frame the demo can ever emit — the static fixtures, the +// opening script, and the reply to every verb the transport handles — through +// the SAME classifier and parser the live wire goes through. +import 'package:antgrid/demo/demo_identity.dart'; +import 'package:antgrid/demo/demo_script.dart'; +import 'package:antgrid/demo/demo_transport.dart'; +import 'package:antgrid/demo/fixtures/demo_transcript_fixtures.dart'; +import 'package:antgrid/demo/fixtures/demo_workspace_fixtures.dart'; +import 'package:antgrid/models/ab_config.dart'; +import 'package:antgrid/models/ab_message.dart'; +import 'package:antgrid/models/session_entry.dart'; +import 'package:antgrid/project/project_message_classification.dart'; +import 'package:antgrid/services/config_service.dart'; +import 'package:antgrid_relay_client/antgrid_relay_client.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Frames `parseAbMessage` has no case for by design: `SessionsService`, +/// `ConfigService` and `UploadService` subscribe to the RAW status JSON and +/// decode these themselves, so a null from the parser is correct for them and +/// says nothing about whether the frame is well formed. +/// +/// An exemption here removes the ONLY automated check a demo frame gets, so +/// every type listed is re-checked against its real consumer below — that is +/// what makes the exemption safe rather than a hole. `session:updated` is the +/// one entry with no such test: the demo emits none (see the `session:start` +/// arm in `demo_transport.dart`), so there is nothing to decode. Emit one and +/// it needs a check here first. +const Set _rawConsumedTypes = { + 'session:list:result', + 'session:result', + 'session:updated', + 'config:read-result', + 'config:write-result', + 'config:detect-tools-result', + 'file:upload-result', +}; + +/// The two hurdles a frame clears to reach a reducer, in the order the app puts +/// them: `MessageRouter` tiers on the classifier, then the tier's subscriber +/// parses. Failing either drops the frame silently, which in the demo shows up +/// only as a surface that stays empty. +void expectRoutable(Map frame) { + final type = frame['type'] as String?; + final json = {'id': 'contract', 'timestamp': 0, ...frame}; + expect( + classifyAbMessage(json), + isNot(MessageTier.ignore), + reason: 'MessageRouter drops a demo frame of type "$type"', + ); + if (_rawConsumedTypes.contains(type)) return; + expect( + parseAbMessage(json), + isNotNull, + reason: 'parseAbMessage dropped a demo frame of type "$type"', + ); +} + +/// Drives [message] through a connected transport and returns everything it +/// published in reply. +Future>> replies(Map message) async { + final transport = DemoTransport(); + await transport.connect(); + final seen = []; + transport.messages.listen(seen.add); + // The opening script is drained BEFORE the clear, not after the send: + // `drainScript` flushes the WHOLE queue, and its six still-pending beats + // would otherwise land in `seen` beside the reply — making "this verb + // answered" true for every verb, including the ones that answer with + // nothing. + transport.drainScript(); + await Future.delayed(Duration.zero); + seen.clear(); + + await transport.send(message); + transport.drainScript(); + await Future.delayed(Duration.zero); + await transport.dispose(); + return seen.map((m) => m.json).toList(); +} + +void main() { + final now = DateTime(2026, 3, 4, 10, 30); + + test('the connect-time snapshot routes', () async { + final transport = DemoTransport(now: now); + addTearDown(transport.dispose); + await transport.connect(); + + expect(transport.snapshotCache, isNotEmpty); + for (final message in transport.snapshotCache) { + expectRoutable(message.json); + } + }); + + test('the opening script routes', () { + expect(kDemoScript, isNotEmpty); + for (final beat in kDemoScript) { + expectRoutable(beat.frame); + } + }); + + test('the standalone workspace fixtures route', () { + for (final frame in >[ + ...kDemoDurableFrames, + kDemoTerminalStarted, + kDemoTerminalSnapshot, + kDemoGitBranches, + kDemoPortsUpdate, + kDemoPreviewUrl, + kDemoPreviewSnapshot, + demoCapabilities(kDemoSessionCheckoutId), + ]) { + expectRoutable(frame); + } + }); + + test('every transcript frame routes', () { + final transcripts = demoTranscripts(now); + expect( + transcripts.keys, + containsAll([kDemoSessionCheckoutId, kDemoSessionCartId]), + ); + for (final frames in transcripts.values) { + expect(frames, isNotEmpty); + for (final frame in frames) { + expectRoutable(frame); + } + } + }); + + test('a canned reply turn routes', () { + final beats = demoPromptReplyBeats( + sessionId: kDemoSessionCheckoutId, + turnId: 'turn-1', + promptText: 'hello', + ); + expect(beats, isNotEmpty); + for (final beat in beats) { + expectRoutable(beat.frame); + } + }); + + // Every verb `_repliesFor` handles, so a reply built with a stale key fails + // here rather than leaving a spinner up in the demo. + test('every answered verb replies with routable frames', () async { + final verbs = >[ + {'type': 'session:list', 'requestId': 'q'}, + { + 'type': 'session:start', + 'requestId': 'q', + 'sessionId': kDemoSessionCheckoutId, + }, + { + 'type': 'session:stop', + 'requestId': 'q', + 'sessionId': kDemoSessionCheckoutId, + }, + {'type': 'session:create', 'requestId': 'q'}, + { + 'type': 'session:delete', + 'requestId': 'q', + 'sessionId': kDemoSessionCartId, + }, + { + 'type': 'session:rename', + 'requestId': 'q', + 'sessionId': kDemoSessionCartId, + }, + { + 'type': 'session:archive', + 'requestId': 'q', + 'sessionId': kDemoSessionCartId, + }, + { + 'type': 'session:unarchive', + 'requestId': 'q', + 'sessionId': kDemoSessionCartId, + }, + { + 'type': 'session:set-mode', + 'requestId': 'q', + 'sessionId': kDemoSessionCartId, + }, + {'type': 'file:read', 'path': kDemoFileContents.keys.first}, + {'type': 'file:read', 'path': 'not/in/the/sample.ts'}, + {'type': 'file:tree:snapshot:request'}, + {'type': 'preview:snapshot:request'}, + {'type': 'terminal:snapshot:request'}, + {'type': 'terminal:start'}, + {'type': 'terminal:input', 'terminalId': kDemoTerminalId, 'data': 'ls\r'}, + {'type': 'terminal:input', 'terminalId': 'demo-terminal-2', 'data': 'ls'}, + {'type': 'terminal:start', 'terminalId': 'demo-terminal-2'}, + {'type': 'terminal:snapshot:request', 'terminalId': 'demo-terminal-2'}, + { + 'type': 'agent:set-config', + 'sessionId': kDemoSessionCheckoutId, + 'key': 'mode', + 'value': 'auto', + }, + {'type': 'config:read'}, + {'type': 'config:write'}, + {'type': 'config:detect-tools'}, + {'type': 'git:diff', 'path': kDemoGitDiffContent.keys.first}, + {'type': 'git:list-branches'}, + {'type': 'git:checkout', 'branch': 'main'}, + {'type': 'git:commit', 'message': 'wip'}, + { + 'type': 'git:discard', + 'files': ['src/checkout.ts'], + }, + { + 'type': 'git:stage', + 'files': ['src/checkout.ts'], + }, + { + 'type': 'git:unstage', + 'files': ['src/checkout.ts'], + }, + {'type': 'file:search', 'requestId': 'q', 'query': 'quantity'}, + {'type': 'command:run', 'commandName': 'test'}, + {'type': 'file:upload-start', 'requestId': 'q'}, + { + 'type': 'agent:prompt', + 'sessionId': kDemoSessionCheckoutId, + 'text': 'hi', + }, + ]; + + for (final verb in verbs) { + final answered = await replies(verb); + expect( + answered, + isNotEmpty, + reason: 'the demo answered "${verb['type']}" with nothing', + ); + for (final frame in answered) { + expectRoutable(frame); + } + } + }); + + // The parser-less set, checked against its real consumer instead. + group('session status frames', () { + test('the list result decodes into SessionEntry rows', () { + final result = demoSessionsListResult( + requestId: 'q', + entries: demoSessionEntries(now), + ); + expect(result['type'], 'session:list:result'); + final rows = (result['sessions'] as List).cast>(); + expect(rows, isNotEmpty); + for (final row in rows) { + final entry = SessionEntry.fromJson(row); + expect(entry.id, isNotEmpty); + expect(entry.name, isNotEmpty); + // The Recent list orders on these; a zero would sort the demo to 1970. + expect(entry.createdAt, greaterThan(0)); + expect(entry.lastUsedAt, greaterThan(0)); + } + }); + + test('a refused mutation carries the reason the UI shows', () async { + final answered = await replies({ + 'type': 'session:create', + 'requestId': 'q', + }); + final result = answered.firstWhere((f) => f['type'] == 'session:result'); + expect(result['ok'], isFalse); + expect(result['error'], isA()); + expect(result['errorCode'], isNotEmpty); + }); + }); + + // The other half of the parser-less set. `ConfigService._handleReadResult` + // and friends cast rather than tolerate (`j['ok'] as bool`), so a key spelled + // wrong here is a throw inside the status subscription, not an empty surface. + group('config status frames', () { + test('the read result decodes into the sample AbConfig', () async { + final answered = await replies({'type': 'config:read'}); + final result = answered.firstWhere( + (f) => f['type'] == 'config:read-result', + ); + expect(result['ok'], isTrue); + final cfg = AbConfig.fromJson(result['config'] as Map); + expect(cfg.name, kDemoDisplayName); + expect(cfg.agent?.tool, kDemoAgentTool); + // Both lists are also rendered from `agent:status`, one tab away from + // Project Settings — an empty one here is the two disagreeing. + expect(cfg.services, isNotEmpty); + expect(cfg.commands, isNotEmpty); + }); + + test( + 'the write refusal carries the reason under the key the UI reads', + () async { + final answered = await replies({'type': 'config:write'}); + final result = answered.firstWhere( + (f) => f['type'] == 'config:write-result', + ); + expect(result['ok'], isFalse); + // `errors`, plural — `_handleWriteResult` completes the caller with this + // list, and Save reports "no reason given" when it is empty. + expect((result['errors'] as List).cast(), isNotEmpty); + }, + ); + + test('the detect result decodes into DetectedTool rows', () async { + final answered = await replies({'type': 'config:detect-tools'}); + final result = answered.firstWhere( + (f) => f['type'] == 'config:detect-tools-result', + ); + final tools = (result['tools'] as List) + .map((e) => DetectedTool.fromJson(e as Map)) + .toList(); + // Empty is the honest answer — the demo probes no machine — but the key + // has to exist and hold a list, or the decode above throws. + expect(tools, isEmpty); + }); + }); + + test('the upload refusal names a code and a sentence', () async { + final answered = await replies({ + 'type': 'file:upload-start', + 'requestId': 'q', + }); + final result = answered.firstWhere( + (f) => f['type'] == 'file:upload-result', + ); + expect(result['ok'], isFalse); + // `UploadService._throwIfFailed` reads `error` as the CODE and `message` as + // the sentence, and `uploadErrorText` shows the code when the sentence is + // missing — which would put E_DEMO_UNSUPPORTED in front of the user. + expect(result['error'], kDemoRefusalCode); + expect(result['message'], isNotEmpty); + }); + + test('the sessions the list advertises are the ones with transcripts', () { + final listed = demoSessionEntries(now).map((s) => s['id']).toSet(); + expect(demoTranscripts(now).keys.toSet(), listed); + }); +} diff --git a/app/test/demo/demo_frame_test.dart b/app/test/demo/demo_frame_test.dart new file mode 100644 index 00000000..205af52d --- /dev/null +++ b/app/test/demo/demo_frame_test.dart @@ -0,0 +1,99 @@ +// DemoFrame is mounted from `MaterialApp.builder`, which makes everything it +// draws a SIBLING of the app's Navigator rather than a descendant. That is the +// whole point (the strip survives every pushed route) and it is also the trap: +// the Navigator owns the app's only Overlay, so any chrome the frame mounts is +// on its own. These run the frame on Windows, the one platform where it draws +// caption buttons — no other demo test overrides the platform, which is why a +// missing Overlay reached a real window instead of a red test. +import 'dart:async'; + +import 'package:antgrid/design/widgets/ab_window_controls.dart'; +import 'package:antgrid/navigation/root_navigator.dart'; +import 'package:antgrid/providers/demo_mode.dart'; +import 'package:antgrid/screens/sign_in_screen.dart'; +import 'package:antgrid/widgets/demo_frame.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/demo_harness.dart'; + +/// Runs [body] as a Windows desktop window. +/// +/// The platform override is undone in a `finally` rather than an `addTearDown`: +/// the binding asserts every foundation debug variable is back to its default +/// at the END OF THE TEST BODY, which is before tear-downs run. +Future _onWindowsDesktop( + WidgetTester tester, + Future Function() body, +) async { + try { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + // The real surface, not just MediaQuery: the frame's chrome lays out + // against the view, so a desktop MediaQuery over the default 800x600 view + // overflows the shell and buries the assertion we care about. + tester.view.physicalSize = const Size(1400, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await body(); + } finally { + debugDefaultTargetPlatformOverride = null; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('the demo window chrome on Windows', () { + testWidgets('builds its caption buttons without an error widget', ( + tester, + ) async { + await _onWindowsDesktop(tester, () async { + await pumpDemoApp(tester, enterDemo: true, size: const Size(1400, 900)); + await tester.pump(const Duration(milliseconds: 100)); + + expect(tester.takeException(), isNull); + expect(find.byType(ErrorWidget), findsNothing); + expect(find.byType(DemoFrame), findsOneWidget); + expect(find.byType(AbWindowControls), findsOneWidget); + }); + }); + }); + + group('a route pushed inside the demo', () { + testWidgets('does not outlive Exit demo', (tester) async { + final container = await pumpDemoApp(tester, enterDemo: true); + await tester.pump(const Duration(milliseconds: 100)); + + // Pushed straight onto the app's Navigator rather than through a real + // affordance: the demo's only modal is behind a Ctrl/Cmd-K binding no + // touch device can reach, and what is under test is the route stack, not + // whatever happens to fill it. + final navigator = container.read(rootNavigatorKeyProvider).currentState!; + unawaited( + navigator.push( + MaterialPageRoute(builder: (_) => const Text('demo modal')), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.text('demo modal'), findsOneWidget); + expect(navigator.canPop(), isTrue); + + exitDemoMode(container); + + // Unwinding the stack is synchronous; only the exit transition is + // animated. Asserted before any pump for exactly that reason — it is the + // invariant, and the frames below are just the modal finishing leaving. + expect(navigator.canPop(), isFalse); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + + // Reparenting alone would have carried it across — `WidgetsApp` keys its + // Navigator with a GlobalKey — leaving a demo modal over the real app. + expect(find.text('demo modal'), findsNothing); + expect(find.byType(SignInScreen), findsOneWidget); + }); + }); +} diff --git a/app/test/demo/demo_isolation_test.dart b/app/test/demo/demo_isolation_test.dart new file mode 100644 index 00000000..411e5296 --- /dev/null +++ b/app/test/demo/demo_isolation_test.dart @@ -0,0 +1,437 @@ +// The demo's one hard promise is that nothing it shows or does outlives it: +// no cached sessions, no persisted layout, no status file, no telemetry. Each +// of those is a separate store with its own write path, so each gets its own +// assertion here — a single missed guard is a sample project the user finds +// sitting in their drawer after a relaunch. +import 'package:antgrid/analytics/analytics_service.dart'; +import 'package:antgrid/demo/demo_identity.dart'; +import 'package:antgrid/demo/fixtures/demo_workspace_fixtures.dart'; +import 'package:antgrid/main.dart' show telemetryAllowed; +import 'package:antgrid/models/preferences_models.dart'; +import 'package:antgrid/models/git_branch.dart'; +import 'package:antgrid/models/session_entry.dart'; +import 'package:antgrid/project/project_session.dart'; +import 'package:antgrid/project/project_session_registry.dart'; +import 'package:antgrid/providers/control_plane.dart'; +import 'package:antgrid/providers/demo_mode.dart'; +import 'package:antgrid/providers/focused_tools.dart'; +import 'package:antgrid/providers/analytics.dart'; +import 'package:antgrid/providers/drawer_entries.dart'; +import 'package:antgrid/providers/cached_sessions.dart'; +import 'package:antgrid/providers/recent_sessions.dart'; +import 'package:antgrid/providers/new_session_action.dart'; +import 'package:antgrid/providers/new_session_picker.dart'; +import 'package:antgrid/providers/providers.dart'; +import 'package:antgrid/services/app_settings_service.dart'; +import 'package:antgrid/storage/cached_sessions_store.dart'; +import 'package:antgrid/storage/drawer_collapsed_store.dart'; +import 'package:antgrid/storage/project_store.dart'; +import 'package:antgrid/storage/recent_ports_store.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import '../helpers/demo_harness.dart'; +import '../helpers/prefs_test_mock.dart'; + +SessionEntry _entry(String id) => SessionEntry( + id: id, + name: 'sample', + createdAt: 1, + lastUsedAt: 2, + archived: false, + running: true, +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('the session cache', () { + test('refuses the demo on all three write paths', () async { + useInMemoryPrefs(); + final store = await CachedSessionsStore.open(); + addTearDown(store.close); + + await store.put(kDemoProjectId, [_entry('s1')]); + store.putLabel(kDemoProjectId, kDemoDisplayName); + store.putStatus(kDemoProjectId, 'working'); + + expect(store.has(kDemoProjectId), isFalse); + expect(store.get(kDemoProjectId), isEmpty); + expect(store.label(kDemoProjectId), isNull); + expect(store.statusOf(kDemoProjectId), isNull); + expect(store.entries(), isEmpty); + }); + + test('still accepts a real project', () async { + useInMemoryPrefs(); + final store = await CachedSessionsStore.open(); + addTearDown(store.close); + + await store.put('real-project', [_entry('s1')]); + store.putLabel('real-project', 'real'); + + expect(store.has('real-project'), isTrue); + expect(store.label('real-project'), 'real'); + }); + }); + + group('the Recent list', () { + // The cache guards above are all WRITE-side: they keep the demo out of the + // store. Nothing stopped the demo READING it back, and Recent is the one + // demo surface that renders the whole store rather than the focused + // project — so a tester with real history saw their own machines listed + // under a banner promising nothing was connected. + test('drops the real machines the cache is full of', () async { + final container = await demoContainer(); + final store = container.read(cachedSessionsStoreProvider); + await store.put('real-project', [_entry('s1')]); + + // The same read is the user's real Recent list, so the gate below has to + // be the demo flag and not an empty fixture. + expect( + container.read(recentSessionsProvider).map((r) => r.origin.projectId), + contains('real-project'), + ); + + enterDemoMode(container); + + expect(container.read(recentSessionsProvider), isEmpty); + }); + + // The demo's own rows come from the LIVE session state, never the cache, + // so nothing in the store can name their project. Without the sample + // project standing in as the local match, every row falls through to + // `buildRecentSessions`' unmatched-key branch and wears the raw id. + testWidgets('names the sample project rather than its raw id', ( + tester, + ) async { + final container = await pumpDemoApp(tester, enterDemo: true); + await tester.pump(const Duration(milliseconds: 100)); + + final rows = container.read(recentSessionsProvider); + expect(rows, isNotEmpty); + expect(rows.map((r) => r.origin.projectName).toSet(), {kDemoDisplayName}); + }); + }); + + group('recent ports', () { + test('the demo dev server is never remembered', () async { + useInMemoryPrefs(); + final store = await RecentPortsStore.open(); + addTearDown(store.close); + + await store.add(kDemoProjectId, 5173, 'http'); + + expect(store.list(kDemoProjectId), isEmpty); + }); + + test('a real project still remembers its ports', () async { + useInMemoryPrefs(); + final store = await RecentPortsStore.open(); + addTearDown(store.close); + + await store.add('real-project', 5173, 'http'); + + expect(store.list('real-project'), hasLength(1)); + }); + }); + + test('project preferences resolve to defaults, off disk', () async { + final container = await demoContainer(); + enterDemoMode(container); + + // PreferencesService keys one file by projectId — the demo must never be + // the id that file is written under. Read through a live subscription: + // `container.read(...future)` closes its own subscription before the + // stream's first microtask, so it never settles. + final sub = container.listen( + projectPreferencesProvider, + (_, _) {}, + fireImmediately: true, + ); + addTearDown(sub.close); + await Future.delayed(Duration.zero); + + final prefs = sub.read().value; + expect(prefs, isNotNull); + // Defaults, not a file: every field is what the constructor gives. + expect(prefs!.splitRatio, const ProjectPreferences().splitRatio); + expect(prefs.selectedFilePath, isNull); + expect(prefs.expandedPaths, isEmpty); + expect(prefs.panelMode, isNull); + }); + + test('the eviction snapshot writes no status file for the demo', () async { + final container = await demoContainer(); + enterDemoMode(container); + // Warm the session so `snapshotAndInvalidateOnEvict` has something to + // snapshot; without it the write is skipped for an uninteresting reason. + final session = await container.read( + projectSessionProvider(kDemoProjectId).future, + ); + expect(session, isA()); + + final cache = container.read(projectStatusCacheProvider); + await container + .read(projectSessionRegistryProvider.notifier) + .forceEvictAndSettle(kDemoProjectId); + + expect(await cache.read(kDemoProjectId), isNull); + }); + + test('the session carries no analytics sink', () async { + final container = await demoContainer( + extraOverrides: [ + analyticsServiceProvider.overrideWithValue(_RecordingAnalytics()), + ], + ); + enterDemoMode(container); + + final demo = await container.read( + projectSessionProvider(kDemoProjectId).future, + ); + + expect(demo.analytics, isNull); + }); + + test('telemetry is off while the demo is on, whatever the setting', () async { + final container = await demoContainer(); + // The default, and the case that matters: an opted-in user who opens the + // sample project must still send nothing about it. + expect(container.read(appSettingsServiceProvider).telemetryEnabled, isTrue); + expect(telemetryAllowed(container), isTrue); + + enterDemoMode(container); + expect(telemetryAllowed(container), isFalse); + + exitDemoMode(container); + expect(telemetryAllowed(container), isTrue); + }); + + test('the drawer is the sample project and nothing else', () async { + final container = await demoContainer(); + enterDemoMode(container); + await container.read(projectSessionProvider(kDemoProjectId).future); + + // Not merely "the demo appears": the demo must be the WHOLE list. Every + // other source the merge reads costs a keychain read or a relay dial, and + // an empty list here is what made the drawer header say "PROJECTS · 0" + // over a workspace that was plainly showing one. + expect(container.read(drawerEntriesProvider).map((e) => e.id), [ + kDemoProjectId, + ]); + }); + + test('the demo is not in the drawer once it is left', () async { + final container = await demoContainer(); + enterDemoMode(container); + exitDemoMode(container); + + expect( + container.read(drawerEntriesProvider).map((e) => e.id), + isNot(contains(kDemoProjectId)), + ); + }); + + test('the file tree writes nothing into the last real project', () async { + final container = await demoContainer(); + // The state that makes this dangerous: PreferencesService is still pointed + // at a REAL project, because nothing rebinds it for the demo. A binding + // made here would debounce-write the sample project's expanded paths and + // selection into that project's preferences.json — and the binding's own + // `projectId == null` guard would not catch it, since the id is non-null + // and simply belongs to someone else. + final prefsService = container.read(preferencesServiceProvider); + await prefsService.load('real-project'); + expect(prefsService.projectId, 'real-project'); + + final written = []; + final prefsSub = prefsService.stream.listen(written.add); + addTearDown(prefsSub.cancel); + + enterDemoMode(container); + final session = await container.read( + projectSessionProvider(kDemoProjectId).future, + ); + // The binding is anchored in fileTreeStateProvider, so it is the act of + // watching the tree that would create it. + final treeSub = container.listen( + fileTreeStateProvider, + (_, _) {}, + fireImmediately: true, + ); + addTearDown(treeSub.close); + await Future.delayed(Duration.zero); + + // Exactly what a reviewer poking around the sample project does, and the + // only thing that makes the write observable: an untouched tree emits a + // state equal to the defaults, which `update` drops on its own. + session.fileService + ..toggleExpanded('src') + ..selectFile(kDemoFileContents.keys.first); + await Future.delayed(Duration.zero); + + expect(written, isEmpty); + expect(prefsService.current, const ProjectPreferences()); + }); + + test('the New Session picker offers only the sample project', () async { + final container = await demoContainer(); + enterDemoMode(container); + + final sources = container.read(pickerSourcesProvider); + expect(sources.map((s) => s.id), ['local']); + expect(sources.single.projects.map((p) => p.id), [kDemoProjectId]); + }); + + test('the drawer and the picker name the sample project the same', () async { + final container = await demoContainer(); + enterDemoMode(container); + + // Two surfaces, two independent sources; a demo that named itself + // differently in each would read as two projects. + expect( + container.read(drawerEntriesProvider).single.displayName, + container.read(pickerSourcesProvider).single.projects.single.name, + ); + }); + + test('the project list refuses the demo', () async { + useInMemoryPrefs(); + final store = await ProjectStore.open(); + + // What opening the sample project's drawer row does: activation records an + // open, and an open is an upsert. + await store.upsert(demoProject()); + + expect(store.list(), isEmpty); + }); + + test('the collapsed-drawer set keeps real ids and drops the demo', () async { + useInMemoryPrefs(); + final store = await DrawerCollapsedStore.open(); + + // One tap on the sample project's row is enough to put its id in here, and + // nothing prunes the set afterwards. + await store.write({'real-project', kDemoProjectId}); + + expect(store.read(), {'real-project'}); + }); + + test('the branch catalog answers from fixtures, never from a host', () async { + final container = await demoContainer(); + enterDemoMode(container); + + // The New Session canvas resolves branches for whatever the picker has + // selected. Without the demo gate the local arm calls + // `HostController.ensureHost()`, which SPAWNS the real bridge. + container + .read(selectedTargetProjectProvider.notifier) + .set(container.read(pickerSourcesProvider).single.projects.single); + + final catalog = await container.read( + newSessionBranchCatalogProvider.future, + ); + + expect(catalog?.branches, kDemoBranches); + expect(catalog?.current, kDemoBranch); + }); + + test('a paused flush holds the user\u0027s own queued events', () async { + final posted = []; + var demo = false; + final service = AnalyticsService( + client: MockClient((req) async { + posted.add(req.url.path); + return http.Response('', 202); + }), + plausibleUrl: 'https://plausible.test', + plausibleDomain: 'antgrid.test', + eventsApiUrl: 'https://events.test', + installId: 'install-1', + platform: 'test', + appVersion: '0.0.0', + // Composed exactly as main() composes it, so the demo really does stop + // `track` from enqueuing. + enabled: () => !demo, + paused: () => demo, + ); + + service.track('real_event'); + demo = true; + // The pause-lifecycle flush the demo's own entry fires. + await service.flush(); + expect(posted.where((p) => p.endsWith('/events')), isEmpty); + + demo = false; + await service.flush(); + expect(posted.where((p) => p.endsWith('/events')), hasLength(1)); + }); + + test('nothing the demo opens asks for the bridge host', () async { + // Value-blind on purpose. Every provider below swallows a host failure and + // answers with the same empty/null it answers with under its demo gate, so + // the returned value cannot tell a guard from a spawn — only whether the + // controller was ever read can. That makes this the one assertion that + // still fails if someone deletes a gate. + var askedForHost = false; + final container = await demoContainer( + extraOverrides: [ + hostControllerProvider.overrideWith((ref) { + askedForHost = true; + throw StateError('the demo reached HostController'); + }), + ], + ); + enterDemoMode(container); + // The workspace half needs nothing else — `enterDemoMode` focuses the + // sample project, and a LocalProject is what sends every one of these down + // their local arm. The New Session half needs a draft target, which the + // user supplies by picking the sample project in the composer. + final project = container + .read(pickerSourcesProvider) + .single + .projects + .single; + container.read(selectedTargetProjectProvider.notifier).set(project); + + await container.read(focusedMachineToolsProvider.future); + await container.read(newSessionDetectedToolsProvider.future); + await container.read(newSessionChatCapableToolsProvider.future); + await container.read(newSessionBranchCatalogProvider.future); + await container.read( + newSessionBranchRemoteStatusProvider(( + targetId: project.id, + branch: kDemoBranch, + )).future, + ); + + // Pressing Start with a branch picked. Not one of the providers above: the + // checkout that arms `ensureHost()` here is step 0 of the ACTION, and the + // demo populates the branch menu, so choosing one of its branches is + // ordinary demo navigation rather than a path only a real project reaches. + container + .read(newSessionBranchSelectionProvider.notifier) + .set( + NewSessionBranchSelection(targetId: project.id, branch: kDemoBranch), + ); + try { + await startNewSession(container); + } catch (_) { + // The create step refuses, which is the demo behaving correctly — this + // test is only about whether the host was reached on the way there. + } + + expect(askedForHost, isFalse); + }); +} + +/// Fails the test if the demo ever reaches a sink. Deliberately not a spy with +/// assertions after the fact: a track() here is already the bug. +class _RecordingAnalytics implements AnalyticsService { + @override + dynamic noSuchMethod(Invocation invocation) { + fail('the demo reached AnalyticsService.${invocation.memberName}'); + } +} diff --git a/app/test/demo/demo_mode_gate_test.dart b/app/test/demo/demo_mode_gate_test.dart new file mode 100644 index 00000000..1de4ded2 --- /dev/null +++ b/app/test/demo/demo_mode_gate_test.dart @@ -0,0 +1,115 @@ +// The demo flag is the sample project's whole lifetime: it selects the +// project, it decides whether the transport family hands out a DemoTransport, +// and flipping it off is what disposes that transport. These tests pin that +// loop, because a stale demo transport left warm is indistinguishable on +// screen from a real machine. +import 'package:antgrid/demo/demo_identity.dart'; +import 'package:antgrid/demo/demo_transport.dart'; +import 'package:antgrid/models/session_target.dart'; +import 'package:antgrid/providers/agent_transport.dart'; +import 'package:antgrid/providers/demo_mode.dart'; +import 'package:antgrid_relay_client/antgrid_relay_client.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/demo_harness.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('the demo is off until something enters it', () async { + final container = await demoContainer(); + + expect(container.read(demoModeProvider), isFalse); + expect(container.read(selectedTargetProvider), isNull); + expect(await demoTransportFrom(container), isNull); + }); + + test('entering selects the sample project and flips the flag', () async { + final container = await demoContainer(); + + enterDemoMode(container); + + expect(container.read(demoModeProvider), isTrue); + expect( + container.read(selectedTargetProvider), + const LocalProject(kDemoProjectId), + ); + expect(container.read(selectedRegistrationIdProvider), kDemoProjectId); + }); + + // No AbProject is registered here, so the relay and local branches below the + // demo gate would both resolve to null. A DemoTransport coming back is proof + // the gate sits above them — the branches that read the keychain. + test('the sample project resolves to a DemoTransport', () async { + final container = await demoContainer(); + enterDemoMode(container); + + final transport = await demoTransportFrom(container); + + expect(transport, isA()); + expect(transport!.isLocal, isTrue); + expect(transport.currentState, TransportState.connected); + }); + + test('leaving disposes the transport and clears the focus', () async { + final container = await demoContainer(); + // Kept alive across the flag flip so the family entry actually rebuilds + // rather than sitting on a value nobody is watching. + container.listen( + agentTransportForProvider(kDemoProjectId), + (_, _) {}, + fireImmediately: true, + ); + enterDemoMode(container); + final transport = await demoTransportFrom(container); + expect(transport, isA()); + + exitDemoMode(container); + // The dispose is fired through `unawaited`, so let it land. + await Future.delayed(Duration.zero); + + expect(container.read(demoModeProvider), isFalse); + expect(container.read(selectedTargetProvider), isNull); + expect((transport! as DemoTransport).outbound.isClosed, isTrue); + expect(await demoTransportFrom(container), isNull); + }); + + test('re-entering builds a fresh transport, not the disposed one', () async { + final container = await demoContainer(); + container.listen( + agentTransportForProvider(kDemoProjectId), + (_, _) {}, + fireImmediately: true, + ); + + enterDemoMode(container); + final first = await demoTransportFrom(container); + exitDemoMode(container); + await Future.delayed(Duration.zero); + enterDemoMode(container); + final second = await demoTransportFrom(container); + + expect(second, isA()); + expect(identical(first, second), isFalse); + expect(second!.currentState, TransportState.connected); + }); + + test('the demo id is the only one the gate answers for', () async { + final container = await demoContainer(); + enterDemoMode(container); + + // Same shape as a real local project id, and no AbProject backs it. + final other = await container.read( + agentTransportForProvider('some-other-project').future, + ); + + expect(other, isNull); + }); + + test('isDemoEntryId also matches a relay-scoped key', () { + expect(isDemoEntryId(kDemoProjectId), isTrue); + expect(isDemoEntryId('machine-uuid.$kDemoProjectId'), isTrue); + expect(isDemoEntryId('antgrid-demo-but-not'), isFalse); + expect(isDemoEntryId(null), isFalse); + }); +} diff --git a/app/test/demo/demo_transport_test.dart b/app/test/demo/demo_transport_test.dart new file mode 100644 index 00000000..8ebfff11 --- /dev/null +++ b/app/test/demo/demo_transport_test.dart @@ -0,0 +1,369 @@ +// The demo transport is the whole demo: every surface a reviewer sees is a +// real widget reducing frames this class hands it. These tests pin the two +// properties that make it safe to ship — it answers everything it is asked, +// and it holds nothing open once disposed. +import 'package:antgrid/demo/demo_identity.dart'; +import 'package:antgrid/demo/demo_transport.dart'; +import 'package:antgrid/demo/fixtures/demo_workspace_fixtures.dart'; +import 'package:antgrid_relay_client/antgrid_relay_client.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Subscribes [into] to everything published on [DemoTransport.messages], +/// snapshot replay included (the stream replays `snapshotCache` to each new +/// subscriber). +void _collect(DemoTransport transport, List into) { + transport.messages.listen(into.add); +} + +Iterable _types(List messages) => + messages.map((m) => m.json['type'] as String? ?? ''); + +void main() { + test('connect replays the snapshot and reports connected', () async { + final transport = DemoTransport(); + addTearDown(transport.dispose); + + await transport.connect(); + + expect(transport.currentState, TransportState.connected); + expect(transport.isEstablished, isTrue); + expect(transport.isLocal, isTrue); + expect(_types(transport.snapshotCache), contains('agent:status')); + expect(_types(transport.snapshotCache), contains('git:status')); + // The composer refuses to render a session it has no capabilities for. + expect(_types(transport.snapshotCache), contains('agent:capabilities')); + }); + + test('every replayed frame carries an id and a timestamp', () async { + final transport = DemoTransport(); + addTearDown(transport.dispose); + + await transport.connect(); + + for (final message in transport.snapshotCache) { + expect(message.json['id'], isA()); + expect(message.json['timestamp'], isA()); + } + }); + + test('a late subscriber still receives the snapshot', () async { + final transport = DemoTransport(); + addTearDown(transport.dispose); + await transport.connect(); + + final seen = []; + _collect(transport, seen); + await Future.delayed(Duration.zero); + + expect(_types(seen), contains('agent:status')); + }); + + test( + 'drainScript plays the opening beats without waiting them out', + () async { + final transport = DemoTransport(); + addTearDown(transport.dispose); + await transport.connect(); + + final seen = []; + _collect(transport, seen); + await Future.delayed(Duration.zero); + seen.clear(); + + transport.drainScript(); + await Future.delayed(Duration.zero); + + expect(_types(seen), contains('terminal:output')); + expect(_types(seen), contains('ports:update')); + expect(_types(seen), contains('preview:url')); + }, + ); + + group('RPC', () { + test('state.snapshot returns the opening frames', () async { + final transport = DemoTransport(); + addTearDown(transport.dispose); + await transport.connect(); + + final result = await transport.request( + 'state.snapshot', + params: { + 'types': ['*'], + }, + ); + + final frames = (result['frames'] as List).cast>(); + expect(frames.map((f) => f['type']), contains('agent:status')); + }); + + test('session.transcriptSnapshot answers per session', () async { + final transport = DemoTransport(); + addTearDown(transport.dispose); + await transport.connect(); + + final result = await transport.request( + 'session.transcriptSnapshot', + params: {'sessionId': kDemoSessionCheckoutId}, + ); + + final frames = (result['frames'] as List).cast>(); + expect(frames, isNotEmpty); + expect( + frames.map((f) => f['sessionId']), + everyElement(kDemoSessionCheckoutId), + ); + }); + + test('an unknown session gets an empty transcript, never a hang', () async { + final transport = DemoTransport(); + addTearDown(transport.dispose); + await transport.connect(); + + final result = await transport.request( + 'session.transcriptSnapshot', + params: {'sessionId': 'nope'}, + ); + + expect(result['frames'], isEmpty); + }); + + // The one failure a demo cannot recover from is silence: the caller sits on + // a spinner until its own timeout and the surface never settles. + test('an unsupported method is refused rather than dropped', () async { + final transport = DemoTransport(); + addTearDown(transport.dispose); + await transport.connect(); + + await expectLater( + transport.request('host.restart'), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'E_DEMO_UNSUPPORTED', + ), + ), + ); + }); + }); + + group('messages', () { + test('session:list is answered with the canned rows', () async { + final transport = DemoTransport(); + addTearDown(transport.dispose); + await transport.connect(); + + final seen = []; + _collect(transport, seen); + await transport.send({ + 'type': 'session:list', + 'requestId': 'req-1', + 'checkoutId': 'main', + }); + transport.drainScript(); + await Future.delayed(Duration.zero); + + final result = seen.firstWhere( + (m) => m.json['type'] == 'session:list:result', + ); + expect(result.json['requestId'], 'req-1'); + final sessions = (result.json['sessions'] as List) + .cast>(); + expect( + sessions.map((s) => s['id']), + containsAll([kDemoSessionCheckoutId, kDemoSessionCartId]), + ); + }); + + test('a mutating session verb is refused, not ignored', () async { + final transport = DemoTransport(); + addTearDown(transport.dispose); + await transport.connect(); + + final seen = []; + _collect(transport, seen); + await transport.send({ + 'type': 'session:create', + 'requestId': 'req-2', + 'checkoutId': 'main', + }); + transport.drainScript(); + await Future.delayed(Duration.zero); + + final result = seen.firstWhere((m) => m.json['type'] == 'session:result'); + expect(result.json['requestId'], 'req-2'); + expect(result.json['ok'], isFalse); + expect(result.json['errorCode'], 'E_DEMO_UNSUPPORTED'); + }); + + test('file:read serves a sample file and refuses anything else', () async { + final transport = DemoTransport(); + addTearDown(transport.dispose); + await transport.connect(); + + final seen = []; + _collect(transport, seen); + final path = kDemoFileContents.keys.first; + await transport.send({'type': 'file:read', 'path': path}); + await transport.send({'type': 'file:read', 'path': 'etc/shadow'}); + transport.drainScript(); + await Future.delayed(Duration.zero); + + final contents = seen + .where((m) => m.json['type'] == 'file:content') + .toList(); + expect(contents, hasLength(2)); + expect(contents.first.json['content'], kDemoFileContents[path]); + // The refusal arrives as CONTENT in a successful frame, never as the + // envelope's `error`: an error-bearing frame is classified onto the + // status tier, which `FileService` does not handle `file:content` on, so + // an honest error would strand the viewer on its spinner. + expect(contents.last.json['error'], isNull); + expect(contents.last.json['content'], contains(kDemoRefusalText)); + }); + + test('file:search really searches the sample bodies', () async { + final transport = DemoTransport(); + addTearDown(transport.dispose); + await transport.connect(); + + final seen = []; + _collect(transport, seen); + await transport.send({ + 'type': 'file:search', + 'requestId': 'req-3', + 'query': 'quantity', + }); + transport.drainScript(); + await Future.delayed(Duration.zero); + + final done = seen.firstWhere((m) => m.json['type'] == 'file:search-done'); + expect(done.json['requestId'], 'req-3'); + expect(done.json['totalMatches'], greaterThan(0)); + }); + + test('a prompt streams a canned reply and closes its turn', () async { + final transport = DemoTransport(); + addTearDown(transport.dispose); + await transport.connect(); + + final seen = []; + _collect(transport, seen); + await transport.send({ + 'type': 'agent:prompt', + 'sessionId': kDemoSessionCheckoutId, + 'text': 'add a coupon field', + }); + transport.drainScript(); + await Future.delayed(Duration.zero); + + expect(_types(seen), contains('agent:turn-start')); + expect(_types(seen), contains('agent:item-delta')); + expect(_types(seen), contains('agent:turn-end')); + // The user's own text has to come back as an item, or the transcript + // shows a reply to nothing. + final userItem = seen.firstWhere( + (m) => + m.json['type'] == 'agent:item-added' && + ((m.json['item'] as Map)['role'] == 'user'), + ); + expect((userItem.json['item'] as Map)['text'], 'add a coupon field'); + }); + + test('cancel drops the queued deltas and ends the turn', () async { + final transport = DemoTransport(); + addTearDown(transport.dispose); + await transport.connect(); + + final seen = []; + _collect(transport, seen); + await transport.send({ + 'type': 'agent:prompt', + 'sessionId': kDemoSessionCheckoutId, + 'text': 'hello', + }); + await Future.delayed(Duration.zero); + final turnId = seen + .firstWhere((m) => m.json['type'] == 'agent:turn-start') + .json['turnId']; + seen.clear(); + + await transport.send({ + 'type': 'agent:cancel', + 'sessionId': kDemoSessionCheckoutId, + 'turnId': turnId, + }); + transport.drainScript(); + await Future.delayed(Duration.zero); + + expect(_types(seen), isNot(contains('agent:item-delta'))); + final end = seen.firstWhere((m) => m.json['type'] == 'agent:turn-end'); + expect(end.json['stopReason'], 'cancelled'); + }); + + test('terminal input echoes and says plainly that nothing ran', () async { + final transport = DemoTransport(); + addTearDown(transport.dispose); + await transport.connect(); + + final seen = []; + _collect(transport, seen); + await transport.send({ + 'type': 'terminal:input', + 'terminalId': kDemoTerminalId, + 'data': 'ls\r', + }); + transport.drainScript(); + await Future.delayed(Duration.zero); + + final output = seen.firstWhere( + (m) => m.json['type'] == 'terminal:output', + ); + expect(output.json['data'], contains('sample project')); + }); + }); + + group('dispose', () { + test('fails in-flight RPCs instead of leaving them pending', () async { + final transport = DemoTransport(); + await transport.connect(); + + // Never answered on its own: drainScript is the only thing that flushes + // the queued reply, and dispose happens first. The matcher is attached + // BEFORE dispose so the rejection is never momentarily unhandled. + final pending = expectLater( + transport.request('state.snapshot'), + throwsA( + isA().having((e) => e.code, 'code', 'E_DISPOSED'), + ), + ); + await transport.dispose(); + await pending; + }); + + test('drops the script and closes every controller', () async { + final transport = DemoTransport(); + await transport.connect(); + + await transport.dispose(); + + expect(transport.snapshotCache, isEmpty); + expect(transport.outbound.isClosed, isTrue); + expect(transport.stateController.isClosed, isTrue); + expect(transport.droppedFrameController.isClosed, isTrue); + // A second dispose is what an evicted-then-disposed project does; it must + // not throw on the already-closed controllers. + await transport.dispose(); + // Nothing queued can fire after teardown. + transport.drainScript(); + }); + + test('a send after dispose is a no-op', () async { + final transport = DemoTransport(); + await transport.connect(); + await transport.dispose(); + + await transport.send({'type': 'session:list', 'requestId': 'x'}); + }); + }); +} diff --git a/app/test/helpers/demo_harness.dart b/app/test/helpers/demo_harness.dart new file mode 100644 index 00000000..42f28101 --- /dev/null +++ b/app/test/helpers/demo_harness.dart @@ -0,0 +1,98 @@ +// Shared scaffolding for the demo-mode tests. +// +// Demo mode is deliberately reachable with no account, no keychain and no +// socket, so these helpers install ONLY the persistent-store overrides every +// widget test needs — nothing that stands in for the wire. The real +// [DemoTransport] runs in every test that reaches it, which is the point: a +// fixture that stops parsing must fail here rather than in review. +import 'package:antgrid/demo/demo_identity.dart'; +import 'package:antgrid/main.dart'; +import 'package:antgrid/providers/agent_transport.dart'; +import 'package:antgrid/providers/auth.dart'; +import 'package:antgrid/providers/demo_mode.dart'; +import 'package:antgrid/services/auth_service.dart'; +import 'package:antgrid/window/window_chrome.dart'; +import 'package:antgrid_relay_client/antgrid_relay_client.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +// Riverpod 3 keeps `Override` out of the main barrel. +import 'package:flutter_riverpod/misc.dart' show Override; +import 'package:flutter_test/flutter_test.dart'; + +import 'prefs_test_mock.dart'; +import 'test_store_overrides.dart'; + +/// A container wired like the app's root, for tests that assert on providers +/// rather than pixels. +Future demoContainer({ + List extraOverrides = const [], +}) async { + useInMemoryPrefs(); + final stores = await buildTestStoreOverrides(); + addTearDown(stores.close); + + final container = ProviderContainer( + overrides: [...stores.overrides, ...extraOverrides], + ); + addTearDown(container.dispose); + return container; +} + +/// Pumps the real [AbApp] — the whole builder chain, so `DemoFrame`'s strip is +/// under test too, not just the screen below it. +/// +/// [signedIn] defaults to false because that is the reviewer's case: no +/// account, straight to the sign-in screen. Pass `enterDemo: true` to skip +/// the affordance and start inside the sample project. +Future pumpDemoApp( + WidgetTester tester, { + bool signedIn = false, + bool enterDemo = false, + Size size = const Size(400, 800), + List extraOverrides = const [], +}) async { + useInMemoryPrefs(); + final stores = await buildTestStoreOverrides(); + addTearDown(stores.close); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...stores.overrides, + currentUserProvider.overrideWith( + (_) async => signedIn + ? CurrentUser( + userId: 'user-1', + email: 'dev@antgrid.local', + tier: 'pro', + ) + : null, + ), + hasStoredSessionProvider.overrideWith((_) async => signedIn), + // WorkspaceShell mounts WindowTitleBar directly on a desktop-sized + // window, and the real chrome talks to the platform channel. + windowChromeProvider.overrideWithValue(FakeWindowChrome()), + ...extraOverrides, + ], + child: MediaQuery( + data: MediaQueryData(size: size), + child: const AbApp(), + ), + ), + ); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(AbApp)), + ); + if (enterDemo) { + enterDemoMode(container); + await tester.pump(); + await tester.pump(); + } + return container; +} + +/// Resolves the sample project's transport the way the session factory does. +Future demoTransportFrom(ProviderContainer container) => + container.read(agentTransportForProvider(kDemoProjectId).future); diff --git a/app/test/screens/sign_in_screen_test.dart b/app/test/screens/sign_in_screen_test.dart index f10c6491..c72d8439 100644 --- a/app/test/screens/sign_in_screen_test.dart +++ b/app/test/screens/sign_in_screen_test.dart @@ -7,6 +7,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; +import 'package:antgrid/demo/demo_identity.dart'; import 'package:antgrid/design/theme_presets.dart'; import 'package:antgrid/design/widgets/ab_password_field.dart'; import 'package:antgrid/design/widgets/ab_text_field.dart'; @@ -172,7 +173,7 @@ Future _continueWith(WidgetTester tester, String email) async { } /// Drives the screen to step 2 through a remembered `password` hint and fills -/// the password field. Step 1's "Continue with a password" is the other way +/// the password field. Step 1's "Password" method cell is the other way /// in; it has its own tests. Future> _openPasswordStep( WidgetTester tester, { @@ -238,7 +239,7 @@ void main() { final storage = _GatedStorage(null)..gate.complete(); await tester.pumpWidget(_wrap(storage)); - await tester.tap(find.text('Continue with GitHub')); + await tester.tap(find.text('GitHub')); await tester.pump(); await tester.pump(); @@ -282,6 +283,39 @@ void main() { ); }); + testWidgets('every method is offered without asking the server', ( + tester, + ) async { + final paths = await _pumpScreen(tester); + + // The cells name their own method and ignore the hint, so all three are + // unconditional. Visibility that tracked the hint would flicker as the + // address is typed and would leak which addresses this device remembers. + expect(find.text('GitHub'), findsOneWidget); + expect(find.text('Google'), findsOneWidget); + expect(find.text('Password'), findsOneWidget); + expect(paths, isEmpty); + }); + + testWidgets('the offline demo is offered with its caveat attached', ( + tester, + ) async { + await _pumpScreen(tester); + + // Ungated on purpose: on mobile this screen is the whole app until an + // account exists, so for an App Store reviewer — or a tester whose + // desktop is somewhere else — this is the only affordance here that + // leads anywhere at all. + expect(find.text(kDemoEntryLabel), findsOneWidget); + expect( + find.text('No account needed. Sample data, nothing is connected.'), + findsOneWidget, + reason: + 'the caveat rides with the offer, not as an orphan line below ' + 'a button that has already been read', + ); + }); + testWidgets('with nothing remembered, Continue sends a magic link', ( tester, ) async { @@ -347,10 +381,10 @@ void main() { expect(find.text('Could not open the browser'), findsOneWidget); // Step 1 offers no link of its own, so this is the whole escape route: - // the password button ignores the hint, and the step it reaches is where + // the password cell ignores the hint, and the step it reaches is where // the link lives. Without it a provider hint would relaunch itself on // every Continue with no way out short of clearing the device's storage. - await tester.tap(find.text('Continue with a password')); + await tester.tap(find.text('Password')); await tester.pump(); expect(find.text('Enter your password'), findsOneWidget); @@ -377,7 +411,7 @@ void main() { await tester.enterText(find.byType(TextField), 'user@example.com'); await tester.pump(); - await tester.tap(find.text('Continue with a password')); + await tester.tap(find.text('Password')); await tester.pump(); expect(find.text('Enter your password'), findsOneWidget); @@ -405,7 +439,7 @@ void main() { ) async { await _pumpScreen(tester); - await tester.tap(find.text('Continue with a password')); + await tester.tap(find.text('Password')); await tester.pump(); expect(find.text('Enter a valid email'), findsOneWidget); @@ -435,7 +469,7 @@ void main() { await tester.enterText(find.byType(TextField), 'user@example.com'); await tester.pump(); - await tester.tap(find.text('Continue with a password')); + await tester.tap(find.text('Password')); await tester.pump(); await tester.enterText(find.byType(TextField), 'a-very-long-password'); await tester.pump(); @@ -466,7 +500,7 @@ void main() { await tester.enterText(find.byType(TextField), 'user@example.com'); await tester.pump(); - await tester.tap(find.text('Continue with a password')); + await tester.tap(find.text('Password')); await tester.pump(); await tester.enterText(find.byType(TextField), 'not-the-password'); await tester.pump(); @@ -503,7 +537,7 @@ void main() { await tester.enterText(find.byType(TextField), 'user@example.com'); await tester.pump(); - await tester.tap(find.text('Continue with GitHub')); + await tester.tap(find.text('GitHub')); await tester.pump(); await tester.pump(); @@ -524,7 +558,7 @@ void main() { await tester.enterText(find.byType(TextField), 'user@example.com'); await tester.pump(); - await tester.tap(find.text('Continue with GitHub')); + await tester.tap(find.text('GitHub')); await tester.pump(); await tester.pump(); @@ -785,7 +819,7 @@ void main() { await tester.enterText(find.byType(TextField), 'user@example.com'); await tester.pump(); - await tester.tap(find.text('Continue with a password')); + await tester.tap(find.text('Password')); await tester.pump(); expect( diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index 31e82a85..15dab6ac 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -75,6 +75,9 @@ void main() { expect(find.byType(SignInScreen), findsOneWidget); expect(find.byType(AppShell), findsNothing); expect(find.text('Continue without signing in'), findsNothing); + // The reviewer's only way in — this screen is the whole app on mobile + // until an account exists, so losing the link is a 2.1 rejection. + expect(find.text('Explore a sample project'), findsOneWidget); } finally { debugDefaultTargetPlatformOverride = null; } From 9b1ee152644fa344c7c69b0992c3b9c6ebcb01da Mon Sep 17 00:00:00 2001 From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:18:01 +0800 Subject: [PATCH 12/15] The workspace rail recedes over the transcript instead of sitting on it (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rail is a pinned popup: nothing dismisses it, so it hangs over the agent's transcript for as long as the context pane is closed. At full strength the whole time, that is a claim on attention it has not earned. It now rests translucent over a blur, flat, divided by an ordinary border, with its labels at the muted foreground, and comes back to a full popup on hover or keyboard focus. A touch platform never recedes: the resting state is only earned where the pointer that undoes it exists. The shell also holds the rail down for as long as the context pane is on screen, since the pane's own tab strip already lists the same five views. Closing the pane hands it back — but only if the shell was the one that took it away, so a rail the user shut by hand stays shut. Two things the rail was getting wrong on its own. It was laid out at a width reserved for the widest row it could ever hold (the Git row's whole-worktree +/-), which left it two thirds empty every other time; it now ends where its longest label does. And its trailing figures carry their own colour — the badge's border, the diffstat's green and red — so they could not recede by taking the muted foreground the way the labels do, which left them the loudest thing on a surface nobody had reached for. The Git +/- was also rendering a size smaller than the counts beside it, at AbDiffStat's default, which is set for the dense per-file badge in the changed-file tree. --- app/CLAUDE.md | 2 +- app/lib/design/widgets/ab_menu.dart | 117 ++++- app/lib/providers/visible_surface.dart | 33 +- app/lib/screens/workspace_shell.dart | 110 ++--- app/lib/widgets/workspace_menu_button.dart | 437 ++++++++++++++---- app/test/design/widgets/ab_menu_test.dart | 58 +++ .../screens/workspace_menu_docking_test.dart | 73 ++- .../workspace_shell_tablet_swipe_test.dart | 11 + .../widgets/workspace_menu_button_test.dart | 186 +++++++- 9 files changed, 842 insertions(+), 185 deletions(-) diff --git a/app/CLAUDE.md b/app/CLAUDE.md index 292aa8d3..2e1c1da1 100644 --- a/app/CLAUDE.md +++ b/app/CLAUDE.md @@ -16,7 +16,7 @@ app's relay layer lives outside this tree: see - `services/` — 7 per-project services (`FileService`, `SessionsService`, `TerminalService`, `ConfigService`, `SearchService`, `CommandService`, `PreviewService`), each `XxxService.fromSession(session)` owned by a `ProjectSession`. They subscribe to `session.heavyStream`/`statusStream` in their constructor (so welcome-cached messages are caught), send via `session.send(...)`, and have no app-wide singleton — `xServiceProvider` returns the focused project's instance. Local and relay flows are unified: `selectedProjectIdProvider` is the single focus id; `agentTransportForProvider(id)` picks relay vs local; `projectSessionProvider(id)` wires the session + 7 services. - `project/project_session.dart` — per-project aggregate (transport + MessageRouter + ProjectStatusNotifier + services). Lifetime via `ProjectSessionRegistry`, **no `autoDispose`**. - `project/project_session_registry.dart` — warm-projects LRU with per-bucket caps (`warmCapForBucket` in `limits.dart`): desktop = local `kWarmCapLocal=10` + relay `kWarmCapRelay=30` as SEPARATE quotas (no shared budget — opening a relay socket never evicts a local agent), mobile = `kWarmCapMobile=3` for both. `touch(id, {required isLocal})` buckets each project; eviction stays within the overflowing bucket (oldest by last-focus time, `selectEvictionVictim` in `lru_policy.dart`; the just-focused project is protected). `onEvict` writes final status to cache and invalidates session+transport providers. v3: evicting a relay project closes its stream binding, not a socket — the machine's `RelayConnection` is released only when nothing on that machine needs it (control-plane reaper in `control_plane.dart`). -- `screens/` — terminal, file explorer, preview, scanner (QR coordinate import — not a rendezvous). `workspace_shell.dart` = responsive layout, split at `kCompactBreakpoint` (mobile PageView swipe; desktop/tablet rail + resizable split — same layout for both). Signed-in user (email + tier pill) renders via `AccountFooter` in the drawer. `widgets/window_title_bar.dart` mounts above the route only for a non-touch (mouse) desktop at `>= kMediumBreakpoint` (`app_shell.dart` — `isMobilePlatform`, i.e. Android/iOS, skips it at ANY width, same rationale as `_defaultPanelMode` below) and owns the nav/chips/window-controls row plus the two pane toggles at its outer edges — the projects drawer on the left and the context panel on the right — which WorkspaceShell publishes through `sidebarControlProvider` / `contextPanelControlProvider` because the bar mounts above its route (hiding either pane takes its own affordances with it, so these controls are the only way back). `new_session_screen.dart`'s `NewSessionScreen` publishes its own `sidebarControlProvider` the same way (there is no context panel on that route, so it alone), backed by the same `sidebarHidden` app setting — the drawer's hidden/shown state and its title-bar toggle are shared across both routes, not WorkspaceShell-only. `_PanelMode.contextExpanded` renders no collapsed agent stub of its own (unlike `contextHidden`'s full-width agent panel, expanded has no equivalent affordance) — the title bar's context-panel toggle is the only way back from it, same as from `contextHidden`. Two DIFFERENT no-title-bar cases, two DIFFERENT recoveries, both in `WorkspaceShellState._buildDesktop`: a narrow non-touch desktop window (`kCompactBreakpoint..kMediumBreakpoint`) forces the drawer permanently visible (ignores `sidebarHidden`) and reveals the context panel by restoring `_PanelMode.normal`; a touch tablet (any width) routes to `_buildTabletTouch` instead, where BOTH the sidebar and the context panel are docked panes like the mouse desktop's — the sidebar open by default (matching the mouse desktop's own always-on rail), the context pane closed by default unlike `_PanelMode.normal`: a session opens on the agent alone plus the sidebar, and the context pane appears only once the user swipes or picks a view from its popup (the popup itself starts open regardless of pane state, so it needs no tap to reach) — both hand-rolled and ALWAYS-mounted, deliberately NOT real `Scaffold.drawer`/`endDrawer`s, since Flutter's `DrawerController` drops its child from the tree entirely while closed, which would dispose `WorkspacePanel`'s `PreviewScreen` WebView and terminal on every swipe-close of the context pane (the same class of regression the `_agentPanelKey`/`_contextPanelKey` GlobalKeys below exist to prevent). Neither pane's own width ever changes during its open/close animation (only an `AnimatedSlide` offset does, so neither is ever laid out at an intermediate width) while the agent pane's reserved space on both sides animates via ONE `AnimatedPadding` on the same duration/curve as both slides, so all three move in lockstep. Both cases' context-panel reveal funnels through `_openContextPanel`, so `WorkspaceMenuButton` (in `AgentBar`) and `revealHandlerTab` never need to know which one is live; on the tablet, that button shows the SAME popup as desktop regardless of pane state (it starts open independently of the pane) — a tap only ever shows/hides that popup, on every platform, never a side-effecting shortcut on the pane itself — see the button's own doc. `AgentBar` also grows a leading "Projects" button, touch platform only, opening the sidebar pane the same way, alongside the swipe (one raw-pointer fling dispatcher for all four pane actions, `_onTabletFlingDown`/`Up`, mirroring mobile's own fling-not-edge-drag drawer gesture — edge-anchored drags are not available to us, since Android's system back owns both edges) as the discoverable opener. **Only the sidebar answers to a fling, and only on its own half of the screen** (split at the agent pane's midpoint): the context pane takes no swipe in either direction — it opens from `WorkspaceMenuButton`'s popup and closes from its tab bar's close button, since a pane a swipe could also OPEN made every sideways drag over the agent a coin flip. A fling never reaches across the window either (the far-pane fallback that used to close the sidebar from the right edge is gone), so a gesture with nothing to do on its own side does nothing. **Nothing arbitrates with that gesture any more** — the git rows' swipe tray, the workspace tab strip and the code viewers all live in the context pane, whose half now moves no pane at all, so the whole claim-flag mechanism they fed (`util/swipe_row_arbitration.dart`) is deleted. The dispatcher still watches raw pointers, so it fires under a descendant that already won the arena: putting a swipeable widget beside the agent, or giving the context pane a fling again, brings that arbitration question back with it. `new_session_screen.dart`'s touch branch mirrors this same sidebar treatment (`_tabletSidebarOpen`) at any width `>= kCompactBreakpoint`, falling back to a real swiped-in `Scaffold.drawer` only at phone width, same as `WorkspaceShell`'s own three-way split. The session's breadcrumb/branch/agent-mark/mode/handler cluster belongs to `AgentBar` (`widgets/agent_panel.dart`), which mirrors `WorkspaceTabBar` across the divider; in the panel modes that mount no agent bar (`agentBarMountedProvider`), the title bar takes back only the mode control (`SessionModeControl`) — never the name (whose duplicate flashed a project id one row up before the name settled), and never the agent mark or handler shield, which the title bar carries no fallback for at all. The bar's elastic middle carries `SessionSearchField`; it is also the window drag target, so nothing may fill that gap edge to edge. +- `screens/` — terminal, file explorer, preview, scanner (QR coordinate import — not a rendezvous). `workspace_shell.dart` = responsive layout, split at `kCompactBreakpoint` (mobile PageView swipe; desktop/tablet rail + resizable split — same layout for both). Signed-in user (email + tier pill) renders via `AccountFooter` in the drawer. `widgets/window_title_bar.dart` mounts above the route only for a non-touch (mouse) desktop at `>= kMediumBreakpoint` (`app_shell.dart` — `isMobilePlatform`, i.e. Android/iOS, skips it at ANY width, same rationale as `_defaultPanelMode` below) and owns the nav/chips/window-controls row plus the two pane toggles at its outer edges — the projects drawer on the left and the context panel on the right — which WorkspaceShell publishes through `sidebarControlProvider` / `contextPanelControlProvider` because the bar mounts above its route (hiding either pane takes its own affordances with it, so these controls are the only way back). `new_session_screen.dart`'s `NewSessionScreen` publishes its own `sidebarControlProvider` the same way (there is no context panel on that route, so it alone), backed by the same `sidebarHidden` app setting — the drawer's hidden/shown state and its title-bar toggle are shared across both routes, not WorkspaceShell-only. `_PanelMode.contextExpanded` renders no collapsed agent stub of its own (unlike `contextHidden`'s full-width agent panel, expanded has no equivalent affordance) — the title bar's context-panel toggle is the only way back from it, same as from `contextHidden`. Two DIFFERENT no-title-bar cases, two DIFFERENT recoveries, both in `WorkspaceShellState._buildDesktop`: a narrow non-touch desktop window (`kCompactBreakpoint..kMediumBreakpoint`) forces the drawer permanently visible (ignores `sidebarHidden`) and reveals the context panel by restoring `_PanelMode.normal`; a touch tablet (any width) routes to `_buildTabletTouch` instead, where BOTH the sidebar and the context panel are docked panes like the mouse desktop's — the sidebar open by default (matching the mouse desktop's own always-on rail), the context pane closed by default unlike `_PanelMode.normal`: a session opens on the agent alone plus the sidebar, and the context pane appears only once the user picks a view from `WorkspaceMenuButton`'s popup (no swipe opens it — see below); the popup is up by default while no view is on screen, so it needs no tap to reach — both hand-rolled and ALWAYS-mounted, deliberately NOT real `Scaffold.drawer`/`endDrawer`s, since Flutter's `DrawerController` drops its child from the tree entirely while closed, which would dispose `WorkspacePanel`'s `PreviewScreen` WebView and terminal on every swipe-close of the context pane (the same class of regression the `_agentPanelKey`/`_contextPanelKey` GlobalKeys below exist to prevent). Neither pane's own width ever changes during its open/close animation (only an `AnimatedSlide` offset does, so neither is ever laid out at an intermediate width) while the agent pane's reserved space on both sides animates via ONE `AnimatedPadding` on the same duration/curve as both slides, so all three move in lockstep. Both cases' context-panel reveal funnels through `_openContextPanel`, so `WorkspaceMenuButton` (in `AgentBar`) and `revealHandlerTab` never need to know which one is live; on the tablet, that button shows the SAME popup as desktop regardless of pane state, and the shell keeps that popup DOWN for as long as any view is on screen (`_syncMenuToContextPane` — the pane's own `WorkspaceTabBar` already lists the same five views), restoring it when the pane closes only if it was the one that hid it — a tap only ever shows/hides that popup, on every platform, never a side-effecting shortcut on the pane itself — see the button's own doc. `AgentBar` also grows a leading "Projects" button, touch platform only, opening the sidebar pane the same way, alongside the swipe (one raw-pointer fling dispatcher for all four pane actions, `_onTabletFlingDown`/`Up`, mirroring mobile's own fling-not-edge-drag drawer gesture — edge-anchored drags are not available to us, since Android's system back owns both edges) as the discoverable opener. **Only the sidebar answers to a fling, and only on its own half of the screen** (split at the agent pane's midpoint): the context pane takes no swipe in either direction — it opens from `WorkspaceMenuButton`'s popup and closes from its tab bar's close button, since a pane a swipe could also OPEN made every sideways drag over the agent a coin flip. A fling never reaches across the window either (the far-pane fallback that used to close the sidebar from the right edge is gone), so a gesture with nothing to do on its own side does nothing. **Nothing arbitrates with that gesture any more** — the git rows' swipe tray, the workspace tab strip and the code viewers all live in the context pane, whose half now moves no pane at all, so the whole claim-flag mechanism they fed (`util/swipe_row_arbitration.dart`) is deleted. The dispatcher still watches raw pointers, so it fires under a descendant that already won the arena: putting a swipeable widget beside the agent, or giving the context pane a fling again, brings that arbitration question back with it. `new_session_screen.dart`'s touch branch mirrors this same sidebar treatment (`_tabletSidebarOpen`) at any width `>= kCompactBreakpoint`, falling back to a real swiped-in `Scaffold.drawer` only at phone width, same as `WorkspaceShell`'s own three-way split. The session's breadcrumb/branch/agent-mark/mode/handler cluster belongs to `AgentBar` (`widgets/agent_panel.dart`), which mirrors `WorkspaceTabBar` across the divider; in the panel modes that mount no agent bar (`agentBarMountedProvider`), the title bar takes back only the mode control (`SessionModeControl`) — never the name (whose duplicate flashed a project id one row up before the name settled), and never the agent mark or handler shield, which the title bar carries no fallback for at all. The bar's elastic middle carries `SessionSearchField`; it is also the window drag target, so nothing may fill that gap edge to edge. - **The session search is a POPUP, not a filter over anything on screen** (`widgets/session_search_field.dart` + `providers/session_search.dart`). It answers into its own `OverlayPortal` from any route, which is why it neither narrows the drawer (project rows the user is already looking at) nor the Recent list. Results come from `sessionSearchResultsProvider` over Recent's rows — the one flat view spanning machines and projects — which reads the persisted session cache and NEVER the wire: a keystroke must not dial a machine. The popup opens on FOCUS (so Ctrl+K alone reveals it, resting on the recent list) and closes on its barrier, on Escape-when-empty, or on a row being taken (`RecentSessionRowWidget.onOpened`); blur must NOT close it, because the rows are focusable. **Desktop and mobile are deliberately DIFFERENT surfaces, not one responsive widget** — and a touch tablet is mobile's surface at ANY width, and a narrow non-touch desktop window is too (`< kMediumBreakpoint`), because neither has a title bar to host a field in. Desktop is `SessionSearchField` — an always-open title-bar field whose popup measures its width and max height off the field (a pointer pattern). Everything else gets `SessionSearchButton` + `showSessionSearch()` (`widgets/session_search_modal.dart`) — an icon opening a full-screen `Dialog.fullscreen`, which is what both Material and iOS prescribe: a phone (or any title-bar-less window) has no row to spare for a permanent text box, and an anchored popup loses most of itself to the keyboard. On the New Session/Recent screen the button sits in `NewSessionContent`'s `_TopBar`; `new_session_screen.dart` routes any touch platform through the SAME branch as phone width (`isMobile || isMobilePlatform`) for the full hamburger+button bar, reserving the search-only variant (`showSearchButton`, no hamburger — the drawer there is a persistent pane, not a slide-in) for a narrow non-touch desktop window. Inside a session, `WorkspaceShellState._focusSessionSearch` opens the same modal for Ctrl/⌘-K whenever `isMobilePlatform` or the window is below `kMediumBreakpoint`. A dialog ROUTE, not an overlay, so system back closes it. Only the shared `SessionSearchResults` (`widgets/session_search_results.dart`) is common, so the surfaces can never answer differently. Mobile/tablet clear the query on close (nothing survives to show a stale one); desktop keeps it. - `models/` — Dart mirrors of agent protocol types. - `demo/` — the offline sample project reachable with no account (`kDemoEntryLabel`). `providers/demo_mode.dart`'s `demoModeProvider` is the single switch, in memory only; while it is on `agentTransportForProvider` hands back a `DemoTransport` — a real `AgentTransport` over canned wire frames, so the demo renders through the real `MessageRouter`, the real per-project services and the real widgets rather than a parallel set of fakes — and `screens/demo_home.dart` replaces `AppShell` as the root route (AppShell's `initState` dials the relay and reads the keychain). `demo/fixtures/` are RAW wire envelopes on purpose: a frame whose shape `parseAbMessage` rejects is dropped silently and only shows up as an empty surface, which is what `test/demo/demo_fixture_contract_test.dart` walks every one of them for. **The invariant is that nothing about the demo reaches disk, spawns a bridge host, or phones home, and no real state is written under it** — and it is held by scattered call-site gates, not by a boundary: every persistence store, host-spawn path, analytics sink and first-run latch has to check `demoModeProvider` (or `isDemoProjectId`/`isDemoEntryId` from `demo/demo_identity.dart`) for itself, so anything added later leaks by default. Prefer gating inside the STORE over gating at each caller, and pin the new gate in `test/demo/demo_isolation_test.dart`. The host-spawn class is the one that keeps recurring, because a `LocalProject` target is what arms it and the demo's target IS one: every `ensureHost()` caller reachable from the workspace or the New Session canvas needs its own gate, and each swallows its failure, so the value it returns cannot tell a gate from a spawn — the spy test there asserts the controller was never read. diff --git a/app/lib/design/widgets/ab_menu.dart b/app/lib/design/widgets/ab_menu.dart index 621b9c2b..3fcc581b 100644 --- a/app/lib/design/widgets/ab_menu.dart +++ b/app/lib/design/widgets/ab_menu.dart @@ -1,3 +1,5 @@ +import 'dart:ui' show ImageFilter, lerpDouble; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -154,26 +156,57 @@ class AbMenu extends StatelessWidget { } } +/// How far a fully receded popup lets its ground through — see +/// [AbPopupSurface.quiet]. Kept above the point where the transcript underneath +/// starts competing with the popup's own labels for the eye. +const double _quietSurfaceAlpha = 0.84; + +/// Blur applied behind a fully receded popup. Its job is to turn the text below +/// into ground rather than a second layer of figure; without it a translucent +/// popup over a live transcript is unreadable in both directions. +const double _quietBlurSigma = 10; + +/// Antgrid `--elev-overlay`'s shadow colour. Named so the fade below scales the +/// alpha this already carries rather than restating it — a second literal there +/// silently shifts the shadow of every popup in the app the first time the two +/// disagree. +const Color _popupShadowColor = Color(0xB3000000); + /// Shared popup chrome for [AbMenu] and [showAbPanel]: raised surface, /// strong border, contracted overlay shadow — one popup look across /// menus and live-widget panels. -BoxDecoration _popupDecoration(AbColors p) { +/// +/// [quiet] recedes that chrome for a popup the user has not reached for yet; +/// 0 is the full popup and every caller but the workspace rail passes it. +BoxDecoration _popupDecoration(AbColors p, {double quiet = 0}) { return BoxDecoration( - color: p.bgRaised, + color: p.bgRaised.withValues( + alpha: lerpDouble(1, _quietSurfaceAlpha, quiet), + ), borderRadius: AbTokens.borderRadius8, - border: Border.all(color: p.borderStrong), + // A receded popup drops to the ordinary 1px separator the rest of the app + // divides with, which is what stops it reading as a raised thing at rest. + border: Border.all( + color: Color.lerp(p.borderStrong, p.borderDefault, quiet)!, + ), // Antgrid `--elev-overlay`: a deep, contracted drop shadow // (negative spread = inset corners, lifted center) plus the // 1px borderStrong ring above. Without the negative spread the // shadow bleeds wide and reads "Material card" instead of "popup". - boxShadow: const [ - BoxShadow( - color: Color(0xB3000000), - blurRadius: 48, - spreadRadius: -12, - offset: Offset(0, 24), - ), - ], + // Dropped outright once fully receded rather than faded to transparent: a + // transparent BoxShadow still costs its blur pass every frame. + boxShadow: quiet >= 1 + ? null + : [ + BoxShadow( + color: _popupShadowColor.withValues( + alpha: _popupShadowColor.a * (1 - quiet), + ), + blurRadius: 48, + spreadRadius: -12, + offset: const Offset(0, 24), + ), + ], ); } @@ -184,20 +217,70 @@ BoxDecoration _popupDecoration(AbColors p) { /// menu you pick from and wrong for one the user pins open; those mount this in /// an [OverlayPortal] instead and still read as the same popup. class AbPopupSurface extends StatelessWidget { - const AbPopupSurface({super.key, required this.child, this.width = 280}); + const AbPopupSurface({ + super.key, + required this.child, + this.width = 280, + this.quiet = 0, + }); final Widget child; final double width; + /// How far the popup has receded from the reader, 0 (full popup: opaque, + /// strong border, lifted) to 1 (translucent over a blur, plain border, flat), + /// and any point between for an animated approach or withdrawal. + /// + /// For a PINNED popup only — one that stays up under content the user is + /// reading, where sitting at full strength the whole time would be a claim on + /// attention it hasn't earned. A popup that opens on demand is already the + /// thing being looked at and leaves this at 0. See `workspace_menu_button.dart`. + final double quiet; + @override Widget build(BuildContext context) { return Material( type: MaterialType.transparency, - child: Container( - constraints: BoxConstraints(maxWidth: width, minWidth: 0), - padding: const EdgeInsets.all(5), - decoration: _popupDecoration(context.antgrid), - child: child, + // The blur is a SIBLING painted behind the surface, never a wrapper + // around it, for two reasons a wrapper gets wrong. Its clip would eat the + // drop shadow, which `_popupDecoration` paints entirely outside the + // popup's own rect — invisible for every quiet above 0, then snapping in + // whole at 0. And a wrapper that comes and goes as quiet crosses 0 + // changes the tree's SHAPE mid-animation: `Widget.canUpdate` fails at + // that slot, so everything below — row state, focus nodes, icons — is + // discarded and re-inflated on the last frame of every reveal. + // + // `Clip.none` because the shadow is exactly the overflow a Stack would + // otherwise be entitled to clip. + child: Stack( + clipBehavior: Clip.none, + children: [ + if (quiet > 0) + Positioned.fill( + // The blur is what buys the translucency: see [_quietBlurSigma]. + // Clipped to the popup's own radius, or it blurs a rectangle out + // past the corners. + child: ClipRRect( + borderRadius: AbTokens.borderRadius8, + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: _quietBlurSigma * quiet, + sigmaY: _quietBlurSigma * quiet, + ), + child: const SizedBox.expand(), + ), + ), + ), + Container( + // Keyed so the surface — and every State beneath it — keeps its + // identity as the blur appears and disappears beside it. + key: const ValueKey('ab-popup-surface'), + constraints: BoxConstraints(maxWidth: width, minWidth: 0), + padding: const EdgeInsets.all(5), + decoration: _popupDecoration(context.antgrid, quiet: quiet), + child: child, + ), + ], ), ); } diff --git a/app/lib/providers/visible_surface.dart b/app/lib/providers/visible_surface.dart index 52378aff..20a8af82 100644 --- a/app/lib/providers/visible_surface.dart +++ b/app/lib/providers/visible_surface.dart @@ -151,23 +151,28 @@ final workspaceMenuControlProvider = WorkspaceMenuControl? >(() => ValueController(null)); -/// Whether the agent bar's workspace menu POPUP is up. Shared by a mouse -/// desktop and a touch tablet — the tablet's context panel is a docked pane -/// beside the agent (`WorkspaceShellState._buildTabletTouch`), not an overlay -/// covering it, so the popup and the panel's own [WorkspaceTabBar] no longer -/// compete for the same screen space the way they did when the panel was a -/// full-width overlay. (Mobile phone width never reads this at all — -/// `WorkspaceMenuButton` renders nothing there; see [workspaceMenuControlProvider].) +/// Whether the agent bar's workspace rail is up. Shared by a mouse desktop and +/// a touch tablet, whose context panel is a docked pane beside the agent +/// (`WorkspaceShellState._buildTabletTouch`) rather than an overlay covering +/// it. (Mobile phone width never reads this at all — `WorkspaceMenuButton` +/// renders nothing there; see [workspaceMenuControlProvider].) /// -/// Defaults to OPEN: the five views are on screen the moment a session is, and -/// the icon is the only thing that takes them away. +/// Defaults to OPEN, but the shell holds it down for as long as the context +/// pane is on screen — the pane's own [WorkspaceTabBar] lists the same five +/// views, so the rail would be a second switcher floating over the transcript +/// (`WorkspaceShellState._syncMenuToContextPane`). On a mouse desktop, whose +/// pane starts open, that means the rail's first appearance is the first time +/// the user closes the pane. The icon still takes it away by hand. /// /// App state rather than the button's own `State` because the button does not -/// survive the thing its menu does. Revealing a view replaces the agent panel, -/// which unmounts the bar the button lives in; a flag held in the widget would -/// die with it and come back closed, so the menu would silently shut itself -/// every time it was used. Held here, the button re-opens the menu as soon as it -/// is mounted again. +/// survive the thing this flag controls: a workbench surface takes the whole +/// agent bar off screen, and the shell swaps `WorkspaceShell` out entirely on +/// the way to a new session. A flag held in the widget would die with the bar +/// and come back at its default, so the rail could neither stay down where the +/// user shut it nor come back up where they left it. Held here, the button +/// resolves the rail's state against this on its next mount — which is also why +/// `WorkspaceShellState._menuAutoHidden`, and not this value, is what says +/// whether the shell may reopen it. final workspaceMenuOpenProvider = NotifierProvider, bool>( () => ValueController(true), ); diff --git a/app/lib/screens/workspace_shell.dart b/app/lib/screens/workspace_shell.dart index ee19b7d8..5ff1b54b 100644 --- a/app/lib/screens/workspace_shell.dart +++ b/app/lib/screens/workspace_shell.dart @@ -172,10 +172,18 @@ class WorkspaceShellState extends ConsumerState /// with the agent panel. bool _tabletContextPanelExpanded = false; - /// [workspaceMenuOpenProvider]'s value from just before a squeeze hid it — - /// see [_setTabletContextExpanded] — so leaving the squeeze restores a - /// closed menu as closed instead of forcing it back open. - bool _menuOpenBeforeSqueeze = true; + /// Whether THIS State is the one that pulled the rail down to make room for + /// the context pane — the only case in which giving the pane back may reopen + /// it. See [_syncMenuToContextPane]. A rail the user shut by hand, or one + /// already shut when this shell mounted, is not ours to restore: + /// [workspaceMenuOpenProvider] is root-scoped and outlives this State, so its + /// value at mount time says nothing about who last set it. + bool _menuAutoHidden = false; + + /// What [_syncMenuToContextPane] last acted on, so it fires on the EDGES of + /// the pane appearing and disappearing rather than on every rebuild. Null + /// until the first desktop build resolves a layout. + bool? _contextPaneOnScreen; /// Gesture state for [_buildTabletTouch]'s single fling dispatcher — see /// its doc for why this replaced three separate [_HorizontalFlingDetector]s. @@ -1053,6 +1061,12 @@ class WorkspaceShellState extends ConsumerState // route — means no workspace tab is on screen at all, so nothing in it // may consume a back press. ref.read(visibleWorkspaceViewProvider.notifier).set(visibleView); + // Driven off the same `visibleView` the tab strip and the back gate + // read, so the rail can never disagree with them about whether the pane + // is up. Skipped entirely behind a workbench surface: the pane has not + // moved, it is merely covered, and syncing there would spend the + // remembered value on a round trip through settings. + if (surfaceChild == null) _syncMenuToContextPane(visibleView != null); }); } @@ -1560,13 +1574,10 @@ class WorkspaceShellState extends ConsumerState // [_buildPanels]'s list — corrupted the element tree here // instead (this slot sits under an [AnimatedPadding], not a // plain list, and swapping widget types under it blanked - // the whole screen). [WorkspaceMenuButton]'s popup survives - // an ordinary squeeze (sidebar open, pane open but not - // expanded) untouched — it only gets forced shut at FULL - // zero width (pane expanded, see [_tabletContextPanelWidth]), - // via [_setTabletContextExpanded]/[_openTabletContextPanel]/ - // [_closeTabletContextPanel], not the icon alone; see those - // methods' docs. + // the whole screen). Nothing here has to think about + // [WorkspaceMenuButton]'s popup: the rail is already down for + // as long as any view is on screen, whatever width this pane + // is squeezed to — see [_syncMenuToContextPane]. child: surfaceChild ?? _agentPanel(), ), Positioned( @@ -1762,65 +1773,56 @@ class WorkspaceShellState extends ConsumerState return mq.size.width - mq.padding.horizontal; } - /// [WorkspaceMenuButton]'s popup is "pinned" (see its own doc) and normally - /// survives every squeeze of the agent pane untouched. That breaks down at - /// full "expanded" width ([_tabletContextPanelWidth]): the agent pane — - /// and the button's [CompositedTransformTarget] living in its [AgentBar] — - /// is squeezed to zero width. The popup is right-anchored off that point - /// ([WorkspaceMenuButton]'s `followerAnchor: topRight`), so a zero-width - /// anchor there makes it hang leftward over the context pane instead of - /// sitting under a real button. + /// The workspace rail and the context pane's own [WorkspaceTabBar] list the + /// same five views, so only ever one of them is up: the pane takes the job + /// over as it opens and hands it back as it closes. What is left to the rail + /// is the one thing the tab strip cannot do — being the way back to a + /// workspace the user has closed, which otherwise takes its own tab strip + /// off screen with it (see [contextPanelControlProvider], the only other). /// - /// The mouse desktop never hits this: entering its own - /// [_PanelMode.contextExpanded] drops the agent panel (and the button with - /// it) from `_buildPanels`'s list entirely, so the popup's owning - /// [OverlayPortal] is torn down and comes back only once the button - /// remounts. Doing the same by unmounting here previously corrupted this - /// slot's element tree (see [_buildTabletTouch]'s doc), so this reaches the - /// same visible outcome — the popup gone for the squeeze, back once it - /// isn't — by toggling [workspaceMenuOpenProvider] instead of the widget. + /// Hidden, not unmounted: unmounting the button from here corrupted this + /// slot's element tree once already (see [_buildTabletTouch]'s doc), and + /// [workspaceMenuOpenProvider] reaches the same visible outcome from outside + /// the widget. [_menuAutoHidden] is what stops the hand-back from reopening a + /// rail this shell never took down — one the user shut by hand, and one + /// already shut before this shell mounted, both stay shut. /// - /// [_menuOpenBeforeSqueeze] is what stops that toggle from reopening a menu - /// the user had already closed by hand before expanding. - void _setTabletContextExpanded(bool expanded) { - final wasSqueezed = _tabletEndDrawerOpen && _tabletContextPanelExpanded; - final willSqueeze = _tabletEndDrawerOpen && expanded; - if (willSqueeze && !wasSqueezed) { - _menuOpenBeforeSqueeze = ref.read(workspaceMenuOpenProvider); - ref.read(workspaceMenuOpenProvider.notifier).set(false); - } else if (wasSqueezed && !willSqueeze && _menuOpenBeforeSqueeze) { - ref.read(workspaceMenuOpenProvider.notifier).set(true); + /// A squeezed pane — [_tabletContextPanelExpanded] over an open + /// [_tabletEndDrawerOpen], leaving the agent pane (and with it the button's + /// [CompositedTransformTarget] in its [AgentBar]) at zero width for the + /// right-anchored popup to hang off — is one shape of an already-OPEN pane, + /// so the auto-hide has normally taken the rail down long before the squeeze + /// arrives. The gap is a rail the user reopened by hand OVER an open pane: + /// the pane never changes state, so nothing here fires to take it down again. + void _syncMenuToContextPane(bool onScreen) { + if (_contextPaneOnScreen == onScreen) return; + _contextPaneOnScreen = onScreen; + final open = ref.read(workspaceMenuOpenProvider); + if (onScreen) { + _menuAutoHidden = open; + if (open) ref.read(workspaceMenuOpenProvider.notifier).set(false); + } else if (_menuAutoHidden) { + _menuAutoHidden = false; + if (!open) ref.read(workspaceMenuOpenProvider.notifier).set(true); } + } + + void _setTabletContextExpanded(bool expanded) { setState(() => _tabletContextPanelExpanded = expanded); } /// Opens the touch tablet's context pane — reached only from /// [_openContextPanel]'s tablet branch, since no fling opens the pane (see - /// [_tabletFlingLeftward]). Mirrors [_setTabletContextExpanded]'s squeeze - /// check, since [_tabletContextPanelExpanded] survives a close (only - /// [_closeTabletContextPanel] restores the popup, and only if the pane was - /// squeezed when it closed) — so reopening an already-expanded pane - /// re-enters that same squeeze immediately. + /// [_tabletFlingLeftward]). void _openTabletContextPanel() { if (_tabletEndDrawerOpen) return; - if (_tabletContextPanelExpanded) { - _menuOpenBeforeSqueeze = ref.read(workspaceMenuOpenProvider); - ref.read(workspaceMenuOpenProvider.notifier).set(false); - } setState(() => _tabletEndDrawerOpen = true); } /// Closes the touch tablet's context pane — the shared tail of its two /// close paths ([_closeTabletDrawers]'s back handler and the close button in - /// the pane's own tab bar) — restoring the workspace menu the same way - /// [_setTabletContextExpanded] does, since closing the pane while expanded - /// ends the squeeze exactly as un-expanding it does. + /// the pane's own tab bar). void _closeTabletContextPanel() { - if (_tabletEndDrawerOpen && - _tabletContextPanelExpanded && - _menuOpenBeforeSqueeze) { - ref.read(workspaceMenuOpenProvider.notifier).set(true); - } setState(() => _tabletEndDrawerOpen = false); } diff --git a/app/lib/widgets/workspace_menu_button.dart b/app/lib/widgets/workspace_menu_button.dart index 30422630..8370c56c 100644 --- a/app/lib/widgets/workspace_menu_button.dart +++ b/app/lib/widgets/workspace_menu_button.dart @@ -1,20 +1,23 @@ +import 'dart:async'; +import 'dart:ui' show lerpDouble; + +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../design/ab_colors.dart'; import '../design/ab_icons.dart'; import '../design/ab_tokens.dart'; import '../design/widgets/ab_diff_stat.dart'; +import '../design/widgets/ab_icon.dart'; import '../design/widgets/ab_icon_button.dart'; import '../design/widgets/ab_menu.dart'; import '../providers/visible_surface.dart'; -// The shared popup-panel chrome (section header + hover/focus row) that the -// composer's environment and branch pickers already use, so every grouped popup -// in the app reads as one control. -import 'new_session/environment_menu.dart' show PanelRow, PanelSectionHeader; +import '../utils/platform_utils.dart'; import 'workspace_tab_bar.dart'; -/// The agent bar's way into the workspace views: a dropdown naming all five, -/// anchored under the icon (see the popup doc below) — the SAME popup on a +/// The agent bar's way into the workspace views: a rail naming all five, +/// anchored under the icon (see the popup doc below) — the SAME rail on a /// touch tablet as on a mouse desktop, since the touch tablet's context panel /// is now a docked pane beside the agent (see `WorkspaceShellState._buildTabletTouch`), /// not an overlay covering it, so the two no longer compete for the same @@ -33,14 +36,20 @@ import 'workspace_tab_bar.dart'; /// in the (now visible) popup, which un-hides it — see /// [WorkspaceMenuPanel]'s doc. /// -/// **The popup is pinned, not modal, and it starts open.** It hangs in the -/// overlay with no barrier, so it survives every click that lands elsewhere — -/// including the ones that drive the agent underneath it — and the icon is -/// the only thing that shuts it. That is why it cannot be a [showAbPanel] -/// route: a [PopupRoute] both closes on the first outside click and swallows -/// that click on the way. The trade is that a pinned popup is invisible in -/// the icon's resting look, so the icon latches on ([AbIconButton.selected]) -/// for as long as the menu is up. +/// **The popup is pinned, not modal.** It hangs in the overlay with no +/// barrier, so it survives every click that lands elsewhere — including the +/// ones that drive the agent underneath it — and the icon is the only thing +/// that shuts it BY HAND. That is why it cannot be a [showAbPanel] route: a +/// [PopupRoute] both closes on the first outside click and swallows that click +/// on the way. The trade is that a pinned popup is invisible in the icon's +/// resting look, so the icon latches on ([AbIconButton.selected]) for as long +/// as the rail is up. +/// +/// The shell keeps it down for as long as the context pane is on screen, since +/// the pane's own [WorkspaceTabBar] already lists the same five views (see +/// `WorkspaceShellState._syncMenuToContextPane`). What is left for the rail is +/// the one job nothing else can do — the way back to the workspace once the +/// pane is closed. /// /// Open/closed is [workspaceMenuOpenProvider], not local state — see there for /// why the button cannot be trusted to remember it. @@ -63,19 +72,6 @@ class _WorkspaceMenuButtonState extends ConsumerState { final _link = LayerLink(); final _portal = OverlayPortalController(); - @override - void initState() { - super.initState(); - // The controller is detached here otherwise, so this is a queued show - // that the OverlayPortal below picks up when it mounts. That queueing is - // what brings the menu back with the button after a workspace surface - // took the agent bar down — and what opens it on the first session of the - // launch, with no click at all. - if (ref.read(workspaceMenuOpenProvider)) { - _portal.show(); - } - } - /// Guarded rather than a bare show/hide: hiding an already-hidden controller /// asserts while it is detached, which it is whenever no workspace is /// published. @@ -91,10 +87,15 @@ class _WorkspaceMenuButtonState extends ConsumerState { final open = ref.watch(workspaceMenuOpenProvider); ref.listen(workspaceMenuOpenProvider, (_, next) => _sync(next)); - // The portal can also fall out of step without the flag moving: a workbench - // surface covering the route unmounts it while THIS State survives, so - // initState never re-runs to re-queue the show. Repaired after the frame - // because show/hide must not run during a build. + // Every way the portal can be out of step with the flag, including the + // first build of all: the flag is app state that outlives this State (see + // [workspaceMenuOpenProvider]), so a rail that was up when a workbench + // surface took the agent bar down has to come back up with it, and a + // session's first rail has to appear with no click at all. Repaired after + // the frame because show/hide must not run during a build — which also + // lets the shell's own post-frame pass, registered first from an ancestor + // build, settle whether the context pane is up before the rail appears + // over it for a frame. if (open != _portal.isShowing) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _sync(ref.read(workspaceMenuOpenProvider)); @@ -109,18 +110,18 @@ class _WorkspaceMenuButtonState extends ConsumerState { // Material paints a shape, whose CustomPaint hit-tests as OPAQUE over // every pixel it is given. Screen-sized, it would silently swallow every // click in the window while the menu was up. Align loosens the - // constraints so the surface is only as big as the menu. + // constraints so the surface is only as big as the rail. overlayChildBuilder: (_) => Align( alignment: Alignment.topLeft, child: CompositedTransformFollower( link: _link, - // Right-aligned under the icon. The button lives at the agent bar's - // trailing edge, so hanging the panel leftward from it is the only - // placement that stays inside the window. + // Right edges flush, hanging under the button. The button lives at + // the agent bar's trailing edge, so leftward is the only direction + // the panel can grow and stay inside the window. targetAnchor: Alignment.bottomRight, followerAnchor: Alignment.topRight, offset: const Offset(0, AbTokens.space4), - child: const AbPopupSurface(child: WorkspaceMenuPanel()), + child: const WorkspaceMenuPanel(), ), ), child: CompositedTransformTarget( @@ -137,66 +138,338 @@ class _WorkspaceMenuButtonState extends ConsumerState { } } -/// Panel content: one row per [WorkspaceView], badged and check-marked to match -/// the tab strip. Live (ConsumerWidget) so a commit landing or an escalation -/// arriving updates the counts while the menu is open. +/// How far a row's trailing figure is let down while the rail is receded. +/// Still legible, no longer the brightest thing on a surface the reader has +/// not reached for. +const double _restingFigure = 0.6; + +/// How long the pointer has to stay before the rail comes forward. A cursor +/// crossing the top-right corner on its way to the agent bar is not a request +/// to read anything, and without this the rail flares at every pass. +const _hoverIntent = Duration(milliseconds: 90); + +/// The workspace rail: one row per [WorkspaceView], badged and marked to match +/// the tab strip. Live (a Consumer) so a commit landing or an escalation +/// arriving updates the counts while it is up. +/// +/// **It rests receded and comes forward under the pointer.** Pinned over the +/// agent's transcript, a popup at full strength the whole time is a claim on +/// attention it has not earned: at rest it sits translucent over a blur, flat, +/// divided by an ordinary border ([AbPopupSurface.quiet]), with its labels at +/// the muted foreground. Hover or keyboard focus restores the full popup. /// -/// Picking a view does not close the menu — only the icon does — so the check -/// mark moving to the row just tapped is the confirmation. Picking a view -/// un-hides the docked context panel if the user had it closed, then selects -/// that view there, alongside the agent. -class WorkspaceMenuPanel extends ConsumerWidget { +/// **Only its weight changes, never its size.** A version that furled to an +/// icon column and charged a hover for the labels was built and taken back +/// out: the rail's one job is to say what the workspace holds while the pane +/// is closed, and a rail that has to be reached for before it will answer has +/// stopped doing it. +/// +/// Picking a view opens the context pane, which is what takes the rail away — +/// the pane's own tab strip carries on from there (see +/// `WorkspaceShellState._syncMenuToContextPane`). +class WorkspaceMenuPanel extends ConsumerStatefulWidget { const WorkspaceMenuPanel({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _WorkspaceMenuPanelState(); +} + +class _WorkspaceMenuPanelState extends ConsumerState + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: AbTokens.motionDefault, + // A touch platform has no hover to bring a receded rail back, so it never + // recedes: the resting state is only earned where the pointer that undoes + // it exists. Starts forward there and stays that way. + value: _recedes ? 0 : 1, + ); + late final CurvedAnimation _lift = CurvedAnimation( + parent: _controller, + curve: Curves.easeOut, + reverseCurve: Curves.easeIn, + ); + Timer? _intent; + bool _hovering = false; + bool _hasFocus = false; + + bool get _recedes => !isMobilePlatform; + + @override + void dispose() { + _intent?.cancel(); + _lift.dispose(); + _controller.dispose(); + super.dispose(); + } + + void _setHovering(bool hovering) { + if (_hovering == hovering) return; + _hovering = hovering; + _applyLift(immediate: false); + } + + void _setFocused(bool hasFocus) { + if (_hasFocus == hasFocus) return; + _hasFocus = hasFocus; + // Keyboard focus skips [_hoverIntent]: Tab landing on a row is already a + // deliberate arrival, and a row that stayed receded after taking focus is + // a row the user cannot see they have selected. + _applyLift(immediate: hasFocus); + } + + /// The one place hover and keyboard focus meet: the rail stays forward while + /// EITHER holds and recedes only once neither does. Wired straight to the + /// controller instead, a pointer leaving dimmed a row that still had the + /// keyboard, and tabbing out dimmed the rail under a stationary cursor. + void _applyLift({required bool immediate}) { + if (!_recedes) return; + _intent?.cancel(); + if (!_hovering && !_hasFocus) { + _controller.reverse(); + return; + } + // [_hoverIntent] gates the FIRST approach only, so it is armed just from a + // fully receded rail. Any value above 0 means the rail is already partway + // forward — approaching, arrived, or withdrawing — and re-arming there + // would let a pointer that clipped the rail's edge on its way to a row + // carry on dimming for another 90ms before it turned around, which reads + // as a dip rather than as hesitation. + if (immediate || _controller.value > 0) { + _controller.forward(); + } else { + _intent = Timer(_hoverIntent, _controller.forward); + } + } + + @override + Widget build(BuildContext context) { + // Read before the guard below: `_controller` is a `late final`, so a panel + // that only ever built with a null control would otherwise run the + // initializer inside `dispose()` — creating a Ticker against a defunct + // element. + _controller.duration = MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : AbTokens.motionDefault; + final control = ref.watch(workspaceMenuControlProvider); final badges = ref.watch(workspaceBadgesProvider); final gitStat = ref.watch(gitDiffTotalsProvider); if (control == null) return const SizedBox.shrink(); - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const PanelSectionHeader('Workspace', mono: false), - for (final view in WorkspaceView.values) - PanelRow( - icon: view.icon, - label: view.label, - selected: view == control.active, - mono: false, - // The Git row trades its file count for the worktree's +/-: how - // much changed is what the row is read for, and one trailing - // figure is all it has room for. The tab strip deliberately - // keeps its plain count: five tabs on one scrolling row have no - // space for a figure this wide. - trailing: _viewTrailing( - view: view, - badge: badges[view], - gitStat: gitStat, - active: view == control.active, - ), - onTap: () => control.reveal(view), - ), - ], + return MouseRegion( + onEnter: (_) => _setHovering(true), + onExit: (_) => _setHovering(false), + child: Focus( + canRequestFocus: false, + skipTraversal: true, + // Reports the whole subtree, so this is every row's focus at once. + onFocusChange: _setFocused, + child: AnimatedBuilder( + animation: _lift, + builder: (context, _) { + final t = _lift.value; + return AbPopupSurface( + quiet: 1 - t, + // Shrink-wrapped rather than laid out at a fixed width, so the + // rail ends where its longest label does. A fixed width has to + // be sized for the widest row the rail can ever hold — the Git + // row's whole-worktree `+N -M` — which leaves it two thirds + // empty every other time. + child: IntrinsicWidth( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final view in WorkspaceView.values) + _RailRow( + view: view, + selected: view == control.active, + badge: badges[view], + gitStat: gitStat, + lift: t, + onTap: () => control.reveal(view), + ), + ], + ), + ), + ); + }, + ), + ), ); } +} + +/// One view's row. +class _RailRow extends StatefulWidget { + const _RailRow({ + required this.view, + required this.selected, + required this.badge, + required this.gitStat, + required this.lift, + required this.onTap, + }); + + final WorkspaceView view; - Widget? _viewTrailing({ - required WorkspaceView view, - required int? badge, - required GitDiffTotals gitStat, - required bool active, - }) { - if (view == WorkspaceView.git && - (gitStat.additions > 0 || gitStat.deletions > 0)) { - return AbDiffStat( - additions: gitStat.additions, - deletions: gitStat.deletions, + /// Whether this is the view on screen — not the row under the pointer. + final bool selected; + final int? badge; + final GitDiffTotals gitStat; + + /// 0 while the rail is receded, 1 once it has come forward. + final double lift; + final VoidCallback onTap; + + @override + State<_RailRow> createState() => _RailRowState(); +} + +class _RailRowState extends State<_RailRow> { + bool _hover = false; + bool _focused = false; + + /// The row's trailing figure, and the same fact in words — resolved together + /// so what a screen reader is told can never drift from what is drawn. + /// + /// The Git row trades its file count for the worktree's +/-: how much changed + /// is what the row is read for, and one trailing figure is all it has room + /// for. The tab strip deliberately keeps its plain count — five tabs on one + /// scrolling row have no space for a figure this wide. + /// + /// Sized up from [AbDiffStat]'s own default, which is set for the dense + /// per-file badge in the changed-file tree. Here it shares a column with + /// [WorkspaceViewBadge] and has to read as the same rank of figure, not as a + /// footnote wedged in beside the Git label. + ({Widget figure, String spoken})? _resolveTrailing() { + final stat = widget.gitStat; + if (widget.view == WorkspaceView.git && + (stat.additions > 0 || stat.deletions > 0)) { + return ( + figure: AbDiffStat( + additions: stat.additions, + deletions: stat.deletions, + fontSize: AbTokens.fontXs, + ), + spoken: AbDiffStat.describe(stat.additions, stat.deletions), ); } + final badge = widget.badge; if (badge == null) return null; - return WorkspaceViewBadge(count: badge, active: active); + return ( + figure: WorkspaceViewBadge(count: badge, active: widget.selected), + spoken: '$badge', + ); + } + + @override + Widget build(BuildContext context) { + final p = context.antgrid; + // Treat keyboard focus the same as pointer hover for the highlight — one + // "row under the user" state for either input mode, matching `PanelRow`. + final under = _hover || _focused; + final trailing = _resolveTrailing(); + // A receded rail is glanced past, not read: its labels sit at the muted + // foreground and come up to their own colour along with the surface behind + // them. The selected row's accent is exempt — which view is on screen is + // the one thing worth answering without being reached for. + Color forward(Color arrived) => + Color.lerp(p.textMuted, arrived, widget.lift)!; + + return FocusableActionDetector( + mouseCursor: SystemMouseCursors.click, + onShowHoverHighlight: (v) { + if (_hover != v) setState(() => _hover = v); + }, + onShowFocusHighlight: (v) { + if (_focused != v) setState(() => _focused = v); + }, + shortcuts: const { + SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(), + SingleActivator(LogicalKeyboardKey.space): ActivateIntent(), + SingleActivator(LogicalKeyboardKey.numpadEnter): ActivateIntent(), + }, + actions: { + ActivateIntent: CallbackAction( + onInvoke: (_) { + widget.onTap(); + return null; + }, + ), + }, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onTap, + // Trailing figures draw bare, which announce as a stray digit (or, for + // the Git row's `+N -M`, as punctuation) after the view's name; folding + // them into the label is what makes them read as this row's own count — + // the same treatment, for the same reason, as the tab strip's `_TabItem`. + child: Semantics( + button: true, + selected: widget.selected, + label: trailing == null + ? widget.view.label + : '${widget.view.label}, ${trailing.spoken}', + excludeSemantics: true, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AbTokens.space8, + vertical: AbTokens.space6, + ), + decoration: BoxDecoration( + // The view on screen keeps its own mark under hover: the rail is + // read for which view is up at least as often as it is clicked. + color: widget.selected + ? p.bgSelected + : (under ? p.bgHover : null), + borderRadius: AbTokens.borderRadius3, + ), + child: Row( + children: [ + AbIcon( + widget.view.icon, + size: AbTokens.iconButtonGlyph, + color: widget.selected + ? p.accent + : forward(under ? p.textPrimary : p.textSecondary), + ), + const SizedBox(width: AbTokens.space8), + Expanded( + child: Text( + widget.view.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AbTokens.sansStyle( + fontSize: AbTokens.fontSm, + color: forward( + under || widget.selected + ? p.textPrimary + : p.textSecondary, + ), + ), + ), + ), + if (trailing != null) ...[ + const SizedBox(width: AbTokens.space12), + // The figures carry their own colour — the badge's border, + // the diffstat's green and red — so they cannot recede by + // taking the muted foreground the way the labels do, and a + // receded rail would otherwise have its loudest element be + // the one thing nobody is looking at. Never to nothing: how + // much the worktree has moved is the one fact worth reading + // off a rail at rest. + Opacity( + opacity: lerpDouble(_restingFigure, 1, widget.lift)!, + child: trailing.figure, + ), + ], + ], + ), + ), + ), + ), + ); } } diff --git a/app/test/design/widgets/ab_menu_test.dart b/app/test/design/widgets/ab_menu_test.dart index 5675eea8..efa99990 100644 --- a/app/test/design/widgets/ab_menu_test.dart +++ b/app/test/design/widgets/ab_menu_test.dart @@ -1,3 +1,4 @@ +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:antgrid/design/ab_icons.dart'; import 'package:antgrid/design/widgets/ab_menu.dart'; @@ -34,4 +35,61 @@ void main() { await tester.tap(find.text('Delete session')); expect(deleted, isTrue); }); + + group('AbPopupSurface quiet', () { + Widget surface(double quiet) => + AbPopupSurface(quiet: quiet, child: const _StatefulProbe()); + + testWidgets('the content keeps its State as quiet crosses 0', ( + tester, + ) async { + // The blur only exists above quiet 0, so this is the frame where a + // wrapper-shaped implementation changes the tree's shape and Flutter + // re-inflates everything under it — silently taking row state, focus and + // scroll position with it on the last frame of every reveal. + await pumpAntgrid(tester, surface(1)); + final receded = tester.state<_StatefulProbeState>( + find.byType(_StatefulProbe), + ); + + for (final quiet in [0.5, 0.0, 0.5, 1.0]) { + await pumpAntgrid(tester, surface(quiet)); + expect( + tester.state<_StatefulProbeState>(find.byType(_StatefulProbe)), + same(receded), + reason: 'quiet $quiet re-inflated the popup content', + ); + } + }); + + testWidgets('the drop shadow is never inside the blur clip', ( + tester, + ) async { + // `_popupDecoration` paints its shadow entirely OUTSIDE the popup's own + // rect (negative spread, +24 y offset), so a clip anywhere above the + // decorated box erases it — leaving a shadow that is invisible for every + // quiet above 0 and then snaps in whole at 0. + await pumpAntgrid(tester, surface(0.5)); + expect(find.byType(BackdropFilter), findsOneWidget); + expect( + find.ancestor( + of: find.byType(_StatefulProbe), + matching: find.byType(ClipRRect), + ), + findsNothing, + ); + }); + }); +} + +class _StatefulProbe extends StatefulWidget { + const _StatefulProbe(); + + @override + State<_StatefulProbe> createState() => _StatefulProbeState(); +} + +class _StatefulProbeState extends State<_StatefulProbe> { + @override + Widget build(BuildContext context) => const SizedBox(width: 40, height: 40); } diff --git a/app/test/screens/workspace_menu_docking_test.dart b/app/test/screens/workspace_menu_docking_test.dart index b4d196f8..08114209 100644 --- a/app/test/screens/workspace_menu_docking_test.dart +++ b/app/test/screens/workspace_menu_docking_test.dart @@ -63,10 +63,23 @@ Future _settle(WidgetTester tester) async { await tester.pump(const Duration(milliseconds: 400)); } -/// Picks [label] from the agent bar's workspace menu, which is already open — -/// it opens itself with the session. Scoped to the popup: the docked panel's tab -/// strip carries the same five labels. +/// Hides the context pane the way the title bar's control does, which is what +/// brings the workspace rail back — with the pane up it has nothing to do (see +/// `WorkspaceShellState._syncMenuToContextPane`) and the shell keeps it down. +Future _closePane( + WidgetTester tester, + ProviderContainer container, +) async { + container.read(contextPanelControlProvider)!.toggle(); + await _settle(tester); +} + +/// Picks [label] from the agent bar's workspace rail. Scoped to the rail: the +/// docked panel's tab strip carries the same five labels. Future _pickView(WidgetTester tester, String label) async { + // Settled first: the rail is shown from a post-frame callback, so it is not + // in the tree on the frame that mounts the button carrying it. + await _settle(tester); await tester.tap( find.descendant( of: find.byType(WorkspaceMenuPanel), @@ -77,16 +90,56 @@ Future _pickView(WidgetTester tester, String label) async { } void main() { + // The rail and the pane's own tab strip list the same five views, so only + // one of them is ever up. A mouse desktop opens onto the pane, which is why + // the rail's first appearance is the first time the pane is closed. + testWidgets('the rail keeps out of the way while the pane is up', ( + tester, + ) async { + await _withShell(tester, (container) async { + expect(find.byType(WorkspacePanel), findsOneWidget); + expect(find.byType(WorkspaceMenuPanel), findsNothing); + expect(container.read(workspaceMenuOpenProvider), isFalse); + + await _closePane(tester, container); + + expect(find.byType(WorkspaceMenuPanel), findsOneWidget); + expect(container.read(workspaceMenuOpenProvider), isTrue); + }); + }); + + // Hiding the pane takes its tab strip off screen with it, so the rail coming + // back is the only way into the workspace from there — but not if the user + // had already shut the rail by hand. The hand-back restores what they left. + testWidgets('closing the pane does not reopen a rail the user had shut', ( + tester, + ) async { + await _withShell(tester, (container) async { + await _closePane(tester, container); + await tester.tap(find.byKey(WorkspaceMenuButton.buttonKey)); + await _settle(tester); + expect(find.byType(WorkspaceMenuPanel), findsNothing); + + // Out to a view and back, so the pane opens and closes under a rail the + // user has already dismissed. + container.read(contextPanelControlProvider)!.toggle(); + await _settle(tester); + await _closePane(tester, container); + + expect(find.byType(WorkspaceMenuPanel), findsNothing); + expect(container.read(workspaceMenuOpenProvider), isFalse); + }); + }); + testWidgets( - 'picking a view from the menu docks it beside the agent, not full width', + 'picking a view from the rail docks it beside the agent, not full width', (tester) async { await _withShell(tester, (container) async { - expect(find.byType(AgentPanel), findsOneWidget); - expect(find.byType(WorkspacePanel), findsOneWidget); + await _closePane(tester, container); await _pickView(tester, 'Git'); - // Still side-by-side with the agent — the menu never replaces it. + // Still side-by-side with the agent — the rail never replaces it. expect(find.byType(AgentPanel), findsOneWidget); expect(find.byType(WorkspacePanel), findsOneWidget); expect(container.read(visibleWorkspaceViewProvider), WorkspaceView.git); @@ -99,8 +152,7 @@ void main() { tester, ) async { await _withShell(tester, (container) async { - container.read(contextPanelControlProvider)!.toggle(); - await _settle(tester); + await _closePane(tester, container); expect(find.byType(WorkspacePanel), findsNothing); await _pickView(tester, 'Preview'); @@ -110,6 +162,9 @@ void main() { container.read(visibleWorkspaceViewProvider), WorkspaceView.preview, ); + // ...and the pane arriving is what takes the rail away: the strip it + // brought with it lists the same five views. + expect(find.byType(WorkspaceMenuPanel), findsNothing); }); }); } diff --git a/app/test/screens/workspace_shell_tablet_swipe_test.dart b/app/test/screens/workspace_shell_tablet_swipe_test.dart index bd86eec0..16ac2444 100644 --- a/app/test/screens/workspace_shell_tablet_swipe_test.dart +++ b/app/test/screens/workspace_shell_tablet_swipe_test.dart @@ -111,7 +111,18 @@ Future _settle(WidgetTester tester) async { } } +/// Picks a view from the agent bar's workspace rail. Settles first: the rail +/// is shown from a post-frame callback (see `WorkspaceMenuButton`), so it is +/// one frame behind the shell that publishes it. +/// +/// Taps the label with no hover because [_withTabletShell] pins +/// `TargetPlatform.android`, where the rail never recedes and the labels are +/// laid out from the first frame. Should the rail ever recede here, this taps a +/// clipped, zero-opacity label ~200px outside the rail's box — and `tester.tap` +/// only WARNS on a miss, so the swipe-routing tests would fail as "expected +/// git, got null" and read as a bug in the fling router. Future _pickView(WidgetTester tester, String label) async { + await _settle(tester); await tester.tap( find.descendant( of: find.byType(WorkspaceMenuPanel), diff --git a/app/test/widgets/workspace_menu_button_test.dart b/app/test/widgets/workspace_menu_button_test.dart index 3d8f0ef5..520dfafc 100644 --- a/app/test/widgets/workspace_menu_button_test.dart +++ b/app/test/widgets/workspace_menu_button_test.dart @@ -15,14 +15,16 @@ // POPUP's mechanics, not the platform default — and the touch group at the // bottom exercises the unpinned (touch) default deliberately, to confirm // there is no platform-specific path left to regress. +import 'package:antgrid/design/widgets/ab_diff_stat.dart'; +import 'package:antgrid/design/widgets/ab_icon.dart'; import 'package:antgrid/design/widgets/ab_icon_button.dart'; +import 'package:antgrid/design/widgets/ab_menu.dart'; import 'package:antgrid/providers/value_controller.dart'; import 'package:antgrid/providers/visible_surface.dart'; -import 'package:antgrid/widgets/new_session/environment_menu.dart' - show PanelRow; import 'package:antgrid/widgets/workspace_menu_button.dart'; import 'package:antgrid/widgets/workspace_tab_bar.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -68,6 +70,40 @@ Future _pump( AbIconButton _button(WidgetTester tester) => tester.widget(find.byKey(WorkspaceMenuButton.buttonKey)); +/// Parks a mouse pointer over the rail and waits out its hover-intent delay, +/// which is what brings it forward from its resting, receded look. Nothing +/// about the rail's LAYOUT depends on this — every label is on screen either +/// way — so only the tests that assert its weight need it. +Future _hoverRail(WidgetTester tester) async { + final pointer = await tester.createGesture(kind: PointerDeviceKind.mouse); + await pointer.addPointer(location: Offset.zero); + addTearDown(pointer.removePointer); + await pointer.moveTo(tester.getCenter(find.byType(WorkspaceMenuPanel))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + await tester.pumpAndSettle(); +} + +/// How far the rail has receded: 1 at rest, 0 once it has come forward. +double _quiet(WidgetTester tester) => tester + .widget( + find.descendant( + of: find.byType(WorkspaceMenuPanel), + matching: find.byType(AbPopupSurface), + ), + ) + .quiet; + +/// Where a view's glyph sits on screen. +Rect _iconRect(WidgetTester tester, WorkspaceView view) => tester.getRect( + find + .descendant( + of: find.byType(WorkspaceMenuPanel), + matching: find.byType(AbIcon), + ) + .at(WorkspaceView.values.indexOf(view)), +); + /// A no-op `reveal`-carrying control, so every test below only has to name /// the field it actually cares about. WorkspaceMenuControl _control({ @@ -111,10 +147,11 @@ void main() { }); }); - // The menu is pinned: the icon that opened it is the only thing that - // shuts it, so picking a view leaves it standing and the user can pick - // again. - testWidgets('picking a view reveals it and leaves the menu up', ( + // The rail is pinned: nothing in it dismisses itself, so picking a view + // leaves it standing and the user can pick again. In the app the shell + // takes it away as the pane it just opened arrives — but that is the + // shell's doing, covered in test/screens/workspace_menu_docking_test.dart. + testWidgets('picking a view reveals it and leaves the rail up', ( tester, ) async { await runDesktop(tester, () async { @@ -131,6 +168,63 @@ void main() { }); }); + // The resting state, and the reason the rail may sit pinned over a + // transcript at all: receded to a translucent, flat surface — but with + // every label still on it, which is the whole job. + testWidgets('rests receded and comes forward under the pointer', ( + tester, + ) async { + await runDesktop(tester, () async { + await _pump(tester, control: _control()); + + expect(_quiet(tester), 1); + for (final view in WorkspaceView.values) { + expect(find.text(view.label), findsOneWidget, reason: view.label); + } + + await _hoverRail(tester); + expect(_quiet(tester), 0); + }); + }); + + // Coming forward is a change of weight, never of size or shape. An earlier + // version furled to an icon column, which moved every row under the + // pointer and hid the labels until one was asked for. + testWidgets('coming forward moves nothing', (tester) async { + await runDesktop(tester, () async { + await _pump(tester, control: _control()); + + final resting = tester.getRect(find.byType(WorkspaceMenuPanel)); + final rows = { + for (final view in WorkspaceView.values) + view: _iconRect(tester, view), + }; + + await _hoverRail(tester); + + expect(tester.getRect(find.byType(WorkspaceMenuPanel)), resting); + for (final view in WorkspaceView.values) { + expect(_iconRect(tester, view), rows[view], reason: view.label); + } + }); + }); + + // The rail is as wide as its longest row and no wider. It used to be laid + // out at a width fixed for the widest row it could EVER hold — the Git + // row's whole-worktree `+N -M` — which left two thirds of it empty every + // other time. + testWidgets('is as wide as its content, not a reserved width', ( + tester, + ) async { + await runDesktop(tester, () async { + await _pump(tester, control: _control()); + + final panel = tester.getRect(find.byType(WorkspaceMenuPanel)); + final longest = tester.getRect(find.text('Terminals')); + expect(panel.right - longest.right, lessThan(20)); + }); + }); + // The whole point of dropping the popup ROUTE: a click meant for the // agent beneath the menu has to reach it, and take nothing away on the // way past. @@ -224,17 +318,26 @@ void main() { }); }); + // Asserted through the row's semantics rather than its widget, because + // that is the half a screen reader gets: the row excludes its own subtree, + // so the label and the selected state are stated on the wrapper or they + // are stated nowhere. testWidgets('marks the view already on screen', (tester) async { await runDesktop(tester, () async { await _pump(tester, control: _control(active: WorkspaceView.preview)); expect( - find.byWidgetPredicate((w) => w is PanelRow && w.selected), + find.byWidgetPredicate( + (w) => w is Semantics && (w.properties.selected ?? false), + ), findsOneWidget, ); expect( find.byWidgetPredicate( - (w) => w is PanelRow && w.selected && w.label == 'Preview', + (w) => + w is Semantics && + (w.properties.selected ?? false) && + w.properties.label == 'Preview', ), findsOneWidget, ); @@ -282,6 +385,59 @@ void main() { }); }); + // Both figures sit in the same trailing column, so they have to read as + // the same rank of thing. AbDiffStat's own default is set for the dense + // per-file badge in the changed-file tree and is a size smaller. + testWidgets('the Git +/- is the same size as a plain count', ( + tester, + ) async { + await runDesktop(tester, () async { + await _pump( + tester, + control: _control(), + badges: const {WorkspaceView.handler: 2}, + gitTotals: (additions: 4, deletions: 3), + ); + + double sizeOf(String text) => + tester.widget(find.text(text)).style!.fontSize!; + expect(sizeOf('+4'), sizeOf('2')); + }); + }); + + // A receded rail dims its labels by taking the muted foreground, which a + // green `+4` and a bordered badge cannot do — left alone they end up the + // loudest thing on a surface nobody has reached for. + testWidgets('the counts recede with the rail and come back with it', ( + tester, + ) async { + await runDesktop(tester, () async { + await _pump( + tester, + control: _control(), + gitTotals: (additions: 4, deletions: 3), + ); + + double figureOpacity() => tester + .widget( + find + .ancestor( + of: find.byType(AbDiffStat), + matching: find.byType(Opacity), + ) + .first, + ) + .opacity; + + expect(figureOpacity(), lessThan(1)); + // ...and never to nothing: it is the one fact worth reading at rest. + expect(figureOpacity(), greaterThan(0.4)); + + await _hoverRail(tester); + expect(figureOpacity(), 1); + }); + }); + testWidgets('a rename-only tree keeps the file count', (tester) async { // Files changed, no lines did: a Git row with nothing after it would // read as a clean worktree. @@ -379,6 +535,20 @@ void main() { }, ); + // Receding is a trade: the rail hands the transcript back some of its + // weight and takes a hover to get it again. A touch platform has no hover + // to pay with, so it is never charged — the rail arrives forward and + // stays there. + testWidgets('never recedes where there is no hover to undo it', ( + tester, + ) async { + await runTouch(tester, () async { + await _pump(tester, control: _control()); + + expect(_quiet(tester), 0); + }); + }); + // `selected` mirrors the popup, exactly as on desktop — not whether a // view happens to be on screen: it starts true with no active view, and // a tap closes the popup (and flips it false) exactly like desktop. From c549493173638be5e5f8e10debe6618d07bb098c Mon Sep 17 00:00:00 2001 From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:35:52 +0800 Subject: [PATCH 13/15] fix: make OSC 8 hyperlinks in the agent terminal actually clickable (#14) * fix: make OSC 8 hyperlinks in the agent terminal actually clickable Text an agent emits as a hyperlink -- a PR reference, an issue number -- painted blue and underlined but did nothing when tapped, while a bare URL in plain text was tappable without ever looking like a link. The two were exact inverses: rendering read the native snapshot's per-cell hasHyperlink, hit-testing only ran a bare-URL regex over visible text, and the styled VT formatter emits no OSC 8, so the URI never reached the path that needed it. The engine-side half is antgrid-ai/dart_terminal#3, merged as 1277a6b, so the three fork overrides move to that SHA. It wraps a symbol the shipped v0.1.4 prebuilt already exported, so no native release was needed -- noted in docs/dart-terminal-fork-release.md, whose Status table this would otherwise look like a counterexample to. The app half stops relying on the package's fallback launcher. An OSC 8 payload is written by whatever program is running in the terminal, so a tap acts on untrusted input: only http and https open now, and a failure to launch is visible instead of silent. * fix: open the URI that was validated, and show every terminal-link failure Review of the previous commit. The scheme check parsed a trimmed string but handed the raw one on to Uri.parse, so a padded URI cleared the check and then diverged from it: measured, a trailing space parses to host `example.com%20`, and a leading one throws FormatException. The test asserting a padded URI was openable certified exactly that input. The validator now returns the Uri it validated, so the checked URI is the opened one. openExternalUrl parsed outside its own try, so that FormatException escaped the function -- an unhandled rejection for the update callers, and for the terminal caller a log line and nothing else, which app/CLAUDE.md calls out as indistinguishable from a dropped tap. It now tryParses and routes an unparseable URL down the same visible path as a failed launch. A URI written by a terminal program is unbounded, so both the SnackBar and the log field elide at 120 chars; the log component moves to TerminalView, which is the key logs from this layer are filtered by. Adds an injectable open seam, matching HelpAboutSection.openUrl, so the function's behaviour is testable rather than only its predicate -- plus a wiring test, since onOpenHyperlink is optional and its package default launches any scheme. THIRD-PARTY.md still pinned the fork two bumps back; it names the modified work for an ELv2/MIT audit and nothing checks the pairing with pubspec. The fork-release note also claimed the symbol shipped in v0.1.4 'all along' -- true of the native binaries, not of ghostty-vt.wasm, which exports no _hyperlink_uri at all. * chore: bump the dart_terminal pin so terminal links survive a mouse-tracking TUI antgrid-ai/dart_terminal#4. Resolving an OSC 8 URI was not enough on its own: the view returned on _currentPointerUsesTerminalMouse before the resolution ran, so under any live mouse mode -- which is to say under a full-screen agent, the case this was written for -- the click went to the program and the link stayed dead. Shift now bypasses mouse reporting for mouse-like pointers, the xterm escape hatch, and the hover affordance follows it. Touch still goes to the program: there is no modifier to hold, so a link under a mouse-tracking TUI remains unreachable on the phone until it gets an affordance of its own. That is the remaining half of this fix. THIRD-PARTY.md moves in the same commit, per the lockstep note it grew last time. * feat: confirm a terminal link's destination before opening it on touch OSC 8 lets a link's visible text disagree with where it goes, so the text under a finger is not evidence of anything: https://github.com@evil.example/ reads as GitHub and resolves to evil.example. Desktop reveals the target on hover before the click, so a click there is already informed. Touch has no hover at all, which is why the mobile path asks instead of guessing that the user knew. The sheet leads with the host on its own line -- that is the whole of what an impostor URL misrepresents, and burying it inside the full string is how a userinfo prefix goes unread. A dismiss reads as no, not as null. Pairs with antgrid-ai/dart_terminal#5, which lets a touch tap reach an explicit OSC 8 link at all when a full-screen agent holds the mouse. Bare URL matches stay with the program there -- taking a TUI's clicks away on a regex guess is a hole nobody can explain. * chore: bump the dart_terminal pin so a touch tap can reach a terminal link antgrid-ai/dart_terminal#5. Shift covered the desktop; touch had no modifier to hold, so on a phone a link under a full-screen agent was unreachable however it was painted. A tap on an explicit OSC 8 cell now opens it and its forwarded click is suppressed, so the TUI does not also react. Bare-URL matches stay with the program: taking a TUI's clicks away on a regex guess is a hole nobody can explain. This is what the confirm sheet in 245073f exists for -- the tap that reaches a link is also the tap that cannot see where it goes. --- THIRD-PARTY.md | 6 +- app/lib/util/external_url.dart | 135 +++++++- app/lib/widgets/terminal_hyperlink_sheet.dart | 117 +++++++ app/lib/widgets/terminal_view_wrapper.dart | 6 + app/pubspec.lock | 12 +- app/pubspec.yaml | 6 +- app/test/terminal_hyperlink_test.dart | 308 ++++++++++++++++++ .../terminal_view_wrapper_font_test.dart | 14 + docs/dart-terminal-fork-release.md | 12 + 9 files changed, 599 insertions(+), 17 deletions(-) create mode 100644 app/lib/widgets/terminal_hyperlink_sheet.dart create mode 100644 app/test/terminal_hyperlink_test.dart diff --git a/THIRD-PARTY.md b/THIRD-PARTY.md index 122af9a6..3acff507 100644 --- a/THIRD-PARTY.md +++ b/THIRD-PARTY.md @@ -114,12 +114,16 @@ ghostty_vte: git: url: https://github.com/antgrid-ai/dart_terminal.git path: pkgs/vte/ghostty_vte - ref: 8cc4d3b6720c3a27898b30ba555a1da8cae80a60 + ref: ce7f469dba73216843a3c684b0697b0f8a5ec64c ``` `ghostty_vte_flutter` and `portable_pty` are pinned to the same repository and ref, at `pkgs/vte/ghostty_vte_flutter` and `pkgs/pty/portable_pty`. +The ref above must match `app/pubspec.yaml`. Nothing checks the pairing, and +this file is only read under audit pressure — so a stale ref here names a tree +that was never distributed and no one finds out until it matters. + | Package | Version | Licence | Copyright holder | Upstream | |---|---|---|---|---| | `ghostty_vte` | 0.1.4+antgrid.1 | MIT | Copyright (c) 2026 kingwill101 | `https://github.com/kingwill101/dart_terminal/tree/master/pkgs/vte/ghostty_vte` | diff --git a/app/lib/util/external_url.dart b/app/lib/util/external_url.dart index 2e04feeb..2e8cdfb5 100644 --- a/app/lib/util/external_url.dart +++ b/app/lib/util/external_url.dart @@ -1,19 +1,140 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; import '../design/widgets/ab_snack_bar.dart'; +import '../widgets/terminal_hyperlink_sheet.dart'; +import 'ab_log.dart'; + +/// Longest URL echoed back to the user or written to `app.log`. +/// +/// A terminal hyperlink's URI is written by whatever program is running, so it +/// is unbounded program-chosen text. `showAbSnackBar`'s line cap stops a long +/// one being PAINTED but not laid out, and the log line would carry all of it +/// to disk on every failure. +const int _maxShownUrlChars = 120; + +String _elide(String url) { + if (url.length <= _maxShownUrlChars) return url; + // Back off a trailing high surrogate: `substring` cuts UTF-16 code units, and + // a stranded half renders as a replacement glyph and corrupts the logged URI + // at exactly the character a reader is trying to identify. + final last = url.codeUnitAt(_maxShownUrlChars - 1); + final end = (last >= 0xD800 && last <= 0xDBFF) + ? _maxShownUrlChars - 1 + : _maxShownUrlChars; + return '${url.substring(0, end)}…'; +} /// Open [url] in the system browser, falling back to a SnackBar with the URL -/// if launching fails. Used by the sign-in / activation / blocked screens. +/// if launching fails. Used by the sign-in / activation / blocked screens and +/// by [openTerminalHyperlink]. Future openExternalUrl(BuildContext context, String url) async { - final parsed = Uri.parse(url); + // tryParse, not parse: [openTerminalHyperlink] hands this URLs it did not + // author, and a FormatException here would escape a caller whose future is + // discarded. An unparseable URL takes the same visible path as a failed + // launch rather than vanishing into a log line. + final parsed = Uri.tryParse(url); bool ok = false; - try { - ok = await launchUrl(parsed, mode: LaunchMode.externalApplication); - } catch (_) { - ok = false; + if (parsed != null) { + try { + ok = await launchUrl(parsed, mode: LaunchMode.externalApplication); + } catch (_) { + ok = false; + } } if (!ok && context.mounted) { - showAbSnackBar(context, 'Could not open browser. Visit $url.'); + showAbSnackBar(context, 'Could not open browser. Visit ${_elide(url)}.'); } } + +/// The URI to open for a hyperlink carried by terminal output, or null when it +/// may not be opened. +/// +/// An OSC 8 payload is written by whatever program is running in the terminal, +/// so a tap on it acts on untrusted input rather than on something the user +/// typed. Only the web schemes pass: `file:` would hand out local paths and a +/// custom scheme could deep-link into another installed app. +/// +/// This bounds the SCHEME and nothing else. The host is not vetted, OSC 8 lets +/// a link's visible text disagree with its target, and `openExternalUrl` hands +/// the URI to the OS with [LaunchMode.externalApplication] — so an `https:` +/// host holding a verified App Link still opens that app rather than a browser. +/// +/// Returns the parsed URI rather than a bool so the caller opens exactly what +/// was checked. Validating one string and launching another is how a padded +/// URI cleared the scheme check and then reached `Uri.parse` with the padding +/// still on it, where it throws. +Uri? openableTerminalHyperlink(String uri) { + final parsed = Uri.tryParse(uri.trim()); + if (parsed == null) return null; + // `Uri` lower-cases the scheme as it parses, so a literal compare is total. + if (parsed.scheme != 'http' && parsed.scheme != 'https') return null; + return parsed.host.isEmpty ? null : parsed; +} + +/// Open a hyperlink activated in the terminal, refusing non-web schemes. +/// +/// Never completes with an error. The terminal view discards the future this +/// returns, so a rejection would reach `PlatformDispatcher.onError` as a fatal +/// carrying no in-app frames. +/// +/// On touch the destination is confirmed first — see +/// [showTerminalHyperlinkSheet] for why that is not merely a nag. +/// +/// [open] and [confirm] are injectable so tests can assert what would be +/// launched instead of handing a URL to the real browser, matching +/// `HelpAboutSection.openUrl`. +Future openTerminalHyperlink( + BuildContext context, + String uri, { + Future Function(BuildContext, String) open = openExternalUrl, + Future Function(BuildContext, Uri) confirm = showTerminalHyperlinkSheet, +}) async { + try { + final target = openableTerminalHyperlink(uri); + if (target == null) { + // Mounted today by construction — the fork activates a tap synchronously + // — but that is the fork's invariant, not ours, and a deferred tap (a + // double-tap timer, a post-frame hop) would land here on a dead element. + if (context.mounted) { + showAbSnackBar( + context, + 'Only http and https links open from the terminal.', + ); + } + return; + } + if (_revealsDestinationOnHover) { + await open(context, target.toString()); + return; + } + if (!await confirm(context, target)) { + return; + } + if (!context.mounted) { + return; + } + await open(context, target.toString()); + } catch (error, stack) { + // Log-only on purpose: every failure the user can actually provoke — + // a refused scheme, an unparseable URL, a launcher that says no — already + // answers with a SnackBar above. Reaching here means something unforeseen + // threw, and a `showAbSnackBar` in this block could throw again with no + // catch left to hold it. + AbLog.error( + 'TerminalView', + 'open hyperlink failed', + fields: {'uri': _elide(uri), 'error': '$error', 'stack': '$stack'}, + ); + } +} + +/// Whether this platform shows a link's destination before it is activated. +/// +/// Desktop does, through the terminal's hover affordance, so a click there is +/// already an informed one. Touch has no hover, which is why the mobile path +/// asks instead. +bool get _revealsDestinationOnHover => + defaultTargetPlatform != TargetPlatform.android && + defaultTargetPlatform != TargetPlatform.iOS; diff --git a/app/lib/widgets/terminal_hyperlink_sheet.dart b/app/lib/widgets/terminal_hyperlink_sheet.dart new file mode 100644 index 00000000..0cebe15b --- /dev/null +++ b/app/lib/widgets/terminal_hyperlink_sheet.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; + +import '../design/ab_colors.dart'; +import '../design/ab_tokens.dart'; +import '../design/widgets/ab_adaptive_sheet.dart'; +import '../design/widgets/ab_button.dart'; +import '../design/widgets/ab_dialog.dart'; + +/// Asks whether to open [target], showing where it actually goes. +/// +/// OSC 8 lets a link's visible text disagree with its destination, so the text +/// a user taps is not evidence of anything: `https://github.com@evil.example/` +/// reads as GitHub and resolves to `evil.example`. Desktop reveals the target +/// on hover before the click; touch has no hover at all, so without this the +/// destination is never shown before the browser is already open. +/// +/// Returns false when dismissed, so a stray tap outside the sheet cancels. +Future showTerminalHyperlinkSheet( + BuildContext context, + Uri target, +) async { + final confirmed = await showAbAdaptiveSheet( + context, + child: _TerminalHyperlinkConfirm(target: target), + ); + return confirmed ?? false; +} + +class _TerminalHyperlinkConfirm extends StatelessWidget { + const _TerminalHyperlinkConfirm({required this.target}); + + final Uri target; + + @override + Widget build(BuildContext context) { + final p = context.antgrid; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: abDialogTitlePadding, + child: abDialogTitle( + 'Open link', + onClose: () => Navigator.pop(context, false), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB( + AbTokens.space16, + AbTokens.space8, + AbTokens.space16, + 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // The host leads, on its own line: it is the whole of what an + // impostor URL misrepresents, and burying it inside the full + // string is how a userinfo prefix goes unread. + Text( + target.host, + style: AbTokens.monoStyle( + fontSize: AbTokens.fontBody, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: AbTokens.space6), + Text( + target.toString(), + maxLines: 4, + overflow: TextOverflow.ellipsis, + style: AbTokens.monoStyle( + fontSize: AbTokens.fontXs, + color: p.textMuted, + ), + ), + const SizedBox(height: AbTokens.space8), + Text( + 'This link was printed by the terminal, not typed by you.', + style: AbTokens.sansStyle( + fontSize: AbTokens.fontXs, + color: p.textMuted, + ), + ), + ], + ), + ), + const SizedBox(height: AbTokens.space16), + Padding( + padding: const EdgeInsets.fromLTRB( + AbTokens.space16, + 0, + AbTokens.space16, + AbTokens.space16, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + AbButton( + label: 'Cancel', + onTap: () => Navigator.pop(context, false), + ), + const SizedBox(width: AbTokens.space8), + AbButton( + label: 'Open', + variant: AbButtonVariant.primary, + onTap: () => Navigator.pop(context, true), + ), + ], + ), + ), + ], + ); + } +} diff --git a/app/lib/widgets/terminal_view_wrapper.dart b/app/lib/widgets/terminal_view_wrapper.dart index 5797f829..52f8d96a 100644 --- a/app/lib/widgets/terminal_view_wrapper.dart +++ b/app/lib/widgets/terminal_view_wrapper.dart @@ -20,6 +20,7 @@ import '../providers/providers.dart'; import '../services/app_settings_service.dart'; import '../services/terminal_service.dart'; import '../util/detached.dart'; +import '../util/external_url.dart'; import 'clipboard_image.dart'; import 'send_to_agent_button.dart'; import 'send_to_agent_comment.dart'; @@ -589,6 +590,11 @@ class _TerminalViewWrapperState extends ConsumerState { // terminal selection/hyperlinks match the rest of the system. selectionColor: context.antgrid.accent.withValues(alpha: 0.3), hyperlinkColor: context.antgrid.accent, + // Without this the package falls back to a bare `launchUrlString`, which + // uses the platform-default launch mode and reports nothing when it + // fails. Route through the app's helper so a link opens externally and a + // failure is visible, and so terminal-authored URIs are scheme-checked. + onOpenHyperlink: (uri) => openTerminalHyperlink(context, uri), showHeader: false, showFocusRing: false, // Thin terminal-native scrollbar — thumb tracks diff --git a/app/pubspec.lock b/app/pubspec.lock index 3ee93e5a..54b901be 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -682,8 +682,8 @@ packages: dependency: "direct overridden" description: path: "pkgs/vte/ghostty_vte" - ref: a4f96a834bf9a43b76433311678e194812afc7cf - resolved-ref: a4f96a834bf9a43b76433311678e194812afc7cf + ref: ce7f469dba73216843a3c684b0697b0f8a5ec64c + resolved-ref: ce7f469dba73216843a3c684b0697b0f8a5ec64c url: "https://github.com/antgrid-ai/dart_terminal.git" source: git version: "0.1.4+antgrid.1" @@ -691,8 +691,8 @@ packages: dependency: "direct main" description: path: "pkgs/vte/ghostty_vte_flutter" - ref: a4f96a834bf9a43b76433311678e194812afc7cf - resolved-ref: a4f96a834bf9a43b76433311678e194812afc7cf + ref: ce7f469dba73216843a3c684b0697b0f8a5ec64c + resolved-ref: ce7f469dba73216843a3c684b0697b0f8a5ec64c url: "https://github.com/antgrid-ai/dart_terminal.git" source: git version: "0.1.4+antgrid.1" @@ -1204,8 +1204,8 @@ packages: dependency: "direct overridden" description: path: "pkgs/pty/portable_pty" - ref: a4f96a834bf9a43b76433311678e194812afc7cf - resolved-ref: a4f96a834bf9a43b76433311678e194812afc7cf + ref: ce7f469dba73216843a3c684b0697b0f8a5ec64c + resolved-ref: ce7f469dba73216843a3c684b0697b0f8a5ec64c url: "https://github.com/antgrid-ai/dart_terminal.git" source: git version: "0.0.6+antgrid.2" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 7ec0aa50..5b7e71c9 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -191,17 +191,17 @@ dependency_overrides: git: url: https://github.com/antgrid-ai/dart_terminal.git path: pkgs/vte/ghostty_vte_flutter - ref: a4f96a834bf9a43b76433311678e194812afc7cf + ref: ce7f469dba73216843a3c684b0697b0f8a5ec64c ghostty_vte: git: url: https://github.com/antgrid-ai/dart_terminal.git path: pkgs/vte/ghostty_vte - ref: a4f96a834bf9a43b76433311678e194812afc7cf + ref: ce7f469dba73216843a3c684b0697b0f8a5ec64c portable_pty: git: url: https://github.com/antgrid-ai/dart_terminal.git path: pkgs/pty/portable_pty - ref: a4f96a834bf9a43b76433311678e194812afc7cf + ref: ce7f469dba73216843a3c684b0697b0f8a5ec64c # Microsoft Store packaging (MSIX). The Store signs the package itself, so no # code-signing cert is used on this path (unlike the Inno Setup installer in diff --git a/app/test/terminal_hyperlink_test.dart b/app/test/terminal_hyperlink_test.dart new file mode 100644 index 00000000..f942d3b3 --- /dev/null +++ b/app/test/terminal_hyperlink_test.dart @@ -0,0 +1,308 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:antgrid/util/ab_log.dart'; +import 'package:antgrid/util/external_url.dart'; +import 'package:antgrid/widgets/terminal_hyperlink_sheet.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('openableTerminalHyperlink', () { + test('accepts web links', () { + expect(openableTerminalHyperlink('https://example.com'), isNotNull); + expect(openableTerminalHyperlink('http://example.com/a?b=1'), isNotNull); + expect(openableTerminalHyperlink('HTTPS://Example.com'), isNotNull); + }); + + // The whole point of returning the Uri: whatever cleared the scheme check + // is what gets launched. Handing the raw string on instead let a padded URI + // reach `Uri.parse`, which rejects leading whitespace outright and folds + // trailing whitespace into the host. + test( + 'normalizes what it accepts, so the checked URI is the opened one', + () { + expect( + openableTerminalHyperlink(' https://example.com ').toString(), + 'https://example.com', + ); + expect( + openableTerminalHyperlink('https://example.com\n').toString(), + 'https://example.com', + ); + expect( + openableTerminalHyperlink('\thttps://example.com/a?b=1').toString(), + 'https://example.com/a?b=1', + ); + // Every accepted URI must survive a re-parse, since that is what the + // launcher does to it. + for (final raw in [ + ' https://example.com ', + 'https://example.com\n', + 'HTTPS://Example.com', + ]) { + final target = openableTerminalHyperlink(raw)!; + expect(Uri.parse(target.toString()), target); + } + }, + ); + + // An OSC 8 payload is written by whatever runs in the terminal, so these + // are reachable by any program that can print, on a single tap. + test('refuses non-web schemes', () { + expect(openableTerminalHyperlink('file:///etc/passwd'), isNull); + expect(openableTerminalHyperlink('mailto:a@b.com'), isNull); + expect(openableTerminalHyperlink('javascript:alert(1)'), isNull); + expect(openableTerminalHyperlink('antgrid://open/project'), isNull); + expect(openableTerminalHyperlink('vscode://file/C:/secret'), isNull); + }); + + test('refuses schemeless and hostless input', () { + expect(openableTerminalHyperlink(''), isNull); + expect(openableTerminalHyperlink('example.com'), isNull); + expect(openableTerminalHyperlink('https://'), isNull); + expect(openableTerminalHyperlink('/etc/passwd'), isNull); + }); + }); + + group('openTerminalHyperlink', () { + late Directory tmp; + late String logPath; + + setUp(() { + tmp = Directory.systemTemp.createTempSync('hyperlink_'); + logPath = '${tmp.path}/app.log'; + AbLog.configureForTest(logPath); + }); + tearDown(() { + AbLog.dispose(); + tmp.deleteSync(recursive: true); + }); + + // The predicate group above covers the decision; these cover what the app + // DOES with it — the SnackBar, the launch, and the swallowed throw are the + // only behaviour this function adds, and none of it is reachable from a + // pure test. + Future pumpHost(WidgetTester tester) async { + late BuildContext captured; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + captured = context; + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + return captured; + } + + const refusal = 'Only http and https links open from the terminal.'; + + testWidgets('launches the normalized URI, silently', (tester) async { + final context = await pumpHost(tester); + final launched = []; + + await openTerminalHyperlink( + context, + ' https://example.com/a?b=1 ', + open: (_, url) async => launched.add(url), + confirm: (_, _) async => true, + ); + await tester.pump(); + + expect(launched, ['https://example.com/a?b=1']); + expect(find.text(refusal), findsNothing); + }); + + testWidgets('refuses a non-web scheme visibly and launches nothing', ( + tester, + ) async { + final context = await pumpHost(tester); + var launched = 0; + + await openTerminalHyperlink( + context, + 'file:///etc/passwd', + open: (_, _) async => launched++, + ); + await tester.pump(); + + expect(launched, 0); + // A log line alone would leave a refused tap indistinguishable from a + // tap that never registered. + expect(find.text(refusal), findsOneWidget); + }); + + testWidgets('swallows an unexpected throw into the log', (tester) async { + final context = await pumpHost(tester); + + // runAsync, not the fake clock: AbLog's flush is real file I/O, and a + // future waiting on the disk never completes inside a widget test's + // FakeAsync zone. + await tester.runAsync(() async { + // Must not rethrow: the terminal view discards this future, so an + // escape reaches PlatformDispatcher.onError as a fatal. + await openTerminalHyperlink( + context, + 'https://example.com', + open: (_, _) async => throw StateError('launcher exploded'), + confirm: (_, _) async => true, + ); + await AbLog.flush(); + }); + + final line = + jsonDecode( + File( + logPath, + ).readAsLinesSync().firstWhere((l) => l.trim().isNotEmpty), + ) + as Map; + expect(line['component'], 'TerminalView'); + expect(line['error'], contains('launcher exploded')); + }); + + // flutter_test reports android by default, so these run on the touch path + // unless they say otherwise. + testWidgets( + 'asks before opening on touch, and cancelling launches nothing', + (tester) async { + final context = await pumpHost(tester); + var launched = 0; + var asked = 0; + + await openTerminalHyperlink( + context, + 'https://example.com/a', + open: (_, _) async => launched++, + confirm: (_, _) async { + asked++; + return false; + }, + ); + await tester.pump(); + + expect(asked, 1); + expect(launched, 0); + }, + ); + + // The sheet's whole job is naming the destination, so it has to be handed + // the URI that will actually be launched -- not the raw terminal string, + // which can differ from it. + testWidgets('asks about the normalized URI, not the raw one', ( + tester, + ) async { + final context = await pumpHost(tester); + Uri? asked; + + await openTerminalHyperlink( + context, + ' https://github.com@evil.example/pull/13 ', + open: (_, _) async {}, + confirm: (_, target) async { + asked = target; + return true; + }, + ); + await tester.pump(); + + expect(asked.toString(), 'https://github.com@evil.example/pull/13'); + // The host is what an impostor URL misrepresents, and it is not the half + // the anchor text advertises. + expect(asked?.host, 'evil.example'); + }); + + testWidgets('desktop opens without asking -- hover already showed it', ( + tester, + ) async { + // Cleared inside the body, not in addTearDown: the framework asserts + // the foundation debug vars are unset before teardown runs. + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + + final context = await pumpHost(tester); + final launched = []; + var asked = 0; + + await openTerminalHyperlink( + context, + 'https://example.com/a', + open: (_, url) async => launched.add(url), + confirm: (_, _) async { + asked++; + return true; + }, + ); + await tester.pump(); + + debugDefaultTargetPlatformOverride = null; + + expect(asked, 0); + expect(launched, ['https://example.com/a']); + }); + }); + + group('showTerminalHyperlinkSheet', () { + Future> open(WidgetTester tester, Uri target) async { + late Future answer; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => + answer = showTerminalHyperlinkSheet(context, target), + child: const Text('go'), + ), + ), + ), + ), + ); + await tester.tap(find.text('go')); + await tester.pumpAndSettle(); + return answer; + } + + testWidgets('names the host on its own line, and the full URI', ( + tester, + ) async { + final answer = await open( + tester, + Uri.parse('https://github.com@evil.example/antgrid/pull/13'), + ); + + // The host alone, not merely present somewhere inside the URI: a + // userinfo prefix is exactly what goes unread when it is not separated. + expect(find.text('evil.example'), findsOneWidget); + expect( + find.text('https://github.com@evil.example/antgrid/pull/13'), + findsOneWidget, + ); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(await answer, isFalse); + }); + + testWidgets('Open confirms, and a dismiss does not', (tester) async { + final target = Uri.parse('https://example.com/a'); + + var answer = await open(tester, target); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + expect(await answer, isTrue); + + // Dismissing the sheet must read as "no" -- a null from the route would + // otherwise be one `!` away from launching what the user backed out of. + answer = await open(tester, target); + await tester.tapAt(const Offset(400, 20)); + await tester.pumpAndSettle(); + expect(await answer, isFalse); + }); + }); +} diff --git a/app/test/widgets/terminal_view_wrapper_font_test.dart b/app/test/widgets/terminal_view_wrapper_font_test.dart index 07a1097f..9024ae4c 100644 --- a/app/test/widgets/terminal_view_wrapper_font_test.dart +++ b/app/test/widgets/terminal_view_wrapper_font_test.dart @@ -172,6 +172,20 @@ void main() { debugDefaultTargetPlatformOverride = null; }); + // `onOpenHyperlink` is optional on GhosttyTerminalView and its default is an + // unchecked `launchUrlString`, so dropping the argument re-opens every scheme + // to a single tap while the analyzer and the rest of the suite stay green. + testWidgets('terminal routes hyperlink activation through the app', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + + final view = await pumpAtScale(tester, 1.0, 't-hyperlink'); + expect(view.onOpenHyperlink, isNotNull); + + debugDefaultTargetPlatformOverride = null; + }); + testWidgets('terminal detail keeps status and delete in the title row', ( tester, ) async { diff --git a/docs/dart-terminal-fork-release.md b/docs/dart-terminal-fork-release.md index d72b4f7e..6f0745d3 100644 --- a/docs/dart-terminal-fork-release.md +++ b/docs/dart-terminal-fork-release.md @@ -62,6 +62,18 @@ re-asserted on *every* ingest path — PTY, external transport, and injected deb output — rather than one of them. Strictly better. Before concluding a patch was lost, check whether it moved. +`ghostty_vte` is the subtler case: it *does* front a native library, yet a fork +change there is still Dart-only whenever the symbol it needs is already exported +by the pinned prebuilt. The generated bindings cover the whole C surface, so +plenty of it has no Dart wrapper — `ghostty_grid_ref_hyperlink_uri` sat unwrapped +until `1277a6b` (OSC 8 hyperlink URIs) and was exported by the shipped v0.1.4 +*native* binaries all along. Check the shipped library's exports before assuming +a new capability costs a VTE release; the Status table's "not needed" survives +exactly as long as that holds — and check per artifact, not per release: the +same v0.1.4 ships `ghostty-vt.wasm` with a narrower export set (it has +`ghostty_grid_ref_cell`/`_row`/`_style` but not `_hyperlink_uri`), so a symbol +being present natively says nothing about web. + ## The `portable_pty` SIGCHLD patch is superseded — do not port it Antgrid's `packages/portable_pty/rust/src/lib.rs` wraps `ensure_sigchld_handler` From fb99eb9080ddc08a28b50af8fb77a61ba3bcfaef Mon Sep 17 00:00:00 2001 From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:05:50 +0800 Subject: [PATCH 14/15] A remote session must not offer to open a folder on this machine (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * A remote session must not offer to open a folder on this machine The session kebab's working-directory rows (Open folder, Open in , Copy path) resolve their path over the LOOPBACK control plane, so they only mean anything for a checkout this device hosts. They were hidden behind a remote-blocklist — an exact-id match against the paired-agent list — which could not answer for either shape it meets today: a remote project's id is compound (`.`) while a machine record is keyed by the bare uuid, and `paired_agents` is written by nothing any more (admission is account trust; only `forgetMachine` still touches that store, to remove). So the rows showed for every remote session, and picking one spawned the local host to ask about a project it has never seen. Gate on an allowlist instead: the local project store, asked the same way projectDisplayNameProvider asks it. An id it does not hold is a remote project, a machine, or something removed — all of which must lose the rows — so a new source of remote entries is excluded without this gate being revisited, and an unresolved device uuid answers false rather than guessing. Enforce the same predicate inside openCheckoutIn/copyCheckoutPath. Their doc already said callers must not offer them for a relay-backed project; an unenforceable contract is what produced this. * A remote machine must be recognised by the machine part of a project id Every one of these sites asked "is this entry relay-reached?" by matching an id exactly against the paired-agent list. That answers for neither shape that reaches it: a remote PROJECT id is compound (.) while every machine record is keyed by the bare uuid, and the paired list is a dead store on any install that never QR-paired — admission is account trust now, so the list is empty and nothing writes to it. So the focused remote project reported itself local: no host chip in the title bar, and activeAgentProvider resolved to no machine at all. Resolve through baseDeviceUuid instead, and consult the account inventory (/account/agents) and the reconnect list beside the legacy paired rows, so a machine the user never scanned still counts. ensureRemoteOnline no longer routes a compound id through selectRemoteAgent — that threw Bad state: No element on the recent-agents lookup, and re-pointed the focus at the machine instead of the project the user opened. It dials the given registration id and writes no focus. RemoteHostChip's platform becomes nullable and is read from the inventory: the reconnect list caches coordinates and not a platform, and every host is desktop-class, so unstated renders as desktop and the server glyph is left for a platform this build does not know. session_row_start_refusal_test now installs the standard store overrides — the tap reads the recent-agents store, and a throw there is swallowed as a failed activation, i.e. a tap that silently does nothing. * Stop telling the user to scan a QR code QR scanning is gone — there is no scanner screen, no camera permission, no scanner package, and nothing left that emits a QR payload. What survived is the copy in the Forget-machine dialog, which promised the user a way back that does not exist: "You'll need to scan the QR code again to reconnect." Forgetting a machine clears its cached sessions, ports and connection details; the machine itself comes straight back from the account inventory while it is signed in. Say that instead. Also drops the unreferenced PairException (it existed to report a failed QR coordinate import) and corrects the comments and requirements that still described admission as a QR pairing. The compound . id shape those comments explain is not QR-era — it is how a remote project is addressed today — so the shape notes stay, minus the attribution. * Delete the paired-agent store, the last of QR pairing Nothing has written the paired_agents blob since admission became account trust — the QR scan was its only producer — so the list was empty on every install and every reader of it was answering from nothing: - entryIsRelayProvider and activeAgentProvider matched it first and fell through to the reconnect list; only the fallback ever fired. - _buildRelayTransportFor preferred the paired row's pinned relayUrl over the coordinates it had just resolved freshness-first from /account/agents. - selectRemoteAgent's paired branch could not be reached, and selectAgent behind it could not resolve anything. - forgetMachine rewrote a list that was already empty. PairedAgentNotifier keeps only the machine-connection actions (select, forget, cancel, retry) and is renamed MachineConnectionNotifier over a void state; StorageService keeps only clearPairedAgents, which stays so sign-out still evicts the legacy blob from an install that predates the change. The six test fakes that existed to stop the real notifier reading secure storage go with it; paired_agent_forget_test becomes forget_machine_test. * Close the review findings on the QR/base-uuid sweep The base-uuid rewrite armed three branches that were unreachable while the paired list was empty, and each of them was wrong once live: - cancelActiveAgent released the machine socket unconditionally. It is machine-level and un-refcounted, so cancelling from a project whose last session was just deleted killed every other warm project stream and the control plane on that machine. Guarded on a non-Connected supervisor, the same way the control-plane reaper guards its own release. - ensureRemoteOnline awaited a transport element already settled in an error that noProviderRetry guarantees Riverpod will never re-run, so an offline machine could never be re-dialled from a row. It now retries the supervisor AND invalidates, and reports a null transport as a failure rather than as success into a 30s warm-up. - The duplicate-tap guard read the FOCUSED machine's reachability while gating a tap on ANY entry, so one machine mid-dial made every other drawer row inert. It now requires the tapped machine to be the focused one. entryIsRelayProvider grew the two filters the transport builder applies (never the local uuid, never a machine with no relayUrl), so it can no longer call an entry relay that the transport opens locally. entryIsLocalCheckoutProvider takes noProviderRetry — a gate the kebab awaits must settle rather than sit pending through a backoff — and its refusal arm now speaks. RecentAgentsStore.list() drops a corrupt blob instead of throwing it into the synchronous providers that now watch it. forgetMachine resolved its purge set from the reconnect list, which holds bare machine uuids, while every per-entry store is keyed by the project's compound drawer id — so no remote project's cached sessions were ever purged. It now unions the warm registry and the session cache, the latter being the only source that names a machine's COLD projects, which is most of them at the moment it is forgotten. It also bails on an unmounted ref between awaits. activeAgentProvider becomes focusedMachineNameProvider (a String, so == dedupes the rebuild) and shares one resolver with the title bar's host chip: recents-first for the name, matching the three other machine-label sites, inventory for the platform and for the first connect, where nothing is cached yet. Copy: sign-out no longer promises a re-pairing step, and the relay-URL setting describes the field it actually controls. --- app/CLAUDE.md | 2 +- app/android/gradle.properties | 2 +- app/app-requirements.md | 21 +- app/lib/models/drawer_entry.dart | 9 +- app/lib/providers/agent_coordinates.dart | 36 ++++ app/lib/providers/agent_transport.dart | 12 +- app/lib/providers/drawer_entries.dart | 12 +- app/lib/providers/drawer_expansion.dart | 2 +- app/lib/providers/entry_cleanup.dart | 2 +- app/lib/providers/open_checkout.dart | 71 +++++++ app/lib/providers/providers.dart | 183 ++++++++++------- app/lib/screens/app_settings_screen.dart | 3 +- app/lib/screens/app_shell.dart | 5 +- app/lib/screens/workspace_shell.dart | 17 +- app/lib/services/storage_service.dart | 46 +---- app/lib/storage/recent_agents_store.dart | 18 +- app/lib/util/device_id.dart | 4 +- app/lib/widgets/agent_panel.dart | 60 ++++-- app/lib/widgets/drawer_entry_row.dart | 132 ++++++++++--- app/lib/widgets/projects_drawer.dart | 8 +- app/lib/widgets/remote_host_chip.dart | 26 ++- app/lib/widgets/session_row.dart | 35 ++-- app/lib/widgets/sign_out_action.dart | 4 +- app/test/demo/demo_isolation_test.dart | 9 + app/test/helpers/workspace_shell_harness.dart | 40 ++-- .../agent_transport_coords_retry_test.dart | 6 - .../agent_transport_identity_test.dart | 6 - .../agent_transport_machine_creds_test.dart | 7 - app/test/providers/agent_transport_test.dart | 8 - .../control_plane_lifetime_test.dart | 7 - app/test/providers/entry_cleanup_test.dart | 26 +-- .../entry_is_local_checkout_test.dart | 157 +++++++++++++++ app/test/providers/entry_is_relay_test.dart | 185 ++++++++++++++++++ ...get_test.dart => forget_machine_test.dart} | 120 +++--------- .../providers/open_checkout_guard_test.dart | 143 ++++++++++++++ .../relay_connection_supervisor_test.dart | 41 +--- app/test/screens/app_shell_test.dart | 26 +-- app/test/widgets/agent_panel_test.dart | 97 +++++++++ .../drawer_entry_row_activation_test.dart | 44 +---- .../widgets/remote_access_panel_test.dart | 4 +- .../session_row_start_refusal_test.dart | 7 + .../window_title_bar_contents_test.dart | 38 ++-- bridge/tests/relay-promotion.test.ts | 2 +- .../lib/antgrid_relay_client.dart | 1 - .../lib/src/pair_exception.dart | 22 --- packages/antgrid_relay_client/pubspec.yaml | 2 +- 46 files changed, 1143 insertions(+), 565 deletions(-) create mode 100644 app/test/providers/entry_is_local_checkout_test.dart create mode 100644 app/test/providers/entry_is_relay_test.dart rename app/test/providers/{paired_agent_forget_test.dart => forget_machine_test.dart} (63%) create mode 100644 app/test/providers/open_checkout_guard_test.dart delete mode 100644 packages/antgrid_relay_client/lib/src/pair_exception.dart diff --git a/app/CLAUDE.md b/app/CLAUDE.md index 2e1c1da1..71f2c65f 100644 --- a/app/CLAUDE.md +++ b/app/CLAUDE.md @@ -16,7 +16,7 @@ app's relay layer lives outside this tree: see - `services/` — 7 per-project services (`FileService`, `SessionsService`, `TerminalService`, `ConfigService`, `SearchService`, `CommandService`, `PreviewService`), each `XxxService.fromSession(session)` owned by a `ProjectSession`. They subscribe to `session.heavyStream`/`statusStream` in their constructor (so welcome-cached messages are caught), send via `session.send(...)`, and have no app-wide singleton — `xServiceProvider` returns the focused project's instance. Local and relay flows are unified: `selectedProjectIdProvider` is the single focus id; `agentTransportForProvider(id)` picks relay vs local; `projectSessionProvider(id)` wires the session + 7 services. - `project/project_session.dart` — per-project aggregate (transport + MessageRouter + ProjectStatusNotifier + services). Lifetime via `ProjectSessionRegistry`, **no `autoDispose`**. - `project/project_session_registry.dart` — warm-projects LRU with per-bucket caps (`warmCapForBucket` in `limits.dart`): desktop = local `kWarmCapLocal=10` + relay `kWarmCapRelay=30` as SEPARATE quotas (no shared budget — opening a relay socket never evicts a local agent), mobile = `kWarmCapMobile=3` for both. `touch(id, {required isLocal})` buckets each project; eviction stays within the overflowing bucket (oldest by last-focus time, `selectEvictionVictim` in `lru_policy.dart`; the just-focused project is protected). `onEvict` writes final status to cache and invalidates session+transport providers. v3: evicting a relay project closes its stream binding, not a socket — the machine's `RelayConnection` is released only when nothing on that machine needs it (control-plane reaper in `control_plane.dart`). -- `screens/` — terminal, file explorer, preview, scanner (QR coordinate import — not a rendezvous). `workspace_shell.dart` = responsive layout, split at `kCompactBreakpoint` (mobile PageView swipe; desktop/tablet rail + resizable split — same layout for both). Signed-in user (email + tier pill) renders via `AccountFooter` in the drawer. `widgets/window_title_bar.dart` mounts above the route only for a non-touch (mouse) desktop at `>= kMediumBreakpoint` (`app_shell.dart` — `isMobilePlatform`, i.e. Android/iOS, skips it at ANY width, same rationale as `_defaultPanelMode` below) and owns the nav/chips/window-controls row plus the two pane toggles at its outer edges — the projects drawer on the left and the context panel on the right — which WorkspaceShell publishes through `sidebarControlProvider` / `contextPanelControlProvider` because the bar mounts above its route (hiding either pane takes its own affordances with it, so these controls are the only way back). `new_session_screen.dart`'s `NewSessionScreen` publishes its own `sidebarControlProvider` the same way (there is no context panel on that route, so it alone), backed by the same `sidebarHidden` app setting — the drawer's hidden/shown state and its title-bar toggle are shared across both routes, not WorkspaceShell-only. `_PanelMode.contextExpanded` renders no collapsed agent stub of its own (unlike `contextHidden`'s full-width agent panel, expanded has no equivalent affordance) — the title bar's context-panel toggle is the only way back from it, same as from `contextHidden`. Two DIFFERENT no-title-bar cases, two DIFFERENT recoveries, both in `WorkspaceShellState._buildDesktop`: a narrow non-touch desktop window (`kCompactBreakpoint..kMediumBreakpoint`) forces the drawer permanently visible (ignores `sidebarHidden`) and reveals the context panel by restoring `_PanelMode.normal`; a touch tablet (any width) routes to `_buildTabletTouch` instead, where BOTH the sidebar and the context panel are docked panes like the mouse desktop's — the sidebar open by default (matching the mouse desktop's own always-on rail), the context pane closed by default unlike `_PanelMode.normal`: a session opens on the agent alone plus the sidebar, and the context pane appears only once the user picks a view from `WorkspaceMenuButton`'s popup (no swipe opens it — see below); the popup is up by default while no view is on screen, so it needs no tap to reach — both hand-rolled and ALWAYS-mounted, deliberately NOT real `Scaffold.drawer`/`endDrawer`s, since Flutter's `DrawerController` drops its child from the tree entirely while closed, which would dispose `WorkspacePanel`'s `PreviewScreen` WebView and terminal on every swipe-close of the context pane (the same class of regression the `_agentPanelKey`/`_contextPanelKey` GlobalKeys below exist to prevent). Neither pane's own width ever changes during its open/close animation (only an `AnimatedSlide` offset does, so neither is ever laid out at an intermediate width) while the agent pane's reserved space on both sides animates via ONE `AnimatedPadding` on the same duration/curve as both slides, so all three move in lockstep. Both cases' context-panel reveal funnels through `_openContextPanel`, so `WorkspaceMenuButton` (in `AgentBar`) and `revealHandlerTab` never need to know which one is live; on the tablet, that button shows the SAME popup as desktop regardless of pane state, and the shell keeps that popup DOWN for as long as any view is on screen (`_syncMenuToContextPane` — the pane's own `WorkspaceTabBar` already lists the same five views), restoring it when the pane closes only if it was the one that hid it — a tap only ever shows/hides that popup, on every platform, never a side-effecting shortcut on the pane itself — see the button's own doc. `AgentBar` also grows a leading "Projects" button, touch platform only, opening the sidebar pane the same way, alongside the swipe (one raw-pointer fling dispatcher for all four pane actions, `_onTabletFlingDown`/`Up`, mirroring mobile's own fling-not-edge-drag drawer gesture — edge-anchored drags are not available to us, since Android's system back owns both edges) as the discoverable opener. **Only the sidebar answers to a fling, and only on its own half of the screen** (split at the agent pane's midpoint): the context pane takes no swipe in either direction — it opens from `WorkspaceMenuButton`'s popup and closes from its tab bar's close button, since a pane a swipe could also OPEN made every sideways drag over the agent a coin flip. A fling never reaches across the window either (the far-pane fallback that used to close the sidebar from the right edge is gone), so a gesture with nothing to do on its own side does nothing. **Nothing arbitrates with that gesture any more** — the git rows' swipe tray, the workspace tab strip and the code viewers all live in the context pane, whose half now moves no pane at all, so the whole claim-flag mechanism they fed (`util/swipe_row_arbitration.dart`) is deleted. The dispatcher still watches raw pointers, so it fires under a descendant that already won the arena: putting a swipeable widget beside the agent, or giving the context pane a fling again, brings that arbitration question back with it. `new_session_screen.dart`'s touch branch mirrors this same sidebar treatment (`_tabletSidebarOpen`) at any width `>= kCompactBreakpoint`, falling back to a real swiped-in `Scaffold.drawer` only at phone width, same as `WorkspaceShell`'s own three-way split. The session's breadcrumb/branch/agent-mark/mode/handler cluster belongs to `AgentBar` (`widgets/agent_panel.dart`), which mirrors `WorkspaceTabBar` across the divider; in the panel modes that mount no agent bar (`agentBarMountedProvider`), the title bar takes back only the mode control (`SessionModeControl`) — never the name (whose duplicate flashed a project id one row up before the name settled), and never the agent mark or handler shield, which the title bar carries no fallback for at all. The bar's elastic middle carries `SessionSearchField`; it is also the window drag target, so nothing may fill that gap edge to edge. +- `screens/` — terminal, file explorer, preview, sign-in. `workspace_shell.dart` = responsive layout, split at `kCompactBreakpoint` (mobile PageView swipe; desktop/tablet rail + resizable split — same layout for both). Signed-in user (email + tier pill) renders via `AccountFooter` in the drawer. `widgets/window_title_bar.dart` mounts above the route only for a non-touch (mouse) desktop at `>= kMediumBreakpoint` (`app_shell.dart` — `isMobilePlatform`, i.e. Android/iOS, skips it at ANY width, same rationale as `_defaultPanelMode` below) and owns the nav/chips/window-controls row plus the two pane toggles at its outer edges — the projects drawer on the left and the context panel on the right — which WorkspaceShell publishes through `sidebarControlProvider` / `contextPanelControlProvider` because the bar mounts above its route (hiding either pane takes its own affordances with it, so these controls are the only way back). `new_session_screen.dart`'s `NewSessionScreen` publishes its own `sidebarControlProvider` the same way (there is no context panel on that route, so it alone), backed by the same `sidebarHidden` app setting — the drawer's hidden/shown state and its title-bar toggle are shared across both routes, not WorkspaceShell-only. `_PanelMode.contextExpanded` renders no collapsed agent stub of its own (unlike `contextHidden`'s full-width agent panel, expanded has no equivalent affordance) — the title bar's context-panel toggle is the only way back from it, same as from `contextHidden`. Two DIFFERENT no-title-bar cases, two DIFFERENT recoveries, both in `WorkspaceShellState._buildDesktop`: a narrow non-touch desktop window (`kCompactBreakpoint..kMediumBreakpoint`) forces the drawer permanently visible (ignores `sidebarHidden`) and reveals the context panel by restoring `_PanelMode.normal`; a touch tablet (any width) routes to `_buildTabletTouch` instead, where BOTH the sidebar and the context panel are docked panes like the mouse desktop's — the sidebar open by default (matching the mouse desktop's own always-on rail), the context pane closed by default unlike `_PanelMode.normal`: a session opens on the agent alone plus the sidebar, and the context pane appears only once the user picks a view from `WorkspaceMenuButton`'s popup (no swipe opens it — see below); the popup is up by default while no view is on screen, so it needs no tap to reach — both hand-rolled and ALWAYS-mounted, deliberately NOT real `Scaffold.drawer`/`endDrawer`s, since Flutter's `DrawerController` drops its child from the tree entirely while closed, which would dispose `WorkspacePanel`'s `PreviewScreen` WebView and terminal on every swipe-close of the context pane (the same class of regression the `_agentPanelKey`/`_contextPanelKey` GlobalKeys below exist to prevent). Neither pane's own width ever changes during its open/close animation (only an `AnimatedSlide` offset does, so neither is ever laid out at an intermediate width) while the agent pane's reserved space on both sides animates via ONE `AnimatedPadding` on the same duration/curve as both slides, so all three move in lockstep. Both cases' context-panel reveal funnels through `_openContextPanel`, so `WorkspaceMenuButton` (in `AgentBar`) and `revealHandlerTab` never need to know which one is live; on the tablet, that button shows the SAME popup as desktop regardless of pane state, and the shell keeps that popup DOWN for as long as any view is on screen (`_syncMenuToContextPane` — the pane's own `WorkspaceTabBar` already lists the same five views), restoring it when the pane closes only if it was the one that hid it — a tap only ever shows/hides that popup, on every platform, never a side-effecting shortcut on the pane itself — see the button's own doc. `AgentBar` also grows a leading "Projects" button, touch platform only, opening the sidebar pane the same way, alongside the swipe (one raw-pointer fling dispatcher for all four pane actions, `_onTabletFlingDown`/`Up`, mirroring mobile's own fling-not-edge-drag drawer gesture — edge-anchored drags are not available to us, since Android's system back owns both edges) as the discoverable opener. **Only the sidebar answers to a fling, and only on its own half of the screen** (split at the agent pane's midpoint): the context pane takes no swipe in either direction — it opens from `WorkspaceMenuButton`'s popup and closes from its tab bar's close button, since a pane a swipe could also OPEN made every sideways drag over the agent a coin flip. A fling never reaches across the window either (the far-pane fallback that used to close the sidebar from the right edge is gone), so a gesture with nothing to do on its own side does nothing. **Nothing arbitrates with that gesture any more** — the git rows' swipe tray, the workspace tab strip and the code viewers all live in the context pane, whose half now moves no pane at all, so the whole claim-flag mechanism they fed (`util/swipe_row_arbitration.dart`) is deleted. The dispatcher still watches raw pointers, so it fires under a descendant that already won the arena: putting a swipeable widget beside the agent, or giving the context pane a fling again, brings that arbitration question back with it. `new_session_screen.dart`'s touch branch mirrors this same sidebar treatment (`_tabletSidebarOpen`) at any width `>= kCompactBreakpoint`, falling back to a real swiped-in `Scaffold.drawer` only at phone width, same as `WorkspaceShell`'s own three-way split. The session's breadcrumb/branch/agent-mark/mode/handler cluster belongs to `AgentBar` (`widgets/agent_panel.dart`), which mirrors `WorkspaceTabBar` across the divider; in the panel modes that mount no agent bar (`agentBarMountedProvider`), the title bar takes back only the mode control (`SessionModeControl`) — never the name (whose duplicate flashed a project id one row up before the name settled), and never the agent mark or handler shield, which the title bar carries no fallback for at all. The bar's elastic middle carries `SessionSearchField`; it is also the window drag target, so nothing may fill that gap edge to edge. - **The session search is a POPUP, not a filter over anything on screen** (`widgets/session_search_field.dart` + `providers/session_search.dart`). It answers into its own `OverlayPortal` from any route, which is why it neither narrows the drawer (project rows the user is already looking at) nor the Recent list. Results come from `sessionSearchResultsProvider` over Recent's rows — the one flat view spanning machines and projects — which reads the persisted session cache and NEVER the wire: a keystroke must not dial a machine. The popup opens on FOCUS (so Ctrl+K alone reveals it, resting on the recent list) and closes on its barrier, on Escape-when-empty, or on a row being taken (`RecentSessionRowWidget.onOpened`); blur must NOT close it, because the rows are focusable. **Desktop and mobile are deliberately DIFFERENT surfaces, not one responsive widget** — and a touch tablet is mobile's surface at ANY width, and a narrow non-touch desktop window is too (`< kMediumBreakpoint`), because neither has a title bar to host a field in. Desktop is `SessionSearchField` — an always-open title-bar field whose popup measures its width and max height off the field (a pointer pattern). Everything else gets `SessionSearchButton` + `showSessionSearch()` (`widgets/session_search_modal.dart`) — an icon opening a full-screen `Dialog.fullscreen`, which is what both Material and iOS prescribe: a phone (or any title-bar-less window) has no row to spare for a permanent text box, and an anchored popup loses most of itself to the keyboard. On the New Session/Recent screen the button sits in `NewSessionContent`'s `_TopBar`; `new_session_screen.dart` routes any touch platform through the SAME branch as phone width (`isMobile || isMobilePlatform`) for the full hamburger+button bar, reserving the search-only variant (`showSearchButton`, no hamburger — the drawer there is a persistent pane, not a slide-in) for a narrow non-touch desktop window. Inside a session, `WorkspaceShellState._focusSessionSearch` opens the same modal for Ctrl/⌘-K whenever `isMobilePlatform` or the window is below `kMediumBreakpoint`. A dialog ROUTE, not an overlay, so system back closes it. Only the shared `SessionSearchResults` (`widgets/session_search_results.dart`) is common, so the surfaces can never answer differently. Mobile/tablet clear the query on close (nothing survives to show a stale one); desktop keeps it. - `models/` — Dart mirrors of agent protocol types. - `demo/` — the offline sample project reachable with no account (`kDemoEntryLabel`). `providers/demo_mode.dart`'s `demoModeProvider` is the single switch, in memory only; while it is on `agentTransportForProvider` hands back a `DemoTransport` — a real `AgentTransport` over canned wire frames, so the demo renders through the real `MessageRouter`, the real per-project services and the real widgets rather than a parallel set of fakes — and `screens/demo_home.dart` replaces `AppShell` as the root route (AppShell's `initState` dials the relay and reads the keychain). `demo/fixtures/` are RAW wire envelopes on purpose: a frame whose shape `parseAbMessage` rejects is dropped silently and only shows up as an empty surface, which is what `test/demo/demo_fixture_contract_test.dart` walks every one of them for. **The invariant is that nothing about the demo reaches disk, spawns a bridge host, or phones home, and no real state is written under it** — and it is held by scattered call-site gates, not by a boundary: every persistence store, host-spawn path, analytics sink and first-run latch has to check `demoModeProvider` (or `isDemoProjectId`/`isDemoEntryId` from `demo/demo_identity.dart`) for itself, so anything added later leaks by default. Prefer gating inside the STORE over gating at each caller, and pin the new gate in `test/demo/demo_isolation_test.dart`. The host-spawn class is the one that keeps recurring, because a `LocalProject` target is what arms it and the demo's target IS one: every `ensureHost()` caller reachable from the workspace or the New Session canvas needs its own gate, and each swallows its failure, so the value it returns cannot tell a gate from a spawn — the spy test there asserts the controller was never read. diff --git a/app/android/gradle.properties b/app/android/gradle.properties index 97e47654..92bb0dde 100644 --- a/app/android/gradle.properties +++ b/app/android/gradle.properties @@ -14,7 +14,7 @@ android.useAndroidX=true # (settings.gradle.kts + app `id("kotlin-android")`). Flipping this to true and # dropping those declarations DOES build, but it only silences the *app-level* # KGP-deprecation warning; the dominant warning comes from upstream plugins -# (mobile_scanner, shared_preferences_android, webview_flutter_android) +# (shared_preferences_android, webview_flutter_android) # applying KGP themselves and persists until THEY # migrate — so the flip buys no clean build and adopts an experimental default # early. The KGP warning is emitted unconditionally by Flutter and is NOT diff --git a/app/app-requirements.md b/app/app-requirements.md index 01796d8d..2cf8e4c6 100644 --- a/app/app-requirements.md +++ b/app/app-requirements.md @@ -28,7 +28,7 @@ The app connects outbound to the relay server. It never communicates directly wi 1. **Flutter** — Single codebase for iOS, Android, macOS, Windows, Linux. 2. **Two WebSocket channels** — One for command/control (terminals, files, notifications), one for browser preview traffic. Both carry E2E encrypted payloads. 3. **Offline-resilient** — The app can disconnect and reconnect at any time. On reconnect, it catches up via the relay's offline queue and the agent's scrollback buffers. -4. **E2E encryption** — The app holds the shared secret (from QR pairing). All encryption/decryption happens on-device. Nothing is sent in plaintext. +4. **E2E encryption** — Session keys are derived per connection (X25519 ECDH), never persisted. All encryption/decryption happens on-device. Nothing is sent in plaintext. 5. **Multi-project aware** — The agent serves multiple projects. The app must support project switching. --- @@ -44,14 +44,11 @@ The app connects outbound to the relay server. It never communicates directly wi ## Functional Requirements -### 1. Pairing +### 1. Admission -- The app must scan a QR code displayed by the Antgrid Agent to establish a pairing. -- The QR payload contains: relay URL, agent device ID, shared secret, agent name, protocol version. -- Manual short code entry (e.g., "ANTGRID-AX3F") must be supported as a fallback. -- On successful pairing, the app must store the pairing credentials securely on-device. -- The app must support pairing with multiple agents (e.g., work laptop, home desktop) and switching between them. -- Only one agent connection is active at a time. +- Signing in to the same account on both ends is the whole admission step — there is no pairing ceremony, QR code, or short code. +- The app must resolve a machine's dial coordinates and Ed25519 key from the account inventory, and cache them on-device for offline reconnects. +- The app must support several machines on one account (e.g., work laptop, home desktop) and switching between them. ### 2. Terminal Viewer @@ -120,8 +117,8 @@ The app connects outbound to the relay server. It never communicates directly wi ## Security Requirements -- Shared secret from QR pairing must be stored in platform-secure storage (iOS Keychain, Android Keystore). -- All data sent to the relay must be encrypted with AES-256-GCM using the shared secret. +- Device credentials must be stored in platform-secure storage (iOS Keychain, Android Keystore); session keys are per-connection and never persisted. +- All data sent to the relay must be encrypted with AES-256-GCM under the handshake-derived session keys. - No plaintext user data may leave the device (except the unencrypted envelope: device IDs and channel type). - The app must validate the relay's TLS certificate. - Biometric/PIN lock option before accessing the app (optional, user-configurable). @@ -194,8 +191,8 @@ The app connects outbound to the relay server. It never communicates directly wi ## Development Phases -### Phase 1 — Shell & Pairing -Flutter project setup, QR scanner, relay WebSocket connection, encryption layer, device registration, pairing flow. Verify: scan QR → connect to relay → pair with agent. +### Phase 1 — Shell & Admission +Flutter project setup, account sign-in, relay WebSocket connection, encryption layer, device registration. Verify: sign in → connect to relay → reach the account's agent. ### Phase 2 — Terminal Viewer Terminal emulation widget, multi-tab support, keyboard input, send/receive terminal messages, scrollback on reconnect, quick-action keys. Verify: view and interact with agent terminals. diff --git a/app/lib/models/drawer_entry.dart b/app/lib/models/drawer_entry.dart index ca920656..734938db 100644 --- a/app/lib/models/drawer_entry.dart +++ b/app/lib/models/drawer_entry.dart @@ -18,7 +18,7 @@ sealed class DrawerEntry { /// Bare device uuid when this entry represents a remote MACHINE (a /// same-account paired machine or an inventory machine) — the value whose /// control plane advertises projects and under which open projects' sessions - /// nest. Null for a local project or a QR-paired entry that is itself a single + /// nest. Null for a local project or an entry that is itself a single remote /// project (compound `.` id), neither of which is a machine /// the drawer expands into per-project sessions. String? get machineUuid => null; @@ -44,10 +44,9 @@ class RemoteAgentEntry extends DrawerEntry { final RecentAgent agent; RemoteAgentEntry(this.agent); - /// A same-account machine-level pairing persists the BARE `deviceUuid` (no - /// dot); a legacy QR pairing persists the compound `.`. The - /// dot tells the two apart: bare → this row is a machine; compound → a single - /// project. + /// A machine persists the BARE `deviceUuid` (no dot); a legacy per-project + /// row persists the compound `.`. The dot tells the two + /// apart: bare → this row is a machine; compound → a single project. bool get _isMachineLevel => !agent.agentDeviceId.contains('.'); @override diff --git a/app/lib/providers/agent_coordinates.dart b/app/lib/providers/agent_coordinates.dart index 37ae42a5..259f9c74 100644 --- a/app/lib/providers/agent_coordinates.dart +++ b/app/lib/providers/agent_coordinates.dart @@ -2,6 +2,7 @@ import 'package:collection/collection.dart'; import '../services/account_agents_api.dart'; import '../storage/recent_agents_store.dart'; +import '../util/device_id.dart'; /// An agent's endpoint/identity coordinates: where to dial it and which /// Ed25519 pubkey its handshake must verify against, plus display metadata. @@ -69,3 +70,38 @@ AgentCoordinates? resolveAgentCoordinates({ } return null; } + +/// How the UI NAMES a machine, and which platform glyph belongs to it. +/// +/// The recents pass runs first and a match ENDS it, because the two sources may +/// name the same machine differently and a machine named from the recents on +/// one surface and from the inventory on another reads as two machines. This is +/// the same order `_machineLabel` (`widgets/drawer_entry.dart`), +/// `_remoteMachineLabel` (`widgets/recent_session_row.dart`) and +/// `_machineLabelFor` (`widgets/new_session_action.dart`) settle it in. +/// +/// The platform still comes from the inventory whichever source named it — that +/// is the only source carrying one, and a glyph cannot disagree with a name. +/// Returns null when neither source knows [base]. +({String name, String? platform})? resolveMachineDisplay({ + required String base, + required List? inventory, + required List recents, +}) { + final inv = inventory?.firstWhereOrNull((a) => a.deviceUuid == base); + final cached = recents.firstWhereOrNull( + (r) => baseDeviceUuid(r.agentDeviceId) == base, + ); + final coords = resolveAgentCoordinates( + base: base, + // Recents first: pass the inventory only when it is the sole source. + inventory: cached == null ? inventory : null, + cached: cached, + ); + if (coords == null) return null; + final machine = coords.machineName?.trim(); + return ( + name: machine != null && machine.isNotEmpty ? machine : coords.label, + platform: inv?.platform, + ); +} diff --git a/app/lib/providers/agent_transport.dart b/app/lib/providers/agent_transport.dart index 9751589b..f4e0148b 100644 --- a/app/lib/providers/agent_transport.dart +++ b/app/lib/providers/agent_transport.dart @@ -177,10 +177,6 @@ Future _buildRelayTransportFor( InventoryAgent? uncachedInventoryHit; final inventory = ref.read(accountAgentsProvider).value; - final agents = ref.read(pairedAgentProvider).value ?? const []; - final paired = agents.firstWhereOrNull( - (a) => baseDeviceUuid(a.agentDeviceId) == base, - ); final recent = recentStore.list().firstWhereOrNull( (r) => baseDeviceUuid(r.agentDeviceId) == base, ); @@ -193,9 +189,7 @@ Future _buildRelayTransportFor( inventory: inventory, cached: recent, ); - if (paired != null && recent != null && coords != null) { - resolve = (agent: paired, agentEd25519PubB64: coords.ed25519Pub); - } else if (recent != null && coords != null) { + if (recent != null && coords != null) { resolve = ( agent: PairedAgent( relayUrl: coords.relayUrl ?? recent.relayUrl, @@ -245,7 +239,7 @@ Future _buildRelayTransportFor( final invHit = uncachedInventoryHit; if (invHit != null) { - // The activation funnel for the no-QR path. `uncachedInventoryHit` is set + // The activation funnel. `uncachedInventoryHit` is set // only when no cached row backed the machine, so this fires once per // newly-reached machine rather than on every warm rebuild. The `reconnect` // variant this event used to carry has no successor: reconnecting is the @@ -301,7 +295,7 @@ Future _buildRelayTransportFor( } final tokenMinter = minter; // Freshness-first, same polarity as the pubkey: the inventory-resolved - // endpoint wins over the one pinned on the stored PairedAgent, which is + // endpoint wins over the one pinned on the cached machine row, which is // exactly the value that goes stale when a host moves relay. final relayUrl = coords?.relayUrl ?? r.agent.relayUrl; // Read through the container-lifetime resolvers, never through THIS diff --git a/app/lib/providers/drawer_entries.dart b/app/lib/providers/drawer_entries.dart index 3a93d0de..57a61c04 100644 --- a/app/lib/providers/drawer_entries.dart +++ b/app/lib/providers/drawer_entries.dart @@ -28,12 +28,12 @@ import 'recent_agents.dart'; /// when no local project happens to be open (the dedup above relies on an open /// local project to cover it, which isn't guaranteed). /// -/// Note on id formats: QR-paired agents persist [RecentAgent.agentDeviceId] as -/// the agent's full registrationId — `.` — because the -/// QR `d=` param carries the compound registrationId. Inventory rows from the -/// web report only the bare `deviceUuid`. We normalize the recent-id -/// to its `` prefix before deduping so QR-paired agents don't -/// render twice once their inventory entry loads. +/// Note on id formats: a machine's [RecentAgent.agentDeviceId] is the bare +/// `deviceUuid`, but a legacy per-project row persists the compound +/// `.` registrationId. Inventory rows from the web +/// report only the bare `deviceUuid`, so we normalize the recent id to its +/// `` prefix before deduping — otherwise such a row renders twice +/// once its inventory entry loads. List mergeDrawerEntries({ required List locals, required List remotes, diff --git a/app/lib/providers/drawer_expansion.dart b/app/lib/providers/drawer_expansion.dart index 4db3f624..451cf40b 100644 --- a/app/lib/providers/drawer_expansion.dart +++ b/app/lib/providers/drawer_expansion.dart @@ -5,7 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; /// advertised PROJECT sub-rows nested under them (keyed by the compound /// `.` regId). This is the inverse of /// [collapsedDrawerIdsProvider], which serves rows that default to EXPANDED -/// (local + QR-paired projects). +/// (local projects). /// /// Kept in-memory (never persisted): a freshly-launched app must start with /// every remote machine CLOSED so it doesn't open control-plane sockets the diff --git a/app/lib/providers/entry_cleanup.dart b/app/lib/providers/entry_cleanup.dart index 8804fdfd..a4533e04 100644 --- a/app/lib/providers/entry_cleanup.dart +++ b/app/lib/providers/entry_cleanup.dart @@ -36,7 +36,7 @@ Future _runBestEffortSteps( /// resurrects when the same id reappears (the classic symptom: a removed /// project's old session list comes back on reopen). Centralizing it here keeps /// the local (`ProjectsNotifier.remove`) and remote -/// (`PairedAgentNotifier.forgetMachine`) paths from drifting out of sync. +/// (`MachineConnectionNotifier.forgetMachine`) paths from drifting out of sync. /// /// Caller contract: invoke AFTER the id has been evicted from the warm registry /// via [ProjectSessionRegistry.forceEvictAndSettle] (not the fire-and-forget diff --git a/app/lib/providers/open_checkout.dart b/app/lib/providers/open_checkout.dart index de592a70..7227e3cc 100644 --- a/app/lib/providers/open_checkout.dart +++ b/app/lib/providers/open_checkout.dart @@ -3,10 +3,15 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../demo/demo_identity.dart'; import '../design/widgets/ab_snack_bar.dart'; import '../launcher/host_control_client.dart'; +import '../util/ab_log.dart'; import '../util/external_open_target.dart'; import '../utils/platform_utils.dart'; +import 'device_provisioning.dart'; +import 'projects.dart'; +import 'provider_retry.dart'; import 'remote_access.dart'; /// Which external apps this machine can open a checkout in. @@ -21,6 +26,44 @@ final externalOpenTargetsProvider = FutureProvider>(( return detectExternalOpenTargets(); }); +/// True iff [entryId] names a project whose checkout lives on THIS device — +/// the only case the loopback control plane can answer about. +/// +/// An ALLOWLIST, not a remote-blocklist: an id this device holds no local +/// project for is a remote machine's (`.`), a machine +/// itself, or something that has been removed, and every one of those must lose +/// the working-directory affordances. Asking the local store the same way +/// `projectDisplayNameProvider` does — rather than asking whether some machine +/// list happens to mention the id — is what keeps a compound remote id from +/// reading as local: it can never equal a bare local `projectId`. +/// +/// The demo owns no checkout at all, and answering for it would spawn the +/// bridge host from inside a sample project that promises nothing is connected. +/// [noProviderRetry] because the gate must SETTLE: Riverpod 3 otherwise holds +/// `.future` pending across a ten-attempt backoff, and the only reader awaits it +/// before opening a menu — a retrying build is a kebab that never answers, not a +/// menu missing two rows. +final entryIsLocalCheckoutProvider = FutureProvider.family(( + ref, + entryId, +) async { + if (isDemoEntryId(entryId)) return false; + final projects = ref.watch(projectsProvider); + final String? localUuid; + try { + localUuid = await ref.watch(localDeviceUuidProvider.future); + } catch (_) { + return false; + } + // Null on mobile/web, and until the desktop uuid is minted. Local-vs-remote + // is undecidable then, and the safe answer is the one that offers nothing. + if (localUuid == null) return false; + for (final project in projects) { + if (project.projectId == entryId) return project.isLocalFor(localUuid); + } + return false; +}, retry: noProviderRetry); + /// Open a session's working directory in [target]. /// /// LOCAL projects only. The path is resolved over the loopback control plane — @@ -91,6 +134,34 @@ Future _resolveCheckoutPath( required String projectId, required String checkoutId, }) async { + // The local-only contract above, enforced rather than documented: the read + // below goes to the LOOPBACK host, so a remote project asks THIS machine + // about a checkout it has never seen — and the ask spawns a host to answer + // it. The menus that offer these rows gate on the same provider; a menu that + // stops gating (or a new caller) must not be able to reach the host. + bool local; + try { + local = await container.read( + entryIsLocalCheckoutProvider(projectId).future, + ); + } catch (_) { + local = false; + } + if (!local) { + AbLog.warn( + 'open_checkout', + 'refused: not a local checkout', + fields: {'projectId': projectId}, + ); + // Reachable with the row already on screen: the menu gates on this same + // provider, then waits on the user's pick, and a project upsert or a uuid + // re-mint in that window flips the answer. Silence here is what leaves a + // stale clipboard the user believes they just replaced. + if (context.mounted) { + showAbSnackBar(context, 'That session is not on this machine.'); + } + return null; + } try { final client = await container.read(hostControlClientProvider.future); return await client.checkoutPath( diff --git a/app/lib/providers/providers.dart b/app/lib/providers/providers.dart index d5de9079..f3e976df 100644 --- a/app/lib/providers/providers.dart +++ b/app/lib/providers/providers.dart @@ -13,15 +13,18 @@ import '../connection/supervisor_state.dart'; import '../demo/demo_identity.dart'; import '../models/ab_message.dart' show CommandInfo, NotificationPushMessage, TerminalNotificationMessage; -import '../models/session_target.dart'; import '../models/terminal_models.dart'; import '../project/project_session.dart'; import '../project/project_session_registry.dart'; +import '../services/account_agents_api.dart' show InventoryAgent; import '../services/license_token_minter.dart'; import '../services/storage_service.dart'; +import 'account_agents.dart'; import 'auth.dart'; +import 'cached_sessions.dart'; import 'device_provisioning.dart'; import 'entry_cleanup.dart'; +import 'agent_coordinates.dart'; import 'agent_transport.dart'; import 'provider_retry.dart'; import 'relay_connection.dart'; @@ -721,16 +724,40 @@ final focusedAgentBlockedProvider = Provider((ref) { return ref.watch(supervisorStatusProvider(id)).value is Blocked; }); -/// True iff [entryId] corresponds to a relay-paired remote agent (as opposed to -/// a locally-opened folder). Keyed by id rather than reading the focus, because -/// drawer rows render for projects that are not the focused one. +/// True iff [entryId] is reached over the relay (as opposed to a locally-opened +/// folder). Keyed by id rather than reading the focus, because drawer rows +/// render for projects that are not the focused one. +/// +/// Matched on the BASE device uuid against every machine record the app holds, +/// which is the same resolution `_buildRelayTransportFor` does to decide what +/// to dial. Matching an id exactly — what this used to do — answers for neither +/// shape that reaches it: a remote PROJECT is `.` while +/// a machine record is keyed by the bare uuid. +/// +/// A legacy project recorded against another device stays FALSE: it carries a +/// bare projectId that names no machine, so the transport treats it as local +/// too, and this must not disagree with what is actually dialled. final entryIsRelayProvider = Provider.family((ref, entryId) { - final agents = ref.watch(pairedAgentProvider).value ?? const []; - return agents.any((a) => a.agentDeviceId == entryId); + // The sample project's transport reports itself local — that is what keeps it + // out of the relay bucket and its push registration. + if (isDemoEntryId(entryId)) return false; + final base = baseDeviceUuid(entryId); + // This machine appears in its OWN account inventory, and the transport + // refuses to dial itself (`a.deviceUuid != localUuid`) — so an inventory hit + // on the local uuid is a LOCAL entry, not a relay one. + if (base == ref.watch(localDeviceUuidProvider).value) return false; + final recent = ref.watch(recentAgentsProvider); + if (recent.any((r) => baseDeviceUuid(r.agentDeviceId) == base)) return true; + final inventory = + ref.watch(accountAgentsProvider).value ?? const []; + // A null `relayUrl` is a machine that has not enabled remote access: the + // transport builder has nothing to dial and falls through to its local arm, + // so calling it relay here would disagree with what is actually opened. + return inventory.any((a) => a.deviceUuid == base && a.relayUrl != null); }); -/// True iff the currently focused id corresponds to a relay-paired remote -/// agent (as opposed to a locally-opened folder). +/// True iff the currently focused id is reached over the relay (as opposed to a +/// locally-opened folder). final focusedIsRelayProvider = Provider((ref) { final id = ref.watch(selectedRegistrationIdProvider); if (id == null) return false; @@ -829,59 +856,47 @@ final controlPlaneResetProvider = () => ValueController(null), ); -final pairedAgentProvider = - AsyncNotifierProvider>( - PairedAgentNotifier.new, +/// The machine-level connection actions — forget, cancel, retry. +/// +/// Holds no state of its own: the machines themselves live in the reconnect +/// list and the account inventory, and each machine's connection is owned by +/// its `ConnectionSupervisor`. +final machineConnectionProvider = + NotifierProvider( + MachineConnectionNotifier.new, ); -/// The currently active agent from the paired list, if any. -final activeAgentProvider = Provider((ref) { +/// What to call the machine behind the current focus, if the app knows one. +/// +/// Resolved by BASE uuid, for the same reason [entryIsRelayProvider] is: a +/// remote project focus is `.`, so an exact match left +/// the connect screen naming a generic "agent" for every machine in the +/// product. The account inventory answers alongside the reconnect list because +/// the FIRST connect to a machine renders this screen before anything has been +/// written to the reconnect list — the row is upserted fire-and-forget during +/// the dial the screen is waiting on. +/// +/// A plain `String?` rather than a record or a model: providers filter updates +/// with `==`, so an identity-compared object would push a rebuild on every +/// reconnect-list write even when the name is unchanged. +final focusedMachineNameProvider = Provider((ref) { final activeId = ref.watch(selectedRegistrationIdProvider); if (activeId == null) return null; - final agents = ref.watch(pairedAgentProvider).value ?? const []; - try { - return agents.firstWhere((a) => a.agentDeviceId == activeId); - } on StateError { - return null; - } + return resolveMachineDisplay( + base: baseDeviceUuid(activeId), + inventory: ref.watch(accountAgentsProvider).value, + recents: ref.watch(recentAgentsProvider), + )?.name; }); -class PairedAgentNotifier extends AsyncNotifier> { - // Connect + v2 handshake (and their retry/repair machinery) are owned by - // RelayConnection now; this notifier only owns the paired-agent list and the - // focus target. Reading the transport provider for an id is what opens its - // dedicated socket and runs the handshake. +class MachineConnectionNotifier extends Notifier { + // Connect + handshake (and their retry/repair machinery) are owned by + // RelayConnection; this notifier only moves the focus target. Reading the + // transport provider for an id is what opens its dedicated socket and runs + // the handshake. @override - Future> build() async { - final storage = ref.read(storageServiceProvider); - return storage.loadPairedAgents(); - } - - Future> _pairedAgentsSnapshot() async { - final loaded = state.value; - if (loaded != null) return List.from(loaded); - try { - return List.from(await future); - } catch (_) { - return ref.read(storageServiceProvider).loadPairedAgents(); - } - } - - Future selectAgent(String agentDeviceId) async { - final agents = state.value ?? []; - final agent = agents - .where((a) => a.agentDeviceId == agentDeviceId) - .firstOrNull; - if (agent == null) return; - - // Focus the agent. Each id owns a dedicated socket, so connect + handshake - // happen when the transport provider for this id is read (workspace boot); - // there is no shared relay to disconnect on switch. - ref - .read(selectedTargetProvider.notifier) - .set(RemoteTarget.legacy(agentDeviceId)); - } + void build() {} Future forgetMachine(String agentDeviceIdOrUuid) async { final machineUuid = baseDeviceUuid(agentDeviceIdOrUuid); @@ -890,41 +905,55 @@ class PairedAgentNotifier extends AsyncNotifier> { ref.read(selectedTargetProvider.notifier).set(null); } - final agents = await _pairedAgentsSnapshot(); final recentStore = ref.read(recentAgentsStoreProvider); final recentAgents = recentStore.list(); final forgottenIds = { - for (final a in agents) - if (baseDeviceUuid(a.agentDeviceId) == machineUuid) a.agentDeviceId, for (final r in recentAgents) if (baseDeviceUuid(r.agentDeviceId) == machineUuid) r.agentDeviceId, }; final mgr = ref.read(relayConnectionManagerProvider); final registry = ref.read(projectSessionRegistryProvider.notifier); + // Every per-entry store is keyed by the DRAWER id, and a remote project's + // is the compound `.` — a shape the reconnect list never + // holds, since it upserts machines by their bare uuid. Purging only those + // rows leaves each project's cached session list behind, which is precisely + // the resurrection this flow promises to clear. The session cache is the + // authority on which projects were ever seen on this machine: the warm + // registry alone answers for the handful open right now, and a machine is + // usually forgotten from a drawer whose rows are all cold. + forgottenIds.addAll( + ref + .read(projectSessionRegistryProvider) + .where((id) => baseDeviceUuid(id) == machineUuid), + ); + forgottenIds.addAll( + ref + .read(cachedSessionsStoreProvider) + .entries() + .keys + .where((id) => baseDeviceUuid(id) == machineUuid), + ); for (final id in forgottenIds) { mgr.release(id); // `AndSettle` (awaited) before purge: eviction's `onEvict` writes the // status cache that `purgeEntryState` then deletes — ordering matters. await registry.forceEvictAndSettle(id); + // Riverpod 3 throws from every `ref` member once the container is gone, + // and this runs fire-and-forget from a tap handler with no catch — a + // sign-out or a window close mid-purge would surface as a crash rather + // than a half-finished forget. + if (!ref.mounted) return; // Clear the forgotten agent's per-entry footprint (cached session list, // recent ports, status cache). Without this the old session list - // resurrects when the machine is re-paired under the same id. + // resurrects when the machine is reached again under the same id. await purgeEntryState(ref, id); + if (!ref.mounted) return; } - final remainingAgents = agents - .where((a) => baseDeviceUuid(a.agentDeviceId) != machineUuid) - .toList(growable: false); - await ref.read(storageServiceProvider).savePairedAgents(remainingAgents); - - for (final r in recentAgents) { - if (baseDeviceUuid(r.agentDeviceId) == machineUuid) { - await recentStore.remove(r.agentDeviceId); - } + for (final id in forgottenIds) { + await recentStore.remove(id); } - - state = AsyncData(remainingAgents); } /// User-initiated escape from the workspace boot screen — clears the @@ -934,14 +963,20 @@ class PairedAgentNotifier extends AsyncNotifier> { void cancelActiveAgent() { final activeId = ref.read(selectedRegistrationIdProvider); ref.read(selectedTargetProvider.notifier).set(null); - // Drop the machine socket + warm session for the cancelled agent so - // re-selecting this id from the home screen rebuilds a fresh connection + // Drop the in-flight machine socket + warm session for the cancelled agent + // so re-selecting this id from the home screen rebuilds a fresh connection // rather than reusing the half-closed one. The connection is machine-level // (bare uuid) — reduce the compound focus id to its base. if (activeId != null) { final mgr = ref.read(relayConnectionManagerProvider); final machineUuid = baseDeviceUuid(activeId); - mgr.release(machineUuid); + // Same guard, and the same reason, as the control-plane reaper's: the + // socket is machine-level and un-refcounted, so releasing an ESTABLISHED + // one from here would kill the live E2E session and every other project + // stream riding it. Cancel only ever abandons an attempt in flight. + if (mgr.peek(machineUuid)?.supervisor?.status is! Connected) { + mgr.release(machineUuid); + } ref.read(projectSessionRegistryProvider.notifier).forceEvict(activeId); } } @@ -960,9 +995,9 @@ class PairedAgentNotifier extends AsyncNotifier> { /// listener. With no supervisor yet (nothing dialled for this machine), the /// rebuild is what starts one. /// - /// The target comes from the FOCUS, not the paired-agent list: a remote - /// project focus is `.` and never matches a - /// `PairedAgent` row keyed by the bare machine uuid. + /// The target comes from the FOCUS, not the reconnect list: a remote project + /// focus is `.` and never matches a machine row keyed + /// by the bare uuid. Future retryAgentConnection() async { final target = ref.read(selectedTargetProvider); if (target == null || target.isLocal) return; diff --git a/app/lib/screens/app_settings_screen.dart b/app/lib/screens/app_settings_screen.dart index a6ce10f2..96a74c52 100644 --- a/app/lib/screens/app_settings_screen.dart +++ b/app/lib/screens/app_settings_screen.dart @@ -262,7 +262,8 @@ class _AppSettingsScreenState extends ConsumerState { body: [ const SizedBox(height: AbTokens.space8), Text( - 'Default relay URL — used when pairing via a URI that doesn\'t specify a relay.', + 'Relay every connection from this device uses. Leave ' + 'empty for the one this build ships with.', style: AbTokens.sansStyle( fontSize: AbTokens.fontXxs, color: antgrid.textMuted, diff --git a/app/lib/screens/app_shell.dart b/app/lib/screens/app_shell.dart index 9ac978dc..fc642f97 100644 --- a/app/lib/screens/app_shell.dart +++ b/app/lib/screens/app_shell.dart @@ -124,7 +124,9 @@ class _AppShellState extends ConsumerState { // screen: it survives a walk over to New Session or Settings, so restating // it from those surfaces would vouch for a session the user cannot see and // silently exempt it from unread. - if (ref.read(workbenchSurfaceProvider) != WorkbenchSurface.workspace) return; + if (ref.read(workbenchSurfaceProvider) != WorkbenchSurface.workspace) { + return; + } final active = ref.read(activeSessionIdProvider); if (active != null) session.sessionsService.focus(active); } @@ -487,7 +489,6 @@ class _ControlPlaneReaperState extends ConsumerState { sub.close(); } _labelSubs.clear(); - ref.invalidate(pairedAgentProvider); ref.invalidate(accountAgentsProvider); ref.invalidate(agentCatalogProvider); ref.invalidate(remoteProjectLabelsProvider); diff --git a/app/lib/screens/workspace_shell.dart b/app/lib/screens/workspace_shell.dart index 5ff1b54b..4c4693c4 100644 --- a/app/lib/screens/workspace_shell.dart +++ b/app/lib/screens/workspace_shell.dart @@ -821,9 +821,9 @@ class WorkspaceShellState extends ConsumerState // archive of the currently focused session advances to the next sibling). // The "auto-disconnect on empty" behaviour is wired at the delete/archive // call sites (session_row.dart) — NOT here — because `_stopAllServices()` - // empties the session list synchronously during a project switch (see - // `PairedAgentNotifier.selectAgent`), which would race a listener-based - // disconnect and partially undo the in-flight switch. The FILTERED list is + // empties the session list synchronously during a project switch, which + // would race a listener-based disconnect and partially undo the in-flight + // switch. The FILTERED list is // what the selection follows, so a session the bridge is already removing // is stepped off the moment it says so rather than 3-15s later. ref.listen>(selectableSessionsProvider, (_, _) { @@ -1160,7 +1160,9 @@ class WorkspaceShellState extends ConsumerState // the same error. `retry()` is the only input that clears it, // and retryAgentConnection does the invalidate itself. unawaited( - ref.read(pairedAgentProvider.notifier).retryAgentConnection(), + ref + .read(machineConnectionProvider.notifier) + .retryAgentConnection(), ); return; } @@ -2160,14 +2162,14 @@ class _WorkspaceBootStatusState extends ConsumerState<_WorkspaceBootStatus> { if (_retrying) return; setState(() => _retrying = true); try { - await ref.read(pairedAgentProvider.notifier).retryAgentConnection(); + await ref.read(machineConnectionProvider.notifier).retryAgentConnection(); } finally { if (mounted) setState(() => _retrying = false); } } void _cancel() { - final notifier = ref.read(pairedAgentProvider.notifier); + final notifier = ref.read(machineConnectionProvider.notifier); notifier.cancelActiveAgent(); } @@ -2252,8 +2254,7 @@ class _WorkspaceBootStatusState extends ConsumerState<_WorkspaceBootStatus> { Widget build(BuildContext context) { final connAsync = ref.watch(connectionStateProvider); final reach = ref.watch(agentReachabilityProvider); - final activeAgent = ref.watch(activeAgentProvider); - final agentLabel = activeAgent?.agentName ?? 'agent'; + final agentLabel = ref.watch(focusedMachineNameProvider) ?? 'agent'; final rawPhases = _phases( conn: connAsync.value?.connectionState, diff --git a/app/lib/services/storage_service.dart b/app/lib/services/storage_service.dart index fe562940..230e85d3 100644 --- a/app/lib/services/storage_service.dart +++ b/app/lib/services/storage_service.dart @@ -1,54 +1,18 @@ -import 'dart:convert'; - import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:antgrid_relay_client/antgrid_relay_client.dart'; import '../config/storage_scope.dart'; +/// Purges the QR-era `paired_agents` blob. +/// +/// Nothing writes that key any more — QR pairing is gone and admission is +/// account trust — but an install that predates the change still carries one, +/// and the account purge must evict it like any other account-scoped store. class StorageService { final FlutterSecureStorage _storage; StorageService({FlutterSecureStorage? storage}) : _storage = storage ?? const FlutterSecureStorage(); - Future> loadPairedAgents() async { - final raw = await _storage.read(key: scopedStorageKey('paired_agents')); - if (raw == null) return []; - try { - final list = jsonDecode(raw) as List; - return list - .whereType>() - .map( - (e) => PairedAgent( - relayUrl: e['relayUrl'] as String, - agentDeviceId: e['agentDeviceId'] as String, - agentName: e['agentName'] as String, - // Session keys are per-connection and never persisted (v2). - ), - ) - .toList(); - } catch (_) { - return []; - } - } - - Future savePairedAgents(List agents) async { - final list = agents - .map( - (a) => { - 'relayUrl': a.relayUrl, - 'agentDeviceId': a.agentDeviceId, - 'agentName': a.agentName, - // Session keys are per-connection and never persisted (v2). - }, - ) - .toList(); - await _storage.write( - key: scopedStorageKey('paired_agents'), - value: jsonEncode(list), - ); - } - Future clearPairedAgents() async { await _storage.delete(key: scopedStorageKey('paired_agents')); } diff --git a/app/lib/storage/recent_agents_store.dart b/app/lib/storage/recent_agents_store.dart index aaeb1b68..11330c57 100644 --- a/app/lib/storage/recent_agents_store.dart +++ b/app/lib/storage/recent_agents_store.dart @@ -83,13 +83,23 @@ class RecentAgentsStore { static Future open() async => RecentAgentsStore._(await openScopedPrefs({_key})); + /// A blob that fails to decode is DROPPED, not thrown: this runs inside + /// `RecentAgentsNotifier.build()`, which several synchronous providers watch + /// (`entryIsRelayProvider` and the drawer rows and tap handlers behind it), so + /// a throw here is a red screen or a permanently dead tap rather than a + /// missing reconnect list. Same policy as a key bump — stale rows are dropped, + /// never migrated. List list() { final raw = _prefs.getString(_key); if (raw == null) return List.unmodifiable(const []); - final arr = jsonDecode(raw) as List; - return List.unmodifiable( - arr.map((j) => RecentAgent.fromJson(j as Map)), - ); + try { + final arr = jsonDecode(raw) as List; + return List.unmodifiable( + arr.map((j) => RecentAgent.fromJson(j as Map)), + ); + } catch (_) { + return List.unmodifiable(const []); + } } /// Broadcast stream of post-write snapshots. Does NOT replay the current diff --git a/app/lib/util/device_id.dart b/app/lib/util/device_id.dart index c302c6f8..a7d19c6b 100644 --- a/app/lib/util/device_id.dart +++ b/app/lib/util/device_id.dart @@ -1,7 +1,7 @@ /// Reduce a possibly-compound `.` id to its bare /// `` prefix, matching `InventoryAgent.deviceUuid` format. The -/// stored value is either a bare UUID (autoOpen path) or a compound -/// `.` (QR-paired path). +/// stored value is either a bare UUID (a machine) or a compound +/// `.` (one remote project on that machine). String baseDeviceUuid(String agentDeviceId) { final dot = agentDeviceId.indexOf('.'); return dot < 0 ? agentDeviceId : agentDeviceId.substring(0, dot); diff --git a/app/lib/widgets/agent_panel.dart b/app/lib/widgets/agent_panel.dart index f6fc1dd4..cada2959 100644 --- a/app/lib/widgets/agent_panel.dart +++ b/app/lib/widgets/agent_panel.dart @@ -17,6 +17,8 @@ import '../design/widgets/ab_toolbar.dart'; import '../design/widgets/ab_tooltip.dart'; import '../models/handler_state.dart'; import '../models/session_entry.dart'; +import '../providers/account_agents.dart'; +import '../providers/agent_coordinates.dart'; import '../providers/agent_transport.dart'; import '../providers/demo_mode.dart'; import '../providers/device_provisioning.dart'; @@ -24,10 +26,12 @@ import '../providers/first_run.dart'; import '../providers/handler_discovery.dart'; import '../providers/projects.dart'; import '../providers/providers.dart'; +import '../providers/recent_agents.dart'; import '../providers/session_mode.dart'; import '../providers/sessions.dart'; import '../screens/terminal_screen.dart'; import '../util/ab_log.dart'; +import '../util/device_id.dart'; import '../util/detached.dart'; import '../util/relative_time.dart'; import '../utils/platform_utils.dart'; @@ -237,31 +241,59 @@ List titleBarProjectActions(WidgetRef ref) { // on desktop purely so this affordance can render. if (ref.watch(demoModeProvider)) return const []; final localUuid = ref.watch(localDeviceUuidProvider).value; - final selectedId = ref.watch(selectedRegistrationIdProvider); - final projects = ref.watch(projectsProvider); - - // A null selectedId matches nothing: projectId is non-nullable. - final matches = projects.where((p) => p.projectId == selectedId); - final focused = matches.isEmpty ? null : matches.first; - // Until the local uuid resolves, local-vs-remote is undecidable — withhold the - // chip rather than flashing the wrong one. - final remoteHost = - focused != null && localUuid != null && !focused.isLocalFor(localUuid) - ? focused.hostMachineName - : null; + final remoteHost = _focusedRemoteHost(ref); return [ // Rendered even while the policy is unloaded — RemoteAccessControl reports // "not known yet" rather than vanishing, deliberately (see its build()). if (localUuid != null) const RemoteAccessControl(), + // The spacer belongs to the control it follows, and [_focusedRemoteHost] + // already withholds the chip until the uuid resolves. if (localUuid != null && remoteHost != null) const SizedBox(width: AbTokens.space8), if (remoteHost != null) - // TODO(task-13): derive platform from welcome message / agent inventory. - RemoteHostChip(hostMachineName: remoteHost, platform: 'macos'), + RemoteHostChip( + hostMachineName: remoteHost.name, + platform: remoteHost.platform, + ), ]; } +/// The machine hosting the focused project, or null when it is this one (or +/// undecidable yet). +/// +/// Two sources, because a project reaches the focus by two routes. A LOCAL +/// store record answers for a folder this app opened — including the legacy +/// case of one recorded against another device. Everything else is a remote +/// focus that was never upserted locally: a machine's advertised project, whose +/// id is `.`, or a machine itself. Asking only the +/// local store — what this used to do — meant the chip never rendered for the +/// route that actually carries remote projects today, so driving another +/// machine looked exactly like driving your own. +({String name, String? platform})? _focusedRemoteHost(WidgetRef ref) { + final selectedId = ref.watch(selectedRegistrationIdProvider); + if (selectedId == null) return null; + // Until the local uuid resolves, local-vs-remote is undecidable — withhold + // the chip rather than flashing the wrong one. + final localUuid = ref.watch(localDeviceUuidProvider).value; + if (localUuid == null) return null; + + for (final project in ref.watch(projectsProvider)) { + if (project.projectId != selectedId) continue; + return project.isLocalFor(localUuid) + ? null + : (name: project.hostMachineName, platform: null); + } + + final base = baseDeviceUuid(selectedId); + if (base == localUuid) return null; + return resolveMachineDisplay( + base: base, + inventory: ref.watch(accountAgentsProvider).value, + recents: ref.watch(recentAgentsProvider), + ); +} + /// Pill label for a parked session. A park always resumes on its own, so the /// wake time is the whole message; without a deadline (`selfResuming` parks /// have none) the bare state is all we can honestly promise. diff --git a/app/lib/widgets/drawer_entry_row.dart b/app/lib/widgets/drawer_entry_row.dart index 976108ad..707f9ae4 100644 --- a/app/lib/widgets/drawer_entry_row.dart +++ b/app/lib/widgets/drawer_entry_row.dart @@ -5,6 +5,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../connection/relay_mechanisms.dart' show ConnectionBlockedException; import '../connection/supervisor_state.dart'; import '../design/ab_icons.dart'; import '../design/ab_tokens.dart'; @@ -26,6 +27,7 @@ import '../project/perf_recorder.dart'; import '../project/project_session_registry.dart'; import '../project/project_status.dart'; import '../providers/agent_transport.dart'; +import '../providers/relay_connection.dart'; import '../providers/cached_sessions.dart'; import '../providers/drawer_entries.dart'; import '../providers/collapsed_drawer.dart'; @@ -306,10 +308,10 @@ class _DrawerEntryTrailing extends ConsumerWidget { } } -/// Trash affordance for removing a project/agent from history. Any project +/// Trash affordance for removing a project/machine from history. Any project /// can be removed (active sessions or not) — removing the selected project -/// clears the selection (`ProjectsNotifier.remove`) and the remote branch -/// unpairs first, so it's safe regardless of session state. +/// clears the selection (`ProjectsNotifier.remove`) and the remote branch drops +/// the focus first, so it's safe regardless of session state. class _RemoveButton extends ConsumerStatefulWidget { final DrawerEntry entry; const _RemoveButton({required this.entry}); @@ -349,7 +351,9 @@ class _RemoveButtonState extends ConsumerState<_RemoveButton> { : 'Forget ${entry.displayName}?', body: isLocal ? removeLocalProjectBody(container, entry.id) - : 'This removes the saved trust relationship. You\'ll need to scan the QR code again to reconnect.', + : 'This clears the cached sessions and connection details for ' + 'this machine. It comes back on its own while it is signed ' + 'in to your account.', confirmLabel: isLocal ? 'Remove' : 'Forget', destructive: true, ); @@ -364,7 +368,7 @@ class _RemoveButtonState extends ConsumerState<_RemoveButton> { await container.read(projectsProvider.notifier).remove(e.id); case RemoteAgentEntry e: await container - .read(pairedAgentProvider.notifier) + .read(machineConnectionProvider.notifier) .forgetMachine(e.agent.agentDeviceId); case InventoryAgentEntry _: // Inventory agents are not stored locally — nothing to remove. @@ -408,6 +412,31 @@ String removeLocalProjectBody(ProviderContainer container, String projectId) { 'are kept.'; } +/// User-facing copy for a failed dial. +/// +/// Matched on the structured block reason rather than interpolating the error, +/// because everything that reaches here is a developer string: a bare +/// `'Connect failed: $e'` puts `ConnectionBlockedException(handshakeFailing)` +/// (or a raw `TimeoutException`) in a snackbar. The generic arm carries no +/// detail for the same reason — the connection error screen is where a reason +/// belongs, and it has one. +String connectFailureMessage(Object error) => switch (error) { + ConnectionBlockedException(reason: final r) => switch (r) { + BlockReason.licenseExpired => + 'Connect failed: this machine needs an active plan or a sign-in.', + BlockReason.agentOffline => 'Connect failed: that machine is offline.', + BlockReason.deviceRevoked => + "Connect failed: this device's access was revoked.", + BlockReason.sessionTakenOver => + 'Connect failed: another device took over this machine.', + BlockReason.superseded => + 'Connect failed: a newer connection replaced this one.', + BlockReason.handshakeFailing => + 'Connect failed: could not verify that machine.', + }, + _ => 'Connect failed.', +}; + /// Top-level helper so the kebab and inline connect actions can share one /// implementation. Returns `true` when the remote is now active, `false` if /// the attempt failed (snackbar already shown). Callers may use the signal @@ -418,12 +447,6 @@ Future selectRemoteAgent( String agentDeviceId, ) async { try { - final paired = ref.read(pairedAgentProvider).value ?? const []; - final isPaired = paired.any((a) => a.agentDeviceId == agentDeviceId); - if (isPaired) { - await ref.read(pairedAgentProvider.notifier).selectAgent(agentDeviceId); - return true; - } final ra = ref .read(recentAgentsProvider) .firstWhere((r) => r.agentDeviceId == agentDeviceId); @@ -438,25 +461,64 @@ Future selectRemoteAgent( return true; } catch (e) { if (context.mounted) { - showAbSnackBar(context, 'Connect failed: $e'); + showAbSnackBar(context, connectFailureMessage(e)); } return false; } } -/// Reconnects the live remote agent if its transport is currently offline. -/// No-op for local projects (their transport is managed by -/// `agentTransportProvider`). Skips on `connecting` so we don't race an -/// in-flight connect. +/// Reconnects the live remote target if its transport is currently offline. +/// Skips on `connecting` so we don't race an in-flight connect. +/// +/// [registrationId] is whatever the caller already has focused — a machine's +/// bare uuid, or a remote project's `.`. Reading that +/// id's transport is what declares the connection wanted and hands the +/// supervisor the ladder; it throws with the block reason when the supervisor +/// gives up, which is what the snackbar reports. +/// +/// Deliberately NOT [selectRemoteAgent]: that resolves a MACHINE record and +/// sets the focus to it, so a compound project id found no record (its +/// `firstWhere` threw `Bad state: No element` straight into the snackbar) and +/// the machine it would have focused on success is not the project the user is +/// looking at. Nothing here writes the focus — the caller already has the one +/// it wants. Future ensureRemoteOnline( BuildContext context, ProviderContainer ref, - String agentDeviceId, + String registrationId, ) async { if (ref.read(agentReachabilityProvider) != AgentReachability.offline) { return true; } - return selectRemoteAgent(context, ref, agentDeviceId); + // `offline` is reachable ONLY from a Blocked(agentOffline) ladder, so the + // transport element is already settled in an error that `noProviderRetry` + // guarantees Riverpod will never re-run: awaiting `.future` alone replays the + // original exception without dialling anything. BOTH halves are required, for + // the reasons `MachineConnectionNotifier.retryAgentConnection` sets out. + ref + .read(relayConnectionManagerProvider) + .peek(registrationId) + ?.supervisor + ?.retry(); + ref.invalidate(agentTransportForProvider(registrationId)); + try { + // Null is a machine no source can name coordinates for (dropped from the + // inventory, never in the reconnect list). Reporting that as online sends + // the caller into a warm-up that can only time out in silence. + if (await ref.read(agentTransportForProvider(registrationId).future) == + null) { + if (context.mounted) { + showAbSnackBar(context, 'That machine is no longer reachable.'); + } + return false; + } + return true; + } catch (e) { + if (context.mounted) { + showAbSnackBar(context, connectFailureMessage(e)); + } + return false; + } } /// Public activation entry point for drawer interactions (session-row click, @@ -483,16 +545,18 @@ Future activateDrawerEntryById( // A session row nested under a remote MACHINE entry carries its project's // compound `.` regId, which is not itself a drawer entry // (the entry is the bare-uuid machine). When that project is already open - // (warm transport), refocus it as a remote target — no re-pair needed. + // (warm transport), refocus it as a remote target — nothing to dial. if (_focusOpenRemoteProject(ref, entryId)) return true; - // A cold (advertised-but-not-warm) project still needs pairing, promotion, - // and a data-plane socket before it can be focused — `_focusOpenRemoteProject` - // only refocuses one that is already warm. + // A cold (advertised-but-not-warm) project still needs its machine dialled, + // then promotion and a data-plane socket, before it can be focused — + // `_focusOpenRemoteProject` only refocuses one that is already warm. return _openColdRemoteProject(context, ref, entryId); } - // Drop duplicate taps while a remote connection is mid-flight to prevent - // overlapping selectAgent() calls. Gated on `focusedIsRelayProvider` + // Drop a duplicate tap on the machine whose connection is already mid-flight. + // Every reachability provider here reads the FOCUS, so the guard only holds + // while the focused machine IS the tapped one — otherwise one machine dialling + // would swallow every tap on all the others. Gated on `focusedIsRelayProvider` // because `agentReachabilityProvider` returns `connecting` by default // whenever no agent is active (including pure local mode), which would // otherwise block every tap. Gated on `focusedAgentBlockedProvider` because @@ -500,8 +564,12 @@ Future activateDrawerEntryById( // without this the tap is a silent no-op forever and the user can never // reach the error surface that holds Retry. if (entry is RemoteAgentEntry || entry is InventoryAgentEntry) { - final hasActiveRemote = ref.read(focusedIsRelayProvider); - if (hasActiveRemote && + final focusedId = ref.read(selectedRegistrationIdProvider); + final sameMachine = + focusedId != null && + baseDeviceUuid(focusedId) == baseDeviceUuid(entryId); + if (sameMachine && + ref.read(focusedIsRelayProvider) && !ref.read(focusedAgentBlockedProvider) && ref.read(agentReachabilityProvider) == AgentReachability.connecting) { return false; @@ -527,9 +595,9 @@ Future activateDrawerEntryById( } break; case InventoryAgentEntry e: - // Same-account machine straight from the peers inventory — no QR, no - // pairing. Reading its transport brings the supervisor up; the agent - // admits us from the inventory when the E2E handshake lands. + // Same-account machine straight from the peers inventory. Reading its + // transport brings the supervisor up; the agent admits us from the + // inventory when the E2E handshake lands. final priorTarget = ref.read(selectedTargetProvider); ref.read(selectedTargetProvider.notifier).set(null); try { @@ -584,7 +652,7 @@ bool _focusOpenRemoteProject(ProviderContainer ref, String regId) { } /// Opens a cold remote advertised project (its compound `.` -/// regId) from a drawer session-row tap: pairs the machine, promotes it +/// regId) from a drawer session-row tap: dials the machine, promotes it /// (unconditionally — `project:start` is the promote trigger and is idempotent; /// see [openRemoteProjectForActivation]), and focuses it. Restores the prior /// target and shows a snackbar on failure, mirroring the other remote activation @@ -696,8 +764,8 @@ class _NewSessionButtonState extends ConsumerState<_NewSessionButton> { } // Activation resolves the focus id (a remote entry's id may differ from - // the live `agentDeviceId` it reconnects/auto-pairs to), so read it back - // rather than reusing `entryId`. + // the live `agentDeviceId` it reconnects to), so read it back rather than + // reusing `entryId`. final pid = container.read(selectedRegistrationIdProvider); if (pid == null) return; diff --git a/app/lib/widgets/projects_drawer.dart b/app/lib/widgets/projects_drawer.dart index 3d4d9bea..ccd92534 100644 --- a/app/lib/widgets/projects_drawer.dart +++ b/app/lib/widgets/projects_drawer.dart @@ -205,7 +205,7 @@ class _GroupLabel extends ConsumerWidget { // The button shares [refreshDrawer] with the pull-to-refresh gesture so the // two affordances refresh the same things. The in-flight guard keys off the // inventory load (the only load-once FutureProvider); local projects and - // QR-paired recents are store-reactive. Riverpod preserves the prior value + // Recent machines are store-reactive. Riverpod preserves the prior value // during the reload, so the list never blanks. // Demo: the sample project refreshes from nothing, and the watch itself is // what would fetch /account/agents. Neither the flag nor the button. @@ -400,9 +400,9 @@ class _EntryWithSessions extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { // A remote MACHINE entry (same-account or inventory) defaults to COLLAPSED // and tracks its open state in [expandedDrawerIdsProvider] — expanding it is - // what opens the machine's control-plane socket. A local project or legacy - // QR per-project entry defaults to EXPANDED and tracks its (rarer) collapse - // in [collapsedDrawerIdsProvider]. + // what opens the machine's control-plane socket. A local project (or a + // legacy per-project row) defaults to EXPANDED and tracks its (rarer) + // collapse in [collapsedDrawerIdsProvider]. final machineUuid = entry.machineUuid; final expanded = machineUuid != null ? ref.watch(expandedDrawerIdsProvider).contains(machineUuid) diff --git a/app/lib/widgets/remote_host_chip.dart b/app/lib/widgets/remote_host_chip.dart index 5c8d4f86..76ea4b4d 100644 --- a/app/lib/widgets/remote_host_chip.dart +++ b/app/lib/widgets/remote_host_chip.dart @@ -10,9 +10,12 @@ import '../design/widgets/ab_icon.dart'; /// /// Displays the host machine name and a platform-appropriate icon. Degrades /// gracefully to "Remote host" when [hostMachineName] is empty. +/// +/// [platform] is null when nothing has said — the machine is known only from +/// the reconnect list, which caches coordinates and not a platform. class RemoteHostChip extends StatelessWidget { final String hostMachineName; - final String platform; + final String? platform; const RemoteHostChip({ super.key, @@ -20,19 +23,14 @@ class RemoteHostChip extends StatelessWidget { required this.platform, }); - String get _iconName { - switch (platform) { - case 'macos': - case 'linux': - case 'windows': - return AbIcons.deviceDesktop; - case 'ios': - case 'android': - return AbIcons.deviceMobile; - default: - return AbIcons.server; - } - } + /// Unstated reads as desktop, not unknown: every machine that can host an + /// agent is a desktop-class one, so the server glyph is reserved for a + /// platform the inventory named and this build does not recognise. + String get _iconName => switch (platform) { + 'ios' || 'android' => AbIcons.deviceMobile, + 'macos' || 'linux' || 'windows' || null => AbIcons.deviceDesktop, + _ => AbIcons.server, + }; @override Widget build(BuildContext context) { diff --git a/app/lib/widgets/session_row.dart b/app/lib/widgets/session_row.dart index 87932479..694a5a91 100644 --- a/app/lib/widgets/session_row.dart +++ b/app/lib/widgets/session_row.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../demo/demo_identity.dart'; import '../design/ab_colors.dart'; import '../design/ab_icons.dart'; import '../design/ab_status_tone.dart'; @@ -576,22 +575,26 @@ class _SessionMenu extends ConsumerWidget { } Future _openMenu(BuildContext anchor, ProviderContainer ref) async { - // Working-directory rows are LOCAL-only: the checkout lives on the machine - // hosting it, so for a relay project the window would open somewhere the - // user is not sitting. A failed probe degrades to no rows rather than a - // menu that never opens. + // Working-directory rows are offered only for a checkout on THIS device: + // both resolve their path over the loopback control plane + // (`openCheckoutIn`/`copyCheckoutPath` read `hostControlClientProvider`), + // which can only answer about projects this machine hosts. Asked about a + // remote machine's project it spawns a host and refuses — and the window it + // could not open would have been on a machine the user is not sitting at. // - // The demo is excluded for a second reason: both rows resolve their path - // over the loopback control plane (`openCheckoutIn`/`copyCheckoutPath` read - // `hostControlClientProvider`), which is an `ensureHost()` caller — and the - // sample project has no checkout for it to answer about anyway. Not - // `entryIsRelayProvider`'s job: the demo transport reports itself local. - final targets = - ref.read(entryIsRelayProvider(entryId)) || isDemoEntryId(entryId) - ? const [] - : await ref + // Gated on what the local project store holds, never on whether the id + // looks remote: an id absent from it (a remote project, a machine, the + // demo) loses the rows, so a source of remote entries added later is + // excluded without this line being revisited. A failed probe degrades to no + // rows rather than a menu that never opens. + final local = await ref + .read(entryIsLocalCheckoutProvider(entryId).future) + .catchError((_) => false); + final targets = local + ? await ref .read(externalOpenTargetsProvider.future) - .catchError((_) => const []); + .catchError((_) => const []) + : const []; if (!anchor.mounted) return; final anchorRect = abMenuAnchorRect(anchor); if (anchorRect == null) return; @@ -753,7 +756,7 @@ class _SessionMenu extends ConsumerWidget { if (entryId != ref.read(selectedRegistrationIdProvider)) return; if (ref.read(activeSessionsProvider).isNotEmpty) return; if (ref.read(focusedIsRelayProvider)) { - ref.read(pairedAgentProvider.notifier).cancelActiveAgent(); + ref.read(machineConnectionProvider.notifier).cancelActiveAgent(); } else if (ref.read(selectedRegistrationIdProvider) != null) { ref.read(selectedTargetProvider.notifier).set(null); } diff --git a/app/lib/widgets/sign_out_action.dart b/app/lib/widgets/sign_out_action.dart index 2ba20c3f..0f4be281 100644 --- a/app/lib/widgets/sign_out_action.dart +++ b/app/lib/widgets/sign_out_action.dart @@ -16,8 +16,8 @@ Future confirmAndHardSignOut(BuildContext context, WidgetRef ref) async { title: 'Sign out and remove this device?', body: 'This signs you out AND removes this device from your account. ' - 'Phones currently paired with this device will be disconnected and ' - 'need to be re-paired. You can sign in again anytime.', + 'Your phones lose access to this machine until you sign in again — ' + 'there is nothing to set up a second time.', confirmLabel: 'Sign out', destructive: true, ); diff --git a/app/test/demo/demo_isolation_test.dart b/app/test/demo/demo_isolation_test.dart index 411e5296..71ab18d0 100644 --- a/app/test/demo/demo_isolation_test.dart +++ b/app/test/demo/demo_isolation_test.dart @@ -21,6 +21,7 @@ import 'package:antgrid/providers/cached_sessions.dart'; import 'package:antgrid/providers/recent_sessions.dart'; import 'package:antgrid/providers/new_session_action.dart'; import 'package:antgrid/providers/new_session_picker.dart'; +import 'package:antgrid/providers/open_checkout.dart'; import 'package:antgrid/providers/providers.dart'; import 'package:antgrid/services/app_settings_service.dart'; import 'package:antgrid/storage/cached_sessions_store.dart'; @@ -396,6 +397,14 @@ void main() { .single; container.read(selectedTargetProjectProvider.notifier).set(project); + // The session kebab's Open-in-editor / Copy-path rows. Resolving a checkout + // path runs over the loopback control plane, which spawns the host — so the + // gate has to answer before anything reads it, not after. + expect( + await container.read(entryIsLocalCheckoutProvider(kDemoProjectId).future), + isFalse, + ); + await container.read(focusedMachineToolsProvider.future); await container.read(newSessionDetectedToolsProvider.future); await container.read(newSessionChatCapableToolsProvider.future); diff --git a/app/test/helpers/workspace_shell_harness.dart b/app/test/helpers/workspace_shell_harness.dart index 82e3b593..efa59dc6 100644 --- a/app/test/helpers/workspace_shell_harness.dart +++ b/app/test/helpers/workspace_shell_harness.dart @@ -7,12 +7,15 @@ import 'package:antgrid/models/file_tree_models.dart'; import 'package:antgrid/models/preferences_models.dart'; import 'package:antgrid/models/preview_models.dart'; import 'package:antgrid/models/terminal_models.dart'; +import 'package:antgrid/providers/account_agents.dart'; import 'package:antgrid/providers/agent_transport.dart'; +import 'package:antgrid/providers/device_provisioning.dart'; import 'package:antgrid/providers/providers.dart'; import 'package:antgrid/screens/app_shell.dart'; import 'package:antgrid/test_helpers/fake_agent_transport.dart'; import 'package:antgrid/window/window_chrome.dart'; -import 'package:antgrid_relay_client/antgrid_relay_client.dart'; +import 'package:antgrid_relay_client/antgrid_relay_client.dart' + show AgentTransport; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; // Riverpod 3 keeps `Override` out of the main barrel. @@ -22,27 +25,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'prefs_test_mock.dart'; import 'test_store_overrides.dart'; -final testAgent = PairedAgent( - relayUrl: 'wss://test.relay', - agentDeviceId: 'agent-123.test-project', - agentName: 'Test Agent', -); - -/// A fake PairedAgentNotifier that returns a list with one mock PairedAgent. -class FakePairedAgentNotifier extends AsyncNotifier> - implements PairedAgentNotifier { - @override - Future> build() async => [testAgent]; - - @override - Future selectAgent(String agentDeviceId) async {} - @override - Future forgetMachine(String agentDeviceIdOrUuid) async {} - @override - Future retryAgentConnection() async {} - @override - void cancelActiveAgent() {} -} +/// The focused entry id these tests mount the shell on: a remote PROJECT, i.e. +/// the compound `.` shape. +const testAgentDeviceId = 'agent-123.test-project'; /// Pumps the real [AppShell] (which renders WorkspaceShell once paired), with /// a fake window chrome since WorkspaceShell mounts `WindowTitleBar` directly. @@ -66,9 +51,16 @@ Future pumpWorkspaceShell( ProviderScope( overrides: [ ...stores.overrides, - pairedAgentProvider.overrideWith(() => FakePairedAgentNotifier()), + // Account- and keychain-backed, and both are read while the shell + // chrome builds. The real inventory fetch pulls the session cookie out + // of the keychain and the real uuid MINTS a host identity on desktop — + // neither belongs in a widget test. Kept out of [extraOverrides] for + // the same reason the transport is: Riverpod 3 asserts on a provider + // overridden twice in one container. + accountAgentsProvider.overrideWith((_) async => const []), + localDeviceUuidProvider.overrideWith((_) async => 'test-local-device'), selectedRegistrationIdProvider.overrideWith( - (ref) => withProject ? testAgent.agentDeviceId : null, + (ref) => withProject ? testAgentDeviceId : null, ), terminalStateProvider.overrideWith( (ref) => Stream.value(const TerminalState()), diff --git a/app/test/providers/agent_transport_coords_retry_test.dart b/app/test/providers/agent_transport_coords_retry_test.dart index 9a60066e..f12a2875 100644 --- a/app/test/providers/agent_transport_coords_retry_test.dart +++ b/app/test/providers/agent_transport_coords_retry_test.dart @@ -113,11 +113,6 @@ class _FakeConnectionManager extends RelayConnectionManager { RelayConnection? peek(String machineDeviceId) => _conns[machineDeviceId]; } -class _EmptyPairedAgentNotifier extends PairedAgentNotifier { - @override - Future> build() async => const []; -} - Future _connectionRecord() async { final seed = List.generate(32, (i) => (i * 5 + 1) % 256); final kp = await Ed25519().newKeyPairFromSeed(seed); @@ -191,7 +186,6 @@ void main() { final c = ProviderContainer( overrides: [ ...stores.overrides, - pairedAgentProvider.overrideWith(() => _EmptyPairedAgentNotifier()), accountAgentsProvider.overrideWith((_) async => inventory), localDeviceUuidProvider.overrideWith((_) async => 'this-device'), connectionDeviceRecordProvider.overrideWith((_) async => record), diff --git a/app/test/providers/agent_transport_identity_test.dart b/app/test/providers/agent_transport_identity_test.dart index fb6f63df..8888bd22 100644 --- a/app/test/providers/agent_transport_identity_test.dart +++ b/app/test/providers/agent_transport_identity_test.dart @@ -127,11 +127,6 @@ class _FakeConnectionManager extends RelayConnectionManager { RelayConnection? peek(String machineDeviceId) => _conns[machineDeviceId]; } -class _EmptyPairedAgentNotifier extends PairedAgentNotifier { - @override - Future> build() async => const []; -} - Future _connectionRecord() async { final seed = List.generate(32, (i) => (i * 7 + 3) % 256); final kp = await Ed25519().newKeyPairFromSeed(seed); @@ -189,7 +184,6 @@ void main() { _RecordingRelay? on, }) => [ ...stores.overrides, - pairedAgentProvider.overrideWith(() => _EmptyPairedAgentNotifier()), accountAgentsProvider.overrideWith((_) async => inventory), localDeviceUuidProvider.overrideWith((_) async => 'this-device'), connectionDeviceRecordProvider.overrideWith((_) async => record), diff --git a/app/test/providers/agent_transport_machine_creds_test.dart b/app/test/providers/agent_transport_machine_creds_test.dart index f42ba2e2..b3f26d8a 100644 --- a/app/test/providers/agent_transport_machine_creds_test.dart +++ b/app/test/providers/agent_transport_machine_creds_test.dart @@ -20,7 +20,6 @@ import 'package:antgrid/providers/account_agents.dart'; import 'package:antgrid/providers/agent_transport.dart'; import 'package:antgrid/providers/auth.dart'; import 'package:antgrid/providers/device_provisioning.dart'; -import 'package:antgrid/providers/providers.dart'; import 'package:antgrid/services/account_agents_api.dart'; import 'package:antgrid/services/app_settings_service.dart'; import 'package:antgrid/services/auth_service.dart'; @@ -99,11 +98,6 @@ class _CapturingLauncher extends LocalAgentLauncher { // Stub notifiers // --------------------------------------------------------------------------- -class _EmptyPairedAgentNotifier extends PairedAgentNotifier { - @override - Future> build() async => const []; -} - // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- @@ -163,7 +157,6 @@ void main() { }) => [ ...stores.overrides, // No relay agents — fall through to local path. - pairedAgentProvider.overrideWith(() => _EmptyPairedAgentNotifier()), accountAgentsProvider.overrideWith((_) async => const []), localDeviceUuidProvider.overrideWith((_) async => 'local-uuid'), // Test seam introduced in Task 5. diff --git a/app/test/providers/agent_transport_test.dart b/app/test/providers/agent_transport_test.dart index 17060966..d0f5ecaa 100644 --- a/app/test/providers/agent_transport_test.dart +++ b/app/test/providers/agent_transport_test.dart @@ -162,11 +162,6 @@ DeviceRecord _connectionRecord() => DeviceRecord( x25519Priv: base64Encode(List.filled(32, 4)), ); -class _EmptyPairedAgentNotifier extends PairedAgentNotifier { - @override - Future> build() async => const []; -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -195,8 +190,6 @@ void main() { }) { return [ ...stores.overrides, - // Simulate no pre-existing PairedAgents (the race condition). - pairedAgentProvider.overrideWith(() => _EmptyPairedAgentNotifier()), // Override accountAgentsProvider directly (not the API layer) so the // cache is immediately populated. _buildRelayTransportFor uses // `.value` to avoid adding async latency for unresolved cases. @@ -380,7 +373,6 @@ void main() { final container = ProviderContainer( overrides: [ ...stores.overrides, - pairedAgentProvider.overrideWith(() => _EmptyPairedAgentNotifier()), accountAgentsProvider.overrideWith((_) async => inventory), localDeviceUuidProvider.overrideWith((_) async => _localUuid), connectionDeviceRecordProvider.overrideWith( diff --git a/app/test/providers/control_plane_lifetime_test.dart b/app/test/providers/control_plane_lifetime_test.dart index 3b003f44..e6189e14 100644 --- a/app/test/providers/control_plane_lifetime_test.dart +++ b/app/test/providers/control_plane_lifetime_test.dart @@ -7,7 +7,6 @@ import 'package:antgrid/project/project_session_registry.dart'; import 'package:antgrid/providers/control_plane.dart'; import 'package:antgrid/providers/eager_control_planes.dart'; import 'package:antgrid/providers/new_session_picker.dart'; -import 'package:antgrid/providers/providers.dart'; import 'package:antgrid/providers/relay_connection.dart'; import 'package:antgrid/providers/ui_attention_providers.dart'; import 'package:antgrid/providers/value_controller.dart'; @@ -380,7 +379,6 @@ void main() { ...stores.overrides, relayConnectionManagerProvider.overrideWithValue(manager), accountAgentsProvider.overrideWith((_) async => const []), - pairedAgentProvider.overrideWith(() => _EmptyPairedAgentNotifier()), ], ); addTearDown(c.dispose); @@ -481,8 +479,3 @@ void main() { ); }); } - -class _EmptyPairedAgentNotifier extends PairedAgentNotifier { - @override - Future> build() async => const []; -} diff --git a/app/test/providers/entry_cleanup_test.dart b/app/test/providers/entry_cleanup_test.dart index f53e5593..2e6e5941 100644 --- a/app/test/providers/entry_cleanup_test.dart +++ b/app/test/providers/entry_cleanup_test.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:antgrid/config/storage_scope.dart'; import 'package:antgrid/models/agent_descriptor.dart'; import 'package:antgrid/models/session_entry.dart'; import 'package:antgrid/project/project_session_registry.dart' @@ -19,8 +20,6 @@ import 'package:antgrid/storage/agent_catalog_store.dart'; import 'package:antgrid/storage/cached_sessions_store.dart'; import 'package:antgrid/storage/project_store.dart'; import 'package:antgrid/storage/recent_ports_store.dart'; -import 'package:antgrid_relay_client/antgrid_relay_client.dart' - show PairedAgent; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/test/test_flutter_secure_storage_platform.dart'; import 'package:flutter_secure_storage_platform_interface/flutter_secure_storage_platform_interface.dart'; @@ -167,13 +166,10 @@ void main() { handlerChat: true, ), }); - await pairedStore.savePairedAgents(const [ - PairedAgent( - relayUrl: 'wss://r', - agentDeviceId: 'machine-1', - agentName: 'Work laptop', - ), - ]); + // Written by hand: nothing produces this blob any more (QR pairing is + // gone), but a pre-account-trust install still carries one and sign-out + // has to evict it. + secureBacking[scopedStorageKey('paired_agents')] = '[]'; await prefsService.load(entryId); final container = ProviderContainer( @@ -203,7 +199,7 @@ void main() { expect(recentPorts.list(entryId), isEmpty); expect(await cache.read(entryId), isNull); expect(await catalog.read(), isEmpty); - expect(await pairedStore.loadPairedAgents(), isEmpty); + expect(secureBacking, isNot(contains(scopedStorageKey('paired_agents')))); // The prefs file lives behind path_provider, which isn't mocked here — // the in-memory reset is what stops the stale entry being served. expect(prefsService.projectId, isNull); @@ -226,13 +222,7 @@ void main() { await recentPorts.add('p1', 3000, 'http'); await cache.write('p1', const ProjectStatus.empty()); final pairedStore = StorageService(); - await pairedStore.savePairedAgents(const [ - PairedAgent( - relayUrl: 'wss://r', - agentDeviceId: 'machine-1', - agentName: 'Work laptop', - ), - ]); + secureBacking[scopedStorageKey('paired_agents')] = '[]'; final container = ProviderContainer( overrides: [ @@ -259,7 +249,7 @@ void main() { expect(failures, ['cachedSessions']); expect(recentPorts.list('p1'), isEmpty); expect(await cache.read('p1'), isNull); - expect(await pairedStore.loadPairedAgents(), isEmpty); + expect(secureBacking, isNot(contains(scopedStorageKey('paired_agents')))); }); }); } diff --git a/app/test/providers/entry_is_local_checkout_test.dart b/app/test/providers/entry_is_local_checkout_test.dart new file mode 100644 index 00000000..17ab9224 --- /dev/null +++ b/app/test/providers/entry_is_local_checkout_test.dart @@ -0,0 +1,157 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:antgrid/demo/demo_identity.dart'; +import 'package:antgrid/models/ab_project.dart'; +import 'package:antgrid/providers/device_provisioning.dart'; +import 'package:antgrid/providers/open_checkout.dart'; +import 'package:antgrid/providers/projects.dart'; +import 'package:antgrid/storage/project_store.dart'; + +import '../helpers/prefs_test_mock.dart'; + +/// The gate on the session kebab's working-directory rows (Open folder, Open in +/// ``, Copy path). Those resolve their path over the LOOPBACK control +/// plane, so they may only be offered for a checkout this device hosts. +/// +/// Cover for the shipped bug: the rows were gated on a remote-blocklist keyed +/// by exact id against the paired-agent list, which a remote project's compound +/// `.` id can never match — so every remote session +/// offered them. +void main() { + const localUuid = 'local-device-uuid'; + late ProjectStore projectStore; + + Future containerFor({ + String? deviceUuid = localUuid, + }) async { + useInMemoryPrefs(); + projectStore = await ProjectStore.open(); + final container = ProviderContainer( + overrides: [ + projectStoreProvider.overrideWithValue(projectStore), + localDeviceUuidProvider.overrideWith((_) async => deviceUuid), + ], + ); + addTearDown(container.dispose); + return container; + } + + Future addProject( + ProviderContainer container, { + required String projectId, + required String? hostDeviceUuid, + }) => container + .read(projectsProvider.notifier) + .upsert( + AbProject( + projectId: projectId, + folder: '/repos/antgrid', + displayName: 'antgrid', + hostDeviceUuid: hostDeviceUuid, + hostMachineName: 'desk', + lastOpenedAt: DateTime.now(), + ), + ); + + test('a project hosted by this device is local', () async { + final container = await containerFor(); + await addProject( + container, + projectId: '6e5c50e5d6f973a8', + hostDeviceUuid: localUuid, + ); + + expect( + await container.read( + entryIsLocalCheckoutProvider('6e5c50e5d6f973a8').future, + ), + isTrue, + ); + }); + + test( + 'a pre-v2 project with no recorded host is local-to-this-device', + () async { + // Matches `AbProject.isLocalFor`: a null hostDeviceUuid only ever came from + // a project opened on this install, before the field existed. + final container = await containerFor(); + await addProject( + container, + projectId: '6e5c50e5d6f973a8', + hostDeviceUuid: null, + ); + + expect( + await container.read( + entryIsLocalCheckoutProvider('6e5c50e5d6f973a8').future, + ), + isTrue, + ); + }, + ); + + test('a remote machine\'s project is not local', () async { + final container = await containerFor(); + // The regression: a remote project is `.` and is + // never in the local store at all. + expect( + await container.read( + entryIsLocalCheckoutProvider('uuidA.6e5c50e5d6f973a8').future, + ), + isFalse, + ); + }); + + test('a project recorded against ANOTHER device is not local', () async { + final container = await containerFor(); + await addProject( + container, + projectId: '6e5c50e5d6f973a8', + hostDeviceUuid: 'some-other-device', + ); + + expect( + await container.read( + entryIsLocalCheckoutProvider('6e5c50e5d6f973a8').future, + ), + isFalse, + ); + }); + + test('a bare machine uuid is not local', () async { + final container = await containerFor(); + + expect( + await container.read(entryIsLocalCheckoutProvider('uuidA').future), + isFalse, + ); + }); + + test('the sample project is not local — it owns no checkout', () async { + final container = await containerFor(); + + expect( + await container.read(entryIsLocalCheckoutProvider(kDemoProjectId).future), + isFalse, + ); + }); + + test('an unresolved device uuid answers false rather than guessing', () async { + // Mobile/web, and the window before a desktop uuid is minted: local-vs-remote + // is undecidable, and the safe answer is the one that offers nothing. + final container = await containerFor(deviceUuid: null); + await addProject( + container, + projectId: '6e5c50e5d6f973a8', + hostDeviceUuid: localUuid, + ); + + expect( + await container.read( + entryIsLocalCheckoutProvider('6e5c50e5d6f973a8').future, + ), + isFalse, + ); + }); +} diff --git a/app/test/providers/entry_is_relay_test.dart b/app/test/providers/entry_is_relay_test.dart new file mode 100644 index 00000000..2cf1352d --- /dev/null +++ b/app/test/providers/entry_is_relay_test.dart @@ -0,0 +1,185 @@ +import 'package:antgrid/demo/demo_identity.dart'; +import 'package:antgrid/models/session_target.dart'; +import 'package:antgrid/providers/agent_transport.dart'; +import 'package:antgrid/providers/account_agents.dart'; +import 'package:antgrid/providers/device_provisioning.dart'; +import 'package:antgrid/providers/providers.dart'; +import 'package:antgrid/providers/recent_agents.dart'; +import 'package:antgrid/services/account_agents_api.dart'; +import 'package:antgrid/storage/recent_agents_store.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/prefs_test_mock.dart'; + +/// Which ids read as relay-reached, and which machine the focus belongs to. +/// +/// Both used to be an exact-id match, which answers for neither shape that +/// reaches them: a remote PROJECT is `.` while every +/// machine record is keyed by the bare uuid. +const _machineUuid = 'machine-uuid'; + +RecentAgent _recent({String? machineName}) => RecentAgent( + agentDeviceId: _machineUuid, + agentLabel: 'work laptop', + agentEd25519Pubkey: 'pub', + relayUrl: 'ws://relay.test', + pairedAt: DateTime.utc(2026, 1, 1), + lastConnectedAt: DateTime.utc(2026, 1, 1), + hostMachineName: machineName, +); + +InventoryAgent _inventory({String? relayUrl = 'ws://relay.test'}) => + InventoryAgent( + deviceUuid: _machineUuid, + displayName: 'me@example.com', + platform: 'linux', + ed25519Pub: 'pub', + relayUrl: relayUrl, + ); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Future buildContainer({ + List recent = const [], + List inventory = const [], + String? localUuid = 'this-device', + }) async { + useInMemoryPrefs(); + final recentStore = await RecentAgentsStore.open(); + for (final agent in recent) { + await recentStore.upsert(agent); + } + final container = ProviderContainer( + overrides: [ + recentAgentsStoreProvider.overrideWithValue(recentStore), + accountAgentsProvider.overrideWith((_) async => inventory), + // Never the real one: it reads (and mints) the host identity out of the + // keychain. + localDeviceUuidProvider.overrideWith((_) async => localUuid), + ], + ); + addTearDown(container.dispose); + addTearDown(recentStore.close); + // Both are async-sourced; settle them before asking. + await container.read(accountAgentsProvider.future); + await container.read(localDeviceUuidProvider.future); + return container; + } + + group('entryIsRelayProvider', () { + test( + 'a remote project id resolves through its base machine uuid', + () async { + final container = await buildContainer(recent: [_recent()]); + + expect( + container.read( + entryIsRelayProvider('$_machineUuid.6e5c50e5d6f973a8'), + ), + isTrue, + ); + }, + ); + + test('a machine known only from the account inventory is relay', () async { + // The account-trust path: nothing is cached until the first dial writes + // its reconnect row. + final container = await buildContainer(inventory: [_inventory()]); + + expect(container.read(entryIsRelayProvider(_machineUuid)), isTrue); + expect( + container.read(entryIsRelayProvider('$_machineUuid.6e5c50e5d6f973a8')), + isTrue, + ); + }); + + test('a machine that has not enabled remote access is not relay', () async { + // No relayUrl is a host with nothing to dial: `_buildRelayTransportFor` + // falls through to its local arm, and this must not disagree with what is + // actually opened. + final container = await buildContainer( + inventory: [_inventory(relayUrl: null)], + ); + + expect(container.read(entryIsRelayProvider(_machineUuid)), isFalse); + }); + + test('this device is not relay to itself', () async { + // Every machine appears in its OWN account inventory, and the transport + // refuses to dial itself. + final container = await buildContainer( + inventory: [_inventory()], + localUuid: _machineUuid, + ); + + expect(container.read(entryIsRelayProvider(_machineUuid)), isFalse); + }); + + test('a local project id names no machine', () async { + final container = await buildContainer(recent: [_recent()]); + + expect(container.read(entryIsRelayProvider('6e5c50e5d6f973a8')), isFalse); + }); + + test('the sample project is not relay', () async { + // Its transport reports itself local — that is what keeps it out of the + // relay bucket and its push registration. + final container = await buildContainer(recent: [_recent()]); + + expect(container.read(entryIsRelayProvider(kDemoProjectId)), isFalse); + }); + }); + + group('focusedMachineNameProvider', () { + test('a remote project focus resolves to its machine', () async { + final container = await buildContainer( + recent: [_recent(machineName: 'build-server')], + ); + container + .read(selectedTargetProvider.notifier) + .set( + const RemoteProject( + machineUuid: _machineUuid, + projectId: '6e5c50e5d6f973a8', + ), + ); + + expect(container.read(focusedMachineNameProvider), 'build-server'); + }); + + test( + 'a machine with no host name falls back to its stored label', + () async { + final container = await buildContainer(recent: [_recent()]); + container + .read(selectedTargetProvider.notifier) + .set(const RemoteTarget.legacy(_machineUuid)); + + expect(container.read(focusedMachineNameProvider), 'work laptop'); + }, + ); + + test('the first connect names the machine from the inventory', () async { + // The reconnect row is upserted fire-and-forget DURING the dial this + // screen is waiting on, so the inventory is the only source that can + // answer while it renders. + final container = await buildContainer(inventory: [_inventory()]); + container + .read(selectedTargetProvider.notifier) + .set(const RemoteTarget.legacy(_machineUuid)); + + expect(container.read(focusedMachineNameProvider), 'me@example.com'); + }); + + test('an unknown focus resolves to no machine', () async { + final container = await buildContainer(); + container + .read(selectedTargetProvider.notifier) + .set(const LocalProject('6e5c50e5d6f973a8')); + + expect(container.read(focusedMachineNameProvider), isNull); + }); + }); +} diff --git a/app/test/providers/paired_agent_forget_test.dart b/app/test/providers/forget_machine_test.dart similarity index 63% rename from app/test/providers/paired_agent_forget_test.dart rename to app/test/providers/forget_machine_test.dart index c83ce7b9..14c093b0 100644 --- a/app/test/providers/paired_agent_forget_test.dart +++ b/app/test/providers/forget_machine_test.dart @@ -12,33 +12,14 @@ import 'package:antgrid/providers/cached_sessions.dart'; import 'package:antgrid/providers/recent_ports.dart'; import 'package:antgrid/project/project_session_registry.dart' show projectStatusCacheProvider; -import 'package:antgrid/services/storage_service.dart'; import 'package:antgrid/storage/cached_sessions_store.dart'; import 'package:antgrid/storage/recent_agents_store.dart'; import 'package:antgrid/storage/recent_ports_store.dart'; -import 'package:antgrid_relay_client/antgrid_relay_client.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import '../helpers/prefs_test_mock.dart'; -class _MemoryStorageService extends StorageService { - _MemoryStorageService(this.agents); - - List agents; - - @override - Future> loadPairedAgents() async => List.of(agents); - - @override - Future savePairedAgents(List agents) async { - this.agents = List.of(agents); - } -} - -PairedAgent _paired(String id) => - PairedAgent(relayUrl: 'ws://relay.test', agentDeviceId: id, agentName: id); - RecentAgent _recent(String id) { final now = DateTime.utc(2026, 1, 1); return RecentAgent( @@ -54,23 +35,13 @@ RecentAgent _recent(String id) { void main() { TestWidgetsFlutterBinding.ensureInitialized(); - Future< - ({ - ProviderContainer container, - RecentAgentsStore recentStore, - _MemoryStorageService storage, - }) - > - buildContainer({ - required List paired, - required List recent, - }) async { + Future<({ProviderContainer container, RecentAgentsStore recentStore})> + buildContainer({required List recent}) async { useInMemoryPrefs(); final recentStore = await RecentAgentsStore.open(); for (final agent in recent) { await recentStore.upsert(agent); } - final storage = _MemoryStorageService(paired); // forgetMachine purges each forgotten agent's per-entry footprint, so the // container must provide the stores purgeEntryState reads. final cachedSessions = await CachedSessionsStore.open(); @@ -81,7 +52,6 @@ void main() { final statusCache = ProjectStatusCache.testInstance(root: statusTmp.path); final container = ProviderContainer( overrides: [ - storageServiceProvider.overrideWithValue(storage), recentAgentsStoreProvider.overrideWithValue(recentStore), cachedSessionsStoreProvider.overrideWithValue(cachedSessions), recentPortsStoreProvider.overrideWithValue(recentPorts), @@ -99,63 +69,34 @@ void main() { // Windows teardown handle race — harmless. } }); - return (container: container, recentStore: recentStore, storage: storage); + return (container: container, recentStore: recentStore); } - test( - 'forgetMachine removes inactive remote from paired and recent stores', - () async { - final h = await buildContainer( - paired: [_paired('M.project'), _paired('N.project')], - recent: [_recent('M.project'), _recent('N.project')], - ); - await h.container.read(pairedAgentProvider.future); - - await h.container.read(pairedAgentProvider.notifier).forgetMachine('M'); - await Future.delayed(Duration.zero); - - expect(h.storage.agents.map((a) => a.agentDeviceId), ['N.project']); - expect(h.recentStore.list().map((a) => a.agentDeviceId), ['N.project']); - expect( - h.container - .read(pairedAgentProvider) - .value - ?.map((a) => a.agentDeviceId), - ['N.project'], - ); - }, - ); - - test( - 'forgetMachine preserves nonmatching paired agents before provider load', - () async { - final h = await buildContainer( - paired: [_paired('M.project'), _paired('N.project')], - recent: [_recent('M.project'), _recent('N.project')], - ); + test('forgetMachine drops the machine from the reconnect list', () async { + final h = await buildContainer( + recent: [_recent('M.project'), _recent('N.project')], + ); - await h.container.read(pairedAgentProvider.notifier).forgetMachine('M'); - await Future.delayed(Duration.zero); + await h.container + .read(machineConnectionProvider.notifier) + .forgetMachine('M'); + await Future.delayed(Duration.zero); - expect(h.storage.agents.map((a) => a.agentDeviceId), ['N.project']); - expect(h.recentStore.list().map((a) => a.agentDeviceId), ['N.project']); - }, - ); + expect(h.recentStore.list().map((a) => a.agentDeviceId), ['N.project']); + }); test( 'forgetMachine clears active target for the forgotten machine', () async { final h = await buildContainer( - paired: [_paired('M.project'), _paired('N.project')], recent: [_recent('M.project'), _recent('N.project')], ); h.container .read(selectedTargetProvider.notifier) .set(const RemoteTarget.legacy('M.project')); - await h.container.read(pairedAgentProvider.future); await h.container - .read(pairedAgentProvider.notifier) + .read(machineConnectionProvider.notifier) .forgetMachine('M.project'); expect(h.container.read(selectedTargetProvider), isNull); @@ -166,11 +107,6 @@ void main() { 'forgetMachine removes all compound ids for the same bare machine', () async { final h = await buildContainer( - paired: [ - _paired('M.projectA'), - _paired('M.projectB'), - _paired('N.project'), - ], recent: [ _recent('M.projectA'), _recent('M.projectB'), @@ -181,11 +117,11 @@ void main() { mgr.connectionFor('M.projectA'); mgr.connectionFor('M.projectB'); mgr.connectionFor('N.project'); - await h.container.read(pairedAgentProvider.future); - await h.container.read(pairedAgentProvider.notifier).forgetMachine('M'); + await h.container + .read(machineConnectionProvider.notifier) + .forgetMachine('M'); - expect(h.storage.agents.map((a) => a.agentDeviceId), ['N.project']); expect(h.recentStore.list().map((a) => a.agentDeviceId), ['N.project']); expect(mgr.peek('M.projectA'), isNull); expect(mgr.peek('M.projectB'), isNull); @@ -193,8 +129,14 @@ void main() { }, ); + // The shape that actually reaches this today: the reconnect list holds the + // BARE machine uuid (that is what the dial upserts), while every per-entry + // store is keyed by the project's compound drawer id. Resolving the purge set + // from the reconnect list alone leaves each project's cache behind, and the + // machine's projects are usually all cold when it is forgotten — so neither + // that list nor the warm registry can name them. test( - 'forgetMachine purges cached sessions + status cache for its agents', + 'forgetMachine purges the cached projects of a machine held by bare uuid', () async { useInMemoryPrefs(); final tmp = await Directory.systemTemp.createTemp('antgrid-forget-test-'); @@ -208,8 +150,8 @@ void main() { final statusCache = ProjectStatusCache.testInstance(root: tmp.path); final recentStore = await RecentAgentsStore.open(); - await recentStore.upsert(_recent('M.project')); - await recentStore.upsert(_recent('N.project')); + await recentStore.upsert(_recent('M')); + await recentStore.upsert(_recent('N')); addTearDown(recentStore.close); final cachedSessions = await CachedSessionsStore.open(); @@ -238,13 +180,8 @@ void main() { ]); await statusCache.write('M.project', const ProjectStatus.empty()); - final storage = _MemoryStorageService([ - _paired('M.project'), - _paired('N.project'), - ]); final container = ProviderContainer( overrides: [ - storageServiceProvider.overrideWithValue(storage), recentAgentsStoreProvider.overrideWithValue(recentStore), cachedSessionsStoreProvider.overrideWithValue(cachedSessions), recentPortsStoreProvider.overrideWithValue(recentPorts), @@ -252,9 +189,10 @@ void main() { ], ); addTearDown(container.dispose); - await container.read(pairedAgentProvider.future); - await container.read(pairedAgentProvider.notifier).forgetMachine('M'); + await container + .read(machineConnectionProvider.notifier) + .forgetMachine('M'); await Future.delayed(Duration.zero); expect(cachedSessions.get('M.project'), isEmpty); diff --git a/app/test/providers/open_checkout_guard_test.dart b/app/test/providers/open_checkout_guard_test.dart new file mode 100644 index 00000000..dcda8c28 --- /dev/null +++ b/app/test/providers/open_checkout_guard_test.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:antgrid/models/ab_project.dart'; +import 'package:antgrid/providers/device_provisioning.dart'; +import 'package:antgrid/providers/open_checkout.dart'; +import 'package:antgrid/providers/projects.dart'; +import 'package:antgrid/providers/remote_access.dart'; +import 'package:antgrid/storage/project_store.dart'; + +import '../helpers/prefs_test_mock.dart'; + +/// `openCheckoutIn` / `copyCheckoutPath` resolve their path over the LOOPBACK +/// control plane, and reaching it SPAWNS the local bridge host. Their doc says +/// callers must not offer them for a relay-backed project; these pin that the +/// functions enforce it themselves, so a menu that stops gating (or a new +/// caller) cannot ask this machine about another machine's checkout. +void main() { + const localUuid = 'local-device-uuid'; + const localProjectId = '6e5c50e5d6f973a8'; + const remoteProjectId = 'uuidA.6e5c50e5d6f973a8'; + + late int hostReads; + late int clipboardWrites; + + /// Pumps a tree and hands back a context plus its container. + Future<(BuildContext, ProviderContainer)> pumpHarness( + WidgetTester tester, + ) async { + useInMemoryPrefs(); + final projectStore = await ProjectStore.open(); + await projectStore.upsert( + AbProject( + projectId: localProjectId, + folder: '/repos/antgrid', + displayName: 'antgrid', + hostDeviceUuid: localUuid, + hostMachineName: 'desk', + lastOpenedAt: DateTime.now(), + ), + ); + + hostReads = 0; + clipboardWrites = 0; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') clipboardWrites++; + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + projectStoreProvider.overrideWithValue(projectStore), + localDeviceUuidProvider.overrideWith((_) async => localUuid), + // Counts the reach rather than serving one: the host client is a live + // loopback socket, and what these tests are about is whether it is + // reached at all. + hostControlClientProvider.overrideWith((_) async { + hostReads++; + throw StateError('no host in tests'); + }), + ], + child: MaterialApp( + home: Scaffold(body: Builder(builder: (_) => const SizedBox())), + ), + ), + ); + final context = tester.element(find.byType(SizedBox)); + return (context, ProviderScope.containerOf(context)); + } + + testWidgets('a remote project never reaches the local host', (tester) async { + final (context, container) = await pumpHarness(tester); + + await copyCheckoutPath( + context, + container, + projectId: remoteProjectId, + checkoutId: 'main', + ); + await tester.pump(); + + expect(hostReads, 0); + expect(clipboardWrites, 0); + }); + + testWidgets('a project hosted by another device never reaches the host', ( + tester, + ) async { + final (context, container) = await pumpHarness(tester); + await container + .read(projectsProvider.notifier) + .upsert( + AbProject( + projectId: 'a1b2c3d4e5f60718', + folder: '/repos/other', + displayName: 'other', + hostDeviceUuid: 'some-other-device', + hostMachineName: 'build-server', + lastOpenedAt: DateTime.now(), + ), + ); + + await copyCheckoutPath( + context, + container, + projectId: 'a1b2c3d4e5f60718', + checkoutId: 'main', + ); + await tester.pump(); + + expect(hostReads, 0); + expect(clipboardWrites, 0); + }); + + testWidgets('a local project still reaches the host', (tester) async { + final (context, container) = await pumpHarness(tester); + + await copyCheckoutPath( + context, + container, + projectId: localProjectId, + checkoutId: 'main', + ); + await tester.pump(); + + // The guard is a gate, not a wall: the local path is unchanged, and the + // unreachable host in this harness surfaces as the usual snackbar. + expect(hostReads, 1); + expect(find.text('Could not reach the local host.'), findsOneWidget); + }); +} diff --git a/app/test/providers/relay_connection_supervisor_test.dart b/app/test/providers/relay_connection_supervisor_test.dart index 91a9702d..454d184c 100644 --- a/app/test/providers/relay_connection_supervisor_test.dart +++ b/app/test/providers/relay_connection_supervisor_test.dart @@ -15,7 +15,6 @@ import 'package:antgrid/providers/agent_transport.dart'; import 'package:antgrid/providers/providers.dart'; import 'package:antgrid/providers/relay_connection.dart'; import 'package:antgrid/services/license_token_minter.dart'; -import 'package:antgrid/services/storage_service.dart'; import 'package:antgrid_relay_client/antgrid_relay_client.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -154,20 +153,6 @@ class _FixedManager extends RelayConnectionManager { RelayConnection connectionFor(String machineDeviceId) => conn; } -class _MemoryStorageService extends StorageService { - _MemoryStorageService(this.agents); - - List agents; - - @override - Future> loadPairedAgents() async => List.of(agents); - - @override - Future savePairedAgents(List agents) async { - this.agents = List.of(agents); - } -} - DeviceIdentity _identity() => DeviceIdentity( deviceId: 'phone-1', name: 'Test Phone', @@ -438,15 +423,6 @@ void main() { var builds = 0; final container = ProviderContainer( overrides: [ - storageServiceProvider.overrideWithValue( - _MemoryStorageService([ - PairedAgent( - relayUrl: 'ws://relay.test', - agentDeviceId: 'M', - agentName: 'M', - ), - ]), - ), relayConnectionManagerProvider.overrideWithValue(_FixedManager(conn)), agentTransportForProvider.overrideWith((ref, id) async { builds++; @@ -459,11 +435,12 @@ void main() { container .read(selectedTargetProvider.notifier) .set(const RemoteTarget.legacy('M')); - await container.read(pairedAgentProvider.future); await container.read(agentTransportForProvider('M').future); expect(builds, 1); - await container.read(pairedAgentProvider.notifier).retryAgentConnection(); + await container + .read(machineConnectionProvider.notifier) + .retryAgentConnection(); expect(conn.supervisor!.status, isNot(isA())); await container.read(agentTransportForProvider('M').future); @@ -492,9 +469,6 @@ void main() { var builds = 0; final container = ProviderContainer( overrides: [ - storageServiceProvider.overrideWithValue( - _MemoryStorageService(const []), - ), relayConnectionManagerProvider.overrideWithValue(_FixedManager(conn)), agentTransportForProvider.overrideWith((ref, id) async { builds++; @@ -505,15 +479,16 @@ void main() { addTearDown(container.dispose); // A remote PROJECT focus is `.` and never matches - // a PairedAgent row keyed by the bare machine uuid, so resolving the - // retry target off the paired list drops this case entirely. + // a machine row keyed by the bare uuid, so resolving the retry target off + // the machine list drops this case entirely. container .read(selectedTargetProvider.notifier) .set(const RemoteProject(machineUuid: 'M', projectId: 'p')); - await container.read(pairedAgentProvider.future); await container.read(agentTransportForProvider('M.p').future); - await container.read(pairedAgentProvider.notifier).retryAgentConnection(); + await container + .read(machineConnectionProvider.notifier) + .retryAgentConnection(); expect(conn.supervisor!.status, isNot(isA())); await container.read(agentTransportForProvider('M.p').future); diff --git a/app/test/screens/app_shell_test.dart b/app/test/screens/app_shell_test.dart index e9d7d14a..374047be 100644 --- a/app/test/screens/app_shell_test.dart +++ b/app/test/screens/app_shell_test.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:antgrid_relay_client/antgrid_relay_client.dart'; import 'package:antgrid/models/terminal_models.dart'; import 'package:antgrid/models/file_tree_models.dart'; @@ -18,27 +17,7 @@ import 'package:antgrid/screens/workspace_shell.dart'; import '../helpers/test_store_overrides.dart'; import '../helpers/prefs_test_mock.dart'; -final _testAgent = PairedAgent( - relayUrl: 'wss://test.relay', - agentDeviceId: 'agent-123.test-project', - agentName: 'Test Agent', -); - -/// A fake PairedAgentNotifier that returns a list with one mock PairedAgent. -class FakePairedAgentNotifier extends AsyncNotifier> - implements PairedAgentNotifier { - @override - Future> build() async => [_testAgent]; - - @override - Future selectAgent(String agentDeviceId) async {} - @override - Future forgetMachine(String agentDeviceIdOrUuid) async {} - @override - Future retryAgentConnection() async {} - @override - void cancelActiveAgent() {} -} +const _testAgentDeviceId = 'agent-123.test-project'; void main() { late TestStoreOverrides stores; @@ -54,9 +33,8 @@ void main() { return ProviderScope( overrides: [ ...stores.overrides, - pairedAgentProvider.overrideWith(() => FakePairedAgentNotifier()), selectedRegistrationIdProvider.overrideWith( - (ref) => _testAgent.agentDeviceId, + (ref) => _testAgentDeviceId, ), terminalStateProvider.overrideWith( (ref) => Stream.value(const TerminalState()), diff --git a/app/test/widgets/agent_panel_test.dart b/app/test/widgets/agent_panel_test.dart index 3e3bc28c..28ac2372 100644 --- a/app/test/widgets/agent_panel_test.dart +++ b/app/test/widgets/agent_panel_test.dart @@ -8,9 +8,12 @@ import 'package:antgrid/design/widgets/ab_state_chip.dart'; import 'package:antgrid/launcher/host_control_client.dart'; import 'package:antgrid/models/ab_project.dart'; +import 'package:antgrid/providers/account_agents.dart'; import 'package:antgrid/providers/agent_transport.dart'; import 'package:antgrid/providers/device_provisioning.dart'; import 'package:antgrid/providers/remote_access.dart'; +import 'package:antgrid/services/account_agents_api.dart'; +import 'package:antgrid/storage/recent_agents_store.dart'; import 'package:antgrid/widgets/agent_panel.dart'; import 'package:antgrid/widgets/remote_host_chip.dart'; import 'package:antgrid/widgets/window_title_bar.dart'; @@ -75,6 +78,7 @@ Future _pump( TargetPlatform platform = TargetPlatform.macOS, String? localUuid = _localUuid, bool mobileAccessEnabled = false, + List inventory = const [], }) async { debugDefaultTargetPlatformOverride = platform; if (project != null) await stores.projectStore.upsert(project); @@ -84,6 +88,9 @@ Future _pump( ...stores.overrides, localDeviceUuidProvider.overrideWith((ref) async => localUuid), selectedRegistrationIdProvider.overrideWith((_) => selectedId), + // Never the real one: it reads the session cookie out of the keychain + // and fetches /account/agents. + accountAgentsProvider.overrideWith((_) async => inventory), remoteAccessPolicyProvider.overrideWith( () => _FakePolicyNotifier( RemoteAccessPolicy(enabled: mobileAccessEnabled), @@ -162,6 +169,96 @@ void main() { }, ); + testWidgets('a focused remote PROJECT names its machine from the inventory', ( + tester, + ) async { + // The shipped regression: a remote project's focus id is + // `.` and it is never upserted into the local + // project store, so a store-only lookup found nothing and the chip + // vanished for the one route that carries remote projects — driving + // another machine looked identical to driving your own. + final stores = await buildTestStoreOverrides(); + addTearDown(stores.close); + + await _pump( + tester, + stores: stores, + selectedId: '$_remoteUuid.remote-proj', + inventory: [ + InventoryAgent( + deviceUuid: _remoteUuid, + displayName: 'someone@example.com', + platform: 'linux', + ed25519Pub: 'pub', + machineName: 'build-server', + ), + ], + ); + + expect(find.byType(RemoteHostChip), findsOneWidget); + expect(find.text('build-server'), findsOneWidget); + expect( + tester.widget(find.byType(RemoteHostChip)).platform, + 'linux', + ); + }); + + testWidgets('a remote project falls back to the reconnect list offline', ( + tester, + ) async { + // `/account/agents` unreachable: the cached machine row still names it, and + // carries no platform — which the chip must render as desktop, not unknown. + final stores = await buildTestStoreOverrides(); + addTearDown(stores.close); + await stores.recentAgentsStore.upsert( + RecentAgent( + agentDeviceId: _remoteUuid, + agentLabel: 'work laptop', + agentEd25519Pubkey: 'pub', + relayUrl: 'wss://relay.example', + pairedAt: DateTime.now(), + lastConnectedAt: DateTime.now(), + hostMachineName: 'build-server', + ), + ); + + await _pump(tester, stores: stores, selectedId: '$_remoteUuid.remote-proj'); + + expect(find.text('build-server'), findsOneWidget); + expect( + tester.widget(find.byType(RemoteHostChip)).platform, + isNull, + ); + }); + + testWidgets('a focused LOCAL project renders no chip for its own machine', ( + tester, + ) async { + // The local machine is in the account inventory too; matching the focus + // against it must not turn this device into a remote host. + final stores = await buildTestStoreOverrides(); + addTearDown(stores.close); + + final project = _localProject(); + await _pump( + tester, + stores: stores, + project: project, + selectedId: project.projectId, + inventory: [ + InventoryAgent( + deviceUuid: _localUuid, + displayName: 'me@example.com', + platform: 'macos', + ed25519Pub: 'pub', + machineName: 'this-mac', + ), + ], + ); + + expect(find.byType(RemoteHostChip), findsNothing); + }); + testWidgets( 'no focused project still renders the machine switch, without the chip', (tester) async { diff --git a/app/test/widgets/drawer_entry_row_activation_test.dart b/app/test/widgets/drawer_entry_row_activation_test.dart index 0bd51981..88f7190a 100644 --- a/app/test/widgets/drawer_entry_row_activation_test.dart +++ b/app/test/widgets/drawer_entry_row_activation_test.dart @@ -3,15 +3,12 @@ import 'package:antgrid/models/session_target.dart'; import 'package:antgrid/providers/agent_transport.dart'; import 'package:antgrid/providers/control_plane.dart'; import 'package:antgrid/providers/drawer_entries.dart'; -import 'package:antgrid/providers/providers.dart'; import 'package:antgrid/providers/recent_agents.dart'; import 'package:antgrid/services/account_agents_api.dart'; import 'package:antgrid/services/control_plane_client.dart'; -import 'package:antgrid/services/storage_service.dart'; import 'package:antgrid/storage/recent_agents_store.dart'; import 'package:antgrid/test_helpers/fake_agent_transport.dart'; import 'package:antgrid/widgets/drawer_entry_row.dart'; -import 'package:antgrid_relay_client/antgrid_relay_client.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -43,25 +40,6 @@ InventoryAgent _inventoryAgent() => InventoryAgent( relayUrl: 'wss://relay.example.test/ws', ); -class _EmptyPairedAgentNotifier extends PairedAgentNotifier { - @override - Future> build() async => const []; -} - -class _MemoryStorageService extends StorageService { - _MemoryStorageService(this.agents); - - List agents; - - @override - Future> loadPairedAgents() async => List.of(agents); - - @override - Future savePairedAgents(List agents) async { - this.agents = List.of(agents); - } -} - class _SeededRecentAgentsNotifier extends RecentAgentsNotifier { _SeededRecentAgentsNotifier(this._seed); final List _seed; @@ -83,7 +61,6 @@ Future _pumpActivationHarness( overrides: [ ...stores.overrides, drawerEntriesProvider.overrideWithValue([entry]), - pairedAgentProvider.overrideWith(() => _EmptyPairedAgentNotifier()), // Activation now brings the machine up by reading its transport (the // supervisor owns the dial). A machine that cannot be reached surfaces // as this provider rejecting, which is what the restore-prior-target @@ -271,26 +248,18 @@ void main() { }, ); - testWidgets('forget remote row removes local machine trust', (tester) async { + testWidgets('forget remote row drops the machine from the reconnect list', ( + tester, + ) async { useInMemoryPrefs(); final stores = await buildTestStoreOverrides(); addTearDown(stores.close); final recent = _recentAgent(); await stores.recentAgentsStore.upsert(recent); - final storage = _MemoryStorageService([ - PairedAgent( - relayUrl: recent.relayUrl, - agentDeviceId: recent.agentDeviceId, - agentName: recent.agentLabel, - ), - ]); await tester.pumpWidget( ProviderScope( - overrides: [ - ...stores.overrides, - storageServiceProvider.overrideWithValue(storage), - ], + overrides: stores.overrides, child: MaterialApp( home: Scaffold(body: DrawerEntryRow(RemoteAgentEntry(recent))), ), @@ -311,14 +280,13 @@ void main() { await tester.pump(); // forgetMachine does real-event-loop work (awaited registry evict + // purgeEntryState's ProjectStatusCache file I/O) that the fake-async clock - // won't advance — so the trust-removal that follows it never lands and the - // row's busy spinner never resets. Drain the real loop once, then settle. + // won't advance — so the removal that follows it never lands and the row's + // busy spinner never resets. Drain the real loop once, then settle. await tester.runAsync( () => Future.delayed(const Duration(milliseconds: 200)), ); await tester.pumpAndSettle(); - expect(storage.agents, isEmpty); expect(stores.recentAgentsStore.list(), isEmpty); }); } diff --git a/app/test/widgets/remote_access_panel_test.dart b/app/test/widgets/remote_access_panel_test.dart index 8e6e98d8..1cbc3977 100644 --- a/app/test/widgets/remote_access_panel_test.dart +++ b/app/test/widgets/remote_access_panel_test.dart @@ -167,8 +167,8 @@ void main() { await _pumpPanel(tester, devices: _EmptyNotifier.new); expect(find.byKey(const Key('remote-access-switch')), findsOneWidget); - // QR pairing is hidden for the initial release; an account device admits - // itself, so the empty roster has no action to offer. + // There is no pairing ceremony — an account device admits itself, so the + // empty roster has no action to offer. expect(find.byType(AbButton), findsNothing); }); } diff --git a/app/test/widgets/session_row_start_refusal_test.dart b/app/test/widgets/session_row_start_refusal_test.dart index bdca1cb3..8fbe12c6 100644 --- a/app/test/widgets/session_row_start_refusal_test.dart +++ b/app/test/widgets/session_row_start_refusal_test.dart @@ -17,6 +17,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import '../helpers/prefs_test_mock.dart'; +import '../helpers/test_store_overrides.dart'; const _projectId = 'p'; const _sessionId = 'sess-1'; @@ -79,8 +80,14 @@ void main() { ) async { final cache = await CachedSessionsStore.open(); final session = await _session(transport, cache); + // The row's own tap asks whether this entry is relay-reached, which reads + // the recent-agents store — a throw-by-default provider, and a throw there + // is swallowed as a failed activation, i.e. a tap that does nothing. + final stores = await buildTestStoreOverrides(); + addTearDown(stores.close); final container = ProviderContainer( overrides: [ + ...stores.overrides, selectedRegistrationIdProvider.overrideWithValue(_projectId), projectSessionProvider.overrideWith((ref, id) async => session), ], diff --git a/app/test/widgets/window_title_bar_contents_test.dart b/app/test/widgets/window_title_bar_contents_test.dart index f45a624a..a6714832 100644 --- a/app/test/widgets/window_title_bar_contents_test.dart +++ b/app/test/widgets/window_title_bar_contents_test.dart @@ -6,13 +6,14 @@ import 'package:antgrid/design/widgets/ab_brand_mark.dart'; import 'package:antgrid/design/widgets/ab_icon_button.dart'; import 'package:antgrid/models/handler_state.dart'; import 'package:antgrid/models/terminal_models.dart'; +import 'package:antgrid/providers/account_agents.dart'; import 'package:antgrid/providers/agent_transport.dart'; +import 'package:antgrid/providers/device_provisioning.dart'; import 'package:antgrid/providers/providers.dart'; import 'package:antgrid/providers/value_controller.dart'; import 'package:antgrid/test_helpers/fake_agent_transport.dart'; import 'package:antgrid/widgets/window_title_bar.dart'; import 'package:antgrid/window/window_chrome.dart'; -import 'package:antgrid_relay_client/antgrid_relay_client.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -26,30 +27,6 @@ import '../helpers/test_store_overrides.dart'; /// `pumpAt`'s default. const _focusedProjectId = 'agent-123.test-project'; -final _testAgent = PairedAgent( - relayUrl: 'wss://test.relay', - agentDeviceId: _focusedProjectId, - agentName: 'Test Agent', -); - -/// A fake PairedAgentNotifier that returns a list with one mock PairedAgent. -/// -/// Copied from `app_shell_test.dart` (a local class there, not exported). -class FakePairedAgentNotifier extends AsyncNotifier> - implements PairedAgentNotifier { - @override - Future> build() async => [_testAgent]; - - @override - Future selectAgent(String agentDeviceId) async {} - @override - Future forgetMachine(String agentDeviceIdOrUuid) async {} - @override - Future retryAgentConnection() async {} - @override - void cancelActiveAgent() {} -} - void main() { late TestStoreOverrides stores; @@ -82,7 +59,16 @@ void main() { overrides: [ ...stores.overrides, ...extraOverrides, - pairedAgentProvider.overrideWith(() => FakePairedAgentNotifier()), + // The title bar renders RemoteHostChip off these two, and both are + // account/keychain-backed: the real inventory fetch reads the session + // cookie out of the keychain and the real uuid MINTS a host identity + // on desktop. Riverpod 3 asserts on a provider overridden twice in + // one container, so a test wanting different values must not also + // pass them in [extraOverrides]. + accountAgentsProvider.overrideWith((_) async => const []), + localDeviceUuidProvider.overrideWith( + (_) async => 'test-local-device', + ), selectedRegistrationIdProvider.overrideWith((ref) => projectId), // A focused id makes projectSessionProvider reachable — the handler // control resolves its service through serviceWhenReady — so hand diff --git a/bridge/tests/relay-promotion.test.ts b/bridge/tests/relay-promotion.test.ts index fd26eb6f..23d03159 100644 --- a/bridge/tests/relay-promotion.test.ts +++ b/bridge/tests/relay-promotion.test.ts @@ -3,7 +3,7 @@ // events (onAuthenticated/onPaired/onError). In v3 there is exactly // ONE machine RelayClient, owned by HostServer — this controller just asks the // host to bring it up (`ensureMachineRelay`), attaches the local core's bus as -// a stream (`attach`), and publishes the connect QR. All the reconnect/auth/ +// a stream (`attach`), and announces readiness. All the reconnect/auth/ // unpair reactivity that used to live here moved to HostServer's control-plane // wiring and ProjectCore's attachRelayStream (see host-promotion.test.ts, // project-core.test.ts). diff --git a/packages/antgrid_relay_client/lib/antgrid_relay_client.dart b/packages/antgrid_relay_client/lib/antgrid_relay_client.dart index 0d86f722..c6ea4aa0 100644 --- a/packages/antgrid_relay_client/lib/antgrid_relay_client.dart +++ b/packages/antgrid_relay_client/lib/antgrid_relay_client.dart @@ -3,7 +3,6 @@ export 'src/buffered_agent_transport.dart'; export 'src/connection_handshake.dart'; export 'src/local_transport.dart'; export 'src/machine_session.dart'; -export 'src/pair_exception.dart'; export 'src/relay_service.dart'; export 'src/crypto_service.dart'; export 'src/frag.dart'; diff --git a/packages/antgrid_relay_client/lib/src/pair_exception.dart b/packages/antgrid_relay_client/lib/src/pair_exception.dart deleted file mode 100644 index 27cefbf2..00000000 --- a/packages/antgrid_relay_client/lib/src/pair_exception.dart +++ /dev/null @@ -1,22 +0,0 @@ -/// Thrown when importing an agent's coordinates (from a QR scan) fails to -/// establish a session against it. Caller should surface [message] to the -/// user (e.g. via a SnackBar) and may retry when [agentOffline] is set. -/// -/// Lives in the relay client (not the Flutter app) so the pure-Dart eval client -/// and the app share one exception type and one notion of "retryable" — the -/// value mirrors the relay error frame's `retryable` verdict. -class PairException implements Exception { - final String message; - - /// True when the failure is a transient routing miss — the relay's retryable - /// `AGENT_OFFLINE` (a project core whose relay slot hasn't registered yet) or - /// a plain network drop before the agent saw the handshake. The caller may - /// retry on the SAME still-open socket to ride this out. A genuine - /// rejection or a terminal close (license errorCode) is NOT marked offline, - /// so callers don't retry it. - final bool agentOffline; - - PairException(this.message, {this.agentOffline = false}); - @override - String toString() => 'PairException: $message'; -} diff --git a/packages/antgrid_relay_client/pubspec.yaml b/packages/antgrid_relay_client/pubspec.yaml index 48897b60..74c95a70 100644 --- a/packages/antgrid_relay_client/pubspec.yaml +++ b/packages/antgrid_relay_client/pubspec.yaml @@ -1,5 +1,5 @@ name: antgrid_relay_client -description: Pure Dart relay client for Antgrid — handles WebSocket auth, pairing, and E2E encryption. +description: Pure Dart relay client for Antgrid — handles WebSocket auth and E2E encryption. publish_to: none version: 1.0.0 From 9ba4cfafa494ca9106e772078cccf5ff02c23ca5 Mon Sep 17 00:00:00 2001 From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:43:58 +0800 Subject: [PATCH 15/15] A folder on this disk must not become someone else's when this device is re-provisioned (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This device's own host identity can move under an already-stored project. `localDeviceUuidProvider` mints and persists an anonymous uuid whenever it is read with an empty keychain, and `ensureCurrentUserDeviceRecord` reads the prefs key BEFORE its provisioning round trip — so an anonymous uuid minted during that window is replaced the moment the account record lands. A folder opened in the same window keeps the outgoing uuid. The prefs key self-heals; the project rows stamped with the old value never did, and `AbProject.isLocalFor` then reads the folder as hosted elsewhere for good: no Open-in-editor or Copy-path rows, a refusal from the direct callers, and a "Remote host" chip for a folder on the user's own disk. The only repair available was removing the project and re-picking it, which destroys the bridge's sessions.json and the project's isolated worktrees. Repaired at the two points that can know: `ensureCurrentUserDeviceRecord` is the ONE place a persisted host uuid is replaced by a different one, so it re-stamps the rows carrying the outgoing value as it goes; and `registerPickedFolder` re-stamps the row it finds, since the pick itself is proof the folder is on this machine. Both are exact — no blanket "non-matching uuid becomes local" migration, which would be right today only because the local-open path is the sole writer of the field. The doc comment on hostDeviceUuid already promised this backfill. Nothing implemented it. --- app/lib/models/ab_project.dart | 14 +- app/lib/providers/device_provisioning.dart | 38 +++- app/lib/providers/projects.dart | 19 ++ app/lib/widgets/open_folder_button.dart | 15 +- .../providers/host_uuid_backfill_test.dart | 166 ++++++++++++++++++ .../widgets/open_folder_register_test.dart | 40 +++++ 6 files changed, 283 insertions(+), 9 deletions(-) create mode 100644 app/test/providers/host_uuid_backfill_test.dart diff --git a/app/lib/models/ab_project.dart b/app/lib/models/ab_project.dart index b7f13875..ebe0ce10 100644 --- a/app/lib/models/ab_project.dart +++ b/app/lib/models/ab_project.dart @@ -16,11 +16,15 @@ class AbProject { String displayName; /// The physical device whose keychain minted this project's local agent - /// identity. `null` for pre-v2 projects (where the field didn't exist yet) - /// and for projects opened before the current device was provisioned — in - /// both cases the project is treated as "local to whoever is asking" (see - /// [isLocalFor]) and the field is backfilled when the project is next - /// upserted on this device. + /// identity. `null` only for pre-v2 projects, where the field didn't exist + /// yet; those are treated as "local to whoever is asking" (see [isLocalFor]). + /// + /// The value can go STALE while the folder stays local: this device's own + /// identity moves when sign-in provisioning replaces an anonymous uuid, and a + /// row left on the old one fails [isLocalFor] for good. Two paths repair it — + /// `ensureCurrentUserDeviceRecord` remaps every row carrying the outgoing + /// uuid as it replaces it, and `registerPickedFolder` re-stamps a row + /// whenever the user picks that folder again. String? hostDeviceUuid; String hostMachineName; DateTime lastOpenedAt; diff --git a/app/lib/providers/device_provisioning.dart b/app/lib/providers/device_provisioning.dart index 5cf99908..2400f788 100644 --- a/app/lib/providers/device_provisioning.dart +++ b/app/lib/providers/device_provisioning.dart @@ -7,11 +7,13 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:uuid/uuid.dart'; import '../config/storage_scope.dart'; +import '../models/ab_project.dart'; import '../services/device_provisioning.dart'; import '../services/devices_api.dart'; import '../services/keychain_device_store.dart'; import '../util/ab_log.dart'; import 'auth.dart'; +import 'projects.dart'; import 'provider_retry.dart'; bool _isDesktopPlatform() => @@ -64,13 +66,47 @@ Future ensureCurrentUserDeviceRecord(dynamic ref) async { existingDeviceUuid: existing, ); - if (await prefs.getString(kLocalHostUuidKey) != record.deviceUuid) { + // Re-read rather than reuse `existing`: the desktop self-heal in + // [localDeviceUuidProvider] mints and persists an anonymous uuid whenever it + // is read with an empty keychain, which can land during the provisioning + // round trip above — and a folder opened in that window is stamped with it. + final outgoing = await prefs.getString(kLocalHostUuidKey); + if (outgoing != record.deviceUuid) { await prefs.setString(kLocalHostUuidKey, record.deviceUuid); + if (outgoing != null) { + await _rehostLocalProjects(ref, from: outgoing, to: record.deviceUuid); + } } ref.invalidate(localDeviceUuidProvider); return record; } +/// Moves projects recorded against a replaced host identity onto the new one. +/// +/// This is the only place a persisted host uuid is replaced by a *different* +/// value, so it is the only place that can repair the rows carrying the old +/// one: the prefs key self-heals, project rows never did, and a row left behind +/// fails [AbProject.isLocalFor] forever — losing its working-directory actions +/// and wearing a "Remote host" chip for a folder on this disk. +/// +/// Swallowed: a failed repair must not fail provisioning, and the same rows are +/// still fixed by re-opening the folder (`registerPickedFolder`). +Future _rehostLocalProjects( + dynamic ref, { + required String from, + required String to, +}) async { + try { + await ref.read(projectsProvider.notifier).rehost(from: from, to: to); + } catch (e) { + AbLog.warn( + 'device_provisioning', + 'host uuid backfill skipped', + fields: {'error': '$e'}, + ); + } +} + /// Best-effort resolve of the machine [DeviceRecord] to carry into a host /// bootstrap: the keychain record if present, else provision one when a user is /// signed in. ANY failure (auth fetch offline, provisioning rejected) resolves diff --git a/app/lib/providers/projects.dart b/app/lib/providers/projects.dart index e46ea2ac..050df62c 100644 --- a/app/lib/providers/projects.dart +++ b/app/lib/providers/projects.dart @@ -34,6 +34,25 @@ class ProjectsNotifier extends Notifier> { state = _store.list(); } + /// Re-stamps every project recorded against [from] with [to]. + /// + /// Called when this device's persisted host identity is replaced — see + /// `ensureCurrentUserDeviceRecord`, the one place that happens. Only the + /// local-open path ever writes `hostDeviceUuid`, so a row holding the + /// outgoing uuid is a folder on THIS machine whose identity moved, never a + /// project hosted elsewhere; left behind it fails `AbProject.isLocalFor` for + /// good. + Future rehost({required String from, required String to}) async { + if (from == to) return; + var changed = false; + for (final p in _store.list()) { + if (p.hostDeviceUuid != from) continue; + p.hostDeviceUuid = to; + changed = await _store.upsert(p) || changed; + } + if (changed) state = _store.list(); + } + Future remove(String id) async { // Only stop active sessions when the project is already warm — warming a // cold project just to stop sessions would block on the relay connect + diff --git a/app/lib/widgets/open_folder_button.dart b/app/lib/widgets/open_folder_button.dart index a81e69d9..4a353e7c 100644 --- a/app/lib/widgets/open_folder_button.dart +++ b/app/lib/widgets/open_folder_button.dart @@ -77,15 +77,24 @@ Future registerPickedFolder( bool select = true, }) async { final id = await computeProjectId(folder); + final hostUuid = await _resolveLocalHostUuid(ref); final projects = ref.read(projectsProvider); final existingMatches = projects.where((p) => p.projectId == id).toList(); if (existingMatches.isNotEmpty) { - existingMatches.first.lastOpenedAt = DateTime.now(); - await ref.read(projectsProvider.notifier).upsert(existingMatches.first); + final existing = existingMatches.first; + existing.lastOpenedAt = DateTime.now(); + // Re-stamp the host identity, not just the timestamp. The uuid this device + // answers with can move under an already-stored row — a folder opened while + // sign-in provisioning was still in flight keeps the anonymous uuid the + // account record then replaced — and a row left on the old one reads as + // hosted elsewhere forever: no working-directory actions, and a "Remote + // host" chip for a folder on this disk. The pick is the proof of locality; + // the user just named this folder on this machine. + existing.hostDeviceUuid = hostUuid; + await ref.read(projectsProvider.notifier).upsert(existing); if (select) selectProject(ref, id); return id; } - final hostUuid = await _resolveLocalHostUuid(ref); final project = AbProject( projectId: id, folder: folder, diff --git a/app/test/providers/host_uuid_backfill_test.dart b/app/test/providers/host_uuid_backfill_test.dart new file mode 100644 index 00000000..d45cdc23 --- /dev/null +++ b/app/test/providers/host_uuid_backfill_test.dart @@ -0,0 +1,166 @@ +// This device's own host identity can MOVE under an already-stored project: +// `localDeviceUuidProvider` mints an anonymous uuid whenever it is read with an +// empty keychain, and sign-in provisioning then replaces it. The prefs key +// self-heals; the project rows stamped with the outgoing value did not, and a +// row left behind fails `AbProject.isLocalFor` forever — a folder on this disk +// with no working-directory actions and a "Remote host" chip. +import 'dart:async'; + +import 'package:antgrid/models/ab_project.dart'; +import 'package:antgrid/providers/auth.dart'; +import 'package:antgrid/providers/device_provisioning.dart'; +import 'package:antgrid/providers/projects.dart'; +import 'package:antgrid/services/auth_service.dart'; +import 'package:antgrid/services/device_provisioning.dart'; +import 'package:antgrid/services/devices_api.dart'; +import 'package:antgrid/storage/project_store.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/fake_device_store.dart'; +import '../helpers/prefs_test_mock.dart'; + +AbProject _project(String id, String? hostDeviceUuid) => AbProject( + projectId: id, + folder: '/tmp/$id', + displayName: id, + hostDeviceUuid: hostDeviceUuid, + hostMachineName: '', + lastOpenedAt: DateTime.utc(2026, 1, 1), +); + +/// Echoes the requested uuid exactly as `POST /devices` does (it never mints +/// one of its own), and holds the call open so a test can move the persisted +/// host identity mid-flight — the window the race actually lives in. +class _GatedDevicesApi implements DevicesApiCreator { + _GatedDevicesApi(this.gate); + + final Completer gate; + final started = Completer(); + + @override + Future createDevice({ + required String deviceUuid, + required String ed25519Pub, + required String x25519Pub, + required String platform, + required String displayName, + String? kind, + }) async { + if (!started.isCompleted) started.complete(); + await gate.future; + return CreatedDevice( + deviceUuid: deviceUuid, + clientId: 'cid-$deviceUuid', + clientSecret: 'csec-$deviceUuid', + ); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('rehost moves only the rows carrying the outgoing uuid', () async { + useInMemoryPrefs(); + final store = await ProjectStore.open(); + await store.upsert(_project('p1', 'anon-A')); + await store.upsert(_project('p2', 'other-B')); + await store.upsert(_project('p3', null)); + final container = ProviderContainer( + overrides: [projectStoreProvider.overrideWithValue(store)], + ); + addTearDown(container.dispose); + + await container + .read(projectsProvider.notifier) + .rehost(from: 'anon-A', to: 'device-K'); + + final byId = { + for (final p in container.read(projectsProvider)) p.projectId: p, + }; + expect(byId['p1']!.hostDeviceUuid, 'device-K'); + // A row on some other uuid is NOT this device's to claim, and a pre-v2 null + // already reads as local — neither is the row the repair is for. + expect(byId['p2']!.hostDeviceUuid, 'other-B'); + expect(byId['p3']!.hostDeviceUuid, isNull); + }); + + test( + 'a folder opened while provisioning is in flight is re-hosted onto the ' + 'provisioned uuid', + () async { + // Prefs starts empty, so provisioning reads no uuid to reuse and mints + // one — the exact ordering that lets the self-heal slip in behind it. + useInMemoryPrefs(); + final projectStore = await ProjectStore.open(); + final keychain = inMemoryDeviceStore(); + final gate = Completer(); + final api = _GatedDevicesApi(gate); + + final container = ProviderContainer( + overrides: [ + projectStoreProvider.overrideWithValue(projectStore), + keychainDeviceStoreProvider.overrideWithValue(keychain), + licenseApiUrlProvider.overrideWithValue('https://api.antgrid.test'), + deviceProvisioningProvider.overrideWithValue( + DeviceProvisioning(api: api, store: keychain, platform: 'linux'), + ), + currentUserProvider.overrideWith( + (ref) => CurrentUser(userId: 'u-1', email: 'a@b.test'), + ), + ], + ); + addTearDown(container.dispose); + + final provisioning = ensureCurrentUserDeviceRecord(container); + await api.started.future; + // The desktop self-heal mints and persists an anonymous uuid, and the + // folder picked in that window is stamped with it. + await SharedPreferencesAsync().setString(kLocalHostUuidKey, 'anon-A'); + await projectStore.upsert(_project('p1', 'anon-A')); + gate.complete(); + final record = await provisioning; + + expect(record.deviceUuid, isNot('anon-A')); + expect( + await SharedPreferencesAsync().getString(kLocalHostUuidKey), + record.deviceUuid, + ); + final project = container.read(projectsProvider).single; + expect(project.hostDeviceUuid, record.deviceUuid); + expect(project.isLocalFor(record.deviceUuid), isTrue); + }, + ); + + test('a project already on the provisioned uuid is left alone', () async { + useInMemoryPrefs({'antgrid.local_host_uuid': 'anon-A'}); + final projectStore = await ProjectStore.open(); + await projectStore.upsert(_project('p1', 'anon-A')); + final keychain = inMemoryDeviceStore(); + final gate = Completer()..complete(); + final api = _GatedDevicesApi(gate); + + final container = ProviderContainer( + overrides: [ + projectStoreProvider.overrideWithValue(projectStore), + keychainDeviceStoreProvider.overrideWithValue(keychain), + licenseApiUrlProvider.overrideWithValue('https://api.antgrid.test'), + deviceProvisioningProvider.overrideWithValue( + DeviceProvisioning(api: api, store: keychain, platform: 'linux'), + ), + currentUserProvider.overrideWith( + (ref) => CurrentUser(userId: 'u-1', email: 'a@b.test'), + ), + ], + ); + addTearDown(container.dispose); + + // The anon uuid is reused as the account device's, so nothing moves — this + // is the ordinary anonymous→signed-in transition, not the race. + final record = await ensureCurrentUserDeviceRecord(container); + + expect(record.deviceUuid, 'anon-A'); + expect(container.read(projectsProvider).single.hostDeviceUuid, 'anon-A'); + }); +} diff --git a/app/test/widgets/open_folder_register_test.dart b/app/test/widgets/open_folder_register_test.dart index c6e8b027..93a9514e 100644 --- a/app/test/widgets/open_folder_register_test.dart +++ b/app/test/widgets/open_folder_register_test.dart @@ -13,6 +13,8 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:antgrid/launcher/project_id.dart'; +import 'package:antgrid/models/ab_project.dart'; import 'package:antgrid/providers/agent_transport.dart'; import 'package:antgrid/providers/device_provisioning.dart'; import 'package:antgrid/providers/projects.dart'; @@ -81,6 +83,44 @@ void main() { }, ); + testWidgets('re-picking a folder re-stamps a stale host identity', ( + tester, + ) async { + // A row left on an identity this device no longer answers with — what a + // folder opened during sign-in provisioning carries. Bumping only + // `lastOpenedAt` (what this used to do) left it failing `isLocalFor` + // forever: no working-directory actions, and a "Remote host" chip for a + // folder the user just pointed at on this machine. + final ref = await pumpRefHost(tester); + final id = (await tester.runAsync(() => computeProjectId(folder)))!; + await tester.runAsync( + () => stores.projectStore.upsert( + AbProject( + projectId: id, + folder: folder, + displayName: 'stale', + hostDeviceUuid: 'anon-A', + hostMachineName: '', + lastOpenedAt: DateTime.utc(2026, 1, 1), + ), + ), + ); + + await tester.runAsync( + () => registerPickedFolder(ref.container, folder, select: false), + ); + + final localUuid = await tester.runAsync( + () => ref.container.read(localDeviceUuidProvider.future), + ); + final stored = stores.projectStore.list().singleWhere( + (p) => p.projectId == id, + ); + expect(stored.hostDeviceUuid, isNot('anon-A')); + expect(stored.hostDeviceUuid, localUuid); + expect(stored.isLocalFor(localUuid!), isTrue); + }); + testWidgets('registerPickedFolder defaults to selecting the folder project', ( tester, ) async {