From 2c1b4f110e5b588b999917b99651dc0dc63f0bd8 Mon Sep 17 00:00:00 2001 From: patzick <13100280+patzick@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:34:19 +0200 Subject: [PATCH 1/4] feat(workspace): register the boot folder only while the registry is empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Starting cezar in a folder is an implicit "this is my project" only for the very first run. Once the registry holds anything, booting somewhere else serves that folder as before but no longer appends it behind the user's back; adding stays an explicit gesture (`cezar projects add`, the cockpit's Add project dialog), both of which keep using the path-shape guard alone. `shouldAutoRegisterProject` composes `shouldRegisterProject` with the new seed-once rule. An already-registered root still passes, so booting a known project keeps bumping `lastOpenedAt` and keeps handing the server its registry id. `CEZ_SINGLE_PROJECT=1` is exempt — there the launch context IS the project and `cezar projects list` reads its identity back out of the registry. Follow-on copy: the 409 on removing the boot project no longer claims it "re-registers itself at every start" (now false); it points at `cezar projects remove` instead, with the Settings pane tooltip, the remove dialog, README and the spec's boot flow updated to match. --- .../2026-07-20-multi-project-workspace.md | 13 +++++- CHANGELOG.md | 6 +++ README.md | 14 +++++-- packages/cezar/src/index.ts | 13 +++--- .../cezar/src/server/projects-api.test.ts | 4 +- packages/cezar/src/server/server.ts | 12 +++--- packages/cezar/src/workspace/projects.test.ts | 42 +++++++++++++++++++ packages/cezar/src/workspace/projects.ts | 33 +++++++++++++++ .../routes/settings/projects-section.test.tsx | 4 +- .../src/routes/settings/projects-section.tsx | 6 +-- 10 files changed, 125 insertions(+), 22 deletions(-) diff --git a/.ai/specs/2026-07-20-multi-project-workspace.md b/.ai/specs/2026-07-20-multi-project-workspace.md index f296cf870..aa94de816 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,17 @@ 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, `/p//` binds to the boot context, only the sidebar +registry 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. + 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index bc7ce10a9..59e5cb09d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,12 @@ and sandbox CSP as before. ## 🔧 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. Adding a project stays an + explicit gesture: `cezar projects add ` or **Add project** in the cockpit, both unchanged. + `CEZ_SINGLE_PROJECT=1` deployments are exempt, since there the launch folder *is* the project. - Every mutating route is now visible to the typed client, `POST /api/v1/todos/:id/start` included. Its body used to be parsed inside the handler to keep "unknown id 404s before the body is validated"; a small existence guard registered *before* the body validator keeps that status diff --git a/README.md b/README.md index b0b88fbe0..fb87b6e3c 100644 --- a/README.md +++ b/README.md @@ -307,12 +307,18 @@ 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. Adding is an explicit gesture — the **+** button below, +or `cezar projects add`. + Every view is project-scoped: ``` @@ -336,8 +342,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/index.ts b/packages/cezar/src/index.ts index 41727e963..4ccbcff81 100644 --- a/packages/cezar/src/index.ts +++ b/packages/cezar/src/index.ts @@ -31,7 +31,7 @@ import { checkForUpdate } from './update-check.ts'; import { printSkillsBanner } from './skills-banner.ts'; import { loadWorkspaceConfig } from './workspace/config.ts'; import { runMigrations } from './workspace/migrations.ts'; -import { registerProject, shouldRegisterProject } from './workspace/projects.ts'; +import { registerProject, shouldAutoRegisterProject } from './workspace/projects.ts'; import { runProjectsCommand } from './workspace/projects-cli.ts'; import { WorkspaceSemaphore } from './workspace/semaphore.ts'; @@ -171,9 +171,12 @@ async function main(): Promise { /** * 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 + * 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. * @@ -186,7 +189,7 @@ async function main(): Promise { async function initWorkspace(repoRoot: string): Promise { try { await runMigrations({ bootRepoRoot: repoRoot }); - if (await shouldRegisterProject(repoRoot)) return (await registerProject(repoRoot)).id; + 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`); diff --git a/packages/cezar/src/server/projects-api.test.ts b/packages/cezar/src/server/projects-api.test.ts index 96f1babf9..658897201 100644 --- a/packages/cezar/src/server/projects-api.test.ts +++ b/packages/cezar/src/server/projects-api.test.ts @@ -540,12 +540,12 @@ describe('workspace projects API', () => { expect((await getProjects()).projects.map((p) => p.id)).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 bef9eb467..e8042646e 100644 --- a/packages/cezar/src/server/server.ts +++ b/packages/cezar/src/server/server.ts @@ -2394,14 +2394,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, ); diff --git a/packages/cezar/src/workspace/projects.test.ts b/packages/cezar/src/workspace/projects.test.ts index 211c68a98..de8c31100 100644 --- a/packages/cezar/src/workspace/projects.test.ts +++ b/packages/cezar/src/workspace/projects.test.ts @@ -10,6 +10,7 @@ import { listProjects, registerProject, removeProject, + shouldAutoRegisterProject, shouldRegisterProject, } from './projects.ts'; @@ -266,5 +267,46 @@ 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); + }); }); }); diff --git a/packages/cezar/src/workspace/projects.ts b/packages/cezar/src/workspace/projects.ts index 2202dfd43..eb0e53fc6 100644 --- a/packages/cezar/src/workspace/projects.ts +++ b/packages/cezar/src/workspace/projects.ts @@ -13,6 +13,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 @@ -117,6 +120,36 @@ export async function shouldRegisterProject(repoRoot: string): Promise return real !== home; } +/** + * 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 { + if (!(await shouldRegisterProject(repoRoot))) return false; + if (env.CEZ_SINGLE_PROJECT === '1') return true; + const { projects } = await loadWorkspaceConfig(); + if (projects.length === 0) return true; + const real = await normalizeRoot(repoRoot); + return 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 diff --git a/packages/web/src/routes/settings/projects-section.test.tsx b/packages/web/src/routes/settings/projects-section.test.tsx index 4a5ee7f51..6633111be 100644 --- a/packages/web/src/routes/settings/projects-section.test.tsx +++ b/packages/web/src/routes/settings/projects-section.test.tsx @@ -357,8 +357,8 @@ 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') }) }) diff --git a/packages/web/src/routes/settings/projects-section.tsx b/packages/web/src/routes/settings/projects-section.tsx index 073c658aa..c79b79b5e 100644 --- a/packages/web/src/routes/settings/projects-section.tsx +++ b/packages/web/src/routes/settings/projects-section.tsx @@ -308,7 +308,7 @@ function RegistryTable({ 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. {confirming?.root} @@ -375,9 +375,9 @@ function ProjectRow({ // Names the gesture precisely for a screen reader, where the row context that makes a // bare "Remove" safe-sounding isn't read out with it. aria-label={`Unregister ${project.name} (no files are deleted)`} - // The boot project is refused server-side too (it re-registers itself at every start); + // The boot project is refused server-side too (this server runs out of it); // disabling here means the user gets the explanation before the click, not after. - title={isBoot ? 'cezar is serving this project — it re-registers itself at every start' : undefined} + title={isBoot ? 'cezar is serving this project — stop it and use `cezar projects remove`' : undefined} disabled={disabled || isBoot} onClick={onRemove} > From 5708d103e47f15d7246a2ea042171bf0e97043f5 Mon Sep 17 00:00:00 2001 From: patzick <13100280+patzick@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:00:32 +0200 Subject: [PATCH 2/4] fix(workspace): make the unregistered boot folder reachable, and pin its slug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up. Seed-once turned "the boot root is not in the registry" from a rare edge case (task worktree, `$HOME`) into the ordinary state, and two properties built for the rare case did not survive the promotion. `GET /api/v1/projects` now leads with a synthetic `unregistered: true` entry for the boot folder when the registry does not hold it. The server serves that folder — the boot context answers `/p//…` and the unscoped alias — so leaving it out of the list made it unreachable: no sidebar row, no `lastLocation` (the cockpit only saves registry-known ids), and a repo chip naming a project the navigation could not open. The flag keeps registry edits off a row with no registry entry: Settings → Projects renders it as "not registered" with a one-click Add project instead of Remove and the per-project cap, and the sidebar marks it "not saved" while leaving it fully usable. It is also the honest answer when the workspace is unreadable, where an empty sidebar was the alternative. The boot slug is now sticky for the process and reserved against other registrations (`registerProject(root, source, reservedIds)`). 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 first, so adding the served folder adopts its real id. Docs: `BACKWARD_COMPATIBILITY.md`, `workspace/config.ts` and the spec no longer promise that the registry "rebuilds as projects are opened" — the recovery story is the `config.json.bak` snapshot plus `cezar projects add`, and that is what they now say. The `projects-cli` remove comment loses the same stale claim. `initWorkspace` moved to `workspace/boot.ts` so the boot decision itself is testable (`src/index.ts` runs `main()` on import). --- .../2026-07-20-multi-project-workspace.md | 30 +++- BACKWARD_COMPATIBILITY.md | 2 +- CHANGELOG.md | 11 +- README.md | 7 +- packages/cezar/src/index.ts | 34 +---- packages/cezar/src/server/checkout.test.ts | 14 +- .../cezar/src/server/projects-api.test.ts | 133 ++++++++++++++++-- packages/cezar/src/server/server.ts | 74 ++++++++-- packages/cezar/src/workspace/boot.test.ts | 85 +++++++++++ packages/cezar/src/workspace/boot.ts | 39 +++++ packages/cezar/src/workspace/config.ts | 16 ++- packages/cezar/src/workspace/projects-cli.ts | 10 +- packages/cezar/src/workspace/projects.test.ts | 10 ++ packages/cezar/src/workspace/projects.ts | 32 +++-- packages/contract/src/projects.ts | 6 + .../src/components/project-groups.test.tsx | 35 +++++ .../web/src/components/project-groups.tsx | 21 ++- packages/web/src/lib/last-location.test.ts | 32 +++++ .../routes/settings/projects-section.test.tsx | 87 ++++++++++-- .../src/routes/settings/projects-section.tsx | 98 ++++++++++--- 20 files changed, 658 insertions(+), 118 deletions(-) create mode 100644 packages/cezar/src/workspace/boot.test.ts create mode 100644 packages/cezar/src/workspace/boot.ts diff --git a/.ai/specs/2026-07-20-multi-project-workspace.md b/.ai/specs/2026-07-20-multi-project-workspace.md index aa94de816..2fdb9e507 100644 --- a/.ai/specs/2026-07-20-multi-project-workspace.md +++ b/.ai/specs/2026-07-20-multi-project-workspace.md @@ -191,10 +191,27 @@ 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, `/p//` binds to the boot context, only the sidebar -registry stays untouched. `CEZ_SINGLE_PROJECT=1` is exempt — there the launch +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 @@ -265,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) @@ -350,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. | @@ -523,7 +541,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 0f2009890..d5f65b440 100644 --- a/BACKWARD_COMPATIBILITY.md +++ b/BACKWARD_COMPATIBILITY.md @@ -148,7 +148,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}`). 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}`). 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`, `sidebar.collapsed`, and the optional bounded `lastLocation` route identity); 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 59e5cb09d..ea254f579 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,9 +49,14 @@ - **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. Adding a project stays an - explicit gesture: `cezar projects add ` or **Add project** in the cockpit, both unchanged. - `CEZ_SINGLE_PROJECT=1` deployments are exempt, since there the launch folder *is* the project. + 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 **Global settings → Projects** shows it as + *not registered* with a one-click **Add project** — the only row there 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. - Every mutating route is now visible to the typed client, `POST /api/v1/todos/:id/start` included. Its body used to be parsed inside the handler to keep "unknown id 404s before the body is validated"; a small existence guard registered *before* the body validator keeps that status diff --git a/README.md b/README.md index fb87b6e3c..017fc1988 100644 --- a/README.md +++ b/README.md @@ -316,8 +316,11 @@ the repo: per-project state stays exactly where it was, in that repo's **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. Adding is an explicit gesture — the **+** button below, -or `cezar projects add`. +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: diff --git a/packages/cezar/src/index.ts b/packages/cezar/src/index.ts index 4ccbcff81..6aeb0f8c3 100644 --- a/packages/cezar/src/index.ts +++ b/packages/cezar/src/index.ts @@ -29,9 +29,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, shouldAutoRegisterProject } from './workspace/projects.ts'; import { runProjectsCommand } from './workspace/projects-cli.ts'; import { WorkspaceSemaphore } from './workspace/semaphore.ts'; @@ -166,37 +165,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 — 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/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 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; -} - // ---- 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 658897201..ef3cb3231 100644 --- a/packages/cezar/src/server/projects-api.test.ts +++ b/packages/cezar/src/server/projects-api.test.ts @@ -14,7 +14,13 @@ import { basename, join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; 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'; @@ -104,6 +110,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); @@ -111,9 +131,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, [])); @@ -203,6 +223,97 @@ 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]); + }); + }); + describe('single-project management guards', () => { it('refuses checkout before clone or registry side effects', async () => { let cloneCalls = 0; @@ -317,7 +428,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 () => { @@ -331,14 +442,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 () => { @@ -356,7 +467,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)); @@ -379,7 +490,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 () => { @@ -398,7 +509,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 () => { @@ -415,7 +526,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([]); }); }); @@ -537,7 +648,7 @@ 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) — this server is serving it', async () => { diff --git a/packages/cezar/src/server/server.ts b/packages/cezar/src/server/server.ts index e8042646e..c99cfab2b 100644 --- a/packages/cezar/src/server/server.ts +++ b/packages/cezar/src/server/server.ts @@ -1121,12 +1121,25 @@ 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. let bootProjectCache = bootProjectId; + let bootProjectFallback: string | undefined; const resolveBootProject = async (projects?: readonly WorkspaceProject[]): Promise => { if (bootProjectCache) return bootProjectCache; let registry = projects ?? []; @@ -1138,7 +1151,9 @@ export function createApp(deps: ServerDeps) { } 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, @@ -2355,11 +2370,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); }) @@ -2636,9 +2679,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). 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 591080cc8..47b495e48 100644 --- a/packages/cezar/src/workspace/config.ts +++ b/packages/cezar/src/workspace/config.ts @@ -17,10 +17,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}$`. */ @@ -273,7 +277,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 8091e10eb..8a321e3fc 100644 --- a/packages/cezar/src/workspace/projects-cli.ts +++ b/packages/cezar/src/workspace/projects-cli.ts @@ -135,11 +135,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 de8c31100..6d6921bb1 100644 --- a/packages/cezar/src/workspace/projects.test.ts +++ b/packages/cezar/src/workspace/projects.test.ts @@ -104,6 +104,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'); diff --git a/packages/cezar/src/workspace/projects.ts b/packages/cezar/src/workspace/projects.ts index eb0e53fc6..5f0826e34 100644 --- a/packages/cezar/src/workspace/projects.ts +++ b/packages/cezar/src/workspace/projects.ts @@ -113,13 +113,16 @@ 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" @@ -142,22 +145,31 @@ export async function shouldAutoRegisterProject( repoRoot: string, env: NodeJS.ProcessEnv = process.env, ): Promise { - if (!(await shouldRegisterProject(repoRoot))) return false; + const real = await normalizeRoot(repoRoot); + if (!(await isRegistrableRoot(real, repoRoot))) return false; if (env.CEZ_SINGLE_PROJECT === '1') return true; const { projects } = await loadWorkspaceConfig(); - if (projects.length === 0) return true; - const real = await normalizeRoot(repoRoot); - return projects.some((project) => project.root === real); + 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(); @@ -170,7 +182,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, @@ -196,6 +208,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; } interface RootProbe { diff --git a/packages/contract/src/projects.ts b/packages/contract/src/projects.ts index c75e62ffc..4df8e0e1a 100644 --- a/packages/contract/src/projects.ts +++ b/packages/contract/src/projects.ts @@ -38,6 +38,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(), }); export type ProjectListEntry = z.infer; diff --git a/packages/web/src/components/project-groups.test.tsx b/packages/web/src/components/project-groups.test.tsx index 445387aae..b71bbabf7 100644 --- a/packages/web/src/components/project-groups.test.tsx +++ b/packages/web/src/components/project-groups.test.tsx @@ -293,4 +293,39 @@ describe('ProjectGroups', () => { expect(within(group('gone')).queryAllByRole('link')).toHaveLength(0) 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() + }) }) diff --git a/packages/web/src/components/project-groups.tsx b/packages/web/src/components/project-groups.tsx index cc2eeb857..ccd300bc0 100644 --- a/packages/web/src/components/project-groups.tsx +++ b/packages/web/src/components/project-groups.tsx @@ -158,8 +158,15 @@ export function ProjectGroups({ // Most-recently-opened first, per the spec. Sorted here rather than trusted from the wire so // the order is a property of the sidebar, not of whichever route last touched the registry. + // An UNREGISTERED boot folder leads: it has no `lastOpenedAt` to sort by (it was never + // written down), and it is the folder the user just started cezar in — burying it under the + // saved projects would repeat the disappearance this row exists to fix. const ordered = React.useMemo( - () => [...projects].sort((a, b) => b.lastOpenedAt.localeCompare(a.lastOpenedAt)), + () => + [...projects].sort((a, b) => { + if (Boolean(a.unregistered) !== Boolean(b.unregistered)) return a.unregistered ? -1 : 1 + return b.lastOpenedAt.localeCompare(a.lastOpenedAt) + }), [projects], ) @@ -288,6 +295,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/projects-section.test.tsx b/packages/web/src/routes/settings/projects-section.test.tsx index 6633111be..e0929a738 100644 --- a/packages/web/src/routes/settings/projects-section.test.tsx +++ b/packages/web/src/routes/settings/projects-section.test.tsx @@ -56,19 +56,38 @@ 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 = {}) { requests = [] 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 = { @@ -101,6 +120,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) @@ -130,19 +158,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( @@ -361,4 +390,46 @@ describe('Global settings → Projects', () => { expect(removeButton('cezar')?.disabled).toBe(true) 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 c79b79b5e..ccf712f2b 100644 --- a/packages/web/src/routes/settings/projects-section.tsx +++ b/packages/web/src/routes/settings/projects-section.tsx @@ -5,6 +5,7 @@ import { useState } from 'react' import { putWorkspaceConfig } from '@/api/client' import { useProjects, + useRegisterProject, useRemoveProject, useUpdateProject, useWorkspaceConfig, @@ -266,7 +267,7 @@ function RegistryTable({ return ( {registry.projects.length === 0 ? (

@@ -358,36 +359,93 @@ 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} - + {/* Both registry edits are meaningless for a folder that has no registry row: the + per-project cap is 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 ? '—' : shortDate(project.addedAt)} - {shortDate(project.addedAt)} - + {project.unregistered ? ( + + ) : ( + + )} ) } +/** + * The one gesture the 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. + */ +function AddBootProjectButton({ + root, + name, + disabled, +}: { + root: string + name: string + disabled: boolean +}) { + const register = useRegisterProject() + return ( + + ) +} + /** * Per-project "Max parallel tasks" selector (spec 2026-07-22). `Inherit * workspace (N)` is the unset default; `1..16` pins a per-project ceiling. From 7fc6ff876de72fee059245e1214eaba8dcd1f321 Mon Sep 17 00:00:00 2001 From: Patryk Tomczyk Date: Thu, 13 Aug 2026 10:36:12 +0200 Subject: [PATCH 3/4] fix(automations): keep scheduling the boot project the registry does not name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace scheduler refreshes `AutomationCoordinator` before every scheduling pass, and that refresh evicts every store whose id is not in `listProjects()` — the registry. The boot project's store is opened under `deps.bootProjectId ?? 'default'`, and `'default'` is never a registry id, so the sweep dropped it and `enabledProjectIds()` stopped naming the boot project. Nothing surfaced the loss: `createApp` holds the store handle directly, so the Automations pane kept listing the definitions as enabled while nothing polled GitHub for them. The hole predates this branch, but seed-once boot registration turns it from a task-worktree/`$HOME` corner into the ordinary case — after it, any boot outside a saved project leaves the boot project on the `'default'` alias. Pin that id against the sweep instead: registered or not, this process is serving that project, and the registry it is being compared against is precisely the list that cannot know about it. A genuinely stale handle is still evicted. Also documents the `/api/v1/health` half of the same seed-once consequence: `bootProject` is no longer guaranteed to appear in health's `projects[]`, so consumers must treat it as an id to address rather than a lookup key. Fixes #872 --- BACKWARD_COMPATIBILITY.md | 2 +- CHANGELOG.md | 7 +++ .../cezar/src/automations/coordinator.test.ts | 44 +++++++++++++++++++ packages/cezar/src/automations/coordinator.ts | 16 +++++++ packages/cezar/src/server/server.ts | 9 +++- 5 files changed, 76 insertions(+), 2 deletions(-) diff --git a/BACKWARD_COMPATIBILITY.md b/BACKWARD_COMPATIBILITY.md index 078617b10..65482d8ab 100644 --- a/BACKWARD_COMPATIBILITY.md +++ b/BACKWARD_COMPATIBILITY.md @@ -27,7 +27,7 @@ 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?, 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2923b9d04..2c3279a5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -145,6 +145,13 @@ `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. + Only affects deployments that opted into automations with `CEZ_AUTOMATIONS=1`. (#872) - 🐛 **Opening the cockpit on your phone no longer rearranges it on your desktop.** Which sidebar project groups are collapsed, and which page a bare `/` restores, were stored workspace-wide in `~/.cezar/ui-state.json` — so every open cockpit shared one answer: the last client to navigate diff --git a/packages/cezar/src/automations/coordinator.test.ts b/packages/cezar/src/automations/coordinator.test.ts index fa942ce2f..8e0908109 100644 --- a/packages/cezar/src/automations/coordinator.test.ts +++ b/packages/cezar/src/automations/coordinator.test.ts @@ -44,6 +44,50 @@ 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([]); + }); + 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..5eab5322c 100644 --- a/packages/cezar/src/automations/coordinator.ts +++ b/packages/cezar/src/automations/coordinator.ts @@ -11,6 +11,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; } /** @@ -32,7 +44,11 @@ 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)); + if (this.options.pinned !== undefined) present.add(this.options.pinned); for (const id of this.stores.keys()) { if (!present.has(id)) this.remove(id); } diff --git a/packages/cezar/src/server/server.ts b/packages/cezar/src/server/server.ts index 275dfec4f..b5df73a7d 100644 --- a/packages/cezar/src/server/server.ts +++ b/packages/cezar/src/server/server.ts @@ -5514,8 +5514,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, From 79f0b8274a2afcf566211b42350f6a59921ac05a Mon Sep 17 00:00:00 2001 From: Patryk Tomczyk Date: Sun, 16 Aug 2026 14:39:00 +0200 Subject: [PATCH 4/4] fix(automations): one automation store per folder, not per project id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AutomationCoordinator` keyed its stores by project id, and a folder can carry two ids at once: the `'default'` boot alias the server opens it under, and the registry slug it acquires when the user clicks the Add project button this branch adds to the unregistered row. `refresh()` then opened a SECOND `AutomationStore` over the same `.ai/cezar` — the pinned alias store survived the sweep, and the registry loop opened another for the slug. `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 the two instances diverged permanently on the first write. The cockpit writes through the alias handle (`/p//` resolves to the boot context), while the scheduler polled the slug's stale copy — a disabled or edited automation kept firing with its pre-edit definition until the process restarted, and cursors and backoff written by one store were reverted by the other. Key the store map by realpath'd ROOT instead, so two ids for one folder share one instance and the coordinator can no longer represent a state the filesystem cannot. `enabledProjectIds()` now returns one id per root so a doubly-addressed folder is scheduled once, preferring the pinned boot id — only that id routes the scheduler's `launch` to the boot manager and store; the slug would build a second ProjectContext over the boot root. Three regression tests, all verified to fail against the id-keyed version. Also retires two strings that still told the user everything listed is registered, where the list now leads with the folder cezar serves but has not saved: the projects table's screen-reader caption, and the unknown-project page's subtitle above a list built from `GET /api/v1/projects`. --- CHANGELOG.md | 7 +- .../cezar/src/automations/coordinator.test.ts | 78 +++++++++++++++++ packages/cezar/src/automations/coordinator.ts | 86 ++++++++++++++++--- .../src/routes/settings/projects-section.tsx | 5 +- packages/web/src/routes/unknown-project.tsx | 7 +- 5 files changed, 168 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24c0ce19d..08c5dac95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,12 @@ 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. - Only affects deployments that opted into automations with `CEZ_AUTOMATIONS=1`. (#872) + 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.0 (2026-08-14) diff --git a/packages/cezar/src/automations/coordinator.test.ts b/packages/cezar/src/automations/coordinator.test.ts index 8e0908109..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 { @@ -88,6 +89,83 @@ describe('AutomationCoordinator', () => { 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 5eab5322c..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; @@ -31,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) {} @@ -49,7 +79,7 @@ export class AutomationCoordinator { // compared against is exactly the list that does not know about it. const present = new Set(projects.map((project) => project.id)); if (this.options.pinned !== undefined) present.add(this.options.pinned); - for (const id of this.stores.keys()) { + for (const id of [...this.opened]) { if (!present.has(id)) this.remove(id); } for (const project of projects) { @@ -64,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/web/src/routes/settings/projects-section.tsx b/packages/web/src/routes/settings/projects-section.tsx index e7d6e9175..bb1456327 100644 --- a/packages/web/src/routes/settings/projects-section.tsx +++ b/packages/web/src/routes/settings/projects-section.tsx @@ -281,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. */} 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