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..d52faa6 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/composables/useLiveOverview.ts b/apps/dashboard/modules/dashboard/composables/useLiveOverview.ts
new file mode 100644
index 0000000..e21ae4c
--- /dev/null
+++ b/apps/dashboard/modules/dashboard/composables/useLiveOverview.ts
@@ -0,0 +1,111 @@
+// Imported explicitly rather than relying on Nuxt auto-imports, so the dependency stays visible at
+// the call site, the same way `modules/audit/composables/useAuditLogs.ts` does it.
+import { useQueryClient } from '@tanstack/vue-query';
+import { computed, onBeforeUnmount, onMounted, readonly, ref, type Ref } from 'vue';
+
+import type { DashboardOverview } from '../types/dashboard.js';
+
+/**
+ * How long a stream may stay quiet before the page stops presenting its data as current. Longer
+ * than the server's 20s heartbeat, so an idle-but-healthy connection is never called stale.
+ */
+const STALE_AFTER_MS = 45_000;
+
+/** Matches the server's own reconnect expectations without hammering it after a restart. */
+const RECONNECT_DELAY_MS = 1_500;
+
+export interface LiveOverview {
+ /** Whether the stream is currently carrying updates. */
+ connected: Readonly>;
+ /**
+ * Whether the last message is old enough that the page should say so.
+ *
+ * Old data on a monitoring surface is worse than none, because it still looks authoritative.
+ */
+ stale: Readonly>;
+}
+
+/**
+ * Keeps the dashboard overview current from `/api/events`, writing each message straight into the
+ * TanStack Query cache the page already reads.
+ *
+ * `setQueryData` rather than `invalidateQueries`: the message *is* the new overview, so refetching
+ * would ask the server for what it just sent. The query itself stays the loader for the first
+ * paint and for a client that never gets a stream open.
+ *
+ * Takes the query key rather than reaching for `useNuxtApp().$orpc`, so nothing here depends on
+ * the Nuxt app instance: the page already holds the typed client, and a composable that takes what
+ * it needs is one the unit suite can drive without standing up a runtime to inject it.
+ *
+ * Client-only. `EventSource` does not exist during SSR, and a server render has no window in which
+ * a later message could arrive anyway.
+ */
+export function useLiveOverview(queryKey: readonly unknown[]): LiveOverview {
+ const queryClient = useQueryClient();
+
+ const connected = ref(false);
+ const lastMessageAt = ref(0);
+ const now = ref(Date.now());
+
+ const stale = computed(
+ () => lastMessageAt.value > 0 && now.value - lastMessageAt.value > STALE_AFTER_MS,
+ );
+
+ if (import.meta.client) {
+ let source: EventSource | undefined;
+ let reconnect: ReturnType | undefined;
+ const clock = setInterval(() => {
+ now.value = Date.now();
+ }, 1_000);
+
+ const open = (): void => {
+ const stream = new EventSource('/api/events');
+ source = stream;
+ stream.addEventListener('open', () => {
+ connected.value = true;
+ });
+ stream.addEventListener('message', (message: MessageEvent) => {
+ connected.value = true;
+ lastMessageAt.value = Date.now();
+ now.value = lastMessageAt.value;
+ try {
+ const parsed: unknown = JSON.parse(message.data);
+ // Checked rather than asserted: the cache this writes into is what the page renders, so
+ // a frame that is not an overview has to be dropped instead of blanking the board.
+ if (isOverview(parsed)) queryClient.setQueryData(queryKey, parsed);
+ } catch {
+ // A truncated frame is not worth tearing the connection down for: the next message
+ // carries the whole overview again.
+ }
+ });
+ // Fires for a dropped connection and for a refused one alike. `EventSource` retries on its
+ // own, but not after the server closed the stream deliberately, so the reconnect is explicit.
+ stream.addEventListener('error', () => {
+ connected.value = false;
+ stream.close();
+ if (reconnect === undefined)
+ reconnect = setTimeout(() => {
+ reconnect = undefined;
+ open();
+ }, RECONNECT_DELAY_MS);
+ });
+ };
+
+ onMounted(open);
+ onBeforeUnmount(() => {
+ clearInterval(clock);
+ if (reconnect !== undefined) clearTimeout(reconnect);
+ source?.close();
+ connected.value = false;
+ });
+ }
+
+ return { connected: readonly(connected), stale: readonly(stale) };
+}
+
+/** The one field the page cannot render without; everything else is counters it defaults to zero. */
+function isOverview(value: unknown): value is DashboardOverview {
+ return (
+ typeof value === 'object' && value !== null && 'tasks' in value && Array.isArray(value.tasks)
+ );
+}
diff --git a/apps/dashboard/modules/dashboard/index.ts b/apps/dashboard/modules/dashboard/index.ts
index e016a9b..bae238d 100644
--- a/apps/dashboard/modules/dashboard/index.ts
+++ b/apps/dashboard/modules/dashboard/index.ts
@@ -1,10 +1,13 @@
-import { addComponentsDir, createResolver, defineNuxtModule } from 'nuxt/kit';
+import { addComponentsDir, addImportsDir, createResolver, defineNuxtModule } from 'nuxt/kit';
/**
* Registers `dashboard/components` (runner metrics and the task table, timeline, status, and
* inspector). Unprefixed, because the scanner already derives one from the nested directory:
* `components/task/Table.vue` is ``.
*
+ * `dashboard/composables` (useLiveOverview) is registered the same way, so the page reaches it
+ * without a path import back into this module.
+ *
* `dashboard/types` stays a path import (`~~/modules/dashboard/types/dashboard`): it carries types
* only, so auto-importing it would register nothing at runtime.
*/
@@ -16,5 +19,6 @@ export default defineNuxtModule({
const resolver = createResolver(import.meta.url);
addComponentsDir({ path: resolver.resolve('./components') });
+ addImportsDir(resolver.resolve('./composables'));
},
});
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/nuxt.config.ts b/apps/dashboard/nuxt.config.ts
index de47fc3..201fbf3 100644
--- a/apps/dashboard/nuxt.config.ts
+++ b/apps/dashboard/nuxt.config.ts
@@ -229,7 +229,7 @@ export default defineNuxtConfig({
routeRules: {
'/': { appLayout: 'default', auth: { only: 'user' } },
// A session is enough to reach the page; the admin role is enforced by the endpoint it reads
- // (`server/api/audit-logs.get.ts`), so a non-admin sees the refusal rather than a redirect.
+ // (the router's `audit.list`), so a non-admin sees the refusal rather than a redirect.
'/audit': { appLayout: 'default', auth: { only: 'user' } },
'/login': { auth: { only: 'guest' } },
'/signin': { auth: { only: 'guest' } },
diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json
index ddf0fb2..2e7169f 100644
--- a/apps/dashboard/package.json
+++ b/apps/dashboard/package.json
@@ -6,6 +6,7 @@
"build": "nuxt build",
"clean": "nuxt cleanup && tsc -b --clean",
"dev": "nuxt dev",
+ "dev:solo": "nuxt dev --dotenv .env.solo",
"lint": "oxlint --config ../../tooling/oxc/apps.oxlintrc.json --type-aware --type-check --ignore-pattern \"test/nuxt/components/**\" --ignore-pattern \"test/nuxt/pages/**\" app config modules server test",
"lint:fix": "oxlint --fix --config ../../tooling/oxc/apps.oxlintrc.json --type-aware --type-check --ignore-pattern \"test/nuxt/components/**\" --ignore-pattern \"test/nuxt/pages/**\" app config modules server test",
"prelint": "nuxt prepare",
@@ -29,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/api/audit-logs.get.ts b/apps/dashboard/server/api/audit-logs.get.ts
deleted file mode 100644
index e0d66fd..0000000
--- a/apps/dashboard/server/api/audit-logs.get.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { ADMIN_USER_ROLE } from '@code-zero/auth/config';
-
-/**
- * The dashboard's read side of the audit trail, for signed-in administrators only.
- *
- * Deliberately a Nitro route rather than an oRPC procedure. Reads on `rpcRouter` are open by
- * design and CORS-exposed under `/api/v1/**`, and the router authenticates operator tokens, not
- * the browser session a dashboard user actually carries — an audit procedure there would either
- * be world-readable or unreachable from the page. A same-origin route behind the Better Auth
- * cookie is the narrowest guard available, and it keeps the trail out of the public REST surface.
- */
-export default defineEventHandler(async (event) => {
- // Raises 401 when the request carries no session.
- const session = await requireUserSession(event);
- if (!rolesOf(session.user).includes(ADMIN_USER_ROLE))
- throw errors.forbidden('Reading the audit log requires the admin role');
-
- const query = getQuery(event);
- // A repeated query parameter arrives as an array, so both are read as strings or ignored: the
- // store clamps a page size it is given, and an unparseable one falls back to its own default
- // rather than reaching it as NaN.
- const limit = typeof query.limit === 'string' ? Number.parseInt(query.limit, 10) : Number.NaN;
- const cursor = typeof query.cursor === 'string' && query.cursor ? query.cursor : undefined;
- try {
- return await auditLogStore.list({
- ...(Number.isFinite(limit) ? { limit } : {}),
- ...(cursor ? { cursor } : {}),
- });
- } catch (error) {
- throw errors.internal(error);
- }
-});
-
-/**
- * The roles carried by a session's user.
- *
- * `role` is one of `@code-zero/auth`'s Better Auth `additionalFields`, which the module's
- * `AuthUser` type does not reflect, hence the narrow structural read rather than a wider cast of
- * the session itself. Better Auth stores multiple roles as one comma-separated string, so
- * membership is a split rather than an equality check. Anything that is not a string — an absent
- * field, a schema that drifted — yields no roles at all, so the caller fails closed.
- */
-function rolesOf(user: unknown): string[] {
- if (typeof user !== 'object' || user === null || !('role' in user)) return [];
- const role: unknown = user.role;
- return typeof role === 'string' ? role.split(',').map((entry) => entry.trim()) : [];
-}
diff --git a/apps/dashboard/server/api/events.get.ts b/apps/dashboard/server/api/events.get.ts
new file mode 100644
index 0000000..ebeaa9f
--- /dev/null
+++ b/apps/dashboard/server/api/events.get.ts
@@ -0,0 +1,74 @@
+import { dashboardOverview } from '@code-zero/api';
+
+/** Coalesces a burst of writes into one push. A run records several lifecycle events in a row. */
+const PUSH_DELAY_MS = 250;
+
+/**
+ * An empty `heartbeat` message every 20s. Nothing reads it: it exists so an idle connection keeps
+ * producing bytes, which is what stops a proxy from reclaiming it as dead during a long quiet run.
+ * Named rather than unnamed so it never reaches the client's `message` handler as an empty
+ * overview — `EventSource` only delivers a named event to a listener that asked for it.
+ */
+const HEARTBEAT_MS = 20_000;
+
+/**
+ * The dashboard overview as it changes, over Server-Sent Events.
+ *
+ * A Nitro route rather than an oRPC procedure, for the reason `audit-logs.get.ts` is one: this is
+ * the browser session's surface, not the operator token's. `dashboard.overview` stays the way any
+ * other caller reads the same data, and a page that has this stream never has to poll it.
+ *
+ * Each message is the whole overview rather than a delta. The page renders the aggregate anyway,
+ * so a delta would only add a way for the two to disagree, and a reconnecting client would need a
+ * replay log to catch up rather than simply taking the next message as the truth.
+ */
+export default defineEventHandler(async (event) => {
+ // Raises 401 when the request carries no session, so the stream is no more readable than the
+ // page it feeds.
+ await requireUserSession(event);
+
+ const stream = createEventStream(event);
+ let closed = false;
+ let pushTimer: ReturnType | undefined;
+
+ async function push(): Promise {
+ if (closed) return;
+ try {
+ await stream.push(JSON.stringify(dashboardOverview(await taskStore.list())));
+ } catch {
+ // The client went away between the write landing and this read finishing. Nothing to
+ // report: `onClosed` below is what tears the subscription down.
+ }
+ }
+
+ /**
+ * A write only ever schedules a push, never performs one, so a run that records ten events in a
+ * few milliseconds sends one overview rather than ten.
+ */
+ function schedulePush(): void {
+ if (closed || pushTimer) return;
+ pushTimer = setTimeout(() => {
+ pushTimer = undefined;
+ void push();
+ }, PUSH_DELAY_MS);
+ }
+
+ const heartbeat = setInterval(() => {
+ if (!closed) void stream.push({ event: 'heartbeat', data: '' }).catch(() => undefined);
+ }, HEARTBEAT_MS);
+
+ taskChanges.on(TASK_CHANGED, schedulePush);
+ stream.onClosed(() => {
+ closed = true;
+ taskChanges.off(TASK_CHANGED, schedulePush);
+ clearInterval(heartbeat);
+ if (pushTimer) clearTimeout(pushTimer);
+ });
+
+ // The current state before any change, so a page that connects mid-run renders immediately
+ // rather than staying empty until something else happens. Scheduled rather than awaited: `send()`
+ // is what puts the response on the wire, and a push that ran before it would be waiting for a
+ // reader that does not exist yet — the request would hang without ever answering.
+ schedulePush();
+ return stream.send();
+});
diff --git a/apps/dashboard/server/api/v1/[...].ts b/apps/dashboard/server/api/v1/[...].ts
index 780ec44..dc47614 100644
--- a/apps/dashboard/server/api/v1/[...].ts
+++ b/apps/dashboard/server/api/v1/[...].ts
@@ -37,7 +37,7 @@ const openApiSpec = generator.generate(rpcRouter, {
const handler = new OpenAPIHandler(rpcRouter, {
plugins: [
new CORSHandlerPlugin({ origin: controlPlaneOriginsFromEnvironment() }),
- new EvlogHandlerPlugin({ storage: requestLoggerStorage }),
+ new EvlogHandlerPlugin({ storage: requestLoggerStorage, plugins: auditPlugins }),
new OpenAPIReferenceHandlerPlugin({
docsPath: '/docs',
specPath: '/openapi.json',
@@ -53,7 +53,7 @@ export default defineEventHandler(async (event) => {
try {
const { matched, response } = await handler.handle(request, {
prefix: '/api/v1',
- context: { ...buildRpcContext(request, access, taskStore), audit: auditRecorder },
+ context: { ...buildRpcContext(request, access, taskStore), auditLog: auditLogStore },
});
if (matched) return response;
} catch (error) {
diff --git a/apps/dashboard/server/auth.config.ts b/apps/dashboard/server/auth.config.ts
index 865a5e0..cc20eeb 100644
--- a/apps/dashboard/server/auth.config.ts
+++ b/apps/dashboard/server/auth.config.ts
@@ -75,18 +75,21 @@ const options = authBetterAuthOptions({
});
/**
- * `AUTH_E2E_MEMORY` swaps the Postgres adapter for an in-memory one. Set only by the Playwright
+ * `AUTH_E2E_MEMORY` swaps the Postgres adapter for an in-memory one. Two callers set it, both of
+ * which own the whole server process and throw its store away when they exit: the Playwright
* preview server (`start:playwright:webserver`, see `playwright.config.ts`), so the e2e suite in
* `test/e2e/test-utils.ts` can sign up and sign in its own throwaway account through the real
- * `/api/auth/**` endpoints without a live database, staying off the network and off mutable
- * external state. `AUTH_DATABASE_URL` still has to resolve to build `options` above, but nothing
- * ever queries it once `database` is overridden here.
+ * `/api/auth/**` endpoints without a live database; and `dev:solo` (`.env.solo`), so the dashboard
+ * starts from a fresh clone without one either. Both stay off the network and off mutable external
+ * state. `AUTH_DATABASE_URL` still has to resolve to build `options` above, but nothing ever
+ * queries it once `database` is overridden here.
*
* Deliberately not guarded by `NODE_ENV`: `nuxt preview` — the command this app's own e2e suite
* runs, per `start:playwright:webserver` above — sets `NODE_ENV=production` whenever it isn't
* already set (`@nuxt/cli`'s `preview` command), identically to a real deployment's built output.
* A `NODE_ENV === 'production'` check would therefore reject every e2e run, not just a leaked
- * flag. Keep this variable out of any shared `.env`/CI template that a real deployment also reads.
+ * flag. Keep this variable out of any shared `.env`/CI template that a real deployment also reads —
+ * `.env.solo` is not one: `nuxt` loads it only when a command names it with `--dotenv`.
*/
export default defineServerAuth(
process.env.AUTH_E2E_MEMORY === 'true'
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/routes/rpc/[...].ts b/apps/dashboard/server/routes/rpc/[...].ts
index a367e6e..764a2df 100644
--- a/apps/dashboard/server/routes/rpc/[...].ts
+++ b/apps/dashboard/server/routes/rpc/[...].ts
@@ -18,7 +18,7 @@ const handler = new RPCHandler(rpcRouter, {
// cross-site form submission cannot forge — no client-side plugin is needed to satisfy it, see
// `app/plugins/orpc.client.ts` and `orpc.server.ts`.
new SimpleCsrfProtectionHandlerPlugin(),
- new EvlogHandlerPlugin({ storage: requestLoggerStorage }),
+ new EvlogHandlerPlugin({ storage: requestLoggerStorage, plugins: auditPlugins }),
],
});
// Fails closed: without configured tokens every mutation is rejected while reads stay open.
@@ -44,7 +44,7 @@ export default defineEventHandler(async (event) => {
prefix: '/rpc',
context: {
...buildRpcContext(request, access, taskStore, serverAuth(event)),
- audit: auditRecorder,
+ auditLog: auditLogStore,
},
});
if (matched) return response;
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/server/utils/store.ts b/apps/dashboard/server/utils/store.ts
index 074ad13..192c64a 100644
--- a/apps/dashboard/server/utils/store.ts
+++ b/apps/dashboard/server/utils/store.ts
@@ -1,14 +1,17 @@
+import { EventEmitter } from 'node:events';
+
import {
- createAuditRecorder,
+ auditLogPlugins,
PersistentAuditLogStore,
PersistentDeliveryClaimStore,
PersistentTaskStore,
type AuditLogStore,
- type AuditRecorder,
type DeliveryClaimStore,
type KeyValueStorage,
+ type StoredTask,
type TaskStore,
} from '@code-zero/api';
+import type { EvlogPlugin } from 'evlog';
import { kv } from 'vite-hub/kv';
/** Adapts the ViteHub KV Runtime Helper to the transport-neutral {@link KeyValueStorage} contract. */
@@ -43,7 +46,54 @@ class KvKeyValueStorage implements KeyValueStorage {
*/
const storage: KeyValueStorage = new KvKeyValueStorage();
-export const taskStore: TaskStore = new PersistentTaskStore(storage);
+/**
+ * Announces that a task record changed, so `server/api/events.get.ts` can push the overview to
+ * every connected dashboard instead of waiting for someone to press refresh.
+ *
+ * Process-local on purpose. It carries no payload and is not a message bus: a listener re-reads
+ * the store, which is the durable copy every instance shares. A second server instance therefore
+ * pushes its own writes and not this one's — the same limitation the page had when it polled, and
+ * one only a shared pub/sub backend would remove.
+ *
+ * The listener cap is lifted because a listener is one open browser tab, not a leak; each stream
+ * removes its own in `onClosed`.
+ */
+export const taskChanges = new EventEmitter().setMaxListeners(0);
+
+/** The event `taskChanges` emits. Named once so a subscriber cannot misspell it. */
+export const TASK_CHANGED = 'changed';
+
+/**
+ * The same store, announcing each write once it has landed.
+ *
+ * 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.
+ */
+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();
+ },
+ };
+}
+
+/**
+ * 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
@@ -58,15 +108,26 @@ export const taskStore: TaskStore = new PersistentTaskStore(storage);
export const deliveryClaimStore: DeliveryClaimStore = new PersistentDeliveryClaimStore(storage);
/**
- * The durable audit trail, read by `server/api/audit-logs.get.ts` and written by the procedures
- * both transports serve. It shares the deployment's KV backend with task history rather than
+ * The durable audit trail, read by the router's `audit.list` and written by the evlog drain in
+ * {@link auditPlugins}. It shares the deployment's KV backend with task history rather than
* opening a store of its own, so an audit record survives a restart exactly as a task does.
*/
export const auditLogStore: AuditLogStore = new PersistentAuditLogStore(storage);
/**
- * One recorder per server process, injected into the RPC context by both transports. Built here
- * rather than in `context.ts` because it is a deployment-owned capability, like the stores above,
- * and because the recorder must be the same instance for every request the process serves.
+ * The evlog plugins that carry `log.audit()` from a procedure to {@link auditLogStore}, handed to
+ * `EvlogHandlerPlugin` by both transports.
+ *
+ * Built here rather than in each route because it is a deployment-owned capability, like the
+ * stores above, and because both transports must install the same pipeline — a trail that
+ * depended on which wire protocol a caller reached for would be worse than none.
*/
-export const auditRecorder: AuditRecorder = createAuditRecorder({ store: auditLogStore });
+export const auditPlugins: EvlogPlugin[] = auditLogPlugins({
+ store: auditLogStore,
+ // The drain fails open, so a lost record would otherwise be silent. Nitro's console is the one
+ // place this process can still report to at that point: the request it belonged to has already
+ // been answered.
+ onError: (error) => {
+ console.error('[audit] failed to append an audit record', error);
+ },
+});
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/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/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/apps/docs/content/1.guide/10.api/1.overview.md b/apps/docs/content/1.guide/10.api/1.overview.md
index 4d34c91..4594820 100644
--- a/apps/docs/content/1.guide/10.api/1.overview.md
+++ b/apps/docs/content/1.guide/10.api/1.overview.md
@@ -4,11 +4,11 @@ title: API overview
Code Zero exposes one typed router — `rpcRouter` from `packages/api` — served over two wire protocols by the dashboard's Nitro server. Authorization behaves identically either way, because both transports serve the exact same procedures.
-| Surface | Purpose |
-| -------------- | ----------------------------------------------------------------------------------------------------- |
-| `/rpc/**` | Typed oRPC router: `health`, `dashboard.overview`, `tasks.list/get/create`, `approvals.decide` |
-| `/api/v1/**` | The same router over OpenAPI/REST; interactive docs at `/api/v1/docs`, spec at `/api/v1/openapi.json` |
-| `/api/auth/**` | The Better Auth handler (see [Authentication](/guide/authentication/overview)) |
+| Surface | Purpose |
+| -------------- | ------------------------------------------------------------------------------------------------------------ |
+| `/rpc/**` | Typed oRPC router: `health`, `dashboard.overview`, `tasks.list/get/create`, `approvals.decide`, `audit.list` |
+| `/api/v1/**` | The same router over OpenAPI/REST; interactive docs at `/api/v1/docs`, spec at `/api/v1/openapi.json` |
+| `/api/auth/**` | The Better Auth handler (see [Authentication](/guide/authentication/overview)) |
## The API package
diff --git a/code b/code
new file mode 100755
index 0000000..78e451c
Binary files /dev/null and b/code differ
diff --git a/docs/PLAN.md b/docs/PLAN.md
new file mode 100644
index 0000000..ba87b16
--- /dev/null
+++ b/docs/PLAN.md
@@ -0,0 +1,168 @@
+# 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.
+
+## 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 |
+| -------------------------------------- | ------------------------------------------------------------ |
+| 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.
diff --git a/docs/architecture.md b/docs/architecture.md
index f0185f7..4790d6e 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -77,7 +77,7 @@ A subscription transport also owns the one failure that repairs itself. A spent
## API package
-`packages/api` is the library `apps/dashboard`'s server reads from: it composes the agent runtime, source-control adapter, model abstraction, and config into one typed oRPC router (`health`, `tasks.list`, `tasks.get`, `tasks.create`, `approvals.decide`) and a control-plane operations layer (`runTask`, `TaskScheduler`, `TaskStore`). It holds no HTTP host of its own and does not depend on `packages/auth` — `apps/dashboard/server/` is the only place that constructs a transport handler from it, which keeps the router and its authorization rules identical regardless of which wire protocol serves a given request.
+`packages/api` is the library `apps/dashboard`'s server reads from: it composes the agent runtime, source-control adapter, model abstraction, and config into one typed oRPC router (`health`, `tasks.list`, `tasks.get`, `tasks.create`, `approvals.decide`, `audit.list`) and a control-plane operations layer (`runTask`, `TaskScheduler`, `TaskStore`). It holds no HTTP host of its own and does not depend on `packages/auth` — `apps/dashboard/server/` is the only place that constructs a transport handler from it, which keeps the router and its authorization rules identical regardless of which wire protocol serves a given request.
Procedures validate at the boundary with Zod and then delegate; they never invoke a shell or touch a checkout, because `runTask` is the only place that resolves policy and constructs a runner. A hosted `RunnerPool` lease is optional and still yields nothing but a `Runner`. `EvlogHandlerPlugin`, shared by every transport through one `AsyncLocalStorage`-backed logger (`packages/api/src/orpc/logging.ts`), attaches structured request logs; procedures read it defensively (`requestLoggerStorage?.getStore()?.set(...)`) so router tests that call procedures directly through `createRouterClient`, without a transport's plugin attached, still pass.
@@ -109,6 +109,10 @@ 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.
Transport concerns stop at the route handlers: headers, status mapping, and request objects never reach a runtime package.
diff --git a/package.json b/package.json
index 3b9c6ba..d59e862 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,7 @@
"db:generate": "turbo run db:generate",
"db:migrate": "node --env-file-if-exists=apps/dashboard/.env node_modules/turbo/bin/turbo run db:migrate",
"dev": "turbo run dev",
+ "dev:solo": "turbo run dev:solo --filter=@code-zero/dashboard",
"format": "oxfmt -c tooling/oxc/.oxfmtrc.json --ignore-path .oxfmtignore .",
"format:check": "oxfmt -c tooling/oxc/.oxfmtrc.json --ignore-path .oxfmtignore --check .",
"i18n:report": "turbo run i18n:report",
diff --git a/packages/api/src/access.test.ts b/packages/api/src/access.test.ts
index 23f9b64..8960e22 100644
--- a/packages/api/src/access.test.ts
+++ b/packages/api/src/access.test.ts
@@ -19,7 +19,12 @@ function access(overrides: Partial = {}): ControlPlaneAccess
principals: new Map([
[
'token-value',
- { name: 'release-manager', kind: 'token' as const, modes: ['observe', 'suggest'] as const },
+ {
+ name: 'release-manager',
+ kind: 'token' as const,
+ modes: ['observe', 'suggest'] as const,
+ admin: false,
+ },
],
]),
repositories: ['/srv/checkout'],
@@ -28,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', () => {
@@ -96,6 +111,7 @@ describe('authenticate', () => {
expect(authenticate('Bearer token-value', access())).toEqual({
name: 'release-manager',
kind: 'token',
+ admin: false,
modes: ['observe', 'suggest'],
});
});
@@ -149,6 +165,7 @@ describe('sessionPrincipal', () => {
expect(sessionPrincipal('ops@example.test', true)).toEqual({
name: 'ops@example.test',
kind: 'session',
+ admin: true,
modes: ['observe', 'suggest', 'fix', 'autonomous'],
});
});
@@ -157,6 +174,7 @@ describe('sessionPrincipal', () => {
expect(sessionPrincipal('dev@example.test', false)).toEqual({
name: 'dev@example.test',
kind: 'session',
+ admin: false,
modes: ['observe', 'suggest'],
});
});
diff --git a/packages/api/src/access.ts b/packages/api/src/access.ts
index 29dd468..64716a0 100644
--- a/packages/api/src/access.ts
+++ b/packages/api/src/access.ts
@@ -19,6 +19,17 @@ export interface Principal {
kind: PrincipalKind;
/** Execution modes this principal may request from `tasks.create`. */
modes: readonly RunMode[];
+ /**
+ * Whether the caller may read surfaces reserved for an app-wide administrator, `audit.list`
+ * being the one today.
+ *
+ * Carried explicitly rather than inferred from {@link Principal.modes}: the two grants answer
+ * different questions — what a caller may run, and what a caller may see — and a reader of this
+ * type should not have to learn that holding `autonomous` happens to imply the second.
+ * Operator tokens are never administrators: the trail records who used them, so letting a token
+ * read it back would let one audit itself.
+ */
+ admin: boolean;
}
/**
@@ -60,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(':');
@@ -81,21 +105,22 @@ export function accessFromEnvironment(
if (name === '' || token === '')
throw new Error('CODE_ZERO_CONTROL_PLANE_TOKENS entries must be name:token pairs');
names.add(name);
- principals.set(token, { name, kind: 'token', modes: grants.get(name) ?? DEFAULT_MODES });
+ principals.set(token, {
+ name,
+ kind: 'token',
+ modes: grants.get(name) ?? DEFAULT_MODES,
+ // An operator token is a machine credential the trail records the use of; reading the trail
+ // back is a person's surface, reached with a session.
+ 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. */
@@ -169,7 +194,7 @@ export function authenticate(
* way — see {@link mayTargetRepository}.
*/
export function sessionPrincipal(name: string, isAdmin: boolean): Principal {
- return { name, kind: 'session', modes: isAdmin ? ADMIN_MODES : DEFAULT_MODES };
+ return { name, kind: 'session', modes: isAdmin ? ADMIN_MODES : DEFAULT_MODES, admin: isAdmin };
}
/** Whether task creation may target this repository path. Fails closed without a policy. */
diff --git a/packages/api/src/audit.test.ts b/packages/api/src/audit.test.ts
index bbf0044..ae19a3f 100644
--- a/packages/api/src/audit.test.ts
+++ b/packages/api/src/audit.test.ts
@@ -1,10 +1,10 @@
+import type { AuditFields, DrainContext } from 'evlog';
import { describe, expect, it } from 'vitest';
import {
- createAuditRecorder,
+ auditLogDrain,
MemoryAuditLogStore,
PersistentAuditLogStore,
- type AuditEntryInput,
type AuditEvent,
type AuditLogStore,
} from './audit.js';
@@ -37,7 +37,7 @@ function event(id: string, occurredAt: string, overrides: Partial =
return {
id,
occurredAt,
- actor: { kind: 'principal', name: 'release-manager' },
+ actor: { type: 'api', id: 'release-manager' },
action: 'task.created',
outcome: 'success',
...overrides,
@@ -122,18 +122,16 @@ describe.each([
});
describe('audit persistence', () => {
- it('redacts secrets carried in metadata before the record reaches storage', async () => {
+ it('redacts secrets carried in the reason before the record reaches storage', async () => {
const storage = new RecordingStorage();
const store = new PersistentAuditLogStore(storage, ['ghp_supersecret']);
- await store.append(
- event('audit_1', FIRST, { metadata: { reason: 'token ghp_supersecret rejected' } }),
- );
+ await store.append(event('audit_1', FIRST, { reason: 'token ghp_supersecret rejected' }));
const [persisted] = [...storage.values.values()];
expect(JSON.stringify(persisted)).not.toContain('ghp_supersecret');
const page = await store.list();
- expect(page.events[0]?.metadata?.reason).toBe('token [redacted] rejected');
+ expect(page.events[0]?.reason).toBe('token [redacted] rejected');
});
it('refuses to persist a record that is not an audit event', async () => {
@@ -162,9 +160,9 @@ describe('audit persistence', () => {
it.each([
['an outcome outside the union', { outcome: 'approved' }],
- ['an actor kind outside the union', { actor: { kind: 'admin', name: 'root' } }],
- ['a subject missing its id', { subject: { type: 'task' } }],
- ['metadata that is not a flat string map', { metadata: { nested: { deep: 'value' } } }],
+ ['an actor type outside the union', { actor: { type: 'admin', id: 'root' } }],
+ ['an actor missing its id', { actor: { type: 'user' } }],
+ ['a target missing its id', { target: { type: 'task' } }],
])('refuses to persist %s', async (_name, overrides) => {
const store = new PersistentAuditLogStore(new RecordingStorage());
// oxlint-disable-next-line no-unsafe-type-assertion -- deliberately invalid input under test
@@ -202,54 +200,70 @@ describe('audit persistence', () => {
});
});
-describe('audit recorder', () => {
- const entry: AuditEntryInput = {
- actor: { kind: 'principal', name: 'release-manager' },
+describe('audit log drain', () => {
+ const fields = {
+ actor: { type: 'api', id: 'release-manager' },
action: 'task.created',
outcome: 'success',
- subject: { type: 'task', id: 'cz_1' },
- metadata: { repository: 'acme/app', mode: 'observe' },
- };
+ target: { type: 'task', id: 'cz_1', repository: 'acme/app', mode: 'observe' },
+ } as const;
+
+ /** The shape a drain receives: one wide event, with the audit fields `log.audit()` set on it. */
+ function drained(audit?: AuditFields, timestamp?: string): DrainContext {
+ return {
+ event: {
+ timestamp: timestamp ?? FIRST,
+ level: 'info',
+ service: 'app',
+ environment: 'test',
+ ...(audit ? { audit } : {}),
+ },
+ };
+ }
+
+ it('appends the audit fields the wide event carried', async () => {
+ const store = new MemoryAuditLogStore();
+
+ await auditLogDrain({ store, id: () => 'audit_1' })(drained({ ...fields }));
+
+ expect(store.records).toEqual([{ id: 'audit_1', occurredAt: FIRST, ...fields }]);
+ });
+
+ it('takes the identity from the idempotency key, so a retried delivery appends once', async () => {
+ const store = new MemoryAuditLogStore();
+ const drain = auditLogDrain({ store, id: () => 'audit_unused' });
+ const context = drained({ ...fields, idempotencyKey: 'ak_1' });
+
+ await drain(context);
+ await drain(context);
- it('mints the identity and the timestamp the call site does not supply', async () => {
+ expect(store.records.map((record) => record.id)).toEqual(['ak_1']);
+ });
+
+ it('ignores a wide event that carries no audit fields', async () => {
const store = new MemoryAuditLogStore();
- const recorder = createAuditRecorder({ store, now: () => FIRST, id: () => 'audit_1' });
- await recorder.record(entry);
+ await auditLogDrain({ store })(drained());
- expect(store.records).toEqual([{ id: 'audit_1', occurredAt: FIRST, ...entry }]);
+ expect(store.records).toEqual([]);
});
it('resolves and reports the loss when the durable write fails', async () => {
const failures: unknown[] = [];
- const recorder = createAuditRecorder({
- store: {
- async append(): Promise {
- throw new Error('storage unavailable');
- },
- async list() {
- return { events: [], nextCursor: null };
- },
- },
+ const drain = auditLogDrain({
+ store: failingStore(),
onError: (error) => failures.push(error),
});
- // The mutation this records already committed: rejecting here would report a failure for
- // work that actually happened.
- await expect(recorder.record(entry)).resolves.toBeUndefined();
+ // The mutation this records already committed: rejecting here would fail a request whose work
+ // actually happened.
+ await expect(drain(drained({ ...fields }))).resolves.toBeUndefined();
expect(String(failures[0])).toContain('storage unavailable');
});
it('still resolves when the failure observer itself throws', async () => {
- const recorder = createAuditRecorder({
- store: {
- async append(): Promise {
- throw new Error('storage unavailable');
- },
- async list() {
- return { events: [], nextCursor: null };
- },
- },
+ const drain = auditLogDrain({
+ store: failingStore(),
onError: () => {
throw new Error('reporter unavailable');
},
@@ -257,6 +271,17 @@ describe('audit recorder', () => {
// Failing open has to survive a broken observer too, or the reporting path becomes the way a
// committed mutation gets reported as failed.
- await expect(recorder.record(entry)).resolves.toBeUndefined();
+ await expect(drain(drained({ ...fields }))).resolves.toBeUndefined();
});
});
+
+function failingStore(): AuditLogStore {
+ return {
+ async append(): Promise {
+ throw new Error('storage unavailable');
+ },
+ async list() {
+ return { events: [], nextCursor: null };
+ },
+ };
+}
diff --git a/packages/api/src/audit.ts b/packages/api/src/audit.ts
index 40040d6..c776d7e 100644
--- a/packages/api/src/audit.ts
+++ b/packages/api/src/audit.ts
@@ -1,67 +1,46 @@
import { randomUUID } from 'node:crypto';
import { now, redactSecrets, secretValuesFromEnvironment } from '@code-zero/shared';
+import { auditOnly, drainPlugin, enricherPlugin, auditEnricher } from 'evlog';
+import type { AuditFields, DrainFn, EvlogPlugin } from 'evlog';
import type { KeyValueStorage } from './control-plane.js';
-import { requestLoggerStorage } from './orpc/logging.js';
/**
- * Who performed an audited action.
+ * Who performed an audited action, in evlog's vocabulary.
*
- * `principal` is an operator token presented by a machine caller; `user` is the
- * session-authenticated dashboard user. The router derives which one from the authenticated
- * principal's own kind, so a reader never has to guess whether an actor was a human or a token —
- * they are revoked through different channels, and the trail has to say which one to go turn off.
+ * `api` is an operator token presented by a machine caller; `user` is the session-authenticated
+ * dashboard user. The router derives which one from the authenticated principal's own kind, so a
+ * reader never has to guess whether an actor was a human or a token — they are revoked through
+ * different channels, and the trail has to say which one to go turn off.
*/
-export type AuditActorKind = 'principal' | 'user' | 'webhook' | 'system';
-
-export interface AuditActor {
- kind: AuditActorKind;
- name: string;
-}
+export type AuditActor = AuditFields['actor'];
/** Whether the audited attempt went through, was refused by policy, or failed while running. */
-export type AuditOutcome = 'success' | 'denied' | 'failure';
+export type AuditOutcome = AuditFields['outcome'];
/**
* One audited action, appended once and never rewritten.
*
+ * The persisted shape is evlog's own {@link AuditFields} plus the two fields a durable log needs
+ * that a wide event does not carry: the storage identity and when it happened. Recording goes
+ * through `log.audit()`, so this package neither defines a second audit vocabulary nor a second
+ * way to write one — what the trail stores is what the wide event carried.
+ *
* The actor is denormalized onto the record rather than referenced, following the same reasoning
* as `invite_use` in `@code-zero/database`: an audit record states who did what at a moment that
* has already passed, and it has to keep saying so after the token is revoked or the account it
* names is deleted. There is no `updatedAt` for the same reason — a mutable timestamp would
* suggest the record can be corrected, and a correctable audit trail is not one.
*/
-export interface AuditEvent {
+export interface AuditEvent extends AuditFields {
+ /**
+ * `idempotencyKey` when `log.audit()` derived one, so a delivery retried across drains lands on
+ * the key it already wrote rather than appending a second copy of the same action.
+ */
id: string;
/** ISO-8601, so keys built from it sort chronologically as plain strings. */
occurredAt: string;
- /**
- * Never accepted from the wire. Transports derive it from the authenticated caller, the same
- * rule `operations.ts` states for approval actors: a caller that can name itself can frame
- * somebody else.
- */
- actor: AuditActor;
- /** Dotted past-tense action, e.g. `task.created`; the attempted form for a denial. */
- action: string;
- subject?: { type: string; id: string };
- outcome: AuditOutcome;
- /**
- * Flat string map by design. Nested or non-string values would make the records awkward to
- * render in one table and, worse, would let a value through that redaction does not reach.
- */
- metadata?: Record;
-}
-
-/** What call sites supply; the recorder mints the identity and the timestamp. */
-export type AuditEntryInput = Omit;
-
-export interface AuditRecorder {
- /**
- * Records one action. Never rejects: see {@link createAuditRecorder} for why an audit write
- * failure must not turn an already-committed mutation into an error response.
- */
- record(entry: AuditEntryInput): Promise;
}
export interface AuditLogPage {
@@ -165,57 +144,79 @@ export class MemoryAuditLogStore implements AuditLogStore {
}
}
-export interface AuditRecorderOptions {
+export interface AuditLogPipelineOptions {
+ /** Where drained audit records are appended. */
store: AuditLogStore;
/** Injectable clock and identity, so tests assert exact records instead of ignoring them. */
now?: () => string;
id?: () => string;
- /** Observes a failed durable write; the wide event carries it either way. */
+ /** Observes a failed durable write; the wide event carries the action either way. */
onError?: (error: unknown) => void;
}
/**
- * Builds the recorder transports inject into {@link RpcContext}.
+ * The evlog plugins that turn `log.audit()` into a durable, readable trail.
+ *
+ * Handed to `EvlogHandlerPlugin`'s `plugins` option by each transport, rather than to its `drain`
+ * option: a plugin drain runs *alongside* the handler's own drain, so the request line a
+ * deployment already ships to stdout or an aggregator is untouched by adding this.
*
- * Every recorded action is also set on the request's evlog wide event, so one request line
- * carries the action alongside the principal and the route — the log answers "what did this
- * request change" without a join against the durable log. `getStore()` reads the
- * AsyncLocalStorage directly rather than the throwing `useLogger()`, for the same reason the
- * `authenticated` middleware does: it is `undefined` outside an active request, which is exactly
- * the case for procedures exercised through `createRouterClient` without the transport plugin.
+ * Two plugins, in the order they run:
*
- * The durable write fails open. By the time a call site records, its mutation has already
- * committed; rejecting here would report failure for work that actually happened, which is a
- * worse lie than a missing audit line. The loss is not silent — it lands on the wide event and
- * on {@link AuditRecorderOptions.onError}.
+ * 1. {@link auditEnricher} fills `audit.context` (requestId, traceId, ip, user agent) from the
+ * request the action happened on. A trail that says who did what is worth more when it also
+ * says from where, and none of it is something a call site should have to pass by hand.
+ * 2. {@link auditOnly} filters every wide event that carries no `audit` field, so ordinary request
+ * lines never reach the trail, and awaits the append so the record is flushed before the
+ * request resolves — an audited mutation that answered 200 must not lose its record to a
+ * process that exited first.
+ *
+ * The durable write still fails open. By the time a drain runs, the mutation it describes has
+ * already committed and the response is already decided; throwing here would turn a completed
+ * action into a crash rather than un-doing anything. The loss is not silent — it reaches
+ * {@link AuditLogPipelineOptions.onError}.
*/
-export function createAuditRecorder(options: AuditRecorderOptions): AuditRecorder {
+export function auditLogPlugins(options: AuditLogPipelineOptions): EvlogPlugin[] {
+ return [
+ enricherPlugin('code-zero-audit-context', auditEnricher()),
+ drainPlugin('code-zero-audit-log', auditOnly(auditLogDrain(options), { await: true })),
+ ];
+}
+
+/**
+ * The drain that appends one wide event's audit fields to the log.
+ *
+ * Exported for tests and for a composition root that wires its own pipeline; ordinary callers take
+ * {@link auditLogPlugins}, which is this wrapped in the filter and the enricher it expects.
+ */
+export function auditLogDrain(options: AuditLogPipelineOptions): DrainFn {
const timestamp = options.now ?? now;
const identifier = options.id ?? (() => `audit_${randomUUID()}`);
- return {
- async record(entry: AuditEntryInput): Promise {
- const event: AuditEvent = { id: identifier(), occurredAt: timestamp(), ...entry };
- requestLoggerStorage?.getStore()?.set({
- audit: {
- action: event.action,
- outcome: event.outcome,
- ...(event.subject ? { subject: `${event.subject.type}:${event.subject.id}` } : {}),
- },
- });
+ return async ({ event }) => {
+ // `auditOnly` already filters these out, but a drain that assumes its wrapper is a drain that
+ // writes junk the first time someone composes it differently.
+ const fields = event.audit;
+ if (!fields) return;
+ const record: AuditEvent = {
+ ...fields,
+ // A retried delivery re-derives the same idempotency key, and the store refuses to overwrite
+ // an existing one, so the retry is a no-op rather than a duplicate line in the trail.
+ id: fields.idempotencyKey ?? identifier(),
+ // The wide event's own timestamp, so the trail agrees with the request line it came from.
+ occurredAt: typeof event.timestamp === 'string' ? event.timestamp : timestamp(),
+ };
+ try {
+ await options.store.append(record);
+ } catch (error) {
+ // The observer is a courtesy, not a second chance to fail: a throwing `onError` would reject
+ // the drain and, with `await: true`, surface as a failure on a request whose mutation had
+ // already committed — the exact outcome failing open exists to prevent.
try {
- await options.store.append(event);
- } catch (error) {
- requestLoggerStorage?.getStore()?.set({ auditWriteError: String(error) });
- // The observer is a courtesy, not a second chance to fail: a throwing `onError` would
- // reject this call and turn an already-committed mutation into an error response, which
- // is the exact outcome failing open exists to prevent.
- try {
- options.onError?.(error);
- } catch {
- requestLoggerStorage?.getStore()?.set({ auditErrorHandlerFailed: true });
- }
+ options.onError?.(error);
+ } catch {
+ // Nothing left to report it to.
}
- },
+ }
};
}
@@ -237,7 +238,7 @@ function sanitizeEvent(event: AuditEvent, secrets: readonly string[]): AuditEven
return value;
}
-const ACTOR_KINDS = new Set(['principal', 'user', 'webhook', 'system']);
+const ACTOR_TYPES = new Set(['user', 'system', 'api', 'agent']);
const OUTCOMES = new Set(['success', 'denied', 'failure']);
/**
@@ -245,9 +246,10 @@ const OUTCOMES = new Set(['success', 'denied', 'failure']);
*
* `list` returns whatever survives this predicate as an {@link AuditEvent}, so a check that only
* asks whether `outcome` is a string would hand a reader an `outcome` no renderer has a branch
- * for. The unions and the optional objects are validated exactly, and `metadata` is held to the
- * flat string map its contract promises — a nested value there is also one redaction never
- * reached.
+ * for. The unions and the nested objects are validated exactly. Everything evlog may add beyond
+ * them — `changes`, `context`, the signing fields — is left unvalidated on purpose: it is
+ * evlog's schema to evolve, and a predicate that rejected a field this version has not heard of
+ * would drop records rather than render them.
*/
function isAuditEvent(value: unknown): value is AuditEvent {
if (!isRecord(value)) return false;
@@ -258,30 +260,24 @@ function isAuditEvent(value: unknown): value is AuditEvent {
typeof value.outcome === 'string' &&
OUTCOMES.has(value.outcome) &&
isAuditActor(value.actor) &&
- isAuditSubject(value.subject) &&
- isAuditMetadata(value.metadata)
+ isAuditTarget(value.target)
);
}
function isAuditActor(value: unknown): boolean {
return (
isRecord(value) &&
- typeof value.kind === 'string' &&
- ACTOR_KINDS.has(value.kind) &&
- typeof value.name === 'string'
+ typeof value.type === 'string' &&
+ ACTOR_TYPES.has(value.type) &&
+ typeof value.id === 'string'
);
}
-function isAuditSubject(value: unknown): boolean {
+function isAuditTarget(value: unknown): boolean {
if (value === undefined) return true;
return isRecord(value) && typeof value.type === 'string' && typeof value.id === 'string';
}
-function isAuditMetadata(value: unknown): boolean {
- if (value === undefined) return true;
- return isRecord(value) && Object.values(value).every((entry) => typeof entry === 'string');
-}
-
function isRecord(value: unknown): value is Record {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
index 8b174a7..b78e617 100644
--- a/packages/api/src/index.ts
+++ b/packages/api/src/index.ts
@@ -55,19 +55,17 @@ export {
type TaskStore,
} from './control-plane.js';
export {
- createAuditRecorder,
+ auditLogDrain,
+ auditLogPlugins,
MemoryAuditLogStore,
PersistentAuditLogStore,
type AuditActor,
- type AuditActorKind,
- type AuditEntryInput,
type AuditEvent,
type AuditLogPage,
+ type AuditLogPipelineOptions,
type AuditLogQuery,
type AuditLogStore,
type AuditOutcome,
- type AuditRecorder,
- type AuditRecorderOptions,
} from './audit.js';
export { dashboardOverview, type DashboardOverview } from './dashboard.js';
export {
diff --git a/packages/api/src/orpc/router.test.ts b/packages/api/src/orpc/router.test.ts
index db0bbf1..5dede77 100644
--- a/packages/api/src/orpc/router.test.ts
+++ b/packages/api/src/orpc/router.test.ts
@@ -1,14 +1,21 @@
import { createRouterClient } from '@orpc/server';
-import { createRequestLogger } from 'evlog';
-import { beforeEach, describe, expect, it } from 'vitest';
+import { createRequestLogger, initLogger, mockAudit, type MockAudit } from 'evlog';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { Principal } from '../access.js';
-import type { AuditEntryInput, AuditRecorder } from '../audit.js';
+import { MemoryAuditLogStore, type AuditEvent } from '../audit.js';
import { MemoryTaskStore, type StoredTask } from '../control-plane.js';
import type { BetterAuthSessionApi } from './auth.js';
import { requestLoggerStorage } from './logging.js';
import { rpcRouter } from './router.js';
+/**
+ * Emitting a wide event publishes it, and evlog writes one to the console by default, which would
+ * bury this suite's output. Nothing here asserts on that output: `mockAudit` collects the audit
+ * fields as the event is finalised, which is the step a deployment's own drain reads.
+ */
+initLogger({ silent: true });
+
const TIMESTAMP = '2026-08-09T10:00:00.000Z';
const VALIDATION_ERROR = /validation/i;
const APPROVAL_ERROR = /awaiting human review/i;
@@ -16,9 +23,16 @@ const UNAUTHORIZED_ERROR = /authentication required/i;
const FORBIDDEN_ERROR = /not allow-listed/i;
const MODE_ERROR = /not granted/i;
const STORAGE_ERROR = /storage unavailable/i;
+const ADMIN_ERROR = /admin role/i;
+const NO_AUDIT_LOG_ERROR = /keeps no audit log/i;
let store: MemoryTaskStore;
-let audited: AuditEntryInput[];
+let auditLog: MemoryAuditLogStore;
+/**
+ * evlog's own capture helper, so the assertions below read the audit events the router actually
+ * emitted through `log.audit()` rather than a hand-rolled recorder double standing in for it.
+ */
+let audited: MockAudit;
interface ClientOptions {
principal?: Principal;
@@ -27,13 +41,6 @@ interface ClientOptions {
allowRepository?: boolean;
}
-/** Collects what the router recorded; the durable store has its own tests in `audit.test.ts`. */
-const recorder: AuditRecorder = {
- async record(entry) {
- audited.push(entry);
- },
-};
-
/** A server-side client exercises every procedure without opening a network port. */
function client(options: ClientOptions = {}) {
return createRouterClient(rpcRouter, {
@@ -43,7 +50,7 @@ function client(options: ClientOptions = {}) {
...(options.auth ? { auth: options.auth } : {}),
...(options.reqHeaders ? { reqHeaders: options.reqHeaders } : {}),
mayTargetRepository: () => options.allowRepository ?? false,
- audit: recorder,
+ auditLog,
},
});
}
@@ -65,17 +72,19 @@ function failingStoreClient(options: ClientOptions = {}) {
},
...(options.principal ? { principal: options.principal } : {}),
mayTargetRepository: () => options.allowRepository ?? false,
- audit: recorder,
+ auditLog,
},
});
}
-/** Deliberately omits the recorder: the audit trail is an optional capability, not a requirement. */
+/** Deliberately omits the log: reading the trail back is an optional capability, not a requirement. */
function unaudited(options: ClientOptions = {}) {
return createRouterClient(rpcRouter, {
context: {
store,
...(options.principal ? { principal: options.principal } : {}),
+ ...(options.auth ? { auth: options.auth } : {}),
+ ...(options.reqHeaders ? { reqHeaders: options.reqHeaders } : {}),
mayTargetRepository: () => options.allowRepository ?? false,
},
});
@@ -90,7 +99,18 @@ function unaudited(options: ClientOptions = {}) {
* tolerance is what would let the plugin be dropped from a handler without anything failing.
*/
function instrumented(run: () => Promise): Promise {
- return requestLoggerStorage ? requestLoggerStorage.run(createRequestLogger(), run) : run();
+ if (!requestLoggerStorage) return run();
+ const logger = createRequestLogger();
+ return requestLoggerStorage.run(logger, async () => {
+ try {
+ return await run();
+ } finally {
+ // `log.audit()` sets fields on the wide event; evlog finalises and publishes them when the
+ // event is emitted, which a transport does at the end of the request. Emitting here is what
+ // makes `mockAudit` observe exactly what a deployment's drain would receive.
+ logger.emit();
+ }
+ });
}
function operator() {
@@ -99,6 +119,7 @@ function operator() {
name: 'release-manager',
kind: 'token',
modes: ['observe', 'suggest', 'fix', 'autonomous'],
+ admin: false,
},
allowRepository: true,
});
@@ -116,6 +137,16 @@ function betterAuth(user: { email: string; role: string } | null): BetterAuthSes
};
}
+function auditRecord(id: string, occurredAt: string): AuditEvent {
+ return {
+ id,
+ occurredAt,
+ actor: { type: 'user', id: 'ops@example.test' },
+ action: 'approval.decided',
+ outcome: 'success',
+ };
+}
+
function awaiting(id: string): StoredTask {
return {
id,
@@ -129,7 +160,12 @@ function awaiting(id: string): StoredTask {
beforeEach(() => {
store = new MemoryTaskStore();
- audited = [];
+ auditLog = new MemoryAuditLogStore();
+ audited = mockAudit();
+});
+
+afterEach(() => {
+ audited.restore();
});
describe('rpc router', () => {
@@ -184,7 +220,12 @@ describe('rpc router', () => {
await expect(
instrumented(() =>
client({
- principal: { name: 'release-manager', kind: 'token', modes: ['autonomous'] },
+ principal: {
+ name: 'release-manager',
+ kind: 'token',
+ modes: ['autonomous'],
+ admin: false,
+ },
}).tasks.create({
repository: '/etc',
feedback: 'x',
@@ -197,7 +238,7 @@ describe('rpc router', () => {
it('refuses an execution mode outside the principal grant', async () => {
const readOnly = client({
- principal: { name: 'ci', kind: 'token', modes: ['observe', 'suggest'] },
+ principal: { name: 'ci', kind: 'token', modes: ['observe', 'suggest'], admin: false },
allowRepository: true,
});
await expect(
@@ -285,7 +326,9 @@ describe('rpc audit trail', () => {
it('records a repository refusal against the principal that attempted it', async () => {
await expect(
instrumented(() =>
- client({ principal: { name: 'ci', kind: 'token', modes: ['autonomous'] } }).tasks.create({
+ client({
+ principal: { name: 'ci', kind: 'token', modes: ['autonomous'], admin: false },
+ }).tasks.create({
repository: '/etc',
feedback: 'x',
mode: 'autonomous',
@@ -293,12 +336,13 @@ describe('rpc audit trail', () => {
),
).rejects.toThrow(FORBIDDEN_ERROR);
- expect(audited).toEqual([
+ expect(audited.events).toMatchObject([
{
- actor: { kind: 'principal', name: 'ci' },
+ actor: { type: 'api', id: 'ci' },
action: 'task.create',
outcome: 'denied',
- metadata: { repository: '/etc', reason: 'repository-not-allow-listed' },
+ reason: 'Repository is not allow-listed for task creation',
+ target: { type: 'repository', id: '/etc' },
},
]);
});
@@ -307,18 +351,19 @@ describe('rpc audit trail', () => {
await expect(
instrumented(() =>
client({
- principal: { name: 'ci', kind: 'token', modes: ['observe'] },
+ principal: { name: 'ci', kind: 'token', modes: ['observe'], admin: false },
allowRepository: true,
}).tasks.create({ repository: '.', feedback: 'x', mode: 'fix' }),
),
).rejects.toThrow(MODE_ERROR);
- expect(audited).toEqual([
+ expect(audited.events).toMatchObject([
{
- actor: { kind: 'principal', name: 'ci' },
+ actor: { type: 'api', id: 'ci' },
action: 'task.create',
outcome: 'denied',
- metadata: { repository: '.', mode: 'fix', reason: 'mode-not-granted' },
+ reason: "Execution mode 'fix' is not granted to this principal",
+ target: { type: 'repository', id: '.', mode: 'fix' },
},
]);
});
@@ -335,13 +380,12 @@ describe('rpc audit trail', () => {
}),
);
- expect(audited).toEqual([
+ expect(audited.events).toMatchObject([
{
- actor: { kind: 'principal', name: 'release-manager' },
+ actor: { type: 'api', id: 'release-manager' },
action: 'approval.decided',
outcome: 'success',
- subject: { type: 'task', id: 'cz_1' },
- metadata: { decision: 'approved', repository: 'acme/app' },
+ target: { type: 'task', id: 'cz_1', decision: 'approved', repository: 'acme/app' },
},
]);
});
@@ -352,14 +396,19 @@ describe('rpc audit trail', () => {
await expect(
instrumented(() => operator().approvals.decide({ taskId: 'cz_1', decision: 'approved' })),
).rejects.toThrow(APPROVAL_ERROR);
- expect(audited).toEqual([]);
+ expect(audited.events).toEqual([]);
});
it('records a creation that failed after the request was authorised', async () => {
await expect(
instrumented(() =>
failingStoreClient({
- principal: { name: 'release-manager', kind: 'token', modes: ['autonomous'] },
+ principal: {
+ name: 'release-manager',
+ kind: 'token',
+ modes: ['autonomous'],
+ admin: false,
+ },
allowRepository: true,
}).tasks.create({ repository: '.', feedback: 'x', mode: 'autonomous' }),
),
@@ -367,12 +416,13 @@ describe('rpc audit trail', () => {
// Without this the trail would show the request being authorised and then nothing at all,
// which reads as a task that was never attempted rather than one that broke.
- expect(audited).toEqual([
+ expect(audited.events).toMatchObject([
{
- actor: { kind: 'principal', name: 'release-manager' },
+ actor: { type: 'api', id: 'release-manager' },
action: 'task.create',
outcome: 'failure',
- metadata: { repository: '.', mode: 'autonomous', reason: 'storage unavailable' },
+ reason: 'storage unavailable',
+ target: { type: 'repository', id: '.', mode: 'autonomous' },
},
]);
});
@@ -389,24 +439,74 @@ describe('rpc audit trail', () => {
// A person and an operator token are revoked through different channels, so a trail that
// labelled both `principal` could not tell a reader which one to go turn off.
- expect(audited).toEqual([
+ expect(audited.events).toMatchObject([
{
- actor: { kind: 'user', name: 'ops@example.test' },
+ actor: { type: 'user', id: 'ops@example.test' },
action: 'approval.decided',
outcome: 'success',
- subject: { type: 'task', id: 'cz_1' },
- metadata: { decision: 'approved', repository: 'acme/app' },
+ target: { type: 'task', id: 'cz_1', decision: 'approved', repository: 'acme/app' },
},
]);
});
- it('serves callers that keep no audit trail at all', async () => {
+ it('reads the trail back for an administrator, newest first', async () => {
+ await auditLog.append(auditRecord('audit_1', '2026-08-09T10:00:00.000Z'));
+ await auditLog.append(auditRecord('audit_2', '2026-08-09T10:00:01.000Z'));
+
+ const page = await instrumented(() =>
+ client({
+ auth: betterAuth({ email: 'ops@example.test', role: 'admin' }),
+ reqHeaders: new Headers(),
+ }).audit.list({}),
+ );
+
+ expect(page.events.map((entry) => entry.id)).toEqual(['audit_2', 'audit_1']);
+ });
+
+ it('refuses a signed-in reader who is not an administrator', async () => {
+ await expect(
+ instrumented(() =>
+ client({
+ auth: betterAuth({ email: 'dev@example.test', role: 'member' }),
+ reqHeaders: new Headers(),
+ }).audit.list({}),
+ ),
+ ).rejects.toThrow(ADMIN_ERROR);
+ });
+
+ it('refuses an operator token, which the trail records rather than serves', async () => {
+ // A token that could read the trail could read its own use back; reading is a person's
+ // surface, reached with a session.
+ await expect(instrumented(() => operator().audit.list({}))).rejects.toThrow(ADMIN_ERROR);
+ });
+
+ it('refuses an unauthenticated reader', async () => {
+ await expect(instrumented(() => client().audit.list({}))).rejects.toThrow(UNAUTHORIZED_ERROR);
+ });
+
+ it('says a deployment keeps no trail rather than reporting an empty one', async () => {
+ await expect(
+ instrumented(() =>
+ unaudited({
+ auth: betterAuth({ email: 'ops@example.test', role: 'admin' }),
+ reqHeaders: new Headers(),
+ }).audit.list({}),
+ ),
+ ).rejects.toThrow(NO_AUDIT_LOG_ERROR);
+ });
+
+ it('serves callers that keep no readable audit log at all', async () => {
await store.save(awaiting('cz_1'));
await expect(
instrumented(() =>
unaudited({
- principal: { name: 'release-manager', kind: 'token', modes: ['autonomous'] },
+ principal: {
+ name: 'release-manager',
+ kind: 'token',
+ modes: ['autonomous'],
+ admin: false,
+ },
allowRepository: true,
}).approvals.decide({ taskId: 'cz_1', decision: 'approved' }),
),
diff --git a/packages/api/src/orpc/router.ts b/packages/api/src/orpc/router.ts
index a72ba32..f54a875 100644
--- a/packages/api/src/orpc/router.ts
+++ b/packages/api/src/orpc/router.ts
@@ -9,7 +9,7 @@ import { ORPCError, os } from '@orpc/server';
import { z } from 'zod';
import type { Principal } from '../access.js';
-import type { AuditActor, AuditRecorder } from '../audit.js';
+import type { AuditActor, AuditLogStore } from '../audit.js';
import type { TaskStore } from '../control-plane.js';
import { dashboardOverview } from '../dashboard.js';
import {
@@ -29,11 +29,16 @@ export interface RpcContext extends BetterAuthContext {
/** Whether `tasks.create` may target this repository. Fails closed when absent. */
mayTargetRepository?: (repository: string) => boolean;
/**
- * Durable audit trail supplied by the composition root. Optional like the predicate above, but
- * for the opposite reason: an embedded caller that keeps no audit log should still be able to
- * drive the router, so procedures record through `?.` rather than requiring a recorder.
+ * The durable audit trail, for reading it back.
+ *
+ * Writing does not go through here: procedures record with `log.audit()`, which lands on the
+ * request's wide event and reaches this same store through the evlog drain the composition root
+ * installed (`auditLogPlugins`). Optional like the predicate above, because an embedded caller
+ * that keeps no trail should still be able to drive the router — `audit.list` reports that it
+ * has none rather than inventing an empty one, so a reader cannot mistake "not configured" for
+ * "nothing has happened".
*/
- audit?: AuditRecorder;
+ auditLog?: AuditLogStore;
}
const procedure = os.$context();
@@ -103,27 +108,24 @@ export const rpcRouter = {
// repository or a mode it was never given is the signal a trail exists to preserve.
const actor = principalActor(context.principal);
if (!context.mayTargetRepository?.(input.repository)) {
- await context.audit?.record({
+ useLogger().audit.deny('Repository is not allow-listed for task creation', {
actor,
action: 'task.create',
- outcome: 'denied',
- metadata: { repository: input.repository, reason: 'repository-not-allow-listed' },
+ target: { type: 'repository', id: input.repository },
});
throw new ORPCError('FORBIDDEN', {
message: 'Repository is not allow-listed for task creation',
});
}
if (!context.principal.modes.includes(input.mode)) {
- await context.audit?.record({
- actor,
- action: 'task.create',
- outcome: 'denied',
- metadata: {
- repository: input.repository,
- mode: input.mode,
- reason: 'mode-not-granted',
+ useLogger().audit.deny(
+ `Execution mode '${input.mode}' is not granted to this principal`,
+ {
+ actor,
+ action: 'task.create',
+ target: { type: 'repository', id: input.repository, mode: input.mode },
},
- });
+ );
throw new ORPCError('FORBIDDEN', {
message: `Execution mode '${input.mode}' is not granted to this principal`,
});
@@ -136,28 +138,70 @@ export const rpcRouter = {
try {
task = await createTask(input, context.store);
} catch (error) {
- await context.audit?.record({
+ useLogger().audit({
actor,
action: 'task.create',
outcome: 'failure',
- metadata: {
- repository: input.repository,
- mode: input.mode,
- reason: redactSecrets(error instanceof Error ? error.message : String(error)),
- },
+ reason: redactSecrets(error instanceof Error ? error.message : String(error)),
+ target: { type: 'repository', id: input.repository, mode: input.mode },
});
throw error;
}
- await context.audit?.record({
+ useLogger().audit({
actor,
action: 'task.created',
outcome: 'success',
- subject: { type: 'task', id: task.id },
- metadata: { repository: input.repository, mode: input.mode },
+ target: { type: 'task', id: task.id, repository: input.repository, mode: input.mode },
});
return task;
}),
},
+ audit: {
+ /**
+ * The audit trail, newest first, for an app-wide administrator.
+ *
+ * A procedure rather than the Nitro route this used to be: since the router learned to accept
+ * a dashboard session, `authenticated` covers the browser as well as an operator token, so the
+ * read no longer has to live outside the router to reach the person looking at it. Serving it
+ * here also means one authorization rule instead of two — the page and any other client get
+ * the same answer, and the trail is documented alongside every other control-plane operation.
+ *
+ * `admin` rather than a mode grant: reading who did what is not an execution capability, and
+ * an operator token is never an administrator (see {@link Principal.admin}).
+ */
+ list: authenticated
+ .meta(
+ openapi({
+ method: 'GET',
+ path: '/audit-logs',
+ tags: ['Audit'],
+ summary: 'Read the append-only audit trail, newest first',
+ }),
+ )
+ .input(
+ z.object({
+ limit: z.number().int().positive().optional(),
+ /** The storage key of the last record read; the next page starts strictly after it. */
+ cursor: z.string().min(1).optional(),
+ }),
+ )
+ .handler(async ({ input, context }) => {
+ if (!context.principal.admin)
+ throw new ORPCError('FORBIDDEN', {
+ message: 'Reading the audit log requires the admin role',
+ });
+ if (!context.auditLog)
+ throw new ORPCError('NOT_IMPLEMENTED', {
+ message: 'This deployment keeps no audit log',
+ });
+ // Spread conditionally rather than passed whole: under `exactOptionalPropertyTypes` an
+ // absent input field is `undefined`, which is not the same as the store's "not given".
+ return context.auditLog.list({
+ ...(input.limit === undefined ? {} : { limit: input.limit }),
+ ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
+ });
+ }),
+ },
approvals: {
decide: authenticated
.meta(
@@ -180,12 +224,16 @@ export const rpcRouter = {
input.comment,
context.store,
);
- await context.audit?.record({
+ useLogger().audit({
actor: principalActor(context.principal),
action: 'approval.decided',
outcome: 'success',
- subject: { type: 'task', id: input.taskId },
- metadata: { decision: input.decision, repository: task.repository },
+ target: {
+ type: 'task',
+ id: input.taskId,
+ decision: input.decision,
+ repository: task.repository,
+ },
});
return task;
}),
@@ -201,7 +249,7 @@ export const rpcRouter = {
* off — the same reason `AuditActorKind` carries `user` at all.
*/
function principalActor(principal: Principal): AuditActor {
- return { kind: principal.kind === 'session' ? 'user' : 'principal', name: principal.name };
+ return { type: principal.kind === 'session' ? 'user' : 'api', id: principal.name };
}
export type RpcRouter = typeof rpcRouter;
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;
+}
diff --git a/packages/i18n/locales/en/dashboard.json b/packages/i18n/locales/en/dashboard.json
index 2ddc29d..4cd942b 100644
--- a/packages/i18n/locales/en/dashboard.json
+++ b/packages/i18n/locales/en/dashboard.json
@@ -4,21 +4,16 @@
"header": {
"eyebrow": "Code Zero / Operations",
"title": "Control Plane",
- "mode": "Local interface"
+ "mode": "Local interface",
+ "live": "Live",
+ "stale": "Reconnecting",
+ "liveAria": "Live updates connected",
+ "staleAria": "Live updates interrupted; the board may be out of date"
},
"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"
},
@@ -64,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": {
@@ -120,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 4bbaa1b..8ddf621 100644
--- a/packages/i18n/locales/it/dashboard.json
+++ b/packages/i18n/locales/it/dashboard.json
@@ -4,21 +4,16 @@
"header": {
"eyebrow": "Code Zero / Operazioni",
"title": "Piano di controllo",
- "mode": "Interfaccia locale"
+ "mode": "Interfaccia locale",
+ "live": "In diretta",
+ "stale": "Riconnessione",
+ "liveAria": "Aggiornamenti in tempo reale connessi",
+ "staleAria": "Aggiornamenti in tempo reale interrotti; la board potrebbe non essere aggiornata"
},
"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"
},
@@ -64,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": {
@@ -120,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"
+ }
}
}
}
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
diff --git a/turbo.jsonc b/turbo.jsonc
index adb1e23..ff053e5 100644
--- a/turbo.jsonc
+++ b/turbo.jsonc
@@ -197,6 +197,14 @@
"cache": false,
"persistent": true,
},
+ // The dashboard on its own, reading apps/dashboard/.env.solo instead of .env: no database, no
+ // model credentials, no other app in the graph. Same task shape as `dev`, since it is the same
+ // command with a different env file.
+ "dev:solo": {
+ "dependsOn": ["^build"],
+ "cache": false,
+ "persistent": true,
+ },
"clean": {
"cache": false,
},
diff --git a/vscode_cli.tar.gz b/vscode_cli.tar.gz
new file mode 100644
index 0000000..ef72bf5
Binary files /dev/null and b/vscode_cli.tar.gz differ