diff --git a/.mex/AGENTS.md b/.mex/AGENTS.md index 6fb73040..6613f2b9 100644 --- a/.mex/AGENTS.md +++ b/.mex/AGENTS.md @@ -14,7 +14,7 @@ A local-first TypeScript CLI and browser Hub that turns repository code and agen - Ordinary reads never repair, migrate, or initialize state; mutations and maintenance must be explicit. - Keep the package-root API limited to intentional exports from `src/index.ts`. - Never weaken containment, freshness, privacy, or bounded-work checks to make a test pass. -- MEX never stages, commits, pushes, or pulls on a user's behalf. +- MEX may create a local setup commit only after the user reviews the exact setup-file diff and explicitly chooses the Hub commit action. Preserve unrelated staged work. MEX never pushes or pulls. ## Commands - Dev: `npm run dev` diff --git a/.mex/ROUTER.md b/.mex/ROUTER.md index 49ce8a59..1509611b 100644 --- a/.mex/ROUTER.md +++ b/.mex/ROUTER.md @@ -16,7 +16,7 @@ edges: condition: when starting a task — check the pattern index for a matching pattern file - target: patterns/release-readme-visuals.md condition: when refreshing the release README, badges, community links, or architecture illustrations -last_updated: 2026-09-10 +last_updated: 2026-09-11 --- # Session Bootstrap @@ -28,6 +28,28 @@ 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. Setup begins on a welcome screen, then runs the same ordered + `mex setup` steps through a headless engine, pauses at population with a + copyable prompt when no selected CLI is available. Claude/Codex population + runs as a cancellable background process; failures surface safe diagnostics. + Headless Claude pre-approves only the read-only graph, impact, and event-log + `mex` commands (Bash and PowerShell); other commands are still denied. + Finalization failures show their authored remediation in the Hub. + Both tools stream their visible assistant messages and compact fixed tool + activity labels to a read-only view with bounded scrollback, elapsed time, + and honest quiet periods. Commands, arguments, paths, and tool results are + omitted from the tool stream. History stays in process memory and reconnects + through cursor pages. + Mode and empty tool choices survive refresh. New code projects show a bounded + setup-file diff with numbered, highlighted additions/removals and an explicit + local commit action before in-place Hub promotion; the commit preserves + unrelated staged work and never pushes. The review lists per-file counts and + loads each file's diff on expand from the retained snapshot (128 Ki characters + per file, 1 Mi per review); any truncated diff still forces a manual commit. + Unsupported Git configurations retain the manual checkpoint. Setup never + runs git init, while Agent-memory completes without Graph or Wiki. Existing + committed code projects retain Hub Health recovery for missing local indexes. - 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..02683dac 100644 --- a/.mex/context/architecture.md +++ b/.mex/context/architecture.md @@ -23,12 +23,12 @@ 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 status: promoted - revision: 6 + revision: 10 title: architecture relations: - type: related_to @@ -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 shares the CLI setup phases through `runHeadlessSetup()`. Claude Code and Codex run as owned background children. Their structured streams supply activity timing and a separate read-only transcript of assistant messages with compact fixed tool labels; command details and tool results are dropped before transcript retention. Cursor-based SSE replays a bounded process-memory history without copying it into run snapshots. After code setup finishes and the committed scaffold identity passes the existing Team authority check, an explicit action promotes the same listener and session into the full Hub. Existing committed code projects retain Health recovery for missing disposable indexes; Agent memory keeps its separate completion path. - **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/events/decisions.jsonl b/.mex/events/decisions.jsonl new file mode 100644 index 00000000..2075bfb7 --- /dev/null +++ b/.mex/events/decisions.jsonl @@ -0,0 +1 @@ +{"timestamp":"2026-09-10T12:18:18.332Z","kind":"decision","message":"Hub setup now promotes the same process to the Project Hub when Graph and Wiki are ready; the commit/restart screen is no longer the terminal UI.","files":["src/hub/command.ts","packages/hub-web/src/app/App.tsx"],"cwd":"packages/hub-web","source":"agent","status":"decided"} diff --git a/.mex/patterns/secure-local-project-hub.md b/.mex/patterns/secure-local-project-hub.md index 066b1873..b2f4412c 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 @@ -86,8 +86,90 @@ preview/apply services. process. Use a token-bound local lease and recover only a provably dead PID. - Bundlers can rewrite a static `node:sqlite` import. Load it through `createRequire(import.meta.url)` and smoke-test the packed CLI. +- Packed setup fixtures that hide agent CLIs must retain directly executable + Git. On Windows, expose native `git.exe` in its installation directory; + a `git.cmd` wrapper cannot satisfy a shell-free Git spawn. Preflight Git and + agent absence with the setup child's exact working directory and environment, + using an absolute lookup tool so a missing probe cannot imply agent absence. - Terminal SSE events should close the browser connection immediately; do not - let `EventSource` reconnect to a finished job. + 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. +- 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/package.json b/packages/hub-contracts/package.json index 4f31bd38..97fd6f10 100644 --- a/packages/hub-contracts/package.json +++ b/packages/hub-contracts/package.json @@ -20,6 +20,10 @@ "./overview": { "types": "./dist/overview.d.ts", "import": "./dist/overview.js" + }, + "./setup": { + "types": "./dist/setup.d.ts", + "import": "./dist/setup.js" } }, "scripts": { 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..cbc6caab --- /dev/null +++ b/packages/hub-contracts/src/setup-commit.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + SetupCommitDiffRequestSchema, + SetupCommitDiffSchema, + SetupCommitPreviewSchema, + SetupCommitRequestSchema, + SETUP_COMMIT_MAX_FILE_DIFF_CHARACTERS, + 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", additions: 1, deletions: 0, diffCharacters: 4, 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 fileCount = Math.ceil(SETUP_COMMIT_MAX_TOTAL_DIFF_CHARACTERS / SETUP_COMMIT_MAX_FILE_DIFF_CHARACTERS) + 1; + const files = Array.from({ length: fileCount }, (_, index) => ({ ...preview.files[0], path: `.mex/context/${index}.md`, diffCharacters: SETUP_COMMIT_MAX_FILE_DIFF_CHARACTERS })); + expect(SetupCommitPreviewSchema.safeParse({ ...preview, files }).success).toBe(false); + expect(SetupCommitPreviewSchema.safeParse({ ...preview, files: [{ ...preview.files[0], diffCharacters: SETUP_COMMIT_MAX_FILE_DIFF_CHARACTERS + 1 }] }).success).toBe(false); + // The preview is metadata only; review text is never part of it. + expect(SetupCommitPreviewSchema.safeParse({ ...preview, files: [{ ...preview.files[0], diff: "+{}\n" }] }).success).toBe(false); + }); + + it("requests one reviewed path and bounds the returned diff", () => { + const request = { revision: preview.revision, path: ".mex/context/architecture.md" }; + expect(SetupCommitDiffRequestSchema.parse(request)).toEqual(request); + for (const value of [ + { ...request, path: "../outside.md" }, { ...request, path: "/etc/passwd" }, { ...request, path: "a\\b" }, + { ...request, revision: "old" }, { ...request, files: ["README.md"] }, + ]) expect(SetupCommitDiffRequestSchema.safeParse(value).success).toBe(false); + const diff = { ...request, diff: "+text\n", truncated: false }; + expect(SetupCommitDiffSchema.parse(diff)).toEqual(diff); + expect(SetupCommitDiffSchema.safeParse({ ...diff, diff: "x".repeat(SETUP_COMMIT_MAX_FILE_DIFF_CHARACTERS + 1) }).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 new file mode 100644 index 00000000..c8f64412 --- /dev/null +++ b/packages/hub-contracts/src/setup.ts @@ -0,0 +1,224 @@ +import { z } from "zod"; +import { HUB_LIMITS } from "./index.js"; + +const isoTimestamp = z.string().datetime({ offset: true }); +const boundedReason = z.string().min(1).max(512); +const aiTool = z.enum(["claude", "cursor", "windsurf", "copilot", "opencode", "codex"]); + +export const SETUP_STAGES = [ + "needs_git", + "needs_setup", + "needs_population", + "needs_finalize", + "needs_commit", + "complete", + "ready", +] as const; + +export const SETUP_PROGRESS_STEPS = [ + "detect", + "scaffold", + "tools", + "skills", + "identity", + "scan", + "graph", + "population", + "finalize", +] 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({ + id: aiTool, + name: z.string().min(1).max(64), + selected: z.boolean(), + cliAvailable: z.boolean(), +}).strict(); + +export const SetupStatusSchema = z.object({ + mode: SetupModeSchema, + projectName: z.string().min(1).max(256), + hasGit: z.boolean(), + hasScaffold: z.boolean(), + populated: z.boolean(), + graphReady: z.boolean(), + wikiReady: z.boolean(), + state: z.enum(["existing", "fresh", "partial"]), + stage: SetupStageSchema, + 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: 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; +/** One file's diff is fetched on demand; JSON escaping keeps it inside Hub's 1 MiB response bound. */ +export const SETUP_COMMIT_MAX_FILE_DIFF_CHARACTERS = 131_072; +/** Server-retained review text. The preview itself carries only per-file metadata. */ +export const SETUP_COMMIT_MAX_TOTAL_DIFF_CHARACTERS = 1_048_576; +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(); +const lineCount = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER); +export const SetupCommitFileSchema = z.object({ + path: setupCommitPath, + status: z.enum(["added", "modified", "deleted"]), + additions: lineCount, + deletions: lineCount, + diffCharacters: z.number().int().nonnegative().max(SETUP_COMMIT_MAX_FILE_DIFF_CHARACTERS), + truncated: z.boolean(), +}).strict(); + +export const SetupCommitDiffRequestSchema = z.object({ + revision: z.string().uuid(), + path: setupCommitPath, +}).strict(); + +/** One reviewed file's diff, served from the exact snapshot its revision names. */ +export const SetupCommitDiffSchema = z.object({ + revision: z.string().uuid(), + path: setupCommitPath, + 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.diffCharacters, 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", "cancelled"]), + mode: SetupModeSchema, + stage: SetupStageSchema, + populated: z.boolean(), + ready: z.boolean(), + selectedTools: z.array(aiTool).max(8), + 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), + progress: SetupProgressSchema.nullable(), + error: boundedReason.nullable(), + startedAt: isoTimestamp.nullable(), + 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 SetupCommitDiffRequest = z.infer; +export type SetupCommitDiff = z.infer; +export type SetupCommitRequest = z.infer; +export type SetupCommitResult = z.infer; +export type SetupCommitResponse = z.infer; diff --git a/packages/hub-contracts/tsup.config.ts b/packages/hub-contracts/tsup.config.ts index f023910b..0fb7ded8 100644 --- a/packages/hub-contracts/tsup.config.ts +++ b/packages/hub-contracts/tsup.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ relay: "src/relay.ts", ids: "src/ids.ts", overview: "src/overview.ts", + setup: "src/setup.ts", }, format: ["esm"], target: "es2022", diff --git a/packages/hub-web/scripts/assert-production-build.mjs b/packages/hub-web/scripts/assert-production-build.mjs index d0d914c1..d58a8984 100644 --- a/packages/hub-web/scripts/assert-production-build.mjs +++ b/packages/hub-web/scripts/assert-production-build.mjs @@ -27,6 +27,7 @@ const lazyWorkbenchSources = [ "src/pages/ActivityPage.tsx", "src/pages/JobsPage.tsx", "src/pages/HealthPage.tsx", + "src/pages/SetupPage.tsx", ]; const entryKey = Object.keys(manifest).find((key) => manifest[key].isEntry); if (!entryKey) throw new Error("The production Hub manifest has no application entry."); @@ -85,6 +86,18 @@ if (!overviewRuntimeKey || !manifest[overviewRuntimeKey].isDynamicEntry) { if (initialChunks.has(overviewRuntimeKey)) { throw new Error("The Overview aggregate validator leaked into the application shell."); } +const setupRuntimeKey = Object.keys(manifest).find((candidate) => { + const record = manifest[candidate] ?? {}; + return record.name === "setup" || [candidate, record.src].some((value) => ( + typeof value === "string" && /(?:^|\/)hub-contracts\/dist\/setup\.js$/u.test(value) + )); +}); +if (!setupRuntimeKey || !manifest[setupRuntimeKey].isDynamicEntry) { + throw new Error("The production Hub manifest has no lazy Setup contract."); +} +if (initialChunks.has(setupRuntimeKey) || homeChunks.has(setupRuntimeKey)) { + throw new Error("The Setup contract leaked into the application shell or Home workbench."); +} const relayEntry = workbenchEntries.find((entry) => entry.source === "src/pages/RelayPage.tsx"); const relayComposerKey = Object.keys(manifest).find((candidate) => ( candidate === "src/pages/RelayDraftComposer.tsx" diff --git a/packages/hub-web/scripts/assert-test-contract-boundary.mjs b/packages/hub-web/scripts/assert-test-contract-boundary.mjs index a5e35e5a..87bdb591 100644 --- a/packages/hub-web/scripts/assert-test-contract-boundary.mjs +++ b/packages/hub-web/scripts/assert-test-contract-boundary.mjs @@ -10,6 +10,7 @@ const expectedServeEntries = new Map([ ["@mex/hub-contracts", resolve(packageRoot, "../hub-contracts/src/index.ts")], ["@mex/hub-contracts/ids", resolve(packageRoot, "../hub-contracts/src/ids.ts")], ["@mex/hub-contracts/overview", resolve(packageRoot, "../hub-contracts/src/overview.ts")], + ["@mex/hub-contracts/setup", resolve(packageRoot, "../hub-contracts/src/setup.ts")], ["@mex/hub-contracts/relay", resolve(packageRoot, "../hub-contracts/src/relay.ts")], ]); diff --git a/packages/hub-web/scripts/contract-aliases.mjs b/packages/hub-web/scripts/contract-aliases.mjs index 3d0207ec..ddb60b57 100644 --- a/packages/hub-web/scripts/contract-aliases.mjs +++ b/packages/hub-web/scripts/contract-aliases.mjs @@ -8,6 +8,7 @@ export function hubContractAliases(packageRoot, command) { return { "@mex/hub-contracts/ids": resolve(packageRoot, source, `ids.${extension}`), "@mex/hub-contracts/overview": resolve(packageRoot, source, `overview.${extension}`), + "@mex/hub-contracts/setup": resolve(packageRoot, source, `setup.${extension}`), "@mex/hub-contracts/relay": resolve(packageRoot, source, `relay.${extension}`), "@mex/hub-contracts": resolve(packageRoot, source, `index.${extension}`), }; 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 107030e9..1e519445 100644 --- a/packages/hub-web/src/api/client.ts +++ b/packages/hub-web/src/api/client.ts @@ -41,6 +41,17 @@ import { WikiRelationsResponseSchema, } from "@mex/hub-contracts"; import { createFixtureApi } from "virtual:mex-hub-fixture-api"; +import type { + SetupRun, + SetupStartRequest, + SetupStatus, + SetupTranscriptBatch, + SetupCommitPreview, + SetupCommitDiff, + SetupCommitDiffRequest, + SetupCommitRequest, + SetupCommitResponse, +} from "@mex/hub-contracts/setup"; import type { AgentLoggingPolicy, AgentLoggingUpdateRequest, @@ -125,6 +136,11 @@ export class HubApiError extends Error { } } +export function isSetupCapabilityUnavailable(error: unknown): boolean { + return error instanceof HubApiError + && (error.problem.code === "CAPABILITY_UNAVAILABLE" || error.problem.code === "NOT_FOUND"); +} + export interface JobSubscription { close(): void; } @@ -211,6 +227,15 @@ export interface HubApi { startJob(request: StartJobRequest): Promise; cancelJob(id: string): Promise; subscribeToJob(id: string, onSnapshot: (job: JobSummary) => void): JobSubscription; + getSetupStatus?(): Promise; + getSetupRun?(): Promise; + startSetup?(request: SetupStartRequest): Promise; + cancelSetup?(): Promise; + subscribeToSetup?(onSnapshot: (run: SetupRun) => void, onDisconnect?: () => void): JobSubscription; + subscribeToSetupTranscript?(runId: string, onBatch: (batch: SetupTranscriptBatch) => void, onDisconnect?: () => void): JobSubscription; + previewSetupCommit?(): Promise; + setupCommitDiff?(request: SetupCommitDiffRequest): Promise; + commitSetup?(request: SetupCommitRequest): Promise; } function fallbackProblem(status: number, detail?: string): ProblemDetails { @@ -226,20 +251,24 @@ function fallbackProblem(status: number, detail?: string): ProblemDetails { }; } -async function parseBody(response: Response, schema: Parser): Promise { +async function readJsonBody(response: Response): Promise { const contentType = response.headers.get("content-type") ?? ""; - let body: unknown; try { - body = contentType.includes("json") ? await response.json() : undefined; + return contentType.includes("json") ? await response.json() : undefined; } catch { - body = undefined; + return undefined; } +} - if (!response.ok) { - const problem = HubProblemDetailsSchema.safeParse(body); - throw new HubApiError(problem.success ? problem.data : fallbackProblem(response.status)); - } +function throwIfHttpProblem(response: Response, body: unknown): void { + if (response.ok) return; + const problem = HubProblemDetailsSchema.safeParse(body); + throw new HubApiError(problem.success ? problem.data : fallbackProblem(response.status)); +} +async function parseBody(response: Response, schema: Parser): Promise { + const body = await readJsonBody(response); + throwIfHttpProblem(response, body); const parsed = schema.safeParse(body); if (!parsed.success) { throw new HubApiError(fallbackProblem(500)); @@ -305,6 +334,7 @@ function assertSafeInboxProposalId(value: string): string { const loadRelayClient = () => import("./relay-client"); const loadOverviewContract = () => import("@mex/hub-contracts/overview"); +const loadSetupContract = () => import("@mex/hub-contracts/setup"); export function readBootstrapToken(hash = window.location.hash): string | null { if (!hash || hash === "#") return null; @@ -346,26 +376,43 @@ export class HttpHubApi implements HubApi { throw new HubApiError(fallbackProblem(400, detail)); }, }; - async #request( - path: string, - schema: Parser, - init: RequestInit = {}, - mutation = false, - ): Promise { + async #send(path: string, init: RequestInit = {}, mutation = false): Promise { const headers = new Headers(init.headers); headers.set("Accept", "application/json, application/problem+json"); if (mutation) { headers.set("Content-Type", "application/json"); if (this.#csrfToken) headers.set("X-MEX-CSRF", this.#csrfToken); } - - const response = await fetch(`${API_ROOT}${path}`, { + return fetch(`${API_ROOT}${path}`, { ...init, headers, credentials: "same-origin", redirect: "error", }); - return parseBody(response, schema); + } + + async #request( + path: string, + schema: Parser, + init: RequestInit = {}, + mutation = false, + ): Promise { + return parseBody(await this.#send(path, init, mutation), schema); + } + + async #requestWhenOk( + path: string, + loadSchema: () => Promise>, + init: RequestInit = {}, + mutation = false, + ): Promise { + const response = await this.#send(path, init, mutation); + const body = await readJsonBody(response); + throwIfHttpProblem(response, body); + const schema = await loadSchema(); + const parsed = schema.safeParse(body); + if (!parsed.success) throw new HubApiError(fallbackProblem(500)); + return parsed.data; } bootstrap(token: string): Promise { @@ -709,6 +756,132 @@ export class HttpHubApi implements HubApi { source.onmessage = receive; return { close: () => source.close() }; } + + getSetupStatus(): Promise { + return this.#requestWhenOk("/setup", async () => (await loadSetupContract()).SetupStatusSchema); + } + + getSetupRun(): Promise { + return this.#requestWhenOk("/setup/run", async () => (await loadSetupContract()).SetupRunSchema); + } + + startSetup(request: SetupStartRequest): Promise { + return this.#requestWhenOk( + "/setup", + async () => (await loadSetupContract()).SetupRunSchema, + { method: "POST", body: JSON.stringify(request) }, + true, + ); + } + + 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, + ); + } + + setupCommitDiff(request: SetupCommitDiffRequest): Promise { + return this.#requestWhenOk( + "/setup/commit/diff", + async () => (await loadSetupContract()).SetupCommitDiffSchema, + { method: "POST", body: JSON.stringify(request) }, + 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) { + onSnapshot(parsed.data); + if ( + 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(); + } + } + } catch { + // A malformed event cannot poison the current setup snapshot. + } + }); + }; + source.addEventListener("snapshot", receive as EventListener); + source.addEventListener("terminal", receive as EventListener); + source.onmessage = receive; + 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; } }; + } } export function fixturesEnabled(isDevelopment: boolean, search: string): boolean { 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..46fd8c05 --- /dev/null +++ b/packages/hub-web/src/api/setup-commit-client.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { HttpHubApi } from "./client"; +import type { SetupCommitDiff, 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", additions: 1, deletions: 0, diffCharacters: 17, truncated: false }], +}; +const fileDiff: SetupCommitDiff = { revision, path: ".mex/config.json", 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 every explicit POST with CSRF and sends only the reviewed revision, path and commit message", async () => { + const fetch = vi.fn().mockResolvedValueOnce(json(session)).mockResolvedValueOnce(json(preview)) + .mockResolvedValueOnce(json(fileDiff)).mockResolvedValueOnce(json(response)); + vi.stubGlobal("fetch", fetch); + const api = new HttpHubApi(); + await api.getSession(); + expect(await api.previewSetupCommit()).toEqual(preview); + const diffRequest = { revision, path: ".mex/config.json" }; + expect(await api.setupCommitDiff(diffRequest)).toEqual(fileDiff); + const request = { revision, message: "chore: initialize MEX" }; + expect(await api.commitSetup(request)).toEqual(response); + const previewCall = fetch.mock.calls[1] as [string, RequestInit]; + const diffCall = fetch.mock.calls[2] as [string, RequestInit]; + const commitCall = fetch.mock.calls[3] as [string, RequestInit]; + expect(previewCall[0]).toBe("/api/v1/setup/commit/preview"); + expect(previewCall[1].body).toBe("{}"); + expect(diffCall[0]).toBe("/api/v1/setup/commit/diff"); + expect(diffCall[1].body).toBe(JSON.stringify(diffRequest)); + expect(commitCall[0]).toBe("/api/v1/setup/commit"); + expect(commitCall[1].body).toBe(JSON.stringify(request)); + for (const call of [previewCall, diffCall, 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({ ...fileDiff, 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.setupCommitDiff({ revision, path: ".mex/config.json" })).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/telemetry.ts b/packages/hub-web/src/api/telemetry.ts index 27aafbf9..65a3b864 100644 --- a/packages/hub-web/src/api/telemetry.ts +++ b/packages/hub-web/src/api/telemetry.ts @@ -3,6 +3,7 @@ const PAGE_ROUTES = { "/workstreams": "workstreams", "/specs": "specs", "/playbooks": "playbooks", "/catch-up": "catch_up", "/inbox": "inbox", "/relays": "relays", "/members": "members", "/activity": "activity", "/jobs": "jobs", "/health": "health", "/settings": "settings", + "/setup": "setup", } as const; export type HubTelemetryPage = typeof PAGE_ROUTES[keyof typeof PAGE_ROUTES] diff --git a/packages/hub-web/src/api/types.ts b/packages/hub-web/src/api/types.ts index 174e251d..fb79ac2e 100644 --- a/packages/hub-web/src/api/types.ts +++ b/packages/hub-web/src/api/types.ts @@ -139,6 +139,23 @@ export type { WikiRelationsResponse, WikiSource, } from "@mex/hub-contracts"; +export type { + SetupRun, + SetupStartRequest, + SetupStatus, + SetupToolStatus, + SetupStage, + SetupProgress, + SetupProgressStep, + SetupTranscriptEntry, + SetupTranscriptBatch, + SetupCommitPreview, + SetupCommitFile, + SetupCommitDiff, + SetupCommitDiffRequest, + SetupCommitRequest, + SetupCommitResponse, +} from "@mex/hub-contracts/setup"; export type CapabilityName = "graph" | "wiki" | "jobs" | "activity" | "members" | "workstreams" | "specs" | "inbox" | "relays"; export type Tone = "neutral" | "info" | "success" | "warning" | "danger"; diff --git a/packages/hub-web/src/app/App.test.tsx b/packages/hub-web/src/app/App.test.tsx index f4370a31..7d00d372 100644 --- a/packages/hub-web/src/app/App.test.tsx +++ b/packages/hub-web/src/app/App.test.tsx @@ -43,7 +43,7 @@ describe("Project Hub routes", () => { ["/not-a-route", "Page not found"], ])("renders %s as an intentional view", async (route, heading) => { renderRoute(route); - expect(await screen.findByRole("heading", { level: 1, name: heading })).toBeVisible(); + expect(await screen.findByRole("heading", { level: 1, name: heading }, { timeout: 5_000 })).toBeVisible(); }); it("exposes keyboard navigation and a skip link", async () => { diff --git a/packages/hub-web/src/app/App.tsx b/packages/hub-web/src/app/App.tsx index 007ad8ec..83afb73b 100644 --- a/packages/hub-web/src/app/App.tsx +++ b/packages/hub-web/src/app/App.tsx @@ -1,7 +1,8 @@ -import { lazy } from "react"; -import { BrowserRouter, Route, Routes } from "react-router-dom"; -import { HubApiError, type HubApi } from "../api/client"; -import { HubApiProvider, useCapabilities, useSession } from "../api/context"; +import { lazy, Suspense } from "react"; +import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; +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"; import { Button } from "../components/primitives/button"; import styles from "../styles/app.module.css"; @@ -24,11 +25,18 @@ const RelayPage = lazy(async () => ({ default: (await import("../pages/RelayPage const JobsPage = lazy(async () => ({ default: (await import("../pages/JobsPage")).JobsPage })); const HealthPage = lazy(async () => ({ default: (await import("../pages/HealthPage")).HealthPage })); const SettingsPage = lazy(async () => ({ default: (await import("../pages/SettingsPage")).SettingsPage })); +const SetupLayout = lazy(async () => ({ default: (await import("../pages/SetupPage")).SetupLayout })); function SessionBoundary() { const session = useSession(); const capabilities = useCapabilities(); - + const api = useHubApi(); + const setupStatus = useQuery({ + queryKey: ["setup", "status"], + queryFn: () => api.getSetupStatus!(), + retry: false, + enabled: session.isSuccess && typeof api.getSetupStatus === "function", + }); if (session.isPending) { return
; } @@ -46,6 +54,33 @@ function SessionBoundary() { ); } + if ( + typeof api.getSetupStatus === "function" + && setupStatus.isPending + && setupStatus.fetchStatus === "fetching" + ) { + return ( +
+ +
+ ); + } + + // 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 ( + }> + + + ); + } + if (capabilities.isPending) { return (
@@ -80,6 +115,28 @@ function SessionBoundary() { ); } + if (setupStatus.isError && !isSetupCapabilityUnavailable(setupStatus.error)) { + return ( +
+ void setupStatus.refetch()} + > + Try again + + )} + /> +
+ ); + } + return ; } @@ -105,6 +162,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } /> diff --git a/packages/hub-web/src/app/setup-page.test.tsx b/packages/hub-web/src/app/setup-page.test.tsx new file mode 100644 index 00000000..b44ab79e --- /dev/null +++ b/packages/hub-web/src/app/setup-page.test.tsx @@ -0,0 +1,540 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +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 { HubApiError, type HubApi } from "../api/client"; +import { HubApiProvider } from "../api/context"; +import { createFixtureApi } from "../dev/fixture-api"; +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, + populated: false, + graphReady: false, + wikiReady: false, + state: "existing", + stage: "needs_setup", + configuredTools: [], + 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: 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, + selectedTools: [], + prompt: null, + populationTool: null, + populationCompleted: false, + commitCommands: [], + anchorNotes: [], + message: "MEX is not set up in this checkout yet.", + progress: null, + error: null, + 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", additions: 1, deletions: 0, diffCharacters: 21, truncated: false }], +}); +const commitDiff = vi.fn(async ({ revision, path }: { revision: string; path: string }) => ({ revision, path, 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 } } }); + return render( + + + + + + + , + ); +} + +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("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), + setupCommitDiff: commitDiff, + 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("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(harness.api, { + previewSetupCommit: vi.fn(async () => commitPreview()), + setupCommitDiff: commitDiff, + 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()), + setupCommitDiff: commitDiff, + 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); + 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(); + }); + + 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(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(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("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.queryByText("Setup status could not be loaded")).toBeNull(); + expect(harness.close).toHaveBeenCalledTimes(1); + }); + + 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(); + }); + 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("restores a paused Agent memory run after reload and preserves its empty tool selection", async () => { + const user = userEvent.setup(); + 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(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(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..c0f29e22 --- /dev/null +++ b/packages/hub-web/src/pages/SetupCommitReview.test.tsx @@ -0,0 +1,209 @@ +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 { SetupCommitDiff, SetupCommitFile, SetupCommitPreview, SetupCommitResponse } from "../api/types"; +import { SetupCommitReview } from "./SetupCommitReview"; + +const revision = "a944e8d9-7e02-4d04-9a62-d8b347b8e7dc"; +const renewedRevision = "75eff665-7fbe-4b1b-9bf8-9ab33e6f3739"; +type ReviewedFile = SetupCommitFile & { diff: string }; +const file = (path: string, status: SetupCommitFile["status"], diff: string, update: Partial = {}): ReviewedFile => ({ + path, status, diff, additions: 0, deletions: 0, diffCharacters: diff.length, truncated: false, ...update, +}); +const defaultFiles = [ + file(".mex/config.json", "added", "diff --git a/.mex/config.json b/.mex/config.json\n+{\"scaffold_id\":\"example\"}", { additions: 1 }), + file(".mex/AGENTS.md", "modified", "-old instructions\n+", { additions: 1, deletions: 1 }), +]; +/** The preview carries metadata only; tests keep each file's text for the on-demand diff route. */ +const makePreview = (update: Partial> & { files?: ReviewedFile[] } = {}): SetupCommitPreview & { reviewed: ReviewedFile[] } => { + const reviewed = update.files ?? defaultFiles; + return { + revision, expiresAt: new Date(Date.now() + 600_000).toISOString(), branch: "feature/setup", head: null, + defaultMessage: "chore: initialize MEX", canCommit: true, blockedReason: null, ...update, + files: reviewed.map(({ diff: _diff, ...summary }) => summary), reviewed, + }; +}; +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()) { + let current = preview; + const api = { + previewSetupCommit: vi.fn(async () => { + const { reviewed: _reviewed, ...next } = current; + return next; + }), + setupCommitDiff: vi.fn(async ({ revision: requested, path }: { revision: string; path: string }): Promise => { + const match = current.reviewed.find((entry) => entry.path === path)!; + return { revision: requested, path, diff: match.diff, truncated: match.truncated }; + }), + commitSetup: vi.fn(async () => result), + serve: (next: ReturnType) => { current = next; }, + }; + 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(); + expect(h.api.setupCommitDiff).not.toHaveBeenCalled(); + const files = h.container.querySelectorAll("details"); + fireEvent.click(files[1]!.querySelector("summary")!); + await waitFor(() => expect(screen.getByText("Viewed 1 of 2 files")).toBeVisible()); + expect(h.api.setupCommitDiff).toHaveBeenCalledExactlyOnceWith({ revision, path: ".mex/AGENTS.md" }); + 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", + })); + 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(); + h.api.serve(makePreview({ revision: renewedRevision })); + 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: [file(".mex/AGENTS.md", "modified", "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: [file("AGENTS.md", "modified", "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", { additions: 2, deletions: 1 })] })); + 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(); + // Reopening reuses the retained diff for this revision. + await user.click(h.container.querySelector("summary")!); + await waitFor(() => expect(h.container.querySelector("table")).not.toBeNull()); + expect(h.api.setupCommitDiff).toHaveBeenCalledOnce(); + }); + + it("marks a file viewed only after its diff loads and retries a failed load on reopen", async () => { + const user = userEvent.setup(); + const h = harness(); + await review(user); + let finish!: (value: SetupCommitDiff) => void; + h.api.setupCommitDiff + .mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })) + .mockRejectedValueOnce(new HubApiError({ type: "about:blank", title: "Not found", status: 404, code: "NOT_FOUND", detail: "This file is not part of the current setup review.", requestId: "diff-test" })); + const [config, agents] = h.container.querySelectorAll("summary"); + await user.click(config!); + expect(screen.getByRole("status")).toHaveTextContent("Loading diff…"); + expect(screen.getByText("Viewed 0 of 2 files")).toBeVisible(); + await act(async () => { finish({ revision, path: ".mex/config.json", diff: "+{}\n", truncated: false }); }); + await waitFor(() => expect(screen.getByText("Viewed 1 of 2 files")).toBeVisible()); + await user.click(agents!); + expect(await screen.findByRole("alert")).toHaveTextContent("not part of the current setup review"); + expect(screen.getByText("Viewed 1 of 2 files")).toBeVisible(); + await user.click(agents!); + await user.click(agents!); + await waitFor(() => expect(screen.getByText("Viewed 2 of 2 files")).toBeVisible()); + expect(h.api.setupCommitDiff).toHaveBeenCalledTimes(3); + }); + + it("drops a diff that arrives for an earlier review and treats a stale diff as an expired review", async () => { + const user = userEvent.setup(); + const h = harness(); + await review(user); + let late!: (value: SetupCommitDiff) => void; + h.api.setupCommitDiff.mockImplementationOnce(() => new Promise((resolve) => { late = resolve; })); + await user.click(h.container.querySelector("summary")!); + h.api.serve(makePreview({ revision: renewedRevision })); + await user.click(screen.getByRole("button", { name: "Refresh review" })); + await screen.findByRole("button", { name: "Commit setup and open Hub" }); + await act(async () => { late({ revision, path: ".mex/config.json", diff: "+stale text\n", truncated: false }); }); + expect(screen.queryByText("stale text")).toBeNull(); + expect(screen.getByText("Viewed 0 of 2 files")).toBeVisible(); + + h.api.setupCommitDiff.mockRejectedValueOnce(new HubApiError({ type: "about:blank", title: "Review changed", status: 409, code: "REVISION_CONFLICT", detail: "The setup files changed after review.", requestId: "diff-stale" })); + await user.click(h.container.querySelector("summary")!); + expect(await screen.findByText("This review expired. Refresh it before committing.")).toBeVisible(); + expect(screen.getByRole("button", { name: "Commit setup and open Hub" })).toBeDisabled(); + }); + + 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..eeec4340 --- /dev/null +++ b/packages/hub-web/src/pages/SetupCommitReview.tsx @@ -0,0 +1,195 @@ +import { useEffect, 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 { parseSetupDiff, type SetupDiff } from "./setup-commit-diff"; +import styles from "../styles/setup.module.css"; + +type CommitApi = Pick; +/** Diffs load when a file is first expanded, so review size no longer bounds one response. */ +type LoadedDiff = + | { state: "loading" } + | { state: "error"; message: string } + | { state: "loaded"; diff: string; parsed: SetupDiff }; + +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 [diffs, setDiffs] = useState>(() => new Map()); + const [attempted, setAttempted] = useState(false); + const revision = useRef(null); + 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); revision.current = null; }, + onSuccess: (next) => { + revision.current = next.revision; + setPreview(next); + setMessage((current) => current ?? next.defaultMessage); + setViewed(new Set()); + setExpanded(new Set()); + setDiffs(new Map()); + 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 loadDiff = (path: string) => { + const current = preview; + if (!current || diffs.get(path)?.state === "loading" || diffs.get(path)?.state === "loaded") return; + const settle = (value: LoadedDiff) => { + // A response for an earlier review must never appear under a newer one. + if (revision.current !== current.revision) return; + setDiffs((previous) => new Map(previous).set(path, value)); + if (value.state === "loaded") setViewed((previous) => new Set(previous).add(path)); + }; + settle({ state: "loading" }); + if (!api.setupCommitDiff) { + settle({ state: "error", message: "Setup diffs are unavailable in this build. Review the files in Git instead." }); + return; + } + api.setupCommitDiff({ revision: current.revision, path }).then( + (loaded) => settle({ state: "loaded", diff: loaded.diff, parsed: parseSetupDiff(loaded.diff, loaded.truncated) }), + (error: unknown) => { + if (error instanceof HubApiError && error.problem.code === "REVISION_CONFLICT") setExpired(true); + settle({ state: "error", message: problemDetail(error, "This diff could not be loaded. Collapse the file and open it again.") }); + }, + ); + }; + + 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.

+
+ {preview.files.map((file) => { + const loaded = diffs.get(file.path); + return ( +
{ + 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) loadDiff(file.path); + else if (loaded?.state === "error") setDiffs((current) => { const next = new Map(current); next.delete(file.path); return next; }); + }}> + + {file.path} + {file.status} + + +{file.additions}−{file.deletions} + + {viewed.has(file.path) ? : null} + + {loaded?.state === "loaded" ? ( + + ) : ( +
+ {expanded.has(file.path) ? ( +

+ {loaded?.state === "error" ? loaded.message : "Loading diff…"} +

+ ) : 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} +