diff --git a/.gitignore b/.gitignore
index 7305574..aab04ee 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
@@ -23,3 +26,10 @@ test-report.junit.xml
# Skilld references (recreated by `skilld install`)
.skilld
+
+# Local editor tooling; never something a checkout should carry.
+/code
+/vscode_cli.tar.gz
+code-zero.deployment.yml
+!code-zero.deployment.example.yml
+!apps/dashboard/code-zero.deployment.solo.yml
diff --git a/AGENTS.md b/AGENTS.md
index 2b313b5..f7b6ec0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -9,7 +9,7 @@ Code Zero is an open-source autonomous engineer that finds, fixes, and verifies
**Key information:**
- Node version: `24.19.0` (`>=24.2` supported; see `mise.toml` and `engines`)
-- Package manager: `aube@1.38.0` (pinned in `package.json` via `packageManager`)
+- Package manager: `aube@1.41.0` (pinned in `package.json` via `packageManager`)
- TypeScript: `^5.9.2`, overridden to `typescript-native-bridge` so checks run on tsgo
- Main branch: `main`
@@ -85,6 +85,9 @@ 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 dev:docs # docs site alone
+aube run dev:marketing # marketing site alone
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..4a08747 100644
--- a/README.md
+++ b/README.md
@@ -103,6 +103,27 @@ 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_SOLO_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.
+
+`dev:docs` and `dev:marketing` start those two apps on their own the same way `mail:preview`
+already does for `apps/mail-preview` — a plain `turbo run dev` filtered to one app, with no
+alternate env file.
+
`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..5e553d2
--- /dev/null
+++ b/apps/dashboard/modules/dashboard/composables/useLiveOverview.ts
@@ -0,0 +1,120 @@
+// 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.
+ }
+ });
+ // Named so it never reaches the `message` listener above as an empty overview (see
+ // `server/api/events.get.ts`). Counted toward freshness all the same: it is the server
+ // proving the connection is still alive between overviews, and a stream that only ever
+ // updated freshness on an overview would call a healthy, merely quiet connection stale.
+ stream.addEventListener('heartbeat', () => {
+ connected.value = true;
+ lastMessageAt.value = Date.now();
+ now.value = lastMessageAt.value;
+ });
+ // 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..0702ce7 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",
@@ -26,9 +27,12 @@
"@code-zero/api": "workspace:*",
"@code-zero/auth": "workspace:*",
"@code-zero/build-env": "workspace:*",
+ "@code-zero/config": "workspace:*",
+ "@code-zero/database": "workspace:*",
"@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..3daafe6
--- /dev/null
+++ b/apps/dashboard/server/api/events.get.ts
@@ -0,0 +1,105 @@
+/**
+ * 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;
+
+/**
+ * Concurrent streams one signed-in account may hold open, and across every account combined.
+ *
+ * Each stream costs one `subscribeOverview` listener and one heartbeat timer for the life of the
+ * connection (see below), so an account that never closes a tab — or a client retrying without
+ * backing off — must not be able to grow either without bound.
+ *
+ * ponytail: fixed in-process counters, so a multi-instance deployment enforces this per instance
+ * rather than per account across the fleet. Move to a shared counter (e.g. the database or a
+ * cache) if running more than one instance makes that gap matter.
+ */
+const MAX_STREAMS_PER_USER = 6;
+const MAX_TOTAL_STREAMS = 500;
+
+const streamsByUser = new Map();
+let totalStreams = 0;
+
+/**
+ * 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.
+ *
+ * The overview itself is computed once per write and shared by every connected stream (see
+ * `../utils/overview.ts`): this handler only pushes bytes onto its own connection, so opening more
+ * tabs never costs the task store more reads.
+ */
+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.
+ const session = await requireUserSession(event);
+ const userId = session.user.id;
+
+ const forUser = streamsByUser.get(userId) ?? 0;
+ if (forUser >= MAX_STREAMS_PER_USER || totalStreams >= MAX_TOTAL_STREAMS)
+ throw errors.tooManyRequests('Too many live connections; close another tab and retry');
+ streamsByUser.set(userId, forUser + 1);
+ totalStreams += 1;
+ let released = false;
+ function release(): void {
+ if (released) return;
+ released = true;
+ totalStreams -= 1;
+ const remaining = (streamsByUser.get(userId) ?? 1) - 1;
+ if (remaining <= 0) streamsByUser.delete(userId);
+ else streamsByUser.set(userId, remaining);
+ }
+
+ const stream = createEventStream(event);
+ let closed = false;
+ // Set the moment any live broadcast reaches this connection, so the still-pending initial read
+ // below never overwrites it with what is, by then, a stale snapshot.
+ let receivedLiveUpdate = false;
+
+ async function push(overview: unknown): Promise {
+ if (closed) return;
+ try {
+ await stream.push(JSON.stringify(overview));
+ } catch {
+ // The client went away between the write landing and this read finishing. Nothing to
+ // report: `onClosed` below is what tears the subscription down.
+ }
+ }
+
+ const unsubscribe = subscribeOverview((overview) => {
+ receivedLiveUpdate = true;
+ void push(overview);
+ });
+
+ const heartbeat = setInterval(() => {
+ if (!closed) void stream.push({ event: 'heartbeat', data: '' }).catch(() => undefined);
+ }, HEARTBEAT_MS);
+
+ stream.onClosed(() => {
+ closed = true;
+ unsubscribe();
+ clearInterval(heartbeat);
+ release();
+ });
+
+ // The current state before any change, so a page that connects mid-run renders immediately
+ // rather than staying empty until something else happens. Not 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.
+ //
+ // Subscribed above before this read starts, so a broadcast racing this read is never missed —
+ // but that also means a broadcast can land first and finish before this slower read does. This
+ // snapshot is only ever older in that case, so it is dropped rather than sent: applying it would
+ // overwrite the newer state the client already has with a stale one.
+ void currentOverview().then((overview) => (receivedLiveUpdate ? undefined : void push(overview)));
+ return stream.send();
+});
diff --git a/apps/dashboard/server/api/v1/[...].ts b/apps/dashboard/server/api/v1/[...].ts
index 780ec44..a69dd48 100644
--- a/apps/dashboard/server/api/v1/[...].ts
+++ b/apps/dashboard/server/api/v1/[...].ts
@@ -1,9 +1,4 @@
-import {
- accessFromEnvironment,
- controlPlaneOriginsFromEnvironment,
- requestLoggerStorage,
- rpcRouter,
-} from '@code-zero/api';
+import { requestLoggerStorage, rpcRouter } from '@code-zero/api';
import { EvlogHandlerPlugin } from '@orpc/evlog';
import { OpenAPIGenerator } from '@orpc/openapi';
import { OpenAPIHandler } from '@orpc/openapi/fetch';
@@ -30,14 +25,14 @@ const openApiSpec = generator.generate(rpcRouter, {
* Same router, same authorization rules as the `/rpc/**` RPC transport; only the wire protocol
* differs, for callers that want plain HTTP instead of the typed oRPC client. Unlike `/rpc/**`,
* this transport is meant for cross-origin callers, so it carries a CORS plugin — restricted to
- * `CODE_ZERO_CONTROL_PLANE_ORIGINS`'s allow-list (default: none) rather than reflecting any
+ * `control_plane.origins`'s allow-list (default: none) rather than reflecting any
* request origin, since `tasks.list`/`tasks.get`/`health` are unauthenticated and would otherwise
* be readable by any website's browser-side JavaScript.
*/
const handler = new OpenAPIHandler(rpcRouter, {
plugins: [
- new CORSHandlerPlugin({ origin: controlPlaneOriginsFromEnvironment() }),
- new EvlogHandlerPlugin({ storage: requestLoggerStorage }),
+ new CORSHandlerPlugin({ origin: async () => (await deploymentConfig()).controlPlane.origins }),
+ new EvlogHandlerPlugin({ storage: requestLoggerStorage, plugins: auditPlugins }),
new OpenAPIReferenceHandlerPlugin({
docsPath: '/docs',
specPath: '/openapi.json',
@@ -45,15 +40,16 @@ const handler = new OpenAPIHandler(rpcRouter, {
}),
],
});
-// Fails closed: without configured tokens every mutation is rejected while reads stay open.
-const access = accessFromEnvironment();
export default defineEventHandler(async (event) => {
const request = toWebRequest(event);
try {
const { matched, response } = await handler.handle(request, {
prefix: '/api/v1',
- context: { ...buildRpcContext(request, access, taskStore), audit: auditRecorder },
+ context: {
+ ...buildRpcContext(request, await controlPlaneAccess(), taskStore, repositoryStore),
+ 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..42ae0b2 100644
--- a/apps/dashboard/server/auth.config.ts
+++ b/apps/dashboard/server/auth.config.ts
@@ -75,10 +75,12 @@ 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
+ * `/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.
*
@@ -86,7 +88,9 @@ const options = authBetterAuthOptions({
* 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`, which is
+ * how `dev:solo` alone reaches it.
*/
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..f32eb9b
--- /dev/null
+++ b/apps/dashboard/server/plugins/poller.ts
@@ -0,0 +1,101 @@
+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.
+ *
+ * Which repositories it watches is read from the store on every pass, not once at boot: the list
+ * is a table an operator edits from the dashboard, so turning polling on for a repository has to
+ * take effect without a restart. A pass over an empty list asks the provider nothing, which is
+ * what makes it safe to always schedule the next one instead of deciding at boot whether to.
+ *
+ * It runs a timer 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
+ * nothing is watched — 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.
+ *
+ * Each pass schedules the next one when it finishes, rather than running on a fixed interval: a
+ * pass that overruns cannot then have a second one start beside it, and the delay is re-read each
+ * time, so `poll.interval_seconds` is answered by the same lazily-loaded configuration everything
+ * else reads instead of a value captured before the plugin could await it.
+ *
+ * Each repository carries its own mode, and neither mode it may carry can write to a checkout:
+ * work nobody requested must not be able to.
+ *
+ * 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 token = githubTokenFromEnvironment();
+ if (!token) {
+ console.warn('[poll] GITHUB_TOKEN is not configured; not polling');
+ return;
+ }
+
+ const pulls = new GitHubPullRequests({ token });
+ let timer: ReturnType | undefined;
+ let stopped = false;
+
+ async function pass(): Promise {
+ const watched = await repositoryStore.watched();
+ // Only a GitHub source exists in this process; a repository configured for another provider
+ // would otherwise be queried through it under owner/repo coordinates that provider never
+ // issued, and — because `pollClaimKey` keys on the provider it is told — could never converge
+ // with the claim its own webhook takes for the same commit. Reported rather than silently
+ // dropped, since it names an operator's misconfiguration.
+ const repositories = watched.flatMap((repository) => {
+ if (repository.provider !== 'github') {
+ console.warn(
+ `[poll] ${repository.owner}/${repository.name} is configured for '${repository.provider}', which this poller cannot query; skipping`,
+ );
+ return [];
+ }
+ return [{ ...repository, provider: 'github' as const }];
+ });
+ if (repositories.length === 0) return;
+ await pollOnce({
+ repositories,
+ source: pulls,
+ claims: deliveryClaimStore,
+ start: (request) =>
+ runTask(
+ {
+ repository: request.repository,
+ mode: request.mode,
+ trigger: 'proactive',
+ source: request.source,
+ pullRequest: request.pullRequest,
+ },
+ { store: taskStore },
+ ),
+ onError: (repository, error) => {
+ console.error(`[poll] ${repository} failed`, error);
+ },
+ });
+ }
+
+ async function loop(): Promise {
+ try {
+ await pass();
+ } catch (error) {
+ // A pass that cannot even read the store must not take the timer down with it: the database
+ // being briefly unreachable is a reason to try again, not to stop polling.
+ console.error('[poll] pass failed', error);
+ }
+ if (stopped) return;
+ const { poll } = await deploymentConfig();
+ timer = setTimeout(() => void loop(), poll.intervalSeconds * 1_000);
+ // 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', () => {
+ stopped = true;
+ if (timer) clearTimeout(timer);
+ });
+
+ void loop();
+});
diff --git a/apps/dashboard/server/routes/rpc/[...].ts b/apps/dashboard/server/routes/rpc/[...].ts
index a367e6e..e010383 100644
--- a/apps/dashboard/server/routes/rpc/[...].ts
+++ b/apps/dashboard/server/routes/rpc/[...].ts
@@ -1,4 +1,4 @@
-import { accessFromEnvironment, requestLoggerStorage, rpcRouter } from '@code-zero/api';
+import { requestLoggerStorage, rpcRouter } from '@code-zero/api';
import { EvlogHandlerPlugin } from '@orpc/evlog';
import { RPCHandler } from '@orpc/server/fetch';
import {
@@ -18,11 +18,9 @@ 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.
-const access = accessFromEnvironment();
/**
* Typed oRPC surface under `/rpc/**`.
@@ -43,8 +41,14 @@ export default defineEventHandler(async (event) => {
const { matched, response } = await handler.handle(request, {
prefix: '/rpc',
context: {
- ...buildRpcContext(request, access, taskStore, serverAuth(event)),
- audit: auditRecorder,
+ ...buildRpcContext(
+ request,
+ await controlPlaneAccess(),
+ taskStore,
+ repositoryStore,
+ serverAuth(event),
+ ),
+ auditLog: auditLogStore,
},
});
if (matched) return response;
diff --git a/apps/dashboard/server/utils/access.ts b/apps/dashboard/server/utils/access.ts
new file mode 100644
index 0000000..95041da
--- /dev/null
+++ b/apps/dashboard/server/utils/access.ts
@@ -0,0 +1,42 @@
+import { accessFromEnvironment, type ControlPlaneAccess } from '@code-zero/api';
+
+/**
+ * Builds the once-resolved, self-healing cache described by {@link controlPlaneAccess} below.
+ *
+ * Takes the loader as a parameter rather than reaching for `deploymentConfig`/`process.env`
+ * directly, so a test can drive a rejection and its recovery without a real config file — the same
+ * reason `createOverviewBroadcaster` in `./overview.ts` takes its store as a parameter.
+ */
+export function createControlPlaneAccessCache(
+ load: () => Promise,
+): () => Promise {
+ let pending: Promise | undefined;
+
+ return function controlPlaneAccess(): Promise {
+ pending ??= load().catch((error: unknown) => {
+ // A transient failure to read the config must not wedge every request behind it for the
+ // rest of the process's life: clearing the cache here is what lets the next call try again
+ // instead of replaying this same rejection forever.
+ pending = undefined;
+ throw error;
+ });
+ return pending;
+ };
+}
+
+/**
+ * Who may call the control plane as a machine, composed from the two places its halves belong.
+ *
+ * The tokens are secrets and stay in `CODE_ZERO_CONTROL_PLANE_TOKENS`, beside the database
+ * password. What each of them may run is policy and comes from `code-zero.deployment.yml`, where
+ * it is a readable map instead of the `name:mode|mode` string it used to be squeezed into.
+ *
+ * Resolved once: both halves are fixed for the life of the process, and re-reading them per
+ * request would only move the same answer around.
+ */
+export const controlPlaneAccess: () => Promise =
+ createControlPlaneAccessCache(() =>
+ deploymentConfig().then((config) =>
+ accessFromEnvironment(process.env.CODE_ZERO_CONTROL_PLANE_TOKENS, config.controlPlane.modes),
+ ),
+ );
diff --git a/apps/dashboard/server/utils/context.ts b/apps/dashboard/server/utils/context.ts
index e5cc48d..baa0dde 100644
--- a/apps/dashboard/server/utils/context.ts
+++ b/apps/dashboard/server/utils/context.ts
@@ -1,8 +1,10 @@
+import { resolve } from 'node:path';
+
import {
authenticate,
- mayTargetRepository,
type BetterAuthSessionApi,
type ControlPlaneAccess,
+ type RepositoryAdmin,
type RpcContext,
type TaskStore,
} from '@code-zero/api';
@@ -21,6 +23,16 @@ import {
* only for procedures that require an identity, so an anonymous read never queries the
* authentication store.
*
+ * Which checkout a run may target is asked of `repositories` per request rather than fixed at
+ * boot: the allow-list is a table an operator edits from the dashboard, so a repository added a
+ * moment ago has to be answerable without a restart. The path is resolved first, so `/srv/app` and
+ * `/srv/app/../app` cannot be two different answers.
+ *
+ * The same `repositories` also becomes `context.repositories`, the capability
+ * `repositories.list`/`.save`/`.remove` read and write: it is a superset of the narrower
+ * `{ allows }` shape those procedures need, and the composition root has only the one store to
+ * hand either surface.
+ *
* Takes `store` rather than importing `taskStore` itself, so this stays testable without pulling
* in the ViteHub KV binding `../utils/store.js` resolves at runtime.
*/
@@ -28,6 +40,7 @@ export function buildRpcContext(
request: Request,
access: ControlPlaneAccess | undefined,
store: TaskStore,
+ repositories: RepositoryAdmin & { allows: (checkoutPath: string) => Promise },
auth?: BetterAuthSessionApi,
): RpcContext {
const principal = authenticate(request.headers.get('authorization') ?? undefined, access);
@@ -35,6 +48,7 @@ export function buildRpcContext(
store,
...(principal ? { principal } : {}),
...(auth ? { auth } : {}),
- mayTargetRepository: (repository) => mayTargetRepository(repository, access),
+ mayTargetRepository: (repository) => repositories.allows(resolve(repository)),
+ repositories,
};
}
diff --git a/apps/dashboard/server/utils/database.ts b/apps/dashboard/server/utils/database.ts
new file mode 100644
index 0000000..6deaaab
--- /dev/null
+++ b/apps/dashboard/server/utils/database.ts
@@ -0,0 +1,20 @@
+import { createDatabase, databaseUrlFromEnvironment, type Database } from '@code-zero/database';
+
+/**
+ * The one connection pool this process opens, created on first use.
+ *
+ * Lazy rather than at module load: a request that never touches the store — every read the board
+ * makes, the health check a load balancer polls — should not have opened a socket, and a process
+ * running on in-memory stores must be able to import this module without a database existing at
+ * all. `createDatabase` is a factory for the same reason; the composition root owns the lifetime,
+ * and this is that root.
+ *
+ * One pool per process rather than one per request: `postgres` pools internally, and a client per
+ * request would exhaust the server's connection limit under any real load.
+ */
+let pool: Database | undefined;
+
+export function database(): Database {
+ pool ??= createDatabase({ connectionString: databaseUrlFromEnvironment() });
+ return pool;
+}
diff --git a/apps/dashboard/server/utils/deployment.ts b/apps/dashboard/server/utils/deployment.ts
new file mode 100644
index 0000000..c828d11
--- /dev/null
+++ b/apps/dashboard/server/utils/deployment.ts
@@ -0,0 +1,50 @@
+import { resolve } from 'node:path';
+
+import {
+ defaultDeploymentConfig,
+ DEPLOYMENT_CONFIG_FILE,
+ describeDeploymentConfigIssues,
+ loadDeploymentConfig,
+ type DeploymentConfig,
+} from '@code-zero/config';
+
+/**
+ * How this deployment behaves, read once from `code-zero.deployment.yml`.
+ *
+ * One file replaces the handful of comma-separated environment variables this policy used to be
+ * spelled in — a list of origins, a `name:mode|mode` grant string, a poll interval — each of which
+ * had its own ad-hoc format to get wrong. What stays in the environment is what a deployment
+ * already keeps there: secrets, and the two bootstrap values needed to find everything else
+ * (`DATABASE_URL`, and the path to this file).
+ *
+ * `CODE_ZERO_CONFIG` names the file; without it the process reads `code-zero.deployment.yml` from
+ * its working directory, and a deployment that has never needed to change anything ships no file
+ * at all and gets the defaults.
+ *
+ * Resolved once and awaited by every reader, rather than re-read per request: this is deployment
+ * policy, which changes when the process restarts. Repository configuration is the opposite — it
+ * changes while the process runs — which is exactly why it lives in the store instead.
+ */
+let pending: Promise | undefined;
+
+export function deploymentConfig(): Promise {
+ pending ??= read();
+ return pending;
+}
+
+async function read(): Promise {
+ const path = resolve(process.env.CODE_ZERO_CONFIG?.trim() || DEPLOYMENT_CONFIG_FILE);
+ const { config, issues } = await loadDeploymentConfig(path);
+ if (issues.length > 0) {
+ // Reported rather than thrown: a malformed field falls back to its own default, and the
+ // process starting with a loud complaint beats a dashboard that will not load at all because
+ // one line of YAML is wrong. Every issue names its path, so the fix is one edit.
+ console.error(
+ `[config] ${path} has problems; the defaults stand where a value was refused:\n${describeDeploymentConfigIssues(issues)}`,
+ );
+ }
+ return config;
+}
+
+/** The defaults, for a caller that must answer before the file has been read. */
+export const deploymentDefaults: DeploymentConfig = defaultDeploymentConfig;
diff --git a/apps/dashboard/server/utils/errors.ts b/apps/dashboard/server/utils/errors.ts
index e7c7290..50dd271 100644
--- a/apps/dashboard/server/utils/errors.ts
+++ b/apps/dashboard/server/utils/errors.ts
@@ -1,31 +1,36 @@
import { redactSecrets } from '@code-zero/shared';
-import { createError } from 'h3';
+import { createError, type EvlogError } from 'evlog';
/**
* The transport-level failures the routes in this app raise.
*
* A route names the failure instead of spelling out a status inline, so the same disposition
- * cannot drift between the RPC and OpenAPI transports. Each entry builds an `H3Error`, which
- * Nitro serialises and which the app's own `resolveErrorStatus` already understands — routes
- * throw these rather than hand-building a `Response`.
+ * cannot drift between the RPC and OpenAPI transports. Each entry builds an `EvlogError`, which
+ * Nitro serialises through its stock error handler and which the app's own `resolveErrorStatus`
+ * already understands — routes throw these rather than hand-building a `Response`.
*
- * `createError` is imported rather than taken from Nitro's auto-imports — unlike a route, this
- * module is exercised directly from the plain-Node unit suite, where no Nitro globals exist.
+ * `createError` is imported from `evlog` rather than taken from Nitro's auto-imports — unlike a
+ * route, this module is exercised directly from the plain-Node unit suite, where no Nitro globals
+ * exist.
*
- * The client-facing text lives in `message`, not `statusMessage` or `data`. Bare h3 (`sendError`)
- * only ever serialises `statusCode`/`statusMessage`/`data`, dropping `message` — but this app's
- * Nitro server never reaches that code path: Nitro installs its own error handler
- * (`nitropack`'s `defaultNitroErrorHandler`, dev and prod builds alike), which reads
- * `error.message` and forwards it verbatim as long as neither `error.fatal` nor `error.unhandled`
- * is set. `H3Error` defaults both to `false`, and no entry below sets either, so every message
- * here — `'Not found'`, the named variable, the redacted failure — reaches the client. Setting
- * `fatal`/`unhandled` on a future entry would silently replace its message with a generic
- * "Server Error" instead; `errors.test.ts` asserts both stay unset.
+ * The client-facing text lives in `message`. Nitro installs its own error handler (`nitropack`'s
+ * `defaultNitroErrorHandler`, dev and prod builds alike), which reads `error.message` and forwards
+ * it verbatim as long as neither `error.fatal` nor `error.unhandled` is truthy. `EvlogError` itself
+ * never sets either, so {@link fail} sets both to `false` explicitly rather than leaving them
+ * `undefined`: functionally equivalent for Nitro's own truthy check, but it is what lets a shared
+ * assertion elsewhere that expects the fields to be exactly `false` — rather than merely falsy —
+ * pass against every error this module builds. `errors.test.ts` asserts both stay `false`.
*/
+function fail(options: Parameters[0]): EvlogError & {
+ fatal: false;
+ unhandled: false;
+} {
+ return Object.assign(createError(options), { fatal: false as const, unhandled: false as const });
+}
+
export const errors = {
/** No transport matched the request path; the router itself is healthy. */
- notFound: () =>
- createError({ statusCode: 404, statusMessage: 'Not Found', message: 'Not found' }),
+ notFound: () => fail({ status: 404, message: 'Not found' }),
/**
* A required environment variable is absent, so the route fails closed rather than running with
@@ -33,32 +38,32 @@ export const errors = {
* a secret's value.
*/
misconfigured: (variable: string) =>
- createError({
- statusCode: 503,
- statusMessage: 'Service Unavailable',
- message: `${variable} is not configured`,
- }),
+ fail({ status: 503, message: `${variable} is not configured` }),
/**
* The caller is signed in, but the session does not carry the role the route requires. Distinct
* from the 401 `requireUserSession` raises for an absent session: signing in again would not
* help, and saying so is what keeps the reader from retrying the login loop.
*/
- forbidden: (reason: string) =>
- createError({ statusCode: 403, statusMessage: 'Forbidden', message: reason }),
+ forbidden: (reason: string) => fail({ status: 403, message: reason }),
+
+ /**
+ * The caller already holds as many concurrent connections of some kind as this process allows.
+ * Retrying immediately will not help; closing another connection first will.
+ */
+ tooManyRequests: (reason: string) => fail({ status: 429, message: reason }),
/**
* An unexpected failure, redacted before it reaches either the client or Nitro's error log.
*
* The original error is deliberately not attached as `cause`: it is the value most likely to
* carry a token or a checkout path in its message, and anything attached here is logged
- * verbatim. Throwing an `H3Error` also keeps the failure "handled", so Nitro reports this
- * redacted message instead of replacing it with a generic one.
+ * verbatim. Throwing a handled error also keeps Nitro from replacing this redacted message with
+ * a generic one.
*/
internal: (error: unknown) =>
- createError({
- statusCode: 500,
- statusMessage: 'Internal Server Error',
+ fail({
+ status: 500,
message: redactSecrets(error instanceof Error ? error.message : String(error)),
}),
};
diff --git a/apps/dashboard/server/utils/overview.ts b/apps/dashboard/server/utils/overview.ts
new file mode 100644
index 0000000..33b5589
--- /dev/null
+++ b/apps/dashboard/server/utils/overview.ts
@@ -0,0 +1,116 @@
+import { dashboardOverview, type DashboardOverview, type TaskStore } 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;
+
+type OverviewListener = (overview: DashboardOverview) => void;
+
+/** The one thing a broadcaster reads to answer "what changed". Narrow so a test needs no store. */
+interface OverviewSource {
+ list: TaskStore['list'];
+}
+
+/** The one thing a broadcaster listens on. `EventEmitter`, reduced to the method this uses. */
+interface ChangeSignal {
+ on(event: string, listener: () => void): unknown;
+}
+
+export interface OverviewBroadcaster {
+ /** The overview as of right now, for a stream that just connected and has nothing to wait for yet. */
+ currentOverview(): Promise;
+ /** Registers a stream for every future overview. Returns the function that stops it. */
+ subscribeOverview(listener: OverviewListener): () => void;
+}
+
+/**
+ * Builds the debounced, shared broadcast described in {@link currentOverview} below.
+ *
+ * Takes the store and the change signal as parameters rather than reading `taskStore`/
+ * `taskChanges` directly, so this stays testable without the ViteHub KV binding those resolve to
+ * at runtime — the same reason `observeWrites` in `./store.ts` takes its store as a parameter. The
+ * module-level export below is the one real caller; everything here is what a test drives instead.
+ */
+export function createOverviewBroadcaster(
+ store: OverviewSource,
+ changes: ChangeSignal,
+ changedEvent: string,
+): OverviewBroadcaster {
+ const listeners = new Set();
+ let scheduled: ReturnType | undefined;
+ // Set for the duration of the store read a broadcast is actually performing, which is not the
+ // same window `scheduled` covers: `scheduled` only spans the debounce delay before that read
+ // starts. Without this, a change arriving while a slow read is already in flight would see
+ // `scheduled` cleared and arm a second, overlapping read — exactly the N-scans-per-write cost
+ // this exists to avoid, just moved from N listeners to N racing reads.
+ let reading = false;
+ let changedWhileReading = false;
+
+ async function broadcast(): Promise {
+ scheduled = undefined;
+ reading = true;
+ try {
+ const overview = dashboardOverview(await store.list());
+ for (const listener of listeners) listener(overview);
+ } catch (error) {
+ console.error('[events] failed to compute the dashboard overview', error);
+ } finally {
+ reading = false;
+ // A change that arrived mid-read was not represented in the snapshot just read — and was
+ // deliberately not scheduled while `reading` held, so it must not be dropped now that the
+ // read that would have missed it is done.
+ if (changedWhileReading) {
+ changedWhileReading = false;
+ scheduleBroadcast();
+ }
+ }
+ }
+
+ function scheduleBroadcast(): void {
+ if (reading) {
+ changedWhileReading = true;
+ return;
+ }
+ if (scheduled) return;
+ scheduled = setTimeout(() => void broadcast(), PUSH_DELAY_MS);
+ }
+
+ changes.on(changedEvent, scheduleBroadcast);
+
+ return {
+ currentOverview: () => store.list().then((tasks) => dashboardOverview(tasks)),
+ subscribeOverview: (listener) => {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ };
+}
+
+let broadcaster: OverviewBroadcaster | undefined;
+
+/**
+ * The one broadcaster this process runs, built from the real store and the real change signal.
+ *
+ * Lazy rather than built at module load: `createOverviewBroadcaster` is what a test imports and
+ * drives directly, and building this at the top level would reach for `taskStore`/`taskChanges` —
+ * Nitro auto-imports resolved only inside the running server — the moment this module loaded,
+ * which is exactly what a plain test import must not do.
+ *
+ * One `taskStore.list()` per write, shared by every listener, rather than one per listener: a
+ * single signed-in user with several tabs open each held their own subscription and re-read the
+ * whole store independently, so N open streams turned one write into N full scans. This is what
+ * keeps that cost at one regardless of how many streams are listening.
+ */
+function overviewBroadcaster(): OverviewBroadcaster {
+ broadcaster ??= createOverviewBroadcaster(taskStore, taskChanges, TASK_CHANGED);
+ return broadcaster;
+}
+
+export function currentOverview(): Promise {
+ return overviewBroadcaster().currentOverview();
+}
+
+export function subscribeOverview(listener: OverviewListener): () => void {
+ return overviewBroadcaster().subscribeOverview(listener);
+}
diff --git a/apps/dashboard/server/utils/poller.ts b/apps/dashboard/server/utils/poller.ts
new file mode 100644
index 0000000..32b5911
--- /dev/null
+++ b/apps/dashboard/server/utils/poller.ts
@@ -0,0 +1,178 @@
+import { reviewDeliveryKey, type DeliveryClaimStore } from '@code-zero/api';
+import type { OpenPullRequest, ProviderKind, RepositoryTarget } from '@code-zero/source-control';
+
+/**
+ * What one pass needs to know about a configured repository.
+ *
+ * Structural, so `@code-zero/database`'s `WatchedRepositoryRecord` satisfies it without this
+ * module importing the store: the poller composes runs, it does not decide which repositories
+ * exist. 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.
+ */
+export interface WatchedRepository {
+ /**
+ * Which provider `owner/name` names a repository on. Carried per repository rather than assumed,
+ * because the store this is read from (`@code-zero/database`'s `repository` table) accepts any
+ * provider a caller names, while the process feeding this pass a `source` only ever speaks to
+ * one — see `server/plugins/poller.ts`, which is where a repository whose provider that `source`
+ * does not understand is reported and skipped, rather than silently queried under the wrong API.
+ */
+ provider: ProviderKind;
+ owner: string;
+ name: string;
+ checkoutPath: string;
+ /** The mode a run starts in. Neither value can write to the checkout. */
+ mode: 'observe' | 'suggest';
+}
+
+/** 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;
+ /**
+ * The mode this repository is configured with, carried per repository rather than per pass:
+ * one deployment can watch a repository it only observes beside one it may suggest on.
+ */
+ mode: 'observe' | 'suggest';
+ 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.
+ *
+ * Built with `@code-zero/api`'s `reviewDeliveryKey`, the same function the webhook route's
+ * proactive-trigger path claims with: a commit a webhook delivery already claimed is one this pass
+ * skips, and a commit this pass claims first is one a redelivered webhook observes rather than
+ * reviews again — which only holds because both sides key on the repository's real provider rather
+ * than assuming one. 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 reviewDeliveryKey({
+ provider: target.provider,
+ owner: target.owner,
+ repo: target.name,
+ number: pull.number,
+ headSha: pull.headSha,
+ });
+}
+
+/**
+ * One pass over every watched repository, starting a review for each pull request commit that has
+ * not been reviewed yet.
+ *
+ * Every repository is discovered concurrently rather than one after another: `options.source` is
+ * one network round trip per repository, and a pass over many repositories must not pay their sum
+ * in latency when nothing here depends on one repository's answer to ask about the next. Every
+ * claimed review is likewise started without waiting for it to finish before considering the rest
+ * of that repository's pull requests: `options.start` hands the run to a scheduler that already
+ * bounds how many run at once, so serializing ahead of it here would only make one long review
+ * delay the rest of the pass discovering work behind it. Every started review is still awaited
+ * before this function returns, so its outcome is always settled by the time the caller acts on
+ * the count this returns: a review that never started releases its claim for the next pass to
+ * retry, while one that did is never released on a later failure — only starting is retried,
+ * because retrying a review that already ran would duplicate it rather than recover it.
+ *
+ * Returns how many reviews it started — a claim taken and `options.start` resolved without
+ * throwing — which is what the caller logs; everything else about them is on the task records the
+ * run itself writes. A start that throws is not counted: its claim is released for the next pass
+ * to retry, so counting it would report work that, from the trail's point of view, never happened.
+ *
+ * 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;
+ const dispatched: Promise[] = [];
+
+ await Promise.all(
+ options.repositories.map(async (repository) => {
+ const label = `${repository.owner}/${repository.name}`;
+ let open: OpenPullRequest[];
+ try {
+ open = await options.source.listOpenPullRequests({
+ owner: repository.owner,
+ repo: repository.name,
+ });
+ } catch (error) {
+ options.onError?.(label, error);
+ return;
+ }
+
+ 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;
+
+ dispatched.push(
+ (async () => {
+ try {
+ await options.start({
+ repository: repository.checkoutPath,
+ mode: repository.mode,
+ pullRequest: {
+ owner: repository.owner,
+ repo: repository.name,
+ number: pull.number,
+ baseSha: pull.baseSha,
+ headSha: pull.headSha,
+ },
+ source: `poll:${label}#${String(pull.number)}`,
+ });
+ } catch (error) {
+ // The review never ran, so releasing is what lets the next pass retry this commit
+ // instead of finding it claimed forever.
+ await options.claims.release(key).catch(() => undefined);
+ options.onError?.(label, error);
+ return;
+ }
+ started += 1;
+ try {
+ await options.claims.complete(key, { started: true });
+ } catch (error) {
+ // The review already ran — releasing here, unlike above, would let the next pass
+ // claim and start a second review of a commit that has already been reviewed once.
+ // The claim is left standing (claimed, not completed) so this failure only means its
+ // outcome went unrecorded, not that the work repeats.
+ options.onError?.(label, error);
+ }
+ })(),
+ );
+ }
+ }),
+ );
+
+ await Promise.all(dispatched);
+ return started;
+}
diff --git a/apps/dashboard/server/utils/repositories.ts b/apps/dashboard/server/utils/repositories.ts
new file mode 100644
index 0000000..2dcf90f
--- /dev/null
+++ b/apps/dashboard/server/utils/repositories.ts
@@ -0,0 +1,131 @@
+import { resolve } from 'node:path';
+
+import {
+ deleteRepository,
+ isAllowedCheckout,
+ listRepositories,
+ saveRepository,
+ watchedRepositories,
+ type RepositoryInput,
+ type RepositoryRecord,
+ type WatchedRepositoryRecord,
+} from '@code-zero/database';
+
+import { database } from './database.js';
+
+/**
+ * The repositories this deployment may act on.
+ *
+ * A contract rather than the Drizzle functions directly, so the process can run on an in-memory
+ * store when it has no database — the same shape `server/auth.config.ts` takes for the session
+ * store, and for the same reason: `dev:solo` and the Playwright preview both own the whole process
+ * and throw its state away when they exit.
+ */
+export interface RepositoryStore {
+ list(): Promise;
+ /** Those the poller looks for work in: polling on, and provider coordinates to ask about. */
+ watched(): Promise;
+ /** Whether a run may execute against this checkout path. */
+ allows(checkoutPath: string): Promise;
+ save(input: RepositoryInput): Promise;
+ remove(id: string): Promise;
+}
+
+/** The store backed by Postgres, which is every deployment. */
+function postgresRepositoryStore(): RepositoryStore {
+ return {
+ list: () => listRepositories(database()),
+ watched: () => watchedRepositories(database()),
+ allows: (checkoutPath) => isAllowedCheckout(database(), checkoutPath),
+ save: (input) => saveRepository(database(), input),
+ remove: (id) => deleteRepository(database(), id),
+ };
+}
+
+/**
+ * The in-memory stand-in, for a process running without a database.
+ *
+ * Seeded from `CODE_ZERO_SOLO_REPOSITORIES` because the store starts empty on every boot and the
+ * procedures that would fill it require an administrator, which a freshly created throwaway
+ * account is not. That variable is read here and nowhere else: it configures a fixture, not a
+ * deployment, which is why the three variables this table replaced are gone rather than joined by
+ * a fourth.
+ *
+ * Entries are `owner/name=/path` or bare `/path`; the first form is watched, the second is only
+ * allow-listed. A malformed entry is dropped, the same way the poller's own configuration was.
+ */
+export function memoryRepositoryStore(seed: string | undefined): RepositoryStore {
+ const records = new Map();
+ let sequence = 0;
+
+ // Mirrors `saveRepository`'s upsert (`packages/database`): every field on `RepositoryInput` is
+ // optional, so a save that names only `checkoutPath` corrects that path and leaves the rest of
+ // the record alone. A field the input omits keeps the existing record's value — and falls back to
+ // the same default as the column only when there is no existing record — so a watched
+ // `owner/name` repository cannot be silently demoted to an unwatched one with no coordinates by a
+ // save that never mentioned them.
+ const put = (input: RepositoryInput): RepositoryRecord => {
+ const existing = [...records.values()].find(
+ (record) => record.checkoutPath === input.checkoutPath,
+ );
+ sequence += 1;
+ const record: RepositoryRecord = {
+ id: existing?.id ?? `repo_${String(sequence)}`,
+ provider:
+ input.provider === undefined
+ ? (existing?.provider ?? 'github')
+ : input.provider.trim() || 'github',
+ owner: input.owner === undefined ? (existing?.owner ?? null) : input.owner?.trim() || null,
+ name: input.name === undefined ? (existing?.name ?? null) : input.name?.trim() || null,
+ checkoutPath: input.checkoutPath,
+ mode: input.mode ?? existing?.mode ?? 'observe',
+ pollEnabled: input.pollEnabled ?? existing?.pollEnabled ?? false,
+ };
+ records.set(record.id, record);
+ return record;
+ };
+
+ for (const entry of (seed ?? '').split(',')) {
+ const trimmed = entry.trim();
+ if (trimmed === '') continue;
+ const [slug, checkoutPath] = trimmed.includes('=')
+ ? trimmed.split('=', 2).map((part) => part.trim())
+ : [undefined, trimmed];
+ if (!checkoutPath) continue;
+ const [owner, name] = (slug ?? '').split('/', 2).map((part) => part.trim());
+ put({
+ // Resolved the same way `mayTargetRepository` resolves the path a task creation names
+ // (`context.ts`) and `repositories.save` resolves an operator-supplied one (`router.ts`):
+ // a relative or trailing-slash entry here must still string-equal what a task creation
+ // compares it against, or every task creation against it is refused as not allow-listed.
+ checkoutPath: resolve(checkoutPath),
+ ...(owner && name ? { owner, name, pollEnabled: true } : {}),
+ });
+ }
+
+ return {
+ list: () => Promise.resolve([...records.values()]),
+ watched: () =>
+ Promise.resolve(
+ [...records.values()].filter(
+ (record): record is WatchedRepositoryRecord =>
+ record.pollEnabled && record.owner !== null && record.name !== null,
+ ),
+ ),
+ allows: (checkoutPath) =>
+ Promise.resolve([...records.values()].some((r) => r.checkoutPath === checkoutPath)),
+ save: (input) => Promise.resolve(put(input)),
+ remove: (id) => Promise.resolve(records.delete(id)),
+ };
+}
+
+/**
+ * The store this process uses.
+ *
+ * `AUTH_E2E_MEMORY` selects the in-memory one, the same flag `server/auth.config.ts` reads: it
+ * marks a process whose stores live and die with it, which is true of both stores or neither.
+ */
+export const repositoryStore: RepositoryStore =
+ process.env.AUTH_E2E_MEMORY === 'true'
+ ? memoryRepositoryStore(process.env.CODE_ZERO_SOLO_REPOSITORIES)
+ : postgresRepositoryStore();
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/access.test.ts b/apps/dashboard/test/unit/access.test.ts
new file mode 100644
index 0000000..bc0d2c5
--- /dev/null
+++ b/apps/dashboard/test/unit/access.test.ts
@@ -0,0 +1,55 @@
+import type { ControlPlaneAccess } from '@code-zero/api';
+import { describe, expect, it } from 'vitest';
+
+import { createControlPlaneAccessCache } from '../../server/utils/access.js';
+
+describe('createControlPlaneAccessCache', () => {
+ it('resolves once and shares the same answer with every later caller', async () => {
+ let loads = 0;
+ const access: ControlPlaneAccess = { principals: new Map() };
+ const controlPlaneAccess = createControlPlaneAccessCache(() => {
+ loads += 1;
+ return Promise.resolve(access);
+ });
+
+ await expect(controlPlaneAccess()).resolves.toBe(access);
+ await expect(controlPlaneAccess()).resolves.toBe(access);
+ expect(loads).toBe(1);
+ });
+
+ it('retries after a failed load instead of replaying the same rejection forever', async () => {
+ // A transient failure — the config file briefly unreadable during a rolling deploy, say — must
+ // not wedge every later call behind it for the rest of the process's life.
+ let loads = 0;
+ const access: ControlPlaneAccess = { principals: new Map() };
+ const controlPlaneAccess = createControlPlaneAccessCache(() => {
+ loads += 1;
+ return loads === 1 ? Promise.reject(new Error('config unreadable')) : Promise.resolve(access);
+ });
+
+ await expect(controlPlaneAccess()).rejects.toThrow('config unreadable');
+ await expect(controlPlaneAccess()).resolves.toBe(access);
+ // A third call finds the second's success already cached, not a third load.
+ await expect(controlPlaneAccess()).resolves.toBe(access);
+ expect(loads).toBe(2);
+ });
+
+ it('does not start a second load while the first is still in flight', async () => {
+ let loads = 0;
+ let resolve!: (access: ControlPlaneAccess) => void;
+ const first = new Promise((res) => {
+ resolve = res;
+ });
+ const controlPlaneAccess = createControlPlaneAccessCache(() => {
+ loads += 1;
+ return first;
+ });
+
+ const a = controlPlaneAccess();
+ const b = controlPlaneAccess();
+ resolve({ principals: new Map() });
+
+ await Promise.all([a, b]);
+ expect(loads).toBe(1);
+ });
+});
diff --git a/apps/dashboard/test/unit/errors.test.ts b/apps/dashboard/test/unit/errors.test.ts
index ff9b722..5f3f134 100644
--- a/apps/dashboard/test/unit/errors.test.ts
+++ b/apps/dashboard/test/unit/errors.test.ts
@@ -31,13 +31,19 @@ describe('errors', () => {
expect(errors.internal('plain failure').message).toBe('plain failure');
});
- it('never marks an error fatal or unhandled, so Nitro forwards `message` instead of replacing it', () => {
+ it('marks every error `fatal: false` and `unhandled: false`, so Nitro forwards `message` instead of replacing it', () => {
// Nitro's own error handler — not bare h3's `sendError`, which drops `message` — serves every
- // response in this app. It forwards `error.message` verbatim only while both flags stay
- // false (h3's default); either one true and the client sees a generic "Server Error" instead.
+ // response in this app. It forwards `error.message` verbatim only while both flags read
+ // falsy. `EvlogError` itself never declares either one, so this module sets both to `false`
+ // explicitly rather than leaving them `undefined`: functionally identical for Nitro's truthy
+ // check, but it is what lets an assertion elsewhere that expects the fields to be exactly
+ // `false` pass. Either flag present and truthy would make the client see a generic
+ // "Server Error" instead.
for (const error of [
errors.notFound(),
errors.misconfigured('GITHUB_WEBHOOK_SECRET'),
+ errors.forbidden('nope'),
+ errors.tooManyRequests('slow down'),
errors.internal(new Error('boom')),
]) {
expect(error.fatal).toBe(false);
diff --git a/apps/dashboard/test/unit/overview.test.ts b/apps/dashboard/test/unit/overview.test.ts
new file mode 100644
index 0000000..b4ba4ce
--- /dev/null
+++ b/apps/dashboard/test/unit/overview.test.ts
@@ -0,0 +1,191 @@
+import { EventEmitter } from 'node:events';
+
+import type { StoredTask } from '@code-zero/api';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { createOverviewBroadcaster } from '../../server/utils/overview.js';
+
+const CHANGED = 'changed';
+
+/** A `store.list()` a test can resolve or reject on its own schedule. */
+function deferred(): { promise: Promise; resolve: (value: T) => void } {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((res) => {
+ resolve = res;
+ });
+ return { promise, resolve };
+}
+
+beforeEach(() => {
+ vi.useFakeTimers();
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe('createOverviewBroadcaster', () => {
+ it('coalesces a burst of changes into one store read', async () => {
+ let reads = 0;
+ const store = { list: () => Promise.resolve(((reads += 1), [])) };
+ const changes = new EventEmitter();
+ const broadcaster = createOverviewBroadcaster(store, changes, CHANGED);
+ const received: unknown[] = [];
+ broadcaster.subscribeOverview((overview) => received.push(overview));
+
+ changes.emit(CHANGED);
+ changes.emit(CHANGED);
+ changes.emit(CHANGED);
+ await vi.advanceTimersByTimeAsync(250);
+
+ expect(reads).toBe(1);
+ expect(received).toHaveLength(1);
+ });
+
+ it('shares one read across every subscribed listener', async () => {
+ let reads = 0;
+ const store = { list: () => Promise.resolve(((reads += 1), [])) };
+ const changes = new EventEmitter();
+ const broadcaster = createOverviewBroadcaster(store, changes, CHANGED);
+ let a = 0;
+ let b = 0;
+ broadcaster.subscribeOverview(() => {
+ a += 1;
+ });
+ broadcaster.subscribeOverview(() => {
+ b += 1;
+ });
+
+ changes.emit(CHANGED);
+ await vi.advanceTimersByTimeAsync(250);
+
+ expect(reads).toBe(1);
+ expect(a).toBe(1);
+ expect(b).toBe(1);
+ });
+
+ it('does not start a second read while one is already in flight', async () => {
+ const first = deferred();
+ const reads: number[] = [];
+ const store = {
+ list: () => {
+ reads.push(reads.length);
+ return first.promise;
+ },
+ };
+ const changes = new EventEmitter();
+ const broadcaster = createOverviewBroadcaster(store, changes, CHANGED);
+ broadcaster.subscribeOverview(() => undefined);
+
+ changes.emit(CHANGED);
+ await vi.advanceTimersByTimeAsync(250);
+ expect(reads).toHaveLength(1);
+
+ // A second change arrives while the first read is still unresolved. Without the `reading`
+ // guard, this would arm a second timer and start a second, overlapping read once it fires.
+ changes.emit(CHANGED);
+ await vi.advanceTimersByTimeAsync(250);
+ expect(reads).toHaveLength(1);
+
+ first.resolve([]);
+ });
+
+ it('does not drop a change that arrived while a read was in flight', async () => {
+ const first = deferred();
+ const second = deferred();
+ const responses = [first.promise, second.promise];
+ let reads = 0;
+ const store = {
+ list: () => {
+ const response = responses[reads];
+ reads += 1;
+ return response ?? Promise.resolve([]);
+ },
+ };
+ const changes = new EventEmitter();
+ const broadcaster = createOverviewBroadcaster(store, changes, CHANGED);
+ let received = 0;
+ broadcaster.subscribeOverview(() => {
+ received += 1;
+ });
+
+ changes.emit(CHANGED);
+ await vi.advanceTimersByTimeAsync(250);
+ expect(reads).toBe(1);
+
+ // Arrives mid-read: not scheduled immediately, but not lost either.
+ changes.emit(CHANGED);
+
+ first.resolve([]);
+ // Let the first read's `.finally` run and, seeing the change above, schedule the next one.
+ await vi.advanceTimersByTimeAsync(0);
+ await vi.advanceTimersByTimeAsync(250);
+
+ expect(reads).toBe(2);
+ second.resolve([]);
+ await vi.advanceTimersByTimeAsync(0);
+
+ expect(received).toBe(2);
+ });
+
+ it('reports a failed read without throwing, and keeps taking later changes', async () => {
+ const errors: unknown[] = [];
+ const originalError = console.error;
+ console.error = (...args: unknown[]) => {
+ errors.push(args);
+ };
+ let reads = 0;
+ const store = {
+ list: () => {
+ reads += 1;
+ return reads === 1 ? Promise.reject(new Error('storage unavailable')) : Promise.resolve([]);
+ },
+ };
+ const changes = new EventEmitter();
+ const broadcaster = createOverviewBroadcaster(store, changes, CHANGED);
+ let received = 0;
+ broadcaster.subscribeOverview(() => {
+ received += 1;
+ });
+
+ try {
+ changes.emit(CHANGED);
+ await vi.advanceTimersByTimeAsync(250);
+ expect(errors).toHaveLength(1);
+ expect(received).toBe(0);
+
+ changes.emit(CHANGED);
+ await vi.advanceTimersByTimeAsync(250);
+ expect(received).toBe(1);
+ } finally {
+ console.error = originalError;
+ }
+ });
+
+ it('answers currentOverview immediately, without waiting for the debounce window', async () => {
+ const store = { list: () => Promise.resolve([]) };
+ const changes = new EventEmitter();
+ const broadcaster = createOverviewBroadcaster(store, changes, CHANGED);
+
+ await expect(broadcaster.currentOverview()).resolves.toMatchObject({});
+ });
+
+ it('stops delivering to a stream once it unsubscribes', async () => {
+ const store = { list: () => Promise.resolve([]) };
+ const changes = new EventEmitter();
+ const broadcaster = createOverviewBroadcaster(store, changes, CHANGED);
+ let received = 0;
+ const unsubscribe = broadcaster.subscribeOverview(() => {
+ received += 1;
+ });
+
+ changes.emit(CHANGED);
+ await vi.advanceTimersByTimeAsync(250);
+ expect(received).toBe(1);
+
+ unsubscribe();
+ changes.emit(CHANGED);
+ await vi.advanceTimersByTimeAsync(250);
+ expect(received).toBe(1);
+ });
+});
diff --git a/apps/dashboard/test/unit/poller.test.ts b/apps/dashboard/test/unit/poller.test.ts
new file mode 100644
index 0000000..bf56d1a
--- /dev/null
+++ b/apps/dashboard/test/unit/poller.test.ts
@@ -0,0 +1,296 @@
+import { reviewDeliveryKey, type DeliveryClaim, type DeliveryClaimStore } from '@code-zero/api';
+import type { OpenPullRequest } from '@code-zero/source-control';
+import { describe, expect, it } from 'vitest';
+
+import {
+ pollClaimKey,
+ pollOnce,
+ type PollRequest,
+ type WatchedRepository,
+} from '../../server/utils/poller.js';
+
+const HEAD = 'c'.repeat(40);
+const BASE = 'b'.repeat(40);
+
+const WATCHED: WatchedRepository = {
+ provider: 'github',
+ owner: 'acme',
+ name: 'app',
+ checkoutPath: '/srv/checkouts/acme-app',
+ mode: 'observe',
+};
+
+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',
+ mode: 'observe',
+ pullRequest: { owner: 'acme', repo: 'app', number: 412, baseSha: BASE, headSha: HEAD },
+ source: 'poll:acme/app#412',
+ },
+ ]);
+ });
+
+ it('starts each run in the mode its own repository is configured with', async () => {
+ const runs = collector();
+
+ await pollOnce({
+ repositories: [{ ...WATCHED, mode: 'suggest' }],
+ source: source(pull()),
+ claims: new MemoryClaims(),
+ start: runs.start,
+ });
+
+ expect(runs.started[0]?.mode).toBe('suggest');
+ });
+
+ 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('does not retry a commit whose review ran, even when its claim could not be recorded as complete', async () => {
+ // The review itself succeeded; only the write that marks the claim complete failed, the way a
+ // transient storage error would. Unlike a failed start, the claim is left standing here: the
+ // task for this revision already exists, so releasing it would let the next pass claim and
+ // start a second review of the same commit rather than recover from anything.
+ class FlakyClaims extends MemoryClaims {
+ override complete(): Promise {
+ return Promise.reject(new Error('storage unavailable'));
+ }
+ }
+ const claims = new FlakyClaims();
+ const failures: unknown[] = [];
+ const flaky = {
+ repositories: [WATCHED],
+ source: source(pull()),
+ claims,
+ start: collector().start,
+ onError: (_repository: string, error: unknown) => failures.push(error),
+ };
+
+ const started = await pollOnce(flaky);
+ expect(started).toBe(1);
+ expect(String(failures[0])).toContain('storage unavailable');
+
+ // The claim was left standing despite the failed completion write, so the next pass finds this
+ // commit already claimed and does not start a second review of it.
+ const runs = collector();
+ await pollOnce({ ...flaky, start: runs.start, onError: undefined });
+ expect(runs.started).toHaveLength(0);
+ });
+
+ it('keeps polling the other repositories when one provider fails', async () => {
+ const runs = collector();
+ const second = { ...WATCHED, name: '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('starts a review without waiting for an earlier one to finish before considering the rest', async () => {
+ const order: string[] = [];
+ let resolveFirst!: () => void;
+ const first = new Promise((resolve) => {
+ resolveFirst = resolve;
+ });
+ const second = { ...WATCHED, name: 'billing', checkoutPath: '/srv/checkouts/acme-billing' };
+
+ const started = await pollOnce({
+ repositories: [WATCHED, second],
+ source: {
+ listOpenPullRequests: async (target) =>
+ target.repo === 'app' ? [pull({ number: 1 })] : [pull({ number: 2 })],
+ },
+ claims: new MemoryClaims(),
+ start: async (request) => {
+ if (request.pullRequest.repo === 'app') {
+ order.push('app started');
+ await first;
+ order.push('app finished');
+ return;
+ }
+ order.push('billing started');
+ resolveFirst();
+ },
+ });
+
+ expect(started).toBe(2);
+ // "billing started" lands before "app finished": the second repository's review was
+ // dispatched to the scheduler without this pass waiting for the first one to complete.
+ expect(order.indexOf('billing started')).toBeLessThan(order.indexOf('app finished'));
+ });
+
+ 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(`review:github:acme/app#412@${HEAD}`);
+ expect(pollClaimKey(WATCHED, pull({ headSha: 'd'.repeat(40) }))).not.toBe(
+ pollClaimKey(WATCHED, pull()),
+ );
+ });
+
+ it('matches the key the webhook route claims for the same commit, so neither reviews it twice', () => {
+ expect(pollClaimKey(WATCHED, pull())).toBe(
+ reviewDeliveryKey({
+ provider: 'github',
+ owner: WATCHED.owner,
+ repo: WATCHED.name,
+ number: 412,
+ headSha: HEAD,
+ }),
+ );
+ });
+});
diff --git a/apps/dashboard/test/unit/repositories.test.ts b/apps/dashboard/test/unit/repositories.test.ts
new file mode 100644
index 0000000..dde8d06
--- /dev/null
+++ b/apps/dashboard/test/unit/repositories.test.ts
@@ -0,0 +1,57 @@
+import { resolve } from 'node:path';
+
+import { describe, expect, it } from 'vitest';
+
+import { memoryRepositoryStore } from '../../server/utils/repositories.js';
+
+const CHECKOUT = resolve('/srv/widget');
+
+describe('memoryRepositoryStore', () => {
+ it('watches a seeded `owner/name=/path` entry and only allow-lists a bare path', async () => {
+ const store = memoryRepositoryStore(`acme/widget=${CHECKOUT},/srv/other`);
+
+ expect(await store.allows(CHECKOUT)).toBe(true);
+ expect(await store.allows(resolve('/srv/other'))).toBe(true);
+ expect((await store.watched()).map((record) => `${record.owner}/${record.name}`)).toEqual([
+ 'acme/widget',
+ ]);
+ });
+
+ it('keeps the fields a save leaves out, the way the Postgres upsert does', async () => {
+ const store = memoryRepositoryStore(`acme/widget=${CHECKOUT}`);
+
+ const saved = await store.save({ checkoutPath: CHECKOUT });
+
+ // A save naming only the path corrects the path: it must not demote a watched repository to an
+ // unwatched one with no coordinates, which is what the poller reads `watched()` for.
+ expect(saved).toMatchObject({
+ provider: 'github',
+ owner: 'acme',
+ name: 'widget',
+ checkoutPath: CHECKOUT,
+ pollEnabled: true,
+ });
+ expect(await store.list()).toHaveLength(1);
+ expect((await store.watched()).map((record) => record.checkoutPath)).toEqual([CHECKOUT]);
+ });
+
+ it('writes the fields a save does name, and defaults them for a repository it has never seen', async () => {
+ const store = memoryRepositoryStore(`acme/widget=${CHECKOUT}`);
+
+ const updated = await store.save({
+ checkoutPath: CHECKOUT,
+ mode: 'suggest',
+ pollEnabled: false,
+ });
+ expect(updated).toMatchObject({ owner: 'acme', mode: 'suggest', pollEnabled: false });
+
+ const created = await store.save({ checkoutPath: resolve('/srv/fresh') });
+ expect(created).toMatchObject({
+ provider: 'github',
+ owner: null,
+ name: null,
+ mode: 'observe',
+ pollEnabled: false,
+ });
+ });
+});
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/apps/docs/content/1.guide/10.api/4.protect-endpoints.md b/apps/docs/content/1.guide/10.api/4.protect-endpoints.md
index 1a257fb..1d46b37 100644
--- a/apps/docs/content/1.guide/10.api/4.protect-endpoints.md
+++ b/apps/docs/content/1.guide/10.api/4.protect-endpoints.md
@@ -16,25 +16,32 @@ The authenticated principal's **name** (not the token) is what the system record
## Repository allow-list
-`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:
-
-```bash
-CODE_ZERO_CONTROL_PLANE_REPOSITORIES=/srv/checkouts/app,/srv/checkouts/lib
-```
+`tasks.create` additionally requires the target repository path to be one of the configured repositories, so an HTTP caller cannot point a run at an arbitrary server-local path. Repositories are rows in the store, managed through `repositories.list`, `repositories.save`, and `repositories.remove` (and from the dashboard): a checkout added a moment ago is targetable without restarting the process.
## Execution-mode grants
-`CODE_ZERO_CONTROL_PLANE_MODES` holds comma-separated `name:mode|mode` grants for the execution modes each principal may request:
+Which execution modes a principal may request is deployment policy, so it lives in `code-zero.deployment.yml` (or the path `CODE_ZERO_CONFIG` names) rather than in the environment beside the token itself:
-```bash
-CODE_ZERO_CONTROL_PLANE_MODES=ci:observe|suggest,ops:fix
+```yaml
+control_plane:
+ modes:
+ ci: [observe, suggest]
+ release: [observe, suggest, fix, autonomous]
```
-Without a grant, a principal may only request the non-writable `observe` and `suggest` modes — `fix` and `autonomous` require an explicit operator grant.
+The key is the principal name from `CODE_ZERO_CONTROL_PLANE_TOKENS`. Without a grant, a principal may only request the non-writable `observe` and `suggest` modes — `fix` and `autonomous` require an explicit operator grant.
## CORS
-`CODE_ZERO_CONTROL_PLANE_ORIGINS` lists origins allowed to read `/api/v1/**` cross-origin. It is empty by default: `tasks.list`, `tasks.get`, and `health` are unauthenticated by design, so letting a browser read their responses from another origin is an explicit opt-in, not the default.
+`control_plane.origins` in the same file lists origins allowed to read `/api/v1/**` cross-origin:
+
+```yaml
+control_plane:
+ origins:
+ - https://ops.example.com
+```
+
+It is empty by default: `tasks.list`, `tasks.get`, and `health` are unauthenticated by design, so letting a browser read their responses from another origin is an explicit opt-in, not the default.
## Two independent schemes
diff --git a/apps/docs/content/1.guide/11.authentication/3.permissions.md b/apps/docs/content/1.guide/11.authentication/3.permissions.md
index dcd3329..9553a1d 100644
--- a/apps/docs/content/1.guide/11.authentication/3.permissions.md
+++ b/apps/docs/content/1.guide/11.authentication/3.permissions.md
@@ -12,11 +12,11 @@ A Better Auth session grants access to the dashboard UI. Registration is closed
Control-plane mutations require an operator-issued bearer token, with per-principal repository and execution-mode grants:
-| Variable | Grants |
-| -------------------------------------- | ------------------------------------------------ |
-| `CODE_ZERO_CONTROL_PLANE_TOKENS` | Who may mutate at all (`name:token` pairs) |
-| `CODE_ZERO_CONTROL_PLANE_REPOSITORIES` | Which repository paths `tasks.create` may target |
-| `CODE_ZERO_CONTROL_PLANE_MODES` | Which execution modes each principal may request |
+| Setting | Grants |
+| ---------------------------------------------- | ------------------------------------------------ |
+| `CODE_ZERO_CONTROL_PLANE_TOKENS` (environment) | Who may mutate at all (`name:token` pairs) |
+| The `repository` table (store) | Which repository paths `tasks.create` may target |
+| `control_plane.modes` (deployment file) | Which execution modes each principal may request |
Without a mode grant, a principal is limited to the non-writable `observe` and `suggest` modes. Approval decisions record the authenticated principal's name, never a wire-supplied actor. See [Protect endpoints](/guide/api/protect-endpoints).
diff --git a/apps/docs/content/1.guide/16.deployment.md b/apps/docs/content/1.guide/16.deployment.md
index a731469..d45241a 100644
--- a/apps/docs/content/1.guide/16.deployment.md
+++ b/apps/docs/content/1.guide/16.deployment.md
@@ -19,7 +19,7 @@ node apps/dashboard/.output/server/index.mjs
- [ ] **Postgres** — point `DATABASE_URL` at a managed database and apply the schema once with `aube run db:migrate`. See [Database](/guide/database).
- [ ] **Auth policy** — decide `AUTH_ENABLE_SIGNUP`, GitHub OAuth credentials, and organization flags, then **build with them set**: the auth pages capture capabilities at build time. See [Authentication](/guide/authentication/overview).
- [ ] **Mail** — configure Resend (`RESEND_API_KEY`; `MAIL_PROVIDER=resend` is optional) or set `MAIL_PROVIDER=smtp` with its SMTP variables. With neither, `console` only logs. Required if invitations or organizations are enabled. See [Mails](/guide/mails).
-- [ ] **Control plane** — issue bearer tokens via `CODE_ZERO_CONTROL_PLANE_TOKENS`, allow-list repositories via `CODE_ZERO_CONTROL_PLANE_REPOSITORIES`, and grant modes via `CODE_ZERO_CONTROL_PLANE_MODES`. Without them, mutations are rejected. See [Protect endpoints](/guide/api/protect-endpoints).
+- [ ] **Control plane** — issue bearer tokens via `CODE_ZERO_CONTROL_PLANE_TOKENS`, add the repositories `tasks.create` may target from the dashboard (they are rows in the store), and grant modes via `control_plane.modes` in `code-zero.deployment.yml`. Without a token, mutations are rejected. See [Protect endpoints](/guide/api/protect-endpoints).
- [ ] **Webhooks** — set `GITHUB_WEBHOOK_SECRET` and `CODE_ZERO_CHECKOUT_PATH`; the webhook route fails closed (503) until both are set. Configure the webhook on the source-control side per [Source-control providers](/reference/source-control-providers).
- [ ] **Isolated execution** — production runs that write require `runner.isolation: container` in repository policy, with an image and resource limits. The included `LocalRunner` is for trusted local development only. See the [Safety model](/guide/safety).
- [ ] **Model provider** — select the provider in repository policy and set its credential environment variable. See [Model providers](/reference/model-providers).
diff --git a/apps/docs/content/1.guide/4.environment-variables.md b/apps/docs/content/1.guide/4.environment-variables.md
index 473535d..8a9417c 100644
--- a/apps/docs/content/1.guide/4.environment-variables.md
+++ b/apps/docs/content/1.guide/4.environment-variables.md
@@ -51,12 +51,12 @@ Each provider reads only its documented variable. See [Model providers](/referen
The control-plane API (`/rpc/**` and `/api/v1/**`) fails closed: without `CODE_ZERO_CONTROL_PLANE_TOKENS` every mutation is rejected while reads stay open.
-| Variable | Purpose |
-| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
-| `CODE_ZERO_CONTROL_PLANE_TOKENS` | Comma-separated `name:token` bearer credentials |
-| `CODE_ZERO_CONTROL_PLANE_REPOSITORIES` | Comma-separated repository paths `tasks.create` may target |
-| `CODE_ZERO_CONTROL_PLANE_MODES` | Comma-separated `name:mode\|mode` execution-mode grants; without one a principal may only request the non-writable `observe` and `suggest` modes |
-| `CODE_ZERO_CONTROL_PLANE_ORIGINS` | Comma-separated origins allowed to read `/api/v1/**` cross-origin via CORS; empty by default |
+| Variable | Purpose |
+| -------------------------------- | --------------------------------------------------------------------------------------------------- |
+| `CODE_ZERO_CONTROL_PLANE_TOKENS` | Comma-separated `name:token` bearer credentials |
+| `CODE_ZERO_CONFIG` | Path to the deployment policy file; defaults to `code-zero.deployment.yml` in the working directory |
+
+The rest of the control plane's policy is not environment configuration: allowed CORS origins (`control_plane.origins`) and execution-mode grants (`control_plane.modes`) are read from that deployment file, and the repositories `tasks.create` may target are rows in the store rather than a variable.
See [Protect endpoints](/guide/api/protect-endpoints) for how these are enforced.
diff --git a/code-zero.deployment.example.yml b/code-zero.deployment.example.yml
new file mode 100644
index 0000000..1a4d566
--- /dev/null
+++ b/code-zero.deployment.example.yml
@@ -0,0 +1,35 @@
+# Deployment policy for the dashboard, copied to `code-zero.deployment.yml` (or to any path named
+# by CODE_ZERO_CONFIG). Every section is optional; a deployment that has changed nothing ships no
+# file at all and gets these defaults.
+#
+# What belongs here, and what does not:
+#
+# - Here: policy. Values that are fixed for the life of the process, are not secret, and are read
+# more easily as a structured document than as a comma-separated variable.
+# - The environment: secrets, and the two bootstrap values needed to find everything else
+# (DATABASE_URL, CODE_ZERO_CONFIG).
+# - The `repository` table: anything that changes while the process runs. Which checkouts a task
+# may target, and which repositories the poller watches, are rows an operator edits from the
+# dashboard — a repository added a moment ago is answerable without a restart.
+#
+# Every field is validated. A value that is refused is reported on stderr with its own `$.path` and
+# falls back to the default below, so one wrong line does not stop the dashboard from loading.
+
+control_plane:
+ # Origins allowed to read /api/v1/** cross-origin via CORS. Empty by default: `tasks.list`,
+ # `tasks.get` and `health` are unauthenticated by design, so letting a browser read their
+ # responses from another origin is an explicit opt-in, not the default.
+ origins: []
+ # - https://ops.example.com
+
+ # Execution modes each operator token may request from `tasks.create`, keyed by the principal
+ # name in CODE_ZERO_CONTROL_PLANE_TOKENS. Without a grant a principal may only request the
+ # non-writable `observe` and `suggest`. An unknown mode, or an unknown principal name, is
+ # refused rather than quietly narrowed.
+ modes: {}
+ # ci: [observe, suggest]
+ # release: [observe, suggest, fix, autonomous]
+
+poll:
+ # Seconds between polling passes. Clamped to 15..3600.
+ interval_seconds: 60
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..384f8d2 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.
@@ -107,7 +107,11 @@ It differs from the dashboard in exactly one respect. The dashboard renders with
`/rpc/**` and `/api/v1/**` serve the exact same `rpcRouter` and therefore the exact same authorization rules; only the wire protocol differs. `.meta(openapi(...))` metadata on each procedure (method, path, tags) exists purely for the OpenAPI transport and has no effect on the RPC transport — it is attached through a real, regularly-imported function rather than the `@orpc/openapi` package's alternative bare side-effect import, because Nitro's production bundler tree-shakes an unused side-effect import away even though the package's own `sideEffects` field marks it as one to keep.
-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.
+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 be one of the configured 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 by `control_plane.modes` in the deployment configuration (without one a principal is limited to the non-writable `observe` and `suggest` modes). The two grants live in different places on purpose: the mode grants are fixed for the life of the process, while the repositories change while it runs, so the first is a configuration file and the second is a table. 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 reads the watched repositories from the store on every pass, so turning polling on for one takes effect without a restart; it watches nothing until an operator configures a repository with polling on. It requests only the mode that repository is configured with — never one that can write — and reaches a checkout only through the path an operator paired with the repository, never one it derives. `poll.interval_seconds` in the deployment configuration sets the pace. 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.
diff --git a/package.json b/package.json
index 3b9c6ba..68feeb4 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,9 @@
"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:docs": "turbo run dev --filter=@code-zero/docs",
+ "dev:marketing": "turbo run dev --filter=@code-zero/marketing",
+ "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..fec9cad 100644
--- a/packages/api/src/access.test.ts
+++ b/packages/api/src/access.test.ts
@@ -1,44 +1,51 @@
+import type { RunMode } from '@code-zero/shared';
import { describe, expect, it } from 'vitest';
import {
accessFromEnvironment,
authenticate,
- controlPlaneOriginsFromEnvironment,
- mayTargetRepository,
sessionPrincipal,
type ControlPlaneAccess,
} from './access.js';
const TOKEN_FORMAT_ERROR = /name:token/;
-const MODE_FORMAT_ERROR = /name:mode\|mode/;
-const UNKNOWN_MODE_ERROR = /unknown mode/;
const UNKNOWN_PRINCIPAL_ERROR = /unknown principal/;
+/** The grants the deployment configuration resolves; this package only consumes them. */
+function grants(entries: Record): ReadonlyMap {
+ return new Map(Object.entries(entries));
+}
+
function access(overrides: Partial = {}): ControlPlaneAccess {
return {
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'],
...overrides,
};
}
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 no token is configured', () => {
+ // Repository targeting no longer depends on this: a deployment that authenticates only browser
+ // sessions still creates tasks, it just accepts no machine caller.
+ expect(accessFromEnvironment(undefined)).toBeUndefined();
+ expect(accessFromEnvironment('')).toBeUndefined();
+ expect(accessFromEnvironment(' , ')).toBeUndefined();
});
- it('parses name:token pairs and the repository allow-list', () => {
- const parsed = accessFromEnvironment('release-manager:tok1, ci:tok2', '/srv/app, ./checkout');
+ it('parses name:token pairs', () => {
+ const parsed = accessFromEnvironment('release-manager:tok1, ci:tok2');
expect(parsed?.principals.get('tok1')?.name).toBe('release-manager');
expect(parsed?.principals.get('tok2')?.name).toBe('ci');
- expect(parsed?.repositories).toEqual(['/srv/app', './checkout']);
});
it('keeps tokens containing separators intact after the first colon', () => {
@@ -52,43 +59,38 @@ describe('accessFromEnvironment', () => {
expect(() => accessFromEnvironment('name-only:')).toThrow(TOKEN_FORMAT_ERROR);
});
- it('defaults to an empty repository allow-list', () => {
- expect(accessFromEnvironment('ops:tok', undefined)?.repositories).toEqual([]);
+ it('grants only the non-writable modes without an explicit grant', () => {
+ expect(accessFromEnvironment('ops:tok')?.principals.get('tok')?.modes).toEqual([
+ 'observe',
+ 'suggest',
+ ]);
});
- it('grants only the non-writable modes without an explicit mode entry', () => {
- const parsed = accessFromEnvironment('ops:tok', undefined, undefined);
- expect(parsed?.principals.get('tok')?.modes).toEqual(['observe', 'suggest']);
- });
-
- it('parses per-principal mode grants', () => {
+ it('applies the per-principal grants the deployment configuration resolved', () => {
const parsed = accessFromEnvironment(
'release-manager:tok1, ci:tok2',
- undefined,
- 'release-manager:observe|fix|autonomous',
+ grants({ 'release-manager': ['observe', 'fix', 'autonomous'] }),
);
expect(parsed?.principals.get('tok1')?.modes).toEqual(['observe', 'fix', 'autonomous']);
expect(parsed?.principals.get('tok2')?.modes).toEqual(['observe', 'suggest']);
});
- it('refuses unknown modes rather than silently granting or dropping them', () => {
- expect(() => accessFromEnvironment('ops:tok', undefined, 'ops:yolo')).toThrow(
- UNKNOWN_MODE_ERROR,
- );
+ it('grants nothing to a principal whose configured grant resolved to no valid mode', () => {
+ // `packages/config`'s `parseDeploymentConfig` refuses a grant that named an unknown mode, but
+ // still records the principal with an empty mode list rather than omitting it — omitting it
+ // would land here as "no grant configured" and widen the principal to the non-writable
+ // defaults, which is worse than the mistake it was meant to catch.
+ const parsed = accessFromEnvironment('ci:tok', grants({ ci: [] }));
+ expect(parsed?.principals.get('tok')?.modes).toEqual([]);
});
- it('refuses mode grants for principals that hold no token', () => {
- expect(() => accessFromEnvironment('ops:tok', undefined, 'ghost:fix')).toThrow(
+ it('refuses grants for principals that hold no token', () => {
+ // A grant nobody can use is a typo in one of the two places, and the deployment should be told
+ // which rather than quietly running with a narrower policy than it wrote down.
+ expect(() => accessFromEnvironment('ops:tok', grants({ ghost: ['fix'] }))).toThrow(
UNKNOWN_PRINCIPAL_ERROR,
);
});
-
- it('refuses malformed mode entries', () => {
- expect(() => accessFromEnvironment('ops:tok', undefined, 'ops')).toThrow(MODE_FORMAT_ERROR);
- expect(() => accessFromEnvironment('ops:tok', undefined, 'ops:')).toThrow(MODE_FORMAT_ERROR);
- expect(() => accessFromEnvironment('ops:tok', undefined, ':fix')).toThrow(MODE_FORMAT_ERROR);
- expect(() => accessFromEnvironment('ops:tok', undefined, 'ops:|')).toThrow(MODE_FORMAT_ERROR);
- });
});
describe('authenticate', () => {
@@ -96,6 +98,7 @@ describe('authenticate', () => {
expect(authenticate('Bearer token-value', access())).toEqual({
name: 'release-manager',
kind: 'token',
+ admin: false,
modes: ['observe', 'suggest'],
});
});
@@ -113,42 +116,12 @@ describe('authenticate', () => {
});
});
-describe('controlPlaneOriginsFromEnvironment', () => {
- it('defaults to no trusted origins', () => {
- expect(controlPlaneOriginsFromEnvironment(undefined)).toEqual([]);
- expect(controlPlaneOriginsFromEnvironment('')).toEqual([]);
- expect(controlPlaneOriginsFromEnvironment(' , ')).toEqual([]);
- });
-
- it('parses a comma-separated origin allow-list', () => {
- expect(
- controlPlaneOriginsFromEnvironment('https://dashboard.example, https://ops.example'),
- ).toEqual(['https://dashboard.example', 'https://ops.example']);
- });
-});
-
-describe('mayTargetRepository', () => {
- it('authorizes only allow-listed repository paths', () => {
- expect(mayTargetRepository('/srv/checkout', access())).toBe(true);
- expect(mayTargetRepository('/srv/other', access())).toBe(false);
- });
-
- it('compares resolved paths so traversal cannot dodge the allow-list', () => {
- expect(mayTargetRepository('/srv/checkout/../checkout', access())).toBe(true);
- expect(mayTargetRepository('/srv/checkout/../other', access())).toBe(false);
- });
-
- it('fails closed without a policy or with an empty allow-list', () => {
- expect(mayTargetRepository('/srv/checkout', undefined)).toBe(false);
- expect(mayTargetRepository('/srv/checkout', access({ repositories: [] }))).toBe(false);
- });
-});
-
describe('sessionPrincipal', () => {
it('grants an administrator every execution mode', () => {
expect(sessionPrincipal('ops@example.test', true)).toEqual({
name: 'ops@example.test',
kind: 'session',
+ admin: true,
modes: ['observe', 'suggest', 'fix', 'autonomous'],
});
});
@@ -157,6 +130,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..9420e10 100644
--- a/packages/api/src/access.ts
+++ b/packages/api/src/access.ts
@@ -1,5 +1,4 @@
import { timingSafeEqual } from 'node:crypto';
-import { resolve } from 'node:path';
import type { RunMode } from '@code-zero/shared';
@@ -19,60 +18,59 @@ 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;
}
/**
- * Static access policy for the control-plane transport.
+ * Who may call the control plane as a machine, and what each of them may run.
*
- * Mutating procedures fail closed: without configured principals no mutation is accepted, and task
- * creation additionally requires the target repository path to be allow-listed by the operator and
- * the requested execution mode to be granted to the authenticated principal.
+ * Identity only. Which checkout a run may target is a separate grant the composition root answers
+ * from the deployment's own store (`RpcContext.mayTargetRepository`), because that list changes
+ * while the process runs and this one does not: a token is a credential, a repository is data.
*/
export interface ControlPlaneAccess {
/** Bearer token to authenticated principal. */
principals: ReadonlyMap;
- /** Repository paths that `tasks.create` may target. */
- repositories: readonly string[];
}
const BEARER_PREFIX = 'Bearer ';
-const RUN_MODES: ReadonlySet = new Set([
- 'observe',
- 'suggest',
- 'fix',
- 'autonomous',
-] satisfies RunMode[]);
-
/** Granted when a principal has no explicit mode entry; neither mode can produce a writable runner. */
const DEFAULT_MODES: readonly RunMode[] = ['observe', 'suggest'];
/** Every execution mode, including the two that produce a writable runner. */
const ADMIN_MODES: readonly RunMode[] = ['observe', 'suggest', 'fix', 'autonomous'];
-function isRunMode(value: string): value is RunMode {
- return RUN_MODES.has(value);
-}
-
/**
- * Parse the access policy from the environment.
+ * Resolve the operator tokens a machine caller may present.
*
- * `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.
+ * The token is a credential, so it stays in the environment beside the database password and the
+ * signing secret rather than moving into the deployment's configuration file or its store: a
+ * secret belongs where the deployment already keeps secrets. The grants that go with it —
+ * which execution modes each principal may request — are policy, and come from the deployment
+ * configuration instead.
+ *
+ * Returns `undefined` when no token is configured, which keeps a deployment that authenticates
+ * only browser sessions from accepting any machine caller at all.
*/
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,
+ grants: ReadonlyMap = new Map(),
): ControlPlaneAccess | undefined {
if (tokens === undefined || tokens.trim() === '') 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,65 +79,20 @@ 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;
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 !== ''),
- };
-}
-
-/** Parse `name:mode|mode` grants, refusing unknown modes rather than silently widening or narrowing. */
-function parseModeGrants(modes: string | undefined): Map {
- const grants = new Map();
- if (modes === undefined || modes.trim() === '') return grants;
- for (const entry of modes.split(',')) {
- const trimmed = entry.trim();
- if (trimmed === '') continue;
- const separator = trimmed.indexOf(':');
- const name = separator > 0 ? trimmed.slice(0, separator).trim() : '';
- const granted = separator > 0 ? trimmed.slice(separator + 1).trim() : '';
- if (name === '' || granted === '')
- throw new Error('CODE_ZERO_CONTROL_PLANE_MODES entries must be name:mode|mode pairs');
- const parsed: RunMode[] = [];
- for (const candidate of granted.split('|')) {
- const mode = candidate.trim();
- if (mode === '') continue;
- if (!isRunMode(mode))
- throw new Error(`CODE_ZERO_CONTROL_PLANE_MODES grants an unknown mode: ${mode}`);
- parsed.push(mode);
- }
- if (parsed.length === 0)
- throw new Error('CODE_ZERO_CONTROL_PLANE_MODES entries must be name:mode|mode pairs');
- grants.set(name, parsed);
- }
- return grants;
-}
-
-/**
- * Parse the REST/OpenAPI transport's CORS allow-list from the environment.
- *
- * `CODE_ZERO_CONTROL_PLANE_ORIGINS` holds comma-separated origins. Defaults to none: `tasks.list`,
- * `tasks.get`, and `health` are unauthenticated by design, but a browser's ability to read their
- * responses cross-origin is a separate grant that has to be configured explicitly rather than
- * defaulting open.
- */
-export function controlPlaneOriginsFromEnvironment(
- origins = process.env.CODE_ZERO_CONTROL_PLANE_ORIGINS,
-): readonly string[] {
- return (origins ?? '')
- .split(',')
- .map((origin) => origin.trim())
- .filter((origin) => origin !== '');
+ throw new Error(`control_plane.modes grants modes to an unknown principal: ${name}`);
+ return { principals };
}
/** Resolve the principal for an `Authorization` header using constant-time token comparison. */
@@ -166,18 +119,8 @@ export function authenticate(
* same {@link Principal} every procedure already reasons about. Only an app-wide administrator may
* request the two writable modes; every other signed-in user is held to the same non-writable
* {@link DEFAULT_MODES} an ungranted token gets. Repository targeting is a separate grant either
- * way — see {@link mayTargetRepository}.
+ * way, answered by the composition root against the deployment's store.
*/
export function sessionPrincipal(name: string, isAdmin: boolean): Principal {
- return { name, kind: 'session', modes: isAdmin ? ADMIN_MODES : DEFAULT_MODES };
-}
-
-/** Whether task creation may target this repository path. Fails closed without a policy. */
-export function mayTargetRepository(
- repository: string,
- access: ControlPlaneAccess | undefined,
-): boolean {
- if (!access) return false;
- const target = resolve(repository);
- return access.repositories.some((allowed) => resolve(allowed) === target);
+ return { name, kind: 'session', modes: isAdmin ? ADMIN_MODES : DEFAULT_MODES, admin: isAdmin };
}
diff --git a/packages/api/src/audit.test.ts b/packages/api/src/audit.test.ts
index bbf0044..5ccdc84 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
@@ -190,6 +188,22 @@ describe('audit persistence', () => {
expect(page.events.map((entry) => entry.id)).toEqual(['audit_1']);
});
+ it("reads a record written before the actor moved to evlog's { type, id } vocabulary", async () => {
+ const storage = new RecordingStorage();
+ // Written straight past `append`, the shape a pre-upgrade deployment actually left on disk.
+ await storage.setItem(`audit:${FIRST}:audit_1`, {
+ ...event('audit_1', FIRST),
+ actor: { kind: 'principal', name: 'release-manager' },
+ });
+ const store = new PersistentAuditLogStore(storage);
+
+ const page = await store.list();
+
+ expect(page.events).toMatchObject([
+ { id: 'audit_1', actor: { type: 'api', id: 'release-manager' } },
+ ]);
+ });
+
it('ignores foreign records sharing the audit prefix', async () => {
const storage = new RecordingStorage();
await storage.setItem('audit:2026-08-09T10:00:00.000Z:junk', { unrelated: true });
@@ -202,54 +216,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();
- it('mints the identity and the timestamp the call site does not supply', async () => {
+ 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);
+
+ 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 +287,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..60f7610 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 {
@@ -131,7 +110,7 @@ export class PersistentAuditLogStore implements AuditLogStore {
const page = keys.slice(start, start + limit);
const records = await Promise.all(page.map((key) => this.storage.getItem(key)));
return {
- events: records.filter(isAuditEvent),
+ events: records.map(migrateLegacyActor).filter(isAuditEvent),
nextCursor: start + limit < keys.length ? (page.at(-1) ?? null) : null,
};
}
@@ -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.
+ *
+ * Two plugins, in the order they run:
+ *
+ * 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.
*
- * 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.
+ * 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 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.
*
- * 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}.
+ * 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 createAuditRecorder(options: AuditRecorderOptions): AuditRecorder {
+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,17 +238,52 @@ 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']);
+/**
+ * The actor kind this trail recorded before it moved onto evlog's `{ type, id }` vocabulary,
+ * mapped to the closest type in the new one. `principal` was the machine-token actor the router
+ * now calls `api`; `user` is unchanged; `webhook` and `system` were never actually written by any
+ * caller in this codebase, but are handled all the same since the type they came from allowed them.
+ */
+const LEGACY_ACTOR_KINDS: Record = {
+ principal: 'api',
+ user: 'user',
+ webhook: 'system',
+ system: 'system',
+};
+
+/**
+ * Reshapes a record written before the actor moved onto evlog's `{ type, id }` vocabulary into
+ * that shape, so a deployment upgrading past that change keeps reading its own history.
+ *
+ * Applied only when reading: `append` still refuses anything but the current shape, so nothing
+ * new is ever written in the old one. Without this, `isAuditEvent` below would reject every
+ * pre-existing record — `{ kind, name }` has neither field its `isAuditActor` check looks for —
+ * and an append-only trail silently losing history it already has is worse than one that takes a
+ * moment longer to read it.
+ */
+function migrateLegacyActor(value: unknown): unknown {
+ if (!isRecord(value)) return value;
+ const actor = value.actor;
+ if (!isRecord(actor) || typeof actor.type === 'string') return value;
+ const { kind, name } = actor;
+ if (typeof kind !== 'string' || typeof name !== 'string') return value;
+ const type = LEGACY_ACTOR_KINDS[kind];
+ if (!type) return value;
+ return { ...value, actor: { type, id: name } };
+}
+
/**
* The full shape, not just the field names.
*
* `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 +294,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..d104f1b 100644
--- a/packages/api/src/index.ts
+++ b/packages/api/src/index.ts
@@ -11,6 +11,7 @@ export {
openIssuePullRequest,
publishEvidence,
publishIssueValidation,
+ reviewDeliveryKey,
runTask,
statusTokenFromEnvironment,
taskInput,
@@ -29,8 +30,6 @@ export {
export {
accessFromEnvironment,
authenticate,
- controlPlaneOriginsFromEnvironment,
- mayTargetRepository,
sessionPrincipal,
type ControlPlaneAccess,
type Principal,
@@ -55,19 +54,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 {
@@ -78,4 +75,10 @@ export {
} from './orpc/auth.js';
export { requestLoggerStorage, useLogger } from './orpc/logging.js';
export { rpcRouter, type RpcContext, type RpcRouter } from './orpc/router.js';
+export type {
+ RepositoryAdmin,
+ RepositoryInput,
+ RepositoryMode,
+ RepositoryRecord,
+} from './repositories.js';
export { FileKeyValueStorage } from './storage.js';
diff --git a/packages/api/src/operations.test.ts b/packages/api/src/operations.test.ts
index f8b829d..5e1a0f2 100644
--- a/packages/api/src/operations.test.ts
+++ b/packages/api/src/operations.test.ts
@@ -25,6 +25,7 @@ import {
openIssuePullRequest,
publishEvidence,
publishIssueValidation,
+ reviewDeliveryKey,
runTask,
statusTokenFromEnvironment,
taskInput,
@@ -362,6 +363,71 @@ describe('ingestWebhook', () => {
expect(outcome.result.runner.writable).toBe(false);
await expect(getTaskEvidence(outcome.result.id)).resolves.toContain('proactive finding');
});
+
+ it('replays the recorded outcome for a redelivered proactive review rather than running it twice', async () => {
+ await writeFile(
+ join(checkout, '.code-zero.yml'),
+ 'version: 1\nproactive:\n enabled: true\nmode: observe\n',
+ 'utf8',
+ );
+ const deliveryClaims = new PersistentDeliveryClaimStore(memoryStorage(), []);
+ const body = JSON.stringify({
+ action: 'opened',
+ repository: { name: 'app', owner: { login: 'acme' } },
+ pull_request: { number: 7, base: { sha: 'b'.repeat(40) }, head: { sha: 'a'.repeat(40) } },
+ });
+ const first = await ingestWebhook(githubDelivery('pull_request', body), {
+ ...options(),
+ deliveryClaims,
+ });
+ const second = await ingestWebhook(githubDelivery('pull_request', body), {
+ ...options(),
+ deliveryClaims,
+ });
+
+ expect(first.status).toBe('accepted');
+ expect(second).toEqual(JSON.parse(JSON.stringify(first)));
+ expect(tasks.size).toBe(1);
+ });
+
+ it('declines a proactive review already claimed by another channel, such as a poller pass', async () => {
+ await writeFile(
+ join(checkout, '.code-zero.yml'),
+ 'version: 1\nproactive:\n enabled: true\nmode: observe\n',
+ 'utf8',
+ );
+ // The same key `apps/dashboard`'s poller claims for the identical commit — this is what makes
+ // the two channels share one outcome instead of each running its own review.
+ const claimedKey = reviewDeliveryKey({
+ provider: 'github',
+ owner: 'acme',
+ repo: 'app',
+ number: 7,
+ headSha: 'a'.repeat(40),
+ });
+ const deliveryClaims: DeliveryClaimStore = {
+ claim: async (key) =>
+ key === claimedKey ? { claimed: false, outcome: null } : { claimed: true, outcome: null },
+ complete: async () => undefined,
+ release: async () => undefined,
+ };
+ const body = JSON.stringify({
+ action: 'opened',
+ repository: { name: 'app', owner: { login: 'acme' } },
+ pull_request: { number: 7, base: { sha: 'b'.repeat(40) }, head: { sha: 'a'.repeat(40) } },
+ });
+
+ const outcome = await ingestWebhook(githubDelivery('pull_request', body), {
+ ...options(),
+ deliveryClaims,
+ });
+
+ expect(outcome).toEqual({
+ status: 'ignored',
+ reason: 'This commit is already claimed by an in-flight review',
+ });
+ expect(tasks.size).toBe(0);
+ });
});
function issuePayload(overrides: Record = {}): string {
diff --git a/packages/api/src/operations.ts b/packages/api/src/operations.ts
index 1966510..abd2d4c 100644
--- a/packages/api/src/operations.ts
+++ b/packages/api/src/operations.ts
@@ -37,6 +37,7 @@ import {
type ChangeRequestRef,
type IssueTask,
type ProviderKind,
+ type ReviewEvent,
type WebhookHeaders,
} from '@code-zero/source-control';
import { z } from 'zod';
@@ -380,6 +381,56 @@ export async function ingestWebhook(
mode = config.mode;
}
+ // A proactive trigger is unrequested work started on the delivery's own say-so — the same
+ // description that fits a poller's pass over the same commit. Both claim this key before
+ // running, so whichever channel reaches a commit first is the one that reviews it and the
+ // other observes its recorded outcome instead of starting a second run. A `feedback` trigger is
+ // a person asking for this review right now, which only a webhook delivers, so it runs directly.
+ if (event.trigger === 'proactive' && options.deliveryClaims) {
+ const key = reviewDeliveryKey(event.changeRequest);
+ const claim = await options.deliveryClaims.claim(key);
+ if (!claim.claimed) {
+ if (isRecordedWebhookOutcome(claim.outcome)) return claim.outcome;
+ return {
+ status: 'ignored',
+ reason: 'This commit is already claimed by an in-flight review',
+ };
+ }
+ try {
+ const outcome = await runReviewEvent(event, mode, options);
+ // Best effort: a lost outcome write must not fail the finished run, and the standing claim
+ // marker still stops a duplicate; the redelivery is then declined instead of replayed.
+ await options.deliveryClaims.complete(key, outcome).catch(() => undefined);
+ return outcome;
+ } catch (error) {
+ // A transport-level failure recorded no outcome worth replaying; let a redelivery, or the
+ // next poll pass, retry this commit.
+ await options.deliveryClaims.release(key).catch(() => undefined);
+ throw error;
+ }
+ }
+
+ return runReviewEvent(event, mode, options);
+}
+
+/**
+ * The idempotency key for one proactive review of one change-request commit.
+ *
+ * Shared with `apps/dashboard`'s poller, which claims the identical key for the identical reason:
+ * a webhook delivery and a polling pass are two channels that can each notice the same commit
+ * needs review, and only one of them should act on it. Provider, owner, repository, change-request
+ * number, and head commit are exactly what distinguishes one reviewable commit from every other —
+ * the same tuple {@link ChangeRequestRef} already carries.
+ */
+export function reviewDeliveryKey(ref: ChangeRequestRef): string {
+ return `review:${ref.provider}:${ref.owner}/${ref.repo}#${String(ref.number)}@${ref.headSha}`;
+}
+
+async function runReviewEvent(
+ event: ReviewEvent,
+ mode: ReviewInput['mode'],
+ options: WebhookOptions,
+): Promise {
const runOptions: RunTaskOptions = {
...(options.store ? { store: options.store } : {}),
...(options.scheduler ? { scheduler: options.scheduler } : {}),
@@ -391,7 +442,7 @@ export async function ingestWebhook(
return {
status: 'accepted',
result,
- provider: provider.kind,
+ provider: event.changeRequest.provider,
changeRequest: event.changeRequest,
};
}
diff --git a/packages/api/src/orpc/auth.ts b/packages/api/src/orpc/auth.ts
index 59c903a..1b806a8 100644
--- a/packages/api/src/orpc/auth.ts
+++ b/packages/api/src/orpc/auth.ts
@@ -65,9 +65,17 @@ const DEFAULT_ADMIN_ROLE = 'admin';
* Better Auth's base `User` carries no `role`; the deployment adds it as an additional field (see
* `@code-zero/auth`'s `authBetterAuthOptions`). Read defensively rather than asserted, so a
* deployment that drops the field grants the non-writable modes instead of throwing.
+ *
+ * Better Auth stores multiple roles as one comma-separated string, so membership is checked
+ * rather than equality: an account provisioned with, say, `"support,admin"` holds the admin role
+ * exactly as much as one provisioned with `"admin"` alone.
*/
function isAdministrator(user: BetterAuthSessionPayload['user'], adminRole: string): boolean {
- return typeof user.role === 'string' && user.role === adminRole;
+ if (typeof user.role !== 'string') return false;
+ return user.role
+ .split(',')
+ .map((role) => role.trim())
+ .includes(adminRole);
}
/**
diff --git a/packages/api/src/orpc/router.test.ts b/packages/api/src/orpc/router.test.ts
index db0bbf1..c9b0e65 100644
--- a/packages/api/src/orpc/router.test.ts
+++ b/packages/api/src/orpc/router.test.ts
@@ -1,14 +1,54 @@
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 { RepositoryAdmin, RepositoryInput, RepositoryRecord } from '../repositories.js';
import type { BetterAuthSessionApi } from './auth.js';
import { requestLoggerStorage } from './logging.js';
import { rpcRouter } from './router.js';
+/** An in-memory stand-in, so this suite covers the router's gate rather than a Drizzle table. */
+class MemoryRepositoryAdmin implements RepositoryAdmin {
+ private readonly records = new Map();
+ private sequence = 0;
+
+ list(): Promise {
+ return Promise.resolve([...this.records.values()]);
+ }
+
+ save(input: RepositoryInput): Promise {
+ const existing = [...this.records.values()].find(
+ (record) => record.checkoutPath === input.checkoutPath,
+ );
+ this.sequence += 1;
+ const record: RepositoryRecord = {
+ id: existing?.id ?? `repo_${String(this.sequence)}`,
+ provider: input.provider ?? 'github',
+ owner: input.owner ?? null,
+ name: input.name ?? null,
+ checkoutPath: input.checkoutPath,
+ mode: input.mode ?? 'observe',
+ pollEnabled: input.pollEnabled ?? false,
+ };
+ this.records.set(record.id, record);
+ return Promise.resolve(record);
+ }
+
+ remove(id: string): Promise {
+ return Promise.resolve(this.records.delete(id));
+ }
+}
+
+/**
+ * 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 +56,18 @@ 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;
+const NO_REPOSITORY_STORE_ERROR = /keeps no repository store/i;
let store: MemoryTaskStore;
-let audited: AuditEntryInput[];
+let auditLog: MemoryAuditLogStore;
+let repositories: MemoryRepositoryAdmin;
+/**
+ * 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 +76,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 +85,8 @@ function client(options: ClientOptions = {}) {
...(options.auth ? { auth: options.auth } : {}),
...(options.reqHeaders ? { reqHeaders: options.reqHeaders } : {}),
mayTargetRepository: () => options.allowRepository ?? false,
- audit: recorder,
+ repositories,
+ auditLog,
},
});
}
@@ -65,17 +108,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 +135,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 +155,7 @@ function operator() {
name: 'release-manager',
kind: 'token',
modes: ['observe', 'suggest', 'fix', 'autonomous'],
+ admin: false,
},
allowRepository: true,
});
@@ -116,6 +173,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 +196,13 @@ function awaiting(id: string): StoredTask {
beforeEach(() => {
store = new MemoryTaskStore();
- audited = [];
+ auditLog = new MemoryAuditLogStore();
+ repositories = new MemoryRepositoryAdmin();
+ audited = mockAudit();
+});
+
+afterEach(() => {
+ audited.restore();
});
describe('rpc router', () => {
@@ -184,7 +257,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 +275,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 +363,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 +373,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 +388,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 +417,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 +433,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 +453,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,27 +476,195 @@ 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('reads the trail back for an account holding admin among several comma-separated roles', async () => {
+ // Better Auth stores multiple roles as one comma-separated string; an account provisioned
+ // this way holds the admin role exactly as much as one provisioned with "admin" alone.
+ await auditLog.append(auditRecord('audit_1', '2026-08-09T10:00:00.000Z'));
+
+ await expect(
+ instrumented(() =>
+ client({
+ auth: betterAuth({ email: 'ops@example.test', role: 'support,admin' }),
+ reqHeaders: new Headers(),
+ }).audit.list({}),
+ ),
+ ).resolves.toMatchObject({ events: [{ id: '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' }),
),
).resolves.toMatchObject({ approval: { actor: 'release-manager' } });
});
});
+
+describe('rpc repositories', () => {
+ it('lists configured repositories for an administrator', async () => {
+ await repositories.save({ checkoutPath: '/srv/checkouts/acme-app' });
+
+ const page = await instrumented(() =>
+ client({
+ auth: betterAuth({ email: 'ops@example.test', role: 'admin' }),
+ reqHeaders: new Headers(),
+ }).repositories.list(),
+ );
+
+ expect(page).toMatchObject([{ checkoutPath: '/srv/checkouts/acme-app', mode: 'observe' }]);
+ });
+
+ it('adds a repository, and the next task creation honours it with no restart', async () => {
+ const admin = client({
+ auth: betterAuth({ email: 'ops@example.test', role: 'admin' }),
+ reqHeaders: new Headers(),
+ });
+
+ const saved = await instrumented(() =>
+ admin.repositories.save({ checkoutPath: '/srv/checkouts/acme-app', mode: 'suggest' }),
+ );
+
+ expect(saved).toMatchObject({ checkoutPath: '/srv/checkouts/acme-app', mode: 'suggest' });
+ expect(audited.events).toMatchObject([
+ {
+ actor: { type: 'user', id: 'ops@example.test' },
+ action: 'repository.saved',
+ outcome: 'success',
+ target: { type: 'repository', id: '/srv/checkouts/acme-app' },
+ },
+ ]);
+ });
+
+ it('updates the repository already claiming a checkout path rather than duplicating it', async () => {
+ const admin = client({
+ auth: betterAuth({ email: 'ops@example.test', role: 'admin' }),
+ reqHeaders: new Headers(),
+ });
+
+ const first = await instrumented(() =>
+ admin.repositories.save({ checkoutPath: '/srv/checkouts/acme-app' }),
+ );
+ const second = await instrumented(() =>
+ admin.repositories.save({ checkoutPath: '/srv/checkouts/acme-app', mode: 'suggest' }),
+ );
+
+ expect(second.id).toBe(first.id);
+ await expect(instrumented(() => admin.repositories.list())).resolves.toHaveLength(1);
+ });
+
+ it('removes a configured repository', async () => {
+ const admin = client({
+ auth: betterAuth({ email: 'ops@example.test', role: 'admin' }),
+ reqHeaders: new Headers(),
+ });
+ const saved = await instrumented(() =>
+ admin.repositories.save({ checkoutPath: '/srv/checkouts/acme-app' }),
+ );
+
+ const result = await instrumented(() => admin.repositories.remove({ id: saved.id }));
+
+ expect(result).toEqual({ removed: true });
+ expect(audited.events).toMatchObject([
+ {},
+ { action: 'repository.removed', outcome: 'success', target: { id: saved.id } },
+ ]);
+ });
+
+ 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(),
+ }).repositories.list(),
+ ),
+ ).rejects.toThrow(ADMIN_ERROR);
+ });
+
+ it('refuses an operator token, which may run work but not configure what it may run against', () => {
+ return expect(instrumented(() => operator().repositories.list())).rejects.toThrow(ADMIN_ERROR);
+ });
+
+ it('refuses an unauthenticated caller', async () => {
+ await expect(instrumented(() => client().repositories.list())).rejects.toThrow(
+ UNAUTHORIZED_ERROR,
+ );
+ });
+
+ it('says a deployment keeps no repository store rather than reporting an empty one', async () => {
+ await expect(
+ instrumented(() =>
+ unaudited({
+ auth: betterAuth({ email: 'ops@example.test', role: 'admin' }),
+ reqHeaders: new Headers(),
+ }).repositories.list(),
+ ),
+ ).rejects.toThrow(NO_REPOSITORY_STORE_ERROR);
+ });
+});
diff --git a/packages/api/src/orpc/router.ts b/packages/api/src/orpc/router.ts
index a72ba32..56e912c 100644
--- a/packages/api/src/orpc/router.ts
+++ b/packages/api/src/orpc/router.ts
@@ -1,4 +1,7 @@
+import { resolve } from 'node:path';
+
import { redactSecrets } from '@code-zero/shared';
+import { providerKinds } from '@code-zero/source-control';
// `openapi(meta)` builds the same metadata plugin `.route()` sugars over (see
// `@orpc/openapi/extensions/route`), but as a real import a bundler can't tree-shake away. The
// prototype-patching `.route()` extension depends on a bare side-effect import surviving whatever
@@ -9,7 +12,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 {
@@ -21,19 +24,68 @@ import {
listTasks,
taskInput,
} from '../operations.js';
+import type { RepositoryAdmin } from '../repositories.js';
import { authMiddleware, type BetterAuthContext } from './auth.js';
import { useLogger } from './logging.js';
export interface RpcContext extends BetterAuthContext {
store: TaskStore;
- /** 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.
+ * Whether `tasks.create` may target this checkout path. Fails closed when absent.
+ *
+ * Allowed to answer asynchronously because the composition root backs it with the deployment's
+ * own store rather than a list baked in at boot: an operator adds a repository and the next
+ * request honours it, with no restart and no second copy of the allow-list to keep in step.
+ */
+ mayTargetRepository?: (repository: string) => boolean | Promise;
+ /**
+ * The configured repositories themselves, for `repositories.list`/`.save`/`.remove` to read and
+ * write. A separate capability from {@link mayTargetRepository} above: that one only answers
+ * "may a run target this checkout", which every unauthenticated `tasks.create` attempt asks, and
+ * granting it whatever it needed to check membership would let a non-administrator enumerate
+ * every configured repository. Optional for the same reason `auditLog` is: an embedded caller
+ * that keeps no repository store should still be able to drive the rest of the router.
*/
- audit?: AuditRecorder;
+ repositories?: RepositoryAdmin;
+ /**
+ * 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".
+ */
+ auditLog?: AuditLogStore;
+}
+
+/**
+ * `repositories.save`'s input. Mirrors `RepositoryInput`, validated at the wire boundary rather
+ * than trusted from it: `checkoutPath` is the value `mayTargetRepository` will compare a future
+ * `tasks.create` request against verbatim, so an empty or malformed one would silently allow
+ * nothing rather than fail the request that configured it.
+ */
+const repositoryInput = z.object({
+ provider: z.enum(providerKinds).optional(),
+ owner: z.string().min(1).nullable().optional(),
+ name: z.string().min(1).nullable().optional(),
+ checkoutPath: z.string().min(1),
+ mode: z.enum(['observe', 'suggest']).optional(),
+ pollEnabled: z.boolean().optional(),
+});
+
+/** Every procedure below requires the app-wide administrator role, the same gate `audit.list` uses. */
+function requireRepositoryAdmin(context: RpcContext): RepositoryAdmin {
+ if (!context.principal?.admin)
+ throw new ORPCError('FORBIDDEN', {
+ message: 'Configuring repositories requires the admin role',
+ });
+ if (!context.repositories)
+ throw new ORPCError('NOT_IMPLEMENTED', {
+ message: 'This deployment keeps no repository store',
+ });
+ return context.repositories;
}
const procedure = os.$context();
@@ -102,28 +154,25 @@ export const rpcRouter = {
// Refusals are audited as deliberately as grants: a token repeatedly reaching for a
// 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({
+ if (!(await context.mayTargetRepository?.(input.repository))) {
+ 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 +185,144 @@ 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;
}),
},
+ repositories: {
+ /**
+ * Every configured repository, for an app-wide administrator.
+ *
+ * The supported way to see what `tasks.create` will accept: the table starts empty on every
+ * deployment, including one upgrading from an environment-variable allow-list, so there is no
+ * baked-in list to fall back to reading instead.
+ */
+ list: authenticated
+ .meta(
+ openapi({
+ method: 'GET',
+ path: '/repositories',
+ tags: ['Repositories'],
+ summary: 'List configured repositories',
+ }),
+ )
+ .handler(({ context }) => requireRepositoryAdmin(context).list()),
+ /**
+ * Add a repository, or update the one already claiming this checkout path.
+ *
+ * The supported way to populate the allow-list: nothing else in this deployment can, since the
+ * table an upgrade lands with is always empty and a database console is not a control-plane
+ * operation this trail can record.
+ */
+ save: authenticated
+ .meta(
+ openapi({
+ method: 'POST',
+ path: '/repositories',
+ tags: ['Repositories'],
+ summary: 'Add or update a configured repository',
+ }),
+ )
+ .input(repositoryInput)
+ .handler(async ({ input, context }) => {
+ const repositories = requireRepositoryAdmin(context);
+ // Resolved the same way `mayTargetRepository` resolves a future `tasks.create` request
+ // (`context.ts`), so `/srv/app` and `/srv/app/../app` are the same stored path rather than
+ // an allow-list miss or a duplicate row for the same checkout.
+ const record = await repositories.save({
+ ...input,
+ checkoutPath: resolve(input.checkoutPath),
+ });
+ useLogger().audit({
+ actor: principalActor(context.principal),
+ action: 'repository.saved',
+ outcome: 'success',
+ target: { type: 'repository', id: record.checkoutPath },
+ });
+ return record;
+ }),
+ remove: authenticated
+ .meta(
+ openapi({
+ method: 'DELETE',
+ path: '/repositories/{id}',
+ tags: ['Repositories'],
+ summary: 'Remove a configured repository',
+ }),
+ )
+ .input(z.object({ id: z.string().min(1) }))
+ .handler(async ({ input, context }) => {
+ const repositories = requireRepositoryAdmin(context);
+ const removed = await repositories.remove(input.id);
+ useLogger().audit({
+ actor: principalActor(context.principal),
+ action: 'repository.removed',
+ outcome: removed ? 'success' : 'failure',
+ target: { type: 'repository', id: input.id },
+ });
+ return { removed };
+ }),
+ },
+ 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 +345,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 +370,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/api/src/repositories.ts b/packages/api/src/repositories.ts
new file mode 100644
index 0000000..122c3dc
--- /dev/null
+++ b/packages/api/src/repositories.ts
@@ -0,0 +1,49 @@
+/**
+ * Which execution modes a configured repository may run un-requested work under. Only the two
+ * that cannot modify a checkout: a poll or a webhook delivery is never something an operator
+ * asked for at that moment, so the writable modes stay reachable only through an explicit request
+ * that carries its own authorization.
+ */
+export type RepositoryMode = 'observe' | 'suggest';
+
+/**
+ * A configured repository, as the router sees one.
+ *
+ * Defined here rather than imported from `@code-zero/database`: this package composes adapters
+ * into the control-plane API and does not talk to a store directly, so the contract it needs is
+ * the shape a store returns, not the store itself. `apps/dashboard`'s Drizzle-backed store
+ * satisfies this structurally, with no import back the other way.
+ */
+export interface RepositoryRecord {
+ id: string;
+ provider: string;
+ owner: string | null;
+ name: string | null;
+ checkoutPath: string;
+ mode: RepositoryMode;
+ pollEnabled: boolean;
+}
+
+/** What a caller supplies to configure one; the store mints the identity. */
+export interface RepositoryInput {
+ provider?: string | undefined;
+ owner?: string | null | undefined;
+ name?: string | null | undefined;
+ checkoutPath: string;
+ mode?: RepositoryMode | undefined;
+ pollEnabled?: boolean | undefined;
+}
+
+/**
+ * The repository configuration surface a deployment's store backs, reached through
+ * `repositories.list`/`.save`/`.remove`.
+ *
+ * This is the supported way to populate the allow-list `tasks.create` checks: the table starts
+ * empty on every deployment, including one upgrading from an environment-variable allow-list, and
+ * an administrator reaches for these procedures rather than a database console to fill it in.
+ */
+export interface RepositoryAdmin {
+ list(): Promise;
+ save(input: RepositoryInput): Promise;
+ remove(id: string): Promise;
+}
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..df28e04
--- /dev/null
+++ b/packages/cli/src/remote.test.ts
@@ -0,0 +1,202 @@
+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', plan: [] }));
+
+ const outcome = await runRemotely(REQUEST, {
+ fetch: send,
+ credentials: credentials({}),
+ now: () => NOW,
+ });
+
+ expect(outcome).toEqual({ ok: true, result: { id: 'cz_1', state: 'completed', plan: [] } });
+ 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', plan: [] }));
+
+ 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', plan: [] }));
+
+ 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', plan: [] }));
+
+ 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', plan: [] }));
+
+ 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', plan: [] }));
+
+ 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('refuses a non-terminal answer, which the exit-code table has no entry for', async () => {
+ // `id`/`state` alone pass the loosest possible shape check; a real, in-progress `/rpc`
+ // response looks exactly like this before the run finishes. Accepting it here would report
+ // exit code 0 for a task that has not actually finished yet.
+ const { send } = transport(rpc({ id: 'cz_1', state: 'queued' }));
+
+ 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..5247a9f
--- /dev/null
+++ b/packages/cli/src/remote.ts
@@ -0,0 +1,146 @@
+import type { RunMode, TaskResult, TerminalState } 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;
+}
+
+const TERMINAL_STATES = new Set(['completed', 'needs-human', 'failed']);
+
+function isTerminalState(state: string): state is TerminalState {
+ return TERMINAL_STATES.has(state);
+}
+
+/**
+ * Checked, not asserted: this is a remote answer, and the caller maps `state` onto an exit code CI
+ * reads and renders `plan` for a human to read. A queued or in-progress answer has neither — `/rpc`
+ * only resolves once the run reaches one of these three states — so accepting one here would let a
+ * non-terminal response report exit code `0` (no entry in the caller's exit-code table means no
+ * exit code) and throw while rendering a plan that was never populated. A shape short either field
+ * must not be able to reach the caller as a result.
+ */
+function isTaskResult(value: unknown): value is TaskResult {
+ return (
+ isRecord(value) &&
+ typeof value.id === 'string' &&
+ typeof value.state === 'string' &&
+ isTerminalState(value.state) &&
+ Array.isArray(value.plan)
+ );
+}
+
+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/config/src/deployment.test.ts b/packages/config/src/deployment.test.ts
new file mode 100644
index 0000000..50b723a
--- /dev/null
+++ b/packages/config/src/deployment.test.ts
@@ -0,0 +1,132 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ defaultDeploymentConfig,
+ describeDeploymentConfigIssues,
+ parseDeploymentConfig,
+} from './deployment.js';
+
+describe('parseDeploymentConfig', () => {
+ it('reads the whole file', () => {
+ const { config, issues } = parseDeploymentConfig(`
+control_plane:
+ origins:
+ - https://ops.example.com
+ modes:
+ ci: [observe, suggest]
+ release: [observe, suggest, fix, autonomous]
+poll:
+ interval_seconds: 120
+`);
+
+ expect(issues).toEqual([]);
+ expect(config.controlPlane.origins).toEqual(['https://ops.example.com']);
+ expect(config.controlPlane.modes.get('ci')).toEqual(['observe', 'suggest']);
+ expect(config.controlPlane.modes.get('release')).toEqual([
+ 'observe',
+ 'suggest',
+ 'fix',
+ 'autonomous',
+ ]);
+ expect(config.poll.intervalSeconds).toBe(120);
+ });
+
+ it.each(['', ' ', '# only a comment'])(
+ 'treats %j as a deployment that has configured nothing yet',
+ (text) => {
+ expect(parseDeploymentConfig(text)).toEqual({
+ config: defaultDeploymentConfig,
+ issues: [],
+ });
+ },
+ );
+
+ it('defaults every section a file leaves out', () => {
+ const { config, issues } = parseDeploymentConfig('poll:\n interval_seconds: 30\n');
+
+ expect(issues).toEqual([]);
+ // The unstated half is the closed one: no origin may read cross-origin, no token is widened.
+ expect(config.controlPlane.origins).toEqual([]);
+ expect(config.controlPlane.modes.size).toBe(0);
+ expect(config.poll.intervalSeconds).toBe(30);
+ });
+
+ it('names the field and the line-level path for each mistake, not just the first', () => {
+ const { issues } = parseDeploymentConfig(`
+control_plane:
+ origins: "https://ops.example.com"
+poll:
+ interval_seconds: 5
+`);
+
+ expect(issues).toEqual([
+ { path: '$.control_plane.origins', message: 'Expected a list of non-empty strings.' },
+ { path: '$.poll.interval_seconds', message: 'Expected an integer from 15 to 3600.' },
+ ]);
+ });
+
+ it('keeps the default for a field it refused, rather than a value nothing validated', () => {
+ const { config } = parseDeploymentConfig('poll:\n interval_seconds: 99999\n');
+
+ expect(config.poll.intervalSeconds).toBe(defaultDeploymentConfig.poll.intervalSeconds);
+ });
+
+ it('refuses an unknown execution mode instead of quietly narrowing the grant', () => {
+ const { config, issues } = parseDeploymentConfig(
+ 'control_plane:\n modes:\n ci: [observe, teleport]\n',
+ );
+
+ expect(issues).toEqual([
+ { path: '$.control_plane.modes.ci', message: 'Unknown execution mode: teleport' },
+ ]);
+ // The whole grant is refused rather than narrowed to its valid half, but the principal still
+ // resolves to an empty grant rather than none at all: a missing entry reads downstream as "no
+ // grant configured" and widens to the non-writable defaults, which would grant `ci` more than
+ // the mistake in its config ever asked for.
+ expect(config.controlPlane.modes.get('ci')).toEqual([]);
+ });
+
+ it('refuses an empty grant, which reads as a mistake rather than as "no modes"', () => {
+ const { issues } = parseDeploymentConfig('control_plane:\n modes:\n ci: []\n');
+
+ expect(issues).toEqual([
+ {
+ path: '$.control_plane.modes.ci',
+ message: 'Expected a non-empty list of execution modes.',
+ },
+ ]);
+ });
+
+ it('reports malformed YAML against the document rather than throwing at the caller', () => {
+ const { config, issues } = parseDeploymentConfig('control_plane: [unclosed\n');
+
+ expect(issues[0]?.path).toBe('$');
+ expect(config).toEqual(defaultDeploymentConfig);
+ });
+
+ it('refuses a document that is not an object', () => {
+ expect(parseDeploymentConfig('- one\n- two\n').issues).toEqual([
+ { path: '$', message: 'Expected an object.' },
+ ]);
+ });
+
+ it('refuses a section that is not an object', () => {
+ expect(parseDeploymentConfig('poll: 60\n').issues).toEqual([
+ { path: '$.poll', message: 'Expected an object.' },
+ ]);
+ });
+});
+
+describe('describeDeploymentConfigIssues', () => {
+ it('renders one line per issue, so a refusal to start says everything at once', () => {
+ expect(
+ describeDeploymentConfigIssues([
+ { path: '$.poll.interval_seconds', message: 'Expected an integer from 15 to 3600.' },
+ { path: '$.control_plane.origins', message: 'Expected a list of non-empty strings.' },
+ ]),
+ ).toBe(
+ '$.poll.interval_seconds: Expected an integer from 15 to 3600.\n' +
+ '$.control_plane.origins: Expected a list of non-empty strings.',
+ );
+ });
+});
diff --git a/packages/config/src/deployment.ts b/packages/config/src/deployment.ts
new file mode 100644
index 0000000..76fa9e0
--- /dev/null
+++ b/packages/config/src/deployment.ts
@@ -0,0 +1,258 @@
+import { readFile } from 'node:fs/promises';
+
+import type { RunMode } from '@code-zero/shared';
+import { parse } from 'yaml';
+
+/**
+ * One thing wrong with a deployment configuration file, and where.
+ *
+ * The path is JSON-pointer-ish (`$.control_plane.origins`) so an operator can find the line
+ * without reading the loader, and every issue is collected rather than thrown on the first: a file
+ * with three mistakes should report three, not make its author discover them one restart at a time.
+ */
+export interface DeploymentConfigIssue {
+ path: string;
+ message: string;
+}
+
+/**
+ * How this deployment behaves, as distinct from what it is allowed to reach.
+ *
+ * This file is deployment-owned and trusted, unlike `.code-zero.yml`, which comes out of whatever
+ * checkout is being worked on and is untrusted input. The two are deliberately different files
+ * with different names: one states policy for the process, the other states policy for a
+ * repository the process was pointed at.
+ *
+ * Only values that are neither secrets nor data live here. A credential stays in the environment,
+ * beside the database password, because that is where a deployment already keeps secrets. A
+ * repository — which checkout may be targeted, which one is watched — is a row in the store,
+ * because it changes while the process runs and an operator should not restart to add one.
+ */
+export interface DeploymentConfig {
+ controlPlane: {
+ /**
+ * Origins allowed to read `/api/v1/**` cross-origin.
+ *
+ * Empty by default: `tasks.list`, `tasks.get`, and `health` are unauthenticated by design, so
+ * letting a browser on another site read them is a grant that has to be written down.
+ */
+ origins: readonly string[];
+ /**
+ * Execution modes each operator token may request, by principal name.
+ *
+ * The token itself is a secret and stays in the environment; what it may do is policy and
+ * belongs here. A principal with no entry may request only the two non-writable modes.
+ */
+ modes: ReadonlyMap;
+ };
+ poll: {
+ /** Seconds between passes over the watched repositories. */
+ intervalSeconds: number;
+ };
+}
+
+/** What a deployment gets when it ships no configuration file: the most closed setting of each. */
+export const defaultDeploymentConfig: DeploymentConfig = {
+ controlPlane: { origins: [], modes: new Map() },
+ poll: { intervalSeconds: 60 },
+};
+
+/** Below the floor a pass spends more of the provider's rate limit than the work it finds is worth. */
+const MINIMUM_POLL_SECONDS = 15;
+/** Above the ceiling it is not polling any more, and a webhook is the honest answer instead. */
+const MAXIMUM_POLL_SECONDS = 3_600;
+
+const RUN_MODES = new Set(['observe', 'suggest', 'fix', 'autonomous']);
+
+/**
+ * The default path, relative to the process's working directory.
+ *
+ * Named for the deployment rather than the product so it cannot be mistaken for `.code-zero.yml`
+ * at a glance: the leading dot and the different word are both load-bearing, because confusing the
+ * two would mean reading a target repository's file as this deployment's own policy.
+ */
+export const DEPLOYMENT_CONFIG_FILE = 'code-zero.deployment.yml';
+
+export interface DeploymentConfigResult {
+ config: DeploymentConfig;
+ /** Empty when the file parsed cleanly, or when there was no file to parse. */
+ issues: readonly DeploymentConfigIssue[];
+}
+
+/**
+ * Read and validate the deployment configuration.
+ *
+ * An absent file is not an error: every field has a default, and the defaults are what a
+ * deployment that has never needed to change anything should get. A file that exists but is wrong
+ * is an error, reported field by field — the caller decides whether to refuse to start, which is
+ * a decision only a composition root can make.
+ */
+export async function loadDeploymentConfig(path: string): Promise {
+ let raw: string;
+ try {
+ raw = await readFile(path, 'utf8');
+ } catch (error) {
+ if (isRecord(error) && error.code === 'ENOENT')
+ return { config: structuredClone(defaultDeploymentConfig), issues: [] };
+ return {
+ config: structuredClone(defaultDeploymentConfig),
+ issues: [{ path: '$', message: `Could not read ${path}: ${String(error)}` }],
+ };
+ }
+ return parseDeploymentConfig(raw);
+}
+
+/** Validate the file's text. Separated from reading it so the tests never touch a filesystem. */
+export function parseDeploymentConfig(text: string): DeploymentConfigResult {
+ const issues: DeploymentConfigIssue[] = [];
+ let parsed: unknown;
+ try {
+ parsed = parse(text);
+ } catch (error) {
+ return {
+ config: structuredClone(defaultDeploymentConfig),
+ issues: [
+ { path: '$', message: error instanceof Error ? error.message : 'YAML parsing failed.' },
+ ],
+ };
+ }
+ // An empty file is a deployment that wrote the file and configured nothing yet, which is the
+ // same thing as having no file at all.
+ if (parsed === null || parsed === undefined)
+ return { config: structuredClone(defaultDeploymentConfig), issues: [] };
+ if (!isRecord(parsed))
+ return {
+ config: structuredClone(defaultDeploymentConfig),
+ issues: [{ path: '$', message: 'Expected an object.' }],
+ };
+
+ const controlPlane = optionalRecord(parsed, 'control_plane', '$', issues);
+ const poll = optionalRecord(parsed, 'poll', '$', issues);
+
+ return {
+ config: {
+ controlPlane: {
+ origins: stringList(controlPlane, 'origins', '$.control_plane', issues) ?? [],
+ modes: modeGrants(controlPlane, issues),
+ },
+ poll: {
+ intervalSeconds:
+ boundedInteger(
+ poll,
+ 'interval_seconds',
+ '$.poll',
+ MINIMUM_POLL_SECONDS,
+ MAXIMUM_POLL_SECONDS,
+ issues,
+ ) ?? defaultDeploymentConfig.poll.intervalSeconds,
+ },
+ },
+ issues,
+ };
+}
+
+/** Renders issues as one message, for a composition root that refuses to start on a bad file. */
+export function describeDeploymentConfigIssues(issues: readonly DeploymentConfigIssue[]): string {
+ return issues.map((issue) => `${issue.path}: ${issue.message}`).join('\n');
+}
+
+function optionalRecord(
+ source: Record,
+ key: string,
+ path: string,
+ issues: DeploymentConfigIssue[],
+): Record | undefined {
+ const value = source[key];
+ if (value === undefined || value === null) return undefined;
+ if (isRecord(value)) return value;
+ issues.push({ path: `${path}.${key}`, message: 'Expected an object.' });
+ return undefined;
+}
+
+function stringList(
+ source: Record | undefined,
+ key: string,
+ path: string,
+ issues: DeploymentConfigIssue[],
+): string[] | undefined {
+ const value = source?.[key];
+ if (value === undefined || value === null) return undefined;
+ if (!isStringArray(value) || !value.every((entry) => entry.trim() !== '')) {
+ issues.push({ path: `${path}.${key}`, message: 'Expected a list of non-empty strings.' });
+ return undefined;
+ }
+ return value.map((entry) => entry.trim());
+}
+
+function boundedInteger(
+ source: Record | undefined,
+ key: string,
+ path: string,
+ minimum: number,
+ maximum: number,
+ issues: DeploymentConfigIssue[],
+): number | undefined {
+ const value = source?.[key];
+ if (value === undefined || value === null) return undefined;
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < minimum || value > maximum) {
+ issues.push({
+ path: `${path}.${key}`,
+ message: `Expected an integer from ${String(minimum)} to ${String(maximum)}.`,
+ });
+ return undefined;
+ }
+ return value;
+}
+
+/**
+ * `control_plane.modes` maps a principal name to the modes it may request.
+ *
+ * An unknown mode is refused rather than dropped: silently narrowing a grant would leave an
+ * operator wondering why a token they widened still cannot run, and silently widening one is
+ * worse.
+ */
+function modeGrants(
+ controlPlane: Record | undefined,
+ issues: DeploymentConfigIssue[],
+): ReadonlyMap {
+ const grants = new Map();
+ const modes = optionalRecord(controlPlane ?? {}, 'modes', '$.control_plane', issues);
+ if (!modes) return grants;
+ for (const [name, value] of Object.entries(modes)) {
+ const path = `$.control_plane.modes.${name}`;
+ if (!isStringArray(value) || value.length === 0) {
+ issues.push({ path, message: 'Expected a non-empty list of execution modes.' });
+ // Recorded with nothing granted rather than left out: a name absent from this map reads
+ // downstream as "no grant configured", which resolves to the non-writable defaults. A grant
+ // that was configured and refused must never resolve to more than the empty list this
+ // parsed to — the mistake it corrects would otherwise widen the very principal it named.
+ grants.set(name, []);
+ continue;
+ }
+ const parsed: RunMode[] = [];
+ let valid = true;
+ for (const entry of value) {
+ const mode = entry.trim();
+ if (!isRunMode(mode)) {
+ issues.push({ path, message: `Unknown execution mode: ${mode}` });
+ valid = false;
+ continue;
+ }
+ parsed.push(mode);
+ }
+ grants.set(name, valid ? parsed : []);
+ }
+ return grants;
+}
+
+function isStringArray(value: unknown): value is string[] {
+ return Array.isArray(value) && value.every((entry) => typeof entry === 'string');
+}
+
+function isRunMode(value: string): value is RunMode {
+ return RUN_MODES.has(value);
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts
index 80c878f..15300f8 100644
--- a/packages/config/src/index.ts
+++ b/packages/config/src/index.ts
@@ -6,6 +6,16 @@ import { parse } from 'yaml';
import { assertExecutableCommand } from './checks.js';
+export {
+ defaultDeploymentConfig,
+ DEPLOYMENT_CONFIG_FILE,
+ describeDeploymentConfigIssues,
+ loadDeploymentConfig,
+ parseDeploymentConfig,
+ type DeploymentConfig,
+ type DeploymentConfigIssue,
+ type DeploymentConfigResult,
+} from './deployment.js';
export {
assertExecutableCommand,
checkKinds,
diff --git a/packages/database/drizzle/0004_absurd_morg.sql b/packages/database/drizzle/0004_absurd_morg.sql
new file mode 100644
index 0000000..3d4a0e1
--- /dev/null
+++ b/packages/database/drizzle/0004_absurd_morg.sql
@@ -0,0 +1,15 @@
+CREATE TABLE "repository" (
+ "id" text PRIMARY KEY NOT NULL,
+ "provider" text DEFAULT 'github' NOT NULL,
+ "owner" text,
+ "name" text,
+ "checkout_path" text NOT NULL,
+ "mode" text DEFAULT 'observe' NOT NULL,
+ "poll_enabled" boolean DEFAULT false NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX "repository_checkout_path_unique" ON "repository" USING btree ("checkout_path");--> statement-breakpoint
+CREATE UNIQUE INDEX "repository_provider_owner_name_unique" ON "repository" USING btree ("provider","owner","name");--> statement-breakpoint
+CREATE INDEX "repository_poll_enabled_idx" ON "repository" USING btree ("poll_enabled");
\ No newline at end of file
diff --git a/packages/database/drizzle/meta/0004_snapshot.json b/packages/database/drizzle/meta/0004_snapshot.json
new file mode 100644
index 0000000..5bd0afc
--- /dev/null
+++ b/packages/database/drizzle/meta/0004_snapshot.json
@@ -0,0 +1,1324 @@
+{
+ "id": "1ec210ca-3f46-4e8f-bdf8-c799bd3a0d55",
+ "prevId": "5c7c03cc-23e1-44b1-a9a3-56ed7c6ba23f",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "account_user_id_idx": {
+ "name": "account_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.device_code": {
+ "name": "device_code",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "device_code": {
+ "name": "device_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_code": {
+ "name": "user_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "last_polled_at": {
+ "name": "last_polled_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "polling_interval": {
+ "name": "polling_interval",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "device_code_device_code_unique": {
+ "name": "device_code_device_code_unique",
+ "columns": [
+ {
+ "expression": "device_code",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "device_code_user_code_unique": {
+ "name": "device_code_user_code_unique",
+ "columns": [
+ {
+ "expression": "user_code",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "device_code_user_id_idx": {
+ "name": "device_code_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "device_code_expires_at_idx": {
+ "name": "device_code_expires_at_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "device_code_user_id_user_id_fk": {
+ "name": "device_code_user_id_user_id_fk",
+ "tableFrom": "device_code",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.invitation": {
+ "name": "invitation",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "inviter_id": {
+ "name": "inviter_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "invitation_organization_id_idx": {
+ "name": "invitation_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "invitation_email_idx": {
+ "name": "invitation_email_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "invitation_organization_id_organization_id_fk": {
+ "name": "invitation_organization_id_organization_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "organization",
+ "columnsFrom": ["organization_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "invitation_inviter_id_user_id_fk": {
+ "name": "invitation_inviter_id_user_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "user",
+ "columnsFrom": ["inviter_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.invite": {
+ "name": "invite",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'app'"
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "organization_role": {
+ "name": "organization_role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "team_id": {
+ "name": "team_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "preset_seat_limit": {
+ "name": "preset_seat_limit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "pre_created_user_id": {
+ "name": "pre_created_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "inviter_name": {
+ "name": "inviter_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "inviter_email": {
+ "name": "inviter_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "max_uses": {
+ "name": "max_uses",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "use_count": {
+ "name": "use_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked_by_user_id": {
+ "name": "revoked_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "invite_token_hash_unique": {
+ "name": "invite_token_hash_unique",
+ "columns": [
+ {
+ "expression": "token_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "invite_email_idx": {
+ "name": "invite_email_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "invite_status_idx": {
+ "name": "invite_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "invite_organization_id_idx": {
+ "name": "invite_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "invite_created_by_user_id_idx": {
+ "name": "invite_created_by_user_id_idx",
+ "columns": [
+ {
+ "expression": "created_by_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "invite_expires_at_idx": {
+ "name": "invite_expires_at_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.invite_use": {
+ "name": "invite_use",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "invite_id": {
+ "name": "invite_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "used_by_user_id": {
+ "name": "used_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "invitee_email": {
+ "name": "invitee_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "used_at": {
+ "name": "used_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "invite_use_invite_id_idx": {
+ "name": "invite_use_invite_id_idx",
+ "columns": [
+ {
+ "expression": "invite_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "invite_use_invite_id_invite_id_fk": {
+ "name": "invite_use_invite_id_invite_id_fk",
+ "tableFrom": "invite_use",
+ "tableTo": "invite",
+ "columnsFrom": ["invite_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.member": {
+ "name": "member",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "member_organization_user_unique": {
+ "name": "member_organization_user_unique",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "member_user_id_idx": {
+ "name": "member_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "member_organization_id_organization_id_fk": {
+ "name": "member_organization_id_organization_id_fk",
+ "tableFrom": "member",
+ "tableTo": "organization",
+ "columnsFrom": ["organization_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "member_user_id_user_id_fk": {
+ "name": "member_user_id_user_id_fk",
+ "tableFrom": "member",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.organization": {
+ "name": "organization",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "logo": {
+ "name": "logo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "seat_limit": {
+ "name": "seat_limit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "disabled_at": {
+ "name": "disabled_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "organization_slug_unique": {
+ "name": "organization_slug_unique",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.repository": {
+ "name": "repository",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'github'"
+ },
+ "owner": {
+ "name": "owner",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "checkout_path": {
+ "name": "checkout_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'observe'"
+ },
+ "poll_enabled": {
+ "name": "poll_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "repository_checkout_path_unique": {
+ "name": "repository_checkout_path_unique",
+ "columns": [
+ {
+ "expression": "checkout_path",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "repository_provider_owner_name_unique": {
+ "name": "repository_provider_owner_name_unique",
+ "columns": [
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "owner",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "repository_poll_enabled_idx": {
+ "name": "repository_poll_enabled_idx",
+ "columns": [
+ {
+ "expression": "poll_enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "active_organization_id": {
+ "name": "active_organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "columns": [
+ {
+ "expression": "token",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "session_user_id_idx": {
+ "name": "session_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_login_method": {
+ "name": "last_login_method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/packages/database/drizzle/meta/_journal.json b/packages/database/drizzle/meta/_journal.json
index 4272b7d..4708d4d 100644
--- a/packages/database/drizzle/meta/_journal.json
+++ b/packages/database/drizzle/meta/_journal.json
@@ -29,6 +29,13 @@
"when": 1787157872233,
"tag": "0003_messy_hitman",
"breakpoints": true
+ },
+ {
+ "idx": 4,
+ "version": "7",
+ "when": 1788683562349,
+ "tag": "0004_absurd_morg",
+ "breakpoints": true
}
]
}
diff --git a/packages/database/src/client.test.ts b/packages/database/src/client.test.ts
index b5ded0b..118c6bb 100644
--- a/packages/database/src/client.test.ts
+++ b/packages/database/src/client.test.ts
@@ -94,6 +94,7 @@ describe('schema', () => {
'inviteUse',
'member',
'organization',
+ 'repository',
'session',
'user',
'verification',
diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts
index 16d4845..add915e 100644
--- a/packages/database/src/index.ts
+++ b/packages/database/src/index.ts
@@ -7,6 +7,17 @@ export {
type Database,
type DatabaseOptions,
} from './client.js';
+export {
+ deleteRepository,
+ isAllowedCheckout,
+ listRepositories,
+ saveRepository,
+ watchedRepositories,
+ type RepositoryInput,
+ type RepositoryMode,
+ type RepositoryRecord,
+ type WatchedRepositoryRecord,
+} from './repositories.js';
export {
account,
deviceCode,
@@ -15,6 +26,7 @@ export {
inviteUse,
member,
organization,
+ repository,
schema,
session,
timestampColumns,
diff --git a/packages/database/src/repositories.ts b/packages/database/src/repositories.ts
new file mode 100644
index 0000000..b8cc68d
--- /dev/null
+++ b/packages/database/src/repositories.ts
@@ -0,0 +1,182 @@
+import { and, asc, eq, isNotNull } from 'drizzle-orm';
+
+import type { Database } from './client.js';
+import { repository } from './schema/repository.js';
+
+/**
+ * A configured repository, as every consumer outside this package sees one.
+ *
+ * Named here rather than inferred at each call site so nothing else has to spell out a Drizzle
+ * generic, and so a column rename is a compile error in one place instead of a silent shape change
+ * everywhere.
+ */
+export interface RepositoryRecord {
+ id: string;
+ provider: string;
+ owner: string | null;
+ name: string | null;
+ checkoutPath: string;
+ mode: RepositoryMode;
+ pollEnabled: boolean;
+}
+
+/**
+ * The modes a repository may be configured with.
+ *
+ * Only the two that cannot modify a checkout. Work the deployment starts on its own — a poll, a
+ * webhook delivery — is never something an operator asked for at that moment, so the writable
+ * modes stay reachable only through an explicit request that carries its own authorization.
+ */
+export type RepositoryMode = 'observe' | 'suggest';
+
+const MODES = new Set(['observe', 'suggest']);
+
+function isMode(value: string): value is RepositoryMode {
+ return MODES.has(value);
+}
+
+/** A repository that is watched: it has provider coordinates and polling is on. */
+export interface WatchedRepositoryRecord extends RepositoryRecord {
+ owner: string;
+ name: string;
+}
+
+/** What a caller supplies to configure one; the store mints the identity. */
+export interface RepositoryInput {
+ provider?: string;
+ owner?: string | null;
+ name?: string | null;
+ checkoutPath: string;
+ mode?: RepositoryMode;
+ pollEnabled?: boolean;
+}
+
+/** Every configured repository, oldest first, so a list reads in the order it was built. */
+export async function listRepositories(database: Database): Promise {
+ const rows = await database.select().from(repository).orderBy(asc(repository.createdAt));
+ return rows.map(toRecord);
+}
+
+/**
+ * The repositories the poller looks for work in.
+ *
+ * Filtered in the query rather than by the caller: a row with polling on but no provider
+ * coordinates describes nothing to ask a provider about, and letting it through would make every
+ * pass build a request it cannot send.
+ */
+export async function watchedRepositories(database: Database): Promise {
+ const rows = await database
+ .select()
+ .from(repository)
+ .where(
+ and(
+ eq(repository.pollEnabled, true),
+ isNotNull(repository.owner),
+ isNotNull(repository.name),
+ ),
+ )
+ .orderBy(asc(repository.createdAt));
+ return rows
+ .map(toRecord)
+ .filter((record): record is WatchedRepositoryRecord => isWatched(record));
+}
+
+/**
+ * Whether a run may execute against this checkout path.
+ *
+ * A single indexed lookup rather than loading the list and scanning it, so the cost does not grow
+ * with the number of configured repositories on a surface that runs before every task creation.
+ * The path is compared exactly as stored; callers resolve it first, which is what keeps
+ * `/srv/app` and `/srv/app/../app` from being different answers.
+ */
+export async function isAllowedCheckout(
+ database: Database,
+ checkoutPath: string,
+): Promise {
+ const [row] = await database
+ .select({ id: repository.id })
+ .from(repository)
+ .where(eq(repository.checkoutPath, checkoutPath))
+ .limit(1);
+ return row !== undefined;
+}
+
+/**
+ * Add a repository, or update the one already claiming this checkout path.
+ *
+ * An upsert on the path rather than an insert that fails: the path is the identity an operator
+ * thinks in, and configuring the same checkout twice should read as correcting it rather than as
+ * an error they have to resolve by deleting first.
+ */
+export async function saveRepository(
+ database: Database,
+ input: RepositoryInput,
+ id: () => string = () => globalThis.crypto.randomUUID(),
+): Promise {
+ const values = {
+ id: id(),
+ provider: input.provider?.trim() || 'github',
+ owner: input.owner?.trim() || null,
+ name: input.name?.trim() || null,
+ checkoutPath: input.checkoutPath,
+ mode: input.mode ?? 'observe',
+ pollEnabled: input.pollEnabled ?? false,
+ };
+ // Every field here is optional on `input` (see `RepositoryInput`), so an update names only the
+ // fields it means to change — a caller correcting a repository's `checkoutPath` alone must not
+ // also reset its mode to `observe` or turn its polling off. Only a field `input` actually named
+ // is written on conflict; one left out keeps the row's current value instead of `values`' default.
+ const set: {
+ provider?: string;
+ owner?: string | null;
+ name?: string | null;
+ mode?: RepositoryMode;
+ pollEnabled?: boolean;
+ updatedAt: Date;
+ } = { updatedAt: new Date() };
+ if (input.provider !== undefined) set.provider = values.provider;
+ if (input.owner !== undefined) set.owner = values.owner;
+ if (input.name !== undefined) set.name = values.name;
+ if (input.mode !== undefined) set.mode = values.mode;
+ if (input.pollEnabled !== undefined) set.pollEnabled = values.pollEnabled;
+
+ const [row] = await database
+ .insert(repository)
+ .values(values)
+ .onConflictDoUpdate({ target: repository.checkoutPath, set })
+ .returning();
+ if (!row) throw new Error('The repository could not be saved');
+ return toRecord(row);
+}
+
+/** Remove a repository by id. Reports whether anything was removed, so a caller can say so. */
+export async function deleteRepository(database: Database, id: string): Promise {
+ const removed = await database
+ .delete(repository)
+ .where(eq(repository.id, id))
+ .returning({ id: repository.id });
+ return removed.length > 0;
+}
+
+/**
+ * Narrow a stored row to the contract above.
+ *
+ * `mode` is checked rather than trusted: it is a text column an operator can edit directly, and a
+ * value outside the union would otherwise reach the runtime as a mode nothing enforces. Anything
+ * unrecognised reads as `observe`, the mode that cannot write.
+ */
+function toRecord(row: typeof repository.$inferSelect): RepositoryRecord {
+ return {
+ id: row.id,
+ provider: row.provider,
+ owner: row.owner,
+ name: row.name,
+ checkoutPath: row.checkoutPath,
+ mode: isMode(row.mode) ? row.mode : 'observe',
+ pollEnabled: row.pollEnabled,
+ };
+}
+
+function isWatched(record: RepositoryRecord): boolean {
+ return record.owner !== null && record.name !== null;
+}
diff --git a/packages/database/src/schema/index.ts b/packages/database/src/schema/index.ts
index f0385f0..04a8059 100644
--- a/packages/database/src/schema/index.ts
+++ b/packages/database/src/schema/index.ts
@@ -2,12 +2,14 @@ import { account, session, user, verification } from './auth.js';
import { deviceCode } from './device.js';
import { invite, inviteUse } from './enrollment.js';
import { invitation, member, organization } from './organization.js';
+import { repository } from './repository.js';
export { account, session, user, verification } from './auth.js';
export { timestampColumns } from './columns.js';
export { deviceCode } from './device.js';
export { invite, inviteUse } from './enrollment.js';
export { invitation, member, organization } from './organization.js';
+export { repository } from './repository.js';
/**
* Every table in the store, as one object.
@@ -31,6 +33,7 @@ export const schema = {
invite,
inviteUse,
deviceCode,
+ repository,
};
/** The set of tables the database client is opened with. */
diff --git a/packages/database/src/schema/repository.ts b/packages/database/src/schema/repository.ts
new file mode 100644
index 0000000..31d1314
--- /dev/null
+++ b/packages/database/src/schema/repository.ts
@@ -0,0 +1,63 @@
+import { boolean, index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
+
+import { timestampColumns } from './columns.js';
+
+/**
+ * The repositories a deployment may act on, and where each one lives on this host.
+ *
+ * This is operator policy that changes while the process runs — a repository is added, paused, or
+ * pointed at a different checkout — so it is data rather than configuration. It replaces three
+ * environment variables (`CODE_ZERO_CONTROL_PLANE_REPOSITORIES`, `CODE_ZERO_POLL_REPOSITORIES`,
+ * `CODE_ZERO_POLL_MODE`) that had to be edited and the deployment restarted, and that stated the
+ * same repository twice in two different formats with nothing keeping them in step.
+ *
+ * The row answers two questions that were previously answered separately:
+ *
+ * - **May a run target this checkout?** `checkout_path` is the allow-list. A task creation names a
+ * path and it is accepted only when a row claims it, which is what keeps an HTTP caller from
+ * pointing a run at an arbitrary server-local directory.
+ * - **Should work be looked for here?** `poll_enabled` with `owner`/`name` is the watch list.
+ *
+ * `owner` and `name` are nullable because the two questions are independent: a checkout may be
+ * allow-listed for runs started by hand without being watched on any provider. Polling requires
+ * both, which {@link watchedRepositories} enforces in the query rather than leaving to a caller.
+ */
+export const repository = pgTable(
+ 'repository',
+ {
+ id: text('id').primaryKey(),
+ /** Which source-control provider `owner`/`name` are coordinates on. */
+ provider: text('provider').notNull().default('github'),
+ owner: text('owner'),
+ name: text('name'),
+ /**
+ * The absolute path on this host a run may execute against.
+ *
+ * Never derived from `owner`/`name`: deriving it is how a run ends up in a directory nobody
+ * named. Unique, so two rows cannot disagree about which repository a checkout belongs to.
+ */
+ checkoutPath: text('checkout_path').notNull(),
+ /**
+ * The execution mode the poller requests for this repository.
+ *
+ * Only the two non-writable modes are accepted, checked where rows are written rather than
+ * constrained here: work nobody asked for must not be able to modify a checkout, and a mode
+ * that reached this column would be enforced nowhere else.
+ */
+ mode: text('mode').notNull().default('observe'),
+ /** Whether the poller looks for open pull requests here. */
+ pollEnabled: boolean('poll_enabled').notNull().default(false),
+ ...timestampColumns,
+ },
+ (table) => [
+ uniqueIndex('repository_checkout_path_unique').on(table.checkoutPath),
+ // A provider repository is watched by at most one row, so two rows cannot start two reviews of
+ // the same commit against two checkouts.
+ uniqueIndex('repository_provider_owner_name_unique').on(
+ table.provider,
+ table.owner,
+ table.name,
+ ),
+ index('repository_poll_enabled_idx').on(table.pollEnabled),
+ ],
+);
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/i18n/schemas/dashboard.schema.json b/packages/i18n/schemas/dashboard.schema.json
index 941c79b..147d660 100644
--- a/packages/i18n/schemas/dashboard.schema.json
+++ b/packages/i18n/schemas/dashboard.schema.json
@@ -18,6 +18,18 @@
},
"mode": {
"type": "string"
+ },
+ "live": {
+ "type": "string"
+ },
+ "stale": {
+ "type": "string"
+ },
+ "liveAria": {
+ "type": "string"
+ },
+ "staleAria": {
+ "type": "string"
}
},
"additionalProperties": false
@@ -31,36 +43,9 @@
"control": {
"type": "string"
},
- "tasks": {
- "type": "string"
- },
- "runners": {
- "type": "string"
- },
- "models": {
- "type": "string"
- },
- "approvals": {
- "type": "string"
- },
- "findings": {
- "type": "string"
- },
- "repositories": {
- "type": "string"
- },
- "policies": {
- "type": "string"
- },
- "integrations": {
- "type": "string"
- },
"audit": {
"type": "string"
},
- "settings": {
- "type": "string"
- },
"collapse": {
"type": "string"
},
@@ -198,6 +183,39 @@
},
"emptyBody": {
"type": "string"
+ },
+ "approval": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string"
+ },
+ "body": {
+ "type": "string"
+ },
+ "comment": {
+ "type": "string"
+ },
+ "approve": {
+ "type": "string"
+ },
+ "reject": {
+ "type": "string"
+ },
+ "decided": {
+ "type": "string"
+ },
+ "approved": {
+ "type": "string"
+ },
+ "rejected": {
+ "type": "string"
+ },
+ "failed": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
}
},
"additionalProperties": false
@@ -366,6 +384,69 @@
}
},
"additionalProperties": false
+ },
+ "newTask": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string"
+ },
+ "repository": {
+ "type": "string"
+ },
+ "repositoryHint": {
+ "type": "string"
+ },
+ "mode": {
+ "type": "string"
+ },
+ "trigger": {
+ "type": "string"
+ },
+ "feedback": {
+ "type": "string"
+ },
+ "submit": {
+ "type": "string"
+ },
+ "note": {
+ "type": "string"
+ },
+ "failed": {
+ "type": "string"
+ },
+ "modes": {
+ "type": "object",
+ "properties": {
+ "observe": {
+ "type": "string"
+ },
+ "suggest": {
+ "type": "string"
+ },
+ "fix": {
+ "type": "string"
+ },
+ "autonomous": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "triggers": {
+ "type": "object",
+ "properties": {
+ "proactive": {
+ "type": "string"
+ },
+ "feedback": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false
}
},
"additionalProperties": false
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..c2409eb 100644
--- a/packages/source-control/src/providers/github-pulls.test.ts
+++ b/packages/source-control/src/providers/github-pulls.test.ts
@@ -91,6 +91,128 @@ 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, a full page at a time', async () => {
+ const { pulls, requests } = adapter({ '/repos/acme/app/pulls': [] });
+
+ await pulls.listOpenPullRequests(target);
+
+ expect(requests[0]).toMatchObject({ method: 'GET', path: '/repos/acme/app/pulls' });
+ });
+
+ it('walks a second page rather than leaving it undiscovered behind a full first one', async () => {
+ const first = Array.from({ length: 100 }, (_unused, index) => pull(index + 1));
+ const second = [pull(101)];
+ const sizes: { page: string | null; perPage: string | null }[] = [];
+ const pulls = new GitHubPullRequests({
+ token: 'secret-token-value',
+ fetch: async (input) => {
+ const url = new URL(
+ typeof input === 'string' ? input : 'url' in input ? input.url : input.href,
+ );
+ sizes.push({
+ page: url.searchParams.get('page'),
+ perPage: url.searchParams.get('per_page'),
+ });
+ const page = url.searchParams.get('page');
+ return new Response(JSON.stringify(page === '2' ? second : first), { status: 200 });
+ },
+ });
+
+ const requests = await pulls.listOpenPullRequests(target);
+
+ expect(sizes).toEqual([
+ { page: '1', perPage: '100' },
+ { page: '2', perPage: '100' },
+ ]);
+ expect(requests).toHaveLength(101);
+ });
+
+ it('stops paging once a page comes back short of a full one', async () => {
+ const { pulls, requests } = adapter({ '/repos/acme/app/pulls': [pull(1)] });
+
+ await pulls.listOpenPullRequests(target);
+
+ expect(requests).toHaveLength(1);
+ });
+
+ it('stops at a bounded number of pages rather than paging a repository forever', async () => {
+ let requests = 0;
+ const pulls = new GitHubPullRequests({
+ token: 'secret-token-value',
+ fetch: async () => {
+ requests += 1;
+ // Every page comes back full, so nothing but the cap itself ends the loop.
+ return new Response(
+ JSON.stringify(Array.from({ length: 100 }, (_unused, index) => pull(index + 1))),
+ { status: 200 },
+ );
+ },
+ });
+
+ await pulls.listOpenPullRequests(target);
+
+ expect(requests).toBe(20);
+ });
+
+ 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..ec129d7 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;
@@ -47,6 +66,9 @@ export interface GitHubPullRequestsOptions {
const MAX_TITLE = 256;
const MAX_BODY = 60_000;
const COMMIT_SHA = /^[0-9a-f]{7,64}$/i;
+// Caps one poll's worst case for a repository with an unbounded number of open pull requests at
+// 2,000 (100 per page) rather than walking every page that exists.
+const MAX_PAGES = 20;
// Standard base64 with optional padding; anything else is a caller bug, refused before any request.
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
@@ -192,6 +214,66 @@ export class GitHubPullRequests {
return { number, url };
}
+ /**
+ * Every open pull request, newest first.
+ *
+ * Read-only, and the only thing here that goes looking for work rather than publishing it. Pages
+ * are walked in full rather than stopping at the first: sorted newest-first, a pull request past
+ * page one only reaches page one once something touches it again, so a caller that polled just
+ * the first page could leave an old, otherwise-untouched pull request unreviewed forever rather
+ * than merely waiting for a later pass. `MAX_PAGES` bounds the worst case for a repository with
+ * an unbounded number of open pull requests, rather than one poll walking every page that exists.
+ *
+ * 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): Promise {
+ const requests: OpenPullRequest[] = [];
+ for (let page = 1; page <= MAX_PAGES; page += 1) {
+ const query = new URLSearchParams({
+ state: 'open',
+ sort: 'updated',
+ direction: 'desc',
+ per_page: '100',
+ page: String(page),
+ });
+ 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');
+ 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,
+ });
+ }
+ if (payload.length < 100) break;
+ }
+ return requests;
+ }
+
private async send(
method: 'GET' | 'POST',
path: string,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fd10db6..493da9c 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
@@ -93,6 +99,12 @@ importers:
'@code-zero/build-env':
specifier: workspace:*
version: 0.4.0
+ '@code-zero/config':
+ specifier: workspace:*
+ version: 0.4.0
+ '@code-zero/database':
+ specifier: workspace:*
+ version: 0.4.0
'@code-zero/i18n':
specifier: workspace:*
version: 0.3.0
@@ -102,12 +114,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 +461,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 +532,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 +553,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 +722,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 +734,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 +7624,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==}
@@ -18205,7 +18211,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:
- '@prisma/client': 5.22.0(prisma@7.9.1)
+ '@prisma/client': 5.22.0(prisma@7.9.1(better-sqlite3@12.11.1))
prisma: 7.9.1
'@better-auth/prisma-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)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(prisma@7.9.1)':
@@ -23050,8 +23056,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': {}
@@ -25028,7 +25032,7 @@ snapshots:
nanostores: 1.5.1
zod: 4.4.3
optionalDependencies:
- '@prisma/client': 5.22.0(prisma@7.9.1)
+ '@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)
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,
},