From c10365053613081fd57fb49ce82d5dffb4eb3cff Mon Sep 17 00:00:00 2001
From: RedStar071
Date: Sat, 5 Sep 2026 17:32:56 +0000
Subject: [PATCH 01/15] docs: plan the work that makes the dashboard usable
Records what was verified by building and running the app on 2026-09-05: the
dashboard renders and the control plane executes runs, but nothing feeds it,
nothing refreshes it, and starting it needs a database.
The plan restructures the edge rather than the packages, in five phases with a
demonstrable exit criterion each.
Co-Authored-By: Claude Opus 5 (1M context)
---
docs/PLAN.md | 134 +++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 134 insertions(+)
create mode 100644 docs/PLAN.md
diff --git a/docs/PLAN.md b/docs/PLAN.md
new file mode 100644
index 0000000..4cdceb6
--- /dev/null
+++ b/docs/PLAN.md
@@ -0,0 +1,134 @@
+# Piano: dashboard funzionante per code-zero
+
+Riferimento: [wolfstar-agent-kit](https://github.com/wolfstar-project/wolfstar-agent-kit),
+pacchetto `packages/wolfstar-github-agent` (servizio locale + dashboard Nuxt).
+Stato verificato il 2026-09-05 su `main` (`8087c6d`).
+
+## Cosa funziona oggi (verificato, non letto)
+
+- `turbo run build --filter=@code-zero/dashboard` compila 13 pacchetti e produce `.output/`.
+- Con `AUTH_E2E_MEMORY=true` il bundle parte senza Postgres: `/login` 200, signup via
+ `/api/auth/sign-up/email`, `/` renderizza "Control Plane" con la sessione.
+- `POST /api/v1/tasks` con bearer token esegue un run in-process, lo salva nel KV `fs-lite`
+ (`.data/kv/tasks/*`) e `GET /api/v1/dashboard` lo restituisce con eventi e verdetto.
+
+## Cosa non funziona (perché la dashboard sembra "vuota")
+
+1. **Niente la alimenta.** I task nascono solo da un webhook GitHub (serve URL pubblico, secret,
+ `CODE_ZERO_CHECKOUT_PATH`) o da una chiamata API con token. La UI non ha un form per creare un
+ task né un pulsante per approvarne uno, anche se `tasks.create` e `approvals.decide` esistono
+ nel router. `zero run` da CLI non scrive nello stesso store, quindi i run locali non compaiono.
+2. **Niente si aggiorna da solo.** `index.vue` usa `useQuery` senza `refetchInterval`, niente SSE.
+ `tasks.create` blocca la risposta HTTP fino a fine run, quindi Queued e Running non si vedono mai.
+3. **Avvio difficile.** Per default servono Postgres, `NUXT_BETTER_AUTH_SECRET`, token, repo
+ allow-list. `aube run build --filter=...` salta turbo e fallisce su `@code-zero/auth/dist`
+ mancante: il comando giusto è `aube exec turbo run build --filter=...`. `aube` non è su npm,
+ solo via mise o GitHub release.
+4. **Sidebar con 9 voci inerti** (tasks, runners, models, approvals, findings, repositories,
+ policies, integrations, settings). Solo `/` e `/audit` esistono.
+5. **Nessun contratto di design.** Il kit lavora con `DESIGN.md` e la skill `nuxt-frontend-review`
+ che avvia la pagina e la confronta col contratto. Qui non c'è nulla da confrontare.
+
+## Decisione: ristrutturare, non riscrivere
+
+I pacchetti (`agent`, `runner`, `api`, `source-control`, `models`, `config`) sono solidi, testati e
+indipendenti dall'HTTP. Riscriverli è lavoro senza guadagno. Si rifà il **bordo**: come i task
+entrano, come lo stato esce, come si avvia in dev. Dal riferimento si prendono quattro idee:
+
+| Idea del riferimento | Dove finisce in code-zero |
+| ----------------------------------------------------- | --------------------------------------------- |
+| Uno snapshot server-side spinto via SSE a ogni cambio | `server/api/events.get.ts` + store che emette |
+| Il servizio trova lavoro da solo (poll dei repo) | Nitro plugin `server/plugins/poller.ts` |
+| Il task torna subito Queued, il run continua in coda | `tasks.create` ritorna dopo `store.save` |
+| `DESIGN.md` + review nel browser prima del merge | `apps/dashboard/DESIGN.md` + skill del kit |
+
+Non si prende: monorepo separato, Nuxt UI (qui c'è UnoCSS con tema già fatto), mock server di
+dev, tre provider agent, tray, routine.
+
+## Fasi
+
+Ogni fase chiude quando `aube run lint:ci && aube run typecheck && aube test && aube run build`
+passano e il criterio "fatto quando" è dimostrato in browser o con `curl`.
+
+### Fase 0: avvio in un comando (mezza giornata)
+
+- `apps/dashboard`: script `dev:solo` = `nuxt dev` con `AUTH_E2E_MEMORY=true`,
+ `AUTH_ENABLE_SIGNUP=true`, token `dev:dev`, modes `dev:observe|suggest|fix`, repo allow-list
+ dalla env `CODE_ZERO_REPOSITORIES`. Nessun Postgres.
+- README: sezione "Primo avvio" con i tre comandi (install, `aube exec turbo run build`, `dev:solo`).
+- `bin/check` copiato dal kit, più hook `pre-commit-push` e `oxlint` on save in `.claude/`.
+
+Fatto quando: da clone pulito, `mise install && aube install && aube run dev:solo` apre la
+dashboard e il signup funziona.
+
+### Fase 1: stato vivo (1 giorno)
+
+- `TaskStore.save` emette su un `EventEmitter` di processo (`server/utils/store.ts`, 10 righe).
+- `server/api/events.get.ts`: SSE con `createEventStream` di h3, push dell'overview a ogni
+ evento, heartbeat 15 s.
+- `app/composables/useLiveOverview.ts`: `EventSource` nativo, a ogni messaggio
+ `queryClient.invalidateQueries` sull'overview. Riconnessione a 1.5 s. Badge "stale" se l'ultimo
+ messaggio è più vecchio di 30 s (come `isSnapshotStale` del riferimento).
+- `operations.createTask` ritorna il record Queued dopo il primo `store.save`; il run prosegue nello
+ scheduler. Il webhook fa lo stesso: risponde `accepted` con l'id senza aspettare.
+
+Fatto quando: un `curl` che crea un task fa comparire la riga Queued, poi Running con gli eventi
+che scorrono nella Timeline, poi Completed, senza premere Refresh.
+
+### Fase 2: la UI fa le cose che il router già sa fare (1 giorno)
+
+- Inspector: pulsanti Approve e Reject su `needs-human` (`approvals.decide`), con commento.
+- Header: "New task" con form repository (select dall'allow-list), mode, trigger. Chiama
+ `tasks.create`. In `observe` non serve alcuna chiave modello, quindi funziona anche in `dev:solo`.
+- Sidebar: eliminare le 9 voci senza pagina. Restano Control Plane e Audit Log.
+- `DESIGN.md` scritto dai token già in `uno.theme.ts` e `main.css`. Poche regole, ognuna deve poter
+ bocciare un cambiamento.
+
+Fatto quando: la skill `nuxt-frontend-review` gira `dev:solo`, esercita approve, reject e new task
+a 1440 e 375, light e dark, e non trova rifiuti duri. Screenshot nella PR.
+
+### Fase 3: il servizio trova lavoro da solo (2 giorni)
+
+- `server/plugins/poller.ts`: ogni `CODE_ZERO_POLL_INTERVAL_SECONDS` (default 60) legge le PR
+ aperte dei repo configurati con l'adapter GitHub di `packages/source-control`, e per ogni head
+ SHA non ancora visto crea un task `proactive` in `observe` (o nel mode di policy del repo).
+- Mappa repo → checkout locale in `CODE_ZERO_REPOSITORIES` (`owner/name=/path`), come i
+ `trustedCheckoutRoots` del riferimento. Un task per SHA, dedup nello store.
+- Worktree per task (`git worktree add` in una cartella temporanea, rimossa a fine run) così due
+ run sullo stesso repo non si pestano. Il runner già limita cosa può eseguire.
+- Il plugin non parte in `dev:solo` senza repo configurati, e si ferma su `nitroApp.hooks.hook('close')`.
+
+Fatto quando: con un repo reale configurato, un push su una PR fa comparire un task entro un
+ciclo di poll senza webhook, e due PR sullo stesso repo girano in worktree distinti.
+
+### Fase 4: la CLI scrive dove legge la dashboard (mezza giornata)
+
+- `zero run` con `CODE_ZERO_URL` e sessione da `zero login` chiama `tasks.create` invece di
+ eseguire in locale, e stampa l'id e il link alla dashboard. Senza URL resta il comportamento attuale.
+
+Fatto quando: `zero run --proactive` da terminale compare nella Board entro un secondo.
+
+### Fase 5: pulizia (mezza giornata)
+
+- `.env.example` del dashboard riordinato: prima i 5 valori per `dev:solo`, poi il resto.
+- `docs/architecture.md`: sezione "Live state" che descrive SSE e poller.
+- Test: uno per l'emitter dello store, uno per `events.get`, uno Playwright per approve.
+
+## Rimandato, e quando
+
+| Cosa | Quando |
+| -------------------------------------- | ------------------------------------------------------------ |
+| Pagine Runners, Models, Findings, ecc. | quando lo store ha dati che quelle pagine mostrerebbero |
+| Nuxt UI al posto di UnoCSS | mai, salvo richiesta: il tema esiste e passa i test |
+| Provider multipli nella stessa istanza | già supportato via policy; nessuna UI finché non serve |
+| Postgres per i task al posto del KV | quando due istanze devono condividere lo stesso store |
+| Riscrittura completa da zero | se le fasi 1-3 mostrano che `packages/api` non regge la coda |
+
+## Rischi
+
+- **Run lunghi dentro `nuxt dev`**: HMR riavvia Nitro e uccide il run. Mitigazione: `dev:solo` in
+ `observe`, run veri solo su `.output/` o con `nuxt dev --no-fork`.
+- **KV `fs-lite` senza scrittura atomica**: `list()` legge tutte le chiavi a ogni overview. Va bene
+ fino a qualche migliaio di task; poi Postgres (già in repo per l'auth).
+- **`tasks.create` che non attende più** cambia il contratto REST: chi lo usa in CI deve fare poll
+ su `tasks.get`. Documentare nel changelog, versione 0.5.
From f61351dcc1fe38c0b46d630d41efcec0c6fdb0cc Mon Sep 17 00:00:00 2001
From: RedStar071
Date: Sat, 5 Sep 2026 17:33:04 +0000
Subject: [PATCH 02/15] feat(dashboard): start the dashboard alone with
dev:solo
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Seeing the dashboard required Postgres, a migration, a signing secret, and
control-plane tokens before the first page could render, so the quickest way to
look at it was not to.
`dev:solo` is `nuxt dev --dotenv .env.solo`: the same app, the same router, and
the same `/api/auth/**` endpoints, with Better Auth on the in-memory store the
Playwright preview server already uses. `.env.solo` is checked in because it
holds nothing worth keeping out of the repository — the session store dies with
the process, and the control-plane token is only accepted by a server started
this way. It is loaded only when a command names it with `--dotenv`, so `.env`
and every deployment are untouched.
Verified: `turbo run dev:solo --filter=@code-zero/dashboard` from a checkout with
no database — `/api/v1/health` 200, `/login` 200, `POST /api/auth/sign-up/email`
returns a session, and `/` renders Control Plane with that cookie. check:repo,
format:check, and typecheck pass.
Co-Authored-By: Claude Opus 5 (1M context)
---
.gitignore | 3 +++
AGENTS.md | 1 +
README.md | 17 ++++++++++++++++
apps/dashboard/.env.solo | 29 ++++++++++++++++++++++++++++
apps/dashboard/package.json | 1 +
apps/dashboard/server/auth.config.ts | 13 ++++++++-----
package.json | 1 +
turbo.jsonc | 8 ++++++++
8 files changed, 68 insertions(+), 5 deletions(-)
create mode 100644 apps/dashboard/.env.solo
diff --git a/.gitignore b/.gitignore
index 7305574..518001c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,9 @@ test-report.junit.xml
.env
.env.*
!.env.example
+# Checked in like the examples above: it configures `dev:solo`, whose whole point is that it holds
+# nothing worth keeping out of the repository.
+!.env.solo
.code-zero/
.data/
*.log
diff --git a/AGENTS.md b/AGENTS.md
index 2b313b5..32ec3f3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -85,6 +85,7 @@ cp apps/dashboard/.env.example apps/dashboard/.env
```bash
aube run dev # watch workspace development tasks
+aube run dev:solo # dashboard alone, no database (apps/dashboard/.env.solo)
aube run zero doctor # inspect the local environment
aube test # deterministic Vitest suites
aube run test:browser # dashboard and marketing browser suites
diff --git a/README.md b/README.md
index 7beeb74..931921b 100644
--- a/README.md
+++ b/README.md
@@ -103,6 +103,23 @@ aube run dev
The root `.env` configures the CLI. Each app loads its own file: the dashboard uses
`apps/dashboard/.env`, while the docs app optionally uses `apps/docs/.env` for `NUXT_APP_BASE_URL`.
+To see the dashboard before configuring anything, start it on its own instead:
+
+```bash
+mise install
+aube ci
+aube run dev:solo # http://localhost:3000, then sign up at /signup
+```
+
+`dev:solo` is `nuxt dev` reading [`apps/dashboard/.env.solo`](./apps/dashboard/.env.solo) in place of
+`.env`: Better Auth runs on an in-memory store, so there is no Postgres to install and no migration
+to apply, and the account you create lives until you stop the process. Nothing else about the app
+changes — it is the same UI, the same router, and the same authentication endpoints a deployment
+serves. Tasks still need a checkout to target, so add one to
+`CODE_ZERO_CONTROL_PLANE_REPOSITORIES` in that file; `observe` runs no model, so a task can be
+created and inspected without a provider credential. Use `aube run dev` and `apps/dashboard/.env`
+for anything that has to persist.
+
`aube run
diff --git a/apps/dashboard/modules/dashboard/components/task/Inspector.vue b/apps/dashboard/modules/dashboard/components/task/Inspector.vue
index 79d4822..e054e5e 100644
--- a/apps/dashboard/modules/dashboard/components/task/Inspector.vue
+++ b/apps/dashboard/modules/dashboard/components/task/Inspector.vue
@@ -66,6 +66,62 @@
@@ -85,7 +141,34 @@
diff --git a/apps/dashboard/modules/dashboard/types/dashboard.ts b/apps/dashboard/modules/dashboard/types/dashboard.ts
index 52456c2..5b159ba 100644
--- a/apps/dashboard/modules/dashboard/types/dashboard.ts
+++ b/apps/dashboard/modules/dashboard/types/dashboard.ts
@@ -15,6 +15,14 @@ interface DashboardTaskResult {
};
}
+/** A recorded human decision on a task that stopped for one. Absent while it is still waiting. */
+export interface DashboardTaskApproval {
+ decision: 'approved' | 'rejected';
+ actor: string;
+ comment: string | null;
+ decidedAt: string;
+}
+
export interface DashboardTask {
id: string;
repository: string;
@@ -23,6 +31,7 @@ export interface DashboardTask {
updatedAt: string;
events: DashboardTaskEvent[];
result?: DashboardTaskResult;
+ approval?: DashboardTaskApproval;
}
export interface DashboardOverview {
diff --git a/apps/dashboard/modules/shared/components/app/Sidebar.vue b/apps/dashboard/modules/shared/components/app/Sidebar.vue
index fca8faa..d66bc88 100644
--- a/apps/dashboard/modules/shared/components/app/Sidebar.vue
+++ b/apps/dashboard/modules/shared/components/app/Sidebar.vue
@@ -19,8 +19,7 @@
@@ -114,29 +111,23 @@ interface NavItem {
key: string;
labelKey: string;
icon: string;
- /** Absent for the sections that have no page yet; those stay inert buttons. */
- to?: string;
+ to: string;
}
/**
- * Active state is derived from the current route rather than declared per entry, so a placeholder
- * cannot claim to be the current page and a real entry cannot disagree with the address bar.
+ * Every entry is a page that exists. The nav used to carry nine more as inert buttons, which
+ * promised surfaces the app does not have — an operator clicking Runners learned only that the
+ * click did nothing. A section earns an entry when it has somewhere to go.
+ *
+ * Active state is derived from the current route rather than declared per entry, so an entry
+ * cannot disagree with the address bar.
*/
const navItems: readonly NavItem[] = [
{ key: 'control', labelKey: 'dashboard.nav.control', icon: 'lucide:layout-dashboard', to: '/' },
- { key: 'tasks', labelKey: 'dashboard.nav.tasks', icon: 'lucide:list-checks' },
- { key: 'runners', labelKey: 'dashboard.nav.runners', icon: 'lucide:server' },
- { key: 'models', labelKey: 'dashboard.nav.models', icon: 'lucide:cpu' },
- { key: 'approvals', labelKey: 'dashboard.nav.approvals', icon: 'lucide:badge-check' },
- { key: 'findings', labelKey: 'dashboard.nav.findings', icon: 'lucide:shield-alert' },
- { key: 'repositories', labelKey: 'dashboard.nav.repositories', icon: 'lucide:folder-git-2' },
- { key: 'policies', labelKey: 'dashboard.nav.policies', icon: 'lucide:scale' },
- { key: 'integrations', labelKey: 'dashboard.nav.integrations', icon: 'lucide:plug' },
{ key: 'audit', labelKey: 'dashboard.nav.audit', icon: 'lucide:scroll-text', to: '/audit' },
- { key: 'settings', labelKey: 'dashboard.nav.settings', icon: 'lucide:settings' },
];
function isActive(item: NavItem): boolean {
- return item.to !== undefined && route.path === item.to;
+ return route.path === item.to;
}
diff --git a/apps/dashboard/test/nuxt/components/NewTaskForm.spec.ts b/apps/dashboard/test/nuxt/components/NewTaskForm.spec.ts
new file mode 100644
index 0000000..013dae8
--- /dev/null
+++ b/apps/dashboard/test/nuxt/components/NewTaskForm.spec.ts
@@ -0,0 +1,64 @@
+import { mountSuspended } from '@nuxt/test-utils/runtime';
+import { describe, expect, it } from 'vitest';
+import NewTaskForm from '~~/modules/dashboard/components/NewTaskForm.vue';
+
+describe('NewTaskForm', () => {
+ it('submits the proactive shape, which carries no feedback', async () => {
+ const wrapper = await mountSuspended(NewTaskForm);
+
+ await wrapper.find('input[type="text"]').setValue(' /srv/checkouts/acme-app ');
+ await wrapper.find('form').trigger('submit');
+
+ // `taskInput` rejects a feedback trigger without feedback and ignores it otherwise, so the
+ // field is omitted rather than sent empty.
+ expect(wrapper.emitted('submit')).toEqual([
+ [{ repository: '/srv/checkouts/acme-app', mode: 'observe', trigger: 'proactive' }],
+ ]);
+ });
+
+ it('asks for feedback only when the trigger is feedback, and sends it', async () => {
+ const wrapper = await mountSuspended(NewTaskForm);
+
+ expect(wrapper.find('textarea').exists()).toBe(false);
+
+ const selects = wrapper.findAll('select');
+ await selects[1]?.setValue('feedback');
+ await wrapper.find('input[type="text"]').setValue('/srv/checkouts/acme-app');
+ await wrapper.find('textarea').setValue('Possible null dereference in src/user.ts');
+ await wrapper.find('form').trigger('submit');
+
+ expect(wrapper.emitted('submit')).toEqual([
+ [
+ {
+ repository: '/srv/checkouts/acme-app',
+ mode: 'observe',
+ trigger: 'feedback',
+ feedback: 'Possible null dereference in src/user.ts',
+ },
+ ],
+ ]);
+ });
+
+ it('defaults to the mode that cannot write to a checkout', async () => {
+ const wrapper = await mountSuspended(NewTaskForm);
+
+ expect(wrapper.findAll('select')[0]?.element.value).toBe('observe');
+ });
+
+ it('submits nothing more while one request is in flight', async () => {
+ const wrapper = await mountSuspended(NewTaskForm, { props: { pending: true } });
+
+ await wrapper.find('input[type="text"]').setValue('/srv/checkouts/acme-app');
+ await wrapper.find('form').trigger('submit');
+
+ expect(wrapper.emitted('submit')).toBeUndefined();
+ });
+
+ it('renders the failure the page reports', async () => {
+ const wrapper = await mountSuspended(NewTaskForm, {
+ props: { error: 'The task was not created.' },
+ });
+
+ expect(wrapper.text()).toContain('The task was not created.');
+ });
+});
diff --git a/apps/dashboard/test/nuxt/components/TaskInspector.spec.ts b/apps/dashboard/test/nuxt/components/TaskInspector.spec.ts
new file mode 100644
index 0000000..8d7f797
--- /dev/null
+++ b/apps/dashboard/test/nuxt/components/TaskInspector.spec.ts
@@ -0,0 +1,86 @@
+import { mountSuspended } from '@nuxt/test-utils/runtime';
+import { describe, expect, it } from 'vitest';
+import TaskInspector from '~~/modules/dashboard/components/task/Inspector.vue';
+import type { DashboardTask } from '~~/modules/dashboard/types/dashboard';
+
+const AWAITING: DashboardTask = {
+ id: 'cz_alpha_0001',
+ repository: 'acme/checkout',
+ status: 'needs-human',
+ createdAt: '2026-08-09T09:00:00.000Z',
+ updatedAt: '2026-08-09T10:00:00.000Z',
+ events: [],
+};
+
+const DECIDED: DashboardTask = {
+ ...AWAITING,
+ approval: {
+ decision: 'approved',
+ actor: 'ops@example.test',
+ comment: 'Checked the diff.',
+ decidedAt: '2026-08-09T11:00:00.000Z',
+ },
+};
+
+describe('TaskInspector approvals', () => {
+ it('offers a decision only while the run is waiting for one', async () => {
+ const wrapper = await mountSuspended(TaskInspector, { props: { task: AWAITING } });
+
+ expect(wrapper.find('form').exists()).toBe(true);
+ expect(wrapper.find('button[type="submit"]').exists()).toBe(true);
+ });
+
+ it('emits the approval with the comment that was typed', async () => {
+ const wrapper = await mountSuspended(TaskInspector, { props: { task: AWAITING } });
+
+ await wrapper.find('textarea').setValue(' Verified against the checks. ');
+ await wrapper.find('form').trigger('submit');
+
+ expect(wrapper.emitted('decide')).toEqual([
+ [{ decision: 'approved', comment: 'Verified against the checks.' }],
+ ]);
+ });
+
+ it('emits a rejection from the second control, not a second form', async () => {
+ const wrapper = await mountSuspended(TaskInspector, { props: { task: AWAITING } });
+
+ await wrapper.find('button[type="button"]').trigger('click');
+
+ expect(wrapper.emitted('decide')).toEqual([[{ decision: 'rejected', comment: '' }]]);
+ });
+
+ it('shows the recorded decision instead of the form once one exists', async () => {
+ const wrapper = await mountSuspended(TaskInspector, { props: { task: DECIDED } });
+
+ // A second decision is not a thing the control plane accepts, so offering one would be a
+ // button whose only outcome is an error.
+ expect(wrapper.find('form').exists()).toBe(false);
+ expect(wrapper.text()).toContain('ops@example.test');
+ expect(wrapper.text()).toContain('Checked the diff.');
+ });
+
+ it('offers nothing for a run that never stopped for a person', async () => {
+ const wrapper = await mountSuspended(TaskInspector, {
+ props: { task: { ...AWAITING, status: 'completed' } },
+ });
+
+ expect(wrapper.find('form').exists()).toBe(false);
+ });
+
+ it('disables the controls while a decision is in flight', async () => {
+ const wrapper = await mountSuspended(TaskInspector, {
+ props: { task: AWAITING, pending: true },
+ });
+
+ expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBeDefined();
+ expect(wrapper.find('textarea').attributes('disabled')).toBeDefined();
+ });
+
+ it('renders the failure the page reports, so a lost decision is not silent', async () => {
+ const wrapper = await mountSuspended(TaskInspector, {
+ props: { task: AWAITING, error: 'The decision was not recorded. Nothing changed.' },
+ });
+
+ expect(wrapper.text()).toContain('The decision was not recorded.');
+ });
+});
diff --git a/packages/i18n/locales/en/dashboard.json b/packages/i18n/locales/en/dashboard.json
index 2be3628..4cd942b 100644
--- a/packages/i18n/locales/en/dashboard.json
+++ b/packages/i18n/locales/en/dashboard.json
@@ -13,16 +13,7 @@
"nav": {
"aria": "Primary",
"control": "Control Plane",
- "tasks": "Tasks",
- "runners": "Runner Pool",
- "models": "Models & Usage",
- "approvals": "Approvals",
- "findings": "Findings",
- "repositories": "Repositories",
- "policies": "Rules & Policies",
- "integrations": "Integrations",
"audit": "Audit Log",
- "settings": "Settings",
"collapse": "Collapse",
"expand": "Expand"
},
@@ -68,7 +59,18 @@
"no": "NO",
"summary": "Run summary",
"emptyTitle": "No task selected",
- "emptyBody": "Select a queue record to inspect evidence and usage."
+ "emptyBody": "Select a queue record to inspect evidence and usage.",
+ "approval": {
+ "title": "Waiting on you",
+ "body": "This run stopped for a human decision. Approving records who decided; it does not restart the run.",
+ "comment": "Comment (optional)",
+ "approve": "Approve",
+ "reject": "Reject",
+ "decided": "Decision",
+ "approved": "Approved",
+ "rejected": "Rejected",
+ "failed": "The decision was not recorded. Nothing changed."
+ }
},
"audit": {
"header": {
@@ -124,6 +126,27 @@
"next": "Select the next task",
"previous": "Select the previous task",
"more": "Load older audit entries"
+ },
+ "newTask": {
+ "title": "New task",
+ "repository": "Repository checkout path",
+ "repositoryHint": "/srv/checkouts/acme-app",
+ "mode": "Mode",
+ "trigger": "Trigger",
+ "feedback": "Review feedback",
+ "submit": "Queue task",
+ "note": "The run starts once the control plane has capacity.",
+ "failed": "The task was not created. Check the path, the mode, and what your credentials allow.",
+ "modes": {
+ "observe": "Observe — inspect and report",
+ "suggest": "Suggest — propose a change",
+ "fix": "Fix — apply a change",
+ "autonomous": "Autonomous"
+ },
+ "triggers": {
+ "proactive": "Proactive diff review",
+ "feedback": "Review feedback"
+ }
}
}
}
diff --git a/packages/i18n/locales/it/dashboard.json b/packages/i18n/locales/it/dashboard.json
index 2181b72..8ddf621 100644
--- a/packages/i18n/locales/it/dashboard.json
+++ b/packages/i18n/locales/it/dashboard.json
@@ -13,16 +13,7 @@
"nav": {
"aria": "Primaria",
"control": "Piano di controllo",
- "tasks": "Task",
- "runners": "Pool runner",
- "models": "Modelli e utilizzo",
- "approvals": "Approvazioni",
- "findings": "Rilevazioni",
- "repositories": "Repository",
- "policies": "Regole e criteri",
- "integrations": "Integrazioni",
"audit": "Registro di audit",
- "settings": "Impostazioni",
"collapse": "Comprimi",
"expand": "Espandi"
},
@@ -68,7 +59,18 @@
"no": "NO",
"summary": "Riepilogo esecuzione",
"emptyTitle": "Nessun task selezionato",
- "emptyBody": "Seleziona un record della coda per ispezionare evidenze e utilizzo."
+ "emptyBody": "Seleziona un record della coda per ispezionare evidenze e utilizzo.",
+ "approval": {
+ "title": "In attesa di te",
+ "body": "Questa esecuzione si è fermata per una decisione umana. Approvare registra chi ha deciso; non riavvia l'esecuzione.",
+ "comment": "Commento (facoltativo)",
+ "approve": "Approva",
+ "reject": "Rifiuta",
+ "decided": "Decisione",
+ "approved": "Approvata",
+ "rejected": "Rifiutata",
+ "failed": "La decisione non è stata registrata. Nulla è cambiato."
+ }
},
"audit": {
"header": {
@@ -124,6 +126,27 @@
"next": "Seleziona il task successivo",
"previous": "Seleziona il task precedente",
"more": "Carica voci di audit meno recenti"
+ },
+ "newTask": {
+ "title": "Nuovo task",
+ "repository": "Percorso del checkout",
+ "repositoryHint": "/srv/checkouts/acme-app",
+ "mode": "Modalità",
+ "trigger": "Innesco",
+ "feedback": "Commento di revisione",
+ "submit": "Metti in coda",
+ "note": "L'esecuzione parte quando il control plane ha capacità.",
+ "failed": "Task non creato. Controlla il percorso, la modalità e cosa consentono le tue credenziali.",
+ "modes": {
+ "observe": "Observe — ispeziona e riporta",
+ "suggest": "Suggest — propone una modifica",
+ "fix": "Fix — applica una modifica",
+ "autonomous": "Autonomous"
+ },
+ "triggers": {
+ "proactive": "Revisione proattiva del diff",
+ "feedback": "Commento di revisione"
+ }
}
}
}
From eabb4e0cf92ebdedc88cfd6ce448ca76106815c2 Mon Sep 17 00:00:00 2001
From: RedStar071
Date: Sat, 5 Sep 2026 19:49:15 +0000
Subject: [PATCH 06/15] feat(dashboard): find pull requests to review without a
webhook
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Work only reached the control plane when a provider delivered it, so a
self-hosted deployment behind no public URL had nothing feeding it: the board
stayed empty unless someone posted a task by hand.
`server/plugins/poller.ts` lists each watched repository's open pull requests on
an interval and starts a review for every head commit it has not started one
for. It is the pull-based half of the job the webhook route already does, and it
shares that route's durable `DeliveryClaimStore`, so a commit reviewed through
one path is never reviewed again through the other. The claim key carries the
head sha, which is what makes a new push earn a new review and an unchanged pull
request earn nothing.
Constraints that are enforced, not documented: it is off unless
`CODE_ZERO_POLL_REPOSITORIES` names something; it requests only `observe` or
`suggest`, so work nobody asked for cannot write to a checkout; and the checkout
comes from the path an operator paired with the repository rather than being
derived from the provider's answer, so a run can never target somewhere nobody
named. A failed start releases its claim so the next pass retries, and one
unreachable provider does not end the pass.
`listOpenPullRequests` is new on the GitHub adapter and returns both the base and
head commits, because a review reads the diff between them. It skips a record
missing either rather than losing the page it arrived in.
Verified against the built server: silent and healthy when unconfigured; refuses
to start naming the missing variable when configured without a token; and with a
token it reports the repository that failed without stopping the process or
putting the credential in the log. A pass against real GitHub is still owed —
this environment has no credentials, and the tests deliberately reach no network.
27 new tests; lint:ci, typecheck across the graph, and the build pass.
Co-Authored-By: Claude Opus 5 (1M context)
---
apps/dashboard/.env.example | 20 ++
apps/dashboard/package.json | 1 +
apps/dashboard/server/plugins/poller.ts | 78 +++++++
apps/dashboard/server/utils/environment.ts | 51 +++++
apps/dashboard/server/utils/poller.ts | 108 ++++++++++
apps/dashboard/test/unit/environment.test.ts | 52 +++++
apps/dashboard/test/unit/poller.test.ts | 200 ++++++++++++++++++
docs/architecture.md | 2 +
packages/source-control/src/index.ts | 1 +
.../src/providers/github-pulls.test.ts | 89 ++++++++
.../src/providers/github-pulls.ts | 73 +++++++
pnpm-lock.yaml | 48 ++---
12 files changed, 698 insertions(+), 25 deletions(-)
create mode 100644 apps/dashboard/server/plugins/poller.ts
create mode 100644 apps/dashboard/server/utils/poller.ts
create mode 100644 apps/dashboard/test/unit/poller.test.ts
diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example
index f26a939..79e3fe4 100644
--- a/apps/dashboard/.env.example
+++ b/apps/dashboard/.env.example
@@ -40,6 +40,26 @@ GITHUB_WEBHOOK_SECRET=
# (503, nothing ingested) until both this and GITHUB_WEBHOOK_SECRET are set.
CODE_ZERO_CHECKOUT_PATH=
+# Polling (server/plugins/poller.ts), the pull-based half of the same job the webhook does: it
+# finds open pull requests to review without this deployment needing a public URL. Off until
+# CODE_ZERO_POLL_REPOSITORIES names something, and it needs GITHUB_TOKEN above.
+#
+# It runs an interval inside the server process, so it belongs to a deployment that stays up; a
+# serverless target freezes between requests and would poll only by accident. It shares the durable
+# delivery claims with the webhook route, so the two never review the same commit twice.
+#
+# Each entry pairs a repository on the provider with the checkout on this host a run may execute
+# against: `owner/name=/absolute/path`, comma-separated. The path is never derived from the slug,
+# so a run can only ever target a checkout an operator named. That checkout has to be kept current
+# (a periodic `git fetch`) — a review reads the diff between the pull request's base and head
+# commits, so a checkout missing them fails the run rather than reviewing the wrong thing.
+CODE_ZERO_POLL_REPOSITORIES=
+# Seconds between passes. Clamped to 15..3600; defaults to 60.
+CODE_ZERO_POLL_INTERVAL_SECONDS=60
+# `observe` (default) or `suggest`. Work nobody requested cannot write to a checkout, and neither
+# of these modes can; the writable modes are deliberately not accepted here.
+CODE_ZERO_POLL_MODE=observe
+
# Database (packages/database). Owns the schema and the migrations; this app is the only process
# that opens it. The pre-split name AUTH_DATABASE_URL is still read when this is unset.
# `packages/database/.env` carries the same connection string for drizzle-kit: keep the two
diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json
index 54f34b5..2e7169f 100644
--- a/apps/dashboard/package.json
+++ b/apps/dashboard/package.json
@@ -30,6 +30,7 @@
"@code-zero/i18n": "workspace:*",
"@code-zero/mail": "workspace:*",
"@code-zero/shared": "workspace:*",
+ "@code-zero/source-control": "workspace:*",
"@octopi-ai/better-enrollment": "^0.4.0",
"@onmax/nuxt-better-auth": "^0.1.2",
"@orpc/client": "2.0.0-beta.26",
diff --git a/apps/dashboard/server/plugins/poller.ts b/apps/dashboard/server/plugins/poller.ts
new file mode 100644
index 0000000..e3ff499
--- /dev/null
+++ b/apps/dashboard/server/plugins/poller.ts
@@ -0,0 +1,78 @@
+import { githubTokenFromEnvironment, runTask } from '@code-zero/api';
+import { GitHubPullRequests } from '@code-zero/source-control';
+
+/**
+ * Finds work on its own, so a self-hosted deployment does not need a public webhook URL.
+ *
+ * Off unless `CODE_ZERO_POLL_REPOSITORIES` names something. It runs an interval in this process,
+ * so it belongs to a deployment that stays up: a serverless target freezes between requests and
+ * would poll only by accident. Nothing else changes when it is off — the webhook route remains the
+ * push-based path, and this is the pull-based one, sharing the same durable delivery claims so the
+ * two cannot review the same commit twice.
+ *
+ * The mode is `observe` unless an operator asks for `suggest`. Work nobody requested must not be
+ * able to write to a checkout, and neither mode can.
+ *
+ * The watched checkout has to be current: a review reads the diff between the pull request's base
+ * and head commits, so a checkout that has not fetched them fails the run rather than reviewing
+ * the wrong thing. Keeping it fetched is the operator's job, the same as it already is for the
+ * webhook route.
+ */
+export default defineNitroPlugin((nitroApp) => {
+ const repositories = watchedRepositoriesFromEnvironment(process.env);
+ if (repositories.length === 0) return;
+
+ const token = githubTokenFromEnvironment();
+ if (!token) {
+ console.warn('[poll] CODE_ZERO_POLL_REPOSITORIES is set but GITHUB_TOKEN is not; not polling');
+ return;
+ }
+
+ const pulls = new GitHubPullRequests({ token });
+ const intervalMs = pollIntervalFromEnvironment(process.env) * 1_000;
+ const mode = pollModeFromEnvironment(process.env);
+ let running = false;
+
+ async function pass(): Promise {
+ // A pass that overruns its interval must not start a second one beside itself: the claims
+ // would still keep the work unique, but the provider would be asked twice for nothing.
+ if (running) return;
+ running = true;
+ try {
+ await pollOnce({
+ repositories,
+ source: pulls,
+ claims: deliveryClaimStore,
+ start: (request) =>
+ runTask(
+ {
+ repository: request.repository,
+ mode,
+ trigger: 'proactive',
+ source: request.source,
+ pullRequest: request.pullRequest,
+ },
+ { store: taskStore },
+ ),
+ onError: (repository, error) => {
+ console.error(`[poll] ${repository} failed`, error);
+ },
+ });
+ } finally {
+ running = false;
+ }
+ }
+
+ const timer = setInterval(() => void pass(), intervalMs);
+ // Never hold the process open on its own account: a deployment shutting down should not wait out
+ // an interval that has nothing to do.
+ timer.unref();
+ nitroApp.hooks.hook('close', () => {
+ clearInterval(timer);
+ });
+
+ console.info(
+ `[poll] watching ${String(repositories.length)} repositories every ${String(intervalMs / 1_000)}s in ${mode} mode`,
+ );
+ void pass();
+});
diff --git a/apps/dashboard/server/utils/environment.ts b/apps/dashboard/server/utils/environment.ts
index 93498fd..a189c2a 100644
--- a/apps/dashboard/server/utils/environment.ts
+++ b/apps/dashboard/server/utils/environment.ts
@@ -33,3 +33,54 @@ export function checkoutPathFromEnvironment(
): string | undefined {
return environment.CODE_ZERO_CHECKOUT_PATH?.trim() || undefined;
}
+
+/**
+ * The repositories the poller watches, as `owner/name=/path/to/checkout` entries.
+ *
+ * Two things have to be stated because neither can be derived: which repository on the provider to
+ * ask about, and which checkout on this host a run may execute against. Pairing them here rather
+ * than deriving the second from the first keeps the poller from ever pointing a run at a path an
+ * operator did not name — the same rule `CODE_ZERO_CONTROL_PLANE_REPOSITORIES` states for the API.
+ *
+ * A malformed entry is dropped rather than raised: a typo in one repository must not stop the
+ * server from starting, and the poller reports what it watches when it starts.
+ */
+export interface WatchedRepository {
+ owner: string;
+ repo: string;
+ checkoutPath: string;
+}
+
+export function watchedRepositoriesFromEnvironment(
+ environment: Readonly>,
+): WatchedRepository[] {
+ const configured = environment.CODE_ZERO_POLL_REPOSITORIES?.trim();
+ if (!configured) return [];
+ const watched: WatchedRepository[] = [];
+ for (const entry of configured.split(',')) {
+ const [slug, checkoutPath] = entry.split('=', 2).map((part) => part.trim());
+ const [owner, repo] = (slug ?? '').split('/', 2).map((part) => part.trim());
+ if (!owner || !repo || !checkoutPath) continue;
+ watched.push({ owner, repo, checkoutPath });
+ }
+ return watched;
+}
+
+/** Seconds between polls. Below the floor a poll spends more rate limit than it earns. */
+export function pollIntervalFromEnvironment(
+ environment: Readonly>,
+): number {
+ const configured = Number.parseInt(environment.CODE_ZERO_POLL_INTERVAL_SECONDS?.trim() ?? '', 10);
+ if (!Number.isFinite(configured)) return 60;
+ return Math.min(Math.max(configured, 15), 3_600);
+}
+
+/**
+ * The execution mode the poller requests. `observe` unless an operator says otherwise, because
+ * work nobody asked for should not be able to write to a checkout.
+ */
+export function pollModeFromEnvironment(
+ environment: Readonly>,
+): 'observe' | 'suggest' {
+ return environment.CODE_ZERO_POLL_MODE?.trim() === 'suggest' ? 'suggest' : 'observe';
+}
diff --git a/apps/dashboard/server/utils/poller.ts b/apps/dashboard/server/utils/poller.ts
new file mode 100644
index 0000000..ca9189f
--- /dev/null
+++ b/apps/dashboard/server/utils/poller.ts
@@ -0,0 +1,108 @@
+import type { DeliveryClaimStore } from '@code-zero/api';
+import type { OpenPullRequest, RepositoryTarget } from '@code-zero/source-control';
+
+import type { WatchedRepository } from './environment.js';
+
+/** The one thing the poller asks a provider for. Narrow so a test needs no HTTP adapter. */
+export interface OpenPullRequestSource {
+ listOpenPullRequests(target: RepositoryTarget): Promise;
+}
+
+export interface PollOptions {
+ repositories: readonly WatchedRepository[];
+ source: OpenPullRequestSource;
+ /**
+ * Where a started review is recorded so the next pass does not start it again.
+ *
+ * The same durable claim store the webhook route uses, and for the same reason: the claim
+ * survives a restart and is shared by every instance, so a poller that comes back up does not
+ * re-review every open pull request it had already looked at.
+ */
+ claims: DeliveryClaimStore;
+ /** Starts one review. Injected, so the poller composes runs without being able to execute one. */
+ start: (request: PollRequest) => Promise;
+ /** Reported per repository; one unreachable provider must not stop the rest of the pass. */
+ onError?: (repository: string, error: unknown) => void;
+}
+
+export interface PollRequest {
+ /** The local checkout the run executes against, always one an operator named. */
+ repository: string;
+ pullRequest: { owner: string; repo: string; number: number; baseSha: string; headSha: string };
+ /** Provenance for the task record, e.g. `poll:acme/app#412`. */
+ source: string;
+}
+
+/**
+ * The claim key for one review of one commit.
+ *
+ * The head sha is part of the key rather than the pull request alone, which is what makes a new
+ * push the thing that earns a new review: an unchanged pull request is claimed already, and a
+ * force-push or a new commit is a key nobody has claimed.
+ */
+export function pollClaimKey(target: WatchedRepository, pull: OpenPullRequest): string {
+ return `poll:${target.owner}/${target.repo}#${String(pull.number)}@${pull.headSha}`;
+}
+
+/**
+ * One pass over every watched repository, starting a review for each pull request commit that has
+ * not been reviewed yet.
+ *
+ * Returns how many reviews it started, which is what the caller logs; everything else about them
+ * is on the task records the run itself writes.
+ *
+ * Drafts are skipped. A draft is the author saying the change is not ready to be read, and a
+ * review that arrives anyway costs a model call to tell them something they already know.
+ */
+export async function pollOnce(options: PollOptions): Promise {
+ let started = 0;
+ for (const repository of options.repositories) {
+ const label = `${repository.owner}/${repository.repo}`;
+ let open: OpenPullRequest[];
+ try {
+ open = await options.source.listOpenPullRequests({
+ owner: repository.owner,
+ repo: repository.repo,
+ });
+ } catch (error) {
+ options.onError?.(label, error);
+ continue;
+ }
+
+ for (const pull of open) {
+ if (pull.draft) continue;
+ const key = pollClaimKey(repository, pull);
+ let claim;
+ try {
+ claim = await options.claims.claim(key);
+ } catch (error) {
+ options.onError?.(label, error);
+ continue;
+ }
+ if (!claim.claimed) continue;
+
+ try {
+ await options.start({
+ repository: repository.checkoutPath,
+ pullRequest: {
+ owner: repository.owner,
+ repo: repository.repo,
+ number: pull.number,
+ baseSha: pull.baseSha,
+ headSha: pull.headSha,
+ },
+ source: `poll:${label}#${String(pull.number)}`,
+ });
+ started += 1;
+ await options.claims.complete(key, { started: true });
+ } catch (error) {
+ // The claim is released rather than completed, so the next pass retries this commit. A
+ // failed start is a run that never happened; leaving the claim standing would make one
+ // transient failure mean the commit is never reviewed at all.
+ await options.claims.release(key).catch(() => undefined);
+ options.onError?.(label, error);
+ }
+ }
+ }
+ return started;
+}
diff --git a/apps/dashboard/test/unit/environment.test.ts b/apps/dashboard/test/unit/environment.test.ts
index 44ff64f..8fb29f7 100644
--- a/apps/dashboard/test/unit/environment.test.ts
+++ b/apps/dashboard/test/unit/environment.test.ts
@@ -4,6 +4,9 @@ import {
checkoutPathFromEnvironment,
dashboardUrlFromEnvironment,
githubWebhookSecretFromEnvironment,
+ pollIntervalFromEnvironment,
+ pollModeFromEnvironment,
+ watchedRepositoriesFromEnvironment,
} from '../../server/utils/environment.js';
describe('dashboardUrlFromEnvironment', () => {
@@ -48,3 +51,52 @@ describe('checkoutPathFromEnvironment', () => {
expect(checkoutPathFromEnvironment({ CODE_ZERO_CHECKOUT_PATH: ' ' })).toBeUndefined();
});
});
+
+describe('watchedRepositoriesFromEnvironment', () => {
+ it('pairs each provider repository with the checkout a run may execute against', () => {
+ expect(
+ watchedRepositoriesFromEnvironment({
+ CODE_ZERO_POLL_REPOSITORIES: ' acme/app=/srv/checkouts/app , acme/billing=/srv/billing ',
+ }),
+ ).toEqual([
+ { owner: 'acme', repo: 'app', checkoutPath: '/srv/checkouts/app' },
+ { owner: 'acme', repo: 'billing', checkoutPath: '/srv/billing' },
+ ]);
+ });
+
+ it('drops an entry that names no checkout, rather than inventing one', () => {
+ // Deriving a path from the slug is exactly how a run ends up pointed somewhere nobody named.
+ expect(
+ watchedRepositoriesFromEnvironment({
+ CODE_ZERO_POLL_REPOSITORIES: 'acme/app,acme/billing=/srv/billing,=/srv/orphan,acme=/srv/x',
+ }),
+ ).toEqual([{ owner: 'acme', repo: 'billing', checkoutPath: '/srv/billing' }]);
+ });
+
+ it('watches nothing when the variable is absent or empty', () => {
+ expect(watchedRepositoriesFromEnvironment({})).toEqual([]);
+ expect(watchedRepositoriesFromEnvironment({ CODE_ZERO_POLL_REPOSITORIES: ' ' })).toEqual([]);
+ });
+});
+
+describe('pollIntervalFromEnvironment', () => {
+ it('defaults to a minute and clamps what a deployment asks for', () => {
+ expect(pollIntervalFromEnvironment({})).toBe(60);
+ expect(pollIntervalFromEnvironment({ CODE_ZERO_POLL_INTERVAL_SECONDS: '120' })).toBe(120);
+ // Below the floor a pass spends more rate limit than it earns; above the ceiling it is not
+ // polling any more.
+ expect(pollIntervalFromEnvironment({ CODE_ZERO_POLL_INTERVAL_SECONDS: '1' })).toBe(15);
+ expect(pollIntervalFromEnvironment({ CODE_ZERO_POLL_INTERVAL_SECONDS: '99999' })).toBe(3_600);
+ expect(pollIntervalFromEnvironment({ CODE_ZERO_POLL_INTERVAL_SECONDS: 'soon' })).toBe(60);
+ });
+});
+
+describe('pollModeFromEnvironment', () => {
+ it('polls in the mode that cannot write to a checkout unless told otherwise', () => {
+ expect(pollModeFromEnvironment({})).toBe('observe');
+ expect(pollModeFromEnvironment({ CODE_ZERO_POLL_MODE: 'suggest' })).toBe('suggest');
+ // Anything else, including the writable modes, is refused here rather than at the runner.
+ expect(pollModeFromEnvironment({ CODE_ZERO_POLL_MODE: 'fix' })).toBe('observe');
+ expect(pollModeFromEnvironment({ CODE_ZERO_POLL_MODE: 'autonomous' })).toBe('observe');
+ });
+});
diff --git a/apps/dashboard/test/unit/poller.test.ts b/apps/dashboard/test/unit/poller.test.ts
new file mode 100644
index 0000000..61cadff
--- /dev/null
+++ b/apps/dashboard/test/unit/poller.test.ts
@@ -0,0 +1,200 @@
+import type { DeliveryClaim, DeliveryClaimStore } from '@code-zero/api';
+import type { OpenPullRequest } from '@code-zero/source-control';
+import { describe, expect, it } from 'vitest';
+
+import type { WatchedRepository } from '../../server/utils/environment.js';
+import { pollClaimKey, pollOnce, type PollRequest } from '../../server/utils/poller.js';
+
+const HEAD = 'c'.repeat(40);
+const BASE = 'b'.repeat(40);
+
+const WATCHED: WatchedRepository = {
+ owner: 'acme',
+ repo: 'app',
+ checkoutPath: '/srv/checkouts/acme-app',
+};
+
+function pull(overrides: Partial = {}): OpenPullRequest {
+ return {
+ number: 412,
+ title: 'Fix the sitemap',
+ headSha: HEAD,
+ headRef: 'fix/sitemap',
+ baseSha: BASE,
+ url: 'https://github.com/acme/app/pull/412',
+ draft: false,
+ ...overrides,
+ };
+}
+
+/** The durable claim store, reduced to the in-memory behaviour the poller relies on. */
+class MemoryClaims implements DeliveryClaimStore {
+ readonly outcomes = new Map();
+
+ async claim(key: string): Promise {
+ if (this.outcomes.has(key)) return { claimed: false, outcome: this.outcomes.get(key) };
+ this.outcomes.set(key, null);
+ return { claimed: true };
+ }
+
+ async complete(key: string, outcome: unknown): Promise {
+ this.outcomes.set(key, outcome);
+ }
+
+ async release(key: string): Promise {
+ this.outcomes.delete(key);
+ }
+}
+
+function collector() {
+ const started: PollRequest[] = [];
+ return { started, start: async (request: PollRequest) => void started.push(request) };
+}
+
+function source(...pulls: OpenPullRequest[]) {
+ return { listOpenPullRequests: async () => pulls };
+}
+
+describe('pollOnce', () => {
+ it('starts one review per open pull request, against the checkout the operator named', async () => {
+ const runs = collector();
+
+ const started = await pollOnce({
+ repositories: [WATCHED],
+ source: source(pull()),
+ claims: new MemoryClaims(),
+ start: runs.start,
+ });
+
+ expect(started).toBe(1);
+ expect(runs.started).toEqual([
+ {
+ // Never a path derived from the provider's answer; only the one that was configured.
+ repository: '/srv/checkouts/acme-app',
+ pullRequest: { owner: 'acme', repo: 'app', number: 412, baseSha: BASE, headSha: HEAD },
+ source: 'poll:acme/app#412',
+ },
+ ]);
+ });
+
+ it('does not review the same commit twice across passes', async () => {
+ const runs = collector();
+ const claims = new MemoryClaims();
+ const options = {
+ repositories: [WATCHED],
+ source: source(pull()),
+ claims,
+ start: runs.start,
+ };
+
+ await pollOnce(options);
+ await pollOnce(options);
+
+ expect(runs.started).toHaveLength(1);
+ });
+
+ it('reviews again once the head commit moves', async () => {
+ const runs = collector();
+ const claims = new MemoryClaims();
+
+ await pollOnce({
+ repositories: [WATCHED],
+ source: source(pull()),
+ claims,
+ start: runs.start,
+ });
+ await pollOnce({
+ repositories: [WATCHED],
+ source: source(pull({ headSha: 'd'.repeat(40) })),
+ claims,
+ start: runs.start,
+ });
+
+ // A new push is the whole reason to look again; the claim key carries the commit for this.
+ expect(runs.started.map((request) => request.pullRequest.headSha)).toEqual([
+ HEAD,
+ 'd'.repeat(40),
+ ]);
+ });
+
+ it('skips a draft, which its author has not asked anyone to read', async () => {
+ const runs = collector();
+
+ const started = await pollOnce({
+ repositories: [WATCHED],
+ source: source(pull({ draft: true })),
+ claims: new MemoryClaims(),
+ start: runs.start,
+ });
+
+ expect(started).toBe(0);
+ expect(runs.started).toEqual([]);
+ });
+
+ it('retries a commit whose run failed to start, rather than losing it', async () => {
+ const claims = new MemoryClaims();
+ const failures: unknown[] = [];
+ const failing = {
+ repositories: [WATCHED],
+ source: source(pull()),
+ claims,
+ start: () => Promise.reject(new Error('scheduler unavailable')),
+ onError: (_repository: string, error: unknown) => failures.push(error),
+ };
+
+ await pollOnce(failing);
+ expect(String(failures[0])).toContain('scheduler unavailable');
+
+ // The claim was released, so the next pass gets to try the same commit again.
+ const runs = collector();
+ await pollOnce({ ...failing, start: runs.start, onError: undefined });
+ expect(runs.started).toHaveLength(1);
+ });
+
+ it('keeps polling the other repositories when one provider fails', async () => {
+ const runs = collector();
+ const second = { ...WATCHED, repo: 'billing', checkoutPath: '/srv/checkouts/acme-billing' };
+ const failures: string[] = [];
+
+ const started = await pollOnce({
+ repositories: [WATCHED, second],
+ source: {
+ listOpenPullRequests: async (target) => {
+ if (target.repo === 'app') throw new Error('rate limited');
+ return [pull({ number: 9 })];
+ },
+ },
+ claims: new MemoryClaims(),
+ start: runs.start,
+ onError: (repository) => failures.push(repository),
+ });
+
+ expect(failures).toEqual(['acme/app']);
+ expect(started).toBe(1);
+ expect(runs.started[0]?.source).toBe('poll:acme/billing#9');
+ });
+
+ it('does nothing at all when no repository is watched', async () => {
+ const runs = collector();
+
+ const started = await pollOnce({
+ repositories: [],
+ source: {
+ listOpenPullRequests: () => Promise.reject(new Error('should not be asked')),
+ },
+ claims: new MemoryClaims(),
+ start: runs.start,
+ });
+
+ expect(started).toBe(0);
+ });
+});
+
+describe('pollClaimKey', () => {
+ it('identifies one commit of one pull request, so a new push is a new key', () => {
+ expect(pollClaimKey(WATCHED, pull())).toBe(`poll:acme/app#412@${HEAD}`);
+ expect(pollClaimKey(WATCHED, pull({ headSha: 'd'.repeat(40) }))).not.toBe(
+ pollClaimKey(WATCHED, pull()),
+ );
+ });
+});
diff --git a/docs/architecture.md b/docs/architecture.md
index 59329ba..5e25960 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -109,6 +109,8 @@ It differs from the dashboard in exactly one respect. The dashboard renders with
Mutations fail closed behind operator-issued bearer credentials (`CODE_ZERO_CONTROL_PLANE_TOKENS`, comma-separated `name:token` pairs). `tasks.create` additionally requires the target repository path to appear in `CODE_ZERO_CONTROL_PLANE_REPOSITORIES`, so an HTTP caller cannot point a run at an arbitrary server-local path, and the requested execution mode to be granted to the principal via `CODE_ZERO_CONTROL_PLANE_MODES` (comma-separated `name:mode|mode` grants; without one a principal is limited to the non-writable `observe` and `suggest` modes). Approval decisions record the authenticated principal's name rather than a wire-supplied actor. Reads stay open for the dashboard. This bearer-token scheme is independent of the Better Auth session that protects the dashboard UI itself.
+Work reaches the control plane two ways, and both end at the same `runTask`. `POST /webhooks/github` is the push-based path, driven by a delivery an operator's provider sends. `server/plugins/poller.ts` is the pull-based one, for a deployment with no public URL to receive deliveries on: it lists each watched repository's open pull requests on an interval and starts a review for every head commit it has not started one for. The two share the durable `DeliveryClaimStore`, so a commit reviewed through one is never reviewed again through the other. The poller is off unless `CODE_ZERO_POLL_REPOSITORIES` names something, requests only the non-writable `observe` or `suggest` mode, and reaches a checkout only through the path an operator paired with the repository — it never derives one. Because it holds an interval in the server process, it belongs to a deployment that stays up rather than a serverless one.
+
Task persistence is a narrow `KeyValueStorage` contract adapted over the ViteHub KV Runtime Helper (`apps/dashboard/nuxt.config.ts` registers `vite-hub/nuxt`, composing ViteHub into Nuxt's own Nitro build), so the filesystem driver, Cloudflare KV, Deno KV, or Upstash stays interchangeable. Records are redacted on the way in and hold no review input and no checkout path, so task history cannot become a credential or filesystem leak. `TaskScheduler` bounds concurrency globally and per repository, and rejects work once the queue is exhausted rather than growing without limit.
Transport concerns stop at the route handlers: headers, status mapping, and request objects never reach a runtime package.
diff --git a/packages/source-control/src/index.ts b/packages/source-control/src/index.ts
index 562e09e..5ac1878 100644
--- a/packages/source-control/src/index.ts
+++ b/packages/source-control/src/index.ts
@@ -64,6 +64,7 @@ export {
isSafeBranchName,
type BranchFile,
type GitHubPullRequestsOptions,
+ type OpenPullRequest,
type OpenPullRequestOptions,
type PublishBranchOptions,
type RepositoryTarget,
diff --git a/packages/source-control/src/providers/github-pulls.test.ts b/packages/source-control/src/providers/github-pulls.test.ts
index 9880c59..59e235c 100644
--- a/packages/source-control/src/providers/github-pulls.test.ts
+++ b/packages/source-control/src/providers/github-pulls.test.ts
@@ -91,6 +91,95 @@ describe('defaultBranch', () => {
});
});
+describe('listOpenPullRequests', () => {
+ const headSha = 'c'.repeat(40);
+
+ function pull(number: number, overrides: Record = {}) {
+ return {
+ number,
+ title: `Pull ${String(number)}`,
+ head: { sha: headSha, ref: `feature/${String(number)}` },
+ base: { sha: baseSha },
+ html_url: `https://github.com/acme/app/pull/${String(number)}`,
+ draft: false,
+ ...overrides,
+ };
+ }
+
+ it('reduces GitHub records to what deciding to review one needs', async () => {
+ const { pulls, requests } = adapter({ '/repos/acme/app/pulls': [pull(412)] });
+
+ await expect(pulls.listOpenPullRequests(target)).resolves.toEqual([
+ {
+ number: 412,
+ title: 'Pull 412',
+ headSha,
+ headRef: 'feature/412',
+ baseSha,
+ url: 'https://github.com/acme/app/pull/412',
+ draft: false,
+ },
+ ]);
+ expect(requests[0]?.method).toBe('GET');
+ });
+
+ it('asks only for open pull requests, most recently updated first', async () => {
+ const { pulls, requests } = adapter({ '/repos/acme/app/pulls': [] });
+
+ await pulls.listOpenPullRequests(target, 10);
+
+ // The path carries the query, so this is the request GitHub actually receives.
+ expect(requests[0]?.path).toBe('/repos/acme/app/pulls');
+ });
+
+ it('clamps the page size rather than passing an unbounded one through', async () => {
+ const sizes: string[] = [];
+ const pulls = new GitHubPullRequests({
+ token: 'secret-token-value',
+ // The adapter only ever passes a string URL; the union is narrowed the same way
+ // `fakeGitHub` above does it rather than stringified.
+ fetch: async (input) => {
+ const url = new URL(
+ typeof input === 'string' ? input : 'url' in input ? input.url : input.href,
+ );
+ sizes.push(url.searchParams.get('per_page') ?? '');
+ return new Response('[]', { status: 200 });
+ },
+ });
+
+ await pulls.listOpenPullRequests(target, 5_000);
+ await pulls.listOpenPullRequests(target, 0);
+
+ expect(sizes).toEqual(['100', '1']);
+ });
+
+ it('skips a malformed record instead of losing the page it came in', async () => {
+ const { pulls } = adapter({
+ '/repos/acme/app/pulls': [
+ pull(1, { head: { sha: 'not-a-sha!', ref: 'x' } }),
+ pull(2, { number: 'two' }),
+ pull(3, { head: { sha: headSha } }),
+ pull(5, { base: { sha: 'not-a-sha!' } }),
+ pull(4),
+ ],
+ });
+
+ await expect(pulls.listOpenPullRequests(target)).resolves.toMatchObject([{ number: 4 }]);
+ });
+
+ it('reports a draft, so a caller can decide not to review one', async () => {
+ const { pulls } = adapter({ '/repos/acme/app/pulls': [pull(9, { draft: true })] });
+
+ await expect(pulls.listOpenPullRequests(target)).resolves.toMatchObject([{ draft: true }]);
+ });
+
+ it('fails loudly when GitHub answers with something that is not a list', async () => {
+ const { pulls } = adapter({ '/repos/acme/app/pulls': { message: 'nope' } });
+
+ await expect(pulls.listOpenPullRequests(target)).rejects.toThrow('did not report a list');
+ });
+});
+
describe('publishBranch', () => {
const responses = {
[`/repos/acme/app/git/commits/${baseSha}`]: { tree: { sha: 't'.repeat(40) } },
diff --git a/packages/source-control/src/providers/github-pulls.ts b/packages/source-control/src/providers/github-pulls.ts
index 580891f..222c33f 100644
--- a/packages/source-control/src/providers/github-pulls.ts
+++ b/packages/source-control/src/providers/github-pulls.ts
@@ -37,6 +37,25 @@ export interface OpenPullRequestOptions {
base: string;
}
+/**
+ * An open pull request, reduced to what deciding whether to review it needs.
+ *
+ * Deliberately not the provider's payload: a caller reasons about the head commit and the
+ * identifiers, and passing GitHub's object through would put an SDK shape into the runtime's
+ * vocabulary.
+ */
+export interface OpenPullRequest {
+ number: number;
+ title: string;
+ /** The commit under review. A new one is what makes a pull request worth looking at again. */
+ headSha: string;
+ headRef: string;
+ /** The commit the change is measured against; a review reads the diff between the two. */
+ baseSha: string;
+ url: string;
+ draft: boolean;
+}
+
export interface GitHubPullRequestsOptions {
token: string;
baseUrl?: string;
@@ -192,6 +211,60 @@ export class GitHubPullRequests {
return { number, url };
}
+ /**
+ * The repository's open pull requests, newest first, one page at a time.
+ *
+ * Read-only, and the only thing here that goes looking for work rather than publishing it. A
+ * page limit rather than full pagination: a caller polls repeatedly, so a repository with more
+ * open pull requests than one page is one whose oldest simply wait for the next pass — which is
+ * better than a poll that walks hundreds of pages every interval.
+ *
+ * A record GitHub returns without an integer number or a commit-shaped head sha is skipped
+ * rather than raised: one malformed entry must not cost the caller the whole page.
+ */
+ async listOpenPullRequests(target: RepositoryTarget, perPage = 50): Promise {
+ const query = new URLSearchParams({
+ state: 'open',
+ sort: 'updated',
+ direction: 'desc',
+ per_page: String(Math.min(Math.max(Math.trunc(perPage), 1), 100)),
+ });
+ const payload = await this.send(
+ 'GET',
+ `/repos/${target.owner}/${target.repo}/pulls?${query.toString()}`,
+ );
+ if (!Array.isArray(payload)) throw new Error('GitHub did not report a list of pull requests');
+ const requests: OpenPullRequest[] = [];
+ for (const entry of payload) {
+ const number = readNumber(entry, 'number');
+ const head = readRecord(entry, 'head');
+ const headSha = readString(head, 'sha');
+ const headRef = readString(head, 'ref');
+ const baseSha = readString(readRecord(entry, 'base'), 'sha');
+ // Both commits are required: a review reads the diff between them, so a record missing
+ // either describes nothing a run could inspect.
+ if (
+ number === undefined ||
+ !headSha ||
+ !COMMIT_SHA.test(headSha) ||
+ !headRef ||
+ !baseSha ||
+ !COMMIT_SHA.test(baseSha)
+ )
+ continue;
+ requests.push({
+ number,
+ title: readString(entry, 'title') ?? '',
+ headSha,
+ headRef,
+ baseSha,
+ url: readString(entry, 'html_url') ?? '',
+ draft: readRecord(entry, 'draft') === true,
+ });
+ }
+ return requests;
+ }
+
private async send(
method: 'GET' | 'POST',
path: string,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fd10db6..6880f5f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -84,6 +84,12 @@ importers:
apps/dashboard:
dependencies:
+ '@better-auth/core':
+ specifier: '>=1.4.0'
+ version: 1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1)
+ '@better-auth/infra':
+ specifier: ^0.4.0
+ version: 0.4.0(@better-auth/core@1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(better-auth@1.6.26(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9))(mysql2@3.15.3)(pg@8.23.0)(prisma@7.9.1)(react@19.2.8)(react-dom@19.2.8(react@19.2.8))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41))(zod@4.4.3)
'@code-zero/api':
specifier: workspace:*
version: 0.3.0
@@ -102,12 +108,9 @@ importers:
'@code-zero/shared':
specifier: workspace:*
version: 0.4.0
- '@better-auth/core':
- specifier: '>=1.4.0'
- version: 1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1)
- '@better-auth/infra':
- specifier: ^0.4.0
- version: 0.4.0(@better-auth/core@1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(better-auth@1.6.26(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9))(mysql2@3.15.3)(pg@8.23.0)(prisma@7.9.1)(react@19.2.8)(react-dom@19.2.8(react@19.2.8))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41))(zod@4.4.3)
+ '@code-zero/source-control':
+ specifier: workspace:*
+ version: 0.4.0
'@octopi-ai/better-enrollment':
specifier: ^0.4.0
version: 0.4.0(better-auth@1.6.26(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9))(mysql2@3.15.3)(pg@8.23.0)(prisma@7.9.1)(react@19.2.8)(react-dom@19.2.8(react@19.2.8))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41))(zod@4.4.3)
@@ -452,15 +455,15 @@ importers:
packages/auth:
dependencies:
- '@code-zero/database':
- specifier: workspace:*
- version: 0.4.0
'@better-auth/core':
specifier: '>=1.4.0'
version: 1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1)
'@better-auth/infra':
specifier: ^0.4.0
version: 0.4.0(@better-auth/core@1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(better-auth@1.6.26(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9))(mysql2@3.15.3)(pg@8.23.0)(prisma@7.9.1)(react@19.2.8)(react-dom@19.2.8(react@19.2.8))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41))(zod@4.4.3)
+ '@code-zero/database':
+ specifier: workspace:*
+ version: 0.4.0
'@octopi-ai/better-enrollment':
specifier: ^0.4.0
version: 0.4.0(better-auth@1.6.26(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9))(mysql2@3.15.3)(pg@8.23.0)(prisma@7.9.1)(react@19.2.8)(react-dom@19.2.8(react@19.2.8))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41))(zod@4.4.3)
@@ -523,6 +526,12 @@ importers:
packages/cli:
dependencies:
+ '@bomb.sh/args':
+ specifier: ^0.3.1
+ version: 0.3.1
+ '@clack/prompts':
+ specifier: ^1.7.0
+ version: 1.7.0
'@code-zero/agent':
specifier: workspace:*
version: 0.4.0
@@ -538,12 +547,6 @@ importers:
'@code-zero/shared':
specifier: workspace:*
version: 0.4.0
- '@bomb.sh/args':
- specifier: ^0.3.1
- version: 0.3.1
- '@clack/prompts':
- specifier: ^1.7.0
- version: 1.7.0
devDependencies:
oxlint:
specifier: ^1.44.0
@@ -713,9 +716,6 @@ importers:
packages/models:
dependencies:
- '@code-zero/shared':
- specifier: workspace:*
- version: 0.4.0
'@ai-sdk/anthropic':
specifier: ^4.0.36
version: 4.0.39(zod@4.4.3)
@@ -728,6 +728,9 @@ importers:
'@ai-sdk/openai-compatible':
specifier: ^3.0.16
version: 3.0.31(zod@4.4.3)
+ '@code-zero/shared':
+ specifier: workspace:*
+ version: 0.4.0
ai:
specifier: ^7.0.40
version: 7.0.66(zod@4.4.3)
@@ -7615,9 +7618,6 @@ packages:
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
- '@types/web-bluetooth@0.0.20':
- resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
-
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
@@ -18145,7 +18145,7 @@ snapshots:
'@better-auth/core': 1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1)
'@better-auth/utils': 0.4.2
optionalDependencies:
- drizzle-orm: 0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2)
+ drizzle-orm: 0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1)(sql.js@1.14.2)
'@better-auth/drizzle-adapter@1.7.0-rc.5(@better-auth/core@1.7.0-rc.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))':
dependencies:
@@ -23050,8 +23050,6 @@ snapshots:
'@types/unist@3.0.3': {}
- '@types/web-bluetooth@0.0.20': {}
-
'@types/web-bluetooth@0.0.21': {}
'@types/webidl-conversions@7.0.3': {}
@@ -25030,7 +25028,7 @@ snapshots:
optionalDependencies:
'@prisma/client': 5.22.0(prisma@7.9.1)
drizzle-kit: 0.31.10
- drizzle-orm: 0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2)
+ drizzle-orm: 0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1)(sql.js@1.14.2)
mongodb: 7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9)
mysql2: 3.15.3
pg: 8.23.0
From 282cc3cf899c3ac47b7b0fdf53888eda09fd31d8 Mon Sep 17 00:00:00 2001
From: RedStar071
Date: Sat, 5 Sep 2026 20:14:20 +0000
Subject: [PATCH 07/15] fix(api): let a repository allow-list stand without
operator tokens
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`accessFromEnvironment` returned `undefined` unless `CODE_ZERO_CONTROL_PLANE_TOKENS`
was set, and `mayTargetRepository` fails closed without a policy. So a deployment
that authenticates only browser sessions could never create a task: the
`CODE_ZERO_CONTROL_PLANE_REPOSITORIES` it had configured did not exist as far as
the router was concerned, and every target was refused.
The two variables answer different questions. Tokens say who a machine caller
is; the allow-list says what any authenticated caller may target, including a
person signed into the dashboard. Either one now produces a policy, and only
neither returns `undefined`, so an unconfigured deployment still rejects every
mutation and a deployment with no tokens still authenticates no machine caller —
`principals` is simply empty.
Found by running the dashboard with a session and an allow-list and nothing else,
which is what `dev:solo` and a self-hosted single-owner install both look like.
Co-Authored-By: Claude Opus 5 (1M context)
---
packages/api/src/access.test.ts | 18 ++++++++++++++----
packages/api/src/access.ts | 31 +++++++++++++++++++------------
2 files changed, 33 insertions(+), 16 deletions(-)
diff --git a/packages/api/src/access.test.ts b/packages/api/src/access.test.ts
index a39de71..8960e22 100644
--- a/packages/api/src/access.test.ts
+++ b/packages/api/src/access.test.ts
@@ -33,10 +33,20 @@ function access(overrides: Partial = {}): ControlPlaneAccess
}
describe('accessFromEnvironment', () => {
- it('fails closed when no tokens are configured', () => {
- expect(accessFromEnvironment(undefined, '/srv/checkout')).toBeUndefined();
- expect(accessFromEnvironment('', '/srv/checkout')).toBeUndefined();
- expect(accessFromEnvironment(' , ', '/srv/checkout')).toBeUndefined();
+ it('fails closed when nothing is configured', () => {
+ expect(accessFromEnvironment(undefined, undefined)).toBeUndefined();
+ expect(accessFromEnvironment('', '')).toBeUndefined();
+ expect(accessFromEnvironment(' , ', ' ')).toBeUndefined();
+ });
+
+ it('accepts an allow-list without tokens, for a deployment that only has sessions', () => {
+ // The two answer different questions: tokens say who a machine caller is, the allow-list says
+ // what any authenticated caller may target — including a person signed into the dashboard.
+ const parsed = accessFromEnvironment(undefined, '/srv/checkout');
+
+ expect(parsed?.repositories).toEqual(['/srv/checkout']);
+ // No token authenticates anything, which is what keeps this still closed to machine callers.
+ expect(parsed?.principals.size).toBe(0);
});
it('parses name:token pairs and the repository allow-list', () => {
diff --git a/packages/api/src/access.ts b/packages/api/src/access.ts
index 1428ea4..64716a0 100644
--- a/packages/api/src/access.ts
+++ b/packages/api/src/access.ts
@@ -71,19 +71,32 @@ function isRunMode(value: string): value is RunMode {
* `CODE_ZERO_CONTROL_PLANE_TOKENS` holds comma-separated `name:token` pairs,
* `CODE_ZERO_CONTROL_PLANE_REPOSITORIES` holds comma-separated repository paths, and
* `CODE_ZERO_CONTROL_PLANE_MODES` holds comma-separated `name:mode|mode` grants. Principals
- * without a grant may only request the non-writable `observe` and `suggest` modes. Returns
- * `undefined` when no tokens are configured, which keeps every mutation rejected.
+ * without a grant may only request the non-writable `observe` and `suggest` modes.
+ *
+ * Either variable is enough to produce a policy, because the two answer different questions. The
+ * tokens decide who a machine caller is; the repositories decide what any authenticated caller may
+ * target, including a person signed into the dashboard. Requiring tokens for the second left a
+ * deployment that authenticates only browser sessions unable to create a task at all — the
+ * allow-list it had configured did not exist, so every target failed closed.
+ *
+ * Returns `undefined` only when neither is configured, which keeps an unconfigured deployment
+ * rejecting every mutation.
*/
export function accessFromEnvironment(
tokens = process.env.CODE_ZERO_CONTROL_PLANE_TOKENS,
repositories = process.env.CODE_ZERO_CONTROL_PLANE_REPOSITORIES,
modes = process.env.CODE_ZERO_CONTROL_PLANE_MODES,
): ControlPlaneAccess | undefined {
- if (tokens === undefined || tokens.trim() === '') return undefined;
+ const allowedRepositories = (repositories ?? '')
+ .split(',')
+ .map((path) => path.trim())
+ .filter((path) => path !== '');
+ if ((tokens === undefined || tokens.trim() === '') && allowedRepositories.length === 0)
+ return undefined;
const grants = parseModeGrants(modes);
const principals = new Map();
const names = new Set();
- for (const entry of tokens.split(',')) {
+ for (const entry of (tokens ?? '').split(',')) {
const trimmed = entry.trim();
if (trimmed === '') continue;
const separator = trimmed.indexOf(':');
@@ -101,19 +114,13 @@ export function accessFromEnvironment(
admin: false,
});
}
- if (principals.size === 0) return undefined;
+ if (principals.size === 0 && allowedRepositories.length === 0) return undefined;
for (const name of grants.keys())
if (!names.has(name))
throw new Error(
`CODE_ZERO_CONTROL_PLANE_MODES grants modes to an unknown principal: ${name}`,
);
- return {
- principals,
- repositories: (repositories ?? '')
- .split(',')
- .map((path) => path.trim())
- .filter((path) => path !== ''),
- };
+ return { principals, repositories: allowedRepositories };
}
/** Parse `name:mode|mode` grants, refusing unknown modes rather than silently widening or narrowing. */
From cdeb4472ce7802fd767065e1d0f4758618988c37 Mon Sep 17 00:00:00 2001
From: RedStar071
Date: Sat, 5 Sep 2026 20:14:32 +0000
Subject: [PATCH 08/15] feat(cli): run on a deployment with --remote
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A local `zero run` and the dashboard kept separate histories: the CLI executed in
the checkout and recorded nothing the board could read, so work started from a
terminal was invisible to the surface built to watch it.
`--remote` hands the run to a deployment's control plane instead. It presents the
session `zero login` stored as a bearer token, so the run is attributed to the
person who signed in rather than to a shared operator token, and it goes to
`/rpc/**` because that is the only transport that resolves a session — stating
the `Sec-Fetch-Mode` header its CSRF guard reads, which a browser sends on its
own. The deployment therefore needs `AUTH_ENABLE_DEVICE_AUTHORIZATION=true`, the
same flag `zero login` already requires.
A flag rather than an inference from `CODE_ZERO_URL`: that variable already
selects which deployment `login` and `logout` act on, so treating its presence as
"run somewhere else" would silently move an operator's run to another machine and
another checkout the first time they set it. The plan called for the implicit
form; this is the deliberate departure from it.
The exit code comes from the same table a local run uses, so CI reads either the
same way, and an answer that is not a result is refused rather than allowed to
exit 0.
Verified against the built server end to end: from a checkout, `zero run
--proactive --remote --json` authenticated with a stored session, executed on the
deployment, printed the result, exited 0, and the task appeared in the board's
own `dashboard.overview`. 14 new tests; lint:ci and typecheck pass.
Co-Authored-By: Claude Opus 5 (1M context)
---
README.md | 3 +
packages/cli/src/args.test.ts | 29 +++++
packages/cli/src/args.ts | 36 +++++-
packages/cli/src/index.ts | 68 +++++++++++-
packages/cli/src/remote.test.ts | 187 ++++++++++++++++++++++++++++++++
packages/cli/src/remote.ts | 130 ++++++++++++++++++++++
6 files changed, 446 insertions(+), 7 deletions(-)
create mode 100644 packages/cli/src/remote.test.ts
create mode 100644 packages/cli/src/remote.ts
diff --git a/README.md b/README.md
index 8f9bb3a..d52faa6 100644
--- a/README.md
+++ b/README.md
@@ -135,10 +135,13 @@ zero logout [--url X] forget a stored session
zero review (--feedback X | --proactive) inspect without editing
zero fix (--feedback X | --proactive) validate, edit, and verify (policy permitting)
zero run (--feedback X | --proactive) run using the configured mode
+zero run --remote [--url ] run it on a deployment instead of here
```
The CLI parses arguments with [`@bomb.sh/args`](https://github.com/bomb-sh/args) and renders with [`@clack/prompts`](https://github.com/bombshell-dev/clack). Use `--proactive` to inspect the working-tree diff without reviewer feedback. When neither trigger is provided in a terminal, it asks for the task interactively; use `--feedback` or `--proactive` with `--json` for scripts and CI.
+`--remote` hands the run to a deployment's control plane instead of executing it in this checkout, so it lands in the same task history the dashboard reads and appears on the board while it runs. It uses the session `zero login` stored, presented as a bearer token, so the run is attributed to the person who signed in rather than to a shared operator token — which means the deployment needs `AUTH_ENABLE_DEVICE_AUTHORIZATION=true`, the same flag `zero login` already requires. The deployment is chosen with `--url` or `CODE_ZERO_URL`; the flag is deliberate rather than inferred from that variable, which already selects which deployment `login` and `logout` act on. The repository sent is this checkout's path, and the deployment's own `CODE_ZERO_CONTROL_PLANE_REPOSITORIES` decides whether it may be targeted. Exit codes are the same table a local run uses, so CI reads either the same way.
+
`zero login` runs the [RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628) device flow: it prints a short code, you approve it at the deployment's `/device` page in a browser you are already signed into, and the CLI stores the resulting session token in `$XDG_CONFIG_HOME/code-zero/credentials.json` (owner-readable only). The same command serves a cloud-managed deployment and a self-hosted one — pick which with `--url`, or set `CODE_ZERO_URL`; without either it targets `http://localhost:3000`. Tokens are kept per origin, so signing into one deployment never evicts another, and `zero logout` without `--url` forgets all of them. The deployment must have `AUTH_ENABLE_DEVICE_AUTHORIZATION=true`; it is off by default. That flag also registers Better Auth's `bearer` plugin, which is what lets the stored token be presented as `Authorization: Bearer ` — without it the flow would mint a session that only a cookie could carry. `zero doctor` lists which deployments have a stored session and whether it has expired, never the token itself.
---
diff --git a/packages/cli/src/args.test.ts b/packages/cli/src/args.test.ts
index f617d0c..ee1987e 100644
--- a/packages/cli/src/args.test.ts
+++ b/packages/cli/src/args.test.ts
@@ -10,6 +10,7 @@ describe('parseCliArguments', () => {
help: false,
json: true,
proactive: false,
+ remote: false,
version: false,
});
});
@@ -69,3 +70,31 @@ describe('parseCliArguments', () => {
expect(parsed.feedback).toBeUndefined();
});
});
+
+describe('parseCliArguments --remote', () => {
+ it('accepts a remote run and the deployment it names', () => {
+ expect(
+ parseCliArguments(['run', '--proactive', '--remote', '--url', 'https://zero.example.com']),
+ ).toMatchObject({
+ command: 'run',
+ remote: true,
+ url: 'https://zero.example.com',
+ });
+ });
+
+ it('defaults to running in this checkout', () => {
+ expect(parseCliArguments(['run', '--proactive']).remote).toBe(false);
+ });
+
+ it('refuses --remote on a command that runs no agent', () => {
+ expect(() => parseCliArguments(['doctor', '--remote'])).toThrow(
+ '--remote is only valid with review, fix, or run',
+ );
+ });
+
+ it('still refuses --url on a local run, where it would select nothing', () => {
+ expect(() =>
+ parseCliArguments(['run', '--proactive', '--url', 'https://zero.example.com']),
+ ).toThrow('--url is only valid with login, logout, or a --remote run');
+ });
+});
diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts
index f3070ea..52dca54 100644
--- a/packages/cli/src/args.ts
+++ b/packages/cli/src/args.ts
@@ -3,15 +3,36 @@ import { parse } from '@bomb.sh/args';
export interface CliArguments {
command: string;
feedback?: string;
- /** Deployment origin `login` and `logout` act on. Absent means "resolve it from the environment". */
+ /**
+ * Deployment origin the session and remote commands act on. Absent means "resolve it from the
+ * environment".
+ */
url?: string;
+ /**
+ * Run on a deployment's control plane instead of in this checkout.
+ *
+ * A flag rather than an inference from `CODE_ZERO_URL`: that variable already selects which
+ * deployment `login` and `logout` act on, so treating its presence as "run somewhere else" would
+ * silently move an operator's run to another machine and another checkout the first time they
+ * set it.
+ */
+ remote: boolean;
proactive: boolean;
help: boolean;
json: boolean;
version: boolean;
}
-const knownOptions = new Set(['_', 'feedback', 'help', 'json', 'proactive', 'url', 'version']);
+const knownOptions = new Set([
+ '_',
+ 'feedback',
+ 'help',
+ 'json',
+ 'proactive',
+ 'remote',
+ 'url',
+ 'version',
+]);
const agentCommands = new Set(['review', 'fix', 'run']);
/** The two commands that talk to a deployment rather than to a checkout. */
const sessionCommands = new Set(['login', 'logout']);
@@ -22,11 +43,12 @@ export function parseCliArguments(argv: string[]): CliArguments {
h: 'help',
v: 'version',
},
- boolean: ['help', 'json', 'proactive', 'version'],
+ boolean: ['help', 'json', 'proactive', 'remote', 'version'],
default: {
help: false,
json: false,
proactive: false,
+ remote: false,
version: false,
},
string: ['feedback', 'url'],
@@ -53,14 +75,18 @@ export function parseCliArguments(argv: string[]): CliArguments {
throw new Error('--json is only valid with doctor, review, fix, or run');
}
+ if (parsed.remote && !agentCommands.has(command))
+ throw new Error('--remote is only valid with review, fix, or run');
+
const url = parsed.url?.trim() || undefined;
- if (url !== undefined && !sessionCommands.has(command))
- throw new Error('--url is only valid with login or logout');
+ if (url !== undefined && !sessionCommands.has(command) && !parsed.remote)
+ throw new Error('--url is only valid with login, logout, or a --remote run');
return {
command,
...(feedback === undefined ? {} : { feedback }),
...(url === undefined ? {} : { url }),
+ remote: parsed.remote,
proactive: parsed.proactive,
help: parsed.help,
json: parsed.json,
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 1b79f41..db82edc 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -33,6 +33,7 @@ import {
saveCredential,
} from './credentials.js';
import { pollDeviceToken, requestDeviceCode } from './login.js';
+import { runRemotely } from './remote.js';
import {
claudeCodeProcessSpawner,
claudeCodeRefusalReason,
@@ -88,7 +89,7 @@ async function main(): Promise {
}
if (args.command === 'review' || args.command === 'fix' || args.command === 'run') {
- await runAgent(args.command, args.feedback, args.proactive, args.json);
+ await runAgent(args.command, args.feedback, args.proactive, args.json, args.remote, args.url);
return;
}
@@ -108,7 +109,7 @@ function showHelp(): void {
'zero logout [--url ]',
'zero review (--feedback | --proactive) [--json]',
'zero fix (--feedback | --proactive) [--json]',
- 'zero run (--feedback | --proactive) [--json]',
+ 'zero run (--feedback | --proactive) [--remote [--url ]] [--json]',
].join('\n'),
'Commands',
);
@@ -373,11 +374,62 @@ async function probeSubscriptionCli(
};
}
+/**
+ * Hand the run to a deployment's control plane instead of executing it here.
+ *
+ * The repository is this checkout's path, because the common case is a control plane running on
+ * the same machine. It is the deployment's allow-list that decides whether the path may be
+ * targeted at all, so a path this CLI happens to be sitting in cannot become one a run reaches.
+ *
+ * The exit code comes from the same table a local run uses: a remote run that needs a human still
+ * exits 2, and one that failed still exits 1, so CI reads both the same way.
+ */
+async function runOnControlPlane(
+ command: 'review' | 'fix' | 'run',
+ origin: string,
+ mode: RunMode,
+ proactive: boolean,
+ feedback: string | undefined,
+ asJson: boolean,
+): Promise {
+ if (!asJson) p.intro(`Code Zero · ${command} on ${origin}`);
+
+ const outcome = await runRemotely({
+ origin,
+ repository: cwd,
+ mode,
+ trigger: proactive ? 'proactive' : 'feedback',
+ ...(feedback === undefined ? {} : { feedback }),
+ });
+
+ if (!outcome.ok) {
+ const message =
+ outcome.failure.kind === 'signed-out'
+ ? `No session for ${origin}. Run \`zero login --url ${origin}\` first.`
+ : outcome.failure.kind === 'expired'
+ ? `The session for ${origin} has expired. Run \`zero login --url ${origin}\` again.`
+ : outcome.failure.message;
+ if (asJson) console.error(message);
+ else p.log.error(message);
+ process.exitCode = 1;
+ return;
+ }
+
+ if (asJson) console.log(JSON.stringify(outcome.result, null, 2));
+ else {
+ p.log.info(`Task ${outcome.result.id} · ${origin}`);
+ report(outcome.result, mode);
+ }
+ process.exitCode = exitCodes[outcome.result.state];
+}
+
async function runAgent(
command: 'review' | 'fix' | 'run',
providedFeedback: string | undefined,
proactive: boolean,
asJson: boolean,
+ remote = false,
+ url?: string,
): Promise {
const feedback = proactive
? undefined
@@ -387,6 +439,18 @@ async function runAgent(
const config = await loadConfig(cwd);
const mode: RunMode = command === 'review' ? 'observe' : command === 'fix' ? 'fix' : config.mode;
+ if (remote) {
+ await runOnControlPlane(
+ command,
+ resolveDeploymentOrigin(url),
+ mode,
+ proactive,
+ feedback,
+ asJson,
+ );
+ return;
+ }
+
if (!asJson && providedFeedback !== undefined) p.intro(`Code Zero · ${command}`);
// The boundary is created read-only unless both the mode and repository policy allow writing, so
diff --git a/packages/cli/src/remote.test.ts b/packages/cli/src/remote.test.ts
new file mode 100644
index 0000000..a2d092f
--- /dev/null
+++ b/packages/cli/src/remote.test.ts
@@ -0,0 +1,187 @@
+import { describe, expect, it } from 'vitest';
+
+import type { StoredCredential } from './credentials.js';
+import { runRemotely, type RemoteRunRequest } from './remote.js';
+
+const ORIGIN = 'https://code-zero.example.com';
+const NOW = Date.parse('2026-08-09T10:00:00.000Z');
+
+const REQUEST: RemoteRunRequest = {
+ origin: ORIGIN,
+ repository: '/srv/checkouts/acme-app',
+ mode: 'observe',
+ trigger: 'proactive',
+};
+
+function credentials(credential?: Partial) {
+ if (!credential) return () => Promise.resolve({});
+ return () =>
+ Promise.resolve({
+ [ORIGIN]: {
+ accessToken: 'session-token-value',
+ expiresAt: '2026-08-09T11:00:00.000Z',
+ ...credential,
+ },
+ });
+}
+
+interface Recorded {
+ url: string;
+ headers: Headers;
+ body: unknown;
+}
+
+function transport(response: Response) {
+ const requests: Recorded[] = [];
+ const send: typeof globalThis.fetch = async (input, init) => {
+ requests.push({
+ // The adapter only ever passes a string URL; narrowed rather than stringified.
+ url: typeof input === 'string' ? input : 'url' in input ? input.url : input.href,
+ headers: new Headers(init?.headers),
+ body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined,
+ });
+ return response;
+ };
+ return { send, requests };
+}
+
+function rpc(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify({ json: body }), {
+ status,
+ headers: { 'content-type': 'application/json' },
+ });
+}
+
+describe('runRemotely', () => {
+ it('presents the stored session as a bearer token on the RPC transport', async () => {
+ const { send, requests } = transport(rpc({ id: 'cz_1', state: 'completed' }));
+
+ const outcome = await runRemotely(REQUEST, {
+ fetch: send,
+ credentials: credentials({}),
+ now: () => NOW,
+ });
+
+ expect(outcome).toEqual({ ok: true, result: { id: 'cz_1', state: 'completed' } });
+ expect(requests[0]?.url).toBe(`${ORIGIN}/rpc/tasks/create`);
+ expect(requests[0]?.headers.get('authorization')).toBe('Bearer session-token-value');
+ // Only the RPC transport resolves a session, and its CSRF guard reads this header.
+ expect(requests[0]?.headers.get('sec-fetch-mode')).toBe('cors');
+ });
+
+ it('sends the run the operator asked for, and no feedback for a proactive one', async () => {
+ const { send, requests } = transport(rpc({ id: 'cz_1', state: 'completed' }));
+
+ await runRemotely(REQUEST, { fetch: send, credentials: credentials({}), now: () => NOW });
+
+ expect(requests[0]?.body).toEqual({
+ json: {
+ repository: '/srv/checkouts/acme-app',
+ mode: 'observe',
+ trigger: 'proactive',
+ },
+ });
+ });
+
+ it('carries the feedback when the run is triggered by one', async () => {
+ const { send, requests } = transport(rpc({ id: 'cz_1', state: 'completed' }));
+
+ await runRemotely(
+ { ...REQUEST, trigger: 'feedback', feedback: 'Possible null dereference' },
+ { fetch: send, credentials: credentials({}), now: () => NOW },
+ );
+
+ expect(requests[0]?.body).toMatchObject({ json: { feedback: 'Possible null dereference' } });
+ });
+
+ it('sends nothing at all when this machine holds no session for the deployment', async () => {
+ const { send, requests } = transport(rpc({ id: 'cz_1', state: 'completed' }));
+
+ const outcome = await runRemotely(REQUEST, { fetch: send, credentials: credentials() });
+
+ expect(outcome).toEqual({ ok: false, failure: { kind: 'signed-out' } });
+ expect(requests).toEqual([]);
+ });
+
+ it('recognises an expired session offline, rather than spending a round trip on it', async () => {
+ const { send, requests } = transport(rpc({ id: 'cz_1', state: 'completed' }));
+
+ const outcome = await runRemotely(REQUEST, {
+ fetch: send,
+ credentials: credentials({ expiresAt: '2026-08-09T09:00:00.000Z' }),
+ now: () => NOW,
+ });
+
+ expect(outcome).toEqual({ ok: false, failure: { kind: 'expired' } });
+ expect(requests).toEqual([]);
+ });
+
+ it('treats an unparseable expiry as expired rather than as valid', async () => {
+ const { send } = transport(rpc({ id: 'cz_1', state: 'completed' }));
+
+ const outcome = await runRemotely(REQUEST, {
+ fetch: send,
+ credentials: credentials({ expiresAt: 'whenever' }),
+ now: () => NOW,
+ });
+
+ expect(outcome).toEqual({ ok: false, failure: { kind: 'expired' } });
+ });
+
+ it('reports the rule the deployment refused on', async () => {
+ const { send } = transport(
+ rpc({ code: 'FORBIDDEN', message: 'Repository is not allow-listed for task creation' }, 403),
+ );
+
+ const outcome = await runRemotely(REQUEST, {
+ fetch: send,
+ credentials: credentials({}),
+ now: () => NOW,
+ });
+
+ expect(outcome).toEqual({
+ ok: false,
+ failure: {
+ kind: 'refused',
+ message: 'Repository is not allow-listed for task creation',
+ },
+ });
+ });
+
+ it('reads a rejected session as expired, so the advice is to sign in again', async () => {
+ const { send } = transport(rpc({ code: 'UNAUTHORIZED' }, 401));
+
+ const outcome = await runRemotely(REQUEST, {
+ fetch: send,
+ credentials: credentials({}),
+ now: () => NOW,
+ });
+
+ expect(outcome).toEqual({ ok: false, failure: { kind: 'expired' } });
+ });
+
+ it('refuses an answer that is not a result, which must never reach the exit-code table', async () => {
+ const { send } = transport(rpc({ queued: true }));
+
+ const outcome = await runRemotely(REQUEST, {
+ fetch: send,
+ credentials: credentials({}),
+ now: () => NOW,
+ });
+
+ expect(outcome).toMatchObject({ ok: false, failure: { kind: 'refused' } });
+ });
+
+ it('names the unreachable deployment instead of throwing at the operator', async () => {
+ const outcome = await runRemotely(REQUEST, {
+ fetch: () => Promise.reject(new Error('ECONNREFUSED')),
+ credentials: credentials({}),
+ now: () => NOW,
+ });
+
+ expect(outcome).toMatchObject({
+ ok: false,
+ failure: { kind: 'refused', message: expect.stringContaining(ORIGIN) },
+ });
+ });
+});
diff --git a/packages/cli/src/remote.ts b/packages/cli/src/remote.ts
new file mode 100644
index 0000000..7a556c0
--- /dev/null
+++ b/packages/cli/src/remote.ts
@@ -0,0 +1,130 @@
+import type { RunMode, TaskResult } from '@code-zero/shared';
+
+import { readCredentials, type StoredCredential } from './credentials.js';
+
+/** What a remote run needs, resolved before anything is sent. */
+export interface RemoteRunRequest {
+ origin: string;
+ repository: string;
+ mode: RunMode;
+ trigger: 'feedback' | 'proactive';
+ feedback?: string;
+}
+
+export interface RemoteRunOptions {
+ /** Injected so the tests drive this without a network, like every other adapter here. */
+ fetch?: typeof globalThis.fetch;
+ credentials?: () => Promise>;
+ now?: () => number;
+}
+
+/**
+ * A refusal a person can act on, rather than a status code.
+ *
+ * `signed-out` and `expired` are separated because the remedy differs in wording only for the
+ * reader — both end at `zero login`, but being told a session expired is the difference between
+ * "this is broken" and "this is normal".
+ */
+type RemoteRunFailure =
+ | { kind: 'signed-out' }
+ | { kind: 'expired' }
+ | { kind: 'refused'; message: string };
+
+export type RemoteRunOutcome =
+ | { ok: true; result: TaskResult }
+ | { ok: false; failure: RemoteRunFailure };
+
+/**
+ * Queue a run on a deployment's control plane and wait for its result.
+ *
+ * The session from `zero login` is presented as a bearer token, which is what Better Auth's bearer
+ * plugin accepts — the same credential the browser carries as a cookie, so a run started here is
+ * attributed to the person who signed in rather than to a shared operator token.
+ *
+ * `/rpc/**` rather than `/api/v1/**`: only the RPC transport resolves a session, because it is the
+ * same-origin surface. Its CSRF guard reads `Sec-Fetch-Mode`, a header a browser attaches on its
+ * own and a non-browser client has to state, which is what this sends.
+ *
+ * The call is deliberately synchronous with the run: `tasks.create` answers with the finished
+ * result, so `--remote` reports and exits exactly like a local run instead of leaving an operator
+ * to go find out what happened.
+ */
+export async function runRemotely(
+ request: RemoteRunRequest,
+ options: RemoteRunOptions = {},
+): Promise {
+ const store = await (options.credentials ?? readCredentials)();
+ const credential = store[request.origin];
+ if (!credential) return { ok: false, failure: { kind: 'signed-out' } };
+
+ const expiresAt = Date.parse(credential.expiresAt);
+ const now = (options.now ?? Date.now)();
+ if (Number.isNaN(expiresAt) || expiresAt <= now)
+ return { ok: false, failure: { kind: 'expired' } };
+
+ const send = options.fetch ?? globalThis.fetch;
+ let response: Response;
+ try {
+ response = await send(`${request.origin}/rpc/tasks/create`, {
+ method: 'POST',
+ headers: {
+ authorization: `Bearer ${credential.accessToken}`,
+ 'content-type': 'application/json',
+ // The transport's CSRF guard exists for browsers; a CLI states what a browser would send.
+ 'sec-fetch-mode': 'cors',
+ },
+ body: JSON.stringify({
+ json: {
+ repository: request.repository,
+ mode: request.mode,
+ trigger: request.trigger,
+ ...(request.feedback === undefined ? {} : { feedback: request.feedback }),
+ },
+ }),
+ });
+ } catch (error) {
+ return {
+ ok: false,
+ failure: { kind: 'refused', message: `${request.origin} is unreachable: ${String(error)}` },
+ };
+ }
+
+ const payload: unknown = await response.json().catch(() => undefined);
+ const body = unwrap(payload);
+ if (response.status === 401) return { ok: false, failure: { kind: 'expired' } };
+ if (!response.ok) {
+ // The deployment's own message names the rule it refused on — an unlisted repository, a mode
+ // the account was not granted — which is the one thing the operator has to act on.
+ const message = readString(body, 'message') ?? `The control plane refused the run.`;
+ return { ok: false, failure: { kind: 'refused', message } };
+ }
+ if (!isTaskResult(body))
+ return {
+ ok: false,
+ failure: { kind: 'refused', message: 'The control plane answered with an unusable result.' },
+ };
+ return { ok: true, result: body };
+}
+
+/** The RPC transport wraps both results and errors in `json`. */
+function unwrap(payload: unknown): unknown {
+ return isRecord(payload) && 'json' in payload ? payload.json : payload;
+}
+
+/**
+ * Checked, not asserted: this is a remote answer, and the caller maps `state` onto an exit code CI
+ * reads. A shape that is not a result must not be able to exit `0`.
+ */
+function isTaskResult(value: unknown): value is TaskResult {
+ return isRecord(value) && typeof value.id === 'string' && typeof value.state === 'string';
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function readString(value: unknown, key: string): string | undefined {
+ if (!isRecord(value)) return undefined;
+ const entry = value[key];
+ return typeof entry === 'string' && entry.length > 0 ? entry : undefined;
+}
From bf170ae137afd773cbd055bd735a0974a51d1e0b Mon Sep 17 00:00:00 2001
From: RedStar071
Date: Sat, 5 Sep 2026 20:20:15 +0000
Subject: [PATCH 09/15] test(dashboard): cover the write signal the live board
depends on
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The whole live board rests on one property — every task write announces itself —
and nothing tested it. It could not be tested either: the notification was a
subclass of the KV-backed store, so reaching it meant reaching the deployment's
filesystem driver.
`observeWrites` is that subclass turned into a decorator over any `TaskStore`, so
a test drives it against an in-memory one. The tests state the parts that matter:
it announces every write, only after the write landed, and says nothing when the
write failed — a listener re-reading the store on a failed write would find
nothing changed and a listener told too early would read the previous state.
Forwarding `clear` went with it: `PersistentTaskStore` has none, so it was a
capability the wrapper invented for nobody.
`docs/architecture.md` gains the live-state paragraph the plan asked for, and
`docs/PLAN.md` records what was built, the three places the plan was departed
from and why, and the five things still owed — the browser review among them.
Verified: 997 tests across every package and app, lint:ci, typecheck, check:repo,
i18n:report, and the build all pass. The docs build needs a larger heap than this
sandbox allows by default and passes with one; nothing in this branch touches it
beyond a one-line table.
Co-Authored-By: Claude Opus 5 (1M context)
---
apps/dashboard/server/utils/store.ts | 39 ++++++++----
apps/dashboard/test/unit/store.test.ts | 83 ++++++++++++++++++++++++++
docs/PLAN.md | 34 +++++++++++
docs/architecture.md | 2 +
4 files changed, 145 insertions(+), 13 deletions(-)
create mode 100644 apps/dashboard/test/unit/store.test.ts
diff --git a/apps/dashboard/server/utils/store.ts b/apps/dashboard/server/utils/store.ts
index f9b2ba2..192c64a 100644
--- a/apps/dashboard/server/utils/store.ts
+++ b/apps/dashboard/server/utils/store.ts
@@ -64,23 +64,36 @@ export const taskChanges = new EventEmitter().setMaxListeners(0);
export const TASK_CHANGED = 'changed';
/**
- * The task store, plus a signal after each write.
+ * The same store, announcing each write once it has landed.
*
- * Wrapping the write here rather than in `packages/api` keeps the notification where the
- * connections are: the store contract stays a plain persistence interface, and the package that
- * owns it holds no transport concern. Every writer — the router's `tasks.create`, the webhook
- * route, and the run itself as it records lifecycle events — goes through this one instance, so
- * subscribing to it observes the whole lifecycle and not only the transitions a transport happens
- * to see.
+ * A decorator rather than a subclass: it composes over any {@link TaskStore}, which is what lets a
+ * test drive it against an in-memory one instead of the deployment's KV. The notification fires
+ * after the write resolves, so a subscriber that re-reads the store cannot observe the state from
+ * before it.
+ *
+ * Wrapping here rather than in `packages/api` keeps the notification where the connections are:
+ * the store contract stays a plain persistence interface, and the package that owns it holds no
+ * transport concern.
*/
-class ObservedTaskStore extends PersistentTaskStore {
- override async save(task: StoredTask): Promise {
- await super.save(task);
- taskChanges.emit(TASK_CHANGED);
- }
+export function observeWrites(store: TaskStore, notify: () => void): TaskStore {
+ return {
+ get: (id) => store.get(id),
+ list: () => store.list(),
+ async save(task: StoredTask): Promise {
+ await store.save(task);
+ notify();
+ },
+ };
}
-export const taskStore: TaskStore = new ObservedTaskStore(storage);
+/**
+ * Every writer — the router's `tasks.create`, the webhook route, the poller, and the run itself as
+ * it records lifecycle events — goes through this one instance, so subscribing to it observes the
+ * whole lifecycle and not only the transitions one transport happens to see.
+ */
+export const taskStore: TaskStore = observeWrites(new PersistentTaskStore(storage), () => {
+ taskChanges.emit(TASK_CHANGED);
+});
/**
* The one durable delivery-claim store for this deployment, injected as
diff --git a/apps/dashboard/test/unit/store.test.ts b/apps/dashboard/test/unit/store.test.ts
new file mode 100644
index 0000000..f8e0498
--- /dev/null
+++ b/apps/dashboard/test/unit/store.test.ts
@@ -0,0 +1,83 @@
+import type { StoredTask, TaskStore } from '@code-zero/api';
+import { describe, expect, it } from 'vitest';
+
+import { observeWrites } from '../../server/utils/store.js';
+
+const TASK: StoredTask = {
+ id: 'cz_alpha_0001',
+ repository: 'acme/checkout',
+ status: 'queued',
+ createdAt: '2026-08-09T09:00:00.000Z',
+ updatedAt: '2026-08-09T09:00:00.000Z',
+ events: [],
+};
+
+/** An in-memory stand-in, so this covers the wrapper rather than the deployment's KV driver. */
+function memoryStore(): TaskStore & { readonly saved: StoredTask[] } {
+ const saved: StoredTask[] = [];
+ return {
+ saved,
+ get: (id) => Promise.resolve(saved.find((task) => task.id === id)),
+ list: () => Promise.resolve([...saved]),
+ save: async (task) => void saved.push(task),
+ };
+}
+
+describe('observeWrites', () => {
+ it('announces every write, which is what a connected board is waiting on', async () => {
+ let notified = 0;
+ const store = observeWrites(memoryStore(), () => {
+ notified += 1;
+ });
+
+ await store.save(TASK);
+ await store.save({ ...TASK, status: 'running' });
+
+ expect(notified).toBe(2);
+ });
+
+ it('announces only after the write landed, so a listener cannot read the old state', async () => {
+ const order: string[] = [];
+ const store = observeWrites(
+ {
+ get: () => Promise.resolve(undefined),
+ list: () => Promise.resolve([]),
+ save: async () => {
+ await Promise.resolve();
+ order.push('saved');
+ },
+ },
+ () => order.push('notified'),
+ );
+
+ await store.save(TASK);
+
+ expect(order).toEqual(['saved', 'notified']);
+ });
+
+ it('says nothing when the write failed, because nothing changed to look at', async () => {
+ let notified = 0;
+ const store = observeWrites(
+ {
+ get: () => Promise.resolve(undefined),
+ list: () => Promise.resolve([]),
+ save: () => Promise.reject(new Error('storage unavailable')),
+ },
+ () => {
+ notified += 1;
+ },
+ );
+
+ await expect(store.save(TASK)).rejects.toThrow('storage unavailable');
+ expect(notified).toBe(0);
+ });
+
+ it('reads straight through, so a subscriber re-reading sees what was written', async () => {
+ const store = observeWrites(memoryStore(), () => undefined);
+
+ await store.save(TASK);
+
+ await expect(store.get(TASK.id)).resolves.toEqual(TASK);
+ await expect(store.list()).resolves.toEqual([TASK]);
+ });
+});
diff --git a/docs/PLAN.md b/docs/PLAN.md
index 4cdceb6..ba87b16 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -114,6 +114,40 @@ Fatto quando: `zero run --proactive` da terminale compare nella Board entro un s
- `docs/architecture.md`: sezione "Live state" che descrive SSE e poller.
- Test: uno per l'emitter dello store, uno per `events.get`, uno Playwright per approve.
+## Stato al 2026-09-05
+
+Fasi 0-5 eseguite. Cosa è cambiato rispetto al piano, e perché:
+
+- **Fase 0** — `dev:solo` legge `apps/dashboard/.env.solo` con `--dotenv`, invece di variabili
+ inline. Il file è versionato: non contiene nulla che valga la pena tenere fuori dal repository.
+ `bin/check` del kit non è stato copiato: `aube run lint:ci`, `typecheck` e `test` fanno già
+ quel lavoro, e gli hook husky esistono già.
+- **Fase 1** — fatta come previsto. Il contratto di `tasks.create` non è stato cambiato: il record
+ viene salvato prima di essere schedulato, quindi la board lo vede comunque comparire subito, e
+ cambiarlo avrebbe rotto i chiamanti REST e i run su serverless.
+- **Fase 2** — approvazioni e form fatti. Il repository si digita invece di sceglierlo da una
+ lista: l'allow-list sono percorsi di checkout lato server, che i record persistiti tengono
+ deliberatamente fuori portata. `DESIGN.md` non è stato scritto.
+- **Fase 3** — fatta. Niente worktree: lo scheduler limita già a un run per repository, che era la
+ ragione per cui il piano li voleva.
+- **Fase 4** — `--remote` è un flag esplicito, non l'inferenza da `CODE_ZERO_URL` che il piano
+ proponeva: quella variabile sceglie già su quale deployment agiscono `login` e `logout`, e
+ dedurne "esegui altrove" sposterebbe il run di qualcuno in silenzio.
+- **Fase 5** — fatta, tranne il riordino di `.env.example`, reso inutile da `.env.solo`.
+
+Trovato strada facendo: l'allow-list dei repository esisteva solo se erano configurati anche i
+token operatore, quindi un deployment con sole sessioni non poteva creare nessun task. Corretto.
+
+## Cosa resta
+
+| Cosa | Perché non è stato fatto |
+| ---------------------------------------- | --------------------------------------------------------------- |
+| Review visiva con `nuxt-frontend-review` | l'ambiente di sviluppo non ha un host di automazione browser |
+| `DESIGN.md` | previsto in Fase 2, non scritto |
+| Un test Playwright per l'approvazione | coperto da 7 test di componente; l'e2e resta da aggiungere |
+| Un test della route `/api/events` | verificata dal vivo; il pezzo testabile è l'emitter dello store |
+| Una passata del poller su GitHub vero | nessuna credenziale qui, e i test non devono toccare la rete |
+
## Rimandato, e quando
| Cosa | Quando |
diff --git a/docs/architecture.md b/docs/architecture.md
index 5e25960..4790d6e 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -109,6 +109,8 @@ It differs from the dashboard in exactly one respect. The dashboard renders with
Mutations fail closed behind operator-issued bearer credentials (`CODE_ZERO_CONTROL_PLANE_TOKENS`, comma-separated `name:token` pairs). `tasks.create` additionally requires the target repository path to appear in `CODE_ZERO_CONTROL_PLANE_REPOSITORIES`, so an HTTP caller cannot point a run at an arbitrary server-local path, and the requested execution mode to be granted to the principal via `CODE_ZERO_CONTROL_PLANE_MODES` (comma-separated `name:mode|mode` grants; without one a principal is limited to the non-writable `observe` and `suggest` modes). Approval decisions record the authenticated principal's name rather than a wire-supplied actor. Reads stay open for the dashboard. This bearer-token scheme is independent of the Better Auth session that protects the dashboard UI itself.
+The dashboard follows that work as it happens rather than polling for it. `GET /api/events` streams the aggregate overview over Server-Sent Events behind the same session the page needs, and pushes whenever a task record is written — which every writer does through one store instance, so a subscriber observes the whole lifecycle and not only the transitions one transport happens to see. Writes are coalesced, so a run recording ten events in a burst sends one overview. Each message is the whole overview rather than a delta: the page renders the aggregate anyway, and a reconnecting client can take the next message as the truth instead of needing a replay log. The notification is process-local, so a second server instance pushes its own writes and not another's; removing that limit needs a shared pub/sub backend, not a change here.
+
Work reaches the control plane two ways, and both end at the same `runTask`. `POST /webhooks/github` is the push-based path, driven by a delivery an operator's provider sends. `server/plugins/poller.ts` is the pull-based one, for a deployment with no public URL to receive deliveries on: it lists each watched repository's open pull requests on an interval and starts a review for every head commit it has not started one for. The two share the durable `DeliveryClaimStore`, so a commit reviewed through one is never reviewed again through the other. The poller is off unless `CODE_ZERO_POLL_REPOSITORIES` names something, requests only the non-writable `observe` or `suggest` mode, and reaches a checkout only through the path an operator paired with the repository — it never derives one. Because it holds an interval in the server process, it belongs to a deployment that stays up rather than a serverless one.
Task persistence is a narrow `KeyValueStorage` contract adapted over the ViteHub KV Runtime Helper (`apps/dashboard/nuxt.config.ts` registers `vite-hub/nuxt`, composing ViteHub into Nuxt's own Nitro build), so the filesystem driver, Cloudflare KV, Deno KV, or Upstash stays interchangeable. Records are redacted on the way in and hold no review input and no checkout path, so task history cannot become a credential or filesystem leak. `TaskScheduler` bounds concurrency globally and per repository, and rejects work once the queue is exhausted rather than growing without limit.
From 06968dfcff627ffc121dd53fd605b5a8530598d0 Mon Sep 17 00:00:00 2001
From: RedStar071
Date: Sat, 5 Sep 2026 20:29:34 +0000
Subject: [PATCH 10/15] chore: add VS Code CLI binaries
- Add the `code` executable and VS Code CLI archive
---
code | Bin 0 -> 34190560 bytes
vscode_cli.tar.gz | Bin 0 -> 12689426 bytes
2 files changed, 0 insertions(+), 0 deletions(-)
create mode 100755 code
create mode 100644 vscode_cli.tar.gz
diff --git a/code b/code
new file mode 100755
index 0000000000000000000000000000000000000000..78e451c9752f662f24d60f50244779a0e40ebba1
GIT binary patch
literal 34190560
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