From 49b16d8ce59b4f4885a9ec93a0a4026846b62831 Mon Sep 17 00:00:00 2001 From: architdhamija Date: Thu, 10 Sep 2026 17:37:26 +0530 Subject: [PATCH 1/8] setup wizard --- .mex/ROUTER.md | 6 + .mex/context/architecture.md | 4 +- .mex/patterns/secure-local-project-hub.md | 13 +- packages/hub-contracts/package.json | 4 + packages/hub-contracts/src/setup.ts | 88 +++ packages/hub-contracts/tsup.config.ts | 1 + .../scripts/assert-production-build.mjs | 13 + .../scripts/assert-test-contract-boundary.mjs | 1 + packages/hub-web/scripts/contract-aliases.mjs | 1 + packages/hub-web/src/api/client.ts | 117 +++- packages/hub-web/src/api/telemetry.ts | 1 + packages/hub-web/src/api/types.ts | 9 + packages/hub-web/src/app/App.test.tsx | 2 +- packages/hub-web/src/app/App.tsx | 64 ++- packages/hub-web/src/app/setup-page.test.tsx | 157 ++++++ packages/hub-web/src/pages/SetupPage.tsx | 522 ++++++++++++++++++ packages/hub-web/src/styles/setup.module.css | 426 ++++++++++++++ packages/hub-web/src/test/setup.ts | 13 + packages/hub-web/tsconfig.json | 6 +- src/cli.ts | 6 +- src/hub/__tests__/setup-command.test.ts | 107 ++++ src/hub/app.ts | 135 +++++ src/hub/command.ts | 90 +++ src/hub/setup/__tests__/http.test.ts | 150 +++++ src/hub/setup/runner.ts | 208 +++++++ src/hub/setup/services.ts | 193 +++++++ src/hub/telemetry.ts | 2 +- src/setup/__tests__/headless.test.ts | 69 +++ src/setup/headless-population.ts | 55 ++ src/setup/headless.ts | 372 +++++++++++++ src/setup/index.ts | 87 +-- src/telemetry/schema.ts | 2 +- vitest.config.ts | 9 + 33 files changed, 2864 insertions(+), 69 deletions(-) create mode 100644 packages/hub-contracts/src/setup.ts create mode 100644 packages/hub-web/src/app/setup-page.test.tsx create mode 100644 packages/hub-web/src/pages/SetupPage.tsx create mode 100644 packages/hub-web/src/styles/setup.module.css create mode 100644 src/hub/__tests__/setup-command.test.ts create mode 100644 src/hub/setup/__tests__/http.test.ts create mode 100644 src/hub/setup/runner.ts create mode 100644 src/hub/setup/services.ts create mode 100644 src/setup/__tests__/headless.test.ts create mode 100644 src/setup/headless-population.ts create mode 100644 src/setup/headless.ts diff --git a/.mex/ROUTER.md b/.mex/ROUTER.md index 49ce8a59..140b3c49 100644 --- a/.mex/ROUTER.md +++ b/.mex/ROUTER.md @@ -28,6 +28,12 @@ Then read this file fully before doing anything else in this session. ## Current Project State **Working:** +- Incomplete checkouts open a Hub setup wizard from `mex hub` instead of the + full dashboard. The wizard runs the same ordered `mex setup` steps through a + headless engine, pauses at population with a copyable prompt when no Claude or + Codex CLI finishes the scaffold, and prints the same commit commands. MEX + still never runs git init, commit, or push. A ready tracked `.mex/config.json` + keeps the existing Project Hub unchanged. - The MEX repository now dogfoods the ordinary `mex setup` path. Resumed setup reuses persisted AI-tool selection even while population is incomplete, and existing-codebase prompts merge missing knowledge without replacing authored diff --git a/.mex/context/architecture.md b/.mex/context/architecture.md index 9fc02390..92052d62 100644 --- a/.mex/context/architecture.md +++ b/.mex/context/architecture.md @@ -23,7 +23,7 @@ edges: # Broad overview: keep this empty unless a claim depends on a few specific symbols. # Entry shape: { node: "function:", fingerprint: "mh:64:" } grounds_to: [] -last_updated: 2026-09-09 +last_updated: 2026-09-10 mex: id: mx_01M1M0CJ5C5XQV0HM5VM787WQS type: architecture @@ -83,7 +83,7 @@ revision: 1 - **Code Graph (`src/graph/`)** — deterministic extraction, versioned SQLite storage, immutable read sessions, provenance/freshness checks, retrieval, impact, and explicit refresh/rebuild recovery. - **Wiki (`src/wiki/`)** — treats repository Markdown as canonical, owns migration/validation/indexing, and exposes bounded query plus repository-adapter services. - **Team workflows (`src/team/`)** — canonical Members, Activity, Workstreams, Inbox, and Relay records plus signed preview/apply services and isolated checkout-local state. -- **Project Hub (`src/hub/`, `packages/hub-contracts`, `packages/hub-web`)** — `runHubCommand()` composes the loopback server, private API contracts, repository adapters, durable local jobs, and route-lazy React workbench. +- **Project Hub (`src/hub/`, `packages/hub-contracts`, `packages/hub-web`)** — `launchHub()` opens the loopback server. Incomplete checkouts get a setup-only process that runs the same ordered `mex setup` steps through `runHeadlessSetup()`; a ready tracked scaffold still uses `runHubCommand()` for the private API, repository adapters, durable local jobs, and route-lazy React workbench. - **Drift and agent workflows (`src/drift/`, `src/sync/`, `src/agent-skills/`)** — check grounded knowledge, prepare bounded repair briefs, and install the governed Inbox/Relay integrations. ## External Dependencies -- **Git** — repository identity, revisions, sharing, and bounded read-only observations; product code never stages, commits, pushes, or pulls. +- **Git** — repository identity, revisions, sharing, and bounded read-only observations. The setup-only Hub also offers an explicit, revision-bound diff review and local commit of scoped setup files, preserving unrelated staged work. Ordinary reads do not stage or commit; product code never pushes or pulls. - Claude Code or Codex may be launched for setup population; interactive sync can use Claude Code, Codex, or OpenCode, and prompt-only fallback works with any file-reading agent. diff --git a/.mex/patterns/secure-local-project-hub.md b/.mex/patterns/secure-local-project-hub.md index 2cb35d2a..928646c1 100644 --- a/.mex/patterns/secure-local-project-hub.md +++ b/.mex/patterns/secure-local-project-hub.md @@ -11,12 +11,12 @@ edges: condition: "when persisting a Hub job or migrating team.db" - target: "context/architecture.md" condition: "when wiring a real Graph or Wiki adapter" -last_updated: 2026-09-10 +last_updated: 2026-09-11 mex: id: mx_01M1M0CJQ2BSV71G1C7TXZD9RH type: pattern status: promoted - revision: 4 + revision: 10 title: secure-local-project-hub grounds_to: - node: function:188820af31f0d74c6518f6926f559877 @@ -90,15 +90,81 @@ preview/apply services. let `EventSource` reconnect to a finished job. Setup population pause is a terminal setup-run status: return the prompt on the run snapshot, then close the stream. Hub jobs still persist no prompts. -- Incomplete checkouts must not boot Graph/Wiki/Team jobs. `launchHub()` serves - the setup wizard until Graph and Wiki indexes exist. That same process then - replaces the setup app with the full Hub on the existing session and port. - Full Hub returns `CAPABILITY_UNAVAILABLE` for `/api/v1/setup`. MEX still - never commits; the dashboard does not wait for a git checkpoint. +- New code checkouts stay in setup until population and finalization finish and + the existing Team authority check accepts the committed scaffold identity. + Show the Git checkpoint explicitly, with a bounded setup-file diff and a + separate user-requested local commit action. Preview and apply are authenticated + POSTs; bind reviewed files, HEAD, branch, and index to one expiring process-local + revision and revalidate before committing. Scope the candidate to canonical + setup files and selected agent assets; exclude generated databases, local state, + and unrelated project files. Preserve unrelated staged entries. Never push. + Render complete unified diffs as numbered context/addition/deletion rows with + explicit change markers and counts. Hide only recognized Git bookkeeping; + preserve file-mode and missing-newline information. Verify hunk counts before + formatting, mount source rows only for expanded files, and bound formatted + rows per file and across the review. Keep the exact raw diff available when + parsing, truncation or rendering limits prevent a complete formatted view. + Build the exact reviewed tree with an alternate index, create its commit object + with the reviewed parent and configured identity/signing, then publish through + a prepared Git ref transaction with the expected old HEAD. Revalidate the + symbolic branch while Git holds its ref locks; install only the reviewed index + entries alongside the preserved unrelated entries. Review authorization expires + after five minutes; cap it at 200 text files, 256 KiB per file, 32,768 diff + characters per file and 131,072 diff characters in total. Bound review/apply + operations to 60/120 seconds. Active commit/reference hooks, custom content + filters, detached HEAD and incomplete reviews retain the manual Git option. + Return the saved commit receipt even if subsequent Hub promotion fails, so + retries cannot duplicate a successful commit. If index installation cannot be rolled back + safely, retain its recovery file and require manual recovery before retrying + promotion. Promotion replaces + the app on the existing session and port, and its failure must fail the run. + Existing committed projects still open Hub with missing disposable indexes + so Health can offer repair. Agent-memory mode remains separate, even in Git. + Full Hub returns `CAPABILITY_UNAVAILABLE` for `/api/v1/setup`. - Do not spawn interactive `mex setup` from Hub. Reuse `runHeadlessSetup()` so detect → scaffold → tools → skills → identity → scan → graph → population → finalize stay one path. Code-repo without git is a 400; the UI shows `git init` and never runs it. +- Browser population owns an asynchronous headless CLI process, a private + prompt, a bounded deadline, and cancellation of its process tree. Graph + construction uses an isolated worker. Shutdown waits for setup to settle; + cancellation during promotion closes newly composed jobs before app swap. + Keep command construction shared with the terminal adapter and expose only + fixed, safe failure categories, never raw child diagnostics. +- Claude stream-json and Codex JSONL activity remain a closed vocabulary for + timing and completion. The explicitly requested setup console has a separate + projection of visible assistant prose plus fixed, compact tool labels such + as "Ran a command" and "Read a file". Drop tool arguments, command text, paths, + and results before transcript retention and delivery; keep prose as the main + content. Exclude provider user/system message blocks, reasoning, session + identifiers, usage metadata, and raw diagnostic envelopes. Render prose literally + with terminal controls removed and recognizable credentials masked; masking + is best effort, not a guarantee that arbitrary output contains no secrets. + No transcript content enters telemetry, durable jobs, or canonical knowledge. +- Keep transcript retention process-local and bounded by both UTF-8 text bytes + (1 MiB) and entry count (2,048), with bounded ingress and 4,096-character + entries. Send at most 32 entries per cursor page through an authenticated SSE + endpoint; report lost history on eviction, validate the run identity, honor + Last-Event-ID, and expire even backpressured streams. Subscriber notifications + carry no output backlog. Never put the transcript into repeated run snapshots. + Browser retention is independently bounded, with a limited text/row window, + literal selectable text, stable scrollback, and explicit Follow latest. +- Cap individual provider records, tool correlation, and SSE cadence; discard + malformed/oversized records and require recognized terminal success. Claude + partial/final text, Codex cumulative assistant text, and tool lifecycle + reports require deduplication. + Cancellation drops pending text flushes and prevents late writes to another + run. Activity timestamps advance only from real startup/provider reports; + browser clocks show elapsed/quiet time without API polling or invented percent + completion. Output-format flags do not require another AI session or change + the installed CLI's existing authentication configuration. +- Persist setup mode and explicit empty tool choices. Terminal snapshots + invalidate setup readiness and Hub capabilities; recover stream disconnects + because promotion can finish before the browser connects to SSE. A confirmed + completed setup can retry promotion without repeating AI or Graph work. +- Keep SetupPage and its contract lazy. Release guards track its exact `/setup` + redirect separately from operational routes and enforce a separate setup + asset allowance without relaxing existing Home or workbench budgets. - A paginated source can hit its corpus safety bound independently of having a next page. Expose these as separate signals; never turn an incomplete scan into an exact total or silently mix revision-bound pages. diff --git a/README.md b/README.md index 72a74a2a..44b3bbee 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ Then inspect the generated files: git status --short ``` -Review and run the exact scoped `git add` commands printed by setup. After committing that setup checkpoint, open the Hub: +Open `npx mex-agent@0.8.1 hub` to review the setup-file diff and explicitly commit it locally from the setup checkpoint. You can also review and run the scoped `git add` commands printed by setup, then commit through your Git client: ```bash git commit -m "chore: initialize MEX" @@ -112,7 +112,7 @@ npx mex-agent@0.8.1 hub ![Three steps to a ready project: run setup, populate memory, then review and commit the checkpoint before opening Hub.](docs/diagrams/readme/setup.svg) > [!NOTE] -> The Hub starts only when the current `.mex/config.json` is committed at `HEAD`. MEX never stages, commits, pushes, or pulls. +> The full Hub opens when the current `.mex/config.json` is committed at `HEAD`. Its setup wizard can commit the reviewed setup files on your explicit request, preserving unrelated staged work. Repositories with unsupported Git hooks or content filters keep the manual checkpoint. MEX never pushes or pulls. Push the reviewed setup commit through your team's normal Git workflow so teammates receive the same project memory and selected agent instructions. In Hub's Team/Members page, add the people who will participate and choose your local identity. Review and apply those actions explicitly; commit and push new Member records too. Your current-member selection stays local. @@ -396,7 +396,7 @@ MEX 0.8 does **not** provide: - a cloud-hosted Hub or hosted knowledge sync; - live notifications, presence, or real-time chat; -- automatic Git staging, commits, pushes, or pulls; +- Git staging or commits without explicit review, or any pushes or pulls; - authentication, repository authorization, or RBAC; - Jira-style task management; - a shared Code Graph or Wiki SQLite database; diff --git a/packages/hub-contracts/src/setup-activity.test.ts b/packages/hub-contracts/src/setup-activity.test.ts new file mode 100644 index 00000000..53e2cfb0 --- /dev/null +++ b/packages/hub-contracts/src/setup-activity.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { SetupPopulationActivitySchema, SetupTranscriptBatchSchema } from "./setup.js"; + +const activity = { + tool: "codex", startedAt: "2026-09-10T12:00:00.000Z", lastActivityAt: "2026-09-10T12:00:10.000Z", + totalEvents: 1, + events: [{ id: 1, at: "2026-09-10T12:00:10.000Z", kind: "writing", state: "completed", target: "architecture" }], +}; + +describe("setup activity boundary", () => { + it.each(["claude", "codex"])("accepts only bounded %s activity summaries", (tool) => { + expect(SetupPopulationActivitySchema.parse({ ...activity, tool })).toEqual({ ...activity, tool }); + }); + + it("rejects raw provider content and arbitrary labels or paths", () => { + for (const event of [ + { ...activity.events[0], text: "private output" }, + { ...activity.events[0], command: "private command" }, + { ...activity.events[0], kind: "Reading /private/project" }, + { ...activity.events[0], target: "/private/project" }, + ]) { + expect(SetupPopulationActivitySchema.safeParse({ ...activity, events: [event] }).success).toBe(false); + } + expect(SetupPopulationActivitySchema.safeParse({ ...activity, session_id: "private session" }).success).toBe(false); + }); + + it("caps retained history and rejects unbounded counters and invalid dates", () => { + expect(SetupPopulationActivitySchema.safeParse({ ...activity, events: Array(40).fill(activity.events[0]) }).success).toBe(true); + expect(SetupPopulationActivitySchema.safeParse({ ...activity, events: Array(41).fill(activity.events[0]) }).success).toBe(false); + expect(SetupPopulationActivitySchema.safeParse({ ...activity, totalEvents: Infinity }).success).toBe(false); + expect(SetupPopulationActivitySchema.safeParse({ ...activity, lastActivityAt: "yesterday" }).success).toBe(false); + expect(SetupPopulationActivitySchema.safeParse({ ...activity, lastActivityAt: null }).success).toBe(true); + }); +}); + +describe("setup transcript boundary", () => { + const entry = { id: 1, at: "2026-09-10T12:00:00.000Z", kind: "assistant", text: "Actual output", truncated: false }; + const batch = { runId: "00000000-0000-4000-8000-000000000192", entries: [entry], cursor: 1, firstId: 1, truncated: false, done: false }; + it("accepts visible literal output while rejecting internal metadata and unknown channels", () => { + expect(SetupTranscriptBatchSchema.parse(batch)).toEqual(batch); + expect(SetupTranscriptBatchSchema.safeParse({ ...batch, entries: [{ ...entry, text: '' }] }).success).toBe(true); + for (const changed of [{ ...entry, kind: "reasoning" }, { ...entry, session_id: "private" }, { ...entry, usage: {} }]) { + expect(SetupTranscriptBatchSchema.safeParse({ ...batch, entries: [changed] }).success).toBe(false); + } + }); + it("bounds pages and individual output before browser parsing", () => { + expect(SetupTranscriptBatchSchema.safeParse({ ...batch, entries: Array(33).fill(entry) }).success).toBe(false); + expect(SetupTranscriptBatchSchema.safeParse({ ...batch, entries: [{ ...entry, text: "x".repeat(4097) }] }).success).toBe(false); + expect(SetupTranscriptBatchSchema.safeParse({ ...batch, cursor: Infinity }).success).toBe(false); + expect(SetupTranscriptBatchSchema.safeParse({ ...batch, runId: "provider-session-id" }).success).toBe(false); + }); +}); diff --git a/packages/hub-contracts/src/setup-commit.test.ts b/packages/hub-contracts/src/setup-commit.test.ts new file mode 100644 index 00000000..00d0ec67 --- /dev/null +++ b/packages/hub-contracts/src/setup-commit.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { SetupCommitPreviewSchema, SetupCommitRequestSchema, SETUP_COMMIT_MAX_TOTAL_DIFF_CHARACTERS } from "./setup.js"; + +const preview = { + revision: "00000000-0000-4000-8000-000000000192", expiresAt: "2026-09-10T12:00:00.000Z", + branch: "main", head: null, defaultMessage: "chore: initialize MEX", + files: [{ path: ".mex/config.json", status: "added", diff: "+{}\n", truncated: false }], + canCommit: true, blockedReason: null, +}; + +describe("setup commit review contract", () => { + it("accepts a complete exact-path review and rejects partial or contradictory approvals", () => { + expect(SetupCommitPreviewSchema.parse(preview)).toEqual(preview); + for (const value of [ + { ...preview, files: [] }, + { ...preview, blockedReason: "Requires manual Git" }, + { ...preview, files: [{ ...preview.files[0], truncated: true }] }, + { ...preview, files: [...preview.files, ...preview.files] }, + { ...preview, arbitrary: true }, + ]) expect(SetupCommitPreviewSchema.safeParse(value).success).toBe(false); + expect(SetupCommitPreviewSchema.safeParse({ ...preview, canCommit: false, blockedReason: "Diff exceeds review limit", files: [{ ...preview.files[0], truncated: true }] }).success).toBe(true); + }); + + it("bounds review bytes and rejects unsafe paths", () => { + for (const path of ["../config.json", "/config.json", "C:/config.json", ".mex/../config.json", ".mex\\config.json", "a\0b", "a//b"]) { + expect(SetupCommitPreviewSchema.safeParse({ ...preview, files: [{ ...preview.files[0], path }] }).success).toBe(false); + } + const files = Array.from({ length: 5 }, (_, index) => ({ ...preview.files[0], path: `.mex/context/${index}.md`, diff: "x".repeat(SETUP_COMMIT_MAX_TOTAL_DIFF_CHARACTERS / 4) })); + expect(SetupCommitPreviewSchema.safeParse({ ...preview, files }).success).toBe(false); + }); + + it("accepts only a revision and bounded message, with no client-selected paths or Git arguments", () => { + const request = { revision: preview.revision, message: " Initialize MEX\n\nReviewed project knowledge. " }; + expect(SetupCommitRequestSchema.parse(request).message).toBe("Initialize MEX\n\nReviewed project knowledge."); + for (const value of [ + { ...request, files: ["README.md"] }, { ...request, force: true }, + { ...request, revision: "old" }, { ...request, message: " " }, + { ...request, message: "x".repeat(2001) }, { ...request, message: "a\0b" }, + ]) expect(SetupCommitRequestSchema.safeParse(value).success).toBe(false); + }); +}); diff --git a/packages/hub-contracts/src/setup.ts b/packages/hub-contracts/src/setup.ts index 0934ee5a..b0531ddd 100644 --- a/packages/hub-contracts/src/setup.ts +++ b/packages/hub-contracts/src/setup.ts @@ -10,6 +10,8 @@ export const SETUP_STAGES = [ "needs_setup", "needs_population", "needs_finalize", + "needs_commit", + "complete", "ready", ] as const; @@ -26,6 +28,7 @@ export const SETUP_PROGRESS_STEPS = [ ] as const; export const SetupStageSchema = z.enum(SETUP_STAGES); +export const SetupModeSchema = z.enum(["code-repo", "agent-memory"]); export const SetupProgressStepSchema = z.enum(SETUP_PROGRESS_STEPS); export const SetupToolStatusSchema = z.object({ @@ -36,6 +39,7 @@ export const SetupToolStatusSchema = z.object({ }).strict(); export const SetupStatusSchema = z.object({ + mode: SetupModeSchema, projectName: z.string().min(1).max(256), hasGit: z.boolean(), hasScaffold: z.boolean(), @@ -47,22 +51,120 @@ export const SetupStatusSchema = z.object({ configuredTools: z.array(aiTool).max(8), tools: z.array(SetupToolStatusSchema).max(8), ready: z.boolean(), + commitCommands: z.array(z.string().min(1).max(512)).max(16), }).strict(); export const SetupStartRequestSchema = z.object({ - mode: z.enum(["code-repo", "agent-memory"]).default("code-repo"), + mode: SetupModeSchema.default("code-repo"), tools: z.array(aiTool).max(8).default([]), confirmPopulation: z.boolean().optional(), }).strict(); +export const SetupCancelRequestSchema = z.object({}).strict(); + +export const SETUP_COMMIT_MAX_FILES = 200; +export const SETUP_COMMIT_MAX_FILE_DIFF_CHARACTERS = 32_768; +export const SETUP_COMMIT_MAX_TOTAL_DIFF_CHARACTERS = 131_072; +export const SETUP_COMMIT_MAX_FILE_BYTES = 262_144; + +const setupCommitPath = z.string().min(1).max(1_024) + .refine((value) => !value.startsWith("/") && !/^[a-z]:/iu.test(value) + && !/[\\\x00-\x1f\x7f]/u.test(value) + && value.split("/").every((part) => part !== "" && part !== "." && part !== ".."), "Expected a repository-relative path."); +const gitObjectId = z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u); + +export const SetupCommitPreviewRequestSchema = z.object({}).strict(); +export const SetupCommitFileSchema = z.object({ + path: setupCommitPath, + status: z.enum(["added", "modified", "deleted"]), + diff: z.string().max(SETUP_COMMIT_MAX_FILE_DIFF_CHARACTERS), + truncated: z.boolean(), +}).strict(); + +export const SetupCommitPreviewSchema = z.object({ + revision: z.string().uuid(), + expiresAt: isoTimestamp, + branch: z.string().min(1).max(1_024).nullable(), + head: gitObjectId.nullable(), + defaultMessage: z.string().trim().min(1).max(2_000), + files: z.array(SetupCommitFileSchema).max(SETUP_COMMIT_MAX_FILES), + canCommit: z.boolean(), + blockedReason: boundedReason.nullable(), +}).strict().superRefine((preview, context) => { + if (preview.files.reduce((total, file) => total + file.diff.length, 0) > SETUP_COMMIT_MAX_TOTAL_DIFF_CHARACTERS) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "Setup diff exceeds the total review limit." }); + } + if (new Set(preview.files.map((file) => file.path)).size !== preview.files.length) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "Setup review paths must be unique." }); + } + if (preview.canCommit && (preview.files.length === 0 || preview.blockedReason !== null || preview.files.some((file) => file.truncated))) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "A committable setup review must be complete and unblocked." }); + } +}); + +export const SetupCommitRequestSchema = z.object({ + revision: z.string().uuid(), + message: z.string().trim().min(1).max(2_000).refine((value) => !value.includes("\0"), "Commit message cannot contain NUL."), +}).strict(); + +export const SetupCommitResultSchema = z.object({ + commit: gitObjectId, + files: z.array(setupCommitPath).min(1).max(SETUP_COMMIT_MAX_FILES), + message: z.string().min(1).max(2_048), + recoveryRequired: z.boolean().optional(), +}).strict(); + export const SetupProgressSchema = z.object({ step: SetupProgressStepSchema, label: z.string().min(1).max(128), detail: z.string().min(1).max(HUB_LIMITS.maxIdentifierCharacters * 8).optional(), }).strict(); +export const SETUP_ACTIVITY_LIMIT = 40; + +export const SetupPopulationEventSchema = z.object({ + id: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + at: isoTimestamp, + kind: z.enum(["starting", "started", "reading", "searching", "writing", "running_command", "delegating", "working", "completed", "failed"]), + state: z.enum(["running", "completed", "failed"]), + target: z.enum(["architecture", "stack", "conventions", "decisions", "setup", "router", "agents", "patterns"]).optional(), +}).strict(); + +/** Process-local action summaries only; no CLI text, paths, inputs, or output. */ +export const SetupPopulationActivitySchema = z.object({ + tool: z.enum(["claude", "codex"]), + startedAt: isoTimestamp, + lastActivityAt: isoTimestamp.nullable(), + events: z.array(SetupPopulationEventSchema).max(SETUP_ACTIVITY_LIMIT), + totalEvents: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), +}).strict(); + +export const SETUP_TRANSCRIPT_ENTRY_CHARACTERS = 4_096; +export const SETUP_TRANSCRIPT_BATCH_ENTRIES = 32; +export const SETUP_TRANSCRIPT_RETAINED_BYTES = 1_048_576; +export const SETUP_TRANSCRIPT_RETAINED_ENTRIES = 2_048; + +/** Visible CLI output, only for the authenticated process-local setup session. */ +export const SetupTranscriptEntrySchema = z.object({ + id: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + at: isoTimestamp, + kind: z.enum(["assistant", "command", "output", "file", "tool", "notice"]), + text: z.string().min(1).max(SETUP_TRANSCRIPT_ENTRY_CHARACTERS), + truncated: z.boolean(), +}).strict(); + +export const SetupTranscriptBatchSchema = z.object({ + runId: z.string().uuid(), + entries: z.array(SetupTranscriptEntrySchema).max(SETUP_TRANSCRIPT_BATCH_ENTRIES), + cursor: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + firstId: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + truncated: z.boolean(), + done: z.boolean(), +}).strict(); + export const SetupRunSchema = z.object({ - status: z.enum(["idle", "running", "succeeded", "failed", "paused"]), + status: z.enum(["idle", "running", "succeeded", "failed", "paused", "cancelled"]), + mode: SetupModeSchema, stage: SetupStageSchema, populated: z.boolean(), ready: z.boolean(), @@ -70,6 +172,8 @@ export const SetupRunSchema = z.object({ prompt: z.string().max(HUB_LIMITS.maxJsonResponseBytes / 2).nullable(), populationTool: z.enum(["claude", "codex"]).nullable(), populationCompleted: z.boolean(), + populationActivity: SetupPopulationActivitySchema.optional(), + transcriptId: z.string().uuid().optional(), commitCommands: z.array(z.string().min(1).max(512)).max(16), anchorNotes: z.array(z.string().min(1).max(1_024)).max(16), message: z.string().min(1).max(2_048), @@ -79,10 +183,22 @@ export const SetupRunSchema = z.object({ finishedAt: isoTimestamp.nullable(), }).strict(); +export const SetupCommitResponseSchema = SetupCommitResultSchema.extend({ run: SetupRunSchema }); + export type SetupStage = z.infer; +export type SetupMode = z.infer; export type SetupProgressStep = z.infer; export type SetupToolStatus = z.infer; export type SetupStatus = z.infer; export type SetupStartRequest = z.infer; export type SetupProgress = z.infer; +export type SetupPopulationEvent = z.infer; +export type SetupPopulationActivity = z.infer; +export type SetupTranscriptEntry = z.infer; +export type SetupTranscriptBatch = z.infer; export type SetupRun = z.infer; +export type SetupCommitFile = z.infer; +export type SetupCommitPreview = z.infer; +export type SetupCommitRequest = z.infer; +export type SetupCommitResult = z.infer; +export type SetupCommitResponse = z.infer; diff --git a/packages/hub-web/src/api/client.test.ts b/packages/hub-web/src/api/client.test.ts index 319fb20b..099ddfb1 100644 --- a/packages/hub-web/src/api/client.test.ts +++ b/packages/hub-web/src/api/client.test.ts @@ -544,4 +544,25 @@ describe("HttpHubApi shared-contract boundary", () => { expect(received).toHaveBeenCalledWith(terminal); expect(FakeEventSource.latest.close).toHaveBeenCalledOnce(); }); + + it("recovers setup on connection and reconnection errors, then stops on close", () => { + class FakeEventSource { + static latest: FakeEventSource; + readonly close = vi.fn(); + onerror: (() => void) | null = null; + onmessage: EventListener | null = null; + constructor() { FakeEventSource.latest = this; } + addEventListener() { /* No snapshots arrive before promotion. */ } + } + vi.stubGlobal("EventSource", FakeEventSource); + const disconnected = vi.fn(); + const subscription = new HttpHubApi().subscribeToSetup(vi.fn(), disconnected); + FakeEventSource.latest.onerror?.(); + FakeEventSource.latest.onerror?.(); + expect(disconnected).toHaveBeenCalledTimes(2); + subscription.close(); + FakeEventSource.latest.onerror?.(); + expect(disconnected).toHaveBeenCalledTimes(2); + expect(FakeEventSource.latest.close).toHaveBeenCalledOnce(); + }); }); diff --git a/packages/hub-web/src/api/client.ts b/packages/hub-web/src/api/client.ts index 2bf6cc33..9039553a 100644 --- a/packages/hub-web/src/api/client.ts +++ b/packages/hub-web/src/api/client.ts @@ -45,6 +45,10 @@ import type { SetupRun, SetupStartRequest, SetupStatus, + SetupTranscriptBatch, + SetupCommitPreview, + SetupCommitRequest, + SetupCommitResponse, } from "@mex/hub-contracts/setup"; import type { AgentLoggingPolicy, @@ -224,7 +228,11 @@ export interface HubApi { getSetupStatus?(): Promise; getSetupRun?(): Promise; startSetup?(request: SetupStartRequest): Promise; - subscribeToSetup?(onSnapshot: (run: SetupRun) => void): JobSubscription; + cancelSetup?(): Promise; + subscribeToSetup?(onSnapshot: (run: SetupRun) => void, onDisconnect?: () => void): JobSubscription; + subscribeToSetupTranscript?(runId: string, onBatch: (batch: SetupTranscriptBatch) => void, onDisconnect?: () => void): JobSubscription; + previewSetupCommit?(): Promise; + commitSetup?(request: SetupCommitRequest): Promise; } function fallbackProblem(status: number, detail?: string): ProblemDetails { @@ -763,11 +771,40 @@ export class HttpHubApi implements HubApi { ); } - subscribeToSetup(onSnapshot: (run: SetupRun) => void): JobSubscription { + cancelSetup(): Promise { + return this.#requestWhenOk( + "/setup/cancel", + async () => (await loadSetupContract()).SetupRunSchema, + { method: "POST", body: "{}" }, + true, + ); + } + + previewSetupCommit(): Promise { + return this.#requestWhenOk( + "/setup/commit/preview", + async () => (await loadSetupContract()).SetupCommitPreviewSchema, + { method: "POST", body: "{}" }, + true, + ); + } + + commitSetup(request: SetupCommitRequest): Promise { + return this.#requestWhenOk( + "/setup/commit", + async () => (await loadSetupContract()).SetupCommitResponseSchema, + { method: "POST", body: JSON.stringify(request) }, + true, + ); + } + + subscribeToSetup(onSnapshot: (run: SetupRun) => void, onDisconnect?: () => void): JobSubscription { const source = new EventSource(`${API_ROOT}/setup/events`, { withCredentials: true }); const contract = loadSetupContract(); + let closed = false; const receive = (event: MessageEvent) => { void contract.then(({ SetupRunSchema }) => { + if (closed) return; try { const parsed = SetupRunSchema.safeParse(JSON.parse(event.data)); if (parsed.success) { @@ -776,9 +813,11 @@ export class HttpHubApi implements HubApi { event.type === "terminal" || parsed.data.status === "succeeded" || parsed.data.status === "failed" + || parsed.data.status === "cancelled" || parsed.data.status === "paused" || parsed.data.status === "idle" ) { + closed = true; source.close(); } } @@ -790,7 +829,46 @@ export class HttpHubApi implements HubApi { source.addEventListener("snapshot", receive as EventListener); source.addEventListener("terminal", receive as EventListener); source.onmessage = receive; - return { close: () => source.close() }; + source.onerror = () => { + // Promotion can remove the endpoint before the first SSE connection. + // The view coalesces refreshes; retry errors must also recover a promotion + // that happened after an earlier refresh still reported a running job. + if (closed) return; + onDisconnect?.(); + }; + return { close: () => { closed = true; source.close(); } }; + } + + subscribeToSetupTranscript(runId: string, onBatch: (batch: SetupTranscriptBatch) => void, onDisconnect?: () => void): JobSubscription { + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(runId)) { + throw new Error("Invalid setup session identifier."); + } + let closed = false; + let cursor = 0; + let source: EventSource | undefined; + // Open only once the lazy validator is ready; no event queue can grow while + // that module loads. Native reconnects retain this EventSource's event ID. + void loadSetupContract().then(({ SetupTranscriptBatchSchema }) => { + if (closed) return; + source = new EventSource(`${API_ROOT}/setup/transcript/events?run=${encodeURIComponent(runId)}`, { withCredentials: true }); + source.addEventListener("transcript", ((event: MessageEvent) => { + if (closed || typeof event.data !== "string" || event.data.length > 1_048_576) return; + try { + const parsed = SetupTranscriptBatchSchema.safeParse(JSON.parse(event.data)); + if (!parsed.success || parsed.data.runId !== runId || parsed.data.cursor < cursor) return; + const batch = parsed.data; + if (batch.entries.some((entry, index) => entry.id > batch.cursor || (index > 0 && entry.id <= batch.entries[index - 1]!.id))) return; + const entries = batch.entries.filter((entry) => entry.id > cursor); + cursor = batch.cursor; + onBatch({ ...batch, entries }); + if (batch.done) { closed = true; source?.close(); } + } catch { + // Malformed transcript data cannot replace the retained session. + } + }) as EventListener); + source.onerror = () => { if (!closed) onDisconnect?.(); }; + }).catch(() => { if (!closed) onDisconnect?.(); }); + return { close: () => { if (!closed) source?.close(); closed = true; } }; } } diff --git a/packages/hub-web/src/api/setup-commit-client.test.ts b/packages/hub-web/src/api/setup-commit-client.test.ts new file mode 100644 index 00000000..ae6d147d --- /dev/null +++ b/packages/hub-web/src/api/setup-commit-client.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { HttpHubApi } from "./client"; +import type { SetupCommitPreview, SetupCommitResponse } from "./types"; + +const revision = "a944e8d9-7e02-4d04-9a62-d8b347b8e7dc"; +const session = { csrfToken: "a".repeat(43), expiresAt: "2099-09-10T10:00:00.000Z" }; +const preview: SetupCommitPreview = { + revision, expiresAt: "2099-09-10T10:00:00.000Z", branch: "main", head: null, + defaultMessage: "chore: initialize MEX", canCommit: true, blockedReason: null, + files: [{ path: ".mex/config.json", status: "added", diff: "+project identity", truncated: false }], +}; +const response: SetupCommitResponse = { + commit: "a".repeat(40), files: [".mex/config.json"], message: "Setup committed locally.", + run: { + status: "running", mode: "code-repo", stage: "ready", populated: true, ready: false, + selectedTools: [], prompt: null, populationTool: null, populationCompleted: true, + commitCommands: [], anchorNotes: [], message: "Opening the Hub…", progress: null, + error: null, startedAt: "2026-09-10T10:00:00.000Z", finishedAt: null, + }, +}; +const json = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status, headers: { "Content-Type": "application/json" } }); + +afterEach(() => vi.unstubAllGlobals()); + +describe("setup commit transport", () => { + it("protects both explicit POSTs with CSRF and sends only the reviewed revision and commit message", async () => { + const fetch = vi.fn().mockResolvedValueOnce(json(session)).mockResolvedValueOnce(json(preview)).mockResolvedValueOnce(json(response)); + vi.stubGlobal("fetch", fetch); + const api = new HttpHubApi(); + await api.getSession(); + expect(await api.previewSetupCommit()).toEqual(preview); + const request = { revision, message: "chore: initialize MEX" }; + expect(await api.commitSetup(request)).toEqual(response); + const previewCall = fetch.mock.calls[1] as [string, RequestInit]; + const commitCall = fetch.mock.calls[2] as [string, RequestInit]; + expect(previewCall[0]).toBe("/api/v1/setup/commit/preview"); + expect(previewCall[1].body).toBe("{}"); + expect(commitCall[0]).toBe("/api/v1/setup/commit"); + expect(commitCall[1].body).toBe(JSON.stringify(request)); + for (const call of [previewCall, commitCall]) { + expect(call[1].method).toBe("POST"); + expect(call[1].credentials).toBe("same-origin"); + expect((call[1].headers as Headers).get("X-MEX-CSRF")).toBe(session.csrfToken); + expect((call[1].headers as Headers).get("Content-Type")).toBe("application/json"); + } + }); + + it("rejects malformed review/commit responses at the shared contract boundary", async () => { + const fetch = vi.fn().mockResolvedValueOnce(json(session)) + .mockResolvedValueOnce(json({ ...preview, files: [{ ...preview.files[0], path: "../private" }] })) + .mockResolvedValueOnce(json({ ...response, commit: "not-a-git-object" })); + vi.stubGlobal("fetch", fetch); + const api = new HttpHubApi(); + await api.getSession(); + await expect(api.previewSetupCommit()).rejects.toMatchObject({ problem: { code: "INTERNAL_ERROR" } }); + await expect(api.commitSetup({ revision, message: "Review setup" })).rejects.toMatchObject({ problem: { code: "INTERNAL_ERROR" } }); + }); + + it("preserves a stale-review problem for the UI and does not retry the commit", async () => { + const problem = { type: "about:blank", title: "Review changed", status: 409, code: "REVISION_CONFLICT", detail: "Setup files changed after review.", requestId: revision }; + const fetch = vi.fn().mockResolvedValueOnce(json(session)).mockResolvedValueOnce(json(problem, 409)); + vi.stubGlobal("fetch", fetch); + const api = new HttpHubApi(); + await api.getSession(); + await expect(api.commitSetup({ revision, message: "Review setup" })).rejects.toMatchObject({ problem }); + expect(fetch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/hub-web/src/api/setup-transcript-client.test.ts b/packages/hub-web/src/api/setup-transcript-client.test.ts new file mode 100644 index 00000000..079fd571 --- /dev/null +++ b/packages/hub-web/src/api/setup-transcript-client.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { HttpHubApi } from "./client"; +import type { SetupTranscriptBatch } from "./types"; + +const runId = "a944e8d9-7e02-4d04-9a62-d8b347b8e7dc"; +const otherId = "75eff665-7fbe-4b1b-9bf8-9ab33e6f3739"; +const at = "2026-09-10T10:00:00.000Z"; +class FakeEventSource { + static instances: FakeEventSource[] = []; + readonly close = vi.fn(); + readonly listeners = new Map(); + onerror: (() => void) | null = null; + constructor(readonly url: string, readonly options: EventSourceInit) { FakeEventSource.instances.push(this); } + addEventListener(type: string, listener: EventListener) { this.listeners.set(type, listener); } + emit(value: unknown) { this.listeners.get("transcript")?.(new MessageEvent("transcript", { data: typeof value === "string" ? value : JSON.stringify(value) })); } +} + +function batch(cursor: number, overrides: Partial = {}): SetupTranscriptBatch { + return { runId, cursor, firstId: 1, truncated: false, done: false, entries: [{ id: cursor, at, kind: "assistant", text: "Reading the actual project", truncated: false }], ...overrides }; +} + +async function connect() { + FakeEventSource.instances = []; + vi.stubGlobal("EventSource", FakeEventSource); + const received = vi.fn(); + const disconnected = vi.fn(); + const subscription = new HttpHubApi().subscribeToSetupTranscript(runId, received, disconnected); + await vi.waitFor(() => expect(FakeEventSource.instances).toHaveLength(1)); + return { source: FakeEventSource.instances[0]!, received, disconnected, subscription }; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe("setup transcript transport", () => { + it("isolates the run, rejects malformed and oversized events, and deduplicates native reconnect replay", async () => { + const { source, received, disconnected } = await connect(); + expect(source.url).toBe(`/api/v1/setup/transcript/events?run=${runId}`); + expect(source.options).toEqual({ withCredentials: true }); + source.emit("not json"); + source.emit(" ".repeat(1_048_577)); + source.emit(batch(1, { runId: otherId })); + source.emit(batch(1, { entries: [{ id: 1, at, kind: "assistant", text: "x".repeat(4_097), truncated: false }] })); + expect(received).not.toHaveBeenCalled(); + source.emit(batch(1)); + source.onerror?.(); + expect(disconnected).toHaveBeenCalledOnce(); + expect(source.close).not.toHaveBeenCalled(); + expect(FakeEventSource.instances).toHaveLength(1); + source.emit(batch(2, { entries: [...batch(1).entries, ...batch(2).entries] })); + expect(received.mock.calls[1]![0].entries.map((entry: { id: number }) => entry.id)).toEqual([2]); + source.emit(batch(1)); + expect(received).toHaveBeenCalledTimes(2); + source.emit(batch(2, { entries: [], done: true })); + expect(source.close).toHaveBeenCalledOnce(); + source.emit(batch(3)); + source.onerror?.(); + expect(received).toHaveBeenCalledTimes(3); + expect(disconnected).toHaveBeenCalledOnce(); + }); + + it("drops out-of-order IDs and closes without delivering late events", async () => { + const { source, received, subscription } = await connect(); + source.emit(batch(2, { entries: [...batch(2).entries, ...batch(1).entries] })); + source.emit(batch(1, { entries: batch(2).entries })); + expect(received).not.toHaveBeenCalled(); + subscription.close(); + source.emit(batch(2)); + expect(received).not.toHaveBeenCalled(); + expect(source.close).toHaveBeenCalledOnce(); + }); + + it("does not create a source for invalid IDs or a subscription closed during lazy loading", async () => { + FakeEventSource.instances = []; + vi.stubGlobal("EventSource", FakeEventSource); + const api = new HttpHubApi(); + expect(() => api.subscribeToSetupTranscript("../../private", vi.fn())).toThrow("Invalid setup session identifier"); + api.subscribeToSetupTranscript(runId, vi.fn()).close(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(FakeEventSource.instances).toHaveLength(0); + }); +}); diff --git a/packages/hub-web/src/api/types.ts b/packages/hub-web/src/api/types.ts index 5f8eac7a..0fe6bacd 100644 --- a/packages/hub-web/src/api/types.ts +++ b/packages/hub-web/src/api/types.ts @@ -147,6 +147,11 @@ export type { SetupStage, SetupProgress, SetupProgressStep, + SetupTranscriptEntry, + SetupTranscriptBatch, + SetupCommitPreview, + SetupCommitRequest, + SetupCommitResponse, } from "@mex/hub-contracts/setup"; export type CapabilityName = "graph" | "wiki" | "jobs" | "activity" | "members" | "workstreams" | "specs" | "inbox" | "relays"; diff --git a/packages/hub-web/src/app/App.tsx b/packages/hub-web/src/app/App.tsx index 6ae31a24..83afb73b 100644 --- a/packages/hub-web/src/app/App.tsx +++ b/packages/hub-web/src/app/App.tsx @@ -1,6 +1,6 @@ -import { lazy, Suspense, useEffect } from "react"; +import { lazy, Suspense } from "react"; import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { HubApiError, isSetupCapabilityUnavailable, type HubApi } from "../api/client"; import { HubApiProvider, useCapabilities, useHubApi, useSession } from "../api/context"; import { StatePanel } from "../components/ui"; @@ -31,29 +31,12 @@ function SessionBoundary() { const session = useSession(); const capabilities = useCapabilities(); const api = useHubApi(); - const queryClient = useQueryClient(); const setupStatus = useQuery({ queryKey: ["setup", "status"], queryFn: () => api.getSetupStatus!(), retry: false, enabled: session.isSuccess && typeof api.getSetupStatus === "function", }); - const setupReady = setupStatus.isSuccess && setupStatus.data.ready; - const graphAvailable = capabilities.data?.graph.read.availability === "available"; - - useEffect(() => { - if (!setupReady || graphAvailable) return; - const refresh = () => { - void queryClient.invalidateQueries({ queryKey: ["setup", "status"] }); - void queryClient.invalidateQueries({ queryKey: ["capabilities"] }); - void queryClient.invalidateQueries({ queryKey: ["home"] }); - void queryClient.invalidateQueries({ queryKey: ["overview"] }); - }; - refresh(); - const poll = window.setInterval(refresh, 400); - return () => window.clearInterval(poll); - }, [setupReady, graphAvailable, queryClient]); - if (session.isPending) { return
; } @@ -87,7 +70,10 @@ function SessionBoundary() { ); } - if (setupStatus.isSuccess && !setupStatus.data.ready) { + // A successful setup response means this process still owns the wizard. + // Readiness can change after an external commit; only an explicit setup run + // promotes the listener, after which this endpoint becomes unavailable. + if (setupStatus.isSuccess) { return ( }> @@ -95,42 +81,6 @@ function SessionBoundary() { ); } - if (setupReady) { - if (capabilities.isError) { - return ( -
- void capabilities.refetch()} - > - Try again - - )} - /> -
- ); - } - if (!graphAvailable || !capabilities.data) { - return ( -
- -
- ); - } - return ; - } - if (capabilities.isPending) { return (
diff --git a/packages/hub-web/src/app/setup-page.test.tsx b/packages/hub-web/src/app/setup-page.test.tsx index 7e3a3351..32df7014 100644 --- a/packages/hub-web/src/app/setup-page.test.tsx +++ b/packages/hub-web/src/app/setup-page.test.tsx @@ -1,15 +1,16 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen } from "@testing-library/react"; +import { act, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; import { describe, expect, it, vi } from "vitest"; -import type { HubApi } from "../api/client"; +import { HubApiError, type HubApi } from "../api/client"; import { HubApiProvider } from "../api/context"; import { createFixtureApi } from "../dev/fixture-api"; -import type { SetupRun, SetupStartRequest, SetupStatus } from "../api/types"; +import type { SetupCommitPreview, SetupRun, SetupStartRequest, SetupStatus, SetupTranscriptBatch } from "../api/types"; import { AppRoutes } from "./App"; const status: SetupStatus = { + mode: "code-repo", projectName: "demo", hasGit: true, hasScaffold: false, @@ -22,16 +23,18 @@ const status: SetupStatus = { tools: [ { id: "claude", name: "Claude Code", selected: false, cliAvailable: false }, { id: "cursor", name: "Cursor", selected: false, cliAvailable: false }, - { id: "codex", name: "Codex", selected: false, cliAvailable: false }, + { id: "codex", name: "Codex", selected: false, cliAvailable: true }, { id: "windsurf", name: "Windsurf", selected: false, cliAvailable: false }, { id: "copilot", name: "Copilot", selected: false, cliAvailable: false }, { id: "opencode", name: "OpenCode", selected: false, cliAvailable: false }, ], ready: false, + commitCommands: [], }; const idleRun: SetupRun = { status: "idle", + mode: "code-repo", stage: "needs_setup", populated: false, ready: false, @@ -47,6 +50,16 @@ const idleRun: SetupRun = { startedAt: null, finishedAt: null, }; +const commitCommands = ["git diff -- .mex", "git add .mex", 'git commit -m "chore: initialize MEX"']; +const commitPreview = (): SetupCommitPreview => ({ + revision: "a944e8d9-7e02-4d04-9a62-d8b347b8e7dc", expiresAt: new Date(Date.now() + 600_000).toISOString(), + branch: "main", head: null, defaultMessage: "chore: initialize MEX", canCommit: true, blockedReason: null, + files: [{ path: ".mex/config.json", status: "added", diff: "+new project identity", truncated: false }], +}); +const setupUnavailable = new HubApiError({ + type: "about:blank", title: "Setup unavailable", status: 409, + code: "CAPABILITY_UNAVAILABLE", detail: "This process is serving the Project Hub.", requestId: "setup-test", +}); function renderSetup(api: HubApi) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); @@ -61,157 +74,463 @@ function renderSetup(api: HubApi) { ); } +function setupHarness(initialStatus: Partial = {}, initialRun: Partial = {}) { + let currentStatus: SetupStatus = { ...status, ...initialStatus }; + let run: SetupRun = { ...idleRun, mode: currentStatus.mode, ...initialRun }; + let listener: ((next: SetupRun) => void) | undefined; + let onDisconnect: (() => void) | undefined; + let transcriptListener: ((next: SetupTranscriptBatch) => void) | undefined; + let transcript: SetupTranscriptBatch | undefined; + let operational = false; + const fixture = createFixtureApi(); + const getCapabilities = fixture.getCapabilities.bind(fixture); + const close = vi.fn(() => { listener = undefined; }); + const getSetupStatus = vi.fn(async () => { + if (operational) throw setupUnavailable; + return currentStatus; + }); + const startSetup = vi.fn(async (request: SetupStartRequest): Promise => { + currentStatus = { ...currentStatus, mode: request.mode, configuredTools: request.tools }; + run = { + ...run, mode: request.mode, selectedTools: request.tools, status: "running", ready: false, + message: "Starting MEX setup…", progress: { step: "detect", label: "Detect project state" }, + error: null, startedAt: "2026-09-10T10:00:00.000Z", finishedAt: null, + }; + return run; + }); + const api = Object.assign(fixture, { + getSetupStatus, + getSetupRun: vi.fn(async () => run), + startSetup, + async getCapabilities() { + const caps = await getCapabilities(); + if (operational) return caps; + const unavailable = { availability: "unavailable" as const, reason: "Finish MEX setup before using this Hub workbench." }; + return { ...caps, graph: { read: unavailable, refresh: unavailable, rebuild: unavailable }, wiki: { read: unavailable, refresh: unavailable, rebuild: unavailable } }; + }, + subscribeToSetup: vi.fn((onSnapshot: (next: SetupRun) => void, disconnected?: () => void) => { + listener = onSnapshot; + onDisconnect = disconnected; + return { close }; + }), + subscribeToSetupTranscript: vi.fn((_runId: string, onBatch: (next: SetupTranscriptBatch) => void) => { + transcriptListener = onBatch; + if (transcript) onBatch(transcript); + return { close: () => { transcriptListener = undefined; } }; + }), + cancelSetup: vi.fn(async () => { + run = { ...run, status: "cancelled" as const, message: "The background setup was cancelled. You can resume when ready." }; + return run; + }), + }); + return { + api, close, + sendTranscript(batch: SetupTranscriptBatch) { + if (!transcriptListener) throw new Error("Wait for the transcript subscription first."); + transcript = batch; + transcriptListener(batch); + }, + updateStatus(update: Partial) { currentStatus = { ...currentStatus, ...update }; }, + snapshot(update: Partial) { + if (!listener) throw new Error("Wait for the running setup subscription before sending activity."); + run = { ...run, ...update }; + listener(run); + }, + disconnect(promoted = false, nextRun: Partial = {}) { + operational = promoted; + run = { ...run, ...nextRun }; + if (!onDisconnect) throw new Error("Wait for the setup subscription before disconnecting it."); + onDisconnect(); + }, + complete(update: Partial, nextStatus: Partial = {}, promoted = false) { + if (!listener) throw new Error("Wait for the running setup subscription before completing it."); + currentStatus = { ...currentStatus, ...nextStatus }; + run = { ...run, ...update, finishedAt: "2026-09-10T10:00:01.000Z" }; + operational = promoted; + listener(run); + }, + }; +} + +async function openConfiguration(user: ReturnType) { + await user.click(await screen.findByRole("button", { name: "Set up this project" }, { timeout: 5_000 })); + expect(await screen.findByRole("heading", { level: 1, name: "Set up MEX" })).toBeVisible(); +} + +async function waitForSubscription(harness: ReturnType, times: number) { + await waitFor(() => expect(harness.api.subscribeToSetup).toHaveBeenCalledTimes(times)); +} + describe("Hub setup wizard", () => { - it("keeps the populated fixture on the ordinary Hub shell", async () => { - renderSetup(createFixtureApi()); - expect(await screen.findByRole("heading", { level: 1, name: "Overview" }, { timeout: 5_000 })).toBeVisible(); - expect(screen.queryByRole("heading", { level: 1, name: "Set up MEX" })).toBeNull(); + it("reviews an exact setup commit and follows the returned run into the promoted Hub", async () => { + const user = userEvent.setup(); + const harness = setupHarness({ hasScaffold: true, populated: true, graphReady: true, wikiReady: true, stage: "needs_commit", commitCommands }, { + status: "paused", stage: "needs_commit", populated: true, commitCommands, + }); + const preview = commitPreview(); + const api = Object.assign(harness.api, { + previewSetupCommit: vi.fn(async () => preview), + commitSetup: vi.fn(async () => ({ commit: "a".repeat(40), files: [".mex/config.json"], message: "Setup committed locally.", run: { ...idleRun, status: "running" as const, populated: true, stage: "ready" as const } })), + }); + renderSetup(api); + await user.click(await screen.findByRole("button", { name: "Review setup changes" }, { timeout: 5_000 })); + expect(screen.getByText("Commit manually")).toBeVisible(); + expect(screen.getByText("Commit manually").closest("details")).not.toHaveAttribute("open"); + await user.click(await screen.findByRole("button", { name: "Commit setup and open Hub" })); + expect(api.commitSetup).toHaveBeenCalledExactlyOnceWith({ revision: preview.revision, message: preview.defaultMessage }); + expect(api.startSetup).not.toHaveBeenCalled(); + await waitForSubscription(harness, 1); + act(() => harness.complete({ status: "succeeded", stage: "ready", ready: true }, { stage: "ready", ready: true }, true)); + expect(await screen.findByRole("heading", { name: "Overview", level: 1 }, { timeout: 5_000 })).toBeVisible(); }); - it("walks detect, tool choice, start, population, then opens the Project Hub", async () => { - const user = userEvent.setup(); - let currentStatus: SetupStatus = status; - let run: SetupRun = idleRun; - const startSetup = vi.fn(async (request: SetupStartRequest) => { - if (request.confirmPopulation) { - currentStatus = { - ...currentStatus, - hasScaffold: true, - populated: true, - graphReady: true, - wikiReady: true, - stage: "ready", - ready: true, - }; - run = { - ...run, - status: "succeeded", - stage: "ready", - populated: true, - ready: true, - prompt: null, - commitCommands: ["git add .mex", 'git commit -m "chore: initialize MEX"'], - message: "Graph and Wiki are ready. Review and commit the canonical MEX setup.", - }; - return run; - } - run = { - ...idleRun, - status: "running", - selectedTools: request.tools, - message: "Starting MEX setup…", - progress: { step: "detect", label: "Detect project state" }, - startedAt: "2026-09-10T10:00:00.000Z", - }; - return run; + it("offers only Hub opening after commit succeeds but promotion fails", async () => { + const user = userEvent.setup(); + const harness = setupHarness({ hasScaffold: true, populated: true, graphReady: true, wikiReady: true, stage: "needs_commit", commitCommands }, { + status: "paused", stage: "needs_commit", populated: true, commitCommands, }); - const api = Object.assign(createFixtureApi(), { - getSetupStatus: async () => currentStatus, - getSetupRun: async () => run, - startSetup, - subscribeToSetup(onSnapshot: (next: SetupRun) => void) { - run = { - ...run, - status: "paused", - stage: "needs_population", - prompt: "Populate ROUTER.md and AGENTS.md from this repository.", - message: "Setup paused at population.", - progress: { step: "population", label: "Populate the scaffold" }, - finishedAt: "2026-09-10T10:00:01.000Z", - }; - onSnapshot(run); - return { close() {} }; - }, + const api = Object.assign(harness.api, { + previewSetupCommit: vi.fn(async () => commitPreview()), + commitSetup: vi.fn(async () => ({ commit: "a".repeat(40), files: [".mex/config.json"], message: "Setup committed locally.", run: { ...idleRun, status: "failed" as const, populated: true, stage: "ready" as const, error: "The Project Hub could not open." } })), }); + renderSetup(api); + await user.click(await screen.findByRole("button", { name: "Review setup changes" }, { timeout: 5_000 })); + await user.click(await screen.findByRole("button", { name: "Commit setup and open Hub" })); + await user.click(await screen.findByRole("button", { name: "Retry opening Hub" })); + expect(api.startSetup).toHaveBeenCalledExactlyOnceWith({ mode: "code-repo", tools: [], confirmPopulation: true }); + expect(api.commitSetup).toHaveBeenCalledOnce(); + expect(screen.queryByRole("button", { name: "Commit setup and open Hub" })).toBeNull(); + }); + it("keeps Git recovery prominent after a durable commit even when refreshed setup reports ready", async () => { + const user = userEvent.setup(); + const harness = setupHarness({ hasScaffold: true, populated: true, graphReady: true, wikiReady: true, stage: "needs_commit", commitCommands }, { + status: "paused", stage: "needs_commit", populated: true, commitCommands, + }); + const warning = "The commit is saved. Keep the index.lock recovery file and inspect Git status before continuing manually."; + const api = Object.assign(harness.api, { + previewSetupCommit: vi.fn(async () => commitPreview()), + commitSetup: vi.fn(async () => { + harness.updateStatus({ stage: "ready", ready: true }); + return { commit: "a".repeat(40), files: [".mex/config.json"], recoveryRequired: true, message: warning, run: { ...idleRun, status: "failed" as const, populated: true, stage: "ready" as const, ready: true, error: warning } }; + }), + }); renderSetup(api); - expect(await screen.findByRole("heading", { level: 1, name: "Build a Hub for this checkout" }, { timeout: 5_000 })).toBeVisible(); - expect(screen.getByText(/Setup gives/)).toBeVisible(); + await user.click(await screen.findByRole("button", { name: "Review setup changes" }, { timeout: 5_000 })); + await user.click(await screen.findByRole("button", { name: "Commit setup and open Hub" })); + expect(await screen.findByRole("alert")).toHaveTextContent(warning); + expect(screen.getByRole("alert")).toHaveTextContent("Setup committed; Git needs attention"); + expect(screen.getByText("Your setup commit is saved. Resolve the Git issue above, then check again.")).toBeVisible(); + expect(api.startSetup).not.toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: "Commit setup and open Hub" })).toBeNull(); + expect(screen.queryByText("Commit manually")).toBeNull(); + await user.click(screen.getByRole("button", { name: "Check recovery and open Hub" })); + expect(api.startSetup).toHaveBeenCalledExactlyOnceWith({ mode: "code-repo", tools: [], confirmPopulation: true }); + expect(api.commitSetup).toHaveBeenCalledOnce(); + }); + + it.each(["claude", "codex"] as const)("shows the actual %s session while retaining activity timing, cancellation, and terminal output", async (tool) => { + const user = userEvent.setup(); + const startedAt = new Date().toISOString(); + const transcriptId = "a944e8d9-7e02-4d04-9a62-d8b347b8e7dc"; + const harness = setupHarness({ hasScaffold: true }, { + status: "running", selectedTools: [tool], populationTool: tool, transcriptId, + progress: { step: "population", label: "Populating MEX" }, + populationActivity: { tool, startedAt, lastActivityAt: startedAt, totalEvents: 1, events: [{ id: 1, at: startedAt, kind: "reading", state: "running" }] }, + }); + renderSetup(harness.api); + expect(await screen.findByRole("region", { name: "Agent session transcript" }, { timeout: 5_000 })).toBeVisible(); + expect(screen.getByRole("heading", { name: `${tool === "claude" ? "Claude Code" : "Codex"} is building your project memory` })).toBeVisible(); + expect(screen.getByText(/Running for/)).toBeVisible(); + expect(screen.queryByRole("list", { name: "Recent agent activity" })).toBeNull(); + act(() => harness.sendTranscript({ + runId: transcriptId, cursor: 2, firstId: 1, truncated: false, done: false, + entries: [ + { id: 1, at: startedAt, kind: "assistant", text: "I will inspect the project's README and package manifest.", truncated: false }, + { id: 2, at: startedAt, kind: "command", text: "cat README.md package.json", truncated: false }, + ], + })); + expect(screen.getByText("I will inspect the project's README and package manifest.")).toBeVisible(); + expect(screen.getByText("Ran a command")).toBeVisible(); + expect(screen.queryByText("cat README.md package.json")).toBeNull(); + await user.click(screen.getByRole("button", { name: "Cancel setup" })); + expect(await screen.findByText("Setup cancelled")).toBeVisible(); + expect(screen.getByText("Ran a command")).toBeVisible(); + expect(screen.queryByText("cat README.md package.json")).toBeNull(); + expect(screen.getByRole("button", { name: "Resume setup" })).toBeEnabled(); + }); + + it.each(["claude", "codex"] as const)("restores %s population activity on refresh and accepts real SSE updates without a percentage", async (tool) => { + // Keep async bootstrap/render duration out of the elapsed-time assertion. + vi.spyOn(Date, "now").mockReturnValue(Date.now()); + const startedAt = new Date(Date.now() - 30_000).toISOString(); + const activity: NonNullable = { + tool, startedAt, lastActivityAt: startedAt, totalEvents: 1, + events: [{ id: 1, at: startedAt, kind: "reading", state: "completed" }], + }; + const harness = setupHarness({ hasScaffold: true }, { + status: "running", selectedTools: [tool], populationTool: tool, populationActivity: activity, + progress: { step: "population", label: "Populating MEX" }, + }); + renderSetup(harness.api); + expect(await screen.findByRole("heading", { name: `${tool === "claude" ? "Claude Code" : "Codex"} is building your project memory` }, { timeout: 5_000 })).toBeVisible(); + await waitForSubscription(harness, 1); + expect(screen.getByText(/Running for 30s/)).toBeVisible(); + expect(within(screen.getByRole("list", { name: "Recent agent activity" })).getByText("Read repository files")).toBeVisible(); + expect(screen.queryByRole("progressbar")).toBeNull(); + expect(screen.queryByText(/\d+%/)).toBeNull(); + expect(screen.getByText(/You can leave this tab; setup continues while the local Hub is running/)).toBeVisible(); + expect(screen.getByRole("button", { name: "Cancel setup" })).toBeEnabled(); + const before = harness.api.getSetupRun.mock.calls.length; + const at = new Date(Date.now()).toISOString(); + act(() => harness.snapshot({ populationActivity: { + ...activity, lastActivityAt: at, totalEvents: 2, + events: [...activity.events, { id: 2, at, kind: "writing", state: "completed", target: "architecture" }], + } })); + await waitFor(() => expect(within(screen.getByRole("list", { name: "Recent agent activity" })).getAllByRole("listitem")[0]).toHaveTextContent("Updated architecture notes")); + expect(harness.api.getSetupRun).toHaveBeenCalledTimes(before); + act(() => harness.complete({ status: "paused", stage: "needs_commit", populated: true, commitCommands }, { + stage: "needs_commit", populated: true, commitCommands, + })); + expect(await screen.findByRole("button", { name: "Check commit and open Hub" })).toBeVisible(); + expect(screen.queryByRole("list", { name: "Recent agent activity" })).toBeNull(); + expect(harness.close).toHaveBeenCalledTimes(1); + }); + + it("drops the percentage as soon as population begins, before the first provider report", async () => { + const harness = setupHarness({ hasScaffold: true }, { + status: "running", progress: { step: "population", label: "Populating MEX" }, + }); + renderSetup(harness.api); + expect(await screen.findByText(/Waiting for the first activity report/, undefined, { timeout: 5_000 })).toBeVisible(); + expect(screen.queryByRole("progressbar")).toBeNull(); + expect(screen.queryByText(/\d+%/)).toBeNull(); + }); - await user.click(screen.getByRole("button", { name: "Set up this project" })); - expect(await screen.findByRole("heading", { level: 1, name: "Set up MEX" })).toBeVisible(); + it("keeps the populated fixture on the ordinary Hub shell", async () => { + renderSetup(createFixtureApi()); + expect(await screen.findByRole("heading", { level: 1, name: "Overview" }, { timeout: 5_000 })).toBeVisible(); + expect(screen.queryByRole("heading", { level: 1, name: "Set up MEX" })).toBeNull(); + }); + it("refreshes terminal SSE state, waits for the commit checkpoint, then opens the promoted Hub", async () => { + const user = userEvent.setup(); + const harness = setupHarness(); + renderSetup(harness.api); + await openConfiguration(user); await user.click(screen.getByRole("checkbox", { name: /Cursor/ })); await user.click(screen.getByRole("button", { name: "Start setup" })); - expect(startSetup).toHaveBeenCalledWith({ mode: "code-repo", tools: ["cursor"] }); - expect(await screen.findByText("Populate the scaffold")).toBeVisible(); - expect(screen.getByLabelText("Population prompt")).toHaveValue( - "Populate ROUTER.md and AGENTS.md from this repository.", - ); + expect(harness.api.startSetup).toHaveBeenCalledWith({ mode: "code-repo", tools: ["cursor"] }); + await waitForSubscription(harness, 1); + expect(screen.getByRole("heading", { name: "Running setup" })).toBeVisible(); + const beforePause = harness.api.getSetupStatus.mock.calls.length; + + act(() => harness.complete({ + status: "paused", stage: "needs_population", prompt: "Populate ROUTER.md and AGENTS.md from this repository.", + message: "No supported CLI was selected. Populate the scaffold with your agent.", + }, { hasScaffold: true, stage: "needs_population" })); + expect(await screen.findByLabelText("Population prompt")).toHaveValue("Populate ROUTER.md and AGENTS.md from this repository."); + await waitFor(() => expect(harness.api.getSetupStatus.mock.calls.length).toBeGreaterThan(beforePause)); + expect(harness.close).toHaveBeenCalledTimes(1); await user.click(screen.getByRole("button", { name: "I've populated the scaffold" })); - expect(startSetup).toHaveBeenLastCalledWith({ - mode: "code-repo", - tools: ["cursor"], - confirmPopulation: true, - }); + expect(harness.api.startSetup).toHaveBeenLastCalledWith({ mode: "code-repo", tools: ["cursor"], confirmPopulation: true }); + await waitForSubscription(harness, 2); + act(() => harness.complete({ + status: "succeeded", stage: "needs_commit", populated: true, prompt: null, commitCommands, + message: "Review and commit the canonical MEX setup.", + }, { populated: true, graphReady: true, wikiReady: true, stage: "needs_commit", commitCommands })); + expect(await screen.findByLabelText("Commit commands")).toHaveValue(commitCommands.join("\n")); + expect(screen.queryByRole("heading", { name: "Opening the Project Hub" })).toBeNull(); + const copy = vi.spyOn(navigator.clipboard, "writeText"); + await user.click(screen.getByRole("button", { name: "Copy commands" })); + expect(copy).toHaveBeenCalledWith(commitCommands.join("\n")); + + await user.click(screen.getByRole("button", { name: "Check commit and open Hub" })); + await waitForSubscription(harness, 3); + act(() => harness.complete({ status: "succeeded", stage: "ready", ready: true }, { stage: "ready", ready: true }, true)); expect(await screen.findByRole("heading", { level: 1, name: "Overview" }, { timeout: 5_000 })).toBeVisible(); expect(screen.queryByRole("heading", { level: 1, name: "Set up MEX" })).toBeNull(); - expect(screen.queryByText("Restart")).toBeNull(); - }); - - it("opens the Project Hub when setup is already ready", async () => { - const api = Object.assign(createFixtureApi(), { - getSetupStatus: async () => ({ - ...status, - hasScaffold: true, - populated: true, - graphReady: true, - wikiReady: true, - stage: "ready" as const, - ready: true, - }), - }); - renderSetup(api); + expect(screen.queryByText("Setup status could not be loaded")).toBeNull(); + expect(harness.close).toHaveBeenCalledTimes(3); + }); + + it("opens the Hub when fast promotion replaces the setup app before its terminal event reaches the browser", async () => { + const user = userEvent.setup(); + const harness = setupHarness({ hasScaffold: true, populated: true, graphReady: true, wikiReady: true, stage: "ready", ready: true }); + renderSetup(harness.api); + await user.click(await screen.findByRole("button", { name: "Open Project Hub" }, { timeout: 5_000 })); + await waitForSubscription(harness, 1); + expect(screen.getByRole("heading", { name: "Running setup" })).toBeVisible(); + // The POST returned running, but /setup/events now belongs to the promoted + // app and fails before delivering either a snapshot or the terminal event. + act(() => harness.disconnect(true)); expect(await screen.findByRole("heading", { level: 1, name: "Overview" }, { timeout: 5_000 })).toBeVisible(); - expect(screen.queryByRole("heading", { level: 1, name: "Set up MEX" })).toBeNull(); + expect(screen.queryByText("Setup status could not be loaded")).toBeNull(); + expect(harness.close).toHaveBeenCalledTimes(1); }); - it("waits to open the Project Hub until Graph workbenches are available", async () => { - const fixture = createFixtureApi(); - const getCapabilities = fixture.getCapabilities.bind(fixture); - const unavailable = { availability: "unavailable" as const, reason: "Finish MEX setup before using this Hub workbench." }; - const api = Object.assign(fixture, { - getSetupStatus: async () => ({ - ...status, - hasScaffold: true, - populated: true, - graphReady: true, - wikiReady: true, - stage: "ready" as const, - ready: true, - }), - async getCapabilities() { - const caps = await getCapabilities(); - return { - ...caps, - graph: { read: unavailable, refresh: unavailable, rebuild: unavailable }, - wiki: { read: unavailable, refresh: unavailable, rebuild: unavailable }, - }; - }, + it("recovers a missed pause snapshot with bounded reads when the setup stream disconnects", async () => { + const harness = setupHarness({ hasScaffold: true }, { status: "running" }); + renderSetup(harness.api); + await waitForSubscription(harness, 1); + const statusReads = harness.api.getSetupStatus.mock.calls.length; + const runReads = harness.api.getSetupRun.mock.calls.length; + act(() => { + harness.disconnect(false, { status: "paused", stage: "needs_population", prompt: "Populate the recovered scaffold." }); + harness.disconnect(); + harness.disconnect(); }); - renderSetup(api); - expect(await screen.findByText("Setup finished. Switching this session to the Project Hub.", undefined, { timeout: 5_000 })).toBeVisible(); - expect(screen.getByRole("heading", { level: 2, name: "Opening the Project Hub" })).toBeVisible(); + expect(await screen.findByLabelText("Population prompt")).toHaveValue("Populate the recovered scaffold."); + expect(harness.api.getSetupStatus).toHaveBeenCalledTimes(statusReads + 1); + expect(harness.api.getSetupRun).toHaveBeenCalledTimes(runReads + 1); + expect(harness.close).toHaveBeenCalledTimes(1); + }); + + it("requires an explicit open action after an external commit and does not poll idle setup", async () => { + const user = userEvent.setup(); + const harness = setupHarness({ hasScaffold: true, populated: true, graphReady: true, wikiReady: true, stage: "ready", ready: true }); + renderSetup(harness.api); + expect(await screen.findByRole("button", { name: "Open Project Hub" }, { timeout: 5_000 })).toBeEnabled(); expect(screen.queryByRole("heading", { level: 1, name: "Overview" })).toBeNull(); + const reads = harness.api.getSetupStatus.mock.calls.length; + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 500)); }); + expect(harness.api.getSetupStatus).toHaveBeenCalledTimes(reads); + expect(harness.api.startSetup).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Open Project Hub" })); + expect(harness.api.startSetup).toHaveBeenCalledWith({ mode: "code-repo", tools: [], confirmPopulation: true }); }); - it("asks for git init on code-repo setup and does not start without a repository", async () => { + it("restores a paused Agent memory run after reload and preserves its empty tool selection", async () => { const user = userEvent.setup(); - const startSetup = vi.fn(async () => idleRun); - const api = Object.assign(createFixtureApi(), { - getSetupStatus: async () => ({ ...status, hasGit: false, stage: "needs_git" as const }), - getSetupRun: async () => idleRun, - startSetup, + const harness = setupHarness({ hasGit: false, hasScaffold: true, mode: "agent-memory", configuredTools: ["codex"], stage: "needs_population" }, { + status: "paused", mode: "agent-memory", stage: "needs_population", selectedTools: [], prompt: "Populate your agent memory.", message: "Population needs your agent.", }); - renderSetup(api); - expect(await screen.findByRole("heading", { level: 1, name: "Build a Hub for this checkout" }, { timeout: 5_000 })).toBeVisible(); - await user.click(screen.getByRole("button", { name: "Set up this project" })); - expect(await screen.findByRole("heading", { level: 2, name: "Git repository required" })).toBeVisible(); - expect(screen.getAllByText("git init").length).toBeGreaterThan(0); + renderSetup(harness.api); + expect(await screen.findByLabelText("Population prompt", undefined, { timeout: 5_000 })).toHaveValue("Populate your agent memory."); + expect(screen.queryByText("Git repository required")).toBeNull(); + await user.click(screen.getByRole("button", { name: "I've populated the scaffold" })); + expect(harness.api.startSetup).toHaveBeenCalledWith({ mode: "agent-memory", tools: [], confirmPopulation: true }); + }); + + it.each([false, true])("finishes Agent memory without dashboard loading or indexes (Git: %s)", async (hasGit) => { + const harness = setupHarness({ hasGit, hasScaffold: true, mode: "agent-memory", populated: true, stage: "complete" }); + renderSetup(harness.api); + expect(await screen.findByRole("heading", { name: "Agent memory setup complete" }, { timeout: 5_000 })).toBeVisible(); + expect(screen.getByText(/You can close this tab/)).toBeVisible(); + expect(screen.queryByText("Opening the Project Hub")).toBeNull(); + expect(screen.queryByRole("button", { name: /Finish setup|Open Project Hub|Start setup/ })).toBeNull(); + expect(harness.api.startSetup).not.toHaveBeenCalled(); + }); + + it("restores persisted Agent memory mode from status when no run has started", async () => { + const harness = setupHarness({ hasGit: false, hasScaffold: true, mode: "agent-memory", stage: "needs_population" }); + renderSetup(harness.api); + expect(await screen.findByRole("radio", { name: /Agent memory/ }, { timeout: 5_000 })).toBeChecked(); + expect(screen.queryByText("Git repository required")).toBeNull(); + expect(screen.getByRole("button", { name: "Start setup" })).toBeEnabled(); + }); + + it("shows failed agent diagnostics and keeps manual completion and retry available", async () => { + const user = userEvent.setup(); + const harness = setupHarness({ hasScaffold: true, stage: "needs_population", configuredTools: ["codex"] }, { + status: "failed", stage: "needs_population", selectedTools: ["codex"], populationTool: "codex", + prompt: "Populate the project scaffold.", error: "Codex exited with code 1: authentication required.", message: "Codex population failed.", + }); + renderSetup(harness.api); + expect(await screen.findByRole("alert", undefined, { timeout: 5_000 })).toHaveTextContent("authentication required"); + expect(screen.getByLabelText("Population prompt")).toHaveValue("Populate the project scaffold."); + expect(screen.getByRole("button", { name: "I've populated the scaffold" })).toBeEnabled(); + await user.click(screen.getByRole("button", { name: "Retry population" })); + expect(harness.api.startSetup).toHaveBeenCalledWith({ mode: "code-repo", tools: ["codex"] }); + }); + + it("lets users change tools after agent failure and save a deliberately empty selection", async () => { + const user = userEvent.setup(); + const harness = setupHarness({ hasScaffold: true, configuredTools: ["codex"], stage: "needs_population" }, { + status: "failed", stage: "needs_population", selectedTools: ["codex"], prompt: "Populate memory.", error: "Codex could not start.", + }); + renderSetup(harness.api); + await user.click(await screen.findByRole("button", { name: "Change AI tools" }, { timeout: 5_000 })); + await user.click(screen.getByRole("checkbox", { name: /Codex/ })); + await user.click(screen.getByRole("button", { name: "Retry setup" })); + expect(harness.api.startSetup).toHaveBeenCalledWith({ mode: "code-repo", tools: [] }); + }); + + it("cancels background setup and offers to resume the selected mode", async () => { + const user = userEvent.setup(); + const harness = setupHarness({ mode: "agent-memory", hasGit: false, hasScaffold: true }, { + status: "running", mode: "agent-memory", selectedTools: ["codex"], progress: { step: "population", label: "Populating agent memory" }, + }); + renderSetup(harness.api); + await user.click(await screen.findByRole("button", { name: "Cancel setup" }, { timeout: 5_000 })); + expect(harness.api.cancelSetup).toHaveBeenCalledTimes(1); + expect(await screen.findByText("Setup cancelled")).toBeVisible(); + expect(screen.getByRole("radio", { name: /Agent memory/ })).toBeChecked(); + await user.click(screen.getByRole("button", { name: "Resume setup" })); + expect(harness.api.startSetup).toHaveBeenCalledWith({ mode: "agent-memory", tools: ["codex"] }); + }); + + it("keeps the stop control disabled until the background process exits and resets it on resume", async () => { + const user = userEvent.setup(); + const running: SetupRun = { + ...idleRun, status: "running", stage: "needs_population", selectedTools: ["codex"], + progress: { step: "population", label: "Populating MEX" }, + }; + const harness = setupHarness({ hasScaffold: true }, running); + harness.api.cancelSetup.mockImplementationOnce(async () => ({ ...running, message: "Stopping setup…" })); + renderSetup(harness.api); + await waitForSubscription(harness, 1); + await user.click(screen.getByRole("button", { name: "Cancel setup" })); + expect(await screen.findByRole("button", { name: "Stopping…" })).toBeDisabled(); + expect(harness.close).not.toHaveBeenCalled(); + act(() => harness.complete({ status: "cancelled", message: "The background setup was cancelled." })); + await user.click(await screen.findByRole("button", { name: "Resume setup" })); + expect(await screen.findByRole("button", { name: "Cancel setup" })).toBeEnabled(); + }); + + it("refreshes the Git prerequisite after the user initializes the repository", async () => { + const user = userEvent.setup(); + const harness = setupHarness({ hasGit: false, stage: "needs_git" }); + renderSetup(harness.api); + await openConfiguration(user); + expect(screen.getByRole("heading", { name: "Git repository required" })).toBeVisible(); expect(screen.getByRole("button", { name: "Start setup" })).toBeDisabled(); + harness.updateStatus({ hasGit: true, stage: "needs_setup" }); + await user.click(screen.getByRole("button", { name: "Check repository again" })); + expect(await screen.findByRole("button", { name: "Start setup" })).toBeEnabled(); + expect(harness.api.startSetup).not.toHaveBeenCalled(); + }); + + it("allows Agent memory configuration without Git", async () => { + const user = userEvent.setup(); + const harness = setupHarness({ hasGit: false, stage: "needs_git" }); + renderSetup(harness.api); + await openConfiguration(user); await user.click(screen.getByRole("radio", { name: /Agent memory/ })); expect(screen.getByRole("button", { name: "Start setup" })).toBeEnabled(); - expect(startSetup).not.toHaveBeenCalled(); + expect(screen.queryByText("Git repository required")).toBeNull(); + }); + + it("retries failed promotion as confirmed population instead of rebuilding completed setup", async () => { + const user = userEvent.setup(); + const harness = setupHarness({ hasScaffold: true, populated: true, graphReady: true, wikiReady: true, stage: "ready", ready: true }, { + status: "failed", stage: "ready", populated: true, error: "Project Hub could not open. Fix the repository state and retry.", + }); + renderSetup(harness.api); + expect(await screen.findByRole("alert", undefined, { timeout: 5_000 })).toHaveTextContent("Project Hub could not open"); + expect(screen.getByRole("button", { name: "Retry opening Hub" })).toBeEnabled(); + expect(screen.queryByText("Opening the Project Hub")).toBeNull(); + await user.click(screen.getByRole("button", { name: "Retry opening Hub" })); + expect(harness.api.startSetup).toHaveBeenCalledWith({ mode: "code-repo", tools: [], confirmPopulation: true }); + }); + + it("reloads a failed run read before allowing setup to continue", async () => { + const user = userEvent.setup(); + const harness = setupHarness({ hasScaffold: true }); + harness.api.getSetupRun.mockRejectedValueOnce(new Error("Connection interrupted")); + renderSetup(harness.api); + await user.click(await screen.findByRole("button", { name: "Reload progress" }, { timeout: 5_000 })); + expect(await screen.findByRole("button", { name: "Start setup" })).toBeEnabled(); }); }); diff --git a/packages/hub-web/src/pages/SetupCommitDiff.test.tsx b/packages/hub-web/src/pages/SetupCommitDiff.test.tsx new file mode 100644 index 00000000..1c0ff283 --- /dev/null +++ b/packages/hub-web/src/pages/SetupCommitDiff.test.tsx @@ -0,0 +1,57 @@ +import { render, screen, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { SetupCommitDiff } from "./SetupCommitDiff"; +import { parseSetupDiff } from "./setup-commit-diff"; + +const diff = "diff --git a/x b/x\nindex abcd123..def4567 100644\n--- a/x\n+++ b/x\n@@ -8,2 +8,2 @@\n context\n-old\n+\t & \n\\ No newline at end of file\n"; + +describe("setup diff rendering", () => { + it("renders escaped source with line-number gutters, explicit change markers, and no Git metadata", () => { + const { container } = render(); + const region = screen.getByRole("region", { name: "Diff for x" }); + expect(within(region).getByRole("columnheader", { name: "Old line" })).toBeInTheDocument(); + expect(within(region).getByRole("cell", { name: "Added" })).toHaveTextContent("+"); + expect(within(region).getByRole("cell", { name: "Removed" })).toHaveTextContent("−"); + const addition = container.querySelector('[data-diff-kind="addition"]')!; + expect(addition.querySelector('[data-line-side="old"]')).toBeEmptyDOMElement(); + expect(addition.querySelector('[data-line-side="new"]')).toHaveTextContent("9"); + expect(addition.querySelector("[data-diff-content]")?.textContent).toBe("\t & "); + expect(container.querySelector("img")).toBeNull(); + expect(region.textContent).not.toMatch(/diff --git|index abcd|--- a\/x|\+\+\+ b\/x/u); + expect(within(region).getByText("No newline at end of file")).toBeInTheDocument(); + }); + + it("mounts no source rows inside a closed file and releases them again when closed", () => { + const parsed = parseSetupDiff(diff); + const { container, rerender } = render(); + expect(screen.getByRole("region", { name: "Diff for x" })).toBeEmptyDOMElement(); + rerender(); + expect(container.querySelectorAll("[data-diff-kind]")).toHaveLength(5); + rerender(); + expect(container.querySelectorAll("[data-diff-kind]")).toHaveLength(0); + }); + + it("explains mode-only changes while preserving exact permission values", () => { + const raw = "diff --git a/x b/x\nold mode 100644\nnew mode 100755\n"; + render(); + expect(screen.getByText("Previous file permissions: 100644")).toBeVisible(); + expect(screen.getByText("File permissions changed to 100755 (executable)")).toBeVisible(); + }); + + it.each([false, true])("shows the exact escaped raw data on malformed/truncated fallback (%s)", (truncated) => { + const raw = "diff --git a/x b/x\n+\n\t trailing \n"; + const { container } = render(); + expect(container.querySelector("pre")?.textContent).toBe(raw); + expect(container.querySelector("script")).toBeNull(); + expect(container.querySelector("table")).toBeNull(); + expect(screen.getByText(truncated ? /This diff was shortened/ : /could not be formatted safely/)).toBeVisible(); + }); + + it("keeps all pathological diff content in one raw text node", () => { + const raw = "@@ -0,0 +1,16000 @@\n" + "+\n".repeat(16_000); + const { container } = render(); + expect(container.querySelector("pre")?.textContent).toBe(raw); + expect(container.querySelectorAll("[data-diff-kind]")).toHaveLength(0); + expect(screen.getByText(/complete raw diff to keep this review responsive/)).toBeVisible(); + }); +}); diff --git a/packages/hub-web/src/pages/SetupCommitDiff.tsx b/packages/hub-web/src/pages/SetupCommitDiff.tsx new file mode 100644 index 00000000..4a33e9aa --- /dev/null +++ b/packages/hub-web/src/pages/SetupCommitDiff.tsx @@ -0,0 +1,60 @@ +import { memo } from "react"; +import type { SetupDiff } from "./setup-commit-diff"; +import styles from "../styles/setup.module.css"; + +const fallbackNotice = { + truncated: "This diff was shortened. Showing the available raw diff; review the full file before continuing.", + large: "Showing the complete raw diff to keep this review responsive.", + malformed: "This diff could not be formatted safely. Showing the complete raw diff.", + empty: "No text diff is available for this file.", +}; + +export const SetupCommitDiff = memo(function SetupCommitDiff({ path, diff, parsed, expanded }: { + path: string; + diff: string; + parsed: SetupDiff; + expanded: boolean; +}) { + return ( +
+ {expanded ? parsed.formatted ? ( + + + + + + {parsed.rows.map((row, index) => ( + + {row.kind === "hunk" || row.kind === "notice" ? ( + + ) : ( + <> + + + + + + )} + + ))} + +
Old lineNew lineChangeContent
{row.kind === "notice" ? noticeLabel(row.text) : row.text}{row.oldLine}{row.newLine}{row.kind === "addition" ? "+" : row.kind === "deletion" ? "−" : " "}{row.text}
+ ) : ( + <> +

{fallbackNotice[parsed.reason]}

+ {diff ?
{diff}
: null} + + ) : null} +
+ ); +}); + +function noticeLabel(text: string): string { + const mode = /^(old mode|new mode|new file mode|deleted file mode) ([0-7]{6})$/u.exec(text); + if (!mode) return text; + const executable = mode[2] === "100755"; + if (mode[1] === "new file mode") return `${executable ? "New executable file" : "New file"} (mode ${mode[2]})`; + if (mode[1] === "deleted file mode") return `Deleted ${executable ? "executable file" : "file"} (mode ${mode[2]})`; + if (mode[1] === "old mode") return `Previous file permissions: ${mode[2]}`; + return `File permissions changed to ${mode[2]}${executable ? " (executable)" : ""}`; +} diff --git a/packages/hub-web/src/pages/SetupCommitReview.test.tsx b/packages/hub-web/src/pages/SetupCommitReview.test.tsx new file mode 100644 index 00000000..89dc8959 --- /dev/null +++ b/packages/hub-web/src/pages/SetupCommitReview.test.tsx @@ -0,0 +1,139 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { HubApiError } from "../api/client"; +import type { SetupCommitPreview, SetupCommitResponse } from "../api/types"; +import { SetupCommitReview } from "./SetupCommitReview"; + +const revision = "a944e8d9-7e02-4d04-9a62-d8b347b8e7dc"; +const renewedRevision = "75eff665-7fbe-4b1b-9bf8-9ab33e6f3739"; +const makePreview = (update: Partial = {}): SetupCommitPreview => ({ + revision, expiresAt: new Date(Date.now() + 600_000).toISOString(), branch: "feature/setup", head: null, + defaultMessage: "chore: initialize MEX", canCommit: true, blockedReason: null, + files: [ + { path: ".mex/config.json", status: "added", diff: "diff --git a/.mex/config.json b/.mex/config.json\n+{\"scaffold_id\":\"example\"}", truncated: false }, + { path: ".mex/AGENTS.md", status: "modified", diff: "-old instructions\n+", truncated: false }, + ], ...update, +}); +const result: SetupCommitResponse = { + commit: "a".repeat(40), files: [".mex/config.json", ".mex/AGENTS.md"], message: "Setup committed locally.", + run: { + status: "running", mode: "code-repo", stage: "ready", ready: false, populated: true, + selectedTools: ["codex"], prompt: null, populationTool: "codex", populationCompleted: true, + commitCommands: [], anchorNotes: [], message: "Opening the Hub…", progress: null, + error: null, startedAt: new Date().toISOString(), finishedAt: null, + }, +}; + +function harness(preview = makePreview()) { + const api = { previewSetupCommit: vi.fn(async () => preview), commitSetup: vi.fn(async () => result) }; + const onCommitted = vi.fn(); + const onReviewInvalid = vi.fn(); + const onOpenHub = vi.fn(); + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + const view = render(); + return { api, onCommitted, onReviewInvalid, onOpenHub, ...view }; +} + +async function review(user: ReturnType) { + await user.click(screen.getByRole("button", { name: "Review setup changes" })); + return await screen.findByRole("button", { name: "Commit setup and open Hub" }); +} + +describe("setup commit review", () => { + it("loads only on request, displays exact escaped diffs, and submits the displayed revision and edited message once", async () => { + const user = userEvent.setup(); + const h = harness(); + expect(h.api.previewSetupCommit).not.toHaveBeenCalled(); + expect(h.api.commitSetup).not.toHaveBeenCalled(); + const submit = await review(user); + expect(screen.getByText("2 files in this commit")).toBeVisible(); + expect(screen.getByText("Branch: feature/setup")).toBeVisible(); + expect(screen.getByText(/Only the files listed below will be committed/)).toBeVisible(); + const files = h.container.querySelectorAll("details"); + fireEvent.click(files[1]!.querySelector("summary")!); + await waitFor(() => expect(screen.getByText("Viewed 1 of 2 files")).toBeVisible()); + expect(screen.getByLabelText("Diff for .mex/AGENTS.md")).toHaveTextContent(""); + expect(h.container.querySelector("img")).toBeNull(); + await user.clear(screen.getByRole("textbox", { name: "Commit message" })); + await user.type(screen.getByRole("textbox", { name: "Commit message" }), " chore: add project memory "); + let resolveCommit!: (value: SetupCommitResponse) => void; + h.api.commitSetup.mockImplementationOnce(() => new Promise((resolve) => { resolveCommit = resolve; })); + await user.dblClick(submit); + expect(h.api.commitSetup).toHaveBeenCalledExactlyOnceWith({ revision, message: "chore: add project memory" }); + expect(screen.getByRole("button", { name: "Committing…" })).toBeDisabled(); + expect(screen.getByRole("textbox", { name: "Commit message" })).toBeDisabled(); + await act(async () => { resolveCommit(result); }); + await waitFor(() => expect(h.onCommitted).toHaveBeenCalledExactlyOnceWith(result)); + expect(screen.queryByRole("button", { name: "Commit setup and open Hub" })).toBeNull(); + }); + + it("invalidates a stale review and requires a fresh revision before another commit", async () => { + const user = userEvent.setup(); + const h = harness(); + h.api.commitSetup.mockRejectedValueOnce(new HubApiError({ + type: "about:blank", title: "Review changed", status: 409, code: "REVISION_CONFLICT", detail: "The setup files changed after review.", requestId: "commit-test", + })); + h.api.previewSetupCommit.mockResolvedValueOnce(makePreview()).mockResolvedValueOnce(makePreview({ revision: renewedRevision })); + await user.click(await review(user)); + expect(await screen.findByRole("alert")).toHaveTextContent("Refresh the review before trying again"); + expect(h.onReviewInvalid).toHaveBeenCalledOnce(); + expect(screen.queryByRole("button", { name: "Commit setup and open Hub" })).toBeNull(); + await user.click(screen.getByRole("button", { name: "Refresh review" })); + await user.click(await screen.findByRole("button", { name: "Commit setup and open Hub" })); + expect(h.api.commitSetup).toHaveBeenLastCalledWith({ revision: renewedRevision, message: "chore: initialize MEX" }); + }); + + it("shows a blocked or truncated review without allowing a commit", async () => { + const user = userEvent.setup(); + const h = harness(makePreview({ canCommit: false, blockedReason: "A Git hook requires a manual commit.", files: [{ path: ".mex/AGENTS.md", status: "modified", diff: "large diff", truncated: true }] })); + expect(await review(user)).toBeDisabled(); + expect(screen.getByRole("status")).toHaveTextContent("A Git hook requires a manual commit."); + fireEvent.click(h.container.querySelector("summary")!); + expect(await screen.findByText(/This diff was shortened/)).toBeVisible(); + expect(h.api.commitSetup).not.toHaveBeenCalled(); + }); + + it("shows per-file change counts and preserves viewed state while releasing closed diff rows", async () => { + const user = userEvent.setup(); + const h = harness(makePreview({ files: [{ path: "AGENTS.md", status: "modified", truncated: false, diff: "diff --git a/AGENTS.md b/AGENTS.md\nindex abcd123..def4567 100644\n--- a/AGENTS.md\n+++ b/AGENTS.md\n@@ -1 +1,2 @@\n-old\n+new\n+extra\n" }] })); + await review(user); + expect(screen.getByLabelText("2 added lines, 1 deleted lines")).toBeVisible(); + expect(h.container.querySelector("table")).toBeNull(); + await user.click(h.container.querySelector("summary")!); + await waitFor(() => expect(h.container.querySelector("table")).not.toBeNull()); + expect(screen.getByText("Viewed 1 of 1 files")).toBeVisible(); + await user.click(h.container.querySelector("summary")!); + await waitFor(() => expect(h.container.querySelector("table")).toBeNull()); + expect(screen.getByText("Viewed 1 of 1 files")).toBeVisible(); + }); + + it("rechecks expiry at the explicit commit action and disables empty messages", async () => { + const user = userEvent.setup(); + const now = Date.now(); + const clock = vi.spyOn(Date, "now").mockReturnValue(now); + const h = harness(makePreview({ expiresAt: new Date(now + 60_000).toISOString() })); + const submit = await review(user); + await user.clear(screen.getByRole("textbox", { name: "Commit message" })); + expect(submit).toBeDisabled(); + await user.type(screen.getByRole("textbox", { name: "Commit message" }), "Review setup"); + clock.mockReturnValue(now + 60_001); + await user.click(submit); + expect(screen.getByRole("status")).toHaveTextContent("This review expired"); + expect(submit).toBeDisabled(); + expect(h.api.commitSetup).not.toHaveBeenCalled(); + }); + + it("keeps a successful commit separate from a failed Hub opening and offers only an open retry", async () => { + const user = userEvent.setup(); + const h = harness(); + h.api.commitSetup.mockResolvedValueOnce({ ...result, run: { ...result.run, status: "failed", error: "The Hub could not open." } }); + await user.click(await review(user)); + expect(await screen.findByText("Setup committed locally")).toBeVisible(); + expect(screen.queryByRole("button", { name: "Commit setup and open Hub" })).toBeNull(); + await user.click(screen.getByRole("button", { name: "Retry opening Hub" })); + expect(h.onOpenHub).toHaveBeenCalledOnce(); + expect(h.api.commitSetup).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/hub-web/src/pages/SetupCommitReview.tsx b/packages/hub-web/src/pages/SetupCommitReview.tsx new file mode 100644 index 00000000..5c41c2e6 --- /dev/null +++ b/packages/hub-web/src/pages/SetupCommitReview.tsx @@ -0,0 +1,158 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { Check } from "lucide-react"; +import { HubApiError, type HubApi } from "../api/client"; +import type { SetupCommitPreview, SetupCommitResponse } from "../api/types"; +import { Button } from "../components/primitives/button"; +import { Textarea } from "../components/primitives/textarea"; +import { SetupCommitDiff } from "./SetupCommitDiff"; +import { MAX_FORMATTED_DIFF_LINES, MAX_FORMATTED_REVIEW_LINES, parseSetupDiff } from "./setup-commit-diff"; +import styles from "../styles/setup.module.css"; + +type CommitApi = Pick; + +export function SetupCommitReview({ api, onCommitted, onReviewInvalid, onOpenHub, opening = false }: { + api: CommitApi; + onCommitted: (response: SetupCommitResponse) => void; + onReviewInvalid: () => void; + onOpenHub: () => void; + opening?: boolean; +}) { + const [preview, setPreview] = useState(null); + const [message, setMessage] = useState(null); + const [viewed, setViewed] = useState>(() => new Set()); + const [expanded, setExpanded] = useState>(() => new Set()); + const [attempted, setAttempted] = useState(false); + const [expired, setExpired] = useState(false); + const [committed, setCommitted] = useState(null); + const inFlight = useRef(false); + + const review = useMutation({ + mutationFn: () => { + if (!api.previewSetupCommit) throw new Error("Setup review is unavailable."); + return api.previewSetupCommit(); + }, + onMutate: () => { setAttempted(true); setPreview(null); }, + onSuccess: (next) => { + setPreview(next); + setMessage((current) => current ?? next.defaultMessage); + setViewed(new Set()); + setExpanded(new Set()); + setExpired(Date.parse(next.expiresAt) <= Date.now()); + commit.reset(); + }, + }); + const commit = useMutation({ + mutationFn: ({ revision, message: commitMessage }: { revision: string; message: string }) => { + if (!api.commitSetup) throw new Error("Setup commit is unavailable."); + return api.commitSetup({ revision, message: commitMessage }); + }, + onSuccess: (response) => { + setCommitted(response); + setPreview(null); + onCommitted(response); + }, + onError: () => { + setPreview(null); + onReviewInvalid(); + }, + onSettled: () => { inFlight.current = false; }, + }); + + useEffect(() => { + if (!preview) return; + const delay = Math.max(0, Date.parse(preview.expiresAt) - Date.now()); + const timer = window.setTimeout(() => setExpired(true), Math.min(delay, 2_147_483_647)); + return () => window.clearTimeout(timer); + }, [preview]); + + const files = useMemo(() => { + let remaining = MAX_FORMATTED_REVIEW_LINES; + return preview?.files.map((file) => { + const parsed = parseSetupDiff(file.diff, file.truncated, Math.min(remaining, MAX_FORMATTED_DIFF_LINES)); + if (parsed.formatted) remaining -= parsed.rows.length; + return { ...file, parsed }; + }) ?? []; + }, [preview]); + + if (committed) return ( +
+ {committed.recoveryRequired ? "Setup committed; Git needs attention" : "Setup committed locally"} + {committed.recoveryRequired ? committed.run.error ?? committed.message : committed.run.status === "failed" ? "Your commit is saved. Retry opening the Hub." : "Opening the Project Hub…"} + {committed.run.status === "failed" || committed.recoveryRequired ? : null} +
+ ); + + const trimmedMessage = (message ?? "").trim(); + const validMessage = trimmedMessage.length > 0 && trimmedMessage.length <= 2_000 && !trimmedMessage.includes("\0"); + const ready = Boolean(preview?.canCommit && !expired && validMessage && !review.isPending && !commit.isPending); + + return ( +
+
+
+

Review and commit setup

+

Review the generated changes, then save a local commit and open the Hub.

+
+ +
+ {review.isError ?

{problemDetail(review.error, "The setup changes could not be loaded. Refresh the review to try again.")}

: null} + {commit.isError ?

{problemDetail(commit.error, "The commit could not be confirmed.")} Refresh the review before trying again.

: null} + {preview ? ( +
{ + event.preventDefault(); + if (inFlight.current || !ready) return; + if (Date.parse(preview.expiresAt) <= Date.now()) { setExpired(true); return; } + inFlight.current = true; + commit.mutate({ revision: preview.revision, message: trimmedMessage }); + }}> +
+ {preview.files.length} {preview.files.length === 1 ? "file" : "files"} in this commit + {preview.branch ? `Branch: ${preview.branch}` : preview.head ? "Detached HEAD" : "First commit"} +
+

Only the files listed below will be committed. Nothing is pushed.

+
+ {files.map((file) => ( +
{ + const open = event.currentTarget.open; + setExpanded((current) => { + const next = new Set(current); + if (open) next.add(file.path); else next.delete(file.path); + return next; + }); + if (open) setViewed((current) => new Set(current).add(file.path)); + }}> + + {file.path} + {file.status} + {file.parsed.formatted ? + +{file.parsed.added}−{file.parsed.deleted} + : null} + {viewed.has(file.path) ? : null} + + +
+ ))} +
+

Viewed {viewed.size} of {preview.files.length} files

+ {!preview.canCommit ?

{preview.blockedReason ?? "These changes cannot be committed yet. Refresh the review after resolving the project state."}

: null} + {expired ?

This review expired. Refresh it before committing.

: null} +