Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,6 +85,7 @@ cp apps/dashboard/.env.example apps/dashboard/.env

```bash
aube run dev # watch workspace development tasks
aube run dev:solo # dashboard alone, no database (apps/dashboard/.env.solo)
aube run zero doctor # inspect the local environment
aube test # deterministic Vitest suites
aube run test:browser # dashboard and marketing browser suites
Expand Down
30 changes: 25 additions & 5 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -103,6 +103,23 @@ aube run dev
The root `.env` configures the CLI. Each app loads its own file: the dashboard uses
`apps/dashboard/.env`, while the docs app optionally uses `apps/docs/.env` for `NUXT_APP_BASE_URL`.

To see the dashboard before configuring anything, start it on its own instead:

```bash
mise install
aube ci
aube run dev:solo # http://localhost:3000, then sign up at /signup
```

`dev:solo` is `nuxt dev` reading [`apps/dashboard/.env.solo`](./apps/dashboard/.env.solo) in place of
`.env`: Better Auth runs on an in-memory store, so there is no Postgres to install and no migration
to apply, and the account you create lives until you stop the process. Nothing else about the app
changes — it is the same UI, the same router, and the same authentication endpoints a deployment
serves. Tasks still need a checkout to target, so add one to
`CODE_ZERO_CONTROL_PLANE_REPOSITORIES` in that file; `observe` runs no model, so a task can be
created and inspected without a provider credential. Use `aube run dev` and `apps/dashboard/.env`
for anything that has to persist.

`aube run <script>` and `aube test` check install freshness first, so a separate install step is rarely needed. aube reads and writes the existing `pnpm-lock.yaml` and `pnpm-workspace.yaml` in place — the lockfile stays in pnpm's v9 format for anyone who still runs pnpm.

---
Expand All@@ -118,10 +135,13 @@ zero logout [--url X] forget a stored session
zero review (--feedback X | --proactive) inspect without editing
zero fix (--feedback X | --proactive) validate, edit, and verify (policy permitting)
zero run (--feedback X | --proactive) run using the configured mode
zero run --remote [--url <origin>] run it on a deployment instead of here
```

The CLI parses arguments with [`@bomb.sh/args`](https://github.com/bomb-sh/args) and renders with [`@clack/prompts`](https://github.com/bombshell-dev/clack). Use `--proactive` to inspect the working-tree diff without reviewer feedback. When neither trigger is provided in a terminal, it asks for the task interactively; use `--feedback` or `--proactive` with `--json` for scripts and CI.

`--remote` hands the run to a deployment's control plane instead of executing it in this checkout, so it lands in the same task history the dashboard reads and appears on the board while it runs. It uses the session `zero login` stored, presented as a bearer token, so the run is attributed to the person who signed in rather than to a shared operator token — which means the deployment needs `AUTH_ENABLE_DEVICE_AUTHORIZATION=true`, the same flag `zero login` already requires. The deployment is chosen with `--url` or `CODE_ZERO_URL`; the flag is deliberate rather than inferred from that variable, which already selects which deployment `login` and `logout` act on. The repository sent is this checkout's path, and the deployment's own `CODE_ZERO_CONTROL_PLANE_REPOSITORIES` decides whether it may be targeted. Exit codes are the same table a local run uses, so CI reads either the same way.

`zero login` runs the [RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628) device flow: it prints a short code, you approve it at the deployment's `/device` page in a browser you are already signed into, and the CLI stores the resulting session token in `$XDG_CONFIG_HOME/code-zero/credentials.json` (owner-readable only). The same command serves a cloud-managed deployment and a self-hosted one — pick which with `--url`, or set `CODE_ZERO_URL`; without either it targets `http://localhost:3000`. Tokens are kept per origin, so signing into one deployment never evicts another, and `zero logout` without `--url` forgets all of them. The deployment must have `AUTH_ENABLE_DEVICE_AUTHORIZATION=true`; it is off by default. That flag also registers Better Auth's `bearer` plugin, which is what lets the stored token be presented as `Authorization: Bearer <token>` — without it the flow would mint a session that only a cookie could carry. `zero doctor` lists which deployments have a stored session and whether it has expired, never the token itself.

---
Expand All@@ -130,11 +150,11 @@ The CLI parses arguments with [`@bomb.sh/args`](https://github.com/bomb-sh/args)

`aube run dev` starts the single deployable app on `http://localhost:3000` (override with `PORT`). It is the only adapter that composes a runner for hosted work, and the same Nuxt app serves the UI, the control plane, and authentication from one origin:

| 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 (mounted by `@onmax/nuxt-better-auth` from `server/auth.config.ts`) |
| 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 (mounted by `@onmax/nuxt-better-auth` from `server/auth.config.ts`) |

`/rpc/**` and `/api/v1/**` are the same `rpcRouter` from [`packages/api`](./packages/api) served over two wire protocols, so authorization behaves identically either way. Reads are open for the dashboard; mutations (`tasks.create`, `approvals.decide`) fail closed. `CODE_ZERO_CONTROL_PLANE_TOKENS` holds comma-separated `name:token` bearer credentials, and `CODE_ZERO_CONTROL_PLANE_REPOSITORIES` allow-lists the repository paths `tasks.create` may target; without them every mutation is rejected. `CODE_ZERO_CONTROL_PLANE_MODES` holds comma-separated `name:mode|mode` grants for the execution modes each principal may request; without a grant a principal may only request the non-writable `observe` and `suggest` modes, so `fix` and `autonomous` require an explicit operator grant. The approval actor is the authenticated principal's name, never a wire-supplied value. This bearer-token scheme authorizes the control-plane API and is independent of the Better Auth session that protects the dashboard UI.

Expand Down
20 changes: 20 additions & 0 deletions apps/dashboard/.env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,6 +40,26 @@ GITHUB_WEBHOOK_SECRET=
# (503, nothing ingested) until both this and GITHUB_WEBHOOK_SECRET are set.
CODE_ZERO_CHECKOUT_PATH=

# Polling (server/plugins/poller.ts), the pull-based half of the same job the webhook does: it
# finds open pull requests to review without this deployment needing a public URL. Off until
# CODE_ZERO_POLL_REPOSITORIES names something, and it needs GITHUB_TOKEN above.
#
# It runs an interval inside the server process, so it belongs to a deployment that stays up; a
# serverless target freezes between requests and would poll only by accident. It shares the durable
# delivery claims with the webhook route, so the two never review the same commit twice.
#
# Each entry pairs a repository on the provider with the checkout on this host a run may execute
# against: `owner/name=/absolute/path`, comma-separated. The path is never derived from the slug,
# so a run can only ever target a checkout an operator named. That checkout has to be kept current
# (a periodic `git fetch`) — a review reads the diff between the pull request's base and head
# commits, so a checkout missing them fails the run rather than reviewing the wrong thing.
CODE_ZERO_POLL_REPOSITORIES=
# Seconds between passes. Clamped to 15..3600; defaults to 60.
CODE_ZERO_POLL_INTERVAL_SECONDS=60
# `observe` (default) or `suggest`. Work nobody requested cannot write to a checkout, and neither
# of these modes can; the writable modes are deliberately not accepted here.
CODE_ZERO_POLL_MODE=observe

# Database (packages/database). Owns the schema and the migrations; this app is the only process
# that opens it. The pre-split name AUTH_DATABASE_URL is still read when this is unset.
# `packages/database/.env` carries the same connection string for drizzle-kit: keep the two
Expand Down
29 changes: 29 additions & 0 deletions apps/dashboard/.env.solo
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
# Environment for `aube run dev:solo` — the dashboard on its own, with no Postgres and no model
# credentials. Loaded only by that script (`nuxt dev --dotenv .env.solo`); `nuxt dev`, `nuxt build`,
# and a deployment all keep reading `.env`, which this file never touches.
#
# Checked in on purpose: it holds no secret worth keeping. The session store is in memory, so the
# signing secret below protects nothing that outlives the process, and the control-plane token is
# only accepted by a server started this way.
#
# Edit CODE_ZERO_CONTROL_PLANE_REPOSITORIES to point at a checkout you want to run tasks against.

# Better Auth runs on an in-memory store, so the dashboard starts without a database. Same flag the
# Playwright preview server sets (see playwright.config.ts); `server/auth.config.ts` documents why
# it is not guarded by NODE_ENV. Never set it in `.env` or in a deployment's environment.
AUTH_E2E_MEMORY=true
# Nothing is ever dialed once the memory adapter replaces the database, but the options still have
# to build, so the resolver needs a value here.
DATABASE_URL=postgres://solo:solo@127.0.0.1:5432/solo
# Sessions live and die with the process, so this is a fixed placeholder rather than a real secret.
NUXT_BETTER_AUTH_SECRET=dev-only-solo-secret-not-used-in-any-deployment
# The store is empty at every start, so the first thing to do is create an account.
AUTH_ENABLE_SIGNUP=true

# Control plane. `observe` runs no model, so a task can be created and inspected without any
# provider credential; `suggest` and `fix` need one from `.env`.
CODE_ZERO_CONTROL_PLANE_TOKENS=solo:solo
CODE_ZERO_CONTROL_PLANE_MODES=solo:observe|suggest|fix
# Comma-separated absolute checkout paths a task may target. Empty means every task creation is
# refused, which is the safe default; add your own checkout to run one.
CODE_ZERO_CONTROL_PLANE_REPOSITORIES=
8 changes: 6 additions & 2 deletions apps/dashboard/app/pages/(dashboard)/audit.vue
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,8 +54,12 @@ import { useHotkeys } from '@tanstack/vue-hotkeys';

import { useShortcutsDialog } from '../../../modules/shared/composables/useAppShortcuts';

const { rows, pending, error, authError, authEnabled, hasMore, refresh, loadMore } =
useAuditTrail();
// The typed client lives on the Nuxt app; the composables take the one call they need rather than
// the app instance, so they stay drivable from a plain unit test.
const { $orpc } = useNuxtApp();
const { rows, pending, error, authError, authEnabled, hasMore, refresh, loadMore } = useAuditTrail(
(query) => $orpc.audit.list(query),
);
const dialogOpen = useShortcutsDialog();

// Client-side only: both trails authenticate the browser's session, and this page is not
Expand Down
106 changes: 103 additions & 3 deletions apps/dashboard/app/pages/(dashboard)/index.vue
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,27 @@
<div class="hidden h-9 items-center gap-2 border border-line bg-raised px-3 lg:flex">
<span class="label-upper">{{ $t('dashboard.header.mode') }}</span>
</div>
<!--
Says whether the board is following the control plane right now. Without it a stalled
stream is indistinguishable from a quiet one, and a quiet board is exactly what an
operator would take as "nothing is happening".
-->
<ClientOnly>
<div
class="hidden h-9 items-center gap-2 border border-line bg-raised px-3 sm:flex"
:aria-label="live ? $t('dashboard.header.liveAria') : $t('dashboard.header.staleAria')"
role="status"
>
<span
aria-hidden="true"
class="h-1.5 w-1.5 rounded-full"
:class="live ? 'bg-accent' : 'bg-muted'"
/>
<span class="label-upper">
{{ live ? $t('dashboard.header.live') : $t('dashboard.header.stale') }}
</span>
</div>
</ClientOnly>
<ClientOnly>
<div class="hidden h-9 items-center gap-2 border border-line bg-raised px-3 sm:flex">
<Icon aria-hidden="true" class="h-3.5 w-3.5 text-muted" name="lucide:clock-3" />
Expand DownExpand Up@@ -42,6 +63,8 @@
<div class="p-3 sm:p-4 md:p-5">
<RunnerMetrics :overview="overview" />

<NewTaskForm class="mt-4" :pending="createPending" :error="createError" @submit="createTask" />

<section class="mt-4 grid gap-4 xl:grid-cols-[minmax(0,1fr)_22rem]">
<div class="min-w-0 space-y-4">
<TaskTable
Expand All@@ -53,14 +76,20 @@
<TaskTimeline :task="selectedTask" />
</div>

<TaskInspector :task="selectedTask" />
<TaskInspector
:task="selectedTask"
:pending="decisionPending"
:error="decisionError"
@decide="recordDecision"
/>
</section>
</div>
</template>

<script setup lang="ts">
import { useHotkeys } from '@tanstack/vue-hotkeys';
import { useQuery } from '@tanstack/vue-query';
import type { NewTaskRequest } from '~~/modules/dashboard/components/NewTaskForm.vue';
import type { DashboardOverview } from '~~/modules/dashboard/types/dashboard';

/**
Expand DownExpand Up@@ -91,8 +120,22 @@ const emptyOverview = (): DashboardOverview => ({
* the same key during SSR, and matters for anything that *does* fetch server-side, since it is the
* half that forwards the request's cookie.
*/
const { $orpcQuery } = useNuxtApp();
const { data, refetch } = useQuery($orpcQuery.dashboard.overview.queryOptions());
const { $orpc, $orpcQuery } = useNuxtApp();
const { t } = useI18n();
const overviewQuery = $orpcQuery.dashboard.overview.queryOptions();
const { data, refetch } = useQuery(overviewQuery);

/**
* The board follows the control plane as it works, rather than showing whatever the last fetch
* happened to catch. A run records its lifecycle events as they happen, so without this a task
* appears and then sits at whatever state it had when the page loaded until someone refreshes.
*
* `refresh` stays: a stream that dropped is exactly when a person reaches for it.
*/
const { connected, stale } = useLiveOverview(overviewQuery.queryKey);

/** One flag for the header: connected and current. Either half failing reads the same to a person. */
const live = computed(() => connected.value && !stale.value);

const overview = computed<DashboardOverview>(() => data.value ?? emptyOverview());
const selectedId = ref<string>();
Expand DownExpand Up@@ -123,6 +166,63 @@ onBeforeUnmount(() => {
if (clockTimer) clearInterval(clockTimer);
});

const decisionPending = ref(false);
const decisionError = ref<string>();
const createPending = ref(false);
const createError = ref<string>();

/**
* Queues a task through the control plane.
*
* The record is persisted before the run is scheduled, so the board shows it through the live
* stream while this call is still open — the response only decides whether an error is reported,
* not when the task appears.
*/
async function createTask(request: NewTaskRequest): Promise<void> {
if (createPending.value) return;
createPending.value = true;
createError.value = undefined;
try {
await $orpc.tasks.create(request);
} catch {
// Refusals name a rule, not a value, but the server's text is still untrusted input this page
// would render; the trail records which rule refused.
createError.value = t('dashboard.newTask.failed');
} finally {
createPending.value = false;
}
}

/**
* Records a human decision on the selected task.
*
* Nothing is written into the cache here: the decision lands in the store, and the store is what
* `/api/events` pushes back, so the board updates from the same source every other client sees
* rather than from an optimistic guess this page made about what the server did.
*/
async function recordDecision(decision: {
decision: 'approved' | 'rejected';
comment: string;
}): Promise<void> {
const task = selectedTask.value;
if (!task || decisionPending.value) return;
decisionPending.value = true;
decisionError.value = undefined;
try {
await $orpc.approvals.decide({
taskId: task.id,
decision: decision.decision,
...(decision.comment === '' ? {} : { comment: decision.comment }),
});
} catch {
// The server's own text is untrusted input the page would render; the outcome is what the
// operator needs, and the trail carries the rest.
decisionError.value = t('dashboard.inspector.approval.failed');
} finally {
decisionPending.value = false;
}
}

/** Refetches rather than clearing: the button says refresh, and it used to only blank the page. */
function refreshDashboard(): void {
now.value = new Date();
Expand Down
Loading
Loading