diff --git a/.ai/specs/2026-07-20-multi-project-workspace.md b/.ai/specs/2026-07-20-multi-project-workspace.md index 559920b01..17802141e 100644 --- a/.ai/specs/2026-07-20-multi-project-workspace.md +++ b/.ai/specs/2026-07-20-multi-project-workspace.md @@ -170,7 +170,7 @@ never shadow the alias or a route. cezar serve (in /Users/x/proj-b) ├─ runMigrations() # ~/.cezar schemaVersion → latest (idempotent) ├─ ws = loadWorkspaceConfig() # degrade: unreadable → in-memory defaults - ├─ boot = registerProject(cwd-repoRoot) # appends if unknown; bumps lastOpenedAt + ├─ boot = registerProject(cwd-repoRoot) # only while the registry is empty (or the root is known); bumps lastOpenedAt ├─ contexts = ProjectContexts(ws) # lazy — nothing instantiated yet ├─ context(boot.id) # boot project eagerly: recover(), pruneOrphans() ├─ startServer({contexts, bootId, …}) # one port, loopback, as today @@ -184,6 +184,34 @@ worktrees and nested `cez` invocations — the same nesting reality the `CEZ_TODOS_FILE=''` guard in `run.ts:294-305` acknowledges), or the user's home directory itself. Headless `cezar run` applies the same guards. +**Seed once** — boot registration is additionally suppressed once the registry +holds ANY project and the boot root is not one of them. Booting in a folder is +an implicit "this is my project" only for the very first run; after that the +cwd is where the cockpit was *opened from*, and adding a project stays an +explicit gesture (`cezar projects add`, the cockpit's Add project dialog — +both keep using the path-shape guard alone). An unregistered boot root is +served exactly as a suppressed one is: `resolveBootProject` falls back to its +would-be slug and `/p//` binds to the boot context; only the registry +file stays untouched. `CEZ_SINGLE_PROJECT=1` is exempt — there the launch +context IS the project and its identity is read back out of the registry. + +Because that state is now ORDINARY rather than an edge case, two things follow +and are load-bearing: + +- **`GET /api/projects` lists the boot folder anyway**, flagged + `unregistered: true` (see API Contracts). Registry-only, the cockpit would + have no row, no `lastLocation` entry and no way back to the folder it is + serving — the repo chip would name a project the navigation could not open. + Settings → Projects renders that row with **Add project** instead of Remove + and the per-project cap, the sidebar marks it "not saved", and everything + else treats it as a project. +- **The boot slug is sticky for the process**, and reserved against other + registrations. It is a live URL derived from a file the user edits while the + server runs; recomputing it per call let an unrelated `Add project` with the + same basename take the slug and move the boot project to `-2` under an + open tab. The registry lookup still wins when the boot root itself is + registered, so adding the served folder adopts its real id. + Other registered projects get their `ProjectContext` on first API touch (sidebar expand, deep link). Recovery/pruning for a project runs when its context is built. A registered project whose `root` no longer exists (or is @@ -254,8 +282,9 @@ and acquires slots normally. House rules apply verbatim: every field optional/defaulted (`.catch`), `.passthrough()` so newer keys survive an older writer, `.max()` bounds on strings, atomic tmp+rename `0600`, corrupt file → in-memory defaults plus a -one-line boot warning (the registry rebuilds as projects are opened — losing -it is an inconvenience, not data loss). +one-line boot warning (losing it costs no work — the `config.json.bak` +snapshot restores it on load, and what it holds is a list of roots, never +anything from inside a repo). ### `~/.cezar/ui-state.json` (new — global GUI state) @@ -339,7 +368,7 @@ difference until they add a second project. | Route | Shape | Notes | |---|---|---| -| `GET /api/projects` | `{ projects: [{id,name,root,branch?,status,source,lastOpenedAt}], bootProject: string, projectsDir: string }` | `status ∈ 'ok' \| 'missing' \| 'not-git'` (`not-git` is fully usable — same degraded single-queue mode as today; only `missing` blocks). Status/branch probes are cached with a short TTL and refreshed async — the sidebar load must not shell `git` N times per render. Never 404s. | +| `GET /api/projects` | `{ projects: [{id,name,root,branch?,status,source,lastOpenedAt,unregistered?}], bootProject: string, projectsDir: string }` | `status ∈ 'ok' \| 'missing' \| 'not-git'` (`not-git` is fully usable — same degraded single-queue mode as today; only `missing` blocks). Status/branch probes are cached with a short TTL and refreshed async — the sidebar load must not shell `git` N times per render. Never 404s. When the registry does not hold the boot root, the list LEADS with a synthetic `unregistered: true` entry for it (see "Seed once"): the server serves that folder, so the cockpit must be able to reach it. The flag is what keeps registry-editing affordances (Remove, per-project `maxParallel`) off a row that has no registry entry to edit — Settings offers Add project instead. Never written back; the row disappears the moment the folder is registered. | | `POST /api/projects` | `{ root } → { project }` | Registers an existing folder (folder-browser flow). 400 non-absolute/nonexistent path; 409 already registered (returns the existing entry). | | `POST /api/projects/checkout` | `{ url, name? } → { project }` \| `{ error }` | `gh repo clone /`; zod-validates `url` as a GitHub repo URL/`owner/name`; 409 target dir exists; degrades to `{ error, reason }` when `gh` is unavailable (mirrors `github.ts` degradation). Long-running: answers when the clone finishes; the dialog shows progress from `checkout-progress` SSE events. | | `DELETE /api/projects/:projectId` | `{ ok: true }` | Unregisters only. 409 while the project has running tasks. Never deletes files. | @@ -516,7 +545,7 @@ bookmarklets keep working via the redirect (boot project). | Scenario | Behavior | |---|---| | `~/.cezar` unwritable / read-only home | Boot proceeds with an in-memory single-project workspace (boot repo only); one boot-time warning. Nothing requires the file. | -| Corrupt `~/.cezar/config.json` | Degrade to defaults + warning; registry rebuilds as projects are opened. Never crash, never overwrite the corrupt file until the next successful merge-write. | +| Corrupt `~/.cezar/config.json` | Restore from `config.json.bak` when it still holds projects, else degrade to defaults + warning (rebuild with `cezar projects add` — opening a project no longer re-registers it). Never crash, never overwrite the corrupt file until the next successful merge-write. | | Registered project folder deleted/moved | `status: 'missing'`: greyed in sidebar, panes 409, remove offered. Never auto-removed. | | Project registered twice (symlink, trailing slash) | Realpath-normalized on registration → dedupe to the existing entry. | | `cezar` invoked inside a task worktree / nested `cez` / `$HOME` | Registration guard suppresses the registry write; the process still serves that folder normally. | diff --git a/BACKWARD_COMPATIBILITY.md b/BACKWARD_COMPATIBILITY.md index 062362581..f1804be68 100644 --- a/BACKWARD_COMPATIBILITY.md +++ b/BACKWARD_COMPATIBILITY.md @@ -27,9 +27,9 @@ What is protected now: **the shape of each route under `/api/v1`**, the three-wa - Static/GUI: `GET /` and every SPA shell route, `/new` (bookmarklet deep-link, query `?skill=&ref=&auto=&key=`), `/assets/:file`, `/open-mercato.svg` - Meta: `GET /api/v1/health` (the **only** CORS-open route — bookmarklets probe it cross-origin; its shape `{version, latestVersion, repoRoot, repo, checks, defaultRunner}` is the most externally-depended-on JSON in the app), `GET /api/v1/launch-key` - `repoRoot` is the absolute checkout path in local mode (the shape the saved bookmarklets read) but only the checkout's **basename** in hosted mode (`CEZ_REMOTE`), where health is CORS-open off the loopback and the absolute path would leak the developer's username (#431). Always present, always a string — but a hosted consumer must not treat it as a filesystem path. - - Additive since the multi-project workspace (spec `.ai/specs/2026-07-20-multi-project-workspace.md`): `projects: [{id, name}]` + `bootProject` enumerate the per-user registry (section 9). **`projects[].root` is deliberately absent** — health is the CORS-open route, and a per-project absolute path would reintroduce the #431 username leak once per registered project; absolute roots live on the same-origin `GET /api/v1/projects` instead. Every pre-existing health field stays byte-identical; an unreadable registry degrades to `projects: []`, never an error. + - Additive since the multi-project workspace (spec `.ai/specs/2026-07-20-multi-project-workspace.md`): `projects: [{id, name}]` enumerates the per-user registry (section 9), and `bootProject` names the folder THIS server was started in. Those are two different questions, and since boot registration became seed-once they routinely have different answers: **`bootProject` is not guaranteed to appear in `projects[]`** — when the registry does not hold the boot root, health reports the boot slug while listing only the registry, because the synthetic `unregistered` row is a property of this process rather than of the user's registry and health is the CORS-open route. A consumer must therefore treat `bootProject` as an id to address, never as a lookup key into `projects[]`; the same-origin `GET /api/v1/projects` is where that row (and its `root`) can be resolved. **`projects[].root` is deliberately absent** — health is the CORS-open route, and a per-project absolute path would reintroduce the #431 username leak once per registered project; absolute roots live on the same-origin `GET /api/v1/projects` instead. Every pre-existing health field stays byte-identical; an unreadable registry degrades to `projects: []`, never an error. - Additive for issue #737: `capabilities.tokenUsageMetrics` and `capabilities.costMetrics` independently control raw input/output token and reported-cost presentation. Current servers always send both booleans; newer clients fall back to the legacy `tokenMetrics` value (and then visible) for older servers. `tokenMetrics` remains as the fail-closed combined value `tokenUsageMetrics && costMetrics`, so an older cockpit never reveals a dimension a deployment hid. All three flags are presentation-only — run and event telemetry remain unchanged. -- Workspace: `GET/POST /api/v1/projects`, `PATCH /api/v1/projects/:projectId`, `DELETE /api/v1/projects/:projectId`, `POST /api/v1/projects/checkout`, `GET /api/v1/fs/browse`, `GET /api/v1/models`, `GET /api/v1/providers/status`, `POST /api/v1/providers/connect`, `PUT /api/v1/providers/:provider/enabled`, `POST /api/v1/providers/:provider/retry` — the registered-project, filesystem, host model-catalog, and provider-authentication surface. The projects GET shape is `{projects: [{id, name, root, branch?, status, source, lastOpenedAt, forge?, maxParallel?}], bootProject, projectsDir}` (`status`: `ok`/`missing`/`not-git`; `branch` only when cheaply readable; `forge?` is the additive per-project forge classification (#698) — `'github'` when the root's remote parses to a known forge host, omitted otherwise, so an old consumer that ignores it sees no change; `maxParallel?` is the additive per-project concurrency cap from spec `.ai/specs/2026-07-22-per-project-concurrency.md` — omitted means "inherit the workspace cap", so an old consumer that ignores it sees no change; `repoUrl?` is the additive, credential-free web root of the project's remote (`https://github.com/owner/repo`, rebuilt from the PARSED remote so a token in it can never reach a client), which is what lets a cross-project surface link a reference the run knows only by number; `tags?` is the additive grouping-label list the global Tasks page filters and groups by — normalized (trimmed, deduped case-insensitively, sorted) and **omitted rather than `[]`** for an untagged project, so an old consumer that ignores it sees no change). `PATCH /api/v1/projects/:projectId` is additive and **per-key**: body `{maxParallel?: 1..16 | null, tags?: string[] | null}`, each field applied ONLY when the body names it, so the pre-tags `{maxParallel}` body still means exactly what it always did and a tags-only body cannot clear a concurrency ceiling. `null` clears either (an empty `tags` array clears too); an empty body is refused with a 400, as it always was. The answer is `{project}` in the same entry shape. **The agent-account selection is deliberately NOT here** — it lives in `~/.cezar/agent-accounts.json` and is written through `PUT /api/v1/workspace/agent-profiles/selection`, so this route and the project registry are untouched by that feature. Same-origin only — no CORS, which is exactly what licenses the absolute `root`s that health must never carry. The list never 404s: an empty or unreadable registry answers `projects: []`. +- Workspace: `GET/POST /api/v1/projects`, `PATCH /api/v1/projects/:projectId`, `DELETE /api/v1/projects/:projectId`, `POST /api/v1/projects/checkout`, `GET /api/v1/fs/browse`, `GET /api/v1/models`, `GET /api/v1/providers/status`, `POST /api/v1/providers/connect`, `PUT /api/v1/providers/:provider/enabled`, `POST /api/v1/providers/:provider/retry` — the registered-project, filesystem, host model-catalog, and provider-authentication surface. The projects GET shape is `{projects: [{id, name, root, branch?, status, source, lastOpenedAt, forge?, maxParallel?, unregistered?}], bootProject, projectsDir}` (`status`: `ok`/`missing`/`not-git`; `branch` only when cheaply readable; `forge?` is the additive per-project forge classification (#698) — `'github'` when the root's remote parses to a known forge host, omitted otherwise, so an old consumer that ignores it sees no change; `maxParallel?` is the additive per-project concurrency cap from spec `.ai/specs/2026-07-22-per-project-concurrency.md` — omitted means "inherit the workspace cap", so an old consumer that ignores it sees no change; `repoUrl?` is the additive, credential-free web root of the project's remote (`https://github.com/owner/repo`, rebuilt from the PARSED remote so a token in it can never reach a client), which is what lets a cross-project surface link a reference the run knows only by number; `tags?` is the additive grouping-label list the global Tasks page filters and groups by — normalized (trimmed, deduped case-insensitively, sorted) and **omitted rather than `[]`** for an untagged project, so an old consumer that ignores it sees no change; `unregistered?: true` marks the ONE entry that is not a registry row — the folder this server was started in, listed because the boot context serves it even when the registry does not hold it (boot registration is seed-once, section 9). Unlike every other additive field here, ignoring this one is NOT a no-op: `DELETE`/`PATCH` on that entry's id answer 404, and the flag is the only thing that tells the two kinds of row apart. It is never persisted and never emits `project-added`; the row disappears the moment the folder is registered). `PATCH /api/v1/projects/:projectId` is additive and **per-key**: body `{maxParallel?: 1..16 | null, tags?: string[] | null}`, each field applied ONLY when the body names it, so the pre-tags `{maxParallel}` body still means exactly what it always did and a tags-only body cannot clear a concurrency ceiling. `null` clears either (an empty `tags` array clears too); an empty body is refused with a 400, as it always was. The answer is `{project}` in the same entry shape. **The agent-account selection is deliberately NOT here** — it lives in `~/.cezar/agent-accounts.json` and is written through `PUT /api/v1/workspace/agent-profiles/selection`, so this route and the project registry are untouched by that feature. Same-origin only — no CORS, which is exactly what licenses the absolute `root`s that health must never carry. The list never 404s, and it is never empty: an empty or unreadable registry answers the single `unregistered` boot entry, because an empty sidebar in a cockpit that is demonstrably serving a folder was the worse answer. A consumer must therefore not read a one-entry list as "one project registered" without checking `unregistered`. - Agent accounts (spec `.ai/specs/2026-07-29-agent-profiles.md`, additive): `GET/POST /api/v1/workspace/agent-profiles`, `PATCH /api/v1/workspace/agent-profiles/:id`, `DELETE /api/v1/workspace/agent-profiles/:id`, `PUT /api/v1/workspace/agent-profiles/selection` — extra config dirs for a second login of the same agent CLI (`CLAUDE_CONFIG_DIR` / `CODEX_HOME`). Workspace-level and single-mount. `GET` answers `{editable, profiles: [{id, provider, label, configDir, path, exists, looksValid, isDefault, status?, files}], profileCapableProviders, selections, defaults}` (`files` is that agent's user-scope config files resolved inside THAT account's folder). **`status` is absent until the probe has warmed** and the listing never spawns a CLI to fill it — each probe is a shell-out to an agent CLI, one per provider plus one per account, which cost 2.5s on a real machine with four accounts. Absent means "not determined yet", which is NOT the same as the `unknown` a real probe can return; `GET …/:id/status` (optionally `?refresh=1`) is what actually probes, and the cockpit fills each row in from it. Provider auth for every account is warmed once at boot and kept in memory; reads are stale-while-revalidate (an expired answer is refreshed behind the response, never in front of it), a connected answer stands for minutes and a not-connected one is re-checked within a minute so a terminal login is noticed, the run gate re-verifies a provider before refusing, and anything cezar can observe (opening a login, repointing/removing an account, a runtime rejection) invalidates it explicitly, discovered defaults first (`id: "default"`, never stored, never deletable); `selections` maps a project's realpath'd ROOT to `{claude?, codex?, opencode?}`, and `defaults` is the same per-provider shape read as the machine-wide fallback for any repo that has chosen nothing (a repo's own selection always wins, which is what keeps it a default rather than an override). **Writing is a local-machine capability**: every mutator answers 409 in hosted mode (`CEZ_REMOTE`) and `GET` answers `{editable: false, profiles: [], selections: {}, defaults: {}}` there — the listing echoes absolute paths carrying the username, the same disclosure `/api/v1/health` trims. `DELETE` is deregistration only: the directory is never touched, and every selection referencing the removed id is scrubbed in the same atomic write. - Per-account reads: `GET /api/v1/workspace/agent-profiles/:id/status`, `GET /api/v1/workspace/agent-profiles/:id/details` and `POST /api/v1/workspace/agent-profiles/:id/open`. Both address the DISCOVERED account as `default:` (a bare `default` cannot say which agent, and that spelling is reserved by the selection routes). `details` answers `{available, reason?, fields: [{label, value}]}` read from the account's own files — Claude's `.claude.json` `oauthAccount`, Codex's `auth.json` `id_token` claims — by **named field only**: the same files hold API keys and refresh tokens, and nothing is passed through, spread or stringified from a parsed vendor object. It is a SEPARATE route rather than a field on the listing on purpose, so identity is absent from the page until a user asks for it; it is never logged and never persisted. `open` takes `{file, target?}` where `file` is a **catalog id** from that account's own `files` (or the keyword `folder`) — never a path, so the route has no traversal surface — and `target` an `/api/v1/open-targets` id, absent meaning the OS default handler. A file the agent has not written yet answers 409 rather than a false success. Both are localHandoff-gated like the rest of the family. - **State file:** `~/.cezar/agent-accounts.json`, deliberately NOT a key in `config.json`. Its own file is what makes a cezar downgrade safe: a version that has never heard of accounts does not open it, so it cannot drop them — whereas living in `config.json` made their survival depend on a `.passthrough()` in that version's schema, and failed outright whenever any version could not parse `config.json` (it degrades to defaults, and its next merge-write persists them). Accounts written by the branch that first shipped them inside `config.json` are imported once, non-destructively: `config.json` keeps its keys so an older cezar sharing the home reads what it always read. @@ -163,7 +163,7 @@ the instructions emit the new one. The multi-project workspace (spec `.ai/specs/2026-07-20-multi-project-workspace.md`) adds per-user state next to the per-repo files in section 3. Same contract, one extra twist: these files are shared by **every** cezar the user runs across all their repos, so an old CLI and a new one routinely read and write the *same file* — the `.passthrough()` rule cuts both ways (an **older writer must not lose keys a newer version wrote**, not just vice versa). All paths hang off `cezarHomeDir()`, so the `CEZ_HOME` override applies (tests and containers must pin it and never touch a real home). -- **`config.json`** (`packages/cezar/src/workspace/config.ts`) — workspace config + project registry: `schemaVersion` (the migration cursor), `browseRoot` (local-folder picker boundary), `projectsDir` (clone destination), optional `skillsAutoUpdate` (absence inherits `CEZ_SKILLS_AUTO_UPDATE`, then the default `true`), optional `modelsLocked` (only `true` makes native per-runner model settings authoritative in every project), optional `agentDefaults` (`runner`, `models` — the machine-wide agent and model a project with none of its own falls back to; absent means no opinion, which is what keeps them defaults rather than settings every repo inherits), `resources` (`maxParallel`, `memoryLimitMb`, `worktreeRetentionDefault`), `projects[]` (`{id, root, name, addedAt, lastOpenedAt, source, maxParallel?, tags?}` — `tags?` are the grouping labels the global Tasks page reads; absent means untagged, and the writers delete the key rather than storing `[]`). Every field is optional/defaulted — a bad value degrades per-key, a corrupt registry entry is dropped per-entry, never the whole array; `.passthrough()` at every object level so unknown keys survive round-trips through any version. The effective skills preference is computed at read time and unrelated writes must not materialize the optional key. Writes are read-modify-write merges with atomic tmp+rename, mode `0600` (dir `0700`). A corrupt file degrades to in-memory defaults with one warning and is **left on disk untouched** until the next successful merge-write replaces it. The registry is additive state that is *written, never required* — it rebuilds as projects are opened, so losing it is an inconvenience, not data loss, and no code path may ever demand its existence. `resources` is the **enforced** copy since Phase 2: the workspace semaphore (`packages/cezar/src/workspace/semaphore.ts`) caches this slice in memory (refreshed at boot and by `PUT /api/workspace/config`, never re-read per tick; a failed re-read keeps the last good snapshot, never degrading to unlimited) and applies `maxParallel` across every project's manager with the #347 waiting-run exemption intact — the per-repo keys stopped being consulted post-migration (section 3). +- **`config.json`** (`packages/cezar/src/workspace/config.ts`) — workspace config + project registry: `schemaVersion` (the migration cursor), `browseRoot` (local-folder picker boundary), `projectsDir` (clone destination), optional `skillsAutoUpdate` (absence inherits `CEZ_SKILLS_AUTO_UPDATE`, then the default `true`), optional `modelsLocked` (only `true` makes native per-runner model settings authoritative in every project), optional `agentDefaults` (`runner`, `models` — the machine-wide agent and model a project with none of its own falls back to; absent means no opinion, which is what keeps them defaults rather than settings every repo inherits), `resources` (`maxParallel`, `memoryLimitMb`, `worktreeRetentionDefault`), `projects[]` (`{id, root, name, addedAt, lastOpenedAt, source, maxParallel?, tags?}` — `tags?` are the grouping labels the global Tasks page reads; absent means untagged, and the writers delete the key rather than storing `[]`). Every field is optional/defaulted — a bad value degrades per-key, a corrupt registry entry is dropped per-entry, never the whole array; `.passthrough()` at every object level so unknown keys survive round-trips through any version. The effective skills preference is computed at read time and unrelated writes must not materialize the optional key. Writes are read-modify-write merges with atomic tmp+rename, mode `0600` (dir `0700`). A corrupt file degrades to in-memory defaults with one warning and is **left on disk untouched** until the next successful merge-write replaces it. The registry is additive state that is *written, never required* — no code path may ever demand its existence, and losing it costs no work: nothing inside any repo lives here, and the `config.json.bak` snapshot (written beside it on every successful non-empty merge-write, restored on load before degrading) is what makes a lost or corrupt file a non-event. It does **not** rebuild itself by being used: boot registration is seed-once (`shouldAutoRegisterProject` — it seeds the registry only while empty, so starting cezar in a folder never adds it behind the user's back), so a registry lost together with its snapshot is rebuilt one deliberate `cezar projects add ` at a time. Changing that recovery story — dropping the snapshot, or making a lost registry cost more than re-adding roots — is breaking. `resources` is the **enforced** copy since Phase 2: the workspace semaphore (`packages/cezar/src/workspace/semaphore.ts`) caches this slice in memory (refreshed at boot and by `PUT /api/workspace/config`, never re-read per tick; a failed re-read keeps the last good snapshot, never degrading to unlimited) and applies `maxParallel` across every project's manager with the #347 waiting-run exemption intact — the per-repo keys stopped being consulted post-migration (section 3). - **`agent-accounts.json`** (`packages/cezar/src/workspace/agent-accounts.ts`, spec `.ai/specs/2026-07-29-agent-profiles.md`) — extra config dirs for a second login of the same agent CLI, plus which one each project uses: `version`, `accounts[]` (`{id, provider, configDir, label, addedAt}`), `selections` (repo root → `{claude?, codex?, opencode?}`), `defaults` (the machine-wide per-provider fallback a repo with no selection of its own uses). Same house rules as `config.json`: per-key `.catch` degradation, per-entry salvage for `accounts`, `.passthrough()` at every level, merge-write with atomic tmp+rename `0600`, and a corrupt file left on disk after one warning. **Its own file on purpose, and that IS the compatibility argument**: the twist named above — an older writer must not lose a newer version's keys — is a promise this repo cannot make on behalf of a build the user might switch to, and it fails outright whenever any version cannot parse `config.json` (that degrades to in-memory defaults, and the next merge-write persists them). A version that has never heard of accounts does not open this file, so it cannot drop them. Selections live here rather than on `projects[]` for the same reason, and so that deleting an account and scrubbing every reference to it is one atomic write. Accounts written into `config.json` by the branch that first shipped them are imported once and non-destructively — `config.json` keeps its keys, so an older cezar sharing the home reads exactly what it always read. Written, never required: delete the file and every project falls back to its discovered account. - **`ui-state.json`** — the global twin of the per-repo GUI state in section 3, holding cross-project prefs (`appearance`, `notifications`, `importedSkills`, `sidebar.projectOrder`, plus the legacy `sidebar.collapsed` and `lastLocation` keys the cockpit now keeps per-browser in localStorage and no longer reads here); per-project state (pinned runs, templates) stays in each repo's `.ai/cezar/ui-state.json`. Same rules as its twin: unknown keys survive round-trips, writes go through the merge path (`mergeWriteWorkspaceUiState`) with atomic tmp+rename `0600`, and a missing or corrupt file merges from `{}`. - **Migrations** (`packages/cezar/src/workspace/migrations.ts`) — the only sanctioned way to reshape these files, and the framework contract is itself protected: migrations run **ordered** (ascending `to`), are **idempotent** (safe to re-run after a crash mid-way), **additive** (they never delete or rewrite the user's per-repo files — migration 001 imports `maxParallel`/`memoryLimitMb` and the `appearance`/`notifications` ui-state keys by *copying*, leaving every `.ai/cezar/` file in place so a downgraded cezar keeps working off its local copies exactly as before), and **non-blocking** (a failure logs one warning and boot proceeds degraded on in-memory defaults; it is never a boot failure). `schemaVersion` is persisted after **each** migration, so a crash resumes exactly where it left off; an absent version means 0 = "run everything", which idempotence makes safe. Run state (`runs.json`, NDJSON) never migrates — it keeps section 3's additive-zod convention. diff --git a/CHANGELOG.md b/CHANGELOG.md index ec9dae906..4d70319c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,35 @@ unchanged for now: their entries are one uninterrupted stream with no turn boundaries to hang a clock on. Issue: #941. +## 🔧 Changed + +- **Starting cezar in a folder only registers it while you have no projects yet.** The first run + still seeds the registry from the current repo, and booting a project you already have keeps + bumping it to the top of the sidebar — but once anything is registered, running `cezar` somewhere + else serves that folder without quietly adding it to your project list. Run it from a worktree or + a scratch checkout as often as you like; the list stays the one you curated. The folder you + started in is still fully usable: it leads the sidebar marked **not saved**, its tasks and panes + work exactly as a saved project's do, and both **Global settings → Projects** and the + project's own **Settings → General** show it as *not registered* with a one-click **Add project** — + the one place without Remove and a per-project task cap, because there is no registry entry to + edit. Adding is otherwise unchanged: `cezar projects add ` or the **+** button. + `CEZ_SINGLE_PROJECT=1` deployments are exempt, since there the launch folder *is* the project. + +## 🐛 Fixes + +- 🐛 **Automations in the folder cezar is serving keep running when that folder is not one of your + saved projects.** The workspace scheduler compares its live handles against the project registry + and drops anything the registry does not name — which the folder you started cezar in is not, + now that starting somewhere new no longer registers it. Its automations stayed listed and + switched on in the cockpit while nothing polled GitHub for them, and nothing said so. The boot + project is now pinned against that sweep: cezar is demonstrably serving it, registered or not. + And saving that folder with **Add project** no longer splits its automations in two: the folder + briefly answered to both the boot alias and its new registry slug, which opened two independent + handles on one `.ai/cezar` — so switching an automation off in the cockpit left the copy the + scheduler polls untouched, and it went on launching runs until you restarted cezar. Automation + state is now keyed by folder, so a folder addressed twice is still one automation set, scheduled + once. Only affects deployments that opted into automations with `CEZ_AUTOMATIONS=1`. (#872) + # 0.10.1 (2026-09-04) ## Highlights diff --git a/README.md b/README.md index a734a9f92..06279e8fb 100644 --- a/README.md +++ b/README.md @@ -334,12 +334,21 @@ a GitHub page. ## Multiple projects, one cockpit One `cezar serve` hosts **every repo you work in**, not just the one you started -it in. Each repo cezar boots in registers itself in a per-user registry at -`~/.cezar/config.json` — the workspace file that also holds the global knobs +it in. Projects live in a per-user registry at `~/.cezar/config.json` — the +workspace file that also holds the global knobs (the parallel cap, the memory ceiling, the browse root, and the checkout root). Nothing is added to the repo: per-project state stays exactly where it was, in that repo's `.ai/cezar/`. +**Your first run registers the repo you start it in** — that is the whole setup. +After that the registry is yours to curate: starting cezar somewhere else serves +that folder as usual (its own tasks, its own `.ai/cezar/`) but does not add it to +the list behind your back. It shows up at the top of the sidebar marked **not +saved**, and **Settings → Projects** lists it as *not registered* with an +**Add project** button — so the folder you launched in is always one click from +being kept, and never kept without the click. `cezar projects add` does the same +from a terminal. + Every view is project-scoped: ``` @@ -363,8 +372,8 @@ and task list — and the new-task composer names the project it will run in. Removing a project (**Settings → Projects**) drops the registry entry only — the repo and its `.ai/cezar/` are never touched, so re-adding it later finds all its -tasks intact. The project cezar is currently serving can't be removed: it -re-registers itself at the next start. +tasks intact. The project cezar is currently serving can't be removed from the +cockpit — stop the server and use `cezar projects remove ` instead. **From the terminal** — the same registry, no cockpit required (handy over ssh): diff --git a/packages/cezar/src/automations/coordinator.test.ts b/packages/cezar/src/automations/coordinator.test.ts index fa942ce2f..1e09c4c71 100644 --- a/packages/cezar/src/automations/coordinator.test.ts +++ b/packages/cezar/src/automations/coordinator.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { AutomationCoordinator } from './coordinator.ts'; +import { AutomationStore } from './store.ts'; const dirs: string[] = []; async function project(): Promise { @@ -44,6 +45,127 @@ describe('AutomationCoordinator', () => { expect(coordinator.ids()).toEqual([]); }); + /** + * The boot project is the one project the registry may legitimately not name: + * `cezar serve` serves the folder it was started in either way, and since boot + * registration became seed-once that folder is usually unregistered, so its + * store is keyed on the `'default'` alias. Without the pin, the sweep in + * `refresh()` reads "not in the registry" as "stale" and drops it — and because + * the API keeps answering out of the store handle the server already holds, the + * cockpit would go on listing those automations as enabled while the scheduler + * had stopped polling for them. That silence is the whole reason this is pinned. + */ + it('keeps the pinned boot project the registry never names', async () => { + const boot = await project(); + const registered = await project(); + for (const root of [boot, registered]) { + await mkdir(join(root, '.ai/cezar'), { recursive: true }); + await writeFile(join(root, '.ai/cezar/automations.json'), '{"version":1,"automations":[]}'); + } + const coordinator = new AutomationCoordinator({ + // What the real registry answers for an unregistered boot folder: the boot + // root simply is not in it. + listProjects: async () => [{ id: 'kept', root: registered, status: 'ok' }], + pinned: 'default', + }); + // How the server opens it — by the boot alias, before any refresh runs. + expect(coordinator.store('default', boot)).toBeDefined(); + + await coordinator.refresh(); + + expect(coordinator.ids().sort()).toEqual(['default', 'kept']); + expect(coordinator.store('default')).toBeDefined(); + }); + + it('still evicts a genuinely stale handle when a boot project is pinned', async () => { + // The pin is one id, not an amnesty: a project that really did leave the + // registry must still be dropped. + const gone = await project(); + await mkdir(join(gone, '.ai/cezar'), { recursive: true }); + await writeFile(join(gone, '.ai/cezar/automations.json'), '{"version":1,"automations":[]}'); + const coordinator = new AutomationCoordinator({ listProjects: async () => [], pinned: 'default' }); + expect(coordinator.store('gone', gone)).toBeDefined(); + await coordinator.refresh(); + expect(coordinator.ids()).toEqual([]); + }); + + /** + * The exact sequence the cockpit's **Add project** button performs on the folder + * cezar was started in: the server has already opened that root under the boot + * alias, and the registry then gains the SAME root under a freshly allocated + * slug. Two ids, one `.ai/cezar` directory. + * + * `AutomationStore` is file-backed with an in-memory snapshot taken at `open()`, + * so a second instance over that directory is not a harmless duplicate: the + * cockpit keeps writing through the alias handle while the scheduler polls the + * slug's stale copy, and a disabled automation goes on firing until the process + * restarts. One store per root is what makes that state unrepresentable. + */ + it('serves two ids for one root from a single store', async () => { + const root = await project(); + await mkdir(join(root, '.ai/cezar'), { recursive: true }); + await writeFile(join(root, '.ai/cezar/automations.json'), '{"version":1,"automations":[]}'); + let registry: { id: string; root: string; status: 'ok' }[] = []; + const coordinator = new AutomationCoordinator({ + listProjects: async () => registry, + pinned: 'default', + }); + // How the server opens the boot folder before it is registered. + const boot = coordinator.store('default', root); + expect(boot).toBeDefined(); + + // …and what Add project does: the same root, now carrying a registry slug. + registry = [{ id: 'my-repo', root, status: 'ok' }]; + await coordinator.refresh(); + + expect(coordinator.store('my-repo')).toBe(boot); + expect(coordinator.ids().sort()).toEqual(['default', 'my-repo']); + }); + + it('schedules a doubly-addressed root once, under the pinned id', async () => { + // The scheduler builds one due entry per id this returns, so a root reachable + // by two ids polled GitHub and launched runs twice. The pinned id wins the tie + // because only that one routes `launch` to the boot manager and store; the slug + // would build a second ProjectContext over the same root. + const root = await project(); + await mkdir(join(root, '.ai/cezar'), { recursive: true }); + AutomationStore.open(join(root, '.ai/cezar')).create( + { + name: 'Review PRs', + enabled: true, + events: ['pull_request.opened'], + intervalSeconds: 300, + filters: { lookbackDays: 7, maxRecords: 25 }, + task: { prompt: 'Review' }, + }, + 'review-prs', + ); + const coordinator = new AutomationCoordinator({ + listProjects: async () => [{ id: 'my-repo', root, status: 'ok' }], + pinned: 'default', + }); + coordinator.store('default', root); + await coordinator.refresh(); + + expect(coordinator.enabledProjectIds()).toEqual(['default']); + }); + + it('keeps the surviving id its store when its twin is removed', async () => { + // Dropping the shared store with the first id to leave would hand the survivor + // a fresh snapshot — the divergence again, by a slower route. + const root = await project(); + await mkdir(join(root, '.ai/cezar'), { recursive: true }); + await writeFile(join(root, '.ai/cezar/automations.json'), '{"version":1,"automations":[]}'); + const coordinator = new AutomationCoordinator({ listProjects: async () => [] }); + const first = coordinator.store('default', root); + expect(coordinator.store('my-repo', root)).toBe(first); + + coordinator.remove('my-repo'); + + expect(coordinator.store('default')).toBe(first); + expect(coordinator.ids()).toEqual(['default']); + }); + it('degrades registry and corrupt definition failures to warnings', async () => { const warn = vi.fn(); const coordinator = new AutomationCoordinator({ listProjects: async () => { throw new Error('offline'); }, warn }); diff --git a/packages/cezar/src/automations/coordinator.ts b/packages/cezar/src/automations/coordinator.ts index dd9af1a35..ad1cbeabb 100644 --- a/packages/cezar/src/automations/coordinator.ts +++ b/packages/cezar/src/automations/coordinator.ts @@ -1,7 +1,25 @@ -import { existsSync } from 'node:fs'; -import { join } from 'node:path'; +import { existsSync, realpathSync } from 'node:fs'; +import { join, resolve } from 'node:path'; import { AutomationStore } from './store.ts'; +/** + * The identity of a project's automation state is its DIRECTORY, not the id the + * caller happens to address it by. One folder can legitimately carry two ids at + * once: the boot alias (`'default'`) the server opens it under, and the registry + * slug it acquires the moment the user saves the served folder. Realpath'd so a + * symlinked spelling and the registry's normalized root agree — `registerProject` + * stores realpaths, while the boot root is whatever the process was started with. + */ +function rootKey(root: string): string { + try { + return realpathSync(root); + } catch { + // Missing or unreadable folder: fall back to the lexical path rather than + // throwing. A store over a directory that is not there degrades on its own. + return resolve(root); + } +} + export interface AutomationProjectSource { id: string; root: string; @@ -11,6 +29,18 @@ export interface AutomationProjectSource { export interface AutomationCoordinatorOptions { listProjects: () => Promise; warn?: (message: string) => void; + /** + * The boot project's id, which `refresh()` must never evict even though + * `listProjects()` (the REGISTRY) does not name it. The server serves the + * folder it was started in whether or not that folder is registered, and + * since boot registration became seed-once (`shouldAutoRegisterProject`) + * the unregistered case is the ordinary one — the store is then keyed on + * the `'default'` boot alias, which is never a registry id. Without this + * the sweep below drops that store on the first `reschedule()`, and the + * boot project's automations stay visible and editable in the cockpit + * while nothing ever polls for them. + */ + pinned?: string; } /** @@ -19,8 +49,20 @@ export interface AutomationCoordinatorOptions { * full ProjectContext. Schedulers attach to these handles in Phase 4. */ export class AutomationCoordinator { + /** + * ROOT → store, one instance per directory. `AutomationStore` is file-backed + * with an in-memory snapshot taken at `open()` — `list()` serves that snapshot + * and `setState()` rewrites the whole state file from the writer's own copy — + * so two instances over one `.ai/cezar` diverge permanently the first time + * either writes. Keying by id let that happen as soon as one folder had two + * ids: the cockpit wrote through the boot alias while the scheduler polled a + * second store, so a disabled automation kept firing and cursors rolled back. + */ private readonly stores = new Map(); + /** projectId → the root that id addresses (unnormalized; `rootKey` normalizes). */ private readonly roots = new Map(); + /** The project ids that have actually been opened, as `ids()` has always meant. */ + private readonly opened = new Set(); constructor(private readonly options: AutomationCoordinatorOptions) {} @@ -32,8 +74,12 @@ export class AutomationCoordinator { this.options.warn?.(`Unable to refresh GitHub automations: ${error instanceof Error ? error.message : String(error)}`); return; } + // The pinned boot id survives the sweep: it is a project this process is + // demonstrably serving, not a stale handle, and the registry it is being + // compared against is exactly the list that does not know about it. const present = new Set(projects.map((project) => project.id)); - for (const id of this.stores.keys()) { + if (this.options.pinned !== undefined) present.add(this.options.pinned); + for (const id of [...this.opened]) { if (!present.has(id)) this.remove(id); } for (const project of projects) { @@ -48,28 +94,60 @@ export class AutomationCoordinator { } store(projectId: string, root?: string): AutomationStore | undefined { - const existing = this.stores.get(projectId); - if (existing) return existing; const projectRoot = root ?? this.roots.get(projectId); if (!projectRoot) return undefined; - const store = AutomationStore.open(join(projectRoot, '.ai/cezar'), { warn: this.options.warn }); - this.stores.set(projectId, store); this.roots.set(projectId, projectRoot); + this.opened.add(projectId); + const key = rootKey(projectRoot); + let store = this.stores.get(key); + if (!store) { + store = AutomationStore.open(join(projectRoot, '.ai/cezar'), { warn: this.options.warn }); + this.stores.set(key, store); + } return store; } + /** + * One id per DIRECTORY, so a folder addressed twice is scheduled once — the + * scheduler builds a due entry per id returned here, and two ids for one root + * meant every definition in it polled and launched twice. + * + * The pinned boot id wins that tie deliberately. `/p//` resolves to the + * boot context anyway, but the scheduler's `launch` only takes the boot + * manager and store when it is handed the boot id; given the slug it would + * build a SECOND `ProjectContext` over the boot root, double-opening the + * `.ai/cezar` state the server takes care never to open twice. + */ enabledProjectIds(): string[] { - return [...this.stores.entries()] - .filter(([, store]) => store.list().some((definition) => definition.enabled)) - .map(([id]) => id); + const chosen = new Map(); + for (const projectId of this.opened) { + const root = this.roots.get(projectId); + if (root === undefined) continue; + const key = rootKey(root); + const store = this.stores.get(key); + if (!store?.list().some((definition) => definition.enabled)) continue; + if (!chosen.has(key) || projectId === this.options.pinned) chosen.set(key, projectId); + } + return [...chosen.values()]; } remove(projectId: string): void { - this.stores.delete(projectId); + const root = this.roots.get(projectId); this.roots.delete(projectId); + this.opened.delete(projectId); + if (root === undefined) return; + // The store outlives this id when another one still addresses the same + // folder — dropping it would hand the survivor a fresh snapshot and + // reintroduce exactly the divergence one-store-per-root exists to prevent. + const key = rootKey(root); + for (const other of this.opened) { + const otherRoot = this.roots.get(other); + if (otherRoot !== undefined && rootKey(otherRoot) === key) return; + } + this.stores.delete(key); } ids(): string[] { - return [...this.stores.keys()]; + return [...this.opened]; } } diff --git a/packages/cezar/src/index.ts b/packages/cezar/src/index.ts index 2a4096122..1b2953242 100644 --- a/packages/cezar/src/index.ts +++ b/packages/cezar/src/index.ts @@ -30,9 +30,8 @@ import { } from './server/provider-action-gate.ts'; import { checkForUpdate } from './update-check.ts'; import { printSkillsBanner } from './skills-banner.ts'; +import { initWorkspace } from './workspace/boot.ts'; import { loadWorkspaceConfig } from './workspace/config.ts'; -import { runMigrations } from './workspace/migrations.ts'; -import { registerProject, shouldRegisterProject } from './workspace/projects.ts'; import { runProjectsCommand } from './workspace/projects-cli.ts'; import { WorkspaceSemaphore } from './workspace/semaphore.ts'; import { runTaskCommand } from './dispatch/task-cli.ts'; @@ -175,34 +174,6 @@ async function main(): Promise { } } -// ---- workspace boot ---------------------------------------------------------- - -/** - * Boot-time workspace bookkeeping (spec 2026-07-20-multi-project-workspace, - * "Boot flow"): run pending `~/.cezar` migrations first, then register the - * boot repo in the per-user project registry. Registration is suppressed for - * task worktrees and `$HOME` itself (`shouldRegisterProject`) — the process - * still serves those folders normally. Strictly non-fatal: the zero-config - * law says a broken or read-only home degrades to a smaller cockpit, never a - * failed boot, so any workspace error logs one warning and boot continues. - * - * Returns the boot project's registry id when registration happened — - * `serveCommand` plumbs it into the server (`ServerDeps.bootProjectId`) so - * `/api/projects` and `/api/v1/health` can name the boot project without a - * lookup. Undefined when registration was suppressed or the workspace is - * unavailable; the server then derives a fallback on its own. - */ -async function initWorkspace(repoRoot: string): Promise { - try { - await runMigrations({ bootRepoRoot: repoRoot }); - if (await shouldRegisterProject(repoRoot)) return (await registerProject(repoRoot)).id; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.warn(`[cez] workspace registry unavailable (${message}) — continuing without it`); - } - return undefined; -} - // ---- serve ----------------------------------------------------------------- async function serveCommand( diff --git a/packages/cezar/src/server/checkout.test.ts b/packages/cezar/src/server/checkout.test.ts index f9981ef8e..156fba9a6 100644 --- a/packages/cezar/src/server/checkout.test.ts +++ b/packages/cezar/src/server/checkout.test.ts @@ -350,6 +350,12 @@ describe('POST /api/v1/projects/checkout', () => { const listProjectsViaApi = async (): Promise => (await (await apiRequest(makeApp(), '/api/v1/projects')).json()) as ProjectsResponse; + /** The REGISTRY rows. The route also lists the unregistered boot folder (it + * serves it, so the cockpit must be able to reach it — see projects-api.test.ts); + * a checkout assertion is about what the clone did or did not register. */ + const registeredViaApi = async (): Promise => + (await listProjectsViaApi()).projects.filter((project) => !project.unregistered); + /** Point the workspace at a temp checkout root, so nothing lands in `~`. */ const useCheckoutRoot = () => mergeWriteWorkspaceConfig((config) => { @@ -409,7 +415,7 @@ describe('POST /api/v1/projects/checkout', () => { expect(body.error).toContain('already exists'); expect(body.project).toBeUndefined(); expect(readFileSync(join(existing, 'precious.txt'), 'utf8')).toBe('mine'); - expect((await listProjectsViaApi()).projects).toEqual([]); + expect(await registeredViaApi()).toEqual([]); }); it('surfaces a clone failure as a readable error, cleans up, and registers nothing', async () => { @@ -428,7 +434,7 @@ describe('POST /api/v1/projects/checkout', () => { // Verbatim: gh's own words are the only ones that can tell the user WHY. expect(body.error).toContain('Repository not found'); expect(existsSync(join(checkoutRoot, 'nope'))).toBe(false); - expect((await listProjectsViaApi()).projects).toEqual([]); + expect(await registeredViaApi()).toEqual([]); expect(seen.some((s) => s.event === 'project-added')).toBe(false); expect(seen.at(-1)).toMatchObject({ event: 'checkout-progress', @@ -463,7 +469,7 @@ describe('POST /api/v1/projects/checkout', () => { expect(typeof body.error).toBe('string'); } expect(existsSync(join(checkoutRoot, 'escape'))).toBe(false); - expect((await listProjectsViaApi()).projects).toEqual([]); + expect(await registeredViaApi()).toEqual([]); }); it('a repo already registered under a DIFFERENT name still clones and registers fresh', async () => { @@ -473,7 +479,7 @@ describe('POST /api/v1/projects/checkout', () => { expect((await post({ url: 'open-mercato/cezar', name: 'one' })).status).toBe(200); const second = await post({ url: 'open-mercato/cezar', name: 'two' }); expect(second.status).toBe(200); - expect((await listProjectsViaApi()).projects.map((p) => p.name).sort()).toEqual(['one', 'two']); + expect((await registeredViaApi()).map((p) => p.name).sort()).toEqual(['one', 'two']); }); it('a checkout that duplicates an ALREADY-registered root answers 409 with the existing entry', async () => { diff --git a/packages/cezar/src/server/projects-api.test.ts b/packages/cezar/src/server/projects-api.test.ts index 19bd5e1e3..258e965fd 100644 --- a/packages/cezar/src/server/projects-api.test.ts +++ b/packages/cezar/src/server/projects-api.test.ts @@ -15,7 +15,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { PROJECT_TAGS_MAX, PROJECT_TAG_MAX_LENGTH } from '@open-mercato/cezar-contract'; import { RunStore } from '../runs/store.ts'; import type { RunManager } from '../workflows/run.ts'; -import { allocateProjectSlug, clearProjectProbeCache, listProjects, registerProject } from '../workspace/projects.ts'; +import { + allocateProjectSlug, + clearProjectProbeCache, + listProjects, + registerProject, + type ProjectListEntry, +} from '../workspace/projects.ts'; import { ProjectContexts } from './project-context.ts'; import { apiRequest } from './loopback-request.testkit.ts'; import { loadWorkspaceConfig, mergeWriteWorkspaceConfig } from '../workspace/config.ts'; @@ -111,6 +117,20 @@ describe('workspace projects API', () => { return (await res.json()) as ProjectsResponse; }; + /** + * The REGISTRY rows only. `GET /api/v1/projects` also lists the folder this + * server was started in when the registry does not hold it — an + * `unregistered: true` row that exists so the cockpit can reach what the + * server is serving (its own tests below). Every assertion about what the + * registry contains goes through this, so the synthetic row cannot make one + * of them accidentally pass. + */ + const registeredProjects = async (over: Partial = {}): Promise => + (await getProjects(over)).projects.filter((project) => !project.unregistered); + + const registeredIds = async (over: Partial = {}): Promise => + (await registeredProjects(over)).map((project) => project.id); + const getHealth = async (over: Partial = {}): Promise => { const res = await apiRequest(makeApp(over), '/api/v1/health'); expect(res.status).toBe(200); @@ -118,9 +138,9 @@ describe('workspace projects API', () => { }; describe('GET /api/v1/projects', () => { - it('answers an empty registry with projects:[] and defaults — never a 404', async () => { + it('answers an empty registry with no registered projects and defaults — never a 404', async () => { const body = await getProjects(); - expect(body.projects).toEqual([]); + expect(body.projects.filter((project) => !project.unregistered)).toEqual([]); // Unregistered boot repo (e.g. worktree/$HOME/unreadable workspace): // bootProject degrades to the repo's would-be slug, not an error. expect(body.bootProject).toBe(allocateProjectSlug(repoRoot, [])); @@ -210,6 +230,126 @@ describe('workspace projects API', () => { }); }); + /** + * The folder the server was started in, when the registry does not hold it — + * the ordinary state since boot registration became seed-once. It is listed + * so the cockpit can reach what the server serves (sidebar row, `lastLocation` + * eligibility, Settings' Add button); it is flagged so nothing offers to edit + * a registry row that does not exist. + */ + describe('GET /api/v1/projects — the unregistered boot folder', () => { + it('lists the boot folder as unregistered, with its status and no registry timestamps', async () => { + const other = await registerProject(otherRoot); + const body = await getProjects(); + + const boot = body.projects.find((project) => project.id === body.bootProject); + expect(boot).toMatchObject({ + id: allocateProjectSlug(repoRoot, [other.id]), + root: realpathSync(repoRoot), + name: basename(realpathSync(repoRoot)), + status: 'not-git', // a real probe, exactly like a registered row + unregistered: true, + addedAt: '', + lastOpenedAt: '', + }); + // Listed, never written: the registry still holds only the project the + // user actually added. + expect((await loadWorkspaceConfig()).projects.map((p) => p.root)).toEqual([other.root]); + }); + + it('drops the flag once the boot folder is registered, and never duplicates it', async () => { + const boot = await registerProject(repoRoot); + const body = await getProjects(); + expect(body.projects.map((project) => project.id)).toEqual([boot.id]); + expect(body.projects[0]?.unregistered).toBeUndefined(); + }); + + it('is the whole list when the workspace is unreadable — never an empty sidebar', async () => { + // A directory where the config file belongs: every read of it fails, which + // is the zero-config "degrade to a smaller cockpit" path. Nothing is + // registered as far as this process can tell, and the one folder it can + // definitely serve is the one it was started in. + mkdirSync(workspaceConfigPath(), { recursive: true }); + const body = await getProjects(); + expect(body.projects).toMatchObject([{ id: body.bootProject, unregistered: true }]); + expect(body.bootProject).toBe(allocateProjectSlug(repoRoot, [])); + }); + + it('keeps the boot id stable when another project takes its would-be slug', async () => { + // One app instance for the whole test: the boot id is a live URL, and it + // must not move under an open tab because the registry changed around it. + const app = makeApp(); + const first = (await (await apiRequest(app, '/api/v1/projects')).json()) as ProjectsResponse; + const bootId = first.bootProject; + + // A different folder with the SAME basename, added the way the dialog adds one. + const twin = join(otherRoot, basename(repoRoot)); + mkdirSync(twin, { recursive: true }); + const registered = await apiRequest(app, '/api/v1/projects', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ root: twin }), + }); + expect(registered.status).toBe(200); + const { project } = (await registered.json()) as RegisterProjectResponse; + // The reservation: the newcomer takes the suffixed slug, not the one the + // boot folder is already being served under. + expect(project.id).not.toBe(bootId); + + const after = (await (await apiRequest(app, '/api/v1/projects')).json()) as ProjectsResponse; + expect(after.bootProject).toBe(bootId); + expect(after.projects.map((p) => p.id)).toContain(bootId); + }); + + it('hands the boot folder its own slug when the user adds it', async () => { + const app = makeApp(); + const before = (await (await apiRequest(app, '/api/v1/projects')).json()) as ProjectsResponse; + const added = await apiRequest(app, '/api/v1/projects', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ root: repoRoot }), + }); + expect(added.status).toBe(200); + const { project } = (await added.json()) as RegisterProjectResponse; + // The reservation is against OTHER roots only — adding the served folder + // keeps the id the cockpit is already showing, so no URL moves. + expect(project.id).toBe(before.bootProject); + + const after = (await (await apiRequest(app, '/api/v1/projects')).json()) as ProjectsResponse; + expect(after.bootProject).toBe(before.bootProject); + expect(after.projects.map((p) => p.unregistered)).toEqual([undefined]); + }); + + it('yields the slug visibly when another process takes it, rather than shadowing that project', async () => { + const app = makeApp(); + const first = (await (await apiRequest(app, '/api/v1/projects')).json()) as ProjectsResponse; + const bootId = first.bootProject; + + // A same-basename folder registered OUT OF BAND — `cezar projects add` in a + // terminal, where this server's reservation cannot reach it. + const twin = join(otherRoot, basename(repoRoot)); + mkdirSync(twin, { recursive: true }); + const stolen = await registerProject(twin); + expect(stolen.id).toBe(bootId); + + // The boot project moves to the suffixed slug instead of keeping an id the + // registry now points at another repo: the scope resolver binds + // `/p//` to the boot context BEFORE consulting the registry, so + // holding on to the stolen slug would serve this folder under the other + // project's sidebar row. + const after = (await (await apiRequest(app, '/api/v1/projects')).json()) as ProjectsResponse; + expect(after.bootProject).not.toBe(bootId); + expect(after.projects.find((p) => p.unregistered)?.id).toBe(after.bootProject); + + // …and the row that DID take the slug resolves to its own root. + const contexts = new ProjectContexts({ listProjects }); + const scoped = await apiRequest(makeApp({ contexts }), `/api/v1/p/${bootId}/repo`); + expect(scoped.status).toBe(200); + expect(contexts.peek(bootId)?.root).toBe(await realpath(twin)); + contexts.disposeAll(); + }); + }); + describe('single-project management guards', () => { it('refuses checkout before clone or registry side effects', async () => { let cloneCalls = 0; @@ -324,7 +464,7 @@ describe('workspace projects API', () => { expect(body.project.id).toBe(first.id); expect(body.error).toContain(first.id); expect(seen).toEqual([]); - expect((await getProjects()).projects).toHaveLength(1); + expect(await registeredIds()).toEqual([first.id]); }); it('400s a non-absolute path, a missing folder, a file, and a malformed body', async () => { @@ -338,14 +478,14 @@ describe('workspace projects API', () => { expect((await post({})).status).toBe(400); expect((await post({ root: ' ' })).status).toBe(400); // No 400 path may have written anything. - expect((await getProjects()).projects).toEqual([]); + expect(await registeredProjects()).toEqual([]); }); it('refuses $HOME itself — the dialog starts there and could otherwise add it', async () => { const { status, body } = await post({ root: '~' }); expect(status).toBe(400); expect(body.error).toContain('home directory'); - expect((await getProjects()).projects).toEqual([]); + expect(await registeredProjects()).toEqual([]); }); it('hosted mode: a folder outside browseRoot is refused, one inside is registered', async () => { @@ -363,7 +503,7 @@ describe('workspace projects API', () => { expect(refused.status).toBe(400); // The message must not name the root it is protecting (fs-browse's rule). expect(refused.body.error).not.toContain(checkoutRoot); - expect((await getProjects()).projects).toEqual([]); + expect(await registeredProjects()).toEqual([]); const allowed = await post({ root: inside }); expect(allowed.status).toBe(200); expect(allowed.body.project.root).toBe(await realpath(inside)); @@ -386,7 +526,7 @@ describe('workspace projects API', () => { // …and neither leaks the probed spelling back (the `no such folder` // message echoes it; the containment one deliberately does not). expect(absent.body.error).not.toContain('nope'); - expect((await getProjects()).projects).toEqual([]); + expect(await registeredProjects()).toEqual([]); }); it('hosted mode: a missing folder INSIDE the root still says so, not "outside"', async () => { @@ -405,7 +545,7 @@ describe('workspace projects API', () => { const answer = await post({ root: typo }); expect(answer.status).toBe(400); expect(answer.body.error).toBe(`no such folder: ${typo}`); - expect((await getProjects()).projects).toEqual([]); + expect(await registeredProjects()).toEqual([]); }); it('hosted mode: a symlink inside the root pointing out of it is refused', async () => { @@ -422,7 +562,7 @@ describe('workspace projects API', () => { const answer = await post({ root: escape }); expect(answer.status).toBe(400); expect(answer.body.error).toBe('folder is outside the browsable root'); - expect((await getProjects()).projects).toEqual([]); + expect(await registeredProjects()).toEqual([]); }); }); @@ -544,15 +684,15 @@ describe('workspace projects API', () => { expect(status, id).toBe(404); expect(body.error, id).toContain('unknown project'); } - expect((await getProjects()).projects.map((p) => p.id)).toEqual([other.id]); + expect(await registeredIds()).toEqual([other.id]); }); - it('refuses the boot project (and its `default` alias) — it re-registers itself at every start', async () => { + it('refuses the boot project (and its `default` alias) — this server is serving it', async () => { const boot = await registerProject(repoRoot); for (const id of [boot.id, 'default']) { const { status, body } = await del(id); expect(status, id).toBe(409); - expect(body.error, id).toContain('re-registers'); + expect(body.error, id).toContain('is serving'); } expect((await getProjects()).projects.map((p) => p.id)).toEqual([boot.id]); }); diff --git a/packages/cezar/src/server/server.ts b/packages/cezar/src/server/server.ts index a7ea18463..d7ae5b418 100644 --- a/packages/cezar/src/server/server.ts +++ b/packages/cezar/src/server/server.ts @@ -1116,12 +1116,33 @@ export function createApp(deps: ServerDeps) { // The boot flow (`initWorkspace` in src/index.ts) registers the boot repo // and plumbs its registry id in via `deps.bootProjectId`. Legacy callers and // tests construct the app without one — then it is derived lazily from the - // registry by realpath and cached on a hit. A boot repo that is legitimately - // unregistered (task worktree, `$HOME` itself, unreadable workspace) falls - // back to its would-be slug, so `bootProject` always names the repo this - // server was started in. Strictly non-fatal, zero-config: every failure path - // degrades to the slug fallback, never an error. + // registry by realpath and cached on a hit. A boot repo that is not in the + // registry — a task worktree, `$HOME`, an unreadable workspace, or (since + // boot registration became seed-once) any folder started in while the user + // already has projects — falls back to its would-be slug, so `bootProject` + // always names the repo this server was started in. Strictly non-fatal, + // zero-config: every failure path degrades to the slug fallback, never an + // error. + // + // BOTH answers are sticky for the process. The registry hit caches for the + // obvious reason; the FALLBACK caches because it is a live URL the cockpit + // is showing, and it is derived from a file the user edits while the server + // runs — recomputing it per call let an unrelated `Add project` with the + // same basename take the slug and silently move the boot project to + // `-2` under an open tab. The registry lookup still runs first, so the + // day the boot folder IS registered (its own "Add project"), its real id + // takes over from the fallback rather than the two disagreeing; the reserved + // slug below is what keeps those two the same string. + // + // Sticky, but never at the cost of correctness: a pinned fallback that some + // OTHER root has since taken (an out-of-band `cezar projects add ~/other/beta` + // from a second process, where the reservation cannot reach) is dropped and + // re-allocated. That gives back the visible `-2` move instead of + // shadowing — the scope resolver binds `/p//` to the boot + // context before it consults the registry, so keeping the stolen slug would + // quietly serve the boot folder under a sidebar row pointing somewhere else. let bootProjectCache = bootProjectId; + let bootProjectFallback: string | undefined; const resolveBootProject = async (projects?: readonly WorkspaceProject[]): Promise => { if (bootProjectCache) return bootProjectCache; let registry = projects ?? []; @@ -1130,10 +1151,16 @@ export function createApp(deps: ServerDeps) { const real = await realpath(bootRoot).catch(() => bootRoot); const match = registry.find((p) => p.root === real || p.root === bootRoot); if (match) bootProjectCache = match.id; + else if (bootProjectFallback !== undefined + && registry.some((project) => project.id === bootProjectFallback)) { + bootProjectFallback = undefined; + } } catch { // unreadable workspace — fall through to the slug fallback below } - return bootProjectCache ?? allocateProjectSlug(bootRoot, registry.map((project) => project.id)); + if (bootProjectCache) return bootProjectCache; + bootProjectFallback ??= allocateProjectSlug(bootRoot, registry.map((project) => project.id)); + return bootProjectFallback; }; // Health's workspace garnish: id+name ONLY — never `root` (#431, see the // health route). Reads only the registry file; no per-root status probes, @@ -2353,11 +2380,39 @@ export function createApp(deps: ServerDeps) { } catch { // unreadable workspace — degrade to the empty registry + defaults } - const body: ProjectsResponse = { - projects, - bootProject: await resolveBootProject(projects), - projectsDir, - }; + const bootProject = await resolveBootProject(projects); + // The folder this server was started in, when the registry does not hold + // it — the ordinary state since boot registration became seed-once, and + // before that the task-worktree/`$HOME` case. The server serves it (the + // boot context answers `/p//…` and the unscoped alias), so + // leaving it out of this list made it unreachable: no sidebar row, no + // `lastLocation` (the cockpit only saves registry-known ids), and the + // repo chip naming a folder the navigation could not open. It is marked + // `unregistered` rather than merged in silently, so Settings offers to + // add it instead of offering Remove/Max parallel it cannot honour. + // + // Also the honest answer when the workspace is unreadable: nothing IS + // registered as far as this process can tell, and a cockpit showing the + // one folder it can definitely serve beats an empty sidebar. + if (!projects.some((project) => project.id === bootProject)) { + const root = await realpath(bootRoot).catch(() => bootRoot); + projects = [ + { + id: bootProject, + root, + name: basename(root), + // Never registered, so it has no registry timestamps to report — + // empty rather than invented, and Settings renders "—" for them. + addedAt: '', + lastOpenedAt: '', + source: 'local', + unregistered: true, + ...(await probeProjectStatus(root)), + }, + ...projects, + ]; + } + const body: ProjectsResponse = { projects, bootProject, projectsDir }; return c.json(body); }) @@ -2392,14 +2447,16 @@ export function createApp(deps: ServerDeps) { } if (!entry) return c.json({ error: `unknown project: ${id}` }, 404); - // The boot project is refused, not removed: `cezar serve` re-registers the - // repo it was started in on every boot, so "removing" it would undo itself - // at the next restart while breaking this session's sidebar in the - // meantime. The pane disables the button and says the same thing. + // The boot project is refused, not removed: this server is serving that + // repo right now, and dropping its registry row would break the session's + // own sidebar while the process keeps running out of it. Offline removal + // is the honest gesture — `cezar projects remove` has no such refusal + // because it runs with no server. The pane disables the button and says + // the same thing. if (id === bootId) { return c.json( { - error: `cezar is serving ${entry.name} right now — it re-registers itself at every start, so it cannot be removed from here`, + error: `cezar is serving ${entry.name} right now — stop it and run \`cezar projects remove ${id}\` to drop the registry entry`, }, 409, ); @@ -2659,9 +2716,16 @@ export function createApp(deps: ServerDeps) { } catch { // unreadable workspace — treat as unknown; the write below will fail loudly } + // The boot project's id is reserved even when the registry does not hold + // it: an unregistered boot folder is still being served under that slug, + // so letting a same-basename folder take it would point a live URL at the + // wrong repo. Reserved against OTHER roots only — adding the boot folder + // itself is the one registration that should get exactly that slug. + const bootReal = await realpath(bootRoot).catch(() => bootRoot); + const reserved = real === bootReal ? [] : [await resolveBootProject()]; let project: ProjectListEntry; try { - const entry = await registerProject(requested, source); + const entry = await registerProject(requested, source, reserved); project = { ...entry, ...(await probeProjectStatus(entry.root)) }; } catch (err) { // e.g. a read-only home — nothing was persisted (atomic tmp+rename). @@ -5587,8 +5651,15 @@ export function startServer(deps: ServerDeps, port: number): ServerType { // The subscription hub rides the same HTTP server (one port, zero config): // createApp registers the topics, the `upgrade` hook below owns the socket. const socketHub = deps.socketHub ?? createSocketHub(); - const automationCoordinator = new AutomationCoordinator({ listProjects }); const bootProjectId = deps.bootProjectId ?? 'default'; + // `pinned`: the boot project is served whether or not the registry holds it, + // and since boot registration became seed-once it usually does NOT — then + // `bootProjectId` is the `'default'` alias, which `listProjects()` can never + // name, so the coordinator's own refresh sweep would evict the store opened + // one line below and the boot folder's automations would silently stop being + // scheduled while the cockpit kept showing them enabled. Registered or not, + // pinning is the same statement: this process is serving that project. + const automationCoordinator = new AutomationCoordinator({ listProjects, pinned: bootProjectId }); const bootAutomationStore = automationCoordinator.store(bootProjectId, deps.repoRoot)!; const sharedContexts = deps.contexts ?? new ProjectContexts({ listProjects, diff --git a/packages/cezar/src/workspace/boot.test.ts b/packages/cezar/src/workspace/boot.test.ts new file mode 100644 index 000000000..8f42f3fc4 --- /dev/null +++ b/packages/cezar/src/workspace/boot.test.ts @@ -0,0 +1,85 @@ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { initWorkspace } from './boot.ts'; +import { loadWorkspaceConfig } from './config.ts'; +import { clearProjectProbeCache, registerProject } from './projects.ts'; + +/** + * `initWorkspace` — the one function every boot path (`serve`, `run`, the + * single-project `projects list`) calls, and therefore the only thing that + * decides whether starting cezar in a folder writes to the user's registry. + * `shouldAutoRegisterProject` has its own unit tests; these assert the WIRING, + * because a boot that quietly went back to the old path would leave every one + * of those tests green. + */ +describe('initWorkspace', () => { + const originalHome = process.env.CEZ_HOME; + let home: string; + let repos: string; + + beforeEach(() => { + home = mkdtempSync(join(realpathSync(tmpdir()), 'cez-boot-home-')); + repos = mkdtempSync(join(realpathSync(tmpdir()), 'cez-boot-repos-')); + process.env.CEZ_HOME = home; + clearProjectProbeCache(); + }); + + afterEach(() => { + if (originalHome === undefined) delete process.env.CEZ_HOME; + else process.env.CEZ_HOME = originalHome; + rmSync(home, { recursive: true, force: true }); + rmSync(repos, { recursive: true, force: true }); + }); + + const makeRepo = (name: string): string => { + const dir = join(repos, name); + mkdirSync(dir, { recursive: true }); + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir }); + return dir; + }; + + const registeredRoots = async (): Promise => + (await loadWorkspaceConfig()).projects.map((project) => project.root); + + it('seeds the registry from the folder cezar first runs in', async () => { + const root = makeRepo('first'); + const id = await initWorkspace(root); + expect(id).toBe('first'); + expect(await registeredRoots()).toEqual([realpathSync(root)]); + }); + + it('does not add the folder once the user has projects', async () => { + await registerProject(makeRepo('kept')); + const scratch = makeRepo('scratch'); + + // No id to plumb into the server — it derives the boot slug itself and + // lists the folder as unregistered (see the projects API tests). + expect(await initWorkspace(scratch)).toBeUndefined(); + expect(await registeredRoots()).toEqual([realpathSync(join(repos, 'kept'))]); + }); + + it('still opens a known project: same id, refreshed lastOpenedAt', async () => { + const root = makeRepo('known'); + const first = await registerProject(root); + await registerProject(makeRepo('other')); + + const id = await initWorkspace(root); + expect(id).toBe(first.id); + const entry = (await loadWorkspaceConfig()).projects.find((project) => project.id === first.id); + expect(Date.parse(entry?.lastOpenedAt ?? '')).toBeGreaterThanOrEqual( + Date.parse(first.lastOpenedAt), + ); + expect(await registeredRoots()).toHaveLength(2); + }); + + it('runs migrations even when it registers nothing', async () => { + await registerProject(makeRepo('kept')); + await initWorkspace(makeRepo('scratch')); + // The migration cursor is the observable proof migrations ran — the + // suppressed registration must not short-circuit the rest of boot. + expect((await loadWorkspaceConfig()).schemaVersion).toBeGreaterThan(0); + }); +}); diff --git a/packages/cezar/src/workspace/boot.ts b/packages/cezar/src/workspace/boot.ts new file mode 100644 index 000000000..41a6c7147 --- /dev/null +++ b/packages/cezar/src/workspace/boot.ts @@ -0,0 +1,39 @@ +import { runMigrations } from './migrations.ts'; +import { registerProject, shouldAutoRegisterProject } from './projects.ts'; + +/** + * Boot-time workspace bookkeeping (spec 2026-07-20-multi-project-workspace, + * "Boot flow"): run pending `~/.cezar` migrations first, then register the + * boot repo in the per-user project registry — but only while that registry + * is still empty (`shouldAutoRegisterProject`). Once the user has projects, + * booting elsewhere serves the folder without adding it; adding is then an + * explicit gesture (`cezar projects add`, the cockpit's Add project dialog). + * Registration is also suppressed for task worktrees and `$HOME` itself — the + * process still serves those folders normally. Strictly non-fatal: the + * zero-config law says a broken or read-only home degrades to a smaller + * cockpit, never a failed boot, so any workspace error logs one warning and + * boot continues. + * + * Returns the boot project's registry id when registration happened — + * `serveCommand` plumbs it into the server (`ServerDeps.bootProjectId`) so + * `/api/v1/projects` and `/api/v1/health` can name the boot project without a + * lookup. Undefined when registration was suppressed or the workspace is + * unavailable; the server then derives a fallback on its own and lists the + * boot folder as an unregistered project. + * + * Its own module rather than a private function in `src/index.ts`: this is the + * one place that decides whether starting cezar somewhere writes to the user's + * registry, and `src/index.ts` runs `main()` on import, so a test could not + * reach it there. Every boot path (`serve`, `run`, and the single-project + * `projects list`) goes through this function. + */ +export async function initWorkspace(repoRoot: string): Promise { + try { + await runMigrations({ bootRepoRoot: repoRoot }); + if (await shouldAutoRegisterProject(repoRoot)) return (await registerProject(repoRoot)).id; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn(`[cez] workspace registry unavailable (${message}) — continuing without it`); + } + return undefined; +} diff --git a/packages/cezar/src/workspace/config.ts b/packages/cezar/src/workspace/config.ts index 70649330b..a819ed4f3 100644 --- a/packages/cezar/src/workspace/config.ts +++ b/packages/cezar/src/workspace/config.ts @@ -21,10 +21,14 @@ import { assertCezarHomeWriteIsSandboxed, workspaceConfigPath } from '../paths.t * survive a round-trip through an older one; * - `.max()` bounds on strings (this file is parsed on every boot); * - atomic tmp+rename writes with mode `0600` (dir `0700`); - * - a corrupt file degrades to in-memory defaults plus ONE warning line — the - * registry rebuilds as projects are opened, so losing it is an - * inconvenience, not data loss. The corrupt file is left in place until the - * next successful merge-write replaces it. + * - a corrupt file degrades to in-memory defaults plus ONE warning line, and + * is left in place until the next successful merge-write replaces it. What + * makes that survivable is the `config.json.bak` snapshot below, which the + * load path restores from before degrading — NOT re-registration: since boot + * registration became seed-once (`shouldAutoRegisterProject`), opening a + * project no longer writes it back, so a registry lost with its snapshot is + * re-added with `cezar projects add ` (or the cockpit's Add project), + * one gesture per project. Nothing inside any repo is ever at stake. */ /** `id` slug rule — mirrors the spec: `^[a-z0-9][a-z0-9-]{0,63}$`. */ @@ -325,7 +329,9 @@ export async function loadWorkspaceConfig(path: string = workspaceConfigPath()): return restored; } if (raw === null) return defaultWorkspaceConfig(); - console.warn(`[cez] workspace config ${path} is corrupt — using defaults (registry rebuilds)`); + console.warn( + `[cez] workspace config ${path} is corrupt — using defaults (re-add projects with \`cezar projects add\`)`, + ); return defaultWorkspaceConfig(); } diff --git a/packages/cezar/src/workspace/projects-cli.ts b/packages/cezar/src/workspace/projects-cli.ts index 7a3d3a282..d1515e2d4 100644 --- a/packages/cezar/src/workspace/projects-cli.ts +++ b/packages/cezar/src/workspace/projects-cli.ts @@ -153,11 +153,13 @@ async function removeCommand(id: string | undefined, io: ProjectsCommandIo): Pro io.error(USAGE); return 1; } - // Unlike `DELETE /api/projects/:projectId`, there is no boot-project refusal - // here: that rule exists because a running server would break its own + // Unlike `DELETE /api/v1/projects/:projectId`, there is no boot-project + // refusal here: that rule exists because a running server would break its own // sidebar, and the CLI runs with no server and no boot project. Removing the - // repo you normally serve is therefore allowed — and self-healing, since the - // next `cezar serve` in it registers it again (said in the note below). + // repo you normally serve is therefore allowed — and, since boot registration + // became seed-once (`shouldAutoRegisterProject`), it STAYS removed: the next + // `cezar serve` in it will serve the folder without re-registering it. The + // line below says what was and was not touched; `add` puts it back. if (!(await removeProject(id))) { io.error(`unknown project: ${id}`); return 1; diff --git a/packages/cezar/src/workspace/projects.test.ts b/packages/cezar/src/workspace/projects.test.ts index ffcf55b3b..9d1a64252 100644 --- a/packages/cezar/src/workspace/projects.test.ts +++ b/packages/cezar/src/workspace/projects.test.ts @@ -12,6 +12,7 @@ import { normalizeProjectTags, registerProject, removeProject, + shouldAutoRegisterProject, shouldRegisterProject, } from './projects.ts'; @@ -105,6 +106,16 @@ describe('workspace projects', () => { } }); + it('keeps `reservedIds` out of the allocator, even though the registry is free of them', async () => { + // What a running server passes for its UNREGISTERED boot folder: that slug + // is a live URL the boot context answers, so a same-basename newcomer must + // not take it out from under an open tab. + expect((await registerProject(makeDir('one', 'web'), 'local', ['web'])).id).toBe('web-2'); + // The registry still wins where the two overlap — reserving a taken id is a + // no-op, not a second reason to suffix. + expect((await registerProject(makeDir('two', 'web'), 'local', ['web'])).id).toBe('web-3'); + }); + it('slugifies ugly basenames and keeps a checkout source', async () => { const entry = await registerProject(makeDir('My Repo!.git'), 'checkout'); expect(entry.id).toBe('my-repo-git'); @@ -268,6 +279,47 @@ describe('workspace projects', () => { expect(await shouldRegisterProject(homedir())).toBe(false); expect(await shouldRegisterProject(`${homedir()}/`)).toBe(false); }); + + it('keeps allowing an explicit add once the registry is populated', async () => { + await registerProject(makeRepo('first')); + // The path-shape guard is what `cezar projects add` and POST /api/projects + // ask — it must stay blind to how many projects already exist. + expect(await shouldRegisterProject(makeRepo('second'))).toBe(true); + }); + }); + + describe('shouldAutoRegisterProject (boot seeding)', () => { + const env = (single?: boolean): NodeJS.ProcessEnv => + single ? { CEZ_SINGLE_PROJECT: '1' } : {}; + + it('seeds the very first project', async () => { + expect(await shouldAutoRegisterProject(makeRepo('first'), env())).toBe(true); + }); + + it('suppresses an unknown root once any project is registered', async () => { + await registerProject(makeRepo('first')); + expect(await shouldAutoRegisterProject(makeRepo('second'), env())).toBe(false); + expect((await loadWorkspaceConfig()).projects).toHaveLength(1); + }); + + it('still allows a root that is already registered, in any spelling', async () => { + const root = makeRepo('known'); + await registerProject(root); + await registerProject(makeRepo('other')); + expect(await shouldAutoRegisterProject(root, env())).toBe(true); + expect(await shouldAutoRegisterProject(`${root}/`, env())).toBe(true); + }); + + it('keeps the path-shape guards ahead of the seeding rule', async () => { + expect(await shouldAutoRegisterProject(homedir(), env())).toBe(false); + const worktree = makeDir('host', '.ai', 'cezar', 'worktrees', 'abc12345'); + expect(await shouldAutoRegisterProject(worktree, env())).toBe(false); + }); + + it('exempts single-project mode, where the launch context is the project', async () => { + await registerProject(makeRepo('first')); + expect(await shouldAutoRegisterProject(makeRepo('served'), env(true))).toBe(true); + }); }); it('exposes the remote as a credential-free web root', async () => { diff --git a/packages/cezar/src/workspace/projects.ts b/packages/cezar/src/workspace/projects.ts index 89a3c73df..840ad0319 100644 --- a/packages/cezar/src/workspace/projects.ts +++ b/packages/cezar/src/workspace/projects.ts @@ -14,6 +14,9 @@ import { * Project registry operations over `~/.cezar/config.json` (spec * 2026-07-20-multi-project-workspace, "Project identity" + "Boot flow"): * + * - `shouldRegisterProject(root)` / `shouldAutoRegisterProject(root)` — the + * path-shape guard every registration passes, and the stricter boot-time + * guard that only seeds the registry while it is still empty. * - `registerProject(root)` — realpath-normalize, dedupe by realpath, allocate * a human-readable slug from `basename(root)`. Registration is additive and * goes through the read-modify-write merge, so the worst race outcome @@ -111,21 +114,63 @@ function isInsideTaskWorktree(path: string): boolean { * - the user's home directory itself (realpath-compared, so a symlinked * `$HOME` still matches). */ -export async function shouldRegisterProject(repoRoot: string): Promise { - const real = await normalizeRoot(repoRoot); - if (isInsideTaskWorktree(real) || isInsideTaskWorktree(resolve(repoRoot))) return false; +async function isRegistrableRoot(real: string, spelled: string): Promise { + if (isInsideTaskWorktree(real) || isInsideTaskWorktree(resolve(spelled))) return false; const home = await normalizeRoot(homedir()); return real !== home; } +export async function shouldRegisterProject(repoRoot: string): Promise { + return isRegistrableRoot(await normalizeRoot(repoRoot), repoRoot); +} + +/** + * The BOOT-time guard: `shouldRegisterProject` plus the "seed once" rule. + * Starting cezar inside a folder is only an implicit "this is my project" + * when the user has no projects yet — once the registry has any entry, the + * cwd is a place the cockpit is being *opened from*, not a project the user + * asked to add. Booting in an unregistered repo then serves it exactly as + * before; it just never lands in the sidebar behind the user's back. Adding + * a project stays an explicit gesture (`cezar projects add`, the cockpit's + * Add project dialog) — both go through `shouldRegisterProject` directly. + * + * An already-registered root still passes, so booting a known project keeps + * bumping its `lastOpenedAt` and keeps handing the server its registry id. + * + * Single-project mode is exempt: there the launch context IS the project + * (`cezar projects list` reads its identity back out of the registry), so + * suppressing the boot write would leave that deployment with no project at + * all. + */ +export async function shouldAutoRegisterProject( + repoRoot: string, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const real = await normalizeRoot(repoRoot); + if (!(await isRegistrableRoot(real, repoRoot))) return false; + if (env.CEZ_SINGLE_PROJECT === '1') return true; + const { projects } = await loadWorkspaceConfig(); + return projects.length === 0 || projects.some((project) => project.root === real); +} + /** * Register `root` in the workspace registry (idempotent). Known root (by * realpath) → bump its `lastOpenedAt` and return the existing entry, id and * all. Unknown → allocate a slug and append a new entry via merge-write. + * + * `reservedIds` keeps slugs the registry does not (yet) contain out of the + * allocator. A running server hands it the id its UNREGISTERED boot folder is + * being served under: that id is a live URL and the boot context answers it, + * so handing the same slug to a newly added `~/other/beta` would silently + * shadow the served folder. The registry file is the only cross-process truth, + * so this closes the collision for the server that knows about it, not for a + * concurrent `cezar projects add` — which is the same last-writer-wins window + * every registry write already lives with. */ export async function registerProject( root: string, source: 'local' | 'checkout' = 'local', + reservedIds: Iterable = [], ): Promise { const real = await normalizeRoot(root); const now = new Date().toISOString(); @@ -138,7 +183,7 @@ export async function registerProject( return; } entry = { - id: allocateProjectSlug(real, config.projects.map((p) => p.id)), + id: allocateProjectSlug(real, [...config.projects.map((p) => p.id), ...reservedIds]), root: real, name: basename(real), addedAt: now, @@ -192,6 +237,10 @@ export interface ProjectListEntry extends WorkspaceProject { * The sidebar gates each project group's GitHub tab on this, instead of on * the boot folder's health-level forge answer. */ forge?: ForgeKind; + /** Only ever set by `GET /api/v1/projects` on the synthetic entry for an + * unregistered boot folder (see the route). Nothing in this module writes + * it: a row that came out of the registry is registered by definition. */ + unregistered?: true; /** The remote's web root (`https://github.com/owner/repo`), rebuilt from the * parsed remote so it can never carry credentials. What lets a cross-project * surface link a reference the run knows only by NUMBER — the global Tasks diff --git a/packages/contract/src/projects.ts b/packages/contract/src/projects.ts index 6d75efa28..2b914ee3d 100644 --- a/packages/contract/src/projects.ts +++ b/packages/contract/src/projects.ts @@ -48,6 +48,12 @@ export const projectListEntrySchema = z.object({ /** Per-project cap on concurrently running tasks (spec 2026-07-22). Omitted = inherit the * workspace `resources.maxParallel`; a number pins this project. */ maxParallel: z.number().optional(), + /** Set ONLY on the synthetic entry for the folder this server was started in when that folder + * is not in the registry — the ordinary state since boot registration became seed-once. The + * server serves it like any project (it owns the boot context), so the cockpit must be able to + * reach it; but it is not persisted, so the rows that edit the registry (Remove, Max parallel) + * do not apply and Settings offers "Add project" instead. Never present on a registry entry. */ + unregistered: z.literal(true).optional(), /** * Free-form labels grouping CONNECTED repositories — a `storefront` tag on the API, the web * app and the design system says those three are one piece of work spread over three repos. diff --git a/packages/web/src/components/add-project-dialog.test.tsx b/packages/web/src/components/add-project-dialog.test.tsx index a9956e785..0927e1d65 100644 --- a/packages/web/src/components/add-project-dialog.test.tsx +++ b/packages/web/src/components/add-project-dialog.test.tsx @@ -183,6 +183,21 @@ describe('AddProjectDialog', () => { expect(document.querySelector('[data-slot="add-project-error"]')).toBeNull() }) + it('does not mark the folder cezar is serving but has not registered', async () => { + serve({ + browse: { '': json(PROJECTS) }, + // What `GET /api/v1/projects` answers when the boot folder is not in the registry: the row + // is there so the cockpit can reach it, flagged so nothing reads it as saved. Badging it + // "already added" here would contradict Settings, which offers to add the same folder. + projects: [project({ id: 'cezar', root: '/home/me/Projects/cezar', unregistered: true })], + }) + renderDialog() + await waitFor(() => expect(rows().getByText('cezar')).toBeTruthy()) + expect( + within(rows().getByText('cezar').closest('button') as HTMLElement).queryByText('already added'), + ).toBeNull() + }) + it('shows a register refusal verbatim and stays put', async () => { serve({ browse: { '': json(HOME) }, diff --git a/packages/web/src/components/add-project-dialog.tsx b/packages/web/src/components/add-project-dialog.tsx index 893c3851a..597ef5caf 100644 --- a/packages/web/src/components/add-project-dialog.tsx +++ b/packages/web/src/components/add-project-dialog.tsx @@ -48,7 +48,15 @@ export function AddProjectDialog({ // Registry roots are realpath'd server-side; a listed `path` may be a symlink's own spelling, // so this match is best-effort — it decorates a row, it never blocks one. (A duplicate is not // an error anyway: the 409 carries the existing entry and we navigate to it.) - const registered = new Set((projects.data?.projects ?? []).map((project) => project.root)) + // + // The `unregistered` row is filtered out: the list also carries the folder this server was + // started in when it is NOT registered, and badging that one "already added" would contradict + // Settings → Projects, which offers to add the very same folder. + const registered = new Set( + (projects.data?.projects ?? []) + .filter((project) => !project.unregistered) + .map((project) => project.root), + ) const enter = (dir: string) => { setPath(dir) diff --git a/packages/web/src/components/project-groups.test.tsx b/packages/web/src/components/project-groups.test.tsx index 4a5c8cec4..fb22d621a 100644 --- a/packages/web/src/components/project-groups.test.tsx +++ b/packages/web/src/components/project-groups.test.tsx @@ -371,6 +371,41 @@ describe('ProjectGroups', () => { expect(fetchMock.mock.calls.map((call) => String(call[0]))).not.toContain('/api/v1/p/gone/runs') }) + it('leads with the unregistered boot folder and marks it, without limiting what it can do', async () => { + serve({ '/api/v1/workspace/ui-state': {}, '/api/v1/p/scratch/runs': [] }) + renderGroups( + [ + // Saved projects have timestamps; the folder cezar was started in has none, + // so the plain lastOpenedAt sort would bury it at the bottom. + project(), + project({ + id: 'scratch', + name: 'scratch', + addedAt: '', + lastOpenedAt: '', + unregistered: true, + }), + ], + '/p/scratch/', + ) + + await waitFor(() => expect(group('scratch')).not.toBeNull()) + expect( + Array.from(document.querySelectorAll('[data-slot="project-group"]')).map((el) => + el.getAttribute('data-project'), + ), + ).toEqual(['scratch', 'cezar']) + expect(group('scratch').querySelector('[data-slot="project-unregistered"]')?.textContent).toBe( + 'not saved', + ) + // Flagged, not crippled: this is the project the user is working in, and its + // group expands and links exactly like a saved one. + expect(header('scratch').getAttribute('aria-expanded')).toBe('true') + expect( + within(group('scratch')).getByRole('navigation', { name: 'scratch navigation' }), + ).not.toBeNull() + }) + /** * A `/p/` prefix is the ONLY thing that makes a project the one you are standing in. On the * global pages — `/tasks` and `/settings/global` — there is no such prefix and no selected diff --git a/packages/web/src/components/project-groups.tsx b/packages/web/src/components/project-groups.tsx index 5ea358730..f3356f809 100644 --- a/packages/web/src/components/project-groups.tsx +++ b/packages/web/src/components/project-groups.tsx @@ -134,7 +134,17 @@ export function ProjectGroups({ // of whichever route last touched the registry — and shared with the ⌘K palette through // `lib/project-order.ts`, so the same registry is never listed two ways. const { order, canReorder, setOrder } = useProjectOrder() - const ordered = React.useMemo(() => orderProjects(projects, order), [projects, order]) + const ordered = React.useMemo(() => { + const placed = orderProjects(projects, order) + // An UNREGISTERED boot folder leads, ahead of BOTH rules above it: it has no `lastOpenedAt` + // to sort by (it was never written down, so the recency sort would bury it last) and no + // registry entry to be placed by hand — yet it is the folder the user just started cezar in, + // and burying it under the saved projects would repeat the disappearance this row exists to + // fix. A stable partition, so the result is still a permutation of the input. + const lead = placed.filter((project) => project.unregistered) + if (lead.length === 0) return placed + return [...lead, ...placed.filter((project) => !project.unregistered)] + }, [projects, order]) const orderedIds = React.useMemo(() => ordered.map((project) => project.id), [ordered]) // dnd-kit, configured exactly as the workflow builder's step list (`routes/workflows`): the @@ -348,7 +358,11 @@ function ProjectGroup({ // inert (there is nothing behind the chevron either), and a folder that is gone is one to // remove in Global settings → Projects, not one to arrange. It still moves when its neighbours // do, and its id is still persisted, so removing it later leaves no hole. - const canDrag = sortable && !missing + // + // An unregistered boot folder is excluded for a different reason: its lead position is a RULE, + // not a preference (see `ordered` above), so a drag of it could only snap straight back — and + // there is no registry entry for a hand-picked order to be about in the first place. + const canDrag = sortable && !missing && !project.unregistered const { attributes, listeners, @@ -492,6 +506,18 @@ function ProjectGroup({ aria-hidden="true" /> {project.name} + {project.unregistered ? ( + // Says what the row is without pretending it is a problem: cezar is serving this + // folder, it just is not in the saved list. Global settings → Projects has the + // one-click Add; repeating the button here would put a registry write in the nav. + + not saved + + ) : null} {waiting ? ( { it('normalizes a registered project URL including query and hash', () => { expect( @@ -83,6 +103,18 @@ describe('locationToSave', () => { it('waits for the project registry before saving', () => { expect(locationToSave({ pathname: '/p/boot/', search: '', hash: '' }, undefined)).toBeNull() }) + + // Since boot registration became seed-once, the folder cezar was started in is + // routinely absent from the registry — `GET /api/v1/projects` lists it with + // `unregistered: true` so it stays a usable project everywhere, this included. + // Without that row it could never be the restore target, and reopening the bare + // root would drop the user into some other project. + it('saves an unregistered boot project like any other', () => { + expect(locationToSave({ pathname: '/p/scratch/tasks', search: '', hash: '' }, UNREGISTERED_BOOT)).toEqual({ + projectId: 'scratch', + pathname: '/p/scratch/tasks', + }) + }) }) describe('locationToRestore', () => { diff --git a/packages/web/src/routes/settings/project-general.test.tsx b/packages/web/src/routes/settings/project-general.test.tsx index 88a0a1615..80651990d 100644 --- a/packages/web/src/routes/settings/project-general.test.tsx +++ b/packages/web/src/routes/settings/project-general.test.tsx @@ -195,7 +195,38 @@ describe('the General page', () => { const remove = await screen.findByRole('button', { name: /^Remove cezar from the workspace/ }) expect(remove.hasAttribute('disabled')).toBe(true) expect(document.querySelector('[data-slot="project-general-remove-boot"]')?.textContent).toContain( - 're-registers itself', + 'cezar is serving this project', + ) + }) + + it('offers to add — not manage — the folder cezar is serving but has not saved', async () => { + // The ordinary state since boot registration became seed-once: the server lists the folder + // it is serving so the cockpit can reach it, flagged because there is no registry row behind + // it. Max parallel is stored ON that row and Remove would 404, so neither is offered. + const unregistered: ProjectListEntry = { + id: 'boot', + name: 'cezar', + root: BOOT_ROOT, + addedAt: '', + lastOpenedAt: '', + source: 'local', + status: 'ok', + unregistered: true, + } + renderAt('/p/boot/settings', { registry: [unregistered, project('demo')] }) + + await waitFor(() => expect(general()).not.toBeNull()) + expect(document.querySelector('[data-slot="project-general-unregistered"]')?.textContent).toContain( + 'served because cezar was started here', + ) + expect(document.querySelector('[data-action="project-general-remove"]')).toBeNull() + expect(document.querySelector('[data-slot="project-max-parallel"]')).toBeNull() + + fireEvent.click(await screen.findByRole('button', { name: /^Add cezar to your projects/ })) + await waitFor(() => + expect(requests.filter((r) => r.method === 'POST')).toEqual([ + { method: 'POST', url: '/api/v1/projects', body: { root: BOOT_ROOT } }, + ]), ) }) diff --git a/packages/web/src/routes/settings/project-general.tsx b/packages/web/src/routes/settings/project-general.tsx index e52cf7701..7b9fce985 100644 --- a/packages/web/src/routes/settings/project-general.tsx +++ b/packages/web/src/routes/settings/project-general.tsx @@ -6,7 +6,7 @@ import type { Capabilities, ProjectListEntry } from '@open-mercato/cezar-api-cli import { Button } from '@/components/ui/button' import { useActiveProjectId } from '@/lib/project-router' import { ProjectFolderField } from './project-location' -import { MaxParallelSelect, STATUS_LABEL } from './projects-section' +import { AddBootProjectButton, MaxParallelSelect, STATUS_LABEL } from './projects-section' import { RemoveProjectDialog, useProjectRemoval } from './remove-project' import { SettingsField } from './settings-field' @@ -77,12 +77,23 @@ export function ProjectGeneral({ capabilities }: { capabilities?: Partial + {project.unregistered ? ( + + + + ) : null} {managesRegistry ? ( <> ) : null} -
Added
-
- {fullDate(project.addedAt)} - - {project.source === 'checkout' ? ' · cloned from GitHub' : ' · opened locally'} - -
+ {/* A folder cezar is serving but has never registered has no registry dates to read out + — inventing them (or dashing two rows) would say less than naming the state once. */} + {project.unregistered ? ( + <> +
In your projects
+
+ No — served because cezar was started here +
+ + ) : ( + <> +
Added
+
+ {fullDate(project.addedAt)} + + {project.source === 'checkout' ? ' · cloned from GitHub' : ' · opened locally'} + +
-
Last opened
-
{fullDate(project.lastOpenedAt)}
+
Last opened
+
{fullDate(project.lastOpenedAt)}
+ + )}
) @@ -164,9 +188,11 @@ function ProjectFacts({ project, canRemove }: { project: ProjectListEntry; canRe * Deregister this project — the registry table's per-row Remove, offered where the user already * is. Same hook, same dialog, same words (remove-project.tsx). * - * The boot project cannot be removed: cezar is serving it and re-registers it at every start, so - * the server 409s. Disabling here means the explanation arrives before the click rather than as - * an error toast after it. + * The boot project cannot be removed from the cockpit: this server is serving that folder, and + * dropping its registry row would break the session's own sidebar, so the server 409s. Disabling + * here means the explanation arrives before the click rather than as an error toast after it — + * and the offline gesture (`cezar projects remove`, which has no such refusal) is what the + * message points at. * * On success the URL this page lives at (`/p//settings`) has just stopped resolving, so the * navigation is part of the action, not a nicety. It targets the BOOT project explicitly rather @@ -183,7 +209,7 @@ function RemoveProject({ project, bootProject }: { project: ProjectListEntry; bo return (
{isBoot ? ( - cezar is serving this project — it re-registers itself at every start. + cezar is serving this project — stop cezar and run `cezar projects remove` to drop it. ) : null}
diff --git a/packages/web/src/routes/settings/projects-section.test.tsx b/packages/web/src/routes/settings/projects-section.test.tsx index 549bc5f6c..a5843a535 100644 --- a/packages/web/src/routes/settings/projects-section.test.tsx +++ b/packages/web/src/routes/settings/projects-section.test.tsx @@ -62,11 +62,28 @@ const PROJECTS: ProjectListEntry[] = [ }, ] +/** The folder cezar is serving without having saved it — `GET /api/v1/projects` leads the list + * with this since boot registration became seed-once. */ +const UNREGISTERED_BOOT: ProjectListEntry = { + id: 'scratch', + name: 'scratch', + root: '/home/piotr/tmp/scratch', + addedAt: '', + lastOpenedAt: '', + source: 'local', + status: 'ok', + unregistered: true, +} + type Answers = { /** What `PUT /api/v1/workspace/config` answers — a 400 stands in for the writability probe. */ putConfig?: { status: number; payload: unknown } /** What `DELETE /api/v1/projects/:id` answers. */ del?: { status: number; payload: unknown } + /** Serve the boot folder as an unregistered row (and make it `bootProject`). */ + unregisteredBoot?: boolean + /** What `POST /api/v1/projects` answers — the unregistered row's Add button. */ + post?: { status: number; payload: unknown } } function serve(answers: Answers = {}) { @@ -74,8 +91,10 @@ function serve(answers: Answers = {}) { failPatches = null const registry: ProjectsResponse = { // Copies, not the shared PROJECTS objects: the PATCH handler mutates entries. - projects: PROJECTS.map((p) => ({ ...p })), - bootProject: 'cezar', + projects: answers.unregisteredBoot + ? [UNREGISTERED_BOOT, ...PROJECTS.map((p) => ({ ...p }))] + : PROJECTS.map((p) => ({ ...p })), + bootProject: answers.unregisteredBoot ? UNREGISTERED_BOOT.id : 'cezar', projectsDir: '~/cezar/projects', } const config: WorkspaceConfigResponse = { @@ -109,6 +128,15 @@ function serve(answers: Answers = {}) { const body = init?.body ? (JSON.parse(String(init.body)) as Record) : undefined requests.push({ method, url, body }) if (url === '/api/v1/projects' && method === 'GET') return json(registry) + if (url === '/api/v1/projects' && method === 'POST') { + if (answers.post) return json(answers.post.payload, answers.post.status) + // What the real route does: the folder joins the registry, so the next + // read of this list has it as an ordinary row. + const added = { ...UNREGISTERED_BOOT, addedAt: '2026-08-01T09:00:00.000Z' } + delete added.unregistered + registry.projects = [added, ...registry.projects.filter((p) => !p.unregistered)] + return json({ project: added }) + } if (url === '/api/v1/workspace/config' && method === 'GET') return json(config) if (url === '/api/v1/workspace/config' && method === 'PUT') { if (answers.putConfig) return json(answers.putConfig.payload, answers.putConfig.status) @@ -154,19 +182,20 @@ function serve(answers: Answers = {}) { } /** Seeds the step-3.2 route gates so the (unscoped) global settings shell renders immediately. */ -function gateSeededClient() { +function gateSeededClient(unregisteredBoot = false) { const client = createQueryClient() - client.setQueryData(queryKeys.health, { bootProject: 'cezar' }) + const bootProject = unregisteredBoot ? UNREGISTERED_BOOT.id : 'cezar' + client.setQueryData(queryKeys.health, { bootProject }) client.setQueryData(workspaceQueryKeys.projects, { - projects: PROJECTS, - bootProject: 'cezar', + projects: unregisteredBoot ? [UNREGISTERED_BOOT, ...PROJECTS] : PROJECTS, + bootProject, projectsDir: '~/cezar/projects', }) return client } -function renderProjects() { - const client = gateSeededClient() +function renderProjects(unregisteredBoot = false) { + const client = gateSeededClient(unregisteredBoot) render( @@ -644,8 +673,50 @@ describe('Global settings → Projects', () => { serve() renderProjects() await waitFor(() => expect(rows()).toHaveLength(3)) - // The server refuses it too (it re-registers at every start); disabling explains it first. + // The server refuses it too (it is serving that repo); disabling explains it first. expect(removeButton('cezar')?.disabled).toBe(true) - expect(removeButton('cezar')?.title).toContain('re-registers') + expect(removeButton('cezar')?.title).toContain('is serving this project') + }) + + /** + * Starting cezar in a folder no longer registers it, so this pane is where that folder gets + * saved — and the only row whose registry edits (Remove, Max parallel) have nothing to act on. + */ + it('offers Add — not Remove or a cap — for the folder cezar is serving but has not saved', async () => { + serve({ unregisteredBoot: true }) + renderProjects(true) + await waitFor(() => expect(rows()).toHaveLength(4)) + + const scratch = row('scratch')! + expect(scratch.textContent).toContain('not registered') + expect(removeButton('scratch')).toBeNull() + expect(maxParallelSelect('scratch')).toBeNull() + + const add = scratch.querySelector('[data-action="project-add-boot"]')! + fireEvent.click(add) + + await waitFor(() => + expect(requests.filter((r) => r.method === 'POST')).toEqual([ + { method: 'POST', url: '/api/v1/projects', body: { root: '/home/piotr/tmp/scratch' } }, + ]), + ) + // Registered now: the row becomes an ordinary one, Remove and the cap included. + await waitFor(() => expect(removeButton('scratch')).not.toBeNull()) + expect(maxParallelSelect('scratch')).not.toBeNull() + expect(await screen.findByText(/scratch added to your projects/)).not.toBeNull() + }) + + it('surfaces the server’s refusal when the served folder is not addable', async () => { + // `$HOME` and cezar's own task worktrees are boot roots the register route refuses; the + // button does not pre-judge which folders qualify, it shows what the server said. + serve({ + unregisteredBoot: true, + post: { status: 400, payload: { error: 'not a project folder: ~ is your home directory or a cezar task worktree' } }, + }) + renderProjects(true) + await waitFor(() => expect(rows()).toHaveLength(4)) + + fireEvent.click(row('scratch')!.querySelector('[data-action="project-add-boot"]')!) + expect(await screen.findByText(/is your home directory or a cezar task worktree/)).not.toBeNull() }) }) diff --git a/packages/web/src/routes/settings/projects-section.tsx b/packages/web/src/routes/settings/projects-section.tsx index c8dd10d22..bb1456327 100644 --- a/packages/web/src/routes/settings/projects-section.tsx +++ b/packages/web/src/routes/settings/projects-section.tsx @@ -5,6 +5,7 @@ import { useMemo, useRef, useState } from 'react' import { putWorkspaceConfig } from '@/api/client' import { useProjects, + useRegisterProject, useUpdateProject, useWorkspaceConfig, workspaceQueryKeys, @@ -267,8 +268,12 @@ function RegistryTable({ return ( + {/* Defensive: `GET /api/v1/projects` always names at least the folder this server is + serving (as a registry row or as the unregistered one), so today this branch cannot + render. Kept because an empty table with headers and no rows would be a worse answer + than a sentence if that ever changes. */} {registry.projects.length === 0 ? (

No projects registered yet. @@ -276,7 +281,10 @@ function RegistryTable({ ) : (

- + {/* Not "registered": this table also renders the folder cezar is serving without + having saved it, whose only action is Add project. A screen-reader user was told + the list was registered projects and then met a row that is the opposite. */} + {/* Explicit widths rather than letting the browser distribute them by content: Tags is the one cell whose content GROWS with use, and auto-layout kept giving it whatever the fixed-size controls left over — which was not enough for one chip. */} @@ -354,41 +362,105 @@ function ProjectRow({ > {STATUS_LABEL[project.status]} - {project.status !== 'missing' ? ( + {project.unregistered ? ( + // Where `source` (how it got into the registry) would go — it is not in the registry, + // and this is the row's whole story, so it says that instead. + + · not registered + + ) : project.status !== 'missing' ? ( · {project.source} ) : null} + - ) } +/** + * The one gesture an unregistered boot row can offer: save the folder cezar is currently serving. + * It goes through the ordinary `POST /api/v1/projects` — same guards, same 409 on a folder + * already registered — so a root the server refuses (`$HOME`, a task worktree, which can also be + * boot roots) answers with its own explanation and this button surfaces it verbatim rather than + * pre-judging which folders qualify. + * + * Exported for the project's own General page, which faces the same row and must not invent a + * second way to say this — the same reason `MaxParallelSelect` and `STATUS_LABEL` are shared. + */ +export function AddBootProjectButton({ + root, + name, + disabled = false, +}: { + root: string + name: string + disabled?: boolean +}) { + const register = useRegisterProject() + return ( + + ) +} + /** * Per-project tags — the labels that group CONNECTED repositories. * diff --git a/packages/web/src/routes/settings/remove-project.tsx b/packages/web/src/routes/settings/remove-project.tsx index d6c9513e8..53b64a782 100644 --- a/packages/web/src/routes/settings/remove-project.tsx +++ b/packages/web/src/routes/settings/remove-project.tsx @@ -20,7 +20,9 @@ import { toast } from '@/components/ui/toaster' * so the wording is load-bearing and belongs in ONE file — the confirm title, the body, the path * it names and the success toast all insist on the same fact: this DEREGISTERS. * `DELETE /api/v1/projects/:id` is a registry filter and touches nothing under the root (see the - * route), and opening the folder again re-registers it with its tasks and worktrees intact. + * route), and adding the folder back later finds its tasks and worktrees intact. It does NOT come + * back on its own: boot registration is seed-once (`shouldAutoRegisterProject`), so serving the + * folder again never re-registers it. * * The refusals stay the server's: a project with running tasks and the boot project both answer * 409, whose message is toasted verbatim. Callers that can know about the boot project up front @@ -65,7 +67,7 @@ export function RemoveProjectDialog({ This only unregisters the project — nothing on disk is deleted. The folder, its git history and its task history all stay exactly where they are, and - opening it again re-registers it with everything intact. + adding it back later finds everything intact. {project?.root} diff --git a/packages/web/src/routes/unknown-project.tsx b/packages/web/src/routes/unknown-project.tsx index 593433777..504f1daf8 100644 --- a/packages/web/src/routes/unknown-project.tsx +++ b/packages/web/src/routes/unknown-project.tsx @@ -32,7 +32,12 @@ export function UnknownProjectRoute({ icon={} tone="neutral" title={`“${projectId}” isn’t registered here`} - subtitle="This cezar doesn’t serve a project by that id. The link may come from another machine’s workspace — these are the projects registered on this one:" + // "can open", not "registered on this one": the list below is the + // `GET /api/v1/projects` payload, which since seed-once leads with the + // folder cezar is serving WITHOUT having registered it. Offering that row + // under a sentence calling it registered contradicts the "· not registered" + // marker the same folder carries in Settings. + subtitle="This cezar doesn’t serve a project by that id. The link may come from another machine’s workspace — these are the projects this one can open:" >
    {projects.map((project) => (
Projects registered in this workspaceProjects in this workspace
- + {/* Every registry edit is meaningless for a folder that has no registry row: tags and + the per-project cap are stored ON the entry, and Remove would 404. The Add button in + the Actions cell is the only thing this row can honestly offer. */} + {project.unregistered ? ( + + ) : ( + + )} - + {project.unregistered ? ( + + ) : ( + + )} + + {project.unregistered ? '—' : shortDate(project.addedAt)} {shortDate(project.addedAt)} - + {project.unregistered ? ( + + ) : ( + + )}