diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e7a380a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,166 @@ +name: CI + +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: Lint, typecheck and test (Node ${{ matrix.node }}) + runs-on: ubuntu-latest + timeout-minutes: 20 + + strategy: + fail-fast: false + matrix: + # The floor and the newest. Node 22 needs --experimental-sqlite and 24 + # ignores it, so running both is what keeps the supported range honest + # rather than aspirational. + # + # The floor is 22.12 rather than 22.11 because Vite 8, which builds the + # dashboard, declares `^20.19.0 || >=22.12.0`. It is a build tool and + # ships in nothing, so this constrains where the repo can be built, not + # where the runner can run. + node: ['22.12.0', '24.x'] + + env: + # A no-op on 24; required on 22 for node:sqlite. + NODE_OPTIONS: --experimental-sqlite + PARALLAX_DB_PATH: memory + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm lint + + # Build first, for two reasons: cloud-api resolves @parallax/common + # through its built .d.ts rather than a path alias, so a typecheck without + # dist cannot see it; and one test suite imports the built package to + # catch circular imports that only fail in the real ESM graph. + - name: Build + run: pnpm build + + - name: Typecheck + run: | + for pkg in @parallax/common @parallax/orchestrator @parallax/cloud-api parallax-cli @parallax/cloud-dashboard; do + echo "::group::$pkg" + pnpm --filter "$pkg" exec tsc --noEmit + echo "::endgroup::" + done + + - name: Test + run: pnpm test + + workflows: + name: Validate the workflow files + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - uses: actions/checkout@v4 + + # An invalid workflow shows up as a run with no jobs and an error that + # appears in no job log, which is a genuinely confusing way to find out. + - name: Parse every workflow + run: | + npm install --no-save js-yaml >/dev/null 2>&1 + node -e " + const yaml = require('js-yaml'), fs = require('fs'); + let bad = 0; + for (const f of fs.readdirSync('.github/workflows')) { + try { + const w = yaml.load(fs.readFileSync('.github/workflows/' + f, 'utf8')); + // Bare \`on\` is YAML 1.1 boolean true, so accept either key. + const on = w.on ?? w[true]; + if (!on || !w.jobs) throw new Error('missing on: or jobs:'); + console.log(f + ': ' + Object.keys(on).join(', ')); + } catch (e) { + console.log('::error file=.github/workflows/' + f + '::' + e.message); + bad++; + } + } + process.exit(bad ? 1 : 0); + " + + image: + name: Build the cloud-api image + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + # Built for the architecture Railway deploys on, not the runner's, so a + # build that passes here is one that will pass there. + - name: Build + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64 + push: false + load: true + tags: parallax-cloud-api:ci + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Check the entry points resolve + run: | + docker run --rm parallax-cloud-api:ci node dist/org-cli.js | grep -q 'Usage:' + docker run --rm parallax-cloud-api:ci node dist/migrate-cli.js 2>&1 \ + | grep -q 'DATABASE_URL is required' + + dashboard-image: + name: Build the dashboard image + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Build + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.dashboard + platforms: linux/amd64 + push: false + load: true + tags: parallax-dashboard:ci + cache-from: type=gha + cache-to: type=gha,mode=max + + # Starts it for real. The three things that only fail at runtime are the + # API URL being injected from the environment, the SPA falling back to + # index.html for a client-side route, and the health check Railway polls. + - name: Check the container actually serves + run: | + docker run -d --name dash -p 8080:8080 \ + -e PARALLAX_API_URL=https://api.example.invalid parallax-dashboard:ci + for _ in $(seq 1 30); do + curl -sf http://127.0.0.1:8080/health >/dev/null && break + sleep 1 + done + curl -s http://127.0.0.1:8080/health | grep -q '"apiConfigured":true' + curl -s http://127.0.0.1:8080/env.js | grep -q 'api.example.invalid' + curl -sf -o /dev/null http://127.0.0.1:8080/runs/run_1 + docker rm -f dash diff --git a/.github/workflows/deploy-cloud-api.yml b/.github/workflows/deploy-cloud-api.yml new file mode 100644 index 0000000..7681804 --- /dev/null +++ b/.github/workflows/deploy-cloud-api.yml @@ -0,0 +1,95 @@ +name: Deploy cloud-api + +on: + # Two ways in. Manual dispatch lets you pick the branch, so a change can be + # deployed before it merges. The push trigger is path-filtered rather than + # firing on every merge to main, because redeploying the control plane + # interrupts a runner's long poll for no reason. Delete the `push:` block + # below if you would rather every deploy be deliberate. + workflow_dispatch: + inputs: + service: + description: Railway service name + required: true + default: api + environment: + description: Railway environment + required: false + default: production + + push: + branches: [main] + paths: + - 'packages/cloud-api/**' + - 'packages/common/**' + - 'Dockerfile' + - '.railway/railway.ts' + - '.github/workflows/deploy-cloud-api.yml' + +concurrency: + # Never two deploys at once: the pre-deploy step runs migrations. + group: deploy-cloud-api + cancel-in-progress: false + +jobs: + deploy: + name: Deploy to Railway + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: production + + env: + RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }} + SERVICE: ${{ inputs.service || 'api' }} + + steps: + - uses: actions/checkout@v4 + + - name: Require a Railway token + run: | + if [ -z "$RAILWAY_TOKEN" ]; then + echo "::error::RAILWAY_TOKEN is not set. Create a project token in"\ + "Railway (Project Settings -> Tokens) and add it as a repository secret." + exit 1 + fi + + - name: Install the Railway CLI + run: npm install -g @railway/cli@4 + + # Deploys from the checkout, so no git remote needs to be connected on the + # Railway side. The root is the Docker build context. + - name: Deploy + run: railway up --service "$SERVICE" --environment "${{ inputs.environment || 'production' }}" --ci + + - name: Wait for health + run: | + # `railway domain` output has changed shape between CLI versions, so + # the hostname is scraped rather than parsed from a flag that may not + # exist. An explicit CLOUD_HEALTH_URL wins over both. + DOMAIN="${{ vars.CLOUD_HEALTH_URL }}" + if [ -z "$DOMAIN" ]; then + DOMAIN=$(railway domain --service "$SERVICE" 2>/dev/null \ + | grep -oE '[a-z0-9.-]+\.up\.railway\.app' | head -1) + fi + + if [ -z "$DOMAIN" ]; then + echo "::warning::Could not resolve the service domain; skipping the health check." + exit 0 + fi + + URL="$DOMAIN" + case "$URL" in https://*) ;; *) URL="https://$DOMAIN" ;; esac + echo "Checking $URL/health" + for attempt in $(seq 1 30); do + code=$(curl -sS -o /dev/null -w '%{http_code}' "$URL/health" || true) + if [ "$code" = "200" ]; then + echo "Healthy after ${attempt} attempt(s)." + exit 0 + fi + sleep 10 + done + + # The deploy itself may have succeeded and the check raced it, so this + # fails loudly rather than silently passing. + echo "::error::/health did not return 200 within 5 minutes (last: $code)." + exit 1 diff --git a/.github/workflows/deploy-dashboard.yml b/.github/workflows/deploy-dashboard.yml new file mode 100644 index 0000000..46c3506 --- /dev/null +++ b/.github/workflows/deploy-dashboard.yml @@ -0,0 +1,99 @@ +name: Deploy dashboard + +on: + # Same two ways in as the control plane. Manual dispatch picks the branch, so + # a change can be deployed before it merges; the push trigger is path-filtered + # rather than firing on every merge to main. + workflow_dispatch: + inputs: + service: + description: Railway service name + required: true + default: dashboard + environment: + description: Railway environment + required: false + default: production + + push: + branches: [main] + paths: + - 'packages/cloud-dashboard/**' + - 'Dockerfile.dashboard' + - '.railway/railway.ts' + - '.github/workflows/deploy-dashboard.yml' + +concurrency: + # Serialized for the same reason as the API: two deploys racing on one + # service leaves the winner ambiguous. + group: deploy-dashboard + cancel-in-progress: false + +jobs: + deploy: + name: Deploy to Railway + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: production + + env: + RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }} + SERVICE: ${{ inputs.service || 'dashboard' }} + + steps: + - uses: actions/checkout@v4 + + - name: Require a Railway token + run: | + if [ -z "$RAILWAY_TOKEN" ]; then + echo "::error::RAILWAY_TOKEN is not set. Create a project token in"\ + "Railway (Project Settings -> Tokens) and add it as a repository secret." + exit 1 + fi + + - name: Install the Railway CLI + run: npm install -g @railway/cli@4 + + # Which Dockerfile this service builds comes from .railway/railway.ts. + # Run `railway config apply` after changing that file — `up` deploys the + # source, it does not reconcile the project's configuration. + - name: Deploy + run: railway up --service "$SERVICE" --environment "${{ inputs.environment || 'production' }}" --ci + + - name: Wait for health + run: | + DOMAIN="${{ vars.DASHBOARD_HEALTH_URL }}" + if [ -z "$DOMAIN" ]; then + DOMAIN=$(railway domain --service "$SERVICE" 2>/dev/null \ + | grep -oE '[a-z0-9.-]+\.up\.railway\.app' | head -1) + fi + + if [ -z "$DOMAIN" ]; then + echo "::warning::Could not resolve the service domain; skipping the health check." + exit 0 + fi + + URL="$DOMAIN" + case "$URL" in https://*) ;; *) URL="https://$DOMAIN" ;; esac + echo "Checking $URL/health" + for attempt in $(seq 1 30); do + body=$(curl -sS "$URL/health" || true) + case "$body" in + *'"status":"ok"'*) + echo "Healthy after ${attempt} attempt(s): $body" + # A dashboard that cannot reach an API serves a page that can do + # nothing, so this is called out rather than passed silently. + case "$body" in + *'"apiConfigured":false'*) + echo "::warning::The dashboard is up but PARALLAX_API_URL is unset."\ + "Set it on the service, or sign-in will fail." + ;; + esac + exit 0 + ;; + esac + sleep 10 + done + + echo "::error::/health did not report ok within 5 minutes (last: ${body:-no response})." + exit 1 diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml new file mode 100644 index 0000000..8119eea --- /dev/null +++ b/.github/workflows/publish-cli.yml @@ -0,0 +1,101 @@ +name: Publish parallax-cli + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to publish (e.g. 0.2.0). Leave blank to publish the current one.' + required: false + dry_run: + description: Pack and inspect without publishing + type: boolean + default: false + + release: + types: [published] + +permissions: + id-token: write # npm trusted publishing + contents: read + +concurrency: + group: publish-cli + cancel-in-progress: false + +jobs: + publish: + name: Verify and publish + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: npm + + env: + NODE_OPTIONS: --experimental-sqlite + PARALLAX_DB_PATH: memory + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + # Publish from the newest supported runtime; the package itself works + # from 22.5 up, which CI proves separately. + node-version: 24.x + cache: pnpm + registry-url: https://registry.npmjs.org + + - run: pnpm install --frozen-lockfile + + - name: Set the version + if: inputs.version != '' + run: | + pnpm version:set "${{ inputs.version }}" + # The internal @parallax/* packages are unpublished and linked by + # version, so they move in lockstep or the published CLI cannot + # resolve them. set-version.mjs is what keeps them aligned. + git --no-pager diff --stat + + # Publishing something that does not build or pass its tests is the one + # mistake that reaches users directly. + - name: Lint + run: pnpm lint + + - name: Test + run: pnpm test + + - name: Build + run: pnpm build + + - name: Check the published entry point is executable + run: | + test -x packages/cli/dist/cli/src/index.js \ + || { echo "::error::dist/cli/src/index.js is not executable; the global"\ + "command would fail with 'permission denied'."; exit 1; } + node packages/cli/dist/cli/src/index.js --version + + - name: Pack and inspect + run: | + cd packages/cli + pnpm pack:tarball + echo "::group::Tarball contents" + tar -tzf parallax-cli-*.tgz | head -40 + echo "::endgroup::" + # The internal packages are bundled, not fetched from npm, so their + # absence would only surface on a user's machine at install time. + for pkg in common orchestrator; do + tar -tzf parallax-cli-*.tgz | grep -q "node_modules/@parallax/$pkg/" \ + || { echo "::error::@parallax/$pkg is missing from the tarball."; exit 1; } + done + + - name: Publish + if: ${{ !inputs.dry_run }} + run: | + npm install -g npm@latest + pnpm --dir packages/cli publish:package + + - name: Skipped + if: ${{ inputs.dry_run }} + run: | + echo "Dry run: packed and verified, nothing published." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index b1759c2..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Release parallax-cli - -on: - workflow_dispatch: - -permissions: - id-token: write - contents: read - -jobs: - publish: - runs-on: ubuntu-latest - timeout-minutes: 20 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 23.7.0 - cache: pnpm - registry-url: https://registry.npmjs.org - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Setup npm for trusted publishing - run: npm install -g npm@11.5.1 - - - name: Publish parallax-cli to npm - run: pnpm --dir packages/cli publish:package diff --git a/.github/workflows/test-lint.yml b/.github/workflows/test-lint.yml deleted file mode 100644 index 1c8b6a4..0000000 --- a/.github/workflows/test-lint.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Test & Lint - -on: - pull_request: - types: - - opened - - synchronize - - reopened - -jobs: - verify: - runs-on: ubuntu-latest - timeout-minutes: 20 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 23.7.0 - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Lint - run: pnpm lint - - - name: Test - run: pnpm test diff --git a/.railway/README.md b/.railway/README.md new file mode 100644 index 0000000..8d165af --- /dev/null +++ b/.railway/README.md @@ -0,0 +1,54 @@ +# Railway configuration + +This project defines its Railway infrastructure in code. + +```txt +.railway/railway.ts +``` + +Use this file to describe the Railway project you want: services, databases, buckets, custom domains, replicas, groups, and environment variables. + +The TypeScript file imports `railway/iac`. Install the SDK from the repository root: + +```bash +npm install railway +``` + +## Common commands + +Create the configuration files: + +```bash +railway config init +``` + +Import an existing Railway project into code: + +```bash +railway config pull +``` + +Preview what Railway would change: + +```bash +railway config plan +``` + +Apply the planned changes: + +```bash +railway config apply +``` + +## Notes + +- `railway config plan` is safe and does not change Railway. +- `railway config apply` previews changes and asks before applying unless you pass `--yes`. +- Destructive changes in non-interactive or agent sessions require `railway config apply --confirm-destructive` after reviewing the plan. +- CI should pin a plan (`railway config plan --out railway-plan.json`) and apply that file on merge (`railway config apply --plan railway-plan.json --yes --confirm-destructive`) so the reviewed change set is what lands. On GitHub Actions, use https://github.com/railwayapp/config. +- Services already managed by `railway.json` must be migrated before `.railway/railway.ts` can manage them. +- Keep one `.railway` file for the whole project. A named `export const partial` (or `PARTIAL` / `const Partial`) is a last resort for separate repos that cannot share that file. Do not add it unless omit=delete across repos is a blocker. +- Use `replicas` for scaling; advanced placement can still specify region names. +- Use `group("Name", [resources])` to keep large projects organized on the Railway canvas. +- Secrets imported from Railway are rendered as `preserve()` so existing values are retained without writing secret values to source. Use `railway config pull --omit-preserved-variables` for a smaller import. `railway config pull --include-variables` decrypts and inlines non-sealed values (including secrets that were never sealed). +- `railway config migrate` finds every `railway.json` / `railway.toml` in the repository and writes them into this one file. diff --git a/.railway/railway.ts b/.railway/railway.ts new file mode 100644 index 0000000..001957d --- /dev/null +++ b/.railway/railway.ts @@ -0,0 +1,75 @@ +import { defineRailway, postgres, preserve, project, service, volume } from 'railway/iac' + +/** + * The Railway project, in code. + * + * This replaces the per-service `railway.json` files. Config as Code is + * deprecated — Railway stopped letting a service opt in on 2026-08-28 and + * retires the mechanism on 2026-12-01 — and the API now refuses to set a + * service's config file path at all. + * + * Describing both services in one file is better than what it replaces. There + * is no root `railway.json` for a new service to inherit by accident, so the + * failure that prompted this — the dashboard service silently building and + * deploying the control plane's image, then passing its health check while + * serving the wrong thing — is not merely fixed but unrepresentable. Every + * service's builder and Dockerfile are stated here, next to each other. + * + * pnpm railway:plan preview, changes nothing + * pnpm railway:apply apply after review + * + * `restartPolicyType` is deliberately absent. It is applied and live as + * ON_FAILURE on both services, but it is also Railway's default, and the plan + * reader reports it as unset — so declaring it makes every plan show two + * phantom changes forever. A plan that never reads clean is one nobody reads, + * which costs more than restating a default is worth. `restartPolicyMaxRetries` + * stays, because 5 is not the default. + */ +export default defineRailway(() => { + const Postgres = postgres('Postgres', { region: 'europe-west4-drams3a' }) + + const postgresVolume = volume('postgres-volume', { + alerts: { usage: { '80': {}, '95': {}, '100': {} } }, + allowOnlineResize: true, + region: 'europe-west4-drams3a', + sizeMB: 500, + }) + + // The control plane. Migrations run as a pre-deploy step so the schema is in + // place before the new container takes traffic, and so a failed migration + // stops the rollout rather than being discovered by the first request. + const api = service('api', { + build: { builder: 'DOCKERFILE', dockerfilePath: 'Dockerfile' }, + deploy: { + startCommand: 'node dist/index.js', + preDeployCommand: ['node dist/migrate-cli.js'], + healthcheckPath: '/health', + healthcheckTimeout: 30, + restartPolicyMaxRetries: 5, + }, + replicas: { 'europe-west4-drams3a': 1 }, + // preserve() keeps the value already set on Railway without writing a + // credential into source. + env: { DATABASE_URL: preserve() }, + }) + + // The dashboard. Its own Dockerfile, and no pre-deploy command — it owns no + // database and has nothing to migrate. + const dashboard = service('dashboard', { + build: { builder: 'DOCKERFILE', dockerfilePath: 'Dockerfile.dashboard' }, + deploy: { + startCommand: 'node server.mjs', + healthcheckPath: '/health', + healthcheckTimeout: 30, + restartPolicyMaxRetries: 5, + }, + replicas: { 'europe-west4-drams3a': 1 }, + // Read at runtime by server.mjs and served as /env.js, so pointing the + // dashboard at a different control plane is a restart, not a rebuild. + env: { PARALLAX_API_URL: preserve() }, + }) + + return project('parallax', { + resources: [api, dashboard, Postgres, postgresVolume], + }) +}) diff --git a/CLAUDE.md b/CLAUDE.md index da1a077..202db0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,91 +10,175 @@ pnpm build # build all packages (tsc) pnpm test # run all tests pnpm lint # lint all packages pnpm lint:fix # auto-fix lint issues -pnpm clean # remove local DB and worktrees artifacts # run a single package's tests pnpm --filter @parallax/orchestrator test +pnpm --filter @parallax/cloud-api test pnpm --filter parallax-cli test +pnpm --filter @parallax/cloud-dashboard test # local development — use this entrypoint for all manual testing pnpm parallax preflight pnpm parallax init -pnpm parallax start --server-api-port 9371 --server-ui-port 9372 --concurrency 2 -pnpm parallax status -pnpm parallax open +pnpm parallax start --api-port 9371 --concurrency 2 +pnpm parallax agents +pnpm parallax runs pnpm parallax stop + +# railway — plan/apply reconcile .railway/railway.ts, deploy ships source +pnpm railway:plan +pnpm railway:apply +pnpm railway:deploy:api +pnpm railway:deploy:dashboard + +# cloud, against a local or Railway Postgres +DATABASE_URL=... pnpm --filter @parallax/cloud-api dev +DATABASE_URL=... pnpm --filter @parallax/cloud-api db:migrate + +# dashboard, against a local or deployed cloud-api +PARALLAX_API_URL=http://127.0.0.1:8080 pnpm --filter @parallax/cloud-dashboard dev ``` -Node.js >= 23.7.0 and pnpm 10.x are required. The `--filter` flag targets individual workspace packages by their `name` in `package.json`. +Node.js >= 23.7.0 and pnpm 10.x are required. ## Architecture -Parallax is a plan-first AI orchestration runtime. It pulls work from Linear or GitHub, generates a plan via an AI agent, waits for human approval, then executes the approved plan in an isolated git worktree and opens a PR. +Parallax is a **trigger and dispatch layer over [Hermes Agent](https://hermes-agent.nousresearch.com)**. +It watches tickets and pull requests, decides which Hermes agent should start and with +what context, and records what happened. It does not run agents itself. + +### The boundary — read this first + +Everything else follows from this split: + +- **Parallax owns** deciding *when* an agent should start, *which* agent, and *with + what context*; recording the outcome; announcing it. +- **Hermes owns** everything from the moment a run starts — the filesystem, git, + worktrees, tooling, credentials, and the agent's own GitHub identity. + +Parallax creates no worktrees, runs no git commands, and opens no pull requests. The +agent does that work under its own identity and reports back. Consequently the runner +needs **no local clone** of any repository, and `ProjectConfig` has no `workspaceDir`. ### Package layout -- **`packages/common`** — shared models, enums (`TASK_STATUS`, `TaskPlanState`, `AGENT_PROVIDER`, etc.), interfaces (`Task`, `ProjectConfig`, `StoredConfig`, `AgentResult`, `PlanResult`), and the `HostExecutor` abstraction. All cross-package types live here. -- **`packages/orchestrator`** — the runtime process: polling loop, task state machine, AI adapter dispatch, Fastify REST API, Socket.io streaming, SQLite persistence. -- **`packages/cli`** — the published `parallax-cli` npm package. It is the sole entry point for users. Commands talk to the orchestrator over HTTP. The `start` command forks the orchestrator as a child process and writes `~/.parallax/running.json`. -- **`packages/slack`** — optional Slack bot (`SlackBot`) that posts task lifecycle notifications and handles interactive commands (approve, reject, cancel). Integrated at runtime via `setSlackBot()` / `getSlackBot()` in `slack-integration.ts`. -- **`packages/ui`** — React/Vite dashboard served by the orchestrator's UI server in production. -- **`packages/marketing`** — standalone marketing site, not part of the runtime. +- **`packages/common`** — the shared type spine. `RUN_STATUS`, `AgentDescriptor`, + `TriggerEvent`, `RoutingRule`, `RunRecord`, and the config shapes. All cross-package + types live here. +- **`packages/orchestrator`** — the runner. Polls trigger sources, evaluates routes, + dispatches to Hermes, mirrors runs to the cloud. Runs on the same machine as Hermes. +- **`packages/cli`** — the published `parallax-cli` package, the only user entry point. +- **`packages/cloud-api`** — the Railway-deployed control plane. Fastify + Postgres. + Stores config, the agent registry, and run history; sends Slack notifications. +- **`packages/cloud-dashboard`** — the React app over the cloud user API. Vite + + React 19, built on `@16-bits-design/ui`. Named as a sibling of `cloud-api` because + both are hosted; the runner also serves an API, so an unqualified `api` would be + ambiguous. Deployed to Railway as its own service; see `docs/dashboard.md`. ### Runtime state (`~/.parallax/`) -| File/Dir | Purpose | +| File | Purpose | |---|---| -| `config.json` | All project, agent, Slack, and secrets config (managed by `parallax init` and dashboard) | -| `running.json` | PID, ports, concurrency of the active orchestrator process | -| `parallax.db` | SQLite — tasks and task logs tables | -| `worktrees/` | Ephemeral git worktrees created per task, cleaned up after execution | +| `config.json` | Cloud credentials, Hermes profiles and keys, secrets (v2 schema) | +| `routes.json` | Last known good routes; the offline fallback, and the whole route table when no cloud is configured | +| `running.json` | Pid and port of the running runner | +| `parallax.db` | SQLite — runs, run events, dispatch ledger | +| `runner.{stdout,stderr}.log` | Runner output | + +Override the directory with `PARALLAX_DATA_DIR`. + +### Hermes integration (`packages/orchestrator/src/hermes/`) -Override via `PARALLAX_DATA_DIR` env var. +One `HermesClient` addresses exactly one profile: the URL prefix (`/p/`, or +nothing for `default`) and the bearer key are bound together at construction, so the +default profile's key can never be presented to a named profile's routes — which +Hermes rejects under `gateway.multiplex_profiles`. -### Configuration flow +`HermesAdapter.run()` implements the one rule worth remembering: **the SSE stream is +progress, the poll is truth.** Hermes expires run event buffers after five minutes, so +a long run's stream ends while the run continues. Completion is decided exclusively by +polling `GET /v1/runs/{id}`; stream failures are logged and swallowed. -`~/.parallax/config.json` is the single source of truth. `loadConfig()` in `packages/orchestrator/src/config-loader.ts` reads it via `config-store.ts`, injects `secrets` into `process.env`, validates the structure via `config-validation.ts`, and returns `AppConfig`. Agent processes inherit secrets through `process.env`. No YAML files. +### Routing (`packages/orchestrator/src/routing/`) -### Task state machine +`trigger → match → target → execution → outcome`. `rule-engine.ts` is pure — no I/O, +no clock — so the whole "which agent starts, and when" decision is exhaustively +unit-testable. -Tasks move through two parallel dimensions: +Two invariants the dispatcher enforces: -**`TASK_STATUS`**: `PENDING` → `IN_PROGRESS` → `COMPLETED` / `FAILED` / `CANCELED` +1. **One run per agent.** Hermes corrupts a profile's memory if two agents drive it + concurrently. A route targeting a busy agent *defers* without claiming its dedupe + key, so the trigger survives to the next cycle. +2. **Fire once per change.** Every dispatch claims + `sha1(routeId, triggerRef, triggerRevision)` in the SQLite `dispatch_ledger` before + any work starts. `INSERT OR IGNORE` is the concurrency control. A failure before the + agent was reached releases the claim so a fix can run. -**`TaskPlanState`**: `PLAN_GENERATING` → `PLAN_READY` / `PLAN_REQUIRES_CLARIFICATION` → _(user approves)_ → `PLAN_APPROVED` → execution → `PLAN_APPROVED` (persisted on PR creation). `NOT_REQUIRED` is used for PR-review tasks that skip planning. +### Route catalog (`packages/common/src/route-catalog.ts`) -State transitions are coordinated through `taskLifecycle` (`packages/orchestrator/src/task-lifecycle.ts`) which writes to the DB and updates the in-memory log display. +The supported cases are declared once, as complete routes, and served from +`GET /v1/route-templates` for the dashboard to offer. Adding a capability means +adding a template here; `test/routing/route-catalog.test.ts` checks every entry +against `validateRoutingRule` and the prompt renderer, so a template can never +ship in a shape the API would reject. `docs/routes.md` is the prose counterpart. -### Orchestrator polling loop (`packages/orchestrator/src/index.ts`) +### Cloud (`packages/cloud-api`) -The `main()` function runs an infinite loop (15 s interval) calling `pollProjects()`. For each registered project it: -1. Fetches new issues from the configured provider (Linear or GitHub). -2. Creates worktrees and runs `adapter.runPlan()` for tasks needing a plan. -3. Dispatches `adapter.runTask()` for tasks with an approved plan. -4. Enforces a `pLimit` concurrency cap across all projects. +Two API-key scopes, separated from day one: `prx_rnr_` for the runner +(`/v1/runner/*`), `prx_usr_` for humans and the future dashboard (`/v1/*`). Presenting +one where the other is required is a 401. -Cancellation is tracked via an in-memory `canceledTasks: Set` checked at each `throwIfCancellationRequested()` call. +The runner **long-polls** `GET /v1/runner/commands` rather than accepting inbound +connections, so it works behind NAT with no tunnel. That poll also paces the runner's +main loop, and `POST /v1/runner/heartbeat` rides on the same cycle — nothing can ask +the runner how it is doing, so health is pushed or it does not exist. `last_seen_at` is +additionally touched by any authenticated runner request, so liveness never depends on +the runner remembering to report it. -### AI adapters (`packages/orchestrator/src/ai-adapters/`) +CORS on the user API must list its methods explicitly. `@fastify/cors` defaults to +`GET,HEAD,POST`, which makes a browser's preflight refuse every DELETE and PUT while +curl, sending no preflight, works perfectly. -`BaseAgentAdapter` defines two abstract methods: `runPlan(task, workingDir, project)` and `runTask(task, workingDir, project, approvedPlan?, outputMode?)`. Concrete implementations: `CodexAdapter`, `GeminiAdapter`, `ClaudeCodeAdapter`. The adapter is selected from `project.agent.provider` and cached per project in an `adapterCache` map. Secrets are available in `process.env` (injected by `loadConfig()`). +Migrations are plain `.sql` files applied in filename order, one transaction each. -### Dashboard layout +## CI and releasing -Three-column layout: icon nav (left, 52px) | list panel (280px) | main content (fills remainder). +Four workflows in `.github/workflows`: `ci.yml` (lint, typecheck, test on Node 22 and +24, build both images), `deploy-cloud-api.yml` and `deploy-dashboard.yml` (Railway), +`publish-cli.yml` (npm). `docs/releasing.md` covers secrets and the manual fallbacks. -- **NavBar** (`NavBar.tsx`) — icon-only vertical navigation for Tasks / Projects / Integrations -- **ListPanel** (`ListPanel.tsx`) — scrollable list for the active section -- **Main content** — `LogViewer`, `ProjectEditor`, `IntegrationDetail`, or `EmptyState` +The Node matrix is load-bearing: 22 needs `--experimental-sqlite` and 24 ignores it, so +both must run for the supported range to mean anything. Its floor is 22.12 rather than +22.11 because Vite 8, which builds the dashboard, requires it. CI builds before testing +because one suite imports the built package to catch circular imports the source alias +hides. -### API server (`packages/orchestrator/src/runtime/api-server.ts`) +Both Railway services are declared in `.railway/railway.ts` — Railway's Infrastructure +as Code, which replaced the per-service `railway.json` files. Config as Code is +deprecated: services could no longer opt in from 2026-08-28, and it retires on +2026-12-01. One file for the whole project is also what makes the failure it replaced +unrepresentable — with no root `railway.json`, a new service cannot inherit another's +builder and silently deploy the wrong image. -The `mutateConfig(updater)` helper reads `config.json`, applies an updater, writes back atomically, reloads the runtime, and emits `config_updated` over Socket.io. All CRUD endpoints for projects, agents, Slack, and secrets use it. +`pnpm railway:plan` previews; `pnpm railway:apply` applies after review. Neither +deploys — `pnpm railway:deploy:api` and `pnpm railway:deploy:dashboard` do that, and +they reconcile no configuration, so a change to `.railway/railway.ts` needs an apply +as well. ## Key conventions -- **Fail fast**: missing required config or malformed input throws immediately — no silent fallbacks. -- **Strict parsing**: all CLI arg and request parsing goes through dedicated parser functions in `args.ts` and `runtime/api/request-parsers.ts`; never parse inline. -- **`pnpm parallax `** is the canonical local testing entrypoint — do not invoke package-level scripts directly for runtime flows. +- **Fail fast**: missing required config or malformed input throws immediately — no + silent fallbacks. +- **Strict parsing**: all CLI arg and request parsing goes through dedicated parser + functions in `args.ts`; never parse inline. An unknown flag is an error. +- **`pnpm parallax `** is the canonical local testing entrypoint. - **Docs updates belong in the same commit** as behavior changes. - Tests live in `packages//test/` and mirror the `src/` structure. +- The dashboard's screen tests run against payloads recorded from a live `cloud-api` + over real Postgres, in `test/fixtures/`. Hand-written ones agree with the source by + construction and miss what actually breaks a browser — `run_events.ts` arrives as a + string, because node-postgres will not narrow a bigint. +- Prefer testing pure logic directly. `test/hermes/fake-hermes-server.ts` exists so the + adapter's timeout, cancellation, and degradation paths are testable without a real + Hermes; it can misbehave on demand. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..50bde88 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,60 @@ +# Builds @parallax/cloud-api. +# +# Lives at the repo root, not beside the package it builds, for two reasons: the +# build context must be the root (this is a pnpm workspace and @parallax/common +# is a workspace dependency), and Railway auto-detects ./Dockerfile, so no +# builder configuration is required for a deploy to work. +# +# docker build -t parallax-cloud-api . + +# ── base ─────────────────────────────────────────────────────────────────── +# pnpm refuses to remove a node_modules directory without a TTY, and the +# builder has none. +FROM node:23-slim AS base +WORKDIR /app +ENV CI=true +RUN corepack enable + +# Only the manifests, so dependency layers survive source edits. +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY packages/common/package.json packages/common/ +COPY packages/cloud-api/package.json packages/cloud-api/ + +# ── prod dependencies ────────────────────────────────────────────────────── +# Installed once, on a clean tree. The previous version installed everything, +# built, then re-ran install with --prod over the top; that second pass has to +# tear down and rebuild node_modules, which is the step that failed on Railway. +# Resolving the production set from scratch never removes anything. +FROM base AS prod-deps +RUN pnpm install --frozen-lockfile --prod --filter @parallax/cloud-api... + +# ── build ────────────────────────────────────────────────────────────────── +FROM base AS build +RUN pnpm install --frozen-lockfile --filter @parallax/cloud-api... + +COPY tsconfig.base.json ./ +COPY packages/common packages/common +COPY packages/cloud-api packages/cloud-api + +RUN pnpm --filter @parallax/common build && pnpm --filter @parallax/cloud-api build + +# ── runtime ──────────────────────────────────────────────────────────────── +FROM node:23-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production + +# pnpm symlinks each package's node_modules into the root .pnpm store, so the +# root tree has to come along with the per-package one. There is no +# packages/common/node_modules: common has no runtime dependencies of its own, +# and pnpm creates no directory for a package that needs none. +COPY --from=prod-deps /app/node_modules ./node_modules +COPY --from=prod-deps /app/packages/cloud-api/node_modules ./packages/cloud-api/node_modules + +COPY --from=build /app/packages/common/package.json ./packages/common/ +COPY --from=build /app/packages/common/dist ./packages/common/dist +COPY --from=build /app/packages/cloud-api/package.json ./packages/cloud-api/ +COPY --from=build /app/packages/cloud-api/dist ./packages/cloud-api/dist + +WORKDIR /app/packages/cloud-api +EXPOSE 8080 +CMD ["node", "dist/index.js"] diff --git a/Dockerfile.dashboard b/Dockerfile.dashboard new file mode 100644 index 0000000..2b02383 --- /dev/null +++ b/Dockerfile.dashboard @@ -0,0 +1,43 @@ +# Builds @parallax/cloud-dashboard. +# +# A sibling of ./Dockerfile rather than a stage inside it: the two services are +# deployed and scaled independently, and a shared image would rebuild and +# restart the control plane every time a button moved. +# +# Railway is pointed at this file by .railway/railway.ts, which sets the dashboard +# service's builder and dockerfilePath. The build context is the repo root because +# this is a pnpm workspace and the lockfile lives there. +# +# docker build -f Dockerfile.dashboard -t parallax-dashboard . + +# ── build ────────────────────────────────────────────────────────────────── +# pnpm refuses to remove a node_modules directory without a TTY, and the +# builder has none. +FROM node:23-slim AS build +WORKDIR /app +ENV CI=true +RUN corepack enable + +# Only the manifests first, so the dependency layer survives source edits. +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY packages/cloud-dashboard/package.json packages/cloud-dashboard/ +RUN pnpm install --frozen-lockfile --filter @parallax/cloud-dashboard... + +COPY packages/cloud-dashboard packages/cloud-dashboard +RUN pnpm --filter @parallax/cloud-dashboard build + +# ── runtime ──────────────────────────────────────────────────────────────── +# No node_modules at all. server.mjs is deliberately dependency-free and the +# bundle is already built, so the runtime image carries static files and one +# script — nothing that could need patching for a transitive CVE. +FROM node:23-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production + +COPY --from=build /app/packages/cloud-dashboard/dist ./dist +COPY --from=build /app/packages/cloud-dashboard/server.mjs ./server.mjs + +# Railway overrides PORT; this is the local default and matches server.mjs. +EXPOSE 8080 +USER node +CMD ["node", "server.mjs"] diff --git a/README.md b/README.md index 0d7c49c..b1c7282 100644 --- a/README.md +++ b/README.md @@ -1,152 +1,131 @@ -# parallax-cli - -> WARNING: Parallax is currently in alpha. Expect rough edges, missing polish, and occasional breaking changes. - -Parallax is a local AI orchestration runtime for software tasks. -It pulls work from Linear or GitHub, creates isolated worktrees, runs an agent in two phases (`plan` then `execute`), and requires explicit approval before implementation. - -![](./dashboard.png) - -## First version scope +# Parallax + +Trigger your [Hermes](https://hermes-agent.nousresearch.com) agents from your tickets +and pull requests. + +You already run a fleet of Hermes profiles — a product reviewer, a code reviewer, an +implementer, each with its own memory, model, and GitHub account. Parallax is the layer +that decides **which one should start, when, and with what context**, then records what +happened and tells your team about it. + +```jsonc +// "When a Linear ticket gets the feasibility label, +// have the product agent assess it and comment back." +{ + "name": "Product review on feasibility label", + "trigger": { "type": "ticket", "provider": "linear", "projectId": "taplands" }, + "match": { "labels": { "any": ["feasibility"] } }, + "target": { "agentRef": { "profile": "product" } }, + "execution": { "prompt": "Assess {{ticket.ref}}: {{ticket.title}}\n\n{{ticket.body}}", + "timeoutSeconds": 1800 }, + "outcome": { + "postComment": { "target": "ticket" }, + "labels": { "add": ["reviewed"], "remove": ["feasibility"] } + } +} +``` -- Plan-first task lifecycle with explicit approval/rejection. -- Issue intake from Linear and GitHub. -- Global runtime state under `~/.parallax`. -- CLI onboarding wizard plus dashboard UI. -- Codex, Gemini, and Claude Code adapters (configurable per project). -- Slack bot for plan approvals and task notifications (Socket Mode, no public URL needed). +That is the whole idea. Routes are data, so a new workflow is a row, not a code change +— and the prompt lives on the route, so rewording what an agent is asked to do never +needs a release. -## Requirements +Ready-made routes for every supported case, including multi-round pull request review, +are in **[docs/routes.md](./docs/routes.md)** and served from `GET /v1/route-templates`. -- Node.js `>= 23.7.0` -- `pnpm` `10.x` -- `git` -- `gh` -- at least one supported agent CLI (`codex`, `gemini`, or `claude`) +## How it fits together -## Local development setup - -```bash -pnpm install -pnpm parallax preflight -pnpm test -pnpm build ``` - -## Install global CLI - -```bash -npm i -g parallax-cli -parallax preflight +Mac Mini Railway +┌──────────────────────────────┐ ┌──────────────────────┐ +│ Hermes gateway │ │ api │ +│ :8642 /p//v1/… │ │ config · registry │ +│ owns git, PRs, identity │ │ run history │ +│ ▲ │ │ Slack │ +│ │ POST /v1/runs │ │ ▲ │ +│ parallax runner │─────►│ │ │ +│ triggers → routes → │ long │ ┌───────┴────────┐ │ +│ dispatch → outcomes │ poll │ │ dashboard │ │ +└──────────────────────────────┘ │ └────────────────┘ │ + └──────────────────────┘ ``` -## First-time setup +**Parallax never runs an agent itself and never touches a repository.** It decides; +Hermes does the work. The runner needs no clone of your code — only API access to your +tracker and HTTP access to Hermes on the same machine. -Run the interactive setup wizard: +The runner only makes outbound connections, so the Mac Mini works behind NAT with no +tunnel and no port forwarding. -```bash -parallax init -``` +## The dashboard + +A web UI for the same thing: watch runs, create routes from templates, manage projects, +keys and Slack. Sign in with a `prx_usr_` key. It deploys to Railway as a second +service alongside the API. -The wizard collects: -- Project ID and path to your local git repository -- Issue source (GitHub or Linear) and filter settings -- AI agent (Claude Code, Codex, or Gemini) -- Slack notifications (optional) -- API secrets (Linear key if needed) +Full detail: **[docs/dashboard.md](./docs/dashboard.md)** -Configuration is stored in `~/.parallax/config.json`. Projects and integrations can also be managed from the dashboard UI. +## Getting started -## Starting Parallax +Full walkthrough: **[docs/getting-started.md](./docs/getting-started.md)** ```bash +# On the Mac Mini, next to Hermes +npm install -g parallax-cli + +parallax init # cloud key, Hermes profiles — each key is probed as you enter it +parallax preflight # Node, Hermes, cloud, gh auth parallax start -parallax open # opens the dashboard in your browser -parallax status # check health + running projects -parallax stop +parallax runner install # survive reboots (launchd) ``` -To access a headless machine's dashboard from a trusted internal network, opt in when starting: +Then: ```bash -parallax start --network-access +parallax agents # profiles it discovered, with models and toolsets +parallax routes # what it will act on +parallax runs # recent runs +parallax logs --follow # watch one happen +parallax cancel # stop it here and on Hermes ``` -Parallax prints the network URL, such as `http://cerebro.local:9372`. Network access is -unauthenticated and allows dashboard users to approve work and modify configuration, so enable it -only on a trusted network. Localhost-only access remains the default. - -## CLI +Debugging a machine, not a workflow: ```bash -parallax --version -parallax init # first-time setup wizard -parallax start [--server-api-port ] [--server-ui-port ] [--concurrency ] [--network-access] -parallax stop -parallax status -parallax open -parallax preflight -parallax pr-review -parallax retry -parallax cancel -parallax logs [--task ] +parallax run --agent product --prompt "Reply with the word ready." ``` -## Slack bot - -Parallax can connect to a Slack workspace using Bolt Socket Mode. When configured, it posts plan-ready notifications with Approve and Reject buttons directly in Slack, posts PR and failure events, and responds to a `/parallax` slash command for retry, cancel, status, and pr-review. Because Socket Mode uses an outbound WebSocket, no public URL is required — it works on localhost and behind NAT. - -Configure Slack during `parallax init` or via the **Integrations** tab in the dashboard. - -See [docs/slack-bot.md](docs/slack-bot.md) for the full setup guide. - -## Dashboard - -The dashboard is accessible at `http://localhost:9372` by default. With -`parallax start --network-access`, it is also available through the host's network name or IP: +## Documentation -- **Tasks** — live task list with plan approval and log streaming -- **Projects** — add, edit, and delete project configurations -- **Integrations** — configure GitHub, Linear, Slack, and API keys +| | | +|---|---| +| [Getting started](./docs/getting-started.md) | Hermes setup, deploy, keys, first route | +| [Routes](./docs/routes.md) | Every supported trigger, match, guard and outcome | +| [Cloud API](./docs/api.md) | Orgs, keys, projects, routes, runs, Slack | +| [Deploying to Railway](./docs/deploy-cloud.md) | Docker build, migrations, env vars | +| [CI and releasing](./docs/releasing.md) | The three workflows, secrets, publishing | +| [CLAUDE.md](./CLAUDE.md) | Architecture, for contributors | -## Runtime behavior +## Requirements -1. Pull eligible tasks from provider filters. -2. Generate plan text and persist it. -3. Wait for explicit plan approval from UI, CLI, or Slack. -4. Execute only approved plan steps. -5. Open/update PR and move task lifecycle state. +- **Hermes Agent** with its API server enabled and `gateway.multiplex_profiles` on, + and a distinct `API_SERVER_KEY` per profile +- **Node.js >= 22.5** — the CLI re-executes itself under a compatible interpreter if + the active one cannot load `node:sqlite` +- **Postgres**, for the control plane +- `gh`, authenticated, if any project pulls from GitHub -## Publish Global CLI (`parallax-cli`) +## Repository layout -Parallax is published as a single global CLI package: +``` +packages/ + common/ shared types — run status, routing rules, config + orchestrator/ the runner: triggers, routes, dispatch, outcomes + cli/ the published parallax-cli package + cloud-api/ Railway control plane (Fastify + Postgres) +``` ```bash -npm i -g parallax-cli +pnpm install +pnpm build +pnpm test ``` - -Releases are published through the manual GitHub Actions workflow: - -- open the `Release parallax-cli` workflow in GitHub Actions -- trigger it with `Run workflow` -- the workflow publishes the exact version already set in [`packages/cli/package.json`](packages/cli/package.json) - -Before triggering the release, update the version in `packages/cli/package.json`. - -Default runtime locations and ports: - -- runtime state: `~/.parallax` -- API: `http://localhost:9371` -- dashboard: `http://localhost:9372` - -## Development - -See [CONTRIBUTING.md](CONTRIBUTING.md). - -## Documentation - -For full user guides, see [docs/README.md](docs/README.md). - -## License - -MIT. See [LICENSE](LICENSE). diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index f84f0af..0000000 --- a/docs/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# Parallax Documentation - -This documentation is for people trying Parallax for the first time and running it locally from the CLI. - -## Documentation map - -- [Getting Started](./getting-started.md): install Parallax, run the setup wizard, and open the dashboard. -- [Configuration Reference](./configuration.md): how Parallax stores config and what each field means. -- [CLI Reference](./cli-reference.md): the day-to-day commands you will actually run. -- [Task Lifecycle](./task-lifecycle.md): how Parallax processes tasks from pull to PR. -- [Slack Bot](./slack-bot.md): connect Parallax to Slack for plan approvals and task notifications. -- [Troubleshooting](./troubleshooting.md): fixes for common setup and runtime problems. - -## What Parallax does - -- Parallax pulls tasks from Linear or GitHub. -- Each task runs in its own local isolated worktree. -- Parallax generates a plan first, then waits for approval before making changes. -- The dashboard is where you review plans, watch logs, retry work, and inspect PR results. -- Local state lives under `~/.parallax`. - -## Recommended first run - -1. Install: `npm i -g parallax-cli` -2. Validate dependencies: `parallax preflight` -3. Run the setup wizard: `parallax init` -4. Start Parallax: `parallax start` -5. Open the dashboard: `parallax open` -6. Check runtime status: `parallax status` diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..3b2d8f9 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,536 @@ +# Cloud API reference + +Base URL is your Railway deployment. Every endpoint below except `/health` needs a +bearer token. + +## Authentication + +Two key scopes, and they are not interchangeable — a runner key presented to a +management endpoint is rejected, and vice versa. That separation is the only thing +standing between an unattended daemon's credential and a human's. + +| Scope | Prefix | Used by | Reaches | +|---|---|---|---| +| `user` | `prx_usr_` | You and the dashboard | `/v1/*` management endpoints | +| `runner` | `prx_rnr_` | The runner on the Mac Mini | `/v1/runner/*` only | + +``` +Authorization: Bearer prx_usr_… +``` + +Keys are stored as SHA-256 hashes. The plaintext is shown once, at creation, and is +not recoverable. + +--- + +## Bootstrap + +The first key cannot come from the API, because minting keys requires one. It comes +from a one-off command run against the deployment: + +Run it inside the deployed container, where `DATABASE_URL` resolves: + +```bash +railway ssh --service api # your service name +# then, in the container: +node dist/org-cli.js --name "Your Company" +node dist/org-cli.js --list +node dist/org-cli.js --org org_abc123 --add-key runner +``` + +Or from a local checkout, against the database's **public** URL — Railway's +`DATABASE_URL` points at an internal host that only resolves inside the platform: + +```bash +pnpm --filter @parallax/common build && pnpm --filter @parallax/cloud-api build +cd packages/cloud +DATABASE_URL="$(railway variables --service Postgres --kv | grep DATABASE_PUBLIC_URL | cut -d= -f2-)" \ + node dist/org-cli.js --name "Your Company" +``` + +After that, manage keys through the API. + +--- + +## Health + +```http +GET /health +``` + +Unauthenticated, because Railway's health check runs before any key exists. + +```json +{ "status": "ok", "version": "0.2.0" } +``` + +--- + +## Identity + +```http +GET /v1/me +``` + +Resolves the presented key to the organization behind it. + +```json +{ + "org": { "id": "org_abc123", "name": "Your Company", "createdAt": "2026-08-01T00:00:00.000Z" }, + "key": { "id": "key_abc123", "name": "dashboard", "prefix": "prx_usr_9f2a41c8", "scope": "user" } +} +``` + +This exists for the dashboard, where a key is the whole of sign-in: it has to be able +to check one *before* storing it, and to show whose organization it opened. Any other +endpoint would answer the "is this key valid" half, but none names the organization, +and picking an arbitrary one to probe with would make an unrelated endpoint's failure +look like a rejected key. + +A runner key here is a `401`, like anywhere else under `/v1/`. + +--- + +## Keys + +```http +GET /v1/keys +POST /v1/keys { "name": "ci", "scope": "runner" | "user" } +DELETE /v1/keys/:id revokes; the row stays for the audit trail +``` + +`POST` responds with the plaintext key. It is never shown again. + +```json +{ "id": "key_…", "key": "prx_rnr_…", "scope": "runner", "prefix": "prx_rnr_a1b2c3d4" } +``` + +--- + +## Projects + +What the runner should watch. A project is a ticket source, nothing more — there is no +local clone and no agent attached to it. + +Projects live here, not in the runner's local config: `parallax init` never writes +them. A runner with no projects polls nothing, so nothing can ever trigger. + +```http +GET /v1/projects +POST /v1/projects +DELETE /v1/projects/:id +``` + +```jsonc +// Linear +{ "id": "taplands", "provider": "linear", "filters": { "team": "ENG" } } + +// GitHub +{ "id": "www", "provider": "github", + "filters": { "owner": "acme", "repo": "www", "state": "open" } } +``` + +`filters` is a coarse pre-filter applied **at the source**, before routing sees +anything. A route can only ever match a ticket a filter let through, which makes an +over-narrow filter the most common reason a correct-looking route never fires. If a +route matches on `labels`, leave `filters.labels` unset and let the route decide. + +--- + +## Routes + +The core abstraction: **when this happens, start that agent, then do this with the +result.** + +Every supported case, with a ready-made route for each, is in +**[routes.md](./routes.md)**. What follows is the wire format. + +```http +GET /v1/routes +POST /v1/routes create, or update by passing an existing id +DELETE /v1/routes/:id +``` + +```jsonc +{ + "id": "rt_product_review", // omit to have one generated + "name": "Product review on feasibility label", + "priority": 100, // highest wins; ties break on id + "enabled": true, + + "guard": { + "refire": "once", // once | per-change + "markers": true // apply parallax:* labels around the run + }, + + "trigger": { + "type": "ticket", // ticket | pr_event | pr_review_requested | manual + "provider": "linear", // optional; omit to match either provider + "projectId": "taplands" + }, + + "match": { // every clause must hold + "labels": { "any": ["feasibility"], "none": ["blocked"] }, + "state": { "any": ["Backlog"] }, + "assignees": { "any": ["acme-bot"] }, + "titleMatches": "^RFC:", // regex against the title + "bodyMatches": "billing", // regex against the description + + // pull requests only + "isDraft": false, + "baseBranch": { "any": ["main"] }, + + // transitions — what changed since the last poll + "labelsAdded": { "any": ["needs-review"] }, + "labelsRemoved": { "any": ["blocked"] }, + "assigneesAdded": { "any": ["acme-bot"] }, + "reviewersAdded": { "any": ["acme-reviewer"] } + }, + + "target": { + "agentRef": { "profile": "product" } + // or, for pr_review_requested: + // "agentRef": { "githubLogin": "acme-reviewer-bot" } + }, + + "execution": { + "prompt": "Review {{ticket.ref}}: {{ticket.title}}\n\n{{ticket.body}}", + "requireApproval": false, // uses Hermes' own approval gate + "modelOverride": null, // null = the profile's own model + "timeoutSeconds": 1800 + }, + + "outcome": { + "postComment": { "target": "ticket" }, // ticket | pr | none + "labels": { "add": ["reviewed"], "remove": ["feasibility"] } + } +} +``` + +### The prompt + +`execution.prompt` is free text stored on the route — there are no built-in +templates to choose between. Rewording what an agent is asked to do is the main thing +you will want to tune, and that should never require a release. + +Placeholders are `{{name}}`: + +| | | +|---|---| +| `ticket.ref` `ticket.title` `ticket.body` | the ticket | +| `ticket.url` `ticket.state` `ticket.labels` | `labels` renders as a comma-separated list | +| `project.id` | the project that produced the trigger | +| `agent.profile` `agent.role` | the agent about to run | +| `pr.number` `pr.reviewers` | populated for pull-request triggers | + +An unrecognized placeholder is **left in the text verbatim** and logged as a warning, +rather than blanked. A typo like `{{ticket.titel}}` silently becoming an empty string +produces a confidently wrong run; leaving it visible makes the mistake obvious in the +transcript. + +Parallax appends its own closing instruction asking for a `PARALLAX_SUMMARY:` line — +that summary is what lands in the ticket comment and the Slack message. If your prompt +already mentions `PARALLAX_SUMMARY`, yours is used as written. + +```http +GET /v1/route-templates complete routes for every supported case +GET /v1/prompt-templates starter prompts and the placeholder list +GET /v1/reserved-labels the parallax:* labels and the default guard +``` + +These are what a dashboard builds its "new route" flow from. `route-templates` returns +whole routes carrying `` tokens for a user to fill in — distinct from the +`{{variables}}` the runner substitutes at dispatch. Every template is verified in CI +against this API's own validator and the prompt renderer, so one that is picked and +filled always produces a route the API accepts. + +Nothing dispatches by template id: changing a catalog never alters an existing route. + +### Pull request routes + +`pr_event` fires for **every open pull request**, every cycle. Use it for anything +keyed on labels, assignees, draft state or base branch. + +`pr_review_requested` fires only when someone is awaiting review, and is the one to +use with `target.agentRef.githubLogin` — it matches the agent that was actually +requested, not merely any agent. + +A pull request produces both events when it has a requested reviewer, so a route must +pick the trigger type it means. + +```jsonc +// "When acme-bot is assigned a PR, have the reviewer agent look at it." +{ + "name": "Review PRs assigned to the bot", + "trigger": { "type": "pr_event", "provider": "github", "projectId": "www" }, + "match": { "assignees": { "any": ["acme-bot"] }, "isDraft": false }, + "target": { "agentRef": { "profile": "reviewer" } }, + "execution": { "prompt": "Review PR #{{pr.number}}: {{ticket.title}}\n\n{{ticket.body}}", + "requireApproval": false, "timeoutSeconds": 900 } +} +``` + +```jsonc +// "When needs-review is ADDED to a PR" — fires on the transition, not on +// every subsequent poll while the label happens to be there. +{ + "name": "Review on label", + "trigger": { "type": "pr_event", "provider": "github", "projectId": "www" }, + "match": { "labelsAdded": { "any": ["needs-review"] } }, + "target": { "agentRef": { "profile": "reviewer" } }, + "execution": { "prompt": "Review PR #{{pr.number}}.", "requireApproval": false, + "timeoutSeconds": 900 } +} +``` + +### A review cycle + +Request review → the agent reviews → you reply and re-request → the agent reviews +again. Match on `reviewersAdded`, which fires on the *act* of requesting, not while a +request is outstanding: + +```jsonc +{ + "name": "Reviewer agent", + "guard": { "refire": "per-change", "markers": true }, + "trigger": { "type": "pr_review_requested", "provider": "github", "projectId": "www" }, + "match": { "reviewersAdded": { "any": ["acme-reviewer"] } }, + "target": { "agentRef": { "githubLogin": "acme-reviewer" } }, + "execution": { + "prompt": "You have been requested as a reviewer on {{ticket.ref}}.\n\nRead it yourself:\n gh pr view {{pr.number}} --repo {{repo.slug}} --json title,body,comments,reviews\n gh pr diff {{pr.number}} --repo {{repo.slug}}\n\nIf you reviewed this before, your earlier comments are in that thread. Read the\nauthor's replies and pick up from there rather than repeating findings that have\nalready been addressed.\n\nLeave your review with `gh pr review`.", + "requireApproval": false, + "timeoutSeconds": 1800 + } +} +``` + +`refire: "per-change"` is required — the default `once` would fire one round and stop. +That is safe here because `reviewersAdded` only matches when a reviewer is newly +requested: the agent posting comments, or you pushing commits, adds no reviewer and so +cannot re-summon it. + +**Parallax does not fetch the diff or the conversation.** The agent has `gh` and gets +them itself, which is why the prompt tells it to. Inlining that context would put +Parallax back in the business of fetching things the agent can already reach — the same +boundary that keeps git, worktrees and pull requests on the Hermes side. + +`{{repo.slug}}` renders as `owner/repo`, so those commands are copy-pasteable. + +### Transitions vs. state + +`labels` asks *does it have this label now*. `labelsAdded` asks *was it just added*. + +Transition clauses need a previous observation, so they **never match the first time +an item is seen**. That is deliberate: without it, creating a route would fire it +across every pull request that already carries the label. A new route starts quiet and +acts on what happens next. + +`labelsRemoved` and `assigneesAdded` work the same way. + +### Not running twice: the loop guard + +An agent acting on a pull request *changes* it — a commit, a review, a comment. If a +route re-fired on every change, it would retrigger itself on its own work. Two +independent mechanisms prevent that. + +**`guard.refire`** — `once` (the default) means a route fires for an item exactly +once, whatever happens to it afterwards; the item's revision is excluded from the +dedupe key entirely. `per-change` restores fire-on-every-change and is only safe with +markers on, which the API enforces. + +**`guard.markers`** — Parallax writes reserved labels around the run: + +| Label | Meaning | +|---|---| +| `parallax:in-progress` | a run is working on this right now | +| `parallax:done` | a run completed | +| `parallax:failed` | a run failed | + +Everything Parallax writes is prefixed `parallax:`, so machine-managed labels are +obvious in the tracker. They are created automatically if the repo or team does not +have them. + +**No route ever matches an item carrying `parallax:in-progress`** — unconditionally, +even for a route that turned markers off. Starting a second agent on something already +being worked on is never what you want. + +A `once` route also declines anything carrying `parallax:done` or `parallax:failed`. +**Removing that label by hand is how you re-arm a route** — which is also how you retry +something that failed. + +`GET /v1/reserved-labels` returns the list and the default guard. + +### Match semantics + +- `any` — at least one present (OR) +- `all` — every one present (AND) +- `none` — not one present (NOR) + +Omitted keys impose no constraint. An explicitly empty array also imposes none, so a +half-filled rule never accidentally matches everything. An unparseable regex fails +closed: that route matches nothing rather than everything. + +### What routes deliberately cannot do + +There is no `workspace` field and no `openPullRequest` outcome. Branches, commits, and +pull requests belong to the agent, which does them under its own identity. Outcomes +cover only what Parallax owns: the summary comment — which must land even when the run +*failed*, so it cannot be delegated to the thing that failed — and tracker labels. + +### Firing once + +Every dispatch is keyed on `(route, trigger ref, trigger revision)`. `revision` is the +ticket's `updatedAt`, so re-observing an unchanged ticket on the next poll does +nothing, while a genuine edit — a new label, a state change — fires the route again. + +For `pr_review_requested`, the requested-reviewer set is folded into the revision, so +adding an agent as a reviewer re-fires even when nothing else about the PR changed. + +--- + +### Reading and editing one + +```http +GET /v1/routes/{id} +PUT /v1/routes/{id} +``` + +`PUT` replaces the route entirely rather than merging fields. A route is a +single decision, and its parts constrain each other — a `githubLogin` target is +valid on a pull request trigger and rejected on a ticket one — so a partial +update could walk a route through states the validator rejects as a whole. The +body is validated as the complete rule it will become. + +The id in the path wins over any id in the body, so a copy-pasted definition +cannot rewrite a different route. `PUT` to an id that does not exist is a `404`, +not an upsert. + +--- + +## Runs + +```http +GET /v1/runs?status=failed&limit=50 +GET /v1/runs/:id +GET /v1/runs/:id/events?since=&limit=500 +POST /v1/runs { "event": { … } } queue a manual dispatch +POST /v1/runs/:id/cancel +POST /v1/resync make the runner reload config +``` + +`POST` endpoints return `202` with a command id. They queue work for the runner, which +picks it up on its next long poll — usually within a second or two, not on a fixed +interval. + +Statuses: `queued`, `running`, `awaiting_approval`, `completed`, `failed`, `canceled`. +`awaiting_approval` still occupies its agent. + +--- + +## Agents and runners + +```http +GET /v1/agents Hermes profiles, as discovered by the runner (incl. avatar_url) +GET /v1/runners registered runners, with health and a `stale` flag +``` + +Agents are derived state, republished wholesale on every inventory push — a profile +deleted in Hermes disappears here rather than lingering. They are read-only through the +API; the source of truth is Hermes itself. + +### Runner health + +```json +{ + "name": "cerebro", + "hostname": "cerebro.local", + "version": "0.2.0", + "started_at": "2026-09-01T20:00:00.000Z", + "last_seen_at": "2026-09-02T08:14:31.000Z", + "hermes_ok": true, + "hermes_detail": "hermes-4-70b", + "active_runs": 1, + "last_error": null, + "stale": false +} +``` + +The runner accepts no inbound connections, so nothing can ask it how it is doing — +health is pushed on every poll cycle, roughly every 25 seconds. `stale` is +`last_seen_at` older than **90 seconds**, which allows three heartbeats to be missed +before anything is reported wrong. + +Three states matter, not two. A runner that is checking in but reports +`hermes_ok: false` will never start anything, and calling that healthy would defeat the +point of the indicator. `hermes_ok: null` means the runner is too old to send a +heartbeat — reporting "unreachable" for "did not say" would be worse than saying +nothing. + +`last_seen_at` is also refreshed by *any* authenticated runner request, so a runner +predating the heartbeat still reads as alive on the strength of its long poll alone. + +> **If every runner reads as stale**, it is running a build from before this existed. +> `last_seen_at` was previously written only by `hello`, which a runner sends once at +> startup — so a runner up for three days reported "last seen 3 days ago", and +> everything older than 90 seconds was stale. Update the runner. + +--- + +## Slack + +```http +GET /v1/integrations/slack +PUT /v1/integrations/slack { "webhookUrl": "https://hooks.slack.com/services/…" } +DELETE /v1/integrations/slack +``` + +`GET` reports *that* a webhook is configured, never what it is. + +Events: `run.started`, `run.completed`, `run.failed`, `run.needs_approval`, +`run.canceled`, `runner.stale`. Restrict them by passing `events` to `PUT`. + +An agent with an `avatarUrl` has its image rendered **inside** the message, as a Block +Kit accessory. The Slack app's own name and icon are never overridden — the webhook's +identity belongs to the app, not to whichever agent happens to be running. + +Notifications are sent cloud-side rather than by the runner for one reason worth +knowing: only the cloud can report `runner.stale` when the Mac Mini drops off the +network. Delivery is deduplicated on `(run, event)`, so a retry can never double-post. + +--- + +## Runner endpoints + +Documented for completeness. The runner calls these; you should not need to. + +```http +POST /v1/runner/hello register; resets started_at +POST /v1/runner/heartbeat periodic health, once per poll cycle +PUT /v1/runner/inventory publish discovered agents +GET /v1/runner/projects pull ticket sources to watch +GET /v1/runner/routes pull enabled routes +GET /v1/runner/commands?cursor=&wait= long poll, up to 30s +POST /v1/runner/commands/ack +POST /v1/runner/runs mirror a new run +PATCH /v1/runner/runs/:id mirror a status change +POST /v1/runner/runs/:id/events mirror log events +``` + +`GET /v1/runner/commands` is held open until something arrives or the window closes. +An empty array is the normal, healthy result — not an error. This is how a runner +behind NAT receives work without any inbound connection. + +--- + +## Errors + +```json +{ "error": "execution.prompt is required." } +``` + +| Status | Means | +|---|---| +| 400 | Malformed body; the message names the field | +| 401 | Missing, revoked, or wrong-scope key | +| 404 | Not found in your organization | +| 409 | Out of order — e.g. inventory pushed before `hello` | diff --git a/docs/cli-reference.md b/docs/cli-reference.md deleted file mode 100644 index ccefe56..0000000 --- a/docs/cli-reference.md +++ /dev/null @@ -1,174 +0,0 @@ -# CLI Reference - -These are the commands most users will use day to day. - -## Global usage - -```bash -parallax --version -parallax --help -``` - -## parallax init - -Run the interactive setup wizard to configure Parallax for the first time or add another project. - -```bash -parallax init -``` - -The wizard covers: project ID, workspace directory, issue source (GitHub or Linear), agent selection, optional secrets, and optional Slack configuration. All settings are saved to `~/.parallax/config.json`. - -If a config already exists, the wizard offers to add another project, open the dashboard, or exit. - -## parallax preflight - -Validate local prerequisites before first run. - -```bash -parallax preflight -``` - -Notes: - -- no flags accepted -- returns non-zero exit code when required checks fail -- prints a final verdict (`PASS` or `FAIL`) - -## parallax status - -Check whether the current Parallax runtime is healthy. - -```bash -parallax status -``` - -Notes: - -- no flags accepted -- prints a clear message when Parallax is not running -- shows orchestrator PID, dashboard URL, and configured projects when healthy -- prints orchestrator diagnostics when the runtime has issues - -## parallax tasks - -List the 20 most recent tasks with their current status, AI adapter, and model. - -```bash -parallax tasks -``` - -Outputs a table with the following columns: - -| Column | Description | -|--------|-------------| -| TASK ID | External issue ID (e.g. `PROJ-42`) or internal task ID if no external ID is set | -| NAME | Task title, truncated to 50 characters | -| ADAPTER | Agent provider (`claude-code`, `codex`, `gemini`) | -| MODEL | Model configured for the project (`—` if unset) | -| STATUS | Color-coded: green=done, cyan=running, yellow=queued, red=failed, dim=canceled | - -Notes: - -- no flags accepted -- requires Parallax to be running (`parallax start`) -- shows tasks sorted by most recently created, newest first - -## parallax start - -Start orchestrator and dashboard in background. - -```bash -parallax start [--server-api-port ] [--server-ui-port ] [--concurrency ] [--network-access] -``` - -`parallax start` reads project and secret configuration from `~/.parallax/config.json`. -If no projects are configured, it exits with an error: run `parallax init` first. - -Examples: - -```bash -parallax start -parallax start --server-api-port 9371 --server-ui-port 9372 --concurrency 2 -parallax start --network-access -``` - -`--network-access` binds the dashboard and API to all network interfaces and prints a LAN URL such -as `http://cerebro.local:9372`. The default remains localhost-only. Network mode is unauthenticated -and should only be used on a trusted internal network. - -## parallax stop - -Stop background processes recorded in the running manifest. - -```bash -parallax stop -``` - -## parallax open - -Open the dashboard in your default browser. - -```bash -parallax open -``` - -Reads the UI port from `~/.parallax/running.json`. Prints the URL if the orchestrator is not running. - -## parallax retry - -Queue a retry for a task. - -```bash -parallax retry -``` - -## parallax cancel - -Cancel a pending or running task. - -```bash -parallax cancel -``` - -## parallax pr-review - -Experimental on-demand trigger for applying open human PR review comments to an existing PR branch. - -```bash -parallax pr-review -``` - -Notes: - -- prints a prominent experimental warning before triggering -- uses the existing task/project context to locate the repo and branch -- fails unless the task already has a related open PR -- ignores automated/bot review comments -- attempts to resolve the fetched review threads after a successful push - -## parallax logs - -Tail new logs from orchestrator API starting from when the command begins. - -```bash -parallax logs [--task ] -``` - -Examples: - -```bash -parallax logs -parallax logs --task 3ed59f6e7cea -``` - -## Runtime files - -Parallax stores runtime state in `~/.parallax`. - -Common files: - -- `config.json`: project and integration configuration (managed by `parallax init` and the dashboard) -- `running.json`: process manifest (`orchestratorPid`, `uiPid`, ports, start timestamp) -- `running.json` also records whether the current runtime enabled network access -- `parallax.db`: SQLite state database diff --git a/docs/configuration.md b/docs/configuration.md deleted file mode 100644 index 4bd6212..0000000 --- a/docs/configuration.md +++ /dev/null @@ -1,99 +0,0 @@ -# Configuration Reference - -Parallax stores all configuration in `~/.parallax/config.json`. You do not edit this file manually — use `parallax init` or the dashboard to manage it. - -## config.json structure - -```json -{ - "version": 1, - "projects": [...], - "slack": null, - "secrets": { "LINEAR_API_KEY": "..." }, - "updatedAt": 1716300000000 -} -``` - -### projects - -Array of project entries. Each project maps to one repository and one issue source. - -#### Minimal project - -```json -{ - "id": "my-app", - "workspaceDir": "/absolute/path/to/repo", - "pullFrom": { - "provider": "github", - "filters": { - "owner": "myorg", - "repo": "my-app", - "state": "open" - } - }, - "agent": { - "provider": "claude-code" - } -} -``` - -#### Project fields - -**id** (required) — unique identifier across all projects, no spaces. - -**workspaceDir** (required) — absolute path to a local git repository. Must contain `.git/`. - -**pullFrom.provider** (required) — `github` or `linear`. - -**pullFrom.filters** — provider-specific: - -- GitHub: `owner` (required), `repo` (required), `state`, `labels` -- Linear: `team` (required), `labels`, `state` - -**agent.provider** (required) — `claude-code`, `codex`, or `gemini`. - -**agent.model** (optional) — pin a specific model version. Omit to use the provider default. - -### slack - -Slack bot configuration, or `null` if not configured. - -```json -{ - "botToken": "xoxb-...", - "appToken": "xapp-...", - "channel": "#eng-ai" -} -``` - -Managed from the **Integrations → Slack** tab in the dashboard or during `parallax init`. - -See [Slack Bot](./slack-bot.md) for the full setup guide. - -### secrets - -Key-value map of environment variables injected into the orchestrator process at startup. Agent processes inherit them automatically. - -```json -{ - "LINEAR_API_KEY": "lin_api_...", - "SOME_OTHER_KEY": "value" -} -``` - -Managed from the **Integrations** tab in the dashboard. Values are masked in the UI (`•••••••`) and never returned by the API. - -Common secrets: - -- `LINEAR_API_KEY` — required if any project uses Linear as the issue provider - -## Managing configuration - -| Where | What you can do | -|---|---| -| `parallax init` | First-time setup wizard; add a project | -| Dashboard → Projects | Add, edit, delete projects | -| Dashboard → Integrations | Configure GitHub, Linear, Slack, and API keys | - -Changes made in the dashboard take effect immediately without restarting Parallax. diff --git a/docs/dashboard.md b/docs/dashboard.md new file mode 100644 index 0000000..c87d064 --- /dev/null +++ b/docs/dashboard.md @@ -0,0 +1,251 @@ +# The dashboard + +`packages/cloud-dashboard` is a React app over the cloud user API. It is where you +watch runs, create routes, and manage projects, keys and Slack — the same things +`GET /v1/*` exposes, without curl. + +It is a **pure client**. It holds no server-side session, has no database of its own, +and every request it makes is one an operator could make by hand. Everything it can do +is something a `prx_usr_` key can do. + +## Signing in + +Sign-in is a user key, pasted once and kept in `localStorage`. + +The key is verified against `GET /v1/me` *before* it is stored, so a bad key fails at +the login form with a message rather than being kept and failing on every screen after +it. A stored key is re-verified on each load, because it may have been revoked since +the last visit. Any `401` from any screen ends the session and returns to the login +form — the key is gone, and showing six copies of the same error would be both noisier +and wrong. + +> **What this is, and what it is not.** `localStorage` means the key is readable by +> anything with script access to this origin, and it persists until you sign out. +> That is acceptable for v1 because the same key is already sitting in config files on +> operator machines, and the dashboard is not yet multi-user. It is not a substitute +> for real accounts. The upgrade is a server-side session behind an httpOnly cookie, +> and it belongs with the work that introduces users — not before it. + +A runner key pasted here is rejected with a `401`, because the API refuses runner scope +on user routes. The login copy names that case, since pasting the wrong one of two +similar-looking keys is the likeliest mistake. + +## The API URL + +The dashboard reads its API URL **at runtime**, from `PARALLAX_API_URL`. + +`server.mjs` generates `/env.js` per request, and the page loads it before the bundle: + +```js +window.__PARALLAX__ = { apiUrl: 'https://api-production-xxxx.up.railway.app' } +``` + +A `VITE_` variable would have been inlined by the bundler, which would mean rebuilding +and redeploying the image every time the control plane moved. This way it is a Railway +variable and a restart. + +`GET /health` on the dashboard reports whether one is set: + +```json +{ "status": "ok", "apiConfigured": true } +``` + +It reports `apiConfigured: false` rather than failing, because a container that cannot +serve its own pages is the outage worth restarting for; a missing API URL is fixed by +editing a variable. The deploy workflow turns that into a warning, and the login screen +says so in place. + +The API must allow the dashboard's origin. `CORS_ORIGINS` on the cloud-api service is +a comma-separated allowlist, and unset means all. + +## Local development + +```bash +pnpm install +PARALLAX_API_URL=http://127.0.0.1:8080 pnpm --filter @parallax/cloud-dashboard dev +``` + +Vite serves `/env.js` itself in dev, from the same variable, so dev and production +resolve the URL through one code path rather than two that can disagree. + +To run the whole stack locally, with the control plane against a throwaway Postgres: + +```bash +docker run -d --name parallax-pg -e POSTGRES_PASSWORD=parallax \ + -e POSTGRES_DB=parallax -p 55433:5432 postgres:16 + +export DATABASE_URL="postgres://postgres:parallax@127.0.0.1:55433/parallax" +export DATABASE_SSL=disable + +pnpm --filter @parallax/cloud-api build +node packages/cloud-api/dist/migrate-cli.js +node packages/cloud-api/dist/org-cli.js --name "Your Company" # prints both keys + +PORT=8080 node packages/cloud-api/dist/index.js +``` + +Then sign in with the `prx_usr_` key it printed. + +To exercise the production server rather than Vite's: + +```bash +pnpm --filter @parallax/cloud-dashboard build +PORT=8081 PARALLAX_API_URL=http://127.0.0.1:8080 \ + node packages/cloud-dashboard/server.mjs +``` + +## Deploying + +The dashboard is a **second Railway service**, alongside `api`. + +### One-time setup + +1. Create a service named `dashboard` in the same Railway project. +2. Apply the project configuration, which tells that service to build + `Dockerfile.dashboard` rather than the control plane's: + + ```bash + pnpm railway:plan # preview; changes nothing + pnpm railway:apply + ``` + + This step is load-bearing. A service with no configuration of its own falls back + to Railway's own detection, and previously to a root `railway.json` — which is how + a dashboard service ends up building and deploying the *API* image, starting + cleanly, and passing its health check while serving the wrong thing. +3. Under **Variables**, set `PARALLAX_API_URL` to the API service's public URL. +4. Generate a domain for the service. +5. On the **api** service, add the dashboard's origin to `CORS_ORIGINS` if you have + narrowed it from the default. + +### Deploying + +Actions → **Deploy dashboard** → *Run workflow*, which lets you pick the branch; or +automatically on a push to `main` touching `packages/cloud-dashboard/**`, +`Dockerfile.dashboard` or `.railway/railway.ts`. + +By hand: + +```bash +pnpm railway:deploy:dashboard +``` + +The image is built from `Dockerfile.dashboard` at the repo root, with the root as +build context because this is a pnpm workspace. + +> **`railway up` deploys source; it does not reconcile configuration.** After changing +> `.railway/railway.ts`, run `pnpm railway:apply` — otherwise the service keeps +> building whatever it was last told to. + +## Why a server at all + +The runtime image is a static bundle plus one script and **no `node_modules`** — +`server.mjs` is deliberately dependency-free. It exists for three things a bucket +cannot do: + +- **`/env.js`**, so the API URL is a variable rather than a rebuild. +- **SPA fallback.** A request matching no file serves `index.html`, so `/runs/run_1` + survives a reload or a shared link. +- **`/health`**, for Railway's check. + +It also gets the caching right in the one way that matters: hashed assets under +`/assets/` are immutable for a year, and `index.html` never is — cache that and a +deploy reaches nobody still holding the previous one. + +## Creating and editing + +Every screen that creates something does it on **its own page**, reached from a button +in the top right of the list: `/routes/new`, `/projects/new`, `/keys/new`. Routes can +also be edited, at `/routes/:id/edit`. + +The button is why the action slot in the page header is always rendered, even empty. A +slot that appears only on pages with a button shifts the header — and everything under +it — by a button's height as you move between sections. + +### The route form + +Creating starts from a template, because the combinations that actually fire are a +small subset of what the schema permits, and the catalog is that subset. The form then +asks only for what the template declares, and asks for it with the right control: + +| Template placeholder | Control | +|---|---| +| `` | a dropdown of registered projects | +| `` | a dropdown of discovered agents | +| `` | filled from the selected agent | +| anything else | a text field | + +Free text where the API already knows the answer produces a route that is structurally +valid and can never match — a typo'd project id fires nothing, silently, forever. +Selecting the agent is also what fills a `githubLogin` target, so the two cannot drift +apart the way two independent fields would. + +Under the prompt is the list of variables the runner substitutes at dispatch. Clicking +one inserts it at the cursor. This matters more than it looks: the runner deliberately +leaves an unrecognised `{{placeholder}}` visible rather than blanking it, so a typo +cannot become a confidently wrong run — but that only helps if the writer knows which +names are real. + +### What editing changes, and what it keeps + +The form owns name, project, agent, priority, enabled, timeout and prompt. Everything +else — the match clauses, the guard that stops a route re-firing on the agent's own +work, the outcome — is written back untouched, and shown read-only under *show +definition* so it is not a surprise. + +That separation is the point. Renaming a route must never quietly drop its loop guard, +and a form that rebuilt the rule from its own fields would do exactly that. + +`PUT /v1/routes/:id` replaces the whole rule and revalidates it, so a route can never +be saved into a state the runner would reject. + +## What it does not do + +- **No agent management.** Agents are Hermes profiles discovered on the runner's + machine, not records anyone creates here. The next inventory push would overwrite + anything the dashboard wrote. +- **No members, roles or billing.** There is no users table. An organization is a row + and a set of keys; anyone holding a `prx_usr_` key has the same access. +- **No renaming an organization.** Its name is set by `org-cli.js` at creation and + there is no endpoint to change it. + +## Design system + +The UI is built on [`@16-bits-design/ui`](https://github.com/maxigimenez/16-bits-design), +which is the component library for this visual language — square geometry, 2px borders, +offset shadows, JetBrains Mono body and Silkscreen display type, on the `ember` theme. + +Application code uses `--bits-*` semantic variables and never hardcodes a colour, so +the whole dashboard follows the theme. `src/styles.css` holds layout only: the shell, +the tables, and the states the library does not ship yet — alerts, empty states, +loading, segmented filters and code blocks. Those gaps are filed as issues on the +library, and each local implementation is a candidate to delete when its component +lands. + +### The `.px-root` prefix, and why it is not decoration + +The library styles bare elements — `.bits-theme a`, `.bits-theme h1`, `.bits-theme h2`, +`.bits-theme p`, `.bits-theme code` — at specificity **(0,1,1)**. An application class +on one of those elements is **(0,1,0)** and loses *silently*: no error, no warning, just +the library's defaults. + +This is not hypothetical. Every breadcrumb and every idle sidebar link rendered +`--bits-primary` orange instead of muted grey, and every section heading rendered as +24px display type instead of an 11px label, for exactly this reason. It survived +review because the result looks deliberate — an all-orange nav reads as a styling +choice until you hold it next to the design. + +So any rule whose class lands on an element the library styles is written +`.px-root .px-thing`, reaching (0,2,0) without `!important`. Rules targeting a `div`, +`span`, `table` or `pre` need no prefix and do not have one. + +`test/specificity.test.ts` enforces it: it reads the library's stylesheet to learn +which elements are styled bare, scans the JSX for `px-` classes on those elements, and +fails if the matching rule is unscoped. Nothing in TypeScript, ESLint or the build can +catch this, so it is a test. + +One layout note worth keeping, because it is easy to reintroduce: `ThemeProvider` +renders a real `div` between `#root` and the app. `.px-root` gives that element a +height, and `.px-shell` is anchored to `100dvh` rather than a percentage — a height +inherited through flex-grow is used but not *definite*, so percentage heights below it +fall back to auto and every column collapses to its content. diff --git a/docs/deploy-cloud.md b/docs/deploy-cloud.md new file mode 100644 index 0000000..9c1815e --- /dev/null +++ b/docs/deploy-cloud.md @@ -0,0 +1,267 @@ +# Deploying the control plane to Railway + +`packages/cloud-api` is a Fastify service over Postgres. It ships as a Docker image built +from the repo root, because this is a pnpm workspace and `@parallax/common` is a +workspace dependency. + +The `Dockerfile` lives at the **repo root** rather than beside the package it builds, +so that the build context is the workspace root and `./Dockerfile` is what Railway +would find even with no configuration at all. Which builder and which Dockerfile each +service uses is stated explicitly in `.railway/railway.ts`; the root placement is the +belt to that braces, and it is why a misconfigured service fails with *"No start +command detected"* rather than building something plausible and wrong. + +## What you need + +- A Railway project with a Postgres database (you have this) +- The Railway CLI: `npm i -g @railway/cli`, then `railway login` + +## The two database URLs + +Railway's Postgres exposes two connection strings, and picking the wrong one is the +most common way to lose an afternoon here: + +| Variable | Host | Reachable from | +|---|---|---| +| `DATABASE_URL` | `postgres.railway.internal` | **Only inside Railway.** This is what the deployed service uses. | +| `DATABASE_PUBLIC_URL` | `*.proxy.rlwy.net:` | Anywhere, including your laptop. | + +So: the service gets `DATABASE_URL`; anything you run locally against that same +database uses `DATABASE_PUBLIC_URL`. + +## 0. Work against the Railway database from your laptop + +Useful before you deploy anything — it applies the schema and creates your keys, so +the service has something to serve the moment it comes up. + +**Link at the repo root, and stay there.** The Railway CLI scopes a link to the +directory you ran it in, and deploys must happen from the root anyway (that is the +Docker build context). Linking inside `packages/cloud-api` leaves the root unlinked, and +`railway add` there fails with *"No linked project found"*. + +```bash +cd /path/to/parallax-cli # repo root — do everything from here + +pnpm install +pnpm --filter @parallax/common build +pnpm --filter @parallax/cloud-api build + +railway link # once, at the root; pick your project +``` + +Point at the database over its public URL and apply the schema. Both scripts resolve +their own paths, so running them from the root is fine: + +```bash +export DATABASE_URL="$(railway variables --service Postgres --kv \ + | grep '^DATABASE_PUBLIC_URL=' | cut -d= -f2-)" + +node packages/cloud-api/dist/migrate-cli.js # apply the schema +node packages/cloud-api/dist/org-cli.js --name "Your Company" # your two keys, once +``` + +You can also run the whole service locally against that database: + +```bash +PORT=8080 node packages/cloud-api/dist/index.js +curl http://127.0.0.1:8080/health +``` + +TLS is on by default with verification relaxed, which is what the Railway proxy needs. +Only set `DATABASE_SSL=disable` for a plain local Postgres. + +If `railway variables` prints nothing, your Postgres service is named something other +than `Postgres`. List services with `railway status`, or copy `DATABASE_PUBLIC_URL` +out of the dashboard and `export DATABASE_URL=...` by hand. + +## 1. Create the service + +`railway up` deploys straight from your working directory — no git remote required, +which is convenient while this still lives on a branch: + +If you already created a service in the dashboard, skip this — `railway add` would +make a second one. + +```bash +# Still at the repo root, still linked from step 0. +railway add --service api +``` + +Every `--service` below must match your service's real name. `railway status` lists +them. + +### Project configuration + +Both services are declared in **`.railway/railway.ts`** — Railway's Infrastructure as +Code. It replaced the per-service `railway.json` files: Config as Code is deprecated, +services could no longer opt in from 2026-08-28, and it retires on 2026-12-01. + +```ts +const api = service('api', { + build: { builder: 'DOCKERFILE', dockerfilePath: 'Dockerfile' }, + deploy: { + startCommand: 'node dist/index.js', + preDeployCommand: ['node dist/migrate-cli.js'], + healthcheckPath: '/health', + restartPolicyType: 'ON_FAILURE', + }, + env: { DATABASE_URL: preserve() }, +}) +``` + +The image sets `WORKDIR /app/packages/cloud-api`, so both commands are relative to that. +`preserve()` keeps a value already set on Railway rather than writing a credential into +source. + +```bash +pnpm railway:plan # preview; changes nothing +pnpm railway:apply # reconcile the project with the file +``` + +**`railway config apply` and `railway up` are different verbs.** `apply` reconciles +configuration — builders, commands, health checks. `up` uploads source and deploys. +Changing `.railway/railway.ts` and running only `up` leaves the service building +whatever it was last told to. + +A single file for the whole project is also what makes the classic failure +unrepresentable: with no root `railway.json`, a newly created service cannot inherit +another service's builder and silently deploy the wrong image. + +## 2. Attach Postgres + +In the service's Variables tab, reference the database: + +``` +DATABASE_URL = ${{Postgres.DATABASE_URL}} +``` + +That is the only required variable. Optional ones: + +| Variable | Default | Purpose | +|---|---|---| +| `PORT` | injected by Railway | Listen port | +| `CORS_ORIGINS` | all | Comma-separated allowlist, for when the dashboard exists | +| `LOG_LEVEL` | `info` | Fastify log level | +| `DATABASE_POOL_MAX` | `10` | Pool size | +| `DATABASE_SSL` | TLS on | Set to `disable` only for a local Postgres | + +Railway's managed Postgres presents a certificate the default Node agent will not +verify, so the connection is TLS with verification off. `DATABASE_SSL=disable` turns +TLS off entirely and is only appropriate locally. + +## 3. Deploy + +Once the service exists, **[CI can deploy for you](./releasing.md#deploying-cloud-api)** +— add a `RAILWAY_TOKEN` secret and run the *Deploy cloud-api* workflow. To do it by +hand: + +```bash +pnpm railway:deploy:api +railway domain --service api # generate a public URL +``` + +The pre-deploy command applies migrations before the new container takes traffic, so a +deploy is ordered and repeatable. Migrations are plain `.sql` files in +`packages/cloud-api/src/migrations`, applied once each in filename order, one transaction +per file. Running them from your laptop first (step 0) is harmless — they are recorded +in `schema_migrations` and skipped on the next run. + +Watch it come up with `railway logs --service api`. + +Confirm: + +```bash +curl https://.up.railway.app/health +# {"status":"ok","version":"0.2.0"} +``` + +`/health` is unauthenticated on purpose: the health check has to pass before any key +exists. + +## 4. Create your organization + +**Skip this if you did step 0** — the org and both keys already exist. Creating a +second org would give you a second, unrelated set of keys. + +If you deployed first and want to bootstrap from inside the container: + +```bash +railway ssh --service api +# then, in the container: +node dist/org-cli.js --name "Your Company" +``` + +Prints a user key and a runner key, once. Check what already exists with +`node dist/org-cli.js --list`. + +## Reading a failed build + +Railway truncates build output in the deploy summary. The real error — an exit code, +an OOM kill — is in the full log: + +```bash +railway logs --service api --build +``` + +The image is built in three stages: a `prod-deps` stage that resolves runtime +dependencies, a `build` stage that compiles TypeScript, and a slim runtime that copies +`dist` plus the production `node_modules`. Each install runs once on a clean tree. +An earlier version installed everything, built, then re-ran `pnpm install --prod` over +the top; that pass has to tear down and rebuild `node_modules`, and it was the step +that failed on Railway while succeeding locally. + +To reproduce a Railway build exactly, match its architecture: + +```bash +docker build --platform linux/amd64 -t parallax-cloud . +``` + +## Adding a migration + +Add `packages/cloud-api/src/migrations/002_whatever.sql`. Nothing else — the migrator +picks up any `.sql` file it has not already applied and records it in +`schema_migrations`. + +Migrations only run forward. There is no down path, so write additive changes: a +deploy that fails mid-rollout should leave the old container able to serve. + +## The dashboard service + +`packages/cloud-dashboard` deploys to the **same Railway project as a second service**, +built from `Dockerfile.dashboard` rather than `Dockerfile`. + +Its builder and Dockerfile come from `.railway/railway.ts`, alongside the API's: + +```ts +const dashboard = service('dashboard', { + build: { builder: 'DOCKERFILE', dockerfilePath: 'Dockerfile.dashboard' }, + deploy: { startCommand: 'node server.mjs', healthcheckPath: '/health' }, + env: { PARALLAX_API_URL: preserve() }, +}) +``` + +Run `pnpm railway:apply` after creating the service. Skip it and the service has no +configuration of its own, which is how a dashboard service ends up deploying the +control plane image — starting cleanly, passing its health check, and serving the +wrong thing. + +Its only required variable is `PARALLAX_API_URL`, pointing at this service's public +URL. It is read at runtime rather than baked into the bundle, so moving the API is a +variable change and a restart. + +If you have narrowed `CORS_ORIGINS` on this service from its default, add the +dashboard's origin to it — otherwise every request the browser makes is blocked +before it arrives, and the dashboard reports the API as unreachable. + +Full detail: [dashboard.md](./dashboard.md). + +## Notes + +- **The runner never accepts inbound connections.** It long-polls + `/v1/runner/commands`, so the Mac Mini works behind NAT with no tunnel and no port + forwarding. Nothing needs to reach it. +- **The service is stateless.** All state is in Postgres; scaling to more than one + instance is safe, though at one runner per org there is no reason to. +- **Long polls hold a connection for up to 30 seconds.** That is one held connection + per runner, which is why `DATABASE_POOL_MAX` does not need raising for the poll + itself — the poll sleeps between cheap queries rather than holding a transaction. diff --git a/docs/getting-started.md b/docs/getting-started.md index f4d0b0b..97f9973 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,132 +1,316 @@ -# Getting Started +# Getting started -Parallax runs as a local service on your machine. Run the setup wizard once, then use the dashboard to review plans and task output. +End to end: deploy the control plane, create an org and keys, install the runner on +the Mac Mini, and fire your first agent from a ticket label. -## 1. Install +Three things are involved: -Requirements: +| Piece | Where it runs | What it does | +|---|---|---| +| `@parallax/cloud-api` | Railway | Stores config, the agent registry, and run history. Sends Slack notifications. | +| `parallax` runner | The Mac Mini, next to Hermes | Watches tickets/PRs, decides which agent to start, starts it, records what happened. | +| Hermes | The Mac Mini | Runs the agents. Owns git, worktrees, credentials, and pull requests. | -- Node.js `>= 23.7.0` +The runner never executes an agent itself and never touches a repository. It decides +*when*, *which agent*, and *with what context* — Hermes does the work. -Install Parallax globally: +--- + +## 1. Prepare Hermes + +On the Mac Mini, with Hermes already installed. + +**Enable the API server.** In `~/.hermes/.env`: ```bash -npm i -g parallax-cli +API_SERVER_ENABLED=true +API_SERVER_KEY= +API_SERVER_PORT=8642 ``` -Confirm command is available: +**Serve every profile from one gateway:** ```bash -parallax --version +hermes config set gateway.multiplex_profiles true ``` -## 2. Verify local prerequisites - -Run: +**Give every profile its own key.** This is not optional. Under +`multiplex_profiles`, the default profile's key is *rejected* on `/p//…` +routes, so a shared key fails closed. For each profile, in +`~/.hermes/profiles//.env`: ```bash -parallax preflight +API_SERVER_KEY= ``` -`preflight` checks the tools Parallax needs before you start: +**Restart and verify:** -- Node.js version (`>= 23.7.0`) -- `git` CLI -- `pnpm` CLI -- `gh` CLI -- `gh auth status` -- `codex` CLI (optional) -- `gemini` CLI (optional) -- `claude` CLI (optional) -- at least one agent CLI is available +```bash +hermes gateway restart +hermes profile list + +# The default profile (unprefixed): +curl -H "Authorization: Bearer $DEFAULT_KEY" http://127.0.0.1:8642/v1/capabilities -If a required check fails, fix it before moving on. +# A named profile (prefixed, its own key): +curl -H "Authorization: Bearer $PRODUCT_KEY" \ + http://127.0.0.1:8642/p/product/v1/capabilities +``` -## 3. Authenticate the tools Parallax depends on +Both must return JSON with `"platform": "hermes-agent"`. If the second returns 401, +that profile's `.env` key is missing or the gateway was not restarted. -GitHub CLI: +**Make sure each profile can reach the repo it should work on.** Because agents do +their own git and open their own PRs, a profile needs a working directory and its own +git/GitHub credentials. Check with: ```bash -gh auth login -gh auth status +curl -XPOST -H "Authorization: Bearer $PRODUCT_KEY" \ + -H 'content-type: application/json' \ + -d '{"input":"run: pwd && git remote -v && gh auth status"}' \ + http://127.0.0.1:8642/p/product/v1/runs ``` -If you plan to use Linear, have your API key ready — the setup wizard will ask for it. +Set a profile's working directory with `hermes config set terminal.cwd /path/to/repo` +under that profile, or use `hermes project` for multi-folder workspaces. This is only +needed for routes whose agents write code; analysis and review routes do not need it. + +--- -## 4. Run the setup wizard +## 2. Deploy the control plane + +See [deploy-cloud.md](./deploy-cloud.md). In short: point Railway at this repo (the +root `Dockerfile` is auto-detected), attach your Postgres, deploy. + +--- + +## 3. Create your organization and keys + +Run once, inside the deployed container: ```bash -parallax init +railway ssh --service api # your service name +# then: +node dist/org-cli.js --name "Your Company" +``` + +It prints two keys, once: + ``` + user key: prx_usr_… the management API (routes, projects, Slack) + runner key: prx_rnr_… goes on the Mac Mini +``` + +Neither is recoverable. Store them now. + +--- + +## 4. Configure what should happen -The wizard walks through: +Three shapes of route cover most of what you will want: -1. **Project ID** — a short identifier (e.g. `my-app`) -2. **Workspace directory** — absolute path to your local git repository -3. **Issue source** — GitHub Issues or Linear, with owner/repo or team filter -4. **Label filter** — optional, to narrow which issues Parallax picks up (e.g. `ai-ready`) -5. **AI agent** — Claude Code, OpenAI Codex, or Google Gemini -6. **Model override** — optional, to pin a specific model version -7. **Secrets** — Linear API key if you selected Linear and it is not already stored -8. **Slack notifications** — optional, configures bot/app tokens and a notification channel +| You want | `trigger.type` | `match` | +|---|---|---| +| a ticket gets a label | `ticket` | `labels` or `labelsAdded` | +| a PR is assigned to someone | `pr_event` | `assignees` or `assigneesAdded` | +| an agent is asked to review a PR | `pr_review_requested` | target by `githubLogin` | -Configuration is saved to `~/.parallax/config.json`. You can manage projects and integrations later from the dashboard. +Routes fire **once per item** by default and mark their work with `parallax:` labels, +so an agent's own commits and comments cannot retrigger the route that started them. -## 5. Start Parallax +**[routes.md](./routes.md) is the full reference** — every trigger, every match clause, +the loop guard, and a ready-made route for each supported case. You can also fetch +those directly: ```bash -parallax start +curl -sS $CLOUD/v1/route-templates -H "Authorization: Bearer $USER_KEY" ``` -What this does: -- launches the background orchestrator and dashboard -- reads projects and secrets from `~/.parallax/config.json` +Using the **user key**, against your Railway URL. Full reference in [api.md](./api.md). + +Register the project the runner should watch: -### Optional: access a headless machine over the local network +```bash +curl -X POST "$CLOUD/v1/projects" \ + -H "Authorization: Bearer $USER_KEY" -H 'content-type: application/json' \ + -d '{ + "id": "taplands", + "provider": "linear", + "filters": { "team": "ENG" } + }' +``` -On a trusted internal network, start Parallax with: +Create a route — *when this happens, start that agent*: ```bash -parallax start --network-access +curl -X POST "$CLOUD/v1/routes" \ + -H "Authorization: Bearer $USER_KEY" -H 'content-type: application/json' \ + -d '{ + "name": "Product review on feasibility label", + "priority": 100, + "enabled": true, + "trigger": { "type": "ticket", "provider": "linear", "projectId": "taplands" }, + "match": { "labels": { "any": ["feasibility"] } }, + "target": { "agentRef": { "profile": "product" } }, + "execution": { + "prompt": "Assess {{ticket.ref}} for feasibility.\n\nTitle: {{ticket.title}}\n\n{{ticket.body}}\n\nDo not write code.", + "requireApproval": false, + "timeoutSeconds": 1800 + }, + "outcome": { + "postComment": { "target": "ticket" }, + "labels": { "add": ["reviewed"], "remove": ["feasibility"] } + } + }' ``` -The startup output includes a network dashboard URL, for example: +Optionally, get visibility in Slack — create an +[incoming webhook](https://api.slack.com/messaging/webhooks), then: -```text -http://cerebro.local:9372 +```bash +curl -X PUT "$CLOUD/v1/integrations/slack" \ + -H "Authorization: Bearer $USER_KEY" -H 'content-type: application/json' \ + -d '{ "webhookUrl": "https://hooks.slack.com/services/..." }' ``` -You can also use the machine's LAN IP address. This mode has no authentication: anyone who can -reach it can approve tasks and modify Parallax configuration and secrets. Without -`--network-access`, both the dashboard and API remain bound to localhost. +--- + +## 5. Install the runner on the Mac Mini -## 6. Open the dashboard +This rewrite is not published to npm yet, so install from a checkout on the Mac Mini: ```bash -parallax open +git clone && cd parallax-cli +pnpm install +pnpm build +npm install -g ./packages/cli + +parallax init ``` -Or open `http://localhost:9372` in your browser. +Once it is published, `npm install -g parallax-cli` is all you need. -For a remote browser, use the network URL printed by `parallax start --network-access`. +A global install from a local path symlinks `parallax` straight at the compiled +entry point, so that file must be executable. `pnpm build` sets the bit; if you ever +see `zsh: permission denied: parallax`, the build did not run or ran from an older +checkout — rebuild, or `chmod +x $(readlink -f "$(which parallax)")`. -The dashboard has three sections (left navigation): +### Node versions -- **Tasks** — live task list, plan approval, log streaming -- **Projects** — add, edit, and remove project configurations -- **Integrations** — configure GitHub, Linear, and Slack (including API keys) +Parallax needs a Node that can load `node:sqlite` — 22.5 or newer (22.x needs +`--experimental-sqlite`, which Parallax passes for you). -## 7. Check runtime status +You do not have to keep that version selected. If `parallax` is invoked under an +interpreter that cannot load it, it finds one that can — checking the interpreter it +last used, then nvm, fnm, volta, asdf and Homebrew — and re-executes itself there. +The choice is remembered in `~/.parallax/node-runtime.json`. + +The runner is started with that absolute interpreter path rather than whatever `node` +means at the time, so a version switch months later cannot break a daemon that is +already installed. `parallax runner status` warns if that interpreter has since been +removed; `parallax runner install` repins it. + +If no usable Node exists at all, you get a message saying so rather than a failure +deep inside the database layer. + +`init` asks for your cloud URL and the **runner** key, then reads your Hermes install +directly: it lists every profile under `~/.hermes/profiles/`, picks each one's +`API_SERVER_KEY` out of its own `.env`, and asks you which to add. It probes each as it +goes, so a wrong key fails there rather than silently an hour later. Optional per +profile: a role, a GitHub login (for PR-review routes), and an avatar image URL (shown +in Slack). ```bash +parallax preflight # Node, Hermes profiles, cloud, gh auth +parallax start parallax status ``` -Reports whether the local runtime is healthy and lists your configured projects. +Then make it survive reboots: -## 8. Stop Parallax +```bash +parallax runner install # launchd agent: RunAtLoad + KeepAlive +parallax runner status +``` + +--- + +## 6. Check it works + +```bash +parallax projects # ticket sources it is polling — zero here means nothing can fire +parallax agents # every Hermes profile it discovered, with model and toolsets +parallax routes # what it will act on +``` + +Projects and routes are re-read from the cloud every poll cycle, so adding either in +the dashboard takes effect within about 25 seconds with no restart. `parallax reload` +forces it immediately; `parallax restart` is only for changes to +`~/.parallax/config.json` or a new build. + +Send one prompt straight to Hermes, bypassing all routing — the fastest way to tell +whether the machine can drive an agent at all: + +```bash +parallax run --agent product --prompt "Reply with the word ready." +``` + +Then the real thing: add the `feasibility` label to a Linear ticket. Within one poll +cycle: ```bash -parallax stop +parallax runs # a run appears +parallax logs --follow # tool calls and output stream in ``` + +A comment lands on the ticket, the labels swap, and Slack announces it. + +--- + +## Everyday commands + +```bash +parallax status is it up, and what does it see +parallax runs --status failed what went wrong +parallax logs --run one run in full +parallax cancel stop it here and on Hermes +parallax runner status launchd state +``` + +## When something is wrong + +**An agent is missing from `parallax agents`.** Its key is wrong or the profile is +unreachable. `parallax preflight` names it and shows the error. + +**A route fired once and never again.** That is the default. `guard.refire` is `once`, +and the item now carries `parallax:done`. Remove that label to re-arm it, or set +`"guard": { "refire": "per-change", "markers": true }` on the route. + +**A "label added" route never fires.** Transition matching needs a previous +observation, so it never matches the first time Parallax sees an item. Add and remove +the label once while the runner is up, and it will fire on the next add. + +**A labelled ticket produced no run.** Watch one poll cycle in the log — the runner +prints a summary line every cycle: + +``` +poll: 12 event(s) (taplands 12) · dispatched 1 · skipped 11 (no-route 10, duplicate 1) +``` + +`0 event(s)` means the runner never fetched the ticket: either no projects +(`parallax projects`) or the project's `filters` excluded it. `no-route` means it was +fetched but no rule matched. `unknown-agent` means the route names a profile that is +not in `parallax agents`. + +**Runs are queued but never start.** Hermes allows only one run per profile at a +time — concurrent runs corrupt a profile's memory — so a route targeting a busy agent +defers until it is free. `parallax runs` shows what is occupying it. + +**Routes list is empty after a cloud outage.** The runner caches the last known good +set in `~/.parallax/routes.json` and keeps dispatching from it. If that file has never +been written, there is nothing to fall back to. + +**`parallax logs` shows nothing for a long run.** Hermes expires run event buffers +after five minutes, so progress output can stop while the run continues. Status comes +from polling, not the stream, so `parallax runs` stays accurate. diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..bb3fc80 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,249 @@ +# CI and releasing + +Four workflows. One runs on every change; three ship things. + +| Workflow | Trigger | What it does | +|---|---|---| +| [`ci.yml`](../.github/workflows/ci.yml) | PRs, pushes to `main` | Lint, typecheck, test on Node 22 and 24, build both images | +| [`deploy-cloud-api.yml`](../.github/workflows/deploy-cloud-api.yml) | Manual, or `main` touching `cloud-api` | Deploys to Railway and waits for `/health` | +| [`deploy-dashboard.yml`](../.github/workflows/deploy-dashboard.yml) | Manual, or `main` touching `cloud-dashboard` | Deploys to Railway and waits for `/health` | +| [`publish-cli.yml`](../.github/workflows/publish-cli.yml) | Manual, or a published GitHub release | Verifies, packs, and publishes `parallax-cli` to npm | + +--- + +## CI + +Runs the whole suite on **Node 22.12 and Node 24**. That matrix is not decoration: 22 +needs `--experimental-sqlite` for `node:sqlite` and 24 ignores it, so running both is +what keeps the supported range honest rather than aspirational. `NODE_OPTIONS` carries +the flag for the whole job. + +The floor is 22.12 rather than 22.11 because Vite 8, which builds the dashboard, +declares `^20.19.0 || >=22.12.0`. Vite is a build tool and ships in nothing, so this +constrains where the repo can be *built*, not where the runner can run. + +It builds **before** typechecking and testing, and the order matters twice over. +`cloud-api` resolves `@parallax/common` through its built `.d.ts` rather than a path +alias, so a typecheck against a clean tree cannot see it at all. And one suite imports +the built package rather than the source — every other test aliases +`@parallax/common` to `src/`, which resolves module cycles differently from the real +ESM graph. A circular import once passed the entire suite and only failed when the +container started. + +A third job parses every workflow file. An invalid one produces a GitHub run with no +jobs and an error that appears in no job log, which is a genuinely confusing way to +discover a stray `:` in a `run:` line. + +Two more jobs build the Docker images for `linux/amd64`, the architecture Railway +deploys on. An image that builds here is one that builds there. The cloud-api job +checks that both CLI entry points inside the image resolve; the dashboard job starts +the container and checks the three things that can only fail at runtime — the API URL +reaching `/env.js` from the environment, the SPA falling back to `index.html` for a +client-side route, and the health check Railway polls. + +--- + +## Deploying cloud-api + +### One-time setup + +1. In Railway: **Project Settings → Tokens → New Token**. A *project* token is enough + and does not need linking. +2. In GitHub: **Settings → Secrets and variables → Actions**, add `RAILWAY_TOKEN`. +3. Optionally add a repository *variable* `CLOUD_HEALTH_URL` (e.g. + `https://api-production-xxxx.up.railway.app`). The workflow otherwise scrapes the + domain from the Railway CLI, whose output has changed shape between versions. + +### Deploying + +There are two ways in. + +**Manually**, from the Actions tab — *Deploy cloud-api → Run workflow*. GitHub's +**"Use workflow from"** dropdown picks the branch, and the deploy uses that checkout, +so you can ship a branch before it merges. The service name defaults to `api`. + +> **The Run workflow button only appears once the workflow is on your default branch.** +> GitHub reads `workflow_dispatch` from `main` regardless of which branch you want to +> run. Until this merges, deploy by hand with `pnpm railway:deploy:api` from the repo +> root. + +**Automatically**, on a push to `main` that touches `packages/cloud-api/**`, +`packages/common/**`, `Dockerfile` or `.railway/railway.ts`. Path-filtered rather than every +merge, because redeploying the control plane interrupts a runner's long poll for no +reason. Delete the `push:` block if you would rather every deploy be deliberate. + +The workflow deploys straight from the checkout, so no git remote needs connecting on +the Railway side, and the repo root is the Docker build context. + +Deploys are serialized (`concurrency` without cancellation) because the pre-deploy step +runs migrations, and two concurrent migration runs against one database is a way to +lose an afternoon. + +### After a deploy + +The runner reloads projects and routes on its own each cycle, so nothing needs +restarting on the Mac Mini — *unless* the deploy added an endpoint the runner needs, +in which case the runner has to be updated too. Its fallback is deliberately quiet: + +```bash +grep "Could not fetch" ~/.parallax/runner.stdout.log +``` + +--- + +## Deploying the dashboard + +The dashboard is a **second Railway service**, and the one thing that must be right is +its config file. + +### One-time setup + +1. Create a service named `dashboard` in the same project. +2. Apply the project configuration, so the service builds `Dockerfile.dashboard` + rather than the control plane's: + + ```bash + pnpm railway:plan && pnpm railway:apply + ``` + + Skip it and the service falls back to Railway's own detection — which is how a + dashboard service ends up deploying the API image, starting cleanly, and passing + its health check while serving the wrong thing. +3. **Variables**: `PARALLAX_API_URL`, pointing at the API service's public URL. It is + read at runtime, so changing it later is a restart rather than a rebuild. +4. Generate a domain. +5. Optionally add a repository *variable* `DASHBOARD_HEALTH_URL`, for the same reason + the API has one: `railway domain` output has changed shape between CLI versions. + +Full detail, including the local stack, is in [dashboard.md](./dashboard.md). + +### Deploying + +Actions → *Deploy dashboard → Run workflow*, picking the branch; or automatically on a +push to `main` touching `packages/cloud-dashboard/**`, `Dockerfile.dashboard` or +`.railway/railway.ts`. By hand: `pnpm railway:deploy:dashboard`. + +`railway up` deploys source and does not reconcile configuration, so a change to +`.railway/railway.ts` needs `pnpm railway:apply` as well. + +The health check passes on `{"status":"ok"}` and emits a **warning** — not a failure — +when the response says `apiConfigured: false`. A dashboard that cannot reach an API +serves a page that can do nothing, which is worth saying out loud, but it is fixed by +editing a variable rather than by failing the deploy. + +--- + +## Publishing the CLI + +### One-time setup + +`parallax-cli` publishes with **npm trusted publishing** — no `NPM_TOKEN` in GitHub. +On npmjs.com, under the package's **Settings → Trusted Publisher**, set: + +| Field | Value | +|---|---| +| Repository | `maxigimenez/parallax-cli` | +| Workflow | `publish-cli.yml` | +| Environment | `npm` | + +> **If publishing suddenly fails with a permissions error**, check this first. The +> workflow was renamed from `release.yml` to `publish-cli.yml`, and trusted publishing +> matches on the workflow *filename*. A stale entry there rejects the publish with an +> error that does not mention the rename. + +### Publishing + +From the Actions tab — *Publish parallax-cli → Run workflow* — or by publishing a +GitHub release. + +Inputs: + +- **version** — optional. Runs `pnpm version:set`, which moves the root, every + `packages/*`, and the internal `@parallax/*` dependency pins together. They must move + in lockstep: those packages are unpublished and the CLI links them by version, so a + stale pin falls through to the npm registry and fails to resolve on a user's machine. +- **dry_run** — packs, inspects and verifies without publishing. + +Before publishing it runs lint, the full test suite, and a build, then checks two +things that only fail on a user's machine: + +- **the entry point is executable.** `tsc` emits `0644`, and a global install symlinks + `parallax` straight at the compiled file, so without the exec bit the command fails + with `permission denied`. `pnpm build` sets it; this asserts it. +- **the internal packages are in the tarball.** `@parallax/common` and + `@parallax/orchestrator` are bundled rather than fetched from npm, so if + `prepare-package.mjs` misses one the install succeeds and the command then fails at + runtime. + +### Doing it by hand + +```bash +pnpm version:set 0.3.0 # every package, and the cli's internal pins +pnpm install --lockfile-only # the lockfile records those pins +pnpm lint && pnpm test && pnpm build + +cd packages/cli +pnpm pack:tarball # runs prepack, then restores workspace links +tar -tzf parallax-cli-*.tgz | head # inspect before shipping + +pnpm publish:package +``` + +`prepack` dereferences the pnpm symlinks into real directories so the internal packages +can be bundled; `postpack` restores them. If a pack is interrupted, re-run +`pnpm install` to put the workspace back. + +To test a tarball without publishing: + +```bash +mkdir /tmp/t && cd /tmp/t && npm init -y +npm install /path/to/parallax-cli-0.2.0.tgz +./node_modules/.bin/parallax --version +``` + +--- + +## Adding a package to the CLI bundle + +If the CLI ever depends on another internal package, it must be added to **three** +places or `npm publish` fails with a 415, or installs and then breaks at runtime: + +1. `dependencies` and `bundleDependencies` in `packages/cli/package.json` +2. `bundledPackages` in `packages/cli/scripts/prepare-package.mjs` +3. the tarball assertion in `publish-cli.yml` + +--- + +## Railway scripts + +The raw CLI commands are wrapped, so nobody has to remember which verb does what: + +| Script | Command | What it does | +|---|---|---| +| `pnpm railway:plan` | `railway config plan` | Previews. Changes nothing. | +| `pnpm railway:apply` | `railway config apply` | Reconciles the project with `.railway/railway.ts`. | +| `pnpm railway:deploy:api` | `railway up --service api` | Ships source to the control plane. | +| `pnpm railway:deploy:dashboard` | `railway up --service dashboard` | Ships source to the dashboard. | + +**`apply` and `deploy` are different verbs.** `apply` reconciles configuration — +builders, Dockerfile paths, start commands, health checks. `deploy` uploads source. A +change to `.railway/railway.ts` followed by only a deploy leaves the service building +whatever it was last told to. + +--- + +## Secrets and variables + +| Name | Kind | Used by | Required | +|---|---|---|---| +| `RAILWAY_TOKEN` | secret | deploy-cloud-api, deploy-dashboard | yes | +| `CLOUD_HEALTH_URL` | variable | deploy-cloud-api | no | +| `DASHBOARD_HEALTH_URL` | variable | deploy-dashboard | no | +| npm trusted publishing | npm-side config | publish-cli | yes | + +One `RAILWAY_TOKEN` covers both services: a project token reaches every service in the +project, and `--service` picks which one. + +All three shipping workflows declare a GitHub **environment** (`production`, `npm`), so +you can add required reviewers under **Settings → Environments** to gate any of them +behind an approval. diff --git a/docs/routes.md b/docs/routes.md new file mode 100644 index 0000000..c6605bc --- /dev/null +++ b/docs/routes.md @@ -0,0 +1,264 @@ +# Routes + +A route says: **when this happens, start that agent, then do this with the result.** + +Routes are data, stored in the cloud. A new workflow is a row, not a release. + +```jsonc +{ + "name": "Assess on label", + "priority": 100, + "enabled": true, + "guard": { "refire": "once", "markers": true }, + "trigger": { "type": "ticket", "projectId": "taplands" }, + "match": { "labels": { "any": ["feasibility"] } }, + "target": { "agentRef": { "profile": "product" } }, + "execution": { "prompt": "Assess {{ticket.ref}}…", "requireApproval": false, + "timeoutSeconds": 1800 }, + "outcome": { "postComment": { "target": "ticket" } } +} +``` + +Highest `priority` wins; ties break on id. Only one route fires per event. + +**Start from a template.** `GET /v1/route-templates` returns complete, ready-to-create +routes for every case below, each with `` tokens to fill in. Every one is +checked in CI against the API's own validator and the prompt renderer, so a template +you pick and fill always produces a route the API accepts. + +--- + +## Triggers + +| `trigger.type` | Fires on | Notes | +|---|---|---| +| `ticket` | Every ticket matching the project's filters | Linear or GitHub issues | +| `pr_event` | Every open pull request, every cycle | The general-purpose PR trigger | +| `pr_review_requested` | Open PRs with at least one requested reviewer | Use with `target.agentRef.githubLogin` | +| `manual` | `POST /v1/runs` from the API | Queued for the runner's next poll | + +A pull request with a pending review request produces **both** `pr_event` and +`pr_review_requested`, so a route must pick the one it means. + +`trigger.provider` is optional. Omit it to match either provider; pin it when a project +id could exist under both. + +--- + +## Matching + +Every clause must hold. Set clauses take `any` (OR), `all` (AND) and `none` (NOR); +omitting a key, or giving it an empty array, imposes no constraint. + +### On current state + +| Clause | Applies to | +|---|---| +| `labels` | anything | +| `state` | tickets (`Backlog`, `open`, …) | +| `assignees` | anything | +| `titleMatches` / `bodyMatches` | regex against title or description | +| `isDraft` | pull requests only | +| `baseBranch` | pull requests only | + +### On what changed + +| Clause | Fires when | +|---|---| +| `labelsAdded` | a label was just added | +| `labelsRemoved` | a label was just removed | +| `assigneesAdded` | someone was just assigned | +| `reviewersAdded` | someone was just asked to review | + +**Transitions never match the first time an item is seen.** With no prior observation +every label would look newly added, and creating a route would fire it across your +entire backlog. A new route starts quiet and acts on what happens next — so to test +one, add and remove the label once while the runner is up. + +`labels` asks *does it have this now*. `labelsAdded` asks *was it just added*. Pick the +second when the act is the signal. + +### Targeting an agent + +```jsonc +"target": { "agentRef": { "profile": "product" } } // by Hermes profile +"target": { "agentRef": { "githubLogin": "acme-reviewer" } } // by GitHub identity +``` + +`githubLogin` only fires when that identity was the one actually requested or +assigned, which is what makes "review when *this* agent is asked" address one agent +rather than all of them. It applies to pull request triggers only; the API rejects it +on a `ticket` route, which could never fire. + +--- + +## Not running twice + +An agent acting on a pull request *changes* it — a commit, a review, a comment. A route +that re-fired on every change would retrigger itself on its own work. Two independent +mechanisms prevent that. + +### `guard.refire` + +- **`once`** (default) — fires for an item exactly once, whatever happens afterwards. + The item's revision is dropped from the dedupe key entirely, so this holds even + where labels cannot be written. +- **`per-change`** — fires again each time the item changes. Required for anything + with rounds, and only accepted with `markers` on. + +### `guard.markers` + +Parallax writes reserved labels around each run: + +| Label | Meaning | +|---|---| +| `parallax:in-progress` | a run is working on this now | +| `parallax:done` | a run completed | +| `parallax:failed` | a run failed | + +Everything Parallax writes is `parallax:`-prefixed, so machine-managed labels are +obvious. They are created automatically if the repository or team lacks them. + +**No route ever matches an item carrying `parallax:in-progress`** — unconditionally, +even for a route with markers off. Starting a second agent on in-flight work is never +wanted. + +A `once` route also declines anything carrying `parallax:done` or `parallax:failed`. +**Removing that label by hand re-arms the route**, which is also how you retry +something that failed. + +### Choosing + +| You want | `refire` | Why | +|---|---|---| +| act on a ticket, once | `once` | the default; cannot loop | +| review every time you are asked | `per-change` + `reviewersAdded` | only a request re-fires it | +| act on each label change | `per-change` + `labelsAdded` | only labelling re-fires it | + +The pattern for `per-change`: pair it with a **transition** clause. Then only the human +act re-fires the route, and nothing the agent does can. + +--- + +## The prompt + +`execution.prompt` is free text with `{{placeholders}}`. There is no template to +choose — rewording what an agent is asked to do should never need a release. + +| | | +|---|---| +| `{{ticket.ref}}` `{{ticket.title}}` `{{ticket.body}}` | the item | +| `{{ticket.url}}` `{{ticket.state}}` `{{ticket.labels}}` `{{ticket.assignees}}` | | +| `{{project.id}}` `{{repo.slug}}` | `repo.slug` renders `owner/repo` | +| `{{agent.profile}}` `{{agent.role}}` | the agent about to run | +| `{{pr.number}}` `{{pr.reviewers}}` `{{pr.baseBranch}}` | pull requests | +| `{{changes.labelsAdded}}` `{{changes.labelsRemoved}}` `{{changes.assigneesAdded}}` `{{changes.reviewersAdded}}` | what just changed | + +An unrecognized placeholder is **left visible** in the prompt and logged as a warning, +never blanked — a typo silently becoming an empty string produces a confidently wrong +run. + +Parallax appends an instruction asking for a `PARALLAX_SUMMARY:` line, which is what +lands in the ticket comment and the Slack message. If your prompt already mentions +`PARALLAX_SUMMARY`, yours is used as written. + +`GET /v1/prompt-templates` returns starter prompts and this variable list. + +### Let the agent fetch its own context + +Parallax does not inline diffs or comment threads. The agent has `gh` and its own +credentials — tell it what to read: + +``` +Read it yourself: + gh pr view {{pr.number}} --repo {{repo.slug}} --json title,body,comments,reviews + gh pr diff {{pr.number}} --repo {{repo.slug}} +``` + +This is the same boundary that keeps git, worktrees and pull requests on the Hermes +side: Parallax decides *when, which agent, and with what context*; the agent does the +work with the tools it already has. + +--- + +## Outcomes + +What Parallax does after the run: + +```jsonc +"outcome": { + "postComment": { "target": "ticket" }, // ticket | pr | none + "labels": { "add": ["reviewed"], "remove": ["queued"] } +} +``` + +`postComment` posts the agent's summary — **and posts on failure too**, which is why it +belongs to Parallax rather than the agent: a run that failed cannot report on its own +behalf. + +There is no `openPullRequest` outcome. Branches, commits and pull requests belong to +the agent, under its own identity. + +Slack notification is org-level, not per route, so every agent action is visible +without opting in each time. See [api.md](./api.md#slack). + +--- + +## The supported cases + +Each is available complete from `GET /v1/route-templates`. + +### Assess a ticket when it gets a label +`ticket` · `labels` · `once`. The agent reads the ticket and comments back. Writes no +code, so it needs nothing configured on the repository — the safest route to start +with. + +### Triage the moment a label is added +`ticket` · `labelsAdded` · `once`. Same, but on the act of labelling, so creating the +route does not sweep your backlog. + +### Implement a ticket +`ticket` · `labels` · `once`. The agent owns branch, edits, checks, commit, push and +pull request under its own identity. Its Hermes profile needs a working directory and +git credentials for the repository. + +### Review a pull request, every time you are asked +`pr_review_requested` · `reviewersAdded` · `per-change`. Request review → the agent +reviews → you reply and re-request → it reads the thread and reviews again. Pushing +commits or replying does not re-summon it; only a fresh request does. + +### Act on a pull request assigned to an agent +`pr_event` · `assigneesAdded` + `isDraft: false` · `per-change`. Assignment rather than +review request is the signal. + +### Act on a pull request when a label is added +`pr_event` · `labelsAdded` · `per-change`. Pair with an outcome that removes the label +to make the label itself a queue. + +### Pick a pull request back up when it is unblocked +`pr_event` · `labelsRemoved` · `per-change`. The other half of a human gate: label to +pause, unlabel to resume. + +--- + +## Troubleshooting + +**Nothing fired.** Watch one poll cycle — the runner prints a summary line: + +``` +poll: 12 event(s) (taplands 12) · dispatched 1 · skipped 11 (no-route 10, duplicate 1) +``` + +`0 event(s)` means the item was never fetched: no projects (`parallax projects`), or +the project's `filters` excluded it. `no-route` means it was fetched and nothing +matched. `unknown-agent` means the route names a profile absent from `parallax agents`. + +**It fired once and never again.** That is `refire: "once"`. The item now carries +`parallax:done` — remove it to re-arm, or switch to `per-change` with a transition +clause. + +**A transition route never fires.** It needs a prior observation. Add and remove the +label once while the runner is up. + +**Everything fired at once when I created a route.** A state clause (`labels`) matches +everything currently carrying the label. Use `labelsAdded` if you meant the act. diff --git a/docs/slack-bot.md b/docs/slack-bot.md deleted file mode 100644 index bd308cf..0000000 --- a/docs/slack-bot.md +++ /dev/null @@ -1,115 +0,0 @@ -# Slack Bot - -Parallax can connect to a Slack workspace to post plan notifications, accept approvals, and respond to slash commands — all over an outbound WebSocket connection. No public URL is required. - -## What the Slack bot does - -- **Plan-ready notifications**: when Parallax finishes generating a plan, it posts a Block Kit message to your channel showing the agent identity, task details, and plan text. The message includes Approve and Reject buttons so you can approve plans without opening the dashboard. -- **Execution started**: posts when the agent begins implementation work. -- **PR created**: posts the PR URL when a pull request is opened. -- **Failed / Canceled**: posts the error detail when a task fails or is canceled. -- **`/parallax` slash command**: lets you act on tasks directly from Slack. - -### `/parallax` slash command subcommands - -| Subcommand | Effect | -|---|---| -| `/parallax retry ` | Queues a retry for a failed or rejected task | -| `/parallax cancel ` | Cancels a pending or running task | -| `/parallax status ` | Prints the current task status and plan state | -| `/parallax pr-review ` | Triggers an on-demand PR review | - -Task IDs appear in plan-ready messages and in the dashboard. - -All slash-command replies are posted **in-channel** (`response_type: in_channel`), so they are visible to everyone in the channel rather than only the person who ran the command. - -## How it works - -Parallax uses Bolt **Socket Mode**: it opens an outbound WebSocket to Slack's API servers. There is no inbound HTTP server to expose, no public URL to configure, and no need to punch through a firewall or NAT. It works on localhost and on air-gapped machines as long as they have outbound HTTPS/WSS access. - -## Step 1 — Create a Slack App - -1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App**. -2. Choose **From scratch**. -3. Enter an app name (e.g. `Parallax`) and select the workspace you want to install it in. -4. Click **Create App**. - -## Step 2 — Enable Socket Mode and generate an App-Level Token - -1. In the left sidebar, click **Socket Mode**. -2. Toggle **Enable Socket Mode** on. -3. You will be prompted to generate an App-Level Token. Give it a name (e.g. `parallax-socket`) and add the `connections:write` scope. -4. Click **Generate**. Copy the token — it starts with `xapp-`. You will not be able to view it again. - -## Step 3 — Add OAuth scopes - -1. In the left sidebar, click **OAuth & Permissions**. -2. Scroll to **Bot Token Scopes** and add: - - `chat:write` - - `commands` -3. Click **Save Changes**. - -## Step 4 — Install the app to your workspace - -1. Still on the **OAuth & Permissions** page, scroll to the top and click **Install to Workspace**. -2. Review the permissions and click **Allow**. -3. Copy the **Bot User OAuth Token** — it starts with `xoxb-`. - -## Step 5 — Create the `/parallax` slash command - -1. In the left sidebar, click **Slash Commands**, then **Create New Command**. -2. Fill in: - - **Command**: `/parallax` - - **Request URL**: any valid URL (e.g. `https://example.com/slack`) — Socket Mode intercepts delivery before this URL is ever called. - - **Short Description**: `Manage Parallax tasks` -3. Click **Save**. - -## Step 6 — Invite the bot to your channel - -In Slack, open the channel you want Parallax to post in and run: - -``` -/invite @Parallax -``` - -Replace `Parallax` with whatever you named your app. - -## Step 7 — Configure Slack in Parallax - -Run the setup wizard and follow the Slack prompts: - -```bash -parallax init -``` - -Or, if Parallax is already running, open the dashboard and go to **Integrations → Slack**. Fill in: - -- **Bot token** — starts with `xoxb-` -- **App token** — starts with `xapp-` -- **Channel** — the channel where you invited the bot (e.g. `#ai-tasks`) - -The `channel` value must match the channel where you invited the bot. - -## Step 8 — Restart Parallax - -```bash -parallax stop -parallax start -``` - -Parallax reads the config at startup. You must restart for Slack changes to take effect. - -## Step 9 — Verify it works - -Create or trigger a task that Parallax will pick up. When the plan finishes generating you should see a message appear in your configured channel with Approve and Reject buttons. - -If no message appears, check: - -- The bot is invited to the correct channel. -- Both tokens are correct (bot token starts with `xoxb-`, app token with `xapp-`). -- The `channel` value matches exactly (including the `#`). -- `parallax status` shows the orchestrator is running. - -## Security: keeping tokens safe - -Bot and app tokens are sensitive credentials. Parallax stores them in `~/.parallax/config.json`, which is outside any repository by default. The tokens are never returned by the API and are masked in the dashboard UI. diff --git a/docs/task-lifecycle.md b/docs/task-lifecycle.md deleted file mode 100644 index 219af8e..0000000 --- a/docs/task-lifecycle.md +++ /dev/null @@ -1,71 +0,0 @@ -# Task Lifecycle - -This page explains how Parallax processes tasks end-to-end. - -## 1. Pull and queue - -Parallax polls configured providers (Linear or GitHub), applies filters, and creates local task records. - -Each Parallax task gets a deterministic hash id derived from `projectId + externalId`. That task id is the canonical key used by the API, sockets, CLI actions (`approve`, `reject`, `retry`, `cancel`, `logs`), and UI rendering. - -## 2. Plan phase - -The selected agent runs in planning mode first. - -Plan-related states: - -- `PLAN_GENERATING` -- `PLAN_READY` -- `PLAN_REQUIRES_CLARIFICATION` -- `PLAN_APPROVED` -- `PLAN_REJECTED` -- `PLAN_FAILED` -- `NOT_REQUIRED` - -Execution does not proceed until the plan is approved (unless plan is marked `NOT_REQUIRED`). - -## 3. Approval gates - -Plan approval can be done from: - -- task dashboard plan section -- CLI via `parallax pending --approve ` -- Slack, by clicking the **Approve** button in the plan-ready message (requires the [Slack bot](./slack-bot.md) to be configured) - -Rejection: - -- dashboard -- CLI via `parallax pending --reject ` -- Slack, by clicking the **Reject** button in the plan-ready message - -## 4. Execution phase - -After approval, Parallax runs implementation and captures: - -- logs -- changed files and diffs -- branch/PR metadata when available - -Task statuses: - -- `PENDING` -- `IN_PROGRESS` -- `COMPLETED` -- `FAILED` -- `CANCELED` - -## 5. Retry and cancellation - -Manual retry: - -- `parallax retry ` - -Cancellation: - -- `parallax cancel ` - -Cancellation only succeeds for cancellable states; otherwise the API returns conflict. - -## 6. Observability and history - -Parallax stores task state and logs in SQLite under `~/.parallax`. The dashboard subscribes to live updates through sockets and can show historical task information from the same database. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md deleted file mode 100644 index cbcf754..0000000 --- a/docs/troubleshooting.md +++ /dev/null @@ -1,127 +0,0 @@ -# Troubleshooting - -## parallax preflight fails - -### Node.js version check failed - -Use Node.js `>= 23.7.0`, then rerun `parallax preflight`. Older Node versions can fail when Parallax initializes SQLite. - -### gh auth status failed - -Run: - -```bash -gh auth login -gh auth status -``` - -### None of codex, gemini, or claude found - -Install at least one agent CLI and ensure it is on your `PATH`. - -### git or pnpm not found - -Install missing tool and reopen terminal session. - -## parallax start fails - -### No registered configs - -Fix: - -```bash -parallax start -parallax register ./parallax.yml -``` - -Parallax can run with zero registered configs, but it will not poll any projects until at least one config is registered. - -### API did not become healthy - -Possible causes: - -- port `3000` already in use -- config validation failed -- provider auth missing in environment - -Check: - -```bash -parallax stop -parallax start --server-api-port 3000 --server-ui-port 8080 --concurrency 2 -``` - -## Dashboard cannot be reached over the local network - -Parallax binds to localhost unless network access is explicitly enabled: - -```bash -parallax stop -parallax start --network-access -``` - -Use the network URL printed at startup or by `parallax status`, for example -`http://cerebro.local:9372`. - -### Vite says the host is not allowed - -An error such as: - -```text -Blocked request. This host ("cerebro.local") is not allowed. -``` - -means the development dashboard was started without network mode. Restart the full Parallax runtime -with `parallax start --network-access`; do not edit the installed `vite.config.js`. - -### The `.local` hostname does not resolve - -On macOS, check the Bonjour hostname: - -```bash -scutil --get LocalHostName -``` - -Try `.local`, or use the Mac's LAN IP address instead. Ensure both devices are on the -same network and that client isolation is disabled on the router or access point. - -### The hostname resolves but the connection is refused - -- Confirm `parallax status` shows a network dashboard URL. -- Allow incoming connections for Node.js in **System Settings → Network → Firewall**. -- Confirm ports `9371` and `9372`, or your custom API/UI ports, are not blocked by host or network - firewall rules. - -Network access has no authentication. Enable it only on a trusted internal network because remote -dashboard users can approve tasks and modify configuration and secrets. - -## Task actions fail - -### Unknown task id - -Use Parallax task id from dashboard or: - -```bash -parallax pending -``` - -### Task keeps failing after retry - -Approve the task plan first if it is still pending, then run: - -```bash -parallax retry -``` - -## Clean reset - -If local runtime state is corrupted: - -```bash -parallax stop -rm -rf ~/.parallax -parallax start -parallax register ./parallax.yml -``` - -Warning: deleting data dir removes local task/runtime history. diff --git a/package.json b/package.json index e070979..fdeee2c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parallax", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", "scripts": { @@ -9,10 +9,14 @@ "lint": "pnpm --filter './packages/*' -r lint && pnpm exec eslint eslint.config.js", "lint:fix": "pnpm --filter './packages/*' -r lint:fix && pnpm exec eslint eslint.config.js --fix", "parallax": "NODE_ENV=dev pnpm --filter parallax-cli start", - "clean": "rm -rf packages/orchestrator/parallax.db packages/orchestrator/workspaces", + "clean": "rm -rf packages/*/dist", "version:set": "node scripts/set-version.mjs", "release:pack": "pnpm --filter parallax-cli pack:tarball", - "release:publish": "pnpm --dir packages/cli publish:package" + "release:publish": "pnpm --dir packages/cli publish:package", + "railway:plan": "railway config plan", + "railway:apply": "railway config apply", + "railway:deploy:api": "railway up --service api", + "railway:deploy:dashboard": "railway up --service dashboard" }, "devDependencies": { "@eslint/js": "9.32.0", @@ -23,6 +27,7 @@ "eslint-plugin-react": "7.37.5", "globals": "17.3.0", "prettier": "3.8.1", + "railway": "3.11.0", "tsx": "4.19.2", "typescript": "5.7.3", "typescript-eslint": "8.56.1", diff --git a/packages/cli/package.json b/packages/cli/package.json index 8d5c7aa..28f0fc2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,25 +1,24 @@ { "name": "parallax-cli", - "version": "0.1.0", - "description": "Local-first AI orchestration CLI for software tasks, plans, approvals, and pull requests.", + "version": "0.2.0", + "description": "Trigger Hermes agents from your tickets and pull requests.", "type": "module", "keywords": [ "ai", "cli", - "developer-tools", - "automation", - "coding-agent", + "agents", + "hermes", + "orchestrator", "linear", "github", - "pull-request", - "orchestrator", + "automation", "parallax" ], "bin": { "parallax": "dist/cli/src/index.js" }, "scripts": { - "build": "rm -rf dist && tsc", + "build": "rm -rf dist && tsc && chmod +x dist/cli/src/index.js", "lint": "eslint src test scripts vitest.config.ts", "lint:fix": "eslint src test scripts vitest.config.ts --fix", "prepack": "node ./scripts/prepare-package.mjs", @@ -31,32 +30,17 @@ "test": "vitest run" }, "engines": { - "node": ">=23.7.0" + "node": ">=22.5.0" }, "dependencies": { "@clack/prompts": "1.4.0", - "@fastify/cors": "11.2.0", - "@parallax/common": "0.1.0", - "@parallax/orchestrator": "0.1.0", - "@parallax/slack": "0.1.0", - "@parallax/ui": "0.1.0", - "@slack/bolt": "^4.0.0", - "@slack/web-api": "^7.0.0", - "chalk": "4", - "fastify": "5.7.4", - "log-update": "7.1.0", - "p-limit": "6.1.0", - "simple-git": "3.32.3", - "socket.io": "4.8.3", - "strip-ansi": "7.1.2", - "terminal-size": "4.0.1", - "uuid": "11.0.0" + "@parallax/common": "0.2.0", + "@parallax/orchestrator": "0.2.0", + "chalk": "4" }, "bundleDependencies": [ "@parallax/common", - "@parallax/orchestrator", - "@parallax/slack", - "@parallax/ui" + "@parallax/orchestrator" ], "devDependencies": { "@types/node": "25.3.0", diff --git a/packages/cli/scripts/prepare-package.mjs b/packages/cli/scripts/prepare-package.mjs index a7fddea..a101073 100644 --- a/packages/cli/scripts/prepare-package.mjs +++ b/packages/cli/scripts/prepare-package.mjs @@ -42,24 +42,6 @@ const bundledPackages = [ type: 'module', }, }, - { - name: '@parallax/ui', - sourceDir: path.join(workspaceRoot, 'packages/ui'), - packageJson: { - name: '@parallax/ui', - version: '0.0.4', - type: 'module', - }, - }, - { - name: '@parallax/slack', - sourceDir: path.join(workspaceRoot, 'packages/slack'), - packageJson: { - name: '@parallax/slack', - version: '0.0.4', - type: 'module', - }, - }, ] // Pin each bundled package to its real workspace version so the published @@ -118,8 +100,6 @@ async function writeBundledPackage(metadata) { async function main() { runPnpm(['--filter', '@parallax/common', 'build']) runPnpm(['--filter', '@parallax/orchestrator', 'build']) - runPnpm(['--filter', '@parallax/ui', 'build']) - runPnpm(['--filter', '@parallax/slack', 'build']) runPnpm(['--filter', 'parallax-cli', 'build']) const cliPackageJson = JSON.parse(await fs.readFile(cliPackageJsonPath, 'utf8')) @@ -139,8 +119,6 @@ async function main() { ...cliPackageJson.dependencies, '@parallax/common': bundledPackages[0].packageJson.version, '@parallax/orchestrator': bundledPackages[1].packageJson.version, - '@parallax/ui': bundledPackages[2].packageJson.version, - '@parallax/slack': bundledPackages[3].packageJson.version, }, } await fs.writeFile(cliPackageJsonPath, JSON.stringify(rewrittenCliPackageJson, null, 2) + '\n') diff --git a/packages/cli/src/agent-models.ts b/packages/cli/src/agent-models.ts deleted file mode 100644 index 94e1152..0000000 --- a/packages/cli/src/agent-models.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { AgentProvider } from '@parallax/common' - -type ModelOption = { value: string; label: string; hint?: string } - -const MODELS_BY_PROVIDER: Record = { - 'claude-code': [ - { value: 'claude-opus-4-7', label: 'claude-opus-4-7', hint: 'most capable' }, - { value: 'claude-sonnet-4-6', label: 'claude-sonnet-4-6', hint: 'balanced (default)' }, - { value: 'claude-haiku-4-5', label: 'claude-haiku-4-5', hint: 'fast, low cost' }, - ], - codex: [ - { value: 'gpt-5-codex', label: 'gpt-5-codex', hint: 'optimized for coding' }, - { value: 'gpt-5', label: 'gpt-5', hint: 'general purpose' }, - { value: 'o3', label: 'o3', hint: 'reasoning' }, - ], - gemini: [ - { value: 'gemini-2.5-pro', label: 'gemini-2.5-pro', hint: 'most capable' }, - { value: 'gemini-2.5-flash', label: 'gemini-2.5-flash', hint: 'fast' }, - ], -} - -export function getModelOptions(provider: AgentProvider): ModelOption[] { - return MODELS_BY_PROVIDER[provider] ?? [] -} diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts new file mode 100644 index 0000000..bf92c1e --- /dev/null +++ b/packages/cli/src/api.ts @@ -0,0 +1,74 @@ +/** + * Thin HTTP helpers for talking to the local runner and the cloud API. + * + * Kept deliberately small: every CLI command that reads state goes through one + * of these so error messages stay consistent and a stopped runner produces a + * useful sentence instead of a stack trace. + */ + +export class ApiError extends Error { + constructor( + readonly status: number, + message: string + ) { + super(message) + this.name = 'ApiError' + } +} + +async function parseError(response: Response): Promise { + const text = await response.text().catch(() => '') + try { + const body = JSON.parse(text) as { error?: string } + return body.error ?? text + } catch { + return text || response.statusText + } +} + +export async function getJson(url: string, headers: Record = {}): Promise { + let response: Response + try { + response = await fetch(url, { headers, signal: AbortSignal.timeout(15_000) }) + } catch (error: unknown) { + throw new Error( + `Could not reach ${url}: ${error instanceof Error ? error.message : String(error)}` + ) + } + if (!response.ok) { + throw new ApiError(response.status, await parseError(response)) + } + return (await response.json()) as T +} + +export async function postJson( + url: string, + body: unknown, + headers: Record = {} +): Promise { + let response: Response + try { + response = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(body ?? {}), + signal: AbortSignal.timeout(30_000), + }) + } catch (error: unknown) { + throw new Error( + `Could not reach ${url}: ${error instanceof Error ? error.message : String(error)}` + ) + } + if (!response.ok) { + throw new ApiError(response.status, await parseError(response)) + } + return response.status === 204 ? (undefined as T) : ((await response.json()) as T) +} + +/** Explains a dead runner in terms of what to do about it. */ +export function runnerUnreachable(apiBase: string): Error { + return new Error( + `The Parallax runner is not responding at ${apiBase}.\n` + + `Start it with "parallax start", or check "parallax runner status".` + ) +} diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts index 5031325..04863bd 100644 --- a/packages/cli/src/args.ts +++ b/packages/cli/src/args.ts @@ -1,222 +1,143 @@ import path from 'node:path' +import { DEFAULT_API_PORT, DEFAULT_CONCURRENCY } from '@parallax/common' import type { CancelCommandOptions, + EmptyOptions, LogsCommandOptions, - PreflightCommandOptions, - PrReviewCommandOptions, - RetryCommandOptions, + RunCommandOptions, + RunnerCommandOptions, + RunsCommandOptions, StartCommandOptions, - StopCommandOptions, - StatusCommandOptions, - TasksCommandOptions, } from './types.js' -export function parseArg(args: string[], key: string): string | undefined { - const keyWithPrefix = `--${key}` - const valueIdx = args.findIndex((entry) => entry === keyWithPrefix) +/** + * Strict argument parsing. + * + * Every command has one parser, and an unrecognized flag is an error rather + * than something silently ignored -- a typo'd flag that quietly does nothing is + * worse than a failed command. + */ - if (valueIdx >= 0 && args[valueIdx + 1]) { - return args[valueIdx + 1] - } - - const inlineEntry = args.find((entry) => entry.startsWith(`${keyWithPrefix}=`)) - if (inlineEntry) { - return inlineEntry.slice(keyWithPrefix.length + 1) - } - - return undefined +export function resolvePath(raw: string): string { + return path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw) } -export function hasFlag(args: string[], key: string): boolean { - const keyWithPrefix = `--${key}` - return args.includes(keyWithPrefix) || args.some((entry) => entry.startsWith(`${keyWithPrefix}=`)) +export function hasFlag(args: string[], flag: string): boolean { + return args.includes(flag) } -export function parseArgValue(args: string[], key: string): string { - const value = parseArg(args, key) - if (!hasFlag(args, key)) { - throw new Error(`Unexpected parser state: --${key} is not set.`) +function valueOf(args: string[], flag: string): string | undefined { + const index = args.indexOf(flag) + if (index === -1) { + return undefined } - - if (!value || !value.trim()) { - throw new Error(`Missing value for --${key}.`) + const value = args[index + 1] + if (value === undefined || value.startsWith('--')) { + throw new Error(`${flag} requires a value.`) } - return value } -export function parseOptionalArg(args: string[], key: string): string | undefined { - if (!hasFlag(args, key)) { - return undefined +function assertKnownFlags(args: string[], allowed: string[], command: string): void { + for (let i = 0; i < args.length; i += 1) { + const token = args[i] + if (!token.startsWith('--')) { + continue + } + if (!allowed.includes(token)) { + throw new Error(`Unknown flag "${token}" for "${command}". Allowed: ${allowed.join(', ')}.`) + } + // Skip the value so it is not mistaken for a positional argument. + if (args[i + 1] && !args[i + 1].startsWith('--')) { + i += 1 + } } - - return parseArgValue(args, key) } -function parseStrictPort(args: string[], key: string, fallback: number): number { - const raw = parseOptionalArg(args, key) +function parseIntOption( + raw: string | undefined, + label: string, + fallback: number, + min: number, + max: number +): number { if (raw === undefined) { return fallback } - const parsed = Number.parseInt(raw, 10) - if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) { - throw new Error(`--${key} must be an integer between 1 and 65535.`) + if (!Number.isInteger(parsed) || parsed < min || parsed > max) { + throw new Error(`${label} must be an integer between ${min} and ${max}.`) } - return parsed } export function parseStartOptions(args: string[]): StartCommandOptions { - const allowedFlags = new Set([ - '--server-api-port', - '--server-ui-port', - '--concurrency', - '--network-access', - ]) - for (let index = 0; index < args.length; index += 1) { - const arg = args[index] - if (arg.startsWith('--')) { - const flag = arg.includes('=') ? arg.split('=')[0] : arg - if (!allowedFlags.has(flag)) { - throw new Error(`Unsupported flag for parallax start: ${arg}`) - } - if (flag === '--network-access') { - if (arg.includes('=')) { - throw new Error('--network-access does not accept a value.') - } - continue - } - if (!arg.includes('=')) { - index += 1 - } - continue - } - - throw new Error('parallax start accepts flags only.') - } - - const apiPort = parseStrictPort(args, 'server-api-port', 9371) - const uiPort = parseStrictPort(args, 'server-ui-port', 9372) - const rawConcurrency = parseOptionalArg(args, 'concurrency') - const concurrency = rawConcurrency === undefined ? 2 : Number.parseInt(rawConcurrency, 10) - - if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 16) { - throw new Error('--concurrency must be an integer between 1 and 16.') - } - - if (apiPort === uiPort) { - throw new Error('--server-api-port and --server-ui-port must be different.') - } - - return { apiPort, uiPort, concurrency, networkAccess: hasFlag(args, 'network-access') } -} - -export function parseStopOptions(args: string[]): StopCommandOptions { - if (args.length > 0) { - throw new Error('parallax stop does not accept flags.') - } - - return {} -} - -export function parseRetryOptions(args: string[]): RetryCommandOptions { - const taskId = args[0] - if (!taskId || taskId.startsWith('--')) { - throw new Error('parallax retry requires .') - } - - if (args.length > 1) { - throw new Error('parallax retry does not accept flags.') - } - + assertKnownFlags( + args, + ['--api-port', '--concurrency', '--network-access', '--foreground'], + 'start' + ) return { - taskId, + apiPort: parseIntOption(valueOf(args, '--api-port'), '--api-port', DEFAULT_API_PORT, 1, 65535), + concurrency: parseIntOption( + valueOf(args, '--concurrency'), + '--concurrency', + DEFAULT_CONCURRENCY, + 1, + 16 + ), + networkAccess: hasFlag(args, '--network-access'), + foreground: hasFlag(args, '--foreground'), } } -export function parseCancelOptions(args: string[]): CancelCommandOptions { - const taskId = args[0] - if (!taskId || taskId.startsWith('--')) { - throw new Error('parallax cancel requires .') - } - - if (args.length > 1) { - throw new Error('parallax cancel does not accept flags.') - } - - return { - taskId, - } +export function parseLogsOptions(args: string[]): LogsCommandOptions { + assertKnownFlags(args, ['--run', '--follow'], 'logs') + return { runId: valueOf(args, '--run'), follow: hasFlag(args, '--follow') } } -export function parsePrReviewOptions(args: string[]): PrReviewCommandOptions { - const taskId = args[0] - - if (!taskId || taskId.startsWith('--')) { - throw new Error('parallax pr-review requires .') - } - - if (args.length > 1) { - throw new Error('parallax pr-review does not accept flags.') - } - +export function parseRunsOptions(args: string[]): RunsCommandOptions { + assertKnownFlags(args, ['--status', '--limit'], 'runs') return { - taskId, + status: valueOf(args, '--status'), + limit: parseIntOption(valueOf(args, '--limit'), '--limit', 20, 1, 200), } } -export function parseLogsOptions(args: string[]): LogsCommandOptions { - const allowedFlags = new Set(['--task']) - for (let index = 0; index < args.length; index += 1) { - const arg = args[index] - if (arg.startsWith('--')) { - const flag = arg.includes('=') ? arg.split('=')[0] : arg - if (!allowedFlags.has(flag)) { - throw new Error('parallax logs only accepts optional --task .') - } - if (!arg.includes('=')) { - index += 1 - } - continue - } - - if (arg !== undefined) { - throw new Error('parallax logs only accepts optional --task .') - } +export function parseRunOptions(args: string[]): RunCommandOptions { + assertKnownFlags(args, ['--agent', '--prompt', '--timeout'], 'run') + const agent = valueOf(args, '--agent') + const prompt = valueOf(args, '--prompt') + if (!agent) { + throw new Error('run requires --agent .') + } + if (!prompt) { + throw new Error('run requires --prompt "".') } - - const taskId = parseOptionalArg(args, 'task') - return { - taskId: taskId ?? undefined, + agent, + prompt, + timeoutSeconds: parseIntOption(valueOf(args, '--timeout'), '--timeout', 600, 5, 7200), } } -export function parsePreflightOptions(args: string[]): PreflightCommandOptions { - if (args.length > 0) { - throw new Error('parallax preflight does not accept flags.') +export function parseCancelOptions(args: string[]): CancelCommandOptions { + const runId = args.find((arg) => !arg.startsWith('--')) + if (!runId) { + throw new Error('cancel requires a run id.') } - - return {} + return { runId } } -export function parseStatusOptions(args: string[]): StatusCommandOptions { - if (args.length > 0) { - throw new Error('parallax status does not accept flags.') +export function parseRunnerOptions(args: string[]): RunnerCommandOptions { + const action = args.find((arg) => !arg.startsWith('--')) + if (action !== 'install' && action !== 'uninstall' && action !== 'status') { + throw new Error('runner requires one of: install, uninstall, status.') } - - return {} + return { action } } -export function parseTasksOptions(args: string[]): TasksCommandOptions { - if (args.length > 0) { - throw new Error('parallax tasks does not accept flags.') - } - +export function parseEmptyOptions(args: string[], command: string): EmptyOptions { + assertKnownFlags(args, [], command) return {} } - -export function resolvePath(raw: string): string { - return path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw) -} diff --git a/packages/cli/src/commands/agents.ts b/packages/cli/src/commands/agents.ts new file mode 100644 index 0000000..13d4088 --- /dev/null +++ b/packages/cli/src/commands/agents.ts @@ -0,0 +1,41 @@ +import chalk from 'chalk' +import type { AgentDescriptor } from '@parallax/common' +import { getJson, runnerUnreachable } from '../api.js' +import type { CliContext } from '../types.js' + +export async function runAgents(context: CliContext): Promise { + const apiBase = await context.resolveDefaultApiBase() + + const { agents } = await getJson<{ agents: AgentDescriptor[] }>(`${apiBase}/agents`).catch(() => { + throw runnerUnreachable(apiBase) + }) + + if (agents.length === 0) { + console.log(chalk.yellow('No agents discovered.')) + console.log(chalk.dim(' Check "parallax preflight" — a bad profile key hides that profile.')) + return + } + + console.log('') + for (const agent of agents) { + const mark = agent.enabled ? chalk.green('*') : chalk.dim('-') + console.log( + ` ${mark} ${chalk.bold(agent.profile)}${agent.role ? chalk.dim(` ${agent.role}`) : ''}` + ) + console.log(chalk.dim(` model ${agent.model ?? 'unknown'}`)) + if (agent.githubLogin) { + console.log(chalk.dim(` github ${agent.githubLogin}`)) + } + if (agent.toolsets.length) { + console.log(chalk.dim(` toolsets ${agent.toolsets.join(', ')}`)) + } + if (agent.skills.length) { + console.log( + chalk.dim( + ` skills ${agent.skills.slice(0, 6).join(', ')}${agent.skills.length > 6 ? ', …' : ''}` + ) + ) + } + } + console.log('') +} diff --git a/packages/cli/src/commands/cancel.ts b/packages/cli/src/commands/cancel.ts index 7957d29..0943b17 100644 --- a/packages/cli/src/commands/cancel.ts +++ b/packages/cli/src/commands/cancel.ts @@ -1,32 +1,16 @@ -import { parseCancelOptions } from '../args.js' -import type { CliContext } from '../types.js' +import chalk from 'chalk' +import { postJson, runnerUnreachable } from '../api.js' +import type { CancelCommandOptions, CliContext } from '../types.js' -export async function runCancel(args: string[], context: CliContext) { - const options = parseCancelOptions(args) +export async function runCancel(context: CliContext, options: CancelCommandOptions): Promise { + const apiBase = await context.resolveDefaultApiBase() - let apiBase: string - try { - apiBase = await context.resolveDefaultApiBase() - } catch { - throw new Error("Parallax is not running. Start it first with 'parallax start'.") - } - - const url = `${apiBase}/tasks/${encodeURIComponent(options.taskId)}/cancel` - const response = await fetch(url, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: '{}', + await postJson(`${apiBase}/runs/${options.runId}/cancel`, {}).catch((error: unknown) => { + if (error instanceof Error && error.message.includes('Could not reach')) { + throw runnerUnreachable(apiBase) + } + throw error }) - if (response.status === 404) { - throw new Error( - `Task not found: ${options.taskId}. List tasks in the dashboard or check 'parallax status'.` - ) - } - if (!response.ok) { - const body = await response.text().catch(() => '') - throw new Error(`Cancel failed (${response.status}): ${body || response.statusText}`) - } - - console.log(`Canceled: ${options.taskId}`) + console.log(chalk.green(`Canceled ${options.runId}.`)) } diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index aa8ed6f..2a939f5 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,373 +1,293 @@ import * as p from '@clack/prompts' import chalk from 'chalk' -import fs from 'node:fs' -import path from 'node:path' -import type { ProjectConfig, SlackConfig } from '@parallax/common' +import { CONFIG_VERSION, type HermesProfileConfig, type StoredConfig } from '@parallax/common' import type { CliContext } from '../types.js' -import { getModelOptions } from '../agent-models.js' -import { detectGitHubRemote } from '../git-detect.js' +import { getJson } from '../api.js' +import { + defaultHermesBaseUrl, + discoverLocalHermes, + resolveHermesHome, + type LocalHermesProfile, +} from '../hermes-local.js' -const orange = chalk.hex('#f97316') - -function isCancel(value: unknown): value is symbol { - return typeof value === 'symbol' -} +const BRAND = chalk.hex('#f97316') function assertNotCancel(value: T | symbol): T { - if (isCancel(value)) { + if (p.isCancel(value)) { p.cancel('Setup cancelled.') process.exit(0) } return value as T } -function printWelcomeBanner(version: string) { - console.log('') - console.log(` ${orange.bold('parallax')}${orange('_')} ${chalk.dim(`v${version}`)}`) - console.log(` ${chalk.dim('Local-first AI orchestration runtime')}`) - console.log('') +function requiredText(message: string, initialValue?: string, placeholder?: string) { + return p.text({ + message, + initialValue, + placeholder, + validate: (value) => (value?.trim() ? undefined : 'Required.'), + }) } -function validateWorkspaceDir(v: string | undefined): string | undefined { - const resolved = v?.trim() || process.cwd() - if (!path.isAbsolute(resolved)) { - return 'Path must be absolute.' - } +/** + * Verifies a Hermes profile before we write its key to disk. + * + * Catching a wrong key here is far cheaper than catching it as a 401 buried in + * the runner log an hour later, and `/v1/capabilities` is the cheapest call that + * proves both reachability and that this key works on this profile's prefix. + */ +async function probeProfile( + baseUrl: string, + profile: string, + apiKey: string +): Promise<{ ok: boolean; detail: string }> { + const prefix = profile === 'default' ? '' : `/p/${profile}` try { - const stat = fs.statSync(resolved) - if (!stat.isDirectory()) { - return 'Path must be a directory.' - } - } catch { - return 'Directory not found.' - } - if (!fs.existsSync(path.join(resolved, '.git'))) { - return 'Not a git repository (no .git directory found).' + const capabilities = await getJson<{ model?: string; platform?: string }>( + `${baseUrl.replace(/\/+$/, '')}${prefix}/v1/capabilities`, + { authorization: `Bearer ${apiKey}` } + ) + return { ok: true, detail: capabilities.model ?? capabilities.platform ?? 'reachable' } + } catch (error: unknown) { + return { ok: false, detail: error instanceof Error ? error.message : String(error) } } } -async function promptModel( - provider: ProjectConfig['agent']['provider'] -): Promise { - const options = getModelOptions(provider) - const choice = assertNotCancel( - await p.select({ - message: 'Model', - options: [ - { value: '', label: 'Provider default' }, - ...options.map((o) => ({ value: o.value, label: o.label, hint: o.hint })), - { value: '__custom__', label: 'Custom…' }, - ], - }) - ) as string +export async function runInit(context: CliContext): Promise { + p.intro(BRAND(' Parallax — connect this machine to your agents ')) - if (choice === '') { - return undefined - } - if (choice !== '__custom__') { - return choice - } + const existing = await context.loadStoredConfig() - const custom = assertNotCancel( - await p.text({ - message: 'Custom model identifier', - validate: (v) => (!v?.trim() ? 'Required.' : undefined), + // ── Cloud ──────────────────────────────────────────────── + p.log.step('Parallax cloud') + const cloudBaseUrl = assertNotCancel( + await requiredText( + 'Cloud API base URL', + existing.cloud?.baseUrl ?? '', + 'https://parallax-cloud.up.railway.app' + ) + ).trim() + + const cloudApiKey = assertNotCancel( + await p.password({ + message: 'Runner API key (prx_rnr_…)', + validate: (value) => + value?.startsWith('prx_rnr_') + ? undefined + : 'Expected a runner key. Create one with the cloud org:create command.', }) - ) as string - return custom.trim() -} - -export async function runInit(_args: string[], context: CliContext) { - printWelcomeBanner(context.cliVersion) + ) - const storedConfig = await context.loadStoredConfig() - const isFirstRun = storedConfig.projects.length === 0 + const runnerName = assertNotCancel( + await requiredText('Name for this runner', existing.cloud?.runnerName ?? 'cerebro') + ).trim() - if (!isFirstRun) { - p.intro(`${orange('◆')} ${chalk.bold('Add another project')}`) + // ── Hermes ─────────────────────────────────────────────── + p.log.step('Hermes gateway') - const action = assertNotCancel( - await p.select({ - message: `Found ${storedConfig.projects.length} existing project(s). What would you like to do?`, - options: [ - { value: 'add', label: 'Add another project' }, - { value: 'open', label: 'Open dashboard' }, - { value: 'exit', label: 'Exit' }, - ], - }) - ) + const install = await discoverLocalHermes() - if (action === 'open') { - let url = `http://localhost:9372` - try { - const state = await context.loadRunningState() - url = `http://localhost:${state.uiPort}` - } catch { - // orchestrator not running, use default port - } - p.note(url, 'Dashboard URL') - p.outro("Open the URL above in your browser, or run 'parallax open'.") - return - } - - if (action === 'exit') { - p.outro('Bye.') - return + if (install) { + p.log.info(`Found a Hermes install at ${install.home}`) + if (!install.apiServerEnabled) { + p.log.warn( + `API_SERVER_ENABLED is not set in ${install.home}/.env — the gateway will not serve an API.` + ) } } else { - p.intro(`${orange('◆')} ${chalk.bold("Welcome — let's get you set up")}`) - p.note( - [ - 'This wizard sets up your first project. You can add more', - 'projects, integrations (Slack, Linear, etc.), and secrets', - `from the dashboard at any time — or by running ${chalk.cyan('parallax init')} again.`, - ].join('\n'), - 'First project setup' + p.log.warn( + `No Hermes install at ${resolveHermesHome()}. You can still configure profiles by hand.` ) } - // --- Project setup --- - - const projectId = assertNotCancel( - await p.text({ - message: 'Project ID', - placeholder: 'my-app', - validate: (v) => { - if (!v?.trim()) { - return 'Project ID is required.' - } - if (/\s/.test(v)) { - return 'Project ID must not contain spaces.' - } - if (storedConfig.projects.some((proj) => proj.id === v.trim())) { - return `Project ID "${v.trim()}" already exists.` - } - }, - }) + const hermesBaseUrl = assertNotCancel( + await requiredText( + 'Hermes API server base URL', + existing.hermes?.baseUrl ?? defaultHermesBaseUrl(install) + ) + ).trim() + + const previous = new Map( + (existing.hermes?.profiles ?? []).map((profile) => [profile.name, profile]) ) - const workspaceDir = assertNotCancel( - await p.path({ - message: 'Local git repository (use Tab to navigate, Enter to accept)', - directory: true, - initialValue: process.cwd(), - validate: validateWorkspaceDir, - }) - ) as string - - const detected = detectGitHubRemote(workspaceDir.trim()) - - const provider = assertNotCancel( - await p.select({ - message: 'Where should Parallax pull tasks from?', - options: [ - { - value: 'github', - label: 'GitHub Issues', - hint: detected ? `detected: ${detected.owner}/${detected.repo}` : undefined, - }, - { value: 'linear', label: 'Linear' }, - ], - }) - ) as 'github' | 'linear' + /** Confirms one profile and collects the bits Parallax needs beyond the key. */ + async function configureProfile( + name: string, + discovered?: LocalHermesProfile + ): Promise { + const remembered = previous.get(name) + + // Prefer what is on disk: it is the key the gateway is actually serving. + let apiKey = discovered?.apiKey ?? remembered?.apiKey + if (apiKey) { + p.log.info( + `Using the API_SERVER_KEY from ${discovered?.apiKey ? discovered.envPath : 'your existing Parallax config'}` + ) + } else { + apiKey = assertNotCancel( + await p.password({ + message: `API_SERVER_KEY for "${name}"`, + validate: (value) => (value?.trim() ? undefined : 'Required.'), + }) + ) + } + + const spinner = p.spinner() + spinner.start(`Checking ${name}`) + const probe = await probeProfile(hermesBaseUrl, name, apiKey) + spinner.stop(probe.ok ? `${name}: ${probe.detail}` : `${name}: unreachable`) - let filters: ProjectConfig['pullFrom']['filters'] = {} - let needsLinearKey = false + if (!probe.ok) { + p.log.error(probe.detail) + const keep = assertNotCancel( + await p.confirm({ message: `Add "${name}" anyway?`, initialValue: false }) + ) + if (!keep) { + return undefined + } + } - if (provider === 'github') { - const owner = assertNotCancel( + const role = assertNotCancel( await p.text({ - message: 'GitHub owner or org', - initialValue: detected?.owner, - validate: (v) => (!v?.trim() ? 'Required.' : undefined), + message: `Role for "${name}" (optional)`, + initialValue: remembered?.role ?? '', + placeholder: 'product, reviewer…', }) - ) - const repo = assertNotCancel( + ).trim() + + const githubLogin = assertNotCancel( await p.text({ - message: 'GitHub repository name', - initialValue: detected?.repo, - validate: (v) => (!v?.trim() ? 'Required.' : undefined), + message: `GitHub login for "${name}" (optional)`, + initialValue: remembered?.githubLogin ?? '', + placeholder: 'Needed only for PR-review routes', }) - ) - const labelFilter = assertNotCancel( - await p.text({ message: 'Filter by label (optional, e.g. ai-ready)', placeholder: '' }) - ) - filters = { - owner: owner.trim(), - repo: repo.trim(), - state: 'open', - labels: labelFilter.trim() ? [labelFilter.trim()] : undefined, - } - } else { - const team = assertNotCancel( + ).trim() + + const avatarUrl = assertNotCancel( await p.text({ - message: 'Linear team ID or key', - validate: (v) => (!v?.trim() ? 'Required.' : undefined), + message: `Avatar image URL for "${name}" (optional)`, + initialValue: remembered?.avatarUrl ?? '', + placeholder: 'Shown beside this agent’s Slack notifications', }) - ) - const labelFilter = assertNotCancel( - await p.text({ message: 'Filter by label (optional)', placeholder: '' }) - ) - filters = { - team: team.trim(), - labels: labelFilter.trim() ? [labelFilter.trim()] : undefined, + ).trim() + + return { + name, + apiKey, + enabled: true, + ...(role ? { role } : {}), + ...(githubLogin ? { githubLogin } : {}), + ...(avatarUrl ? { avatarUrl } : {}), } - needsLinearKey = !storedConfig.secrets['LINEAR_API_KEY'] } - const agentProvider = assertNotCancel( - await p.select({ - message: 'Which AI agent should work on this project?', - options: [ - { value: 'claude-code', label: 'Claude Code' }, - { value: 'codex', label: 'OpenAI Codex' }, - { value: 'gemini', label: 'Google Gemini' }, - ], - }) - ) as ProjectConfig['agent']['provider'] + const profiles: HermesProfileConfig[] = [] - const modelOverride = await promptModel(agentProvider) + if (install && install.profiles.length > 0) { + p.log.step(`Found ${install.profiles.length} profile(s)`) - // --- Secrets --- + for (const discovered of install.profiles) { + const label = discovered.apiKey + ? `Add "${discovered.name}"?` + : `Add "${discovered.name}"? (no API_SERVER_KEY found in its .env)` - let linearApiKey: string | undefined + const include = assertNotCancel( + await p.confirm({ message: label, initialValue: Boolean(discovered.apiKey) }) + ) + if (!include) { + continue + } - if (needsLinearKey) { - linearApiKey = assertNotCancel( - await p.password({ - message: 'Linear API key', - validate: (v) => (!v?.trim() ? 'Required for Linear integration.' : undefined), - }) - ) as string + const profile = await configureProfile(discovered.name, discovered) + if (profile) { + profiles.push(profile) + } + } } - // --- Slack (offered once if not already configured) --- - - let slackConfig: SlackConfig | undefined - - if (!storedConfig.slack) { - const wantSlack = assertNotCancel( - await p.confirm({ message: 'Set up Slack notifications?', initialValue: false }) + // Always allow adding one by hand: a profile can live on another host, and + // discovery finding nothing must not be a dead end. + for (;;) { + const prompt = + profiles.length === 0 + ? 'No profiles added yet. Add one by name?' + : 'Add another profile by name?' + const more = assertNotCancel( + await p.confirm({ message: prompt, initialValue: profiles.length === 0 }) ) + if (!more) { + break + } - if (wantSlack) { - const botToken = assertNotCancel( - await p.password({ - message: 'Bot token', - validate: (v) => { - if (!v?.trim()) { - return 'Required.' - } - if (!v.trim().startsWith('xoxb-')) { - return 'Must start with xoxb-' - } - }, - }) - ) - const appToken = assertNotCancel( - await p.password({ - message: 'App token', - validate: (v) => { - if (!v?.trim()) { - return 'Required.' - } - if (!v.trim().startsWith('xapp-')) { - return 'Must start with xapp-' - } - }, - }) - ) - const channel = assertNotCancel( - await p.text({ - message: 'Slack channel', - placeholder: '#eng-ai', - validate: (v) => (!v?.trim() ? 'Required.' : undefined), - }) - ) - slackConfig = { - botToken: botToken.trim(), - appToken: appToken.trim(), - channel: channel.trim(), - } + const name = assertNotCancel(await requiredText('Profile name')).trim() + if (profiles.some((profile) => profile.name === name)) { + p.log.warn(`"${name}" was already added.`) + continue + } + + const profile = await configureProfile(name) + if (profile) { + profiles.push(profile) } } - // --- Build new project --- + if (profiles.length === 0) { + p.cancel('No Hermes profiles configured; there would be nothing to dispatch to.') + return + } - const newProject: ProjectConfig = { - id: projectId.trim(), - workspaceDir: workspaceDir.trim() || process.cwd(), - pullFrom: { provider, filters }, - agent: { - provider: agentProvider, - model: modelOverride, - }, + // ── Secrets ────────────────────────────────────────────── + const secrets = { ...existing.secrets } + const needsLinear = assertNotCancel( + await p.confirm({ message: 'Will any project pull from Linear?', initialValue: false }) + ) + if (needsLinear && !secrets.LINEAR_API_KEY) { + secrets.LINEAR_API_KEY = assertNotCancel( + await p.password({ + message: 'Linear API key', + validate: (value) => (value?.trim() ? undefined : 'Required.'), + }) + ) } - // --- Confirmation --- + const config: StoredConfig = { + version: CONFIG_VERSION, + cloud: { baseUrl: cloudBaseUrl.replace(/\/+$/, ''), apiKey: cloudApiKey, runnerName }, + hermes: { baseUrl: hermesBaseUrl.replace(/\/+$/, ''), profiles }, + // Projects and routes are cloud-side configuration; the runner pulls them. + projects: existing.projects, + secrets, + updatedAt: Date.now(), + } p.note( [ - `ID: ${newProject.id}`, - `Workspace: ${newProject.workspaceDir}`, - `Provider: ${provider}`, - `Agent: ${agentProvider}${newProject.agent.model ? ` (${newProject.agent.model})` : ''}`, - slackConfig ? `Slack: ${slackConfig.channel}` : '', - ] - .filter(Boolean) - .join('\n'), - 'Summary' + `Cloud: ${config.cloud!.baseUrl} (runner "${runnerName}")`, + `Hermes: ${config.hermes!.baseUrl}`, + `Profiles: ${profiles.map((profile) => profile.name).join(', ')}`, + ].join('\n'), + 'Configuration' ) - const confirmed = assertNotCancel(await p.confirm({ message: 'Save this configuration?' })) - + const confirmed = assertNotCancel( + await p.confirm({ message: 'Save to ~/.parallax/config.json?', initialValue: true }) + ) if (!confirmed) { - p.cancel('Setup cancelled.') + p.cancel('Nothing was written.') return } - // --- Write --- - - const updatedConfig = { - ...storedConfig, - projects: [...storedConfig.projects, newProject], - slack: slackConfig ?? storedConfig.slack, - secrets: linearApiKey - ? { ...storedConfig.secrets, LINEAR_API_KEY: linearApiKey } - : storedConfig.secrets, - } - - await context.saveStoredConfig(updatedConfig) + await context.saveStoredConfig(config) - // Reload orchestrator if running - let alreadyRunning = false - try { - const state = await context.loadRunningState() - const reloadRes = await fetch(`http://localhost:${state.apiPort}/runtime/reload`, { - method: 'POST', - }) - if (reloadRes.ok) { - alreadyRunning = true - } else { - console.warn( - `Warning: orchestrator reload returned ${reloadRes.status}. You may need to restart Parallax.` - ) - } - } catch { - // not running, ignore - } - - const nextSteps = alreadyRunning - ? [ - `${chalk.dim('•')} Project added. Parallax is already running.`, - `${chalk.dim('•')} Run ${chalk.cyan('parallax open')} to view the dashboard.`, - ] - : [ - `${chalk.dim('•')} Run ${chalk.cyan('parallax start')} to launch the orchestrator.`, - `${chalk.dim('•')} Run ${chalk.cyan('parallax open')} to view the dashboard.`, - `${chalk.dim('•')} Manage projects, integrations and secrets from the dashboard.`, - ] - - p.note(nextSteps.join('\n'), 'Next steps') - p.outro(orange('Setup complete.')) + p.outro( + [ + BRAND('Saved.'), + '', + 'Next:', + ' parallax preflight check this machine can reach everything', + ' parallax start run the orchestrator', + ' parallax runner install keep it running across reboots', + ].join('\n') + ) } diff --git a/packages/cli/src/commands/logs.ts b/packages/cli/src/commands/logs.ts index da59088..54222f7 100644 --- a/packages/cli/src/commands/logs.ts +++ b/packages/cli/src/commands/logs.ts @@ -1,80 +1,65 @@ import chalk from 'chalk' -import { sleep } from '@parallax/common' -import { parseLogsOptions } from '../args.js' -import type { CliContext } from '../types.js' +import { sleep, type RunLogEntry, type RunRecord } from '@parallax/common' +import { getJson, runnerUnreachable } from '../api.js' +import type { CliContext, LogsCommandOptions } from '../types.js' -export type TaskLogsApiRecord = { - taskExternalId: string - message: string - icon: string - level: 'info' | 'warning' | 'error' - timestamp: number -} - -export function formatLogLine(entry: TaskLogsApiRecord, colors: typeof chalk = chalk): string { - const timestamp = colors.dim(new Date(entry.timestamp).toISOString()) - const taskExternalId = colors.magenta(`[${entry.taskExternalId}]`) - const level = - entry.level === 'warning' - ? colors.yellow(entry.level.toUpperCase()) - : entry.level === 'error' - ? colors.red(entry.level.toUpperCase()) - : colors.blue(entry.level.toUpperCase()) - const icon = - entry.level === 'warning' - ? colors.yellow(entry.icon) - : entry.level === 'error' - ? colors.red(entry.icon) - : colors.blue(entry.icon) - - return `${timestamp} ${taskExternalId} ${level} ${icon} ${entry.message}` -} - -async function fetchJson(url: string): Promise { - const response = await fetch(url) - if (!response.ok) { - throw new Error(`Request failed: ${url} ${response.status} ${response.statusText}`) - } +const LEVEL_COLOR = { + info: chalk.white, + warning: chalk.yellow, + error: chalk.red, +} as const - return (await response.json()) as T +function render(entry: RunLogEntry): void { + const time = new Date(entry.timestamp).toLocaleTimeString() + const color = LEVEL_COLOR[entry.level] ?? chalk.white + const title = entry.title ? chalk.cyan(`${entry.title} `) : '' + console.log(`${chalk.dim(time)} ${entry.icon} ${title}${color(entry.message)}`) } -export async function runLogs(args: string[], context: CliContext) { - const options = parseLogsOptions(args) +/** + * Tails one run, or the most recent one. + * + * Polls rather than streams: the runner has no socket layer any more, and a + * 1s poll against local SQLite is cheaper than the machinery a stream needs. + */ +export async function runLogs(context: CliContext, options: LogsCommandOptions): Promise { const apiBase = await context.resolveDefaultApiBase() - let cursor = Date.now() - let seenAtCursor = new Set() - while (true) { - const params = new URLSearchParams({ - since: String(cursor), - limit: '500', + let runId = options.runId + if (!runId) { + const { runs } = await getJson<{ runs: RunRecord[] }>(`${apiBase}/runs?limit=1`).catch(() => { + throw runnerUnreachable(apiBase) }) - if (options.taskId) { - params.set('taskId', options.taskId) + if (runs.length === 0) { + console.log(chalk.yellow('No runs yet.')) + return } + runId = runs[0].id + console.log(chalk.dim(`Showing ${runId} — ${runs[0].title}\n`)) + } - const response = await fetchJson<{ logs: TaskLogsApiRecord[] }>( - `${apiBase}/logs?${params.toString()}` + let since = 0 + for (;;) { + const { events } = await getJson<{ events: RunLogEntry[] }>( + `${apiBase}/runs/${runId}/events?since=${since}` ) - for (const entry of response.logs) { - const signature = `${entry.timestamp}|${entry.level}|${entry.icon}|${entry.message}` - if (entry.timestamp < cursor) { - continue - } - if (entry.timestamp === cursor && seenAtCursor.has(signature)) { - continue - } + for (const entry of events) { + render(entry) + // +1 so the next poll does not replay the newest event. + since = Math.max(since, entry.timestamp + 1) + } + + if (!options.follow) { + return + } - console.log(formatLogLine(entry)) - if (entry.timestamp > cursor) { - cursor = entry.timestamp - seenAtCursor = new Set() - } - seenAtCursor.add(signature) + const { run } = await getJson<{ run: RunRecord }>(`${apiBase}/runs/${runId}`) + if (['completed', 'failed', 'canceled'].includes(run.status)) { + console.log(chalk.dim(`\nRun ${run.status}.`)) + return } - await sleep(2000) + await sleep(1_000) } } diff --git a/packages/cli/src/commands/open.ts b/packages/cli/src/commands/open.ts deleted file mode 100644 index c8632ae..0000000 --- a/packages/cli/src/commands/open.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { execSync } from 'node:child_process' -import type { CliContext } from '../types.js' - -export async function runOpen(_args: string[], context: CliContext) { - let url = `http://localhost:9372` - - try { - const state = await context.loadRunningState() - url = `http://localhost:${state.uiPort}` - } catch { - throw new Error( - `Parallax is not running. Start it first with 'parallax start', then open: ${url}` - ) - } - - try { - const opener = - process.platform === 'darwin' - ? 'open' - : process.platform === 'win32' - ? 'start ""' - : 'xdg-open' - execSync(`${opener} "${url}"`, { stdio: 'ignore' }) - console.log(`Opened ${url}`) - } catch { - console.log(`Dashboard: ${url}`) - } -} diff --git a/packages/cli/src/commands/pr-review.ts b/packages/cli/src/commands/pr-review.ts deleted file mode 100644 index 1a7fee6..0000000 --- a/packages/cli/src/commands/pr-review.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { parsePrReviewOptions } from '../args.js' -import type { CliContext } from '../types.js' - -const YELLOW = '\x1b[33m' -const BOLD = '\x1b[1m' -const RESET = '\x1b[0m' - -async function postJson(url: string, body: unknown): Promise { - const response = await fetch(url, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - }) - - if (!response.ok) { - const payload = (await response.json().catch(() => undefined)) as { error?: string } | undefined - throw new Error( - payload?.error ?? `Request failed: ${url} ${response.status} ${response.statusText}` - ) - } - - return response.json() as Promise -} - -export async function runPrReview(args: string[], context: CliContext) { - const options = parsePrReviewOptions(args) - const apiBase = await context.resolveDefaultApiBase() - - console.log('') - console.log(`${YELLOW}${BOLD}⚠ Experimental: pr-review is an early on-demand workflow.${RESET}`) - console.log( - `${YELLOW}It will try to apply open human PR review comments to the existing PR branch.${RESET}` - ) - console.log('') - - let queuedTask: { reviewTaskId: string; prNumber: number } - try { - queuedTask = await postJson( - `${apiBase}/tasks/${encodeURIComponent(options.taskId)}/pr-review`, - {} - ) - } catch (error) { - throw new Error( - `Failed to queue PR review for task ${options.taskId}: ${ - error instanceof Error ? error.message : String(error) - }` - ) - } - - console.log( - `Queued PR review run for task ${options.taskId} as new task ${queuedTask.reviewTaskId} on PR #${queuedTask.prNumber}.` - ) -} diff --git a/packages/cli/src/commands/preflight.ts b/packages/cli/src/commands/preflight.ts index 2092233..fa4d5d8 100644 --- a/packages/cli/src/commands/preflight.ts +++ b/packages/cli/src/commands/preflight.ts @@ -1,120 +1,122 @@ -import { parsePreflightOptions } from '../args.js' -import { checkGhAuth, commandExists, startSpinner } from '../process.js' -import type { VerifyCheck } from '../types.js' - -function isSupportedNodeVersion(version: string): boolean { - const [majorRaw, minorRaw, patchRaw] = version.replace(/^v/, '').split('.') - const major = Number.parseInt(majorRaw ?? '0', 10) - const minor = Number.parseInt(minorRaw ?? '0', 10) - const patch = Number.parseInt(patchRaw ?? '0', 10) - - if (!Number.isFinite(major) || !Number.isFinite(minor) || !Number.isFinite(patch)) { - return false - } - - if (major > 23) { - return true - } - if (major < 23) { - return false - } - if (minor > 7) { - return true - } - if (minor < 7) { - return false - } - - return patch >= 0 +import { spawn } from 'node:child_process' +import chalk from 'chalk' +import type { CliContext, VerifyCheck } from '../types.js' +import { getJson } from '../api.js' +import { findCapableNode } from '../node-runtime.js' + +async function commandSucceeds(cmd: string, args: string[]): Promise { + return new Promise((resolve) => { + const child = spawn(cmd, args, { stdio: 'ignore' }) + child.on('error', () => resolve(false)) + child.on('close', (code) => resolve(code === 0)) + }) } -function printVerifyChecks(checks: VerifyCheck[]) { - const GREEN = '\x1b[32m' - const RED = '\x1b[31m' - const DIM = '\x1b[2m' - const RESET = '\x1b[0m' - - for (const check of checks) { - const symbol = check.ok ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}` - const scope = check.required ? '' : ` ${DIM}(optional)${RESET}` - const detail = check.detail ? ` ${DIM}- ${check.detail}${RESET}` : '' - console.log(`${symbol} ${check.name}${scope}${detail}`) - } -} - -export async function runPreflight(args: string[]) { - parsePreflightOptions(args) +/** + * Checks exactly what this runner needs. + * + * Notably absent: git, pnpm, and any agent CLI. The runner does not execute + * agents or touch a repository any more -- Hermes does both -- so requiring + * them here would fail machines that are in fact correctly configured. + */ +export async function runPreflight(context: CliContext): Promise { const checks: VerifyCheck[] = [] - const spinner = startSpinner('Running preflight checks...') - - try { - checks.push({ - name: 'Node.js >= 23.7.0', - ok: isSupportedNodeVersion(process.version), - required: true, - detail: isSupportedNodeVersion(process.version) ? undefined : `Current: ${process.version}`, - }) - - const gitOk = await commandExists('git') - checks.push({ name: 'git CLI', ok: gitOk, required: true }) - const pnpmOk = await commandExists('pnpm') - checks.push({ name: 'pnpm CLI', ok: pnpmOk, required: true }) - - const ghOk = await commandExists('gh') - checks.push({ name: 'gh CLI', ok: ghOk, required: true }) - - const ghAuthOk = ghOk ? await checkGhAuth() : false - checks.push({ - name: 'gh auth status', - ok: ghAuthOk, - required: true, - detail: ghAuthOk ? undefined : 'Run: gh auth login', - }) + // Capability, not version: what matters is whether node:sqlite loads, and + // which interpreter the runner will actually be started with. + const runtime = findCapableNode(context.defaultDataDir) + checks.push({ + name: 'A Node that can load node:sqlite', + ok: Boolean(runtime), + required: true, + detail: runtime + ? `${runtime.version ?? '?'} at ${runtime.binary}` + : `none found (running ${process.version})`, + }) + + const config = await context.loadStoredConfig() + + checks.push({ + name: 'Configuration present', + ok: Boolean(config.hermes && config.cloud), + required: true, + detail: config.hermes && config.cloud ? '~/.parallax/config.json' : 'run "parallax init"', + }) + + if (config.hermes) { + for (const profile of config.hermes.profiles.filter((entry) => entry.enabled)) { + const prefix = profile.name === 'default' ? '' : `/p/${profile.name}` + let detail = '' + let ok = false + try { + const capabilities = await getJson<{ model?: string; platform?: string }>( + `${config.hermes.baseUrl}${prefix}/v1/capabilities`, + { authorization: `Bearer ${profile.apiKey}` } + ) + ok = true + detail = capabilities.model ?? capabilities.platform ?? 'reachable' + } catch (error: unknown) { + detail = error instanceof Error ? error.message : String(error) + } + checks.push({ name: `Hermes profile "${profile.name}"`, ok, required: true, detail }) + } + } - const codexOk = await commandExists('codex') - checks.push({ - name: 'codex CLI', - ok: codexOk, - required: false, - detail: codexOk ? undefined : 'Install Codex CLI and ensure it is in PATH.', - }) + if (config.cloud) { + let ok = false + let detail = '' + try { + const health = await getJson<{ status: string }>(`${config.cloud.baseUrl}/health`) + ok = health.status === 'ok' + detail = config.cloud.baseUrl + } catch (error: unknown) { + detail = error instanceof Error ? error.message : String(error) + } + checks.push({ name: 'Parallax cloud reachable', ok, required: true, detail }) + } - const geminiOk = await commandExists('gemini') + const ghInstalled = await commandSucceeds('gh', ['--version']) + checks.push({ + name: 'GitHub CLI installed', + ok: ghInstalled, + required: false, + detail: ghInstalled ? '' : 'Needed only for GitHub projects.', + }) + if (ghInstalled) { + const authed = await commandSucceeds('gh', ['auth', 'status']) checks.push({ - name: 'gemini CLI', - ok: geminiOk, + name: 'GitHub CLI authenticated', + ok: authed, required: false, - detail: geminiOk ? undefined : 'Install Gemini CLI (npm i -g @google/gemini-cli).', + // Only show the remedy when there is something to remedy. + detail: authed ? '' : 'gh auth login', }) + } - const claudeOk = await commandExists('claude') - checks.push({ - name: 'claude CLI', - ok: claudeOk, - required: false, - detail: claudeOk - ? undefined - : 'Install Claude Code CLI (npm i -g @anthropic-ai/claude-code).', - }) + checks.push({ + name: 'LINEAR_API_KEY', + ok: Boolean(process.env.LINEAR_API_KEY || config.secrets.LINEAR_API_KEY), + required: false, + detail: 'Needed only for Linear projects.', + }) - checks.push({ - name: 'At least one agent CLI (codex, gemini, or claude)', - ok: codexOk || geminiOk || claudeOk, - required: true, - detail: codexOk || geminiOk || claudeOk ? undefined : 'Install codex, gemini, or claude.', - }) - } finally { - spinner?.stop() + console.log('') + for (const check of checks) { + const mark = check.ok + ? chalk.green('ok ') + : check.required + ? chalk.red('FAIL') + : chalk.yellow('warn') + const suffix = check.detail ? chalk.dim(` ${check.detail}`) : '' + console.log(` ${mark} ${check.name}${suffix}`) } - printVerifyChecks(checks) - - if (checks.some((check) => check.required && !check.ok)) { - console.log('\nVerdict: FAIL - Parallax is not ready to run in this environment.') + const failed = checks.filter((check) => check.required && !check.ok) + console.log('') + if (failed.length > 0) { + console.log(chalk.red(`Verdict: FAIL (${failed.length} required check(s))`)) process.exitCode = 1 return } - - console.log('\nVerdict: PASS - Parallax prerequisites are satisfied.') + console.log(chalk.green('Verdict: ready')) } diff --git a/packages/cli/src/commands/projects.ts b/packages/cli/src/commands/projects.ts new file mode 100644 index 0000000..383b6b5 --- /dev/null +++ b/packages/cli/src/commands/projects.ts @@ -0,0 +1,42 @@ +import chalk from 'chalk' +import type { ProjectConfig } from '@parallax/common' +import { getJson, runnerUnreachable } from '../api.js' +import type { CliContext } from '../types.js' + +export async function runProjects(context: CliContext): Promise { + const apiBase = await context.resolveDefaultApiBase() + + const { projects } = await getJson<{ projects: ProjectConfig[] }>(`${apiBase}/projects`).catch( + () => { + throw runnerUnreachable(apiBase) + } + ) + + if (projects.length === 0) { + console.log(chalk.yellow('No projects. Nothing can trigger.')) + console.log(chalk.dim(' Projects are cloud configuration: POST /v1/projects')) + console.log(chalk.dim(' Then "parallax reload" to pick them up without a restart.')) + return + } + + console.log('') + for (const project of projects) { + console.log(` ${chalk.bold(project.id)} ${chalk.dim(project.provider)}`) + const filters = Object.entries(project.filters).filter(([, value]) => value !== undefined) + if (filters.length === 0) { + console.log(chalk.dim(' filters (none)')) + } + for (const [key, value] of filters) { + console.log( + chalk.dim(` ${key.padEnd(9)} ${Array.isArray(value) ? value.join(', ') : value}`) + ) + } + } + // The most common reason a route never fires: the source filter never + // fetched the ticket in the first place. + console.log('') + console.log( + chalk.dim(' Filters narrow what is fetched. A route can only match what a filter let through.') + ) + console.log('') +} diff --git a/packages/cli/src/commands/reload.ts b/packages/cli/src/commands/reload.ts new file mode 100644 index 0000000..c1c8ed3 --- /dev/null +++ b/packages/cli/src/commands/reload.ts @@ -0,0 +1,20 @@ +import chalk from 'chalk' +import { postJson, runnerUnreachable } from '../api.js' +import type { CliContext } from '../types.js' + +/** Re-reads local config and re-pulls projects, routes, and agents from the cloud. */ +export async function runReload(context: CliContext): Promise { + const apiBase = await context.resolveDefaultApiBase() + + const result = await postJson<{ projects: number; routes: number }>( + `${apiBase}/runtime/reload`, + {} + ).catch((error: unknown) => { + if (error instanceof Error && error.message.includes('Could not reach')) { + throw runnerUnreachable(apiBase) + } + throw error + }) + + console.log(chalk.green(`Reloaded: ${result.projects} project(s), ${result.routes} route(s).`)) +} diff --git a/packages/cli/src/commands/restart.ts b/packages/cli/src/commands/restart.ts new file mode 100644 index 0000000..90cf7a8 --- /dev/null +++ b/packages/cli/src/commands/restart.ts @@ -0,0 +1,18 @@ +import chalk from 'chalk' +import { runStart } from './start.js' +import { runStop } from './stop.js' +import type { CliContext, StartCommandOptions } from '../types.js' + +/** + * Stop then start. + * + * Projects and routes refresh on their own each cycle, so this is for changes + * that genuinely need a new process -- editing ~/.parallax/config.json, or + * installing a new build. `parallax reload` covers everything else without + * dropping in-flight runs. + */ +export async function runRestart(context: CliContext, options: StartCommandOptions): Promise { + await runStop(context).catch(() => undefined) + console.log(chalk.dim('Restarting…')) + await runStart(context, options) +} diff --git a/packages/cli/src/commands/retry.ts b/packages/cli/src/commands/retry.ts deleted file mode 100644 index b80b526..0000000 --- a/packages/cli/src/commands/retry.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { parseRetryOptions } from '../args.js' -import type { CliContext } from '../types.js' - -export async function runRetry(args: string[], context: CliContext) { - const options = parseRetryOptions(args) - - let apiBase: string - try { - apiBase = await context.resolveDefaultApiBase() - } catch { - throw new Error("Parallax is not running. Start it first with 'parallax start'.") - } - - const url = `${apiBase}/tasks/${encodeURIComponent(options.taskId)}/retry` - const response = await fetch(url, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: '{}', - }) - - if (response.status === 404) { - throw new Error( - `Task not found: ${options.taskId}. List tasks in the dashboard or check 'parallax status'.` - ) - } - if (response.status === 409) { - throw new Error(`Task ${options.taskId} is already running.`) - } - if (!response.ok) { - const body = await response.text().catch(() => '') - throw new Error(`Retry failed (${response.status}): ${body || response.statusText}`) - } - - console.log(`Retried: ${options.taskId}`) -} diff --git a/packages/cli/src/commands/routes.ts b/packages/cli/src/commands/routes.ts new file mode 100644 index 0000000..20e45e4 --- /dev/null +++ b/packages/cli/src/commands/routes.ts @@ -0,0 +1,40 @@ +import chalk from 'chalk' +import type { RoutingRule } from '@parallax/common' +import { getJson, runnerUnreachable } from '../api.js' +import type { CliContext } from '../types.js' + +export async function runRoutes(context: CliContext): Promise { + const apiBase = await context.resolveDefaultApiBase() + + const { routes } = await getJson<{ routes: RoutingRule[] }>(`${apiBase}/routes`).catch(() => { + throw runnerUnreachable(apiBase) + }) + + if (routes.length === 0) { + console.log(chalk.yellow('No routes loaded.')) + console.log(chalk.dim(' Create them against the cloud API: POST /v1/routes')) + return + } + + console.log('') + for (const route of routes) { + const mark = route.enabled ? chalk.green('*') : chalk.dim('-') + const target = route.target.agentRef.profile ?? `@${route.target.agentRef.githubLogin}` + console.log( + ` ${mark} ${chalk.bold(route.name)} ${chalk.dim(`(${route.id}, p${route.priority})`)}` + ) + console.log(chalk.dim(` when ${route.trigger.type} on ${route.trigger.projectId}`)) + if (route.match.labels?.any?.length) { + console.log(chalk.dim(` labels ${route.match.labels.any.join(' | ')}`)) + } + if (route.match.state?.any?.length) { + console.log(chalk.dim(` state ${route.match.state.any.join(' | ')}`)) + } + const firstLine = route.execution.prompt.split('\n').find((line) => line.trim()) ?? '' + console.log(chalk.dim(` then ${target}`)) + console.log( + chalk.dim(` prompt ${firstLine.slice(0, 68)}${firstLine.length > 68 ? '…' : ''}`) + ) + } + console.log('') +} diff --git a/packages/cli/src/commands/run.ts b/packages/cli/src/commands/run.ts new file mode 100644 index 0000000..2131ab3 --- /dev/null +++ b/packages/cli/src/commands/run.ts @@ -0,0 +1,123 @@ +import chalk from 'chalk' +import { sleep } from '@parallax/common' +import type { CliContext, RunCommandOptions } from '../types.js' + +interface RunState { + status: string + output?: unknown + error?: string + usage?: { total_tokens?: number } +} + +function textOf(value: unknown): string { + if (typeof value === 'string') { + return value + } + if (Array.isArray(value)) { + return value.map(textOf).filter(Boolean).join('') + } + if (value && typeof value === 'object') { + const record = value as Record + for (const key of ['text', 'content', 'message', 'output']) { + if (key in record) { + const nested = textOf(record[key]) + if (nested) { + return nested + } + } + } + } + return '' +} + +/** + * Sends one prompt straight to a Hermes profile and prints the result. + * + * A deliberate shortcut around routes, triggers, and the dispatcher: when + * something is wrong on the Mac Mini, this answers "can this machine drive that + * agent at all?" without any Parallax logic in the way. It talks to Hermes + * directly rather than through the runner, so it works while the runner is down. + */ +export async function runSmokeTest(context: CliContext, options: RunCommandOptions): Promise { + const config = await context.loadStoredConfig() + if (!config.hermes) { + throw new Error('No Hermes gateway configured. Run "parallax init" first.') + } + + const profile = config.hermes.profiles.find((entry) => entry.name === options.agent) + if (!profile) { + const known = config.hermes.profiles.map((entry) => entry.name).join(', ') + throw new Error(`Unknown profile "${options.agent}". Configured: ${known || 'none'}.`) + } + + const prefix = profile.name === 'default' ? '' : `/p/${profile.name}` + const base = `${config.hermes.baseUrl}${prefix}` + const headers = { + authorization: `Bearer ${profile.apiKey}`, + 'content-type': 'application/json', + } + + const created = await fetch(`${base}/v1/runs`, { + method: 'POST', + headers, + body: JSON.stringify({ input: options.prompt }), + signal: AbortSignal.timeout(30_000), + }) + + if (!created.ok) { + throw new Error( + `Hermes rejected the run (${created.status}): ${(await created.text()).slice(0, 300)}` + ) + } + + const { run_id: runId } = (await created.json()) as { run_id?: string } + if (!runId) { + throw new Error('Hermes accepted the request but returned no run_id.') + } + + console.log(chalk.dim(`run ${runId} on profile "${profile.name}"`)) + + const deadline = Date.now() + options.timeoutSeconds * 1_000 + let last = '' + + for (;;) { + if (Date.now() > deadline) { + await fetch(`${base}/v1/runs/${runId}/stop`, { method: 'POST', headers }).catch( + () => undefined + ) + throw new Error(`Timed out after ${options.timeoutSeconds}s; asked Hermes to stop the run.`) + } + + const response = await fetch(`${base}/v1/runs/${runId}`, { + headers, + signal: AbortSignal.timeout(15_000), + }) + if (!response.ok) { + throw new Error(`Status poll failed (${response.status}).`) + } + + const state = (await response.json()) as RunState + if (state.status !== last) { + console.log(chalk.dim(` ${state.status}`)) + last = state.status + } + + if (['completed', 'failed', 'cancelled', 'canceled', 'error'].includes(state.status)) { + console.log('') + const output = textOf(state.output) + console.log(output || chalk.dim('(no output)')) + if (state.error) { + console.log(chalk.red(state.error)) + } + if (state.usage?.total_tokens) { + console.log(chalk.dim(`\n${state.usage.total_tokens} tokens`)) + } + if (state.status !== 'completed') { + process.exitCode = 1 + } + return + } + + await sleep(2_000) + } +} diff --git a/packages/cli/src/commands/runner.ts b/packages/cli/src/commands/runner.ts new file mode 100644 index 0000000..f15acb2 --- /dev/null +++ b/packages/cli/src/commands/runner.ts @@ -0,0 +1,179 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { spawn } from 'node:child_process' +import chalk from 'chalk' +import { LAUNCH_AGENT_LABEL, RUNNER_STDERR_FILE, RUNNER_STDOUT_FILE } from '../constants.js' +import { resolveRunnerEntryPoint } from './start.js' +import { readRecordedNode, probeNode, resolveRunnerNode, SQLITE_FLAG } from '../node-runtime.js' +import type { CliContext, RunnerCommandOptions } from '../types.js' + +function plistPath(): string { + return path.join(os.homedir(), 'Library', 'LaunchAgents', `${LAUNCH_AGENT_LABEL}.plist`) +} + +function escapeXml(value: string): string { + return value.replace(/[<>&'"]/g, (char) => { + switch (char) { + case '<': + return '<' + case '>': + return '>' + case '&': + return '&' + case "'": + return ''' + default: + return '"' + } + }) +} + +/** + * Builds the launchd job. + * + * `KeepAlive` restarts the runner if it exits for any reason, and `RunAtLoad` + * brings it back after a reboot -- the same shape `hermes gateway install` + * uses, so both halves of the system survive a power cut on the Mac Mini. + */ +function buildPlist( + nodeBinary: string, + entry: string, + dataDir: string, + env: Record +): string { + const envEntries = Object.entries(env) + .map( + ([key, value]) => + ` ${escapeXml(key)}\n ${escapeXml(value)}` + ) + .join('\n') + + return ` + + + + Label + ${LAUNCH_AGENT_LABEL} + ProgramArguments + + ${escapeXml(nodeBinary)} + ${escapeXml(SQLITE_FLAG)} + ${escapeXml(entry)} + + EnvironmentVariables + +${envEntries} + + RunAtLoad + + KeepAlive + + StandardOutPath + ${escapeXml(path.join(dataDir, RUNNER_STDOUT_FILE))} + StandardErrorPath + ${escapeXml(path.join(dataDir, RUNNER_STDERR_FILE))} + WorkingDirectory + ${escapeXml(dataDir)} + + +` +} + +async function launchctl(args: string[]): Promise<{ code: number; output: string }> { + return new Promise((resolve) => { + const child = spawn('launchctl', args) + let output = '' + child.stdout.on('data', (chunk) => (output += String(chunk))) + child.stderr.on('data', (chunk) => (output += String(chunk))) + child.on('error', () => resolve({ code: 1, output: 'launchctl not available' })) + child.on('close', (code) => resolve({ code: code ?? 1, output })) + }) +} + +export async function runRunner(context: CliContext, options: RunnerCommandOptions): Promise { + if (process.platform !== 'darwin') { + throw new Error('"parallax runner" manages a macOS launchd agent and only works on macOS.') + } + + const target = plistPath() + const dataDir = context.defaultDataDir + const uid = process.getuid?.() ?? 0 + + if (options.action === 'status') { + const exists = await fs + .access(target) + .then(() => true) + .catch(() => false) + if (!exists) { + console.log(chalk.yellow('Not installed.')) + console.log(chalk.dim(' parallax runner install')) + return + } + const { output } = await launchctl(['print', `gui/${uid}/${LAUNCH_AGENT_LABEL}`]) + const state = output.match(/state = (\w+)/)?.[1] + const pid = output.match(/pid = (\d+)/)?.[1] + console.log( + state === 'running' + ? chalk.green(`Installed and running (pid ${pid ?? '?'}).`) + : chalk.yellow(`Installed but ${state ?? 'not running'}.`) + ) + console.log(chalk.dim(` ${target}`)) + + // The agent runs whatever interpreter it was installed with. If a version + // manager later removes it, the job fails at boot with nothing explaining + // why -- so say it here, while someone is looking. + const pinned = readRecordedNode(dataDir) + if (!pinned) { + console.log(chalk.yellow(' Could not determine which Node it was installed with.')) + } else if (!probeNode(pinned)) { + console.log(chalk.red(` Its Node is gone or unusable: ${pinned}`)) + console.log(chalk.dim(' Run "parallax runner install" again to repin it.')) + } else { + console.log(chalk.dim(` node ${pinned}`)) + } + return + } + + if (options.action === 'uninstall') { + await launchctl(['bootout', `gui/${uid}/${LAUNCH_AGENT_LABEL}`]) + await fs.rm(target, { force: true }) + console.log(chalk.green('Uninstalled the Parallax launch agent.')) + return + } + + const config = await context.loadStoredConfig() + if (!config.hermes) { + throw new Error('Run "parallax init" before installing the launch agent.') + } + + await fs.mkdir(path.dirname(target), { recursive: true }) + await fs.mkdir(dataDir, { recursive: true }) + + const env = context.buildEnvConfig(dataDir, { + apiPort: Number.parseInt(process.env.PARALLAX_SERVER_API_PORT ?? '9371', 10), + concurrency: 2, + networkAccess: false, + }) + + // launchd starts jobs with a minimal PATH, so the node binary must be + // absolute and PATH must be spelled out for `gh` to be findable. + env.PATH = process.env.PATH ?? '/usr/local/bin:/usr/bin:/bin' + env.HOME = os.homedir() + + const runtime = resolveRunnerNode(dataDir) + await fs.writeFile( + target, + buildPlist(runtime.binary, resolveRunnerEntryPoint(context.rootDir), dataDir, env) + ) + + await launchctl(['bootout', `gui/${uid}/${LAUNCH_AGENT_LABEL}`]) + const { code, output } = await launchctl(['bootstrap', `gui/${uid}`, target]) + if (code !== 0) { + throw new Error(`launchctl bootstrap failed: ${output.trim()}`) + } + + console.log(chalk.green('Installed. The runner will start now and on every login.')) + console.log(chalk.dim(` ${target}`)) + console.log(chalk.dim(' parallax runner status')) +} diff --git a/packages/cli/src/commands/runs.ts b/packages/cli/src/commands/runs.ts new file mode 100644 index 0000000..c085e54 --- /dev/null +++ b/packages/cli/src/commands/runs.ts @@ -0,0 +1,62 @@ +import chalk from 'chalk' +import { RUN_STATUS, type RunRecord, type RunStatus } from '@parallax/common' +import { getJson, runnerUnreachable } from '../api.js' +import type { CliContext, RunsCommandOptions } from '../types.js' + +const STATUS_COLOR: Record string> = { + [RUN_STATUS.QUEUED]: chalk.dim, + [RUN_STATUS.RUNNING]: chalk.blue, + [RUN_STATUS.AWAITING_APPROVAL]: chalk.yellow, + [RUN_STATUS.COMPLETED]: chalk.green, + [RUN_STATUS.FAILED]: chalk.red, + [RUN_STATUS.CANCELED]: chalk.dim, +} + +function ago(timestamp: number): string { + const seconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000)) + if (seconds < 60) { + return `${seconds}s ago` + } + if (seconds < 3600) { + return `${Math.floor(seconds / 60)}m ago` + } + if (seconds < 86400) { + return `${Math.floor(seconds / 3600)}h ago` + } + return `${Math.floor(seconds / 86400)}d ago` +} + +export async function runRuns(context: CliContext, options: RunsCommandOptions): Promise { + const apiBase = await context.resolveDefaultApiBase() + const query = new URLSearchParams({ limit: String(options.limit) }) + if (options.status) { + query.set('status', options.status) + } + + const { runs } = await getJson<{ runs: RunRecord[] }>(`${apiBase}/runs?${query}`).catch(() => { + throw runnerUnreachable(apiBase) + }) + + if (runs.length === 0) { + console.log(chalk.yellow('No runs yet.')) + return + } + + console.log('') + for (const run of runs) { + const color = STATUS_COLOR[run.status] ?? chalk.white + console.log( + ` ${color(run.status.padEnd(18))} ${chalk.bold(run.agentProfile.padEnd(12))} ${run.triggerRef}` + ) + console.log(chalk.dim(` ${run.id} ${run.title} · ${ago(run.updatedAt)}`)) + if (run.summary) { + console.log(chalk.dim(` ${run.summary.split('\n')[0].slice(0, 100)}`)) + } + if (run.error) { + console.log(chalk.red(` ${run.error.split('\n')[0].slice(0, 100)}`)) + } + } + console.log('') + console.log(chalk.dim(' parallax logs --run full output for one run')) + console.log('') +} diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 6cea83c..2952ff1 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -1,234 +1,126 @@ import fs from 'node:fs/promises' -import fsSync from 'node:fs' import path from 'node:path' import { createRequire } from 'node:module' -import { parseStartOptions } from '../args.js' -import { buildDashboardUrl, resolveNetworkHostname } from '../network.js' -import { - isProcessAlive, - readFileTail, - spawnDetached, - startSpinner, - stopProcessBestEffort, - waitForUrlHealth, -} from '../process.js' -import type { CliContext } from '../types.js' - -const requireFromCli = createRequire(import.meta.url) - -function resolveOrchestratorEntryPoint(rootDir: string): string { - const packageCandidates = [ - '@parallax/orchestrator/dist/orchestrator/src/index.js', - '@parallax/orchestrator/dist/index.js', +import chalk from 'chalk' +import { spawnDetached, waitForUrlHealth } from '../process.js' +import { RUNNER_STDERR_FILE, RUNNER_STDOUT_FILE } from '../constants.js' +import { resolveRunnerNode, SQLITE_FLAG } from '../node-runtime.js' +import type { CliContext, StartCommandOptions } from '../types.js' + +const require = createRequire(import.meta.url) + +/** Locates the built runner entry point across dev and installed layouts. */ +export function resolveRunnerEntryPoint(rootDir: string): string { + const candidates = [ + () => require.resolve('@parallax/orchestrator/dist/orchestrator/src/index.js'), + () => require.resolve('@parallax/orchestrator/dist/index.js'), + () => require.resolve('@parallax/orchestrator'), ] - for (const candidate of packageCandidates) { + for (const candidate of candidates) { try { - return requireFromCli.resolve(candidate) + return candidate() } catch { continue } } - const localCandidates = [ - path.resolve(rootDir, 'packages/orchestrator/dist/orchestrator/src/index.js'), - path.resolve(rootDir, 'packages/orchestrator/dist/index.js'), - ] - for (const candidate of localCandidates) { - if (fsSync.existsSync(candidate)) { - return candidate - } - } - - throw new Error( - 'Unable to resolve orchestrator runtime. Build dependencies first or reinstall parallax package.' - ) + const fallback = path.resolve(rootDir, 'packages/orchestrator/dist/orchestrator/src/index.js') + return fallback } -export async function runStart(args: string[], context: CliContext) { - const CYAN = '\x1b[36m' - const BLUE = '\x1b[34m' - const GREEN = '\x1b[32m' - const YELLOW = '\x1b[33m' - const DIM = '\x1b[2m' - const RESET = '\x1b[0m' - - const options = parseStartOptions(args) +export async function runStart(context: CliContext, options: StartCommandOptions): Promise { const dataDir = context.defaultDataDir - await fs.mkdir(dataDir, { recursive: true }) - console.log('') - console.log(`${CYAN}⏳ Initializing Parallax...${RESET}`) - console.log(`${BLUE}📁 Data Dir:${RESET} ${DIM}${dataDir}${RESET}`) - console.log('') - - const storedConfig = await context.loadStoredConfig() - if (storedConfig.projects.length === 0) { - console.error(`${YELLOW}No projects configured. Run 'parallax init' to get started.${RESET}`) - process.exit(1) + const config = await context.loadStoredConfig() + if (!config.hermes) { + throw new Error('No Hermes gateway configured. Run "parallax init" first.') } - const env = context.buildEnvConfig(dataDir, { - apiPort: options.apiPort, - uiPort: options.uiPort, - concurrency: options.concurrency, - networkAccess: options.networkAccess, - }) - const workspaceDevMode = process.env.NODE_ENV === 'dev' - const orchestratorStdoutPath = path.join(dataDir, 'orchestrator.stdout.log') - const orchestratorStderrPath = path.join(dataDir, 'orchestrator.stderr.log') - const uiStdoutPath = path.join(dataDir, 'ui.stdout.log') - const uiStderrPath = path.join(dataDir, 'ui.stderr.log') - const spinner = startSpinner('Starting Parallax...') - - let orchestratorPid = 0 - let uiPid = 0 + const manifestPath = path.join(dataDir, context.manifestFile) try { - const existingManifestPath = path.join(dataDir, context.manifestFile) - if (await context.ensureFileExists(existingManifestPath)) { - const existingState = await context.loadRunningState().catch(() => undefined) - const existingUiAlive = - existingState?.uiPid !== undefined ? isProcessAlive(existingState.uiPid) : false - if (existingState && (isProcessAlive(existingState.orchestratorPid) || existingUiAlive)) { - throw new Error( - `Parallax is already running on http://localhost:${existingState.uiPort}. Run 'parallax open' to view the dashboard, or 'parallax stop' to stop it.` - ) - } - - await fs.unlink(existingManifestPath).catch(() => undefined) - } - - await Promise.all([ - fs.writeFile(orchestratorStdoutPath, ''), - fs.writeFile(orchestratorStderrPath, ''), - fs.writeFile(uiStdoutPath, ''), - fs.writeFile(uiStderrPath, ''), - ]) - - if (workspaceDevMode) { - orchestratorPid = spawnDetached( - process.execPath, - ['--import', 'tsx', path.resolve(context.rootDir, 'packages/orchestrator/src/index.ts')], - context.rootDir, - env, - { - stdoutPath: orchestratorStdoutPath, - stderrPath: orchestratorStderrPath, - } - ) - - uiPid = spawnDetached( - 'pnpm', - [ - '--filter', - '@parallax/ui', - 'start', - '--host', - options.networkAccess ? '0.0.0.0' : '127.0.0.1', - '--port', - String(options.uiPort), - ], - context.rootDir, - options.networkAccess - ? { - VITE_PARALLAX_API_PORT: String(options.apiPort), - PARALLAX_NETWORK_ACCESS: 'true', - } - : { - VITE_PARALLAX_API_BASE: `http://localhost:${options.apiPort}`, - PARALLAX_NETWORK_ACCESS: 'false', - }, - { - stdoutPath: uiStdoutPath, - stderrPath: uiStderrPath, - } - ) - } else { - orchestratorPid = spawnDetached( - process.execPath, - [resolveOrchestratorEntryPoint(context.rootDir)], - process.cwd(), - env, - { - stdoutPath: orchestratorStdoutPath, - stderrPath: orchestratorStderrPath, - } - ) - } - - if (orchestratorPid <= 0) { - throw new Error('Failed to spawn orchestrator process.') + const running = await context.loadRunningState() + process.kill(running.runnerPid, 0) + throw new Error( + `Parallax is already running (pid ${running.runnerPid}, port ${running.apiPort}). Run "parallax stop" first.` + ) + } catch (error: unknown) { + // ESRCH means the recorded pid is gone, so the manifest is stale. + const code = (error as { code?: string }).code + if (code === 'ESRCH') { + await fs.rm(manifestPath, { force: true }) + } else if (error instanceof Error && error.message.includes('already running')) { + throw error } + } - if (workspaceDevMode && uiPid <= 0) { - throw new Error('Failed to spawn UI process.') - } + const env = context.buildEnvConfig(dataDir, options) + const entry = resolveRunnerEntryPoint(context.rootDir) + + // An absolute interpreter path, so the daemon keeps working after a version + // switch rather than inheriting whatever `node` happens to mean later. + const runtime = resolveRunnerNode(dataDir) + + if (options.foreground) { + // Inherit stdio so logs go straight to the terminal; used by launchd too. + const { spawn } = await import('node:child_process') + const child = spawn(runtime.binary, [SQLITE_FLAG, entry], { + cwd: context.rootDir, + env: { ...process.env, ...env }, + stdio: 'inherit', + }) + await new Promise((resolve) => child.on('close', () => resolve())) + return + } - await waitForUrlHealth(`http://localhost:${options.apiPort}/tasks`, 'Orchestrator API') - await waitForUrlHealth(`http://localhost:${options.uiPort}`, 'Parallax UI') + const stdout = path.join(dataDir, RUNNER_STDOUT_FILE) + const stderr = path.join(dataDir, RUNNER_STDERR_FILE) + await Promise.all([fs.writeFile(stdout, ''), fs.writeFile(stderr, '')]) - await fs.writeFile( - path.join(dataDir, context.manifestFile), - JSON.stringify( - { - startedAt: Date.now(), - orchestratorPid, - uiPid: uiPid || undefined, - apiPort: options.apiPort, - uiPort: options.uiPort, - networkAccess: options.networkAccess, - }, - null, - 2 - ) - ) + const pid = spawnDetached(runtime.binary, [SQLITE_FLAG, entry], context.rootDir, env, { + stdoutPath: stdout, + stderrPath: stderr, + }) - console.log('') - console.log('') - console.log(`${GREEN}✓ Parallax started in background.${RESET}`) - console.log(`${DIM}Orchestrator PID:${RESET} ${orchestratorPid}`) - console.log(`${DIM}Dashboard:${RESET} http://localhost:${options.uiPort}`) - if (options.networkAccess) { - console.log( - `${DIM}Network dashboard:${RESET} ${buildDashboardUrl(resolveNetworkHostname(), options.uiPort)}` - ) - console.log( - `${YELLOW}Warning: network access is unauthenticated. Anyone on this trusted network can control Parallax and modify its configuration.${RESET}` - ) + const apiBase = `http://localhost:${options.apiPort}` + try { + await waitForUrlHealth(`${apiBase}/runtime/health`, 'runner API') + } catch (error: unknown) { + try { + process.kill(pid) + } catch { + // Already gone. } - console.log(`${DIM}Projects:${RESET} ${storedConfig.projects.length}`) - console.log('') - console.log('') - console.log(`${YELLOW}💡 Run 'parallax open' to view the dashboard.${RESET}`) - } catch (error) { - const processAlive = orchestratorPid > 0 ? isProcessAlive(orchestratorPid) : false - await stopProcessBestEffort(orchestratorPid, 'orchestrator', true) - await stopProcessBestEffort(uiPid, 'ui', true) + // The runner's own stderr says far more than "it did not start". + const tail = await fs.readFile(stderr, 'utf8').catch(() => '') throw new Error( - `${error instanceof Error ? error.message : String(error)} - -Startup diagnostics: -- orchestrator PID: ${orchestratorPid || 'n/a'} -- ui PID: ${uiPid || 'n/a'} -- process alive at failure: ${processAlive ? 'yes' : 'no'} -- stdout log: ${orchestratorStdoutPath} -- stderr log: ${orchestratorStderrPath} -- ui stdout log: ${uiStdoutPath} -- ui stderr log: ${uiStderrPath} - -Recent stderr: -${await readFileTail(orchestratorStderrPath, context.ensureFileExists)} - -Recent stdout: -${await readFileTail(orchestratorStdoutPath, context.ensureFileExists)} + `${error instanceof Error ? error.message : String(error)}\n\n${tail + .split('\n') + .slice(-30) + .join('\n')}` + ) + } -Recent UI stderr: -${await readFileTail(uiStderrPath, context.ensureFileExists)} + await fs.writeFile( + manifestPath, + JSON.stringify( + { + startedAt: Date.now(), + runnerPid: pid, + apiPort: options.apiPort, + networkAccess: options.networkAccess, + }, + null, + 2 + ) + ) -Recent UI stdout: -${await readFileTail(uiStdoutPath, context.ensureFileExists)}` + console.log(chalk.green(`Parallax runner started (pid ${pid}) on ${apiBase}`)) + if (options.networkAccess) { + console.log( + chalk.yellow('Network access is on: the unauthenticated runner API is exposed to your LAN.') ) - } finally { - spinner?.stop() } + console.log(chalk.dim(' parallax status see what it is doing')) + console.log(chalk.dim(' parallax logs follow run output')) } diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index f5737d1..be1cf3d 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -1,120 +1,65 @@ -import path from 'node:path' -import { sleep } from '@parallax/common' -import { parseStatusOptions } from '../args.js' -import { buildDashboardUrl, resolveNetworkHostname } from '../network.js' -import { startSpinner, isProcessAlive } from '../process.js' +import chalk from 'chalk' +import { getJson, runnerUnreachable } from '../api.js' +import { isProcessAlive } from '../process.js' import type { CliContext } from '../types.js' -type RuntimeErrorsResponse = { - hasErrors?: boolean - errors?: string[] +interface Health { + status: string + version: string + projects: number + agents: number + routes: number + cloud: string + hermes: string | null } -async function fetchRuntimeErrors(apiBase: string): Promise { - const response = await fetch(`${apiBase}/runtime/errors`) - if (!response.ok) { - throw new Error(`Request failed: ${response.status} ${response.statusText}`) +export async function runStatus(context: CliContext): Promise { + let running + try { + running = await context.loadRunningState() + } catch { + console.log(chalk.yellow('Parallax is not running.')) + console.log(chalk.dim(' parallax start')) + process.exitCode = 1 + return } - return (await response.json()) as RuntimeErrorsResponse -} - -export async function runStatus(args: string[], context: CliContext) { - parseStatusOptions(args) - const GREEN = '\x1b[32m' - const RED = '\x1b[31m' - const YELLOW = '\x1b[33m' - const DIM = '\x1b[2m' - const RESET = '\x1b[0m' - const startTime = Date.now() - const output: string[] = [] - - const spinner = startSpinner('Checking Parallax status...') + if (!isProcessAlive(running.runnerPid)) { + console.log(chalk.yellow(`Manifest points at pid ${running.runnerPid}, which is gone.`)) + console.log(chalk.dim(' parallax stop clear it, then start again')) + process.exitCode = 1 + return + } + const apiBase = `http://localhost:${running.apiPort}` + let health: Health try { - const manifestPath = path.join(context.defaultDataDir, context.manifestFile) - let state - try { - state = await context.loadRunningState() - } catch { - output.push('') - output.push(`${RED}✗ Parallax status: offline.${RESET}`) - output.push(`Run ${YELLOW}parallax start${RESET} to launch the orchestrator and dashboard.`) - return - } - - const orchestratorAlive = isProcessAlive(state.orchestratorPid) - const uiAlive = state.uiPid ? isProcessAlive(state.uiPid) : true - - if (!orchestratorAlive || !uiAlive) { - output.push('') - output.push(`${RED}✗ Parallax status: unhealthy.${RESET}`) - output.push(`${DIM}Manifest:${RESET} ${manifestPath}`) - output.push( - `${DIM}Orchestrator PID:${RESET} ${state.orchestratorPid} ${orchestratorAlive ? '(alive)' : '(not running)'}` - ) - if (state.uiPid) { - output.push(`${DIM}UI PID:${RESET} ${state.uiPid} ${uiAlive ? '(alive)' : '(not running)'}`) - } - output.push(`Run ${YELLOW}parallax stop${RESET} and then ${YELLOW}parallax start${RESET}.`) - return - } - - const apiBase = await context.resolveDefaultApiBase() - const diagnostics = await fetchRuntimeErrors(apiBase) - const errors = Array.isArray(diagnostics.errors) ? diagnostics.errors : [] - - if (diagnostics.hasErrors && errors.length > 0) { - output.push('') - output.push(`${RED}✗ Parallax status: issues detected.${RESET}`) - output.push(`${DIM}Orchestrator PID:${RESET} ${state.orchestratorPid}`) - output.push(`${DIM}Dashboard:${RESET} http://localhost:${state.uiPort}`) - if (state.networkAccess) { - output.push( - `${DIM}Network dashboard:${RESET} ${buildDashboardUrl(resolveNetworkHostname(), state.uiPort)}` - ) - } - output.push('') - output.push(...errors) - return - } + health = await getJson(`${apiBase}/runtime/health`) + } catch { + throw runnerUnreachable(apiBase) + } - let projects: Array<{ id: string; agent: { provider: string } }> = [] - try { - const configRes = await fetch(`${apiBase}/config`) - if (configRes.ok) { - const cfg = (await configRes.json()) as { projects?: typeof projects } - projects = cfg.projects ?? [] - } - } catch { - // ignore - } + const uptime = Math.round((Date.now() - running.startedAt) / 1000) + console.log('') + console.log(` ${chalk.green('running')} pid ${running.runnerPid} ${apiBase}`) + console.log(chalk.dim(` up ${uptime < 60 ? `${uptime}s` : `${Math.floor(uptime / 60)}m`}`)) + console.log('') + console.log(` agents ${health.agents}`) + console.log(` routes ${health.routes}`) + console.log(` projects ${health.projects}`) + console.log(` hermes ${health.hermes ?? chalk.yellow('not configured')}`) + console.log(` cloud ${health.cloud}`) - output.push('') - output.push(`${GREEN}✓ Parallax status: healthy.${RESET}`) - output.push(`${DIM}Orchestrator PID:${RESET} ${state.orchestratorPid}`) - output.push(`${DIM}Dashboard:${RESET} http://localhost:${state.uiPort}`) - if (state.networkAccess) { - output.push( - `${DIM}Network dashboard:${RESET} ${buildDashboardUrl(resolveNetworkHostname(), state.uiPort)}` - ) - } + const { errors, hasErrors } = await getJson<{ errors: string[]; hasErrors: boolean }>( + `${apiBase}/runtime/errors` + ).catch(() => ({ errors: [], hasErrors: false })) - if (projects.length > 0) { - output.push('') - output.push(`${DIM}Projects (${projects.length}):${RESET}`) - for (const project of projects) { - output.push(` ${project.id.padEnd(20)} ${project.agent.provider}`) - } - } - } finally { - const remaining = 400 - (Date.now() - startTime) - if (remaining > 0) { - await sleep(remaining) - } - spinner?.stop() - for (const line of output) { - console.log(line) + if (hasErrors) { + console.log('') + console.log(chalk.red(` recent errors (${errors.length}):`)) + for (const line of errors.slice(-5)) { + console.log(chalk.dim(` ${line}`)) } } + console.log('') } diff --git a/packages/cli/src/commands/stop.ts b/packages/cli/src/commands/stop.ts index 77f3d30..24e7c73 100644 --- a/packages/cli/src/commands/stop.ts +++ b/packages/cli/src/commands/stop.ts @@ -1,30 +1,34 @@ import fs from 'node:fs/promises' import path from 'node:path' -import { parseStopOptions } from '../args.js' -import { startSpinner, stopProcessBestEffort } from '../process.js' +import chalk from 'chalk' +import { isProcessAlive, waitForExit } from '../process.js' import type { CliContext } from '../types.js' -export async function runStop(args: string[], context: CliContext) { - parseStopOptions(args) +export async function runStop(context: CliContext): Promise { const manifestPath = path.join(context.defaultDataDir, context.manifestFile) - const spinner = startSpinner('Stopping Parallax...') - let state + let running try { - state = await context.loadRunningState() + running = await context.loadRunningState() } catch { - spinner?.stop() console.log('Parallax is not running.') return } - try { - await stopProcessBestEffort(state.orchestratorPid, 'orchestrator', true) - await stopProcessBestEffort(state.uiPid, 'UI', true) - await fs.unlink(manifestPath).catch(() => undefined) - } finally { - spinner?.stop() + if (!isProcessAlive(running.runnerPid)) { + await fs.rm(manifestPath, { force: true }) + console.log('Parallax was not running; cleared a stale manifest.') + return + } + + process.kill(running.runnerPid, 'SIGTERM') + const exited = await waitForExit(running.runnerPid, 8_000) + if (!exited) { + // The runner holds a long poll open; SIGTERM during one can take a moment. + process.kill(running.runnerPid, 'SIGKILL') + await waitForExit(running.runnerPid, 2_000) } - console.log('Parallax stopped.') + await fs.rm(manifestPath, { force: true }) + console.log(chalk.green(`Stopped Parallax runner (pid ${running.runnerPid}).`)) } diff --git a/packages/cli/src/commands/tasks.ts b/packages/cli/src/commands/tasks.ts deleted file mode 100644 index b777a30..0000000 --- a/packages/cli/src/commands/tasks.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { CliContext } from '../types.js' - -type TaskEntry = { - id: string - externalId: string - title: string - status: string - projectId: string - createdAt: number -} - -type ProjectEntry = { - id: string - agent: { provider: string; model?: string } -} - -const GREEN = '\x1b[32m' -const RED = '\x1b[31m' -const YELLOW = '\x1b[33m' -const CYAN = '\x1b[36m' -const DIM = '\x1b[2m' -const RESET = '\x1b[0m' -const BOLD = '\x1b[1m' - -function colorStatus(status: string): string { - switch (status) { - case 'done': - return `${GREEN}${status}${RESET}` - case 'running': - return `${CYAN}${status}${RESET}` - case 'queued': - return `${YELLOW}${status}${RESET}` - case 'failed': - return `${RED}${status}${RESET}` - case 'canceled': - return `${DIM}${status}${RESET}` - default: - return status - } -} - -export async function runTasks(_args: string[], context: CliContext) { - let apiBase: string - try { - apiBase = await context.resolveDefaultApiBase() - } catch { - throw new Error("Parallax is not running. Start it first with 'parallax start'.") - } - - const [tasksRes, configRes] = await Promise.all([ - fetch(`${apiBase}/tasks`), - fetch(`${apiBase}/config`), - ]) - - if (!tasksRes.ok) { - throw new Error(`Failed to fetch tasks (${tasksRes.status}): ${tasksRes.statusText}`) - } - if (!configRes.ok) { - throw new Error(`Failed to fetch config (${configRes.status}): ${configRes.statusText}`) - } - - const allTasks = (await tasksRes.json()) as TaskEntry[] - const config = (await configRes.json()) as { projects?: ProjectEntry[] } - const projects = new Map((config.projects ?? []).map((p) => [p.id, p])) - - const tasks = allTasks - .slice() - .sort((a, b) => b.createdAt - a.createdAt) - .slice(0, 20) - - if (tasks.length === 0) { - console.log('No tasks found.') - return - } - - const rows = tasks.map((task) => { - const project = projects.get(task.projectId) - const provider = project?.agent.provider ?? '—' - const model = project?.agent.model ?? '—' - const displayId = task.externalId || task.id - const title = task.title.length > 50 ? task.title.slice(0, 47) + '...' : task.title - return { id: displayId, title, provider, model, status: task.status } - }) - - const colWidths = { - id: Math.max(7, ...rows.map((r) => r.id.length)), - title: Math.max(5, ...rows.map((r) => r.title.length)), - provider: Math.max(8, ...rows.map((r) => r.provider.length)), - model: Math.max(5, ...rows.map((r) => r.model.length)), - status: Math.max(6, ...rows.map((r) => r.status.length)), - } - - const pad = (s: string, n: number) => s.padEnd(n) - - const header = [ - pad('TASK ID', colWidths.id), - pad('NAME', colWidths.title), - pad('ADAPTER', colWidths.provider), - pad('MODEL', colWidths.model), - pad('STATUS', colWidths.status), - ].join(' ') - - const divider = [ - '─'.repeat(colWidths.id), - '─'.repeat(colWidths.title), - '─'.repeat(colWidths.provider), - '─'.repeat(colWidths.model), - '─'.repeat(colWidths.status), - ].join(' ') - - console.log() - console.log(`${BOLD}${header}${RESET}`) - console.log(`${DIM}${divider}${RESET}`) - - for (const row of rows) { - console.log( - [ - pad(row.id, colWidths.id), - pad(row.title, colWidths.title), - pad(row.provider, colWidths.provider), - pad(row.model, colWidths.model), - colorStatus(row.status), - ].join(' ') - ) - } - - console.log() -} diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index a0af524..e72f5dc 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -1,7 +1,7 @@ import fs from 'node:fs/promises' import fsSync from 'node:fs' import path from 'node:path' -import type { StoredConfig } from '@parallax/common' +import { CONFIG_VERSION, type StoredConfig } from '@parallax/common' import type { RunningState } from './types.js' export function resolveCliRoot(startDir: string): string { @@ -48,28 +48,26 @@ export function parseRunningState(raw: string, source: string): RunningState { ) } - if ( - !parsed || - typeof parsed !== 'object' || - typeof (parsed as { startedAt?: unknown }).startedAt !== 'number' || - typeof (parsed as { orchestratorPid?: unknown }).orchestratorPid !== 'number' || - typeof (parsed as { apiPort?: unknown }).apiPort !== 'number' || - typeof (parsed as { uiPort?: unknown }).uiPort !== 'number' || - (parsed as { orchestratorPid: number }).orchestratorPid <= 0 || - (parsed as { apiPort: number }).apiPort <= 0 || - (parsed as { uiPort: number }).uiPort <= 0 || - ('networkAccess' in parsed && - typeof (parsed as { networkAccess?: unknown }).networkAccess !== 'boolean') || - ('uiPid' in parsed && typeof (parsed as { uiPid?: unknown }).uiPid !== 'number') || - (typeof (parsed as { uiPid?: unknown }).uiPid === 'number' && - (parsed as { uiPid: number }).uiPid <= 0) - ) { + const state = parsed as Partial | null + const valid = + state && + typeof state === 'object' && + typeof state.startedAt === 'number' && + typeof state.runnerPid === 'number' && + state.runnerPid > 0 && + typeof state.apiPort === 'number' && + state.apiPort > 0 && + (state.networkAccess === undefined || typeof state.networkAccess === 'boolean') + + if (!valid) { throw new Error(`Invalid running manifest at ${source}.`) } return { - ...(parsed as RunningState), - networkAccess: (parsed as { networkAccess?: boolean }).networkAccess === true, + startedAt: state.startedAt as number, + runnerPid: state.runnerPid as number, + apiPort: state.apiPort as number, + networkAccess: state.networkAccess === true, } } @@ -103,17 +101,25 @@ function parseStoredConfigFromDisk(raw: string, source: string): StoredConfig { } const obj = parsed as Record + const record = (value: unknown) => + value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null + + const version = typeof obj.version === 'number' ? obj.version : 1 + if (version !== CONFIG_VERSION) { + throw new Error( + `Config at ${source} is version ${version}; this CLI requires version ${CONFIG_VERSION}. ` + + `Move it aside and run "parallax init".` + ) + } + return { - version: typeof obj.version === 'number' ? obj.version : 1, + version, + cloud: record(obj.cloud) as StoredConfig['cloud'], + hermes: record(obj.hermes) as StoredConfig['hermes'], projects: Array.isArray(obj.projects) ? (obj.projects as StoredConfig['projects']) : [], - slack: - obj.slack && typeof obj.slack === 'object' && !Array.isArray(obj.slack) - ? (obj.slack as StoredConfig['slack']) - : null, - secrets: - obj.secrets && typeof obj.secrets === 'object' && !Array.isArray(obj.secrets) - ? (obj.secrets as Record) - : {}, + secrets: (record(obj.secrets) as Record) ?? {}, updatedAt: typeof obj.updatedAt === 'number' ? obj.updatedAt : 0, } } @@ -122,9 +128,10 @@ export async function loadStoredConfig(dataDir: string): Promise { const configPath = path.join(dataDir, CONFIG_FILE) if (!(await ensureFileExists(configPath))) { return { - version: 1, + version: CONFIG_VERSION, + cloud: null, + hermes: null, projects: [], - slack: null, secrets: {}, updatedAt: 0, } diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts new file mode 100644 index 0000000..aa9c6e0 --- /dev/null +++ b/packages/cli/src/constants.ts @@ -0,0 +1,4 @@ +export const RUNNER_STDOUT_FILE = 'runner.stdout.log' +export const RUNNER_STDERR_FILE = 'runner.stderr.log' +export const MANIFEST_FILE = 'running.json' +export const LAUNCH_AGENT_LABEL = 'com.parallax.runner' diff --git a/packages/cli/src/git-detect.ts b/packages/cli/src/git-detect.ts deleted file mode 100644 index d075c46..0000000 --- a/packages/cli/src/git-detect.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { execSync } from 'node:child_process' - -export function detectGitHubRemote(workspaceDir: string): { owner: string; repo: string } | null { - try { - const url = execSync('git config --get remote.origin.url', { - cwd: workspaceDir, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }).trim() - - // git@github.com:owner/repo.git OR https://github.com/owner/repo.git - const sshMatch = url.match(/git@github\.com:([^/]+)\/([^/]+?)(\.git)?$/) - if (sshMatch) { - return { owner: sshMatch[1], repo: sshMatch[2] } - } - - const httpsMatch = url.match(/https?:\/\/github\.com\/([^/]+)\/([^/]+?)(\.git)?$/) - if (httpsMatch) { - return { owner: httpsMatch[1], repo: httpsMatch[2] } - } - - return null - } catch { - return null - } -} diff --git a/packages/cli/src/hermes-local.ts b/packages/cli/src/hermes-local.ts new file mode 100644 index 0000000..7ba4d2c --- /dev/null +++ b/packages/cli/src/hermes-local.ts @@ -0,0 +1,116 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +/** + * Reads the Hermes installation on this machine. + * + * `parallax init` runs on the same host as Hermes, so the profiles and their + * API server keys are already on disk. Reading them beats asking an operator to + * copy four keys out of four dotfiles by hand, and it removes the most likely + * setup mistake: pasting the default profile's key against a named profile, + * which Hermes rejects only later, at dispatch time. + * + * Deliberately filesystem-based rather than shelling out to `hermes profile + * list`: the directory layout is a documented contract, whereas CLI output + * formatting is not. + */ + +export interface LocalHermesProfile { + name: string + /** From that profile's own .env, when present. */ + apiKey?: string + envPath: string +} + +export interface LocalHermesInstall { + home: string + /** From the default profile's .env; the gateway-wide settings live there. */ + apiServerEnabled: boolean + port?: string + profiles: LocalHermesProfile[] +} + +export function resolveHermesHome(): string { + return process.env.HERMES_HOME + ? path.resolve(process.env.HERMES_HOME) + : path.join(os.homedir(), '.hermes') +} + +/** Minimal dotenv read: `KEY=value`, ignoring comments, quotes and `export`. */ +export async function readEnvFile(file: string): Promise> { + let raw: string + try { + raw = await fs.readFile(file, 'utf8') + } catch { + return {} + } + + const values: Record = {} + for (const line of raw.split('\n')) { + const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/) + if (!match) { + continue + } + const value = match[2].trim().replace(/\s+#.*$/, '') + values[match[1]] = value.replace(/^["'](.*)["']$/, '$1') + } + return values +} + +async function isDirectory(target: string): Promise { + try { + return (await fs.stat(target)).isDirectory() + } catch { + return false + } +} + +/** + * Enumerates `default` plus every directory under `/profiles`. + * + * Returns undefined when there is no Hermes home here at all, so the caller can + * fall back to asking rather than reporting an empty fleet as if it were fact. + */ +export async function discoverLocalHermes( + home: string = resolveHermesHome() +): Promise { + if (!(await isDirectory(home))) { + return undefined + } + + const rootEnvPath = path.join(home, '.env') + const rootEnv = await readEnvFile(rootEnvPath) + + const profiles: LocalHermesProfile[] = [ + { name: 'default', apiKey: rootEnv.API_SERVER_KEY, envPath: rootEnvPath }, + ] + + const profilesDir = path.join(home, 'profiles') + let entries: string[] = [] + try { + entries = await fs.readdir(profilesDir) + } catch { + entries = [] + } + + for (const name of entries.sort()) { + if (name.startsWith('.') || !(await isDirectory(path.join(profilesDir, name)))) { + continue + } + const envPath = path.join(profilesDir, name, '.env') + const env = await readEnvFile(envPath) + profiles.push({ name, apiKey: env.API_SERVER_KEY, envPath }) + } + + return { + home, + apiServerEnabled: /^(1|true|yes|on)$/i.test(rootEnv.API_SERVER_ENABLED ?? ''), + port: rootEnv.API_SERVER_PORT, + profiles, + } +} + +export function defaultHermesBaseUrl(install?: LocalHermesInstall): string { + return `http://127.0.0.1:${install?.port ?? '8642'}` +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index edbba80..dfee188 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,49 +2,53 @@ import os from 'node:os' import fs from 'node:fs' import path from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' +import { fileURLToPath } from 'node:url' +import chalk from 'chalk' import { DEFAULT_API_PORT } from '@parallax/common' import { - hasFlag, parseCancelOptions, + parseEmptyOptions, parseLogsOptions, - parsePreflightOptions, - parsePrReviewOptions, - parseRetryOptions, + parseRunOptions, + parseRunnerOptions, + parseRunsOptions, parseStartOptions, - parseStatusOptions, - parseTasksOptions, - parseStopOptions as parseStopOptionsInternal, resolvePath, } from './args.js' import { ensureFileExists, loadRunningState as loadRunningStateFromDisk, loadStoredConfig as loadStoredConfigFromDisk, - parseRunningState, resolveCliRoot, saveStoredConfig as saveStoredConfigToDisk, } from './config.js' +import { MANIFEST_FILE } from './constants.js' +import { ensureCapableRuntime, SQLITE_FLAG } from './node-runtime.js' +import { runAgents } from './commands/agents.js' import { runCancel } from './commands/cancel.js' import { runInit } from './commands/init.js' import { runLogs } from './commands/logs.js' -import { runOpen } from './commands/open.js' import { runPreflight } from './commands/preflight.js' -import { runPrReview } from './commands/pr-review.js' -import { runRetry } from './commands/retry.js' +import { runProjects } from './commands/projects.js' +import { runReload } from './commands/reload.js' +import { runRestart } from './commands/restart.js' +import { runRoutes } from './commands/routes.js' +import { runRunner } from './commands/runner.js' +import { runRuns } from './commands/runs.js' +import { runSmokeTest } from './commands/run.js' import { runStart } from './commands/start.js' import { runStatus } from './commands/status.js' import { runStop } from './commands/stop.js' -import { runTasks } from './commands/tasks.js' import type { CliContext } from './types.js' import { printUsage } from './usage.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -const DEFAULT_DATA_DIR = path.join(os.homedir(), '.parallax') +const DEFAULT_DATA_DIR = process.env.PARALLAX_DATA_DIR + ? path.resolve(process.env.PARALLAX_DATA_DIR) + : path.join(os.homedir(), '.parallax') const DEFAULT_API_BASE = `http://localhost:${DEFAULT_API_PORT}` -const MANIFEST_FILE = 'running.json' const ROOT_DIR = resolveCliRoot(__dirname) function resolvePackageVersion(rootDir: string): string { @@ -54,167 +58,129 @@ function resolvePackageVersion(rootDir: string): string { path.resolve(__dirname, '../package.json'), path.resolve(__dirname, '../../package.json'), ] - for (const candidate of candidates) { if (!fs.existsSync(candidate)) { continue } - - const parsed = JSON.parse(fs.readFileSync(candidate, 'utf8')) as { - version?: string - name?: string - } - if ( - typeof parsed.version === 'string' && - (parsed.name === 'parallax-cli' || candidate.endsWith('/packages/cli/package.json')) - ) { - return parsed.version + try { + const parsed = JSON.parse(fs.readFileSync(candidate, 'utf8')) as { version?: string } + if (parsed.version) { + return parsed.version + } + } catch { + continue } } - - throw new Error('Unable to resolve CLI version from package.json.') + return '0.0.0' } -const CLI_VERSION = resolvePackageVersion(ROOT_DIR) +const PACKAGE_VERSION = resolvePackageVersion(ROOT_DIR) -async function resolveDefaultApiBase(): Promise { - const manifest = await loadRunningStateFromDisk(DEFAULT_DATA_DIR, MANIFEST_FILE) - return `http://localhost:${manifest.apiPort}` -} - -function buildEnvConfig( - dataDir: string, - runtime: { apiPort: number; uiPort: number; concurrency: number; networkAccess: boolean } -) { - const existingNodeOptions = process.env.NODE_OPTIONS?.trim() - const sqliteWarningSuppression = '--disable-warning=ExperimentalWarning' - const nodeOptions = existingNodeOptions - ? `${existingNodeOptions} ${sqliteWarningSuppression}` - : sqliteWarningSuppression - - return { - NODE_OPTIONS: nodeOptions, - PARALLAX_DATA_DIR: dataDir, - PARALLAX_DB_PATH: path.join(dataDir, 'parallax.db'), - PARALLAX_SERVER_API_PORT: String(runtime.apiPort), - PARALLAX_SERVER_UI_PORT: String(runtime.uiPort), - PARALLAX_CONCURRENCY: String(runtime.concurrency), - PARALLAX_NETWORK_ACCESS: String(runtime.networkAccess), - } +// Before anything else: if this interpreter cannot load node:sqlite, hand off to +// one that can. Doing it here means every command benefits, and a version switch +// after install produces a re-exec rather than a failure deep in the database. +try { + ensureCapableRuntime(DEFAULT_DATA_DIR, __filename) +} catch (error: unknown) { + console.error(chalk.red(error instanceof Error ? error.message : String(error))) + process.exit(1) } -const cliContext: CliContext = { +const context: CliContext = { defaultApiBase: DEFAULT_API_BASE, defaultDataDir: DEFAULT_DATA_DIR, manifestFile: MANIFEST_FILE, rootDir: ROOT_DIR, - cliVersion: CLI_VERSION, - packageVersion: CLI_VERSION, + cliVersion: PACKAGE_VERSION, + packageVersion: PACKAGE_VERSION, resolvePath, ensureFileExists, loadRunningState: () => loadRunningStateFromDisk(DEFAULT_DATA_DIR, MANIFEST_FILE), loadStoredConfig: () => loadStoredConfigFromDisk(DEFAULT_DATA_DIR), saveStoredConfig: (config) => saveStoredConfigToDisk(DEFAULT_DATA_DIR, config), - resolveDefaultApiBase, - buildEnvConfig, -} - -async function cli() { - const args = process.argv.slice(2) - - if (args.length === 0 || hasFlag(args, 'help') || hasFlag(args, 'h')) { - printUsage() - return - } - - if (hasFlag(args, 'version') || hasFlag(args, 'v')) { - console.log(CLI_VERSION) - return - } - const command = args[0] - const commandArgs = args.slice(1) - - try { - switch (command) { - case 'init': - await runInit(commandArgs, cliContext) - return - case 'start': - await runStart(commandArgs, cliContext) - return - case 'status': - await runStatus(commandArgs, cliContext) - return - case 'open': - await runOpen(commandArgs, cliContext) - return - case 'preflight': - await runPreflight(commandArgs) - return - case 'pr-review': - await runPrReview(commandArgs, cliContext) - return - case 'stop': - await runStop(commandArgs, cliContext) - return - case 'retry': - await runRetry(commandArgs, cliContext) - return - case 'cancel': - await runCancel(commandArgs, cliContext) - return - case 'logs': - await runLogs(commandArgs, cliContext) - return - case 'tasks': - await runTasks(commandArgs, cliContext) - return - default: - console.error(`Unknown command: ${command}\n`) - printUsage() - process.exit(1) + // Commands that talk to a running runner read the port it actually bound, + // rather than assuming the default -- otherwise a non-default --api-port + // silently breaks every read command. + resolveDefaultApiBase: async () => { + try { + const running = await loadRunningStateFromDisk(DEFAULT_DATA_DIR, MANIFEST_FILE) + return `http://localhost:${running.apiPort}` + } catch { + return DEFAULT_API_BASE } - } catch (error: any) { - console.error(`Error: ${error.message}`) - process.exit(1) - } -} - -export { - parseCancelOptions, - parseLogsOptions, - parsePreflightOptions, - parsePrReviewOptions, - parseRetryOptions, - parseStartOptions, - parseStatusOptions, - parseTasksOptions, - parseRunningState, - resolveDefaultApiBase, - resolvePath, -} - -export function parseStopOptions(args: string[]) { - return parseStopOptionsInternal(args) + }, + + buildEnvConfig: (dataDir, runtime) => ({ + // node:sqlite needs the flag on Node 22 and ignores it from 23 on, so one + // invocation covers every supported runtime; the warning is suppressed + // because it fires on every boot and says nothing actionable. + NODE_OPTIONS: + `${process.env.NODE_OPTIONS ?? ''} ${SQLITE_FLAG} --disable-warning=ExperimentalWarning`.trim(), + PARALLAX_DATA_DIR: dataDir, + PARALLAX_DB_PATH: path.join(dataDir, 'parallax.db'), + PARALLAX_SERVER_API_PORT: String(runtime.apiPort), + PARALLAX_CONCURRENCY: String(runtime.concurrency), + PARALLAX_NETWORK_ACCESS: String(runtime.networkAccess), + PARALLAX_VERSION: PACKAGE_VERSION, + }), } -function isDirectExecution() { - if (process.argv[1] === undefined) { - return false - } - - try { - const invokedPath = fs.realpathSync(process.argv[1]) - const modulePath = fs.realpathSync(fileURLToPath(import.meta.url)) - return invokedPath === modulePath - } catch { - return import.meta.url === pathToFileURL(process.argv[1]).href +async function dispatch(command: string | undefined, args: string[]): Promise { + switch (command) { + case 'init': + return runInit(context) + case 'preflight': + return runPreflight(context) + case 'start': + return runStart(context, parseStartOptions(args)) + case 'stop': + parseEmptyOptions(args, 'stop') + return runStop(context) + case 'restart': + return runRestart(context, parseStartOptions(args)) + case 'status': + parseEmptyOptions(args, 'status') + return runStatus(context) + case 'runner': + return runRunner(context, parseRunnerOptions(args)) + case 'projects': + parseEmptyOptions(args, 'projects') + return runProjects(context) + case 'reload': + parseEmptyOptions(args, 'reload') + return runReload(context) + case 'agents': + parseEmptyOptions(args, 'agents') + return runAgents(context) + case 'routes': + parseEmptyOptions(args, 'routes') + return runRoutes(context) + case 'runs': + return runRuns(context, parseRunsOptions(args)) + case 'logs': + return runLogs(context, parseLogsOptions(args)) + case 'cancel': + return runCancel(context, parseCancelOptions(args)) + case 'run': + return runSmokeTest(context, parseRunOptions(args)) + case 'version': + case '--version': + case '-v': + console.log(PACKAGE_VERSION) + return + case undefined: + case 'help': + case '--help': + case '-h': + printUsage(PACKAGE_VERSION) + return + default: + throw new Error(`Unknown command "${command}". Run "parallax help".`) } } -const isExecutedDirectly = isDirectExecution() - -if (isExecutedDirectly) { - void cli() -} +dispatch(process.argv[2], process.argv.slice(3)).catch((error: unknown) => { + console.error(chalk.red(error instanceof Error ? error.message : String(error))) + process.exitCode = 1 +}) diff --git a/packages/cli/src/node-runtime.ts b/packages/cli/src/node-runtime.ts new file mode 100644 index 0000000..6d0729a --- /dev/null +++ b/packages/cli/src/node-runtime.ts @@ -0,0 +1,277 @@ +import { spawnSync } from 'node:child_process' +import { randomBytes } from 'node:crypto' +import { createRequire } from 'node:module' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +// This package is ESM, where bare `require` does not exist. Without this the +// capability probe below throws ReferenceError and reports every runtime as +// incapable -- including the one it just re-executed into. +const require = createRequire(import.meta.url) + +/** + * Finding a Node that can actually run Parallax. + * + * Installing globally under a version manager scopes the command to whichever + * Node was active at install time. Switch versions and `parallax` either + * vanishes from PATH or — worse — runs under an interpreter that cannot load + * `node:sqlite`, failing deep inside the database layer with an error that says + * nothing about Node versions. + * + * So this does not check a version number. It checks the one capability that + * actually matters and re-execs under an interpreter that has it. A capability + * probe stays correct when `node:sqlite` stops being experimental, or when it + * lands in a runtime that reports a version we have never heard of. + */ + +/** Node 22.x needs this; 23+ accepts and ignores it. One invocation fits all. */ +export const SQLITE_FLAG = '--experimental-sqlite' + +/** Set on a re-exec so a broken probe cannot loop forever. */ +const REEXEC_GUARD = 'PARALLAX_RUNTIME_REEXEC' + +/** + * Script a candidate must run to prove it is a Node with node:sqlite. + * + * The expected answer is a nonce read from the environment, never written into + * the arguments. Exit status alone would accept anything that exits 0, and a + * literal sentinel in the script would be echoed back verbatim by something + * like `/bin/echo`. Only a process that actually evaluated this can print a + * value it was never given on the command line. + */ +const PROBE_ENV = 'PARALLAX_PROBE_NONCE' +const PROBE = `if (typeof require("node:sqlite").DatabaseSync === "function") console.log(process.env.${PROBE_ENV})` + +export function currentRuntimeIsCapable(): boolean { + // Loading node:sqlite is the probe, but it emits an ExperimentalWarning that + // would then print on every single CLI invocation. Silence just this one + // load rather than the process's warnings generally. + const emit = process.emitWarning + // process.emitWarning is heavily overloaded, so the stand-in is typed as the + // base callable and reinstated in `finally`. + process.emitWarning = ((warning: string | Error, ...rest: unknown[]) => { + const type = typeof rest[0] === 'string' ? rest[0] : (rest[0] as { type?: string })?.type + if (type === 'ExperimentalWarning' || String(warning).includes('SQLite is an experimental')) { + return + } + ;(emit as (...args: unknown[]) => void)(warning, ...rest) + }) as typeof process.emitWarning + + try { + return typeof require('node:sqlite').DatabaseSync === 'function' + } catch { + return false + } finally { + process.emitWarning = emit + } +} + +/** Runs a candidate interpreter and asks whether it can open a database. */ +export function probeNode(binary: string): boolean { + try { + const nonce = randomBytes(8).toString('hex') + const result = spawnSync(binary, [SQLITE_FLAG, '-e', PROBE], { + encoding: 'utf8', + timeout: 10_000, + env: { ...process.env, [PROBE_ENV]: nonce }, + }) + return result.status === 0 && (result.stdout ?? '').includes(nonce) + } catch { + return false + } +} + +function versionOf(binary: string): string | undefined { + try { + const result = spawnSync(binary, ['--version'], { encoding: 'utf8', timeout: 5_000 }) + return result.status === 0 ? result.stdout.trim() : undefined + } catch { + return undefined + } +} + +/** + * Candidate interpreters, best first. + * + * The recorded choice comes first so a working setup keeps using the same Node + * across restarts, then this process, then the version managers and package + * managers people actually have. Version-manager directories are searched + * newest-first by numeric version, not lexically, so v9 does not beat v10. + */ +export function candidateNodePaths(dataDir: string): string[] { + const candidates: string[] = [] + + const recorded = readRecordedNode(dataDir) + if (recorded) { + candidates.push(recorded) + } + candidates.push(process.execPath) + + const versionDirs = [ + path.join(os.homedir(), '.nvm', 'versions', 'node'), + path.join(os.homedir(), '.local', 'share', 'fnm', 'node-versions'), + path.join(os.homedir(), 'Library', 'Application Support', 'fnm', 'node-versions'), + path.join(os.homedir(), '.volta', 'tools', 'image', 'node'), + path.join(os.homedir(), '.asdf', 'installs', 'nodejs'), + ] + + for (const dir of versionDirs) { + let entries: string[] + try { + entries = fs.readdirSync(dir) + } catch { + continue + } + + const sorted = entries + .map((name) => ({ name, parts: parseVersion(name) })) + .filter((entry) => entry.parts !== undefined) + .sort((a, b) => compareVersions(b.parts!, a.parts!)) + + for (const entry of sorted) { + // fnm and asdf nest the install one level deeper than nvm does. + candidates.push( + path.join(dir, entry.name, 'bin', 'node'), + path.join(dir, entry.name, 'installation', 'bin', 'node') + ) + } + } + + candidates.push('/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node') + + return [...new Set(candidates)].filter((candidate) => { + try { + return fs.statSync(candidate).isFile() + } catch { + return false + } + }) +} + +function parseVersion(name: string): number[] | undefined { + const match = name.match(/^v?(\d+)\.(\d+)\.(\d+)/) + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined +} + +function compareVersions(a: number[], b: number[]): number { + for (let i = 0; i < 3; i += 1) { + if (a[i] !== b[i]) { + return a[i] - b[i] + } + } + return 0 +} + +export interface ResolvedRuntime { + binary: string + version?: string +} + +/** First candidate that passes the probe, or undefined if this machine has none. */ +export function findCapableNode(dataDir: string): ResolvedRuntime | undefined { + for (const binary of candidateNodePaths(dataDir)) { + if (probeNode(binary)) { + return { binary, version: versionOf(binary) } + } + } + return undefined +} + +// ── Recording the choice ──────────────────────────────────────────────────── + +function recordPath(dataDir: string): string { + return path.join(dataDir, 'node-runtime.json') +} + +export function readRecordedNode(dataDir: string): string | undefined { + try { + const parsed = JSON.parse(fs.readFileSync(recordPath(dataDir), 'utf8')) as { binary?: string } + return parsed.binary && fs.existsSync(parsed.binary) ? parsed.binary : undefined + } catch { + return undefined + } +} + +export function recordNode(dataDir: string, runtime: ResolvedRuntime): void { + try { + fs.mkdirSync(dataDir, { recursive: true }) + fs.writeFileSync( + recordPath(dataDir), + JSON.stringify({ binary: runtime.binary, version: runtime.version }, null, 2) + ) + } catch { + // Losing the hint only costs a rediscovery next time. + } +} + +/** + * Resolves the interpreter to launch the runner with. + * + * Long-running processes are started with an absolute path rather than + * inheriting whatever `node` means later, so a version switch months from now + * cannot break a daemon that is already installed and working. + */ +export function resolveRunnerNode(dataDir: string): ResolvedRuntime { + if (currentRuntimeIsCapable()) { + const runtime = { binary: process.execPath, version: process.version } + recordNode(dataDir, runtime) + return runtime + } + + const found = findCapableNode(dataDir) + if (!found) { + throw new Error(unsupportedRuntimeMessage()) + } + recordNode(dataDir, found) + return found +} + +export function unsupportedRuntimeMessage(afterReexec = false): string { + return [ + `This Node (${process.version}) cannot load node:sqlite, which Parallax needs.`, + '', + afterReexec + ? 'Parallax already switched interpreters once and still could not load it,' + : 'No usable Node was found on this machine,', + 'so it is giving up rather than failing later somewhere less obvious.', + '', + 'Node 22.5 or newer works (22.x needs --experimental-sqlite, which Parallax', + 'passes for you). Install or select one, for example:', + '', + ' nvm install 24 && nvm use 24', + ].join('\n') +} + +/** + * Re-executes this CLI under a capable interpreter when the current one is not. + * + * Returns false when the process should simply carry on. Anything else either + * exits or throws, so the caller never continues on a runtime that will fail + * later, in a place that gives no clue why. + */ +export function ensureCapableRuntime(dataDir: string, entryScript: string): boolean { + if (currentRuntimeIsCapable()) { + return false + } + + if (process.env[REEXEC_GUARD]) { + // Already re-executed once and still not capable: stop rather than loop. + throw new Error(unsupportedRuntimeMessage(true)) + } + + const capable = findCapableNode(dataDir) + if (!capable) { + throw new Error(unsupportedRuntimeMessage()) + } + + recordNode(dataDir, capable) + + const result = spawnSync( + capable.binary, + [SQLITE_FLAG, '--disable-warning=ExperimentalWarning', entryScript, ...process.argv.slice(2)], + { stdio: 'inherit', env: { ...process.env, [REEXEC_GUARD]: '1' } } + ) + + process.exit(result.status ?? 1) +} diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 97d832e..a0c1d5b 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -1,40 +1,9 @@ import type { StoredConfig } from '@parallax/common' -export type StopCommandOptions = Record - -export type RetryCommandOptions = { taskId: string } - -export type CancelCommandOptions = { - taskId: string -} - -export type PrReviewCommandOptions = { - taskId: string -} - -export type LogsCommandOptions = { - taskId?: string -} - -export type PreflightCommandOptions = Record - -export type StatusCommandOptions = Record - -export type TasksCommandOptions = Record - -export type StartCommandOptions = { - apiPort: number - uiPort: number - concurrency: number - networkAccess: boolean -} - export type RunningState = { startedAt: number - orchestratorPid: number - uiPid?: number + runnerPid: number apiPort: number - uiPort: number networkAccess?: boolean } @@ -45,21 +14,35 @@ export type VerifyCheck = { detail?: string } +export type StartCommandOptions = { + apiPort: number + concurrency: number + networkAccess: boolean + foreground: boolean +} + +export type LogsCommandOptions = { runId?: string; follow: boolean } +export type RunsCommandOptions = { status?: string; limit: number } +export type RunCommandOptions = { agent: string; prompt: string; timeoutSeconds: number } +export type CancelCommandOptions = { runId: string } +export type RunnerCommandOptions = { action: 'install' | 'uninstall' | 'status' } +export type EmptyOptions = Record + export type CliContext = { defaultApiBase: string defaultDataDir: string manifestFile: string rootDir: string cliVersion: string + packageVersion: string resolvePath: (raw: string) => string ensureFileExists: (filePath: string) => Promise loadRunningState: () => Promise loadStoredConfig: () => Promise saveStoredConfig: (config: StoredConfig) => Promise resolveDefaultApiBase: () => Promise - packageVersion: string buildEnvConfig: ( dataDir: string, - runtime: { apiPort: number; uiPort: number; concurrency: number; networkAccess: boolean } + runtime: { apiPort: number; concurrency: number; networkAccess: boolean } ) => Record } diff --git a/packages/cli/src/usage.ts b/packages/cli/src/usage.ts index 09aae74..6bc3e5e 100644 --- a/packages/cli/src/usage.ts +++ b/packages/cli/src/usage.ts @@ -1,29 +1,45 @@ -export function printUsage(): void { - console.log(`Usage: - parallax --version - parallax --help - parallax init - parallax start [--server-api-port ] [--server-ui-port ] [--concurrency ] [--network-access] - parallax stop - parallax status - parallax tasks - parallax open - parallax preflight - parallax pr-review - parallax retry - parallax cancel - parallax logs [--task ] +import chalk from 'chalk' -Commands: - init Set up Parallax for the first time (interactive wizard). - start Start orchestrator + UI in background. - stop Force-stop the running Parallax processes. - status Show orchestrator state and configured projects. - tasks List the last 20 tasks with their status, AI adapter, and model. - open Open the dashboard in your browser. - preflight Validate local prerequisites and auth. - pr-review [experimental] Apply open human PR review comments to the task's existing open PR. - retry Queue a task for manual retry. - cancel Cancel a pending or running task. - logs Tail new task logs from the running Parallax API.`) +const BRAND = chalk.hex('#f97316') + +export function printUsage(version: string): void { + console.log( + [ + '', + ` ${BRAND('parallax')} ${chalk.dim(version)}`, + chalk.dim(' Triggers Hermes agents from your tickets and pull requests.'), + '', + chalk.bold(' Setup'), + ' init connect this machine to the cloud and to Hermes', + ' preflight check everything this runner needs', + '', + chalk.bold(' Running'), + ' start [--foreground] start the runner', + ' stop stop it', + ' restart stop and start again', + ' status is it up, and what does it see', + ' reload re-pull projects, routes and agents now', + ' runner install|uninstall|status', + chalk.dim(' keep it running across reboots (launchd)'), + '', + chalk.bold(' Inspecting'), + ' projects ticket sources it is polling', + ' agents Hermes profiles this runner discovered', + ' routes routing rules it is dispatching on', + ' runs [--status] [--limit] recent runs', + ' logs [--run ] [--follow]', + ' cancel stop a run, on this side and on Hermes', + '', + chalk.bold(' Debugging'), + ' run --agent --prompt "..."', + chalk.dim(' send one prompt straight to Hermes'), + '', + chalk.bold(' Flags for start'), + ' --api-port default 9371', + ' --concurrency default 2, max 16', + ' --network-access expose the runner API to your LAN', + ' --foreground run in this terminal instead of detaching', + '', + ].join('\n') + ) } diff --git a/packages/cli/test/hermes-local.test.ts b/packages/cli/test/hermes-local.test.ts new file mode 100644 index 0000000..8321813 --- /dev/null +++ b/packages/cli/test/hermes-local.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import fs from 'node:fs/promises' +import path from 'node:path' +import os from 'node:os' +import { discoverLocalHermes, readEnvFile, defaultHermesBaseUrl } from '../src/hermes-local.js' + +let home = '' + +beforeEach(async () => { + home = await fs.mkdtemp(path.join(os.tmpdir(), 'hermes-home-')) +}) + +afterEach(async () => { + await fs.rm(home, { recursive: true, force: true }) +}) + +async function writeProfile(name: string, contents: string): Promise { + const dir = path.join(home, 'profiles', name) + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile(path.join(dir, '.env'), contents) +} + +describe('readEnvFile', () => { + it('parses plain assignments and strips quotes, export, and comments', async () => { + const file = path.join(home, '.env') + await fs.writeFile( + file, + [ + '# a comment', + 'API_SERVER_ENABLED=true', + 'export API_SERVER_KEY="abc123"', + "API_SERVER_PORT='9000' # trailing", + 'MALFORMED', + ].join('\n') + ) + + await expect(readEnvFile(file)).resolves.toEqual({ + API_SERVER_ENABLED: 'true', + API_SERVER_KEY: 'abc123', + API_SERVER_PORT: '9000', + }) + }) + + it('returns nothing for a missing file rather than throwing', async () => { + await expect(readEnvFile(path.join(home, 'nope'))).resolves.toEqual({}) + }) +}) + +describe('discoverLocalHermes', () => { + it('returns undefined when there is no hermes home', async () => { + await expect(discoverLocalHermes(path.join(home, 'absent'))).resolves.toBeUndefined() + }) + + it('always includes the default profile, keyed from the root env', async () => { + await fs.writeFile(path.join(home, '.env'), 'API_SERVER_KEY=root-key') + + const install = await discoverLocalHermes(home) + + expect(install?.profiles).toEqual([ + { name: 'default', apiKey: 'root-key', envPath: path.join(home, '.env') }, + ]) + }) + + it('discovers named profiles and reads each key from its own env', async () => { + await fs.writeFile(path.join(home, '.env'), 'API_SERVER_KEY=root') + await writeProfile('product', 'API_SERVER_KEY=product-key') + await writeProfile('reviewer', 'API_SERVER_KEY=reviewer-key') + + const install = await discoverLocalHermes(home) + + expect(install?.profiles.map((p) => [p.name, p.apiKey])).toEqual([ + ['default', 'root'], + ['product', 'product-key'], + ['reviewer', 'reviewer-key'], + ]) + }) + + it('lists a profile whose env has no key, so it can be flagged not skipped', async () => { + await writeProfile('keyless', 'SOMETHING_ELSE=1') + + const install = await discoverLocalHermes(home) + const keyless = install?.profiles.find((p) => p.name === 'keyless') + + expect(keyless).toBeDefined() + expect(keyless?.apiKey).toBeUndefined() + }) + + it('sorts named profiles for a stable prompt order', async () => { + await writeProfile('zulu', 'API_SERVER_KEY=z') + await writeProfile('alpha', 'API_SERVER_KEY=a') + + const install = await discoverLocalHermes(home) + expect(install?.profiles.map((p) => p.name)).toEqual(['default', 'alpha', 'zulu']) + }) + + it('ignores dotfiles and stray files under profiles/', async () => { + await writeProfile('real', 'API_SERVER_KEY=k') + await fs.mkdir(path.join(home, 'profiles', '.cache'), { recursive: true }) + await fs.writeFile(path.join(home, 'profiles', 'notes.txt'), 'x') + + const install = await discoverLocalHermes(home) + expect(install?.profiles.map((p) => p.name)).toEqual(['default', 'real']) + }) + + it('reports whether the api server is switched on', async () => { + await fs.writeFile(path.join(home, '.env'), 'API_SERVER_ENABLED=true\nAPI_SERVER_PORT=9999') + const on = await discoverLocalHermes(home) + expect(on?.apiServerEnabled).toBe(true) + expect(defaultHermesBaseUrl(on)).toBe('http://127.0.0.1:9999') + + await fs.writeFile(path.join(home, '.env'), 'API_SERVER_ENABLED=false') + const off = await discoverLocalHermes(home) + expect(off?.apiServerEnabled).toBe(false) + expect(defaultHermesBaseUrl(off)).toBe('http://127.0.0.1:8642') + }) +}) diff --git a/packages/cli/test/logs.test.ts b/packages/cli/test/logs.test.ts deleted file mode 100644 index f6b454d..0000000 --- a/packages/cli/test/logs.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import chalk from 'chalk' -import stripAnsi from 'strip-ansi' -import type { CliContext } from '../src/types.js' - -const stopLoop = new Error('stop loop') - -const { sleepMock } = vi.hoisted(() => ({ - sleepMock: vi.fn(), -})) - -vi.mock('@parallax/common', () => ({ - sleep: sleepMock, -})) - -import { formatLogLine, runLogs } from '../src/commands/logs.js' - -function createContext(overrides: Partial = {}): CliContext { - return { - defaultApiBase: 'http://localhost:9371', - defaultDataDir: '/tmp/.parallax', - manifestFile: 'running.json', - rootDir: '/tmp/parallax', - cliVersion: '0.0.8', - packageVersion: '0.0.8', - resolvePath: (raw) => raw, - ensureFileExists: async () => true, - loadRunningState: async () => ({ - startedAt: Date.now(), - orchestratorPid: 1, - apiPort: 9371, - uiPort: 9372, - }), - loadStoredConfig: async () => ({ - version: 1, - projects: [], - slack: null, - secrets: {}, - updatedAt: 0, - }), - saveStoredConfig: async () => {}, - resolveDefaultApiBase: async () => 'http://localhost:9371', - buildEnvConfig: () => ({}), - ...overrides, - } -} - -describe('runLogs', () => { - beforeEach(() => { - vi.restoreAllMocks() - vi.clearAllMocks() - vi.stubGlobal('fetch', vi.fn()) - }) - - it('starts tailing from the current time instead of replaying old logs', async () => { - vi.spyOn(Date, 'now').mockReturnValue(5_000) - sleepMock.mockRejectedValue(stopLoop) - vi.mocked(fetch).mockResolvedValue({ - ok: true, - json: async () => ({ logs: [] }), - } as Response) - - await expect(runLogs([], createContext())).rejects.toBe(stopLoop) - - expect(fetch).toHaveBeenCalledWith('http://localhost:9371/logs?since=5000&limit=500') - }) - - it('prints only new entries once while preserving the existing output shape', async () => { - vi.spyOn(Date, 'now').mockReturnValue(5_000) - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - sleepMock.mockResolvedValueOnce(undefined).mockRejectedValueOnce(stopLoop) - - vi.mocked(fetch) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - logs: [ - { - taskExternalId: 'old-task', - level: 'info', - icon: 'ℹ', - message: 'should be skipped', - timestamp: 4_999, - }, - { - taskExternalId: 'task-1', - level: 'warning', - icon: '⚠', - message: 'first fresh log', - timestamp: 5_000, - }, - ], - }), - } as Response) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - logs: [ - { - taskExternalId: 'task-1', - level: 'warning', - icon: '⚠', - message: 'first fresh log', - timestamp: 5_000, - }, - { - taskExternalId: 'task-2', - level: 'error', - icon: '✖', - message: 'second fresh log', - timestamp: 5_001, - }, - ], - }), - } as Response) - - await expect(runLogs([], createContext())).rejects.toBe(stopLoop) - - expect(logSpy).toHaveBeenCalledTimes(2) - expect(stripAnsi(String(logSpy.mock.calls[0]?.[0]))).toBe( - '1970-01-01T00:00:05.000Z [task-1] WARNING ⚠ first fresh log' - ) - expect(stripAnsi(String(logSpy.mock.calls[1]?.[0]))).toBe( - '1970-01-01T00:00:05.001Z [task-2] ERROR ✖ second fresh log' - ) - expect(fetch).toHaveBeenNthCalledWith(2, 'http://localhost:9371/logs?since=5000&limit=500') - }) - - it('applies severity-based ANSI styling without changing the readable text', () => { - const colors = new chalk.Instance({ level: 1 }) - const line = formatLogLine( - { - taskExternalId: 'task-9', - level: 'warning', - icon: '⚠', - message: 'needs attention', - timestamp: 5_000, - }, - colors - ) - - expect(stripAnsi(line)).toBe('1970-01-01T00:00:05.000Z [task-9] WARNING ⚠ needs attention') - expect(line).not.toBe(stripAnsi(line)) - }) -}) diff --git a/packages/cli/test/network-access.test.ts b/packages/cli/test/network-access.test.ts index 9989d80..1ffca60 100644 --- a/packages/cli/test/network-access.test.ts +++ b/packages/cli/test/network-access.test.ts @@ -1,59 +1,87 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import os from 'node:os' -import { parseStartOptions } from '../src/args.js' +import { describe, expect, it } from 'vitest' +import { parseStartOptions, parseRunOptions, parseRunsOptions } from '../src/args.js' import { parseRunningState } from '../src/config.js' -import { buildDashboardUrl, resolveNetworkHostname } from '../src/network.js' -describe('network access CLI behavior', () => { - afterEach(() => { - vi.restoreAllMocks() +describe('parseStartOptions', () => { + it('defaults to a local-only runner on the standard port', () => { + expect(parseStartOptions([])).toEqual({ + apiPort: 9371, + concurrency: 2, + networkAccess: false, + foreground: false, + }) }) - it('keeps network access disabled by default', () => { - expect(parseStartOptions([]).networkAccess).toBe(false) + it('enables network access from a value-less flag', () => { + expect(parseStartOptions(['--network-access']).networkAccess).toBe(true) }) - it('enables network access with a value-less flag', () => { - expect(parseStartOptions(['--network-access']).networkAccess).toBe(true) - expect(() => parseStartOptions(['--network-access=true'])).toThrow( - '--network-access does not accept a value.' - ) + it('accepts port and concurrency overrides', () => { + const options = parseStartOptions(['--api-port', '9999', '--concurrency', '4']) + expect(options).toMatchObject({ apiPort: 9999, concurrency: 4 }) + }) + + it('rejects out-of-range values rather than clamping them', () => { + expect(() => parseStartOptions(['--concurrency', '99'])).toThrow(/between 1 and 16/) + expect(() => parseStartOptions(['--api-port', '0'])).toThrow(/between 1 and 65535/) + }) + + it('rejects an unknown flag instead of ignoring it', () => { + expect(() => parseStartOptions(['--ui-port', '9372'])).toThrow(/Unknown flag "--ui-port"/) + }) + + it('rejects a flag given without its value', () => { + expect(() => parseStartOptions(['--api-port'])).toThrow(/--api-port requires a value/) + }) +}) + +describe('parseRunOptions', () => { + it('requires both an agent and a prompt', () => { + expect(() => parseRunOptions(['--agent', 'product'])).toThrow(/--prompt/) + expect(() => parseRunOptions(['--prompt', 'hi'])).toThrow(/--agent/) + }) + + it('parses a full invocation with a default timeout', () => { + expect(parseRunOptions(['--agent', 'product', '--prompt', 'say hello'])).toEqual({ + agent: 'product', + prompt: 'say hello', + timeoutSeconds: 600, + }) }) +}) - it('loads old manifests as local-only', () => { - expect( - parseRunningState( - JSON.stringify({ - startedAt: 1, - orchestratorPid: 2, - apiPort: 9371, - uiPort: 9372, - }), - '/tmp/running.json' - ).networkAccess - ).toBe(false) - }) - - it('preserves enabled network access in the running manifest', () => { - expect( - parseRunningState( - JSON.stringify({ - startedAt: 1, - orchestratorPid: 2, - apiPort: 9371, - uiPort: 9372, - networkAccess: true, - }), - '/tmp/running.json' - ).networkAccess - ).toBe(true) - }) - - it('formats macOS hostnames and IPv6 dashboard URLs', () => { - vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') - vi.spyOn(os, 'hostname').mockReturnValue('cerebro') - expect(resolveNetworkHostname()).toBe('cerebro.local') - expect(buildDashboardUrl('cerebro.local', 9372)).toBe('http://cerebro.local:9372') - expect(buildDashboardUrl('fe80::1', 9372)).toBe('http://[fe80::1]:9372') +describe('parseRunsOptions', () => { + it('defaults the page size and passes a status filter through', () => { + expect(parseRunsOptions([])).toEqual({ status: undefined, limit: 20 }) + expect(parseRunsOptions(['--status', 'failed']).status).toBe('failed') + }) +}) + +describe('parseRunningState', () => { + const manifest = { startedAt: 1, runnerPid: 42, apiPort: 9371 } + + it('treats a manifest with no networkAccess as local-only', () => { + expect(parseRunningState(JSON.stringify(manifest), 'm')).toEqual({ + startedAt: 1, + runnerPid: 42, + apiPort: 9371, + networkAccess: false, + }) + }) + + it('preserves enabled network access', () => { + const raw = JSON.stringify({ ...manifest, networkAccess: true }) + expect(parseRunningState(raw, 'm').networkAccess).toBe(true) + }) + + it('rejects a manifest missing the runner pid', () => { + const raw = JSON.stringify({ startedAt: 1, apiPort: 9371 }) + expect(() => parseRunningState(raw, 'm')).toThrow(/Invalid running manifest/) + }) + + it('rejects malformed json with the source path', () => { + expect(() => parseRunningState('{ not json', '/tmp/running.json')).toThrow( + /Invalid running manifest at \/tmp\/running\.json/ + ) }) }) diff --git a/packages/cli/test/node-runtime.test.ts b/packages/cli/test/node-runtime.test.ts new file mode 100644 index 0000000..832254a --- /dev/null +++ b/packages/cli/test/node-runtime.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import fs from 'node:fs/promises' +import fsSync from 'node:fs' +import path from 'node:path' +import os from 'node:os' +import { + SQLITE_FLAG, + candidateNodePaths, + currentRuntimeIsCapable, + probeNode, + readRecordedNode, + recordNode, + resolveRunnerNode, + unsupportedRuntimeMessage, +} from '../src/node-runtime.js' + +let dataDir = '' + +beforeEach(async () => { + dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'px-runtime-')) +}) + +afterEach(async () => { + await fs.rm(dataDir, { recursive: true, force: true }) +}) + +describe('capability probing', () => { + it('reports the test runner itself as capable', () => { + // The suite runs on a supported Node, so this doubles as a check that the + // probe works at all in ESM -- where a bare `require` would always throw. + expect(currentRuntimeIsCapable()).toBe(true) + }) + + it('does not leave process.emitWarning patched', () => { + const before = process.emitWarning + currentRuntimeIsCapable() + expect(process.emitWarning).toBe(before) + }) + + it('accepts the interpreter running these tests', () => { + expect(probeNode(process.execPath)).toBe(true) + }) + + it('rejects something that is not a node binary', () => { + expect(probeNode('/bin/echo')).toBe(false) + }) + + it('rejects a path that does not exist, without throwing', () => { + expect(probeNode('/nonexistent/node')).toBe(false) + }) + + it('passes the sqlite flag, which Node 22 needs and later versions ignore', () => { + expect(SQLITE_FLAG).toBe('--experimental-sqlite') + }) +}) + +describe('candidate discovery', () => { + it('includes the current interpreter and only real files', () => { + const candidates = candidateNodePaths(dataDir) + + expect(candidates).toContain(process.execPath) + for (const candidate of candidates) { + expect(fsSync.statSync(candidate).isFile()).toBe(true) + } + }) + + it('puts a previously recorded interpreter first', () => { + recordNode(dataDir, { binary: process.execPath, version: process.version }) + expect(candidateNodePaths(dataDir)[0]).toBe(process.execPath) + }) + + it('does not repeat a candidate reachable by two routes', () => { + recordNode(dataDir, { binary: process.execPath, version: process.version }) + const candidates = candidateNodePaths(dataDir) + expect(new Set(candidates).size).toBe(candidates.length) + }) +}) + +describe('recording the choice', () => { + it('round-trips', () => { + recordNode(dataDir, { binary: process.execPath, version: 'v24.0.0' }) + expect(readRecordedNode(dataDir)).toBe(process.execPath) + }) + + it('ignores a record pointing at an interpreter that has been removed', () => { + fsSync.writeFileSync( + path.join(dataDir, 'node-runtime.json'), + JSON.stringify({ binary: '/gone/node' }) + ) + // A version manager pruning old versions must not pin us to a dead path. + expect(readRecordedNode(dataDir)).toBeUndefined() + }) + + it('ignores a corrupt record', () => { + fsSync.writeFileSync(path.join(dataDir, 'node-runtime.json'), 'not json') + expect(readRecordedNode(dataDir)).toBeUndefined() + }) + + it('returns undefined before anything is recorded', () => { + expect(readRecordedNode(dataDir)).toBeUndefined() + }) +}) + +describe('resolveRunnerNode', () => { + it('returns an absolute interpreter path and remembers it', () => { + const runtime = resolveRunnerNode(dataDir) + + expect(path.isAbsolute(runtime.binary)).toBe(true) + expect(probeNode(runtime.binary)).toBe(true) + // Recording it is what keeps a long-running daemon on the same interpreter. + expect(readRecordedNode(dataDir)).toBe(runtime.binary) + }) +}) + +describe('unsupportedRuntimeMessage', () => { + it('names the offending version and how to fix it', () => { + const message = unsupportedRuntimeMessage() + expect(message).toContain(process.version) + expect(message).toContain('node:sqlite') + expect(message).toContain('nvm install 24') + }) + + it('distinguishes giving up after a re-exec from finding nothing', () => { + expect(unsupportedRuntimeMessage(false)).toContain('No usable Node was found') + expect(unsupportedRuntimeMessage(true)).toContain('already switched interpreters once') + }) +}) diff --git a/packages/cli/test/open.test.ts b/packages/cli/test/open.test.ts deleted file mode 100644 index 7adf46c..0000000 --- a/packages/cli/test/open.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import type { CliContext } from '../src/types.js' - -const { execSyncMock } = vi.hoisted(() => ({ - execSyncMock: vi.fn(), -})) - -vi.mock('node:child_process', () => ({ - execSync: execSyncMock, -})) - -import { runOpen } from '../src/commands/open.js' - -function createContext(overrides: Partial = {}): CliContext { - return { - defaultApiBase: 'http://localhost:9371', - defaultDataDir: '/tmp/.parallax', - manifestFile: 'running.json', - rootDir: '/tmp/parallax', - cliVersion: '0.0.1', - packageVersion: '0.0.1', - resolvePath: (raw) => raw, - ensureFileExists: async () => true, - loadRunningState: async () => ({ - startedAt: Date.now(), - orchestratorPid: 1, - apiPort: 9371, - uiPort: 9372, - }), - loadStoredConfig: async () => ({ - version: 1, - projects: [], - slack: null, - secrets: {}, - updatedAt: 0, - }), - saveStoredConfig: async () => {}, - resolveDefaultApiBase: async () => 'http://localhost:3000', - buildEnvConfig: () => ({}), - ...overrides, - } -} - -describe('runOpen', () => { - beforeEach(() => { - vi.restoreAllMocks() - vi.clearAllMocks() - }) - - it('throws when Parallax is not running', async () => { - const context = createContext({ - loadRunningState: async () => { - throw new Error('not found') - }, - }) - - await expect(runOpen([], context)).rejects.toThrow( - "Parallax is not running. Start it first with 'parallax start'" - ) - }) - - it('opens the URL from running state', async () => { - execSyncMock.mockImplementation(() => {}) - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - await runOpen([], createContext()) - - expect(execSyncMock).toHaveBeenCalledOnce() - const cmd = execSyncMock.mock.calls[0][0] as string - expect(cmd).toContain('"http://localhost:9372"') - expect(logSpy).toHaveBeenCalledWith('Opened http://localhost:9372') - }) - - it('uses uiPort from running state', async () => { - execSyncMock.mockImplementation(() => {}) - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await runOpen( - [], - createContext({ - loadRunningState: async () => ({ - startedAt: Date.now(), - orchestratorPid: 1, - apiPort: 3001, - uiPort: 9999, - }), - }) - ) - - const cmd = execSyncMock.mock.calls[0][0] as string - expect(cmd).toContain('"http://localhost:9999"') - }) - - it('falls back to printing URL when browser open fails', async () => { - execSyncMock.mockImplementation(() => { - throw new Error('open failed') - }) - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - await runOpen([], createContext()) - - expect(logSpy).toHaveBeenCalledWith('Dashboard: http://localhost:9372') - }) -}) diff --git a/packages/cli/test/status.test.ts b/packages/cli/test/status.test.ts deleted file mode 100644 index 9ae0f06..0000000 --- a/packages/cli/test/status.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { CliContext, RunningState } from '../src/types.js' - -const { sleepMock, startSpinnerMock, isProcessAliveMock } = vi.hoisted(() => ({ - sleepMock: vi.fn().mockResolvedValue(undefined), - startSpinnerMock: vi.fn(), - isProcessAliveMock: vi.fn(), -})) - -vi.mock('@parallax/common', () => ({ - sleep: sleepMock, -})) - -vi.mock('../src/process.js', () => ({ - startSpinner: startSpinnerMock, - isProcessAlive: isProcessAliveMock, -})) - -import { runStatus } from '../src/commands/status.js' - -function createContext(overrides: Partial = {}): CliContext { - return { - defaultApiBase: 'http://localhost:9371', - defaultDataDir: '/tmp/.parallax', - manifestFile: 'running.json', - rootDir: '/tmp/parallax', - cliVersion: '0.0.5', - packageVersion: '0.0.5', - resolvePath: (raw) => raw, - ensureFileExists: async () => true, - loadRunningState: async () => { - throw new Error('offline') - }, - loadStoredConfig: async () => ({ - version: 1, - projects: [], - slack: null, - secrets: {}, - updatedAt: 0, - }), - saveStoredConfig: async () => {}, - resolveDefaultApiBase: async () => 'http://localhost:9371', - buildEnvConfig: () => ({}), - ...overrides, - } -} - -function createRunningState(overrides: Partial = {}): RunningState { - return { - startedAt: Date.now(), - orchestratorPid: 1234, - uiPid: 5678, - apiPort: 9371, - uiPort: 9372, - ...overrides, - } -} - -describe('runStatus', () => { - beforeEach(() => { - vi.restoreAllMocks() - vi.clearAllMocks() - startSpinnerMock.mockReturnValue({ stop: vi.fn() }) - isProcessAliveMock.mockReturnValue(true) - vi.stubGlobal('fetch', vi.fn()) - }) - - it('prints offline status when no running state exists', async () => { - const events: string[] = [] - const spinnerStop = vi.fn(() => events.push('spinner-stop')) - startSpinnerMock.mockImplementation((message: string) => { - events.push(`spinner-start:${message}`) - return { stop: spinnerStop } - }) - const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { - events.push(`log:${String(value ?? '')}`) - }) - - await runStatus([], createContext()) - - expect(events[0]).toBe('spinner-start:Checking Parallax status...') - expect(events[1]).toBe('spinner-stop') - expect(events[2]).toBe('log:') - expect(events[3]).toContain('Parallax status: offline.') - expect(events[4]).toContain('parallax start') - expect(logSpy).toHaveBeenCalled() - }) - - it('prints healthy status when runtime is up and diagnostics are clean', async () => { - const stop = vi.fn() - startSpinnerMock.mockReturnValue({ stop }) - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ hasErrors: false, errors: [] }), - }) - ) - - await runStatus( - [], - createContext({ - loadRunningState: async () => createRunningState(), - }) - ) - - expect(startSpinnerMock).toHaveBeenCalledWith('Checking Parallax status...') - expect(isProcessAliveMock).toHaveBeenCalledWith(1234) - expect(isProcessAliveMock).toHaveBeenCalledWith(5678) - expect(fetch).toHaveBeenCalledWith('http://localhost:9371/runtime/errors') - expect(stop).toHaveBeenCalledOnce() - expect(logSpy.mock.calls.map((call) => String(call[0]))).toEqual([ - '', - expect.stringContaining('Parallax status: healthy.'), - expect.stringContaining('Orchestrator PID:'), - expect.stringContaining('Dashboard:'), - ]) - }) - - it('prints diagnostics when orchestrator errors are present', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - hasErrors: true, - errors: ['first error', 'second error'], - }), - }) - ) - - await runStatus( - [], - createContext({ - loadRunningState: async () => createRunningState({ uiPid: undefined }), - }) - ) - - expect(logSpy.mock.calls.map((call) => String(call[0]))).toEqual([ - '', - expect.stringContaining('Parallax status: issues detected.'), - expect.stringContaining('Orchestrator PID:'), - expect.stringContaining('Dashboard:'), - '', - 'first error', - 'second error', - ]) - }) - - it('prints the network dashboard URL when network access is enabled', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ hasErrors: false, errors: [] }), - }) - ) - - await runStatus( - [], - createContext({ - loadRunningState: async () => createRunningState({ networkAccess: true }), - }) - ) - - expect(logSpy.mock.calls.map((call) => String(call[0]))).toContainEqual( - expect.stringContaining('Network dashboard:') - ) - }) -}) diff --git a/packages/cli/test/tasks.test.ts b/packages/cli/test/tasks.test.ts deleted file mode 100644 index a5294c2..0000000 --- a/packages/cli/test/tasks.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import stripAnsi from 'strip-ansi' -import type { CliContext } from '../src/types.js' - -import { runTasks } from '../src/commands/tasks.js' - -function createContext(overrides: Partial = {}): CliContext { - return { - defaultApiBase: 'http://localhost:9371', - defaultDataDir: '/tmp/.parallax', - manifestFile: 'running.json', - rootDir: '/tmp/parallax', - cliVersion: '0.0.9', - packageVersion: '0.0.9', - resolvePath: (raw) => raw, - ensureFileExists: async () => true, - loadRunningState: async () => ({ - startedAt: Date.now(), - orchestratorPid: 1234, - uiPid: 5678, - apiPort: 9371, - uiPort: 9372, - }), - loadStoredConfig: async () => ({ - version: 1, - projects: [], - slack: null, - secrets: {}, - updatedAt: 0, - }), - saveStoredConfig: async () => {}, - resolveDefaultApiBase: async () => 'http://localhost:9371', - buildEnvConfig: () => ({}), - ...overrides, - } -} - -function makeFetch(tasks: unknown[], projects: unknown[] = []) { - return vi.fn().mockImplementation((url: string) => { - if (String(url).endsWith('/tasks')) { - return Promise.resolve({ ok: true, json: async () => tasks }) - } - if (String(url).endsWith('/config')) { - return Promise.resolve({ ok: true, json: async () => ({ projects }) }) - } - return Promise.resolve({ ok: false, status: 404, statusText: 'Not Found' }) - }) -} - -describe('runTasks', () => { - beforeEach(() => { - vi.restoreAllMocks() - vi.clearAllMocks() - }) - - it('throws when orchestrator is not running', async () => { - const context = createContext({ - resolveDefaultApiBase: async () => { - throw new Error('no manifest') - }, - }) - - await expect(runTasks([], context)).rejects.toThrow( - "Parallax is not running. Start it first with 'parallax start'." - ) - }) - - it('prints "No tasks found." when the API returns an empty list', async () => { - vi.stubGlobal('fetch', makeFetch([], [])) - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - await runTasks([], createContext()) - - expect(logSpy).toHaveBeenCalledWith('No tasks found.') - }) - - it('renders a table with task id, name, adapter, model, and status columns', async () => { - const tasks = [ - { - id: 'internal-1', - externalId: 'PROJ-42', - title: 'Fix the login bug', - status: 'running', - projectId: 'proj-a', - createdAt: 1000, - }, - ] - const projects = [ - { id: 'proj-a', agent: { provider: 'claude-code', model: 'claude-sonnet-4-5' } }, - ] - - vi.stubGlobal('fetch', makeFetch(tasks, projects)) - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - await runTasks([], createContext()) - - const lines = logSpy.mock.calls.map((call) => stripAnsi(String(call[0]))) - const header = lines.find((l) => l.includes('TASK ID')) - const row = lines.find((l) => l.includes('PROJ-42')) - - expect(header).toBeDefined() - expect(header).toContain('NAME') - expect(header).toContain('ADAPTER') - expect(header).toContain('MODEL') - expect(header).toContain('STATUS') - - expect(row).toBeDefined() - expect(row).toContain('PROJ-42') - expect(row).toContain('Fix the login bug') - expect(row).toContain('claude-code') - expect(row).toContain('claude-sonnet-4-5') - expect(row).toContain('running') - }) - - it('uses the internal id when externalId is empty', async () => { - const tasks = [ - { - id: 'internal-abc', - externalId: '', - title: 'Some task', - status: 'queued', - projectId: 'proj-b', - createdAt: 1000, - }, - ] - - vi.stubGlobal('fetch', makeFetch(tasks, [])) - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - await runTasks([], createContext()) - - const lines = logSpy.mock.calls.map((call) => stripAnsi(String(call[0]))) - expect(lines.some((l) => l.includes('internal-abc'))).toBe(true) - }) - - it('shows a dash for adapter and model when no matching project exists', async () => { - const tasks = [ - { - id: 'internal-1', - externalId: 'PROJ-1', - title: 'Orphan task', - status: 'done', - projectId: 'unknown-project', - createdAt: 1000, - }, - ] - - vi.stubGlobal('fetch', makeFetch(tasks, [])) - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - await runTasks([], createContext()) - - const lines = logSpy.mock.calls.map((call) => stripAnsi(String(call[0]))) - const row = lines.find((l) => l.includes('PROJ-1')) - expect(row).toBeDefined() - expect(row).toContain('—') - }) - - it('limits output to the 20 most recent tasks by createdAt', async () => { - const tasks = Array.from({ length: 25 }, (_, i) => ({ - id: `id-${i}`, - externalId: `TASK-${i}`, - title: `Task ${i}`, - status: 'done', - projectId: 'proj-a', - createdAt: i, - })) - - vi.stubGlobal('fetch', makeFetch(tasks, [])) - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - await runTasks([], createContext()) - - const lines = logSpy.mock.calls.map((call) => stripAnsi(String(call[0]))) - const taskRows = lines.filter((l) => l.includes('TASK-')) - expect(taskRows).toHaveLength(20) - expect(taskRows.some((l) => l.includes('TASK-24'))).toBe(true) - expect(taskRows.some((l) => l.includes('TASK-4'))).toBe(false) - }) - - it('truncates titles longer than 50 characters', async () => { - const longTitle = 'A'.repeat(60) - const tasks = [ - { - id: 'id-1', - externalId: 'TASK-1', - title: longTitle, - status: 'running', - projectId: 'proj-a', - createdAt: 1000, - }, - ] - - vi.stubGlobal('fetch', makeFetch(tasks, [])) - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - await runTasks([], createContext()) - - const lines = logSpy.mock.calls.map((call) => stripAnsi(String(call[0]))) - const row = lines.find((l) => l.includes('TASK-1')) - expect(row).toBeDefined() - expect(row).toContain('...') - expect(row).not.toContain(longTitle) - }) - - it('applies ANSI color codes to the status column', async () => { - const statuses = ['done', 'running', 'queued', 'failed', 'canceled'] - const tasks = statuses.map((status, i) => ({ - id: `id-${i}`, - externalId: `TASK-${i}`, - title: `Task ${i}`, - status, - projectId: 'proj-a', - createdAt: i, - })) - - vi.stubGlobal('fetch', makeFetch(tasks, [])) - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - await runTasks([], createContext()) - - const rawLines = logSpy.mock.calls.map((call) => String(call[0])) - const stripped = rawLines.map(stripAnsi) - - const taskRows = rawLines.filter((_, i) => { - const plain = stripped[i] - return ( - plain !== undefined && statuses.some((s) => plain.includes(s) && plain.includes('TASK-')) - ) - }) - - for (const row of taskRows) { - expect(row).not.toBe(stripAnsi(row)) - } - }) - - it('throws when the tasks endpoint fails', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockImplementation((url: string) => { - if (String(url).endsWith('/tasks')) { - return Promise.resolve({ ok: false, status: 500, statusText: 'Internal Server Error' }) - } - return Promise.resolve({ ok: true, json: async () => ({ projects: [] }) }) - }) - ) - - await expect(runTasks([], createContext())).rejects.toThrow('Failed to fetch tasks (500)') - }) -}) diff --git a/packages/cloud-api/package.json b/packages/cloud-api/package.json new file mode 100644 index 0000000..700c36a --- /dev/null +++ b/packages/cloud-api/package.json @@ -0,0 +1,30 @@ +{ + "name": "@parallax/cloud-api", + "version": "0.2.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "rm -rf dist && tsc && cp -r src/migrations dist/migrations", + "start": "node dist/index.js", + "dev": "tsx watch src/index.ts", + "db:migrate": "node dist/migrate-cli.js", + "org:create": "node dist/org-cli.js", + "lint": "eslint src test", + "lint:fix": "eslint src test --fix", + "test": "vitest run" + }, + "dependencies": { + "@fastify/cors": "11.2.0", + "@parallax/common": "workspace:*", + "fastify": "5.7.4", + "pg": "8.13.1" + }, + "devDependencies": { + "@types/node": "25.3.0", + "@types/pg": "^8.23.1" + }, + "files": [ + "dist" + ] +} diff --git a/packages/cloud-api/src/app.ts b/packages/cloud-api/src/app.ts new file mode 100644 index 0000000..0d5b48c --- /dev/null +++ b/packages/cloud-api/src/app.ts @@ -0,0 +1,38 @@ +import Fastify, { type FastifyInstance } from 'fastify' +import cors from '@fastify/cors' +import type { Database } from './db.js' +import { registerRunnerRoutes } from './routes/runner.js' +import { registerUserRoutes } from './routes/user.js' + +export async function buildApp(db: Database): Promise { + const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info' } }) + + // The dashboard is a separate origin, so CORS is on for the user API. Runner + // traffic is server-to-server and unaffected by it either way. + // + // `methods` is not optional here. @fastify/cors defaults to `GET,HEAD,POST`, + // so a browser's preflight refuses PUT, PATCH and DELETE — which silently + // broke every delete button and the Slack save in the dashboard, while curl, + // which sends no preflight, worked perfectly. The list mirrors what the user + // API actually exposes. + await app.register(cors, { + origin: process.env.CORS_ORIGINS?.split(',') ?? true, + methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + }) + + // Unauthenticated on purpose: Railway's health check runs before any key exists. + app.get('/health', async () => { + await db.query('SELECT 1') + return { status: 'ok', version: process.env.npm_package_version ?? 'dev' } + }) + + registerRunnerRoutes(app, db) + registerUserRoutes(app, db) + + app.setErrorHandler((error: Error & { statusCode?: number }, _request, reply) => { + app.log.error(error) + reply.code(error.statusCode ?? 500).send({ error: error.message }) + }) + + return app +} diff --git a/packages/cloud-api/src/auth.ts b/packages/cloud-api/src/auth.ts new file mode 100644 index 0000000..547eff1 --- /dev/null +++ b/packages/cloud-api/src/auth.ts @@ -0,0 +1,96 @@ +import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto' +import type { Database } from './db.js' + +export type KeyScope = 'runner' | 'user' + +export interface AuthContext { + orgId: string + keyId: string + scope: KeyScope +} + +const PREFIXES: Record = { + runner: 'prx_rnr_', + user: 'prx_usr_', +} + +export function hashKey(key: string): string { + return createHash('sha256').update(key).digest('hex') +} + +/** + * Mints a key. The plaintext is returned exactly once and never stored. + * + * The scope is encoded in the visible prefix as well as the row, so an operator + * looking at a key in a config file can tell what it is without a lookup. + */ +export function generateKey(scope: KeyScope): { key: string; hash: string; prefix: string } { + const key = `${PREFIXES[scope]}${randomBytes(24).toString('hex')}` + return { key, hash: hashKey(key), prefix: key.slice(0, 16) } +} + +export function newId(prefix: string): string { + return `${prefix}_${randomUUID().replace(/-/g, '').slice(0, 20)}` +} + +export function parseBearer(header: string | undefined): string | undefined { + if (!header) { + return undefined + } + const match = header.match(/^Bearer\s+(.+)$/i) + return match ? match[1].trim() : undefined +} + +interface KeyRow { + id: string + org_id: string + scope: KeyScope + revoked_at: Date | null +} + +/** + * Resolves a bearer token to an org and scope. + * + * The lookup is by hash, so a timing difference on the hash comparison cannot + * leak key material; the constant-time compare below guards the one place a + * caller-supplied value is compared directly. + */ +export async function authenticate( + db: Database, + token: string | undefined, + required: KeyScope +): Promise { + if (!token) { + return undefined + } + + const hash = hashKey(token) + const { rows } = await db.query( + 'SELECT id, org_id, scope, revoked_at FROM api_keys WHERE key_hash = $1', + [hash] + ) + + const row = rows[0] + if (!row || row.revoked_at) { + return undefined + } + + // A runner key must never reach a user route, or vice versa: scopes are the + // only thing separating an unattended daemon's credential from a human's. + if (!scopeMatches(row.scope, required)) { + return undefined + } + + // Fire-and-forget: last_used_at is diagnostics, not a reason to fail a request. + void db + .query('UPDATE api_keys SET last_used_at = now() WHERE id = $1', [row.id]) + .catch(() => undefined) + + return { orgId: row.org_id, keyId: row.id, scope: row.scope } +} + +function scopeMatches(actual: KeyScope, required: KeyScope): boolean { + const a = Buffer.from(actual) + const b = Buffer.from(required) + return a.length === b.length && timingSafeEqual(a, b) +} diff --git a/packages/cloud-api/src/db.ts b/packages/cloud-api/src/db.ts new file mode 100644 index 0000000..b7dbfa7 --- /dev/null +++ b/packages/cloud-api/src/db.ts @@ -0,0 +1,88 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import pg, { type Pool as PgPool } from 'pg' + +const { Pool } = pg + +export type Database = PgPool + +let pool: Database | undefined + +export function requireEnv(name: string): string { + const value = process.env[name] + if (!value) { + // Fail fast rather than starting a server that will 500 on first request. + throw new Error(`${name} is required.`) + } + return value +} + +export function getPool(): Database { + pool ??= new Pool({ + connectionString: requireEnv('DATABASE_URL'), + // Railway's managed Postgres presents a certificate the default agent will + // not verify; the connection is still TLS, just unverified. + ssl: process.env.DATABASE_SSL === 'disable' ? false : { rejectUnauthorized: false }, + max: Number.parseInt(process.env.DATABASE_POOL_MAX ?? '10', 10), + }) + return pool +} + +export async function closePool(): Promise { + await pool?.end() + pool = undefined +} + +function migrationsDir(): string { + return path.join(path.dirname(fileURLToPath(import.meta.url)), 'migrations') +} + +/** + * Applies pending `.sql` files in filename order, once each. + * + * Deliberately plain SQL rather than an ORM's migration tooling: this runs as a + * Railway release command inside a minimal image, and the fewer build-time + * codegen steps stand between a schema change and a deploy, the fewer ways a + * deploy can fail. + */ +export async function migrate(db: Database = getPool()): Promise { + await db.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + name TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `) + + const dir = migrationsDir() + const files = (await fs.readdir(dir)).filter((file) => file.endsWith('.sql')).sort() + const result = await db.query<{ name: string }>('SELECT name FROM schema_migrations') + const applied = new Set(result.rows.map((row) => row.name)) + + const ran: string[] = [] + for (const file of files) { + if (applied.has(file)) { + continue + } + const sql = await fs.readFile(path.join(dir, file), 'utf8') + const client = await db.connect() + try { + // One transaction per file, so a failure leaves no partial schema behind. + await client.query('BEGIN') + await client.query(sql) + await client.query('INSERT INTO schema_migrations (name) VALUES ($1)', [file]) + await client.query('COMMIT') + ran.push(file) + } catch (error) { + await client.query('ROLLBACK') + throw new Error( + `Migration ${file} failed: ${error instanceof Error ? error.message : String(error)}`, + { cause: error } + ) + } finally { + client.release() + } + } + + return ran +} diff --git a/packages/cloud-api/src/index.ts b/packages/cloud-api/src/index.ts new file mode 100644 index 0000000..0b32908 --- /dev/null +++ b/packages/cloud-api/src/index.ts @@ -0,0 +1,34 @@ +import { buildApp } from './app.js' +import { closePool, getPool, migrate } from './db.js' + +async function main(): Promise { + const db = getPool() + + // Idempotent, and cheap when there is nothing to do. Running it here as well + // as in the release command means a fresh environment works even if someone + // deploys without configuring one. + const applied = await migrate(db) + if (applied.length > 0) { + console.log(`Applied migrations: ${applied.join(', ')}`) + } + + const app = await buildApp(db) + const port = Number.parseInt(process.env.PORT ?? '8080', 10) + + // Railway routes to the container's PORT on all interfaces. + await app.listen({ port, host: '0.0.0.0' }) + + const shutdown = async (signal: string): Promise => { + app.log.info(`${signal} received; shutting down.`) + await app.close() + await closePool() + process.exit(0) + } + process.on('SIGTERM', () => void shutdown('SIGTERM')) + process.on('SIGINT', () => void shutdown('SIGINT')) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/packages/cloud-api/src/migrate-cli.ts b/packages/cloud-api/src/migrate-cli.ts new file mode 100644 index 0000000..e6f8c16 --- /dev/null +++ b/packages/cloud-api/src/migrate-cli.ts @@ -0,0 +1,12 @@ +import { closePool, migrate } from './db.js' + +migrate() + .then(async (applied) => { + console.log(applied.length ? `Applied: ${applied.join(', ')}` : 'No pending migrations.') + await closePool() + }) + .catch(async (error) => { + console.error(error instanceof Error ? error.message : error) + await closePool() + process.exit(1) + }) diff --git a/packages/cloud-api/src/migrations/001_init.sql b/packages/cloud-api/src/migrations/001_init.sql new file mode 100644 index 0000000..40ede8b --- /dev/null +++ b/packages/cloud-api/src/migrations/001_init.sql @@ -0,0 +1,144 @@ +-- Parallax cloud control plane, initial schema. + +CREATE TABLE IF NOT EXISTS organizations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Keys are stored only as SHA-256 hashes; `prefix` is the displayable stub so a +-- human can tell two keys apart without the secret being recoverable. +CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + name TEXT NOT NULL, + scope TEXT NOT NULL CHECK (scope IN ('runner', 'user')), + key_hash TEXT NOT NULL UNIQUE, + prefix TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_used_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS idx_api_keys_org ON api_keys(org_id); + +CREATE TABLE IF NOT EXISTS runners ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + name TEXT NOT NULL, + hostname TEXT, + version TEXT, + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (org_id, name) +); + +-- Derived from Hermes discovery; replaced wholesale on each inventory push. +CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + runner_id TEXT NOT NULL REFERENCES runners(id) ON DELETE CASCADE, + profile TEXT NOT NULL, + display_name TEXT, + role TEXT, + model TEXT, + provider TEXT, + toolsets JSONB NOT NULL DEFAULT '[]'::jsonb, + skills JSONB NOT NULL DEFAULT '[]'::jsonb, + github_login TEXT, + enabled BOOLEAN NOT NULL DEFAULT true, + synced_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (runner_id, profile) +); + +CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + provider TEXT NOT NULL CHECK (provider IN ('linear', 'github')), + filters JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (org_id, id) +); + +CREATE TABLE IF NOT EXISTS routes ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + name TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 0, + enabled BOOLEAN NOT NULL DEFAULT true, + definition JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_routes_org ON routes(org_id, priority DESC); + +CREATE TABLE IF NOT EXISTS runs ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + runner_id TEXT REFERENCES runners(id) ON DELETE SET NULL, + route_id TEXT, + route_name TEXT, + agent_profile TEXT NOT NULL, + project_id TEXT, + trigger_type TEXT NOT NULL, + trigger_ref TEXT NOT NULL, + trigger_url TEXT, + title TEXT NOT NULL, + status TEXT NOT NULL, + hermes_run_id TEXT, + summary TEXT, + error TEXT, + usage JSONB, + started_at TIMESTAMPTZ, + ended_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_runs_org_updated ON runs(org_id, updated_at DESC); + +CREATE TABLE IF NOT EXISTS run_events ( + id BIGSERIAL PRIMARY KEY, + run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE, + title TEXT, + message TEXT NOT NULL, + icon TEXT, + level TEXT NOT NULL, + kind TEXT NOT NULL, + source TEXT NOT NULL, + group_id TEXT, + ts BIGINT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_run_events_run ON run_events(run_id, ts, id); + +-- Work queued by a human for a runner to pick up on its next long poll. +CREATE TABLE IF NOT EXISTS runner_commands ( + cursor BIGSERIAL PRIMARY KEY, + id TEXT NOT NULL UNIQUE, + org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + runner_id TEXT REFERENCES runners(id) ON DELETE CASCADE, + type TEXT NOT NULL CHECK (type IN ('run', 'cancel', 'resync')), + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + acked_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS idx_runner_commands_pending ON runner_commands(org_id, cursor); + +CREATE TABLE IF NOT EXISTS slack_integrations ( + org_id TEXT PRIMARY KEY REFERENCES organizations(id) ON DELETE CASCADE, + webhook_url TEXT NOT NULL, + events TEXT[] NOT NULL DEFAULT ARRAY['run.started','run.completed','run.failed','run.needs_approval','run.canceled','runner.stale'], + enabled BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- (run, event) uniqueness is what makes a delivery retry safe to run. +CREATE TABLE IF NOT EXISTS notification_deliveries ( + id BIGSERIAL PRIMARY KEY, + org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + run_id TEXT, + event TEXT NOT NULL, + status TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (run_id, event) +); diff --git a/packages/cloud-api/src/migrations/002_route_prompt_and_avatar.sql b/packages/cloud-api/src/migrations/002_route_prompt_and_avatar.sql new file mode 100644 index 0000000..cf103fb --- /dev/null +++ b/packages/cloud-api/src/migrations/002_route_prompt_and_avatar.sql @@ -0,0 +1,29 @@ +-- Routes carry their own prompt text. +-- +-- `execution.promptTemplate` named a template compiled into the runner, so +-- rewording what an agent was asked to do meant shipping a release. Routes now +-- store the prompt itself. Existing rows are rewritten in place from the +-- template they referenced, so no route loses its behaviour. + +UPDATE routes +SET definition = jsonb_set( + definition #- '{execution,promptTemplate}', + '{execution,prompt}', + to_jsonb( + CASE definition -> 'execution' ->> 'promptTemplate' + WHEN 'product-review' THEN + E'You are reviewing a proposed piece of work for product sense and feasibility.\nDo not write or change any code. This is an assessment, not an implementation.\n\nTicket: {{ticket.ref}}\nTitle: {{ticket.title}}\nLink: {{ticket.url}}\nLabels: {{ticket.labels}}\n\nDescription:\n{{ticket.body}}\n\nAssess and report on:\n- What is actually being asked for, in your own words.\n- Whether it is worth doing, and what it competes with.\n- Rough feasibility and the main technical risks.\n- Anything underspecified that someone must decide before work starts.\n\nBe direct. If this is a bad idea, say so and say why.' + WHEN 'pr-review' THEN + E'You have been requested as a reviewer on a pull request.\nReview it as you would a colleague''s work: correctness first, then clarity.\n\nPull request: {{ticket.ref}} (#{{pr.number}})\nTitle: {{ticket.title}}\nLink: {{ticket.url}}\n\nDescription:\n{{ticket.body}}\n\nRead the diff before commenting. Prefer a small number of substantive\nfindings over exhaustive nitpicking, and say plainly when it looks good.' + WHEN 'implementation' THEN + E'You are implementing a piece of work end to end.\n\nTicket: {{ticket.ref}}\nTitle: {{ticket.title}}\nLink: {{ticket.url}}\n\nDescription:\n{{ticket.body}}\n\nYou own the whole change: create your own branch, make the edits, run the\nchecks, commit, push, and open the pull request under your own identity.\nKeep the change scoped to what the ticket asks for. If you cannot proceed,\nstop and explain why rather than guessing.' + ELSE + E'Ticket: {{ticket.ref}}\nTitle: {{ticket.title}}\nLink: {{ticket.url}}\nLabels: {{ticket.labels}}\n\nDescription:\n{{ticket.body}}' + END + ), + true + ) +WHERE definition -> 'execution' ? 'promptTemplate'; + +-- Shown beside the agent's Slack notifications. +ALTER TABLE agents ADD COLUMN IF NOT EXISTS avatar_url TEXT; diff --git a/packages/cloud-api/src/migrations/003_runner_health.sql b/packages/cloud-api/src/migrations/003_runner_health.sql new file mode 100644 index 0000000..c67c5c8 --- /dev/null +++ b/packages/cloud-api/src/migrations/003_runner_health.sql @@ -0,0 +1,20 @@ +-- Runner health, so the dashboard can say more than "a runner exists". +-- +-- Before this, `last_seen_at` was written only by POST /v1/runner/hello, which +-- a runner calls once at startup. A runner that had been up and working for +-- three days therefore reported "last seen 3 days ago", and the `stale` flag — +-- last_seen_at older than 90 seconds — was true for every runner that had been +-- running for more than 90 seconds. The indicator was wrong in exactly the case +-- it exists to cover. +-- +-- All columns are nullable: a runner on an older build sends none of them, and +-- must keep registering rather than failing an insert. + +ALTER TABLE runners ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ; +ALTER TABLE runners ADD COLUMN IF NOT EXISTS hermes_ok BOOLEAN; +ALTER TABLE runners ADD COLUMN IF NOT EXISTS hermes_detail TEXT; +ALTER TABLE runners ADD COLUMN IF NOT EXISTS active_runs INTEGER; +ALTER TABLE runners ADD COLUMN IF NOT EXISTS last_error TEXT; + +-- The dashboard's runner list orders by name but filters on liveness. +CREATE INDEX IF NOT EXISTS runners_org_last_seen_idx ON runners (org_id, last_seen_at DESC); diff --git a/packages/cloud-api/src/notifications/slack.ts b/packages/cloud-api/src/notifications/slack.ts new file mode 100644 index 0000000..71b785d --- /dev/null +++ b/packages/cloud-api/src/notifications/slack.ts @@ -0,0 +1,186 @@ +import type { RunRecord } from '@parallax/common' +import type { Database } from '../db.js' + +export type NotificationEvent = + | 'run.started' + | 'run.completed' + | 'run.failed' + | 'run.needs_approval' + | 'run.canceled' + | 'runner.stale' + +const ICONS: Record = { + 'run.started': ':hourglass_flowing_sand:', + 'run.completed': ':white_check_mark:', + 'run.failed': ':x:', + 'run.needs_approval': ':raising_hand:', + 'run.canceled': ':black_square_for_stop:', + 'runner.stale': ':warning:', +} + +function duration(run: RunRecord): string | undefined { + if (!run.startedAt || !run.endedAt) { + return undefined + } + const seconds = Math.round((run.endedAt - run.startedAt) / 1000) + return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s` +} + +/** + * The message a human reads to know what the agents are doing. + * + * Leads with the agent and what triggered it, because that is the question + * someone glancing at the channel is actually asking. + */ +export function buildSlackMessage( + event: NotificationEvent, + run: RunRecord, + agent?: { avatarUrl?: string; displayName?: string } +): Record { + const took = duration(run) + const lines = [ + `${ICONS[event]} *${run.agentProfile}* ${verb(event)} — ${run.title}`, + [ + run.triggerUrl ? `<${run.triggerUrl}|${run.triggerRef}>` : run.triggerRef, + run.routeName ? `via _${run.routeName}_` : undefined, + took ? `in ${took}` : undefined, + ] + .filter(Boolean) + .join(' · '), + ] + + const detail = event === 'run.failed' ? run.error : run.summary + if (detail) { + // Slack renders long blocks badly; the run detail page has the full text. + lines.push('', detail.length > 600 ? `${detail.slice(0, 600)}…` : detail) + } + + const text = lines.join('\n') + + // The agent's avatar goes *inside* the message, as a Block Kit accessory -- + // never as top-level `username`/`icon_url`, which would override the Slack + // app's own identity on the webhook. `text` is kept alongside `blocks` so + // notifications and previews still read correctly where blocks are not shown. + if (!agent?.avatarUrl) { + return { text, mrkdwn: true } + } + + return { + text, + mrkdwn: true, + blocks: [ + { + type: 'section', + text: { type: 'mrkdwn', text }, + accessory: { + type: 'image', + image_url: agent.avatarUrl, + alt_text: agent.displayName ?? run.agentProfile, + }, + }, + ], + } +} + +function verb(event: NotificationEvent): string { + switch (event) { + case 'run.started': + return 'started' + case 'run.completed': + return 'finished' + case 'run.failed': + return 'failed' + case 'run.needs_approval': + return 'needs approval' + case 'run.canceled': + return 'was canceled' + case 'runner.stale': + return 'went offline' + } +} + +interface SlackConfigRow { + webhook_url: string + events: string[] + enabled: boolean +} + +/** + * Posts one run lifecycle event to the org's Slack webhook. + * + * Never throws: notification is a side channel, and a Slack outage must not + * fail the runner's mirror write. The `(run_id, event)` unique constraint on + * `notification_deliveries` is what makes this safe to call more than once for + * the same transition -- a duplicate insert loses the race and does not post. + */ +export async function notifyRunEvent( + db: Database, + orgId: string, + run: RunRecord, + event: string +): Promise { + try { + const { rows } = await db.query( + 'SELECT webhook_url, events, enabled FROM slack_integrations WHERE org_id = $1', + [orgId] + ) + const config = rows[0] + if (!config?.enabled || !config.events.includes(event)) { + return + } + + // Claim the delivery. Re-claiming is allowed only when the previous attempt + // did not succeed: without that, one transient Slack outage would suppress + // that notification permanently, because the claim row already existed. + const claimed = await db.query( + `INSERT INTO notification_deliveries (org_id, run_id, event, status, attempts) + VALUES ($1,$2,$3,'pending',1) + ON CONFLICT (run_id, event) DO UPDATE + SET attempts = notification_deliveries.attempts + 1 + WHERE notification_deliveries.status <> 'delivered' + AND notification_deliveries.attempts < 5 + RETURNING id`, + [orgId, run.id, event] + ) + if (claimed.rowCount === 0) { + return + } + + const agent = await db + .query<{ avatar_url: string | null; display_name: string | null }>( + 'SELECT avatar_url, display_name FROM agents WHERE org_id = $1 AND profile = $2 LIMIT 1', + [orgId, run.agentProfile] + ) + .then((result) => result.rows[0]) + .catch(() => undefined) + + const response = await fetch(config.webhook_url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify( + buildSlackMessage(event as NotificationEvent, run, { + avatarUrl: agent?.avatar_url ?? undefined, + displayName: agent?.display_name ?? undefined, + }) + ), + signal: AbortSignal.timeout(10_000), + }) + + await db.query( + 'UPDATE notification_deliveries SET status = $3, last_error = $4 WHERE run_id = $1 AND event = $2', + [ + run.id, + event, + response.ok ? 'delivered' : 'failed', + response.ok ? null : `HTTP ${response.status}`, + ] + ) + } catch (error: unknown) { + await db + .query( + 'UPDATE notification_deliveries SET status = $3, last_error = $4 WHERE run_id = $1 AND event = $2', + [run.id, event, 'failed', error instanceof Error ? error.message : String(error)] + ) + .catch(() => undefined) + } +} diff --git a/packages/cloud-api/src/org-cli.ts b/packages/cloud-api/src/org-cli.ts new file mode 100644 index 0000000..bce838a --- /dev/null +++ b/packages/cloud-api/src/org-cli.ts @@ -0,0 +1,150 @@ +import { generateKey, newId, type KeyScope } from './auth.js' +import { closePool, getPool, migrate, type Database } from './db.js' + +/** + * One-off bootstrap: creates an organization and its first API keys. + * + * This exists because the key-minting endpoint needs a user key to call, and the + * first one has to come from somewhere. Run it inside the deployed container + * (`railway ssh`), or locally against the database's public URL. It is the only + * path that can create credentials without already holding one. + */ + +interface Args { + name?: string + addKey?: KeyScope + org?: string + list?: boolean +} + +function parseArgs(argv: string[]): Args { + const args: Args = {} + for (let i = 0; i < argv.length; i += 1) { + const flag = argv[i] + const value = argv[i + 1] + switch (flag) { + case '--name': + args.name = requireValue(flag, value) + i += 1 + break + case '--org': + args.org = requireValue(flag, value) + i += 1 + break + case '--add-key': + if (value !== 'runner' && value !== 'user') { + throw new Error('--add-key must be "runner" or "user".') + } + args.addKey = value + i += 1 + break + case '--list': + args.list = true + break + default: + throw new Error(`Unknown argument "${flag}".`) + } + } + return args +} + +function requireValue(flag: string, value: string | undefined): string { + if (!value || value.startsWith('--')) { + throw new Error(`${flag} requires a value.`) + } + return value +} + +async function mintKey( + db: Database, + orgId: string, + scope: KeyScope, + name: string +): Promise { + const { key, hash, prefix } = generateKey(scope) + await db.query( + 'INSERT INTO api_keys (id, org_id, name, scope, key_hash, prefix) VALUES ($1,$2,$3,$4,$5,$6)', + [newId('key'), orgId, name, scope, hash, prefix] + ) + return key +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)) + + if (!args.name && !args.list && !args.addKey) { + console.log( + [ + '', + 'Usage:', + ' node dist/org-cli.js --name "Acme" create an org plus its first user and runner keys', + ' node dist/org-cli.js --list list organizations', + ' node dist/org-cli.js --org --add-key runner|user', + '', + 'Requires DATABASE_URL.', + '', + ].join('\n') + ) + return + } + + const db = getPool() + await migrate(db) + + if (args.list) { + const { rows } = await db.query('SELECT id, name, created_at FROM organizations ORDER BY name') + if (rows.length === 0) { + console.log('No organizations yet. Create one with --name "Your Org".') + return + } + for (const row of rows) { + console.log(`${row.id} ${row.name}`) + } + return + } + + if (args.addKey) { + if (!args.org) { + throw new Error('--add-key requires --org . List them with --list.') + } + const { rowCount } = await db.query('SELECT 1 FROM organizations WHERE id = $1', [args.org]) + if (rowCount === 0) { + throw new Error(`Organization "${args.org}" not found.`) + } + const key = await mintKey(db, args.org, args.addKey, `${args.addKey} key`) + console.log(`\n ${args.addKey} key: ${key}\n`) + console.log('Store it now; it is not recoverable.\n') + return + } + + const orgId = newId('org') + await db.query('INSERT INTO organizations (id, name) VALUES ($1, $2)', [orgId, args.name]) + + const userKey = await mintKey(db, orgId, 'user', 'bootstrap user key') + const runnerKey = await mintKey(db, orgId, 'runner', 'bootstrap runner key') + + console.log( + [ + '', + `Organization created: ${args.name}`, + ` id: ${orgId}`, + '', + ' user key: ' + userKey, + ' Use for the management API (routes, projects, Slack, runs).', + '', + ' runner key: ' + runnerKey, + ' Give this to the runner on cerebro during "parallax init".', + '', + 'Neither key is recoverable. Store them now.', + '', + ].join('\n') + ) +} + +main() + .then(() => closePool()) + .catch(async (error) => { + console.error(error instanceof Error ? error.message : error) + await closePool() + process.exit(1) + }) diff --git a/packages/cloud-api/src/routes/runner.ts b/packages/cloud-api/src/routes/runner.ts new file mode 100644 index 0000000..5ddd40e --- /dev/null +++ b/packages/cloud-api/src/routes/runner.ts @@ -0,0 +1,367 @@ +import type { FastifyInstance } from 'fastify' +import type { AgentDescriptor, RoutingRule, RunLogEntry, RunRecord } from '@parallax/common' +import { authenticate, newId, parseBearer, type AuthContext } from '../auth.js' +import type { Database } from '../db.js' +import { notifyRunEvent } from '../notifications/slack.js' + +/** How long a command long-poll may be held open. */ +const MAX_WAIT_SECONDS = 30 +const POLL_TICK_MS = 500 + +async function requireRunner( + db: Database, + header: string | undefined +): Promise { + return authenticate(db, parseBearer(header), 'runner') +} + +export function registerRunnerRoutes(app: FastifyInstance, db: Database): void { + app.addHook('onRequest', async (request, reply) => { + if (!request.url.startsWith('/v1/runner/')) { + return + } + const auth = await requireRunner(db, request.headers.authorization) + if (!auth) { + return reply.code(401).send({ error: 'A runner API key is required.' }) + } + ;(request as { auth?: AuthContext }).auth = auth + + // Any authenticated runner call is proof of life, and the command long-poll + // makes one roughly every 25 seconds on its own. Touching last_seen_at here + // means liveness never depends on the runner remembering to say so — a + // runner on an older build, which never sends a heartbeat, still reports as + // alive because it is still polling. + // + // Fire-and-forget: liveness bookkeeping must not fail a real request. + void db + .query('UPDATE runners SET last_seen_at = now() WHERE org_id = $1', [auth.orgId]) + .catch(() => undefined) + }) + + const authOf = (request: unknown): AuthContext => (request as { auth: AuthContext }).auth + + // ── Registration / heartbeat ─────────────────────────────── + + app.post('/v1/runner/hello', async (request) => { + const { orgId } = authOf(request) + const { name, hostname, version } = request.body as { + name: string + hostname?: string + version?: string + } + + // started_at is set from the server clock on every hello, because hello is + // sent exactly once per process. It is the runner's uptime, not the age of + // the row, and resetting it on re-registration is the point: a restart is + // the thing an operator wants to see. + const { rows } = await db.query<{ id: string }>( + `INSERT INTO runners (id, org_id, name, hostname, version, last_seen_at, started_at) + VALUES ($1, $2, $3, $4, $5, now(), now()) + ON CONFLICT (org_id, name) + DO UPDATE SET hostname = EXCLUDED.hostname, + version = EXCLUDED.version, + last_seen_at = now(), + started_at = now(), + last_error = NULL + RETURNING id`, + [newId('rnr'), orgId, name, hostname ?? null, version ?? null] + ) + + return { runnerId: rows[0].id, routesRevision: String(Date.now()) } + }) + + /** + * Periodic health, richer than "it is still there". + * + * The runner is behind NAT and accepts no inbound connections, so the + * dashboard can never call the runner — health has to be pushed. Every field + * is optional so an older runner, which sends none of them, still registers + * and still reads as alive. + */ + app.post('/v1/runner/heartbeat', async (request, reply) => { + const { orgId } = authOf(request) + const body = request.body as { + name?: string + startedAt?: string + hermesOk?: boolean + hermesDetail?: string + activeRuns?: number + lastError?: string | null + } + + if (!body.name) { + return reply.code(400).send({ error: 'name is required.' }) + } + + const { rowCount } = await db.query( + `UPDATE runners + SET last_seen_at = now(), + started_at = COALESCE($3::timestamptz, started_at), + hermes_ok = $4, + hermes_detail = $5, + active_runs = $6, + last_error = $7 + WHERE org_id = $1 AND name = $2`, + [ + orgId, + body.name, + body.startedAt ?? null, + body.hermesOk ?? null, + body.hermesDetail ?? null, + body.activeRuns ?? null, + body.lastError ?? null, + ] + ) + + // A heartbeat for a runner the cloud has never seen means the row was + // deleted, or this runner has never said hello. Saying so lets the runner + // re-register rather than heartbeating into the void forever. + if (rowCount === 0) { + return reply.code(404).send({ error: `Runner "${body.name}" is not registered.` }) + } + return { ok: true } + }) + + // ── Inventory ────────────────────────────────────────────── + + app.put('/v1/runner/inventory', async (request, reply) => { + const { orgId } = authOf(request) + const { agents } = request.body as { agents: AgentDescriptor[] } + + const runner = await currentRunner(db, orgId) + if (!runner) { + return reply.code(409).send({ error: 'Call /v1/runner/hello before pushing inventory.' }) + } + + const client = await db.connect() + try { + await client.query('BEGIN') + // Inventory is derived state: replacing it wholesale is what makes a + // deleted Hermes profile disappear from the registry rather than linger. + await client.query('DELETE FROM agents WHERE runner_id = $1', [runner]) + for (const agent of agents) { + await client.query( + `INSERT INTO agents (id, org_id, runner_id, profile, display_name, role, model, + provider, toolsets, skills, github_login, avatar_url, + enabled, synced_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13, now())`, + [ + newId('agt'), + orgId, + runner, + agent.profile, + agent.displayName ?? null, + agent.role ?? null, + agent.model ?? null, + agent.provider ?? null, + JSON.stringify(agent.toolsets ?? []), + JSON.stringify(agent.skills ?? []), + agent.githubLogin ?? null, + agent.avatarUrl ?? null, + agent.enabled, + ] + ) + } + await client.query('COMMIT') + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { + client.release() + } + + return { ok: true, count: agents.length } + }) + + // ── Projects ─────────────────────────────────────────────── + + // The runner has no project configuration of its own: what to watch is + // decided in the cloud, so it has to be able to read it back. + app.get('/v1/runner/projects', async (request) => { + const { orgId } = authOf(request) + const { rows } = await db.query<{ id: string; provider: string; filters: unknown }>( + 'SELECT id, provider, filters FROM projects WHERE org_id = $1 ORDER BY id', + [orgId] + ) + return { projects: rows } + }) + + // ── Routes ───────────────────────────────────────────────── + + app.get('/v1/runner/routes', async (request) => { + const { orgId } = authOf(request) + const { rows } = await db.query<{ definition: RoutingRule; updated_at: Date }>( + 'SELECT definition, updated_at FROM routes WHERE org_id = $1 AND enabled = true ORDER BY priority DESC', + [orgId] + ) + const revision = rows.reduce( + (latest: number, row) => Math.max(latest, new Date(row.updated_at).getTime()), + 0 + ) + return { revision: String(revision), routes: rows.map((row) => row.definition) } + }) + + // ── Command inbox (long poll) ────────────────────────────── + + app.get('/v1/runner/commands', async (request) => { + const { orgId } = authOf(request) + const query = request.query as { cursor?: string; wait?: string } + const cursor = Number.parseInt(query.cursor ?? '0', 10) || 0 + const wait = Math.min(Number.parseInt(query.wait ?? '25', 10) || 25, MAX_WAIT_SECONDS) + + const deadline = Date.now() + wait * 1_000 + + // Poll the table rather than using LISTEN/NOTIFY: it survives connection + // churn and pooling, and at one runner per org the cost is negligible. + for (;;) { + const commands = await fetchCommands(db, orgId, cursor) + if (commands.length > 0 || Date.now() >= deadline) { + return { commands } + } + await new Promise((resolve) => setTimeout(resolve, POLL_TICK_MS)) + } + }) + + app.post('/v1/runner/commands/ack', async (request) => { + const { orgId } = authOf(request) + const { cursor } = request.body as { cursor: number } + await db.query( + 'UPDATE runner_commands SET acked_at = now() WHERE org_id = $1 AND cursor <= $2 AND acked_at IS NULL', + [orgId, cursor] + ) + return { ok: true } + }) + + // ── Run mirroring ────────────────────────────────────────── + + app.post('/v1/runner/runs', async (request) => { + const { orgId } = authOf(request) + const { run } = request.body as { run: RunRecord } + await upsertRun(db, orgId, await currentRunner(db, orgId), run) + await notifyRunEvent(db, orgId, run, 'run.started') + return { ok: true } + }) + + app.patch('/v1/runner/runs/:runId', async (request) => { + const { orgId } = authOf(request) + const { run } = request.body as { run: RunRecord } + await upsertRun(db, orgId, await currentRunner(db, orgId), run) + await notifyRunEvent(db, orgId, run, statusEvent(run.status)) + return { ok: true } + }) + + app.post('/v1/runner/runs/:runId/events', async (request) => { + const { runId } = request.params as { runId: string } + const { events } = request.body as { events: RunLogEntry[] } + + for (const event of events) { + await db.query( + `INSERT INTO run_events (run_id, title, message, icon, level, kind, source, group_id, ts) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, + [ + runId, + event.title ?? null, + event.message, + event.icon ?? null, + event.level, + event.kind, + event.source, + event.groupId ?? null, + event.timestamp, + ] + ) + } + return { ok: true, count: events.length } + }) +} + +function statusEvent(status: string): string { + switch (status) { + case 'completed': + return 'run.completed' + case 'failed': + return 'run.failed' + case 'canceled': + return 'run.canceled' + case 'awaiting_approval': + return 'run.needs_approval' + default: + return 'run.started' + } +} + +async function currentRunner(db: Database, orgId: string): Promise { + const { rows } = await db.query<{ id: string }>( + 'SELECT id FROM runners WHERE org_id = $1 ORDER BY last_seen_at DESC LIMIT 1', + [orgId] + ) + return rows[0]?.id ?? null +} + +interface CommandRow { + cursor: string + id: string + type: 'run' | 'cancel' | 'resync' + payload: Record +} + +async function fetchCommands(db: Database, orgId: string, cursor: number) { + const { rows } = await db.query( + `SELECT cursor, id, type, payload FROM runner_commands + WHERE org_id = $1 AND cursor > $2 AND acked_at IS NULL + ORDER BY cursor ASC LIMIT 50`, + [orgId, cursor] + ) + return rows.map((row) => ({ + id: row.id, + cursor: Number(row.cursor), + type: row.type, + payload: row.payload, + })) +} + +async function upsertRun( + db: Database, + orgId: string, + runnerId: string | null, + run: RunRecord +): Promise { + await db.query( + `INSERT INTO runs (id, org_id, runner_id, route_id, route_name, agent_profile, project_id, + trigger_type, trigger_ref, trigger_url, title, status, hermes_run_id, + summary, error, usage, started_at, ended_at, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16, + to_timestamp($17::double precision / 1000), to_timestamp($18::double precision / 1000), + to_timestamp($19::double precision / 1000), now()) + ON CONFLICT (id) DO UPDATE SET + status = EXCLUDED.status, + hermes_run_id = COALESCE(EXCLUDED.hermes_run_id, runs.hermes_run_id), + summary = COALESCE(EXCLUDED.summary, runs.summary), + error = COALESCE(EXCLUDED.error, runs.error), + usage = COALESCE(EXCLUDED.usage, runs.usage), + started_at = COALESCE(EXCLUDED.started_at, runs.started_at), + ended_at = COALESCE(EXCLUDED.ended_at, runs.ended_at), + updated_at = now()`, + [ + run.id, + orgId, + runnerId, + run.routeId, + run.routeName, + run.agentProfile, + run.projectId, + run.triggerType, + run.triggerRef, + run.triggerUrl ?? null, + run.title, + run.status, + run.hermesRunId ?? null, + run.summary ?? null, + run.error ?? null, + run.usage ? JSON.stringify(run.usage) : null, + run.startedAt ?? null, + run.endedAt ?? null, + run.createdAt, + ] + ) +} diff --git a/packages/cloud-api/src/routes/user.ts b/packages/cloud-api/src/routes/user.ts new file mode 100644 index 0000000..45f3f7e --- /dev/null +++ b/packages/cloud-api/src/routes/user.ts @@ -0,0 +1,448 @@ +import type { FastifyInstance } from 'fastify' +import { + DEFAULT_ROUTE_GUARD, + PARALLAX_LABELS, + PROMPT_CATALOG, + PROMPT_VARIABLES, + ROUTE_CATALOG, + validateRoutingRule, + type RoutingRule, +} from '@parallax/common' +import { authenticate, generateKey, newId, parseBearer, type AuthContext } from '../auth.js' +import type { Database } from '../db.js' + +/** + * The human-facing API. + * + * This is the contract `packages/dashboard` will consume, so it is shaped for a + * UI from the start: list endpoints are paged, mutations return the stored + * object, and nothing here requires knowing how the runner works. + */ +export function registerUserRoutes(app: FastifyInstance, db: Database): void { + app.addHook('onRequest', async (request, reply) => { + if (!request.url.startsWith('/v1/') || request.url.startsWith('/v1/runner/')) { + return + } + const auth = await authenticate(db, parseBearer(request.headers.authorization), 'user') + if (!auth) { + return reply.code(401).send({ error: 'A user API key is required.' }) + } + ;(request as { auth?: AuthContext }).auth = auth + }) + + const authOf = (request: unknown): AuthContext => (request as { auth: AuthContext }).auth + + // ── Identity ─────────────────────────────────────────────── + + /** + * Resolves the presented key to the org behind it. + * + * The dashboard needs this: a key is the whole of its login, so it has to be + * able to check one before storing it, and show whose org it opened. Every + * other endpoint would answer the "is this key valid" half, but none names + * the organization, and picking an arbitrary one to probe with would make an + * unrelated endpoint's failure look like a rejected key. + */ + app.get('/v1/me', async (request) => { + const { orgId, keyId } = authOf(request) + const { rows } = await db.query( + `SELECT o.id, o.name, o.created_at, k.name AS key_name, k.prefix AS key_prefix + FROM organizations o JOIN api_keys k ON k.org_id = o.id + WHERE o.id = $1 AND k.id = $2`, + [orgId, keyId] + ) + const row = rows[0] as + | { id: string; name: string; created_at: string; key_name: string; key_prefix: string } + | undefined + + return { + org: { id: orgId, name: row?.name ?? orgId, createdAt: row?.created_at ?? null }, + key: { + id: keyId, + name: row?.key_name ?? null, + prefix: row?.key_prefix ?? null, + scope: 'user', + }, + } + }) + + // ── Runners and agents ───────────────────────────────────── + + /** + * Runners and how they are doing. + * + * `stale` is the whole point of the endpoint, so the threshold matters: the + * runner heartbeats every 30 seconds, and 90 allows three to be missed before + * anything is reported wrong. A shorter window would report every transient + * network blip as an outage. + */ + app.get('/v1/runners', async (request) => { + const { orgId } = authOf(request) + const { rows } = await db.query( + `SELECT id, name, hostname, version, last_seen_at, started_at, + hermes_ok, hermes_detail, active_runs, last_error, + (last_seen_at < now() - interval '90 seconds') AS stale + FROM runners WHERE org_id = $1 ORDER BY name`, + [orgId] + ) + return { runners: rows } + }) + + app.get('/v1/agents', async (request) => { + const { orgId } = authOf(request) + const { rows } = await db.query( + `SELECT a.id, a.profile, a.display_name, a.role, a.model, a.provider, + a.toolsets, a.skills, a.github_login, a.avatar_url, a.enabled, a.synced_at, + r.name AS runner + FROM agents a JOIN runners r ON r.id = a.runner_id + WHERE a.org_id = $1 ORDER BY a.profile`, + [orgId] + ) + return { agents: rows } + }) + + // ── Projects ─────────────────────────────────────────────── + + app.get('/v1/projects', async (request) => { + const { orgId } = authOf(request) + const { rows } = await db.query( + 'SELECT id, provider, filters FROM projects WHERE org_id = $1 ORDER BY id', + [orgId] + ) + return { projects: rows } + }) + + app.post('/v1/projects', async (request, reply) => { + const { orgId } = authOf(request) + const body = request.body as { id?: string; provider?: string; filters?: unknown } + + if (!body.id || !body.provider) { + return reply.code(400).send({ error: 'id and provider are required.' }) + } + if (body.provider !== 'github' && body.provider !== 'linear') { + return reply.code(400).send({ error: 'provider must be "github" or "linear".' }) + } + + await db.query( + `INSERT INTO projects (id, org_id, provider, filters) VALUES ($1,$2,$3,$4) + ON CONFLICT (id) DO UPDATE SET provider = EXCLUDED.provider, filters = EXCLUDED.filters`, + [body.id, orgId, body.provider, JSON.stringify(body.filters ?? {})] + ) + return reply.code(201).send({ id: body.id }) + }) + + app.delete('/v1/projects/:id', async (request) => { + const { orgId } = authOf(request) + const { id } = request.params as { id: string } + await db.query('DELETE FROM projects WHERE org_id = $1 AND id = $2', [orgId, id]) + return { ok: true } + }) + + // ── Routes ───────────────────────────────────────────────── + + app.get('/v1/routes', async (request) => { + const { orgId } = authOf(request) + const { rows } = await db.query<{ definition: RoutingRule }>( + 'SELECT definition FROM routes WHERE org_id = $1 ORDER BY priority DESC, id', + [orgId] + ) + return { routes: rows.map((row: { definition: RoutingRule }) => row.definition) } + }) + + app.post('/v1/routes', async (request, reply) => { + const { orgId } = authOf(request) + const route = request.body as RoutingRule + + const problem = validateRoutingRule(route) + if (problem) { + return reply.code(400).send({ error: problem }) + } + + const id = route.id || newId('rt') + // Stored explicitly rather than left to the runner's default, so what a + // route will do is visible in the API rather than implied. + const stored: RoutingRule = { ...route, id, guard: { ...DEFAULT_ROUTE_GUARD, ...route.guard } } + + await db.query( + `INSERT INTO routes (id, org_id, name, priority, enabled, definition, updated_at) + VALUES ($1,$2,$3,$4,$5,$6, now()) + ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, priority = EXCLUDED.priority, + enabled = EXCLUDED.enabled, definition = EXCLUDED.definition, + updated_at = now()`, + [ + id, + orgId, + stored.name, + stored.priority ?? 0, + stored.enabled !== false, + JSON.stringify(stored), + ] + ) + return reply.code(201).send({ route: stored }) + }) + + app.get('/v1/routes/:id', async (request, reply) => { + const { orgId } = authOf(request) + const { id } = request.params as { id: string } + const { rows } = await db.query<{ definition: RoutingRule }>( + 'SELECT definition FROM routes WHERE org_id = $1 AND id = $2', + [orgId, id] + ) + if (rows.length === 0) { + return reply.code(404).send({ error: `Route "${id}" not found.` }) + } + return { route: rows[0].definition } + }) + + /** + * Replaces a route in place. + * + * A full replacement rather than a partial merge. A route is a single + * decision — trigger, match, target, execution, outcome — and the parts + * constrain each other: a `githubLogin` target is valid on a pull request + * trigger and invalid on a ticket one. Merging a field at a time would let a + * caller move a route through states the validator rejects as a whole, so the + * body is validated as the complete rule it will become. + * + * The id in the path wins over any id in the body, so a copy-pasted + * definition cannot rewrite a different route. + */ + app.put('/v1/routes/:id', async (request, reply) => { + const { orgId } = authOf(request) + const { id } = request.params as { id: string } + const body = request.body as RoutingRule + + const route: RoutingRule = { ...body, id } + const problem = validateRoutingRule(route) + if (problem) { + return reply.code(400).send({ error: problem }) + } + + const stored: RoutingRule = { ...route, guard: { ...DEFAULT_ROUTE_GUARD, ...route.guard } } + const { rowCount } = await db.query( + `UPDATE routes + SET name = $3, priority = $4, enabled = $5, definition = $6, updated_at = now() + WHERE org_id = $1 AND id = $2`, + [ + orgId, + id, + stored.name, + stored.priority ?? 0, + stored.enabled !== false, + JSON.stringify(stored), + ] + ) + + // Not an upsert: PUT to a route that does not exist is a mistake worth + // reporting, not a reason to create one under a caller-chosen id. + if (rowCount === 0) { + return reply.code(404).send({ error: `Route "${id}" not found.` }) + } + return { route: stored } + }) + + app.delete('/v1/routes/:id', async (request) => { + const { orgId } = authOf(request) + const { id } = request.params as { id: string } + await db.query('DELETE FROM routes WHERE org_id = $1 AND id = $2', [orgId, id]) + return { ok: true } + }) + + /** + * Starter prompts and the placeholders they can use. + * + * The dashboard prefills its prompt editor from this; nothing dispatches by + * template id, so changing the catalog never alters an existing route. + */ + app.get('/v1/prompt-templates', async () => ({ + templates: PROMPT_CATALOG, + variables: PROMPT_VARIABLES, + })) + + /** + * Complete, ready-to-create routes for every supported case. + * + * Each carries `` tokens a user fills in, distinct from the + * `{{variables}}` the runner substitutes at dispatch. Every entry is verified + * against this same API's validator in CI, so picking one and filling it in + * always produces a route the API accepts. + */ + app.get('/v1/route-templates', async () => ({ + templates: ROUTE_CATALOG, + defaultGuard: DEFAULT_ROUTE_GUARD, + })) + + /** Labels Parallax manages itself, for a dashboard to render distinctly. */ + app.get('/v1/reserved-labels', async () => ({ + labels: PARALLAX_LABELS, + defaultGuard: DEFAULT_ROUTE_GUARD, + })) + + // ── Runs ─────────────────────────────────────────────────── + + app.get('/v1/runs', async (request) => { + const { orgId } = authOf(request) + const query = request.query as { limit?: string; status?: string } + const limit = Math.min(Number.parseInt(query.limit ?? '50', 10) || 50, 200) + + const { rows } = await db.query( + `SELECT id, route_name, agent_profile, project_id, trigger_ref, trigger_url, title, + status, summary, error, started_at, ended_at, updated_at + FROM runs WHERE org_id = $1 AND ($2::text IS NULL OR status = $2) + ORDER BY updated_at DESC LIMIT $3`, + [orgId, query.status ?? null, limit] + ) + return { runs: rows } + }) + + app.get('/v1/runs/:id', async (request, reply) => { + const { orgId } = authOf(request) + const { id } = request.params as { id: string } + const { rows } = await db.query('SELECT * FROM runs WHERE org_id = $1 AND id = $2', [orgId, id]) + if (rows.length === 0) { + return reply.code(404).send({ error: `Run "${id}" not found.` }) + } + return { run: rows[0] } + }) + + app.get('/v1/runs/:id/events', async (request) => { + const { orgId } = authOf(request) + const { id } = request.params as { id: string } + const query = request.query as { since?: string; limit?: string } + + const { rows } = await db.query( + `SELECT e.title, e.message, e.icon, e.level, e.kind, e.source, e.group_id, e.ts + FROM run_events e JOIN runs r ON r.id = e.run_id + WHERE r.org_id = $1 AND e.run_id = $2 AND e.ts >= $3 + ORDER BY e.ts ASC, e.id ASC LIMIT $4`, + [ + orgId, + id, + Number.parseInt(query.since ?? '0', 10) || 0, + Math.min(Number.parseInt(query.limit ?? '500', 10) || 500, 2000), + ] + ) + return { events: rows } + }) + + /** Queue a manual dispatch for the runner to pick up on its next poll. */ + app.post('/v1/runs', async (request, reply) => { + const { orgId } = authOf(request) + const body = request.body as { event?: unknown } + if (!body.event) { + return reply.code(400).send({ error: 'event is required.' }) + } + const id = newId('cmd') + await db.query( + `INSERT INTO runner_commands (id, org_id, type, payload) VALUES ($1,$2,'run',$3)`, + [id, orgId, JSON.stringify({ event: body.event })] + ) + return reply.code(202).send({ queued: id }) + }) + + app.post('/v1/runs/:id/cancel', async (request, reply) => { + const { orgId } = authOf(request) + const { id } = request.params as { id: string } + const commandId = newId('cmd') + await db.query( + `INSERT INTO runner_commands (id, org_id, type, payload) VALUES ($1,$2,'cancel',$3)`, + [commandId, orgId, JSON.stringify({ runId: id })] + ) + return reply.code(202).send({ queued: commandId }) + }) + + app.post('/v1/resync', async (request, reply) => { + const { orgId } = authOf(request) + const commandId = newId('cmd') + await db.query( + `INSERT INTO runner_commands (id, org_id, type, payload) VALUES ($1,$2,'resync','{}'::jsonb)`, + [commandId, orgId] + ) + return reply.code(202).send({ queued: commandId }) + }) + + // ── Slack ────────────────────────────────────────────────── + + app.get('/v1/integrations/slack', async (request) => { + const { orgId } = authOf(request) + const { rows } = await db.query( + 'SELECT enabled, events, created_at FROM slack_integrations WHERE org_id = $1', + [orgId] + ) + // The webhook URL is a credential: report that one is configured, never what. + return { configured: rows.length > 0, ...(rows[0] ?? {}) } + }) + + app.put('/v1/integrations/slack', async (request, reply) => { + const { orgId } = authOf(request) + const body = request.body as { webhookUrl?: string; events?: string[]; enabled?: boolean } + + // Slack's own host, unless an operator has deliberately pointed the + // notifier somewhere else (a sink, a relay) via SLACK_WEBHOOK_HOST. + const allowedPrefix = process.env.SLACK_WEBHOOK_HOST ?? 'https://hooks.slack.com/' + if (!body.webhookUrl?.startsWith(allowedPrefix)) { + return reply.code(400).send({ error: `webhookUrl must start with ${allowedPrefix}` }) + } + + await db.query( + `INSERT INTO slack_integrations (org_id, webhook_url, enabled) + VALUES ($1,$2,$3) + ON CONFLICT (org_id) DO UPDATE SET webhook_url = EXCLUDED.webhook_url, + enabled = EXCLUDED.enabled`, + [orgId, body.webhookUrl, body.enabled !== false] + ) + if (body.events?.length) { + await db.query('UPDATE slack_integrations SET events = $2 WHERE org_id = $1', [ + orgId, + body.events, + ]) + } + return { ok: true } + }) + + app.delete('/v1/integrations/slack', async (request) => { + const { orgId } = authOf(request) + await db.query('DELETE FROM slack_integrations WHERE org_id = $1', [orgId]) + return { ok: true } + }) + + // ── API keys ─────────────────────────────────────────────── + + app.get('/v1/keys', async (request) => { + const { orgId } = authOf(request) + const { rows } = await db.query( + `SELECT id, name, scope, prefix, created_at, last_used_at, revoked_at + FROM api_keys WHERE org_id = $1 ORDER BY created_at DESC`, + [orgId] + ) + return { keys: rows } + }) + + /** Mints a key. This is the only time the plaintext is ever available. */ + app.post('/v1/keys', async (request, reply) => { + const { orgId } = authOf(request) + const body = request.body as { name?: string; scope?: string } + + if (body.scope !== 'runner' && body.scope !== 'user') { + return reply.code(400).send({ error: 'scope must be "runner" or "user".' }) + } + + const { key, hash, prefix } = generateKey(body.scope) + const id = newId('key') + await db.query( + 'INSERT INTO api_keys (id, org_id, name, scope, key_hash, prefix) VALUES ($1,$2,$3,$4,$5,$6)', + [id, orgId, body.name ?? body.scope, body.scope, hash, prefix] + ) + return reply.code(201).send({ id, key, scope: body.scope, prefix }) + }) + + app.delete('/v1/keys/:id', async (request) => { + const { orgId } = authOf(request) + const { id } = request.params as { id: string } + await db.query( + 'UPDATE api_keys SET revoked_at = now() WHERE org_id = $1 AND id = $2 AND revoked_at IS NULL', + [orgId, id] + ) + return { ok: true } + }) +} diff --git a/packages/cloud-api/test/auth.test.ts b/packages/cloud-api/test/auth.test.ts new file mode 100644 index 0000000..2ff0b74 --- /dev/null +++ b/packages/cloud-api/test/auth.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest' +import { authenticate, generateKey, hashKey, newId, parseBearer } from '../src/auth.js' +import type { Database } from '../src/db.js' + +function fakeDb(rows: Array>): Database & { queries: string[] } { + const queries: string[] = [] + return { + queries, + query: vi.fn(async (sql: string) => { + queries.push(sql) + return sql.startsWith('SELECT') ? { rows, rowCount: rows.length } : { rows: [], rowCount: 0 } + }), + } as unknown as Database & { queries: string[] } +} + +describe('generateKey', () => { + it('encodes the scope in the visible prefix', () => { + expect(generateKey('runner').key).toMatch(/^prx_rnr_[0-9a-f]{48}$/) + expect(generateKey('user').key).toMatch(/^prx_usr_[0-9a-f]{48}$/) + }) + + it('returns a hash that matches the key and never the key itself', () => { + const { key, hash, prefix } = generateKey('user') + expect(hash).toBe(hashKey(key)) + expect(hash).not.toContain(key) + expect(key.startsWith(prefix)).toBe(true) + }) + + it('does not repeat', () => { + const keys = new Set(Array.from({ length: 50 }, () => generateKey('user').key)) + expect(keys.size).toBe(50) + }) +}) + +describe('parseBearer', () => { + it('extracts the token', () => { + expect(parseBearer('Bearer abc123')).toBe('abc123') + expect(parseBearer('bearer abc123')).toBe('abc123') + }) + + it('returns undefined for anything else', () => { + expect(parseBearer(undefined)).toBeUndefined() + expect(parseBearer('')).toBeUndefined() + expect(parseBearer('Basic abc')).toBeUndefined() + expect(parseBearer('abc123')).toBeUndefined() + }) +}) + +describe('authenticate', () => { + const activeRunnerKey = { id: 'key_1', org_id: 'org_1', scope: 'runner', revoked_at: null } + + it('resolves a valid key of the required scope', async () => { + const db = fakeDb([activeRunnerKey]) + await expect(authenticate(db, 'prx_rnr_x', 'runner')).resolves.toEqual({ + orgId: 'org_1', + keyId: 'key_1', + scope: 'runner', + }) + }) + + it('rejects a runner key presented to a user route, and the reverse', async () => { + await expect(authenticate(fakeDb([activeRunnerKey]), 'k', 'user')).resolves.toBeUndefined() + + const userKey = { ...activeRunnerKey, scope: 'user' } + await expect(authenticate(fakeDb([userKey]), 'k', 'runner')).resolves.toBeUndefined() + }) + + it('rejects a revoked key', async () => { + const db = fakeDb([{ ...activeRunnerKey, revoked_at: new Date() }]) + await expect(authenticate(db, 'k', 'runner')).resolves.toBeUndefined() + }) + + it('rejects an unknown key and a missing token', async () => { + await expect(authenticate(fakeDb([]), 'nope', 'runner')).resolves.toBeUndefined() + await expect( + authenticate(fakeDb([activeRunnerKey]), undefined, 'runner') + ).resolves.toBeUndefined() + }) + + it('looks the key up by hash, never by the raw value', async () => { + const db = fakeDb([activeRunnerKey]) + await authenticate(db, 'prx_rnr_secret', 'runner') + + const call = (db.query as unknown as { mock: { calls: unknown[][] } }).mock.calls[0] + expect(String(call[0])).toContain('key_hash = $1') + expect((call[1] as string[])[0]).toBe(hashKey('prx_rnr_secret')) + expect((call[1] as string[])[0]).not.toBe('prx_rnr_secret') + }) +}) + +describe('newId', () => { + it('prefixes and stays url-safe', () => { + expect(newId('org')).toMatch(/^org_[0-9a-f]{20}$/) + }) +}) diff --git a/packages/cloud-api/test/cors.test.ts b/packages/cloud-api/test/cors.test.ts new file mode 100644 index 0000000..08077f0 --- /dev/null +++ b/packages/cloud-api/test/cors.test.ts @@ -0,0 +1,59 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest' +import { buildApp } from '../src/app.js' +import type { Database } from '../src/db.js' + +beforeAll(() => { + process.env.LOG_LEVEL = 'silent' +}) + +function fakeDb(): Database { + return { query: vi.fn(async () => ({ rows: [], rowCount: 0 })) } as unknown as Database +} + +/** + * The preflight, which is the only place this can be caught. + * + * @fastify/cors defaults `methods` to `GET,HEAD,POST`. Every mutating endpoint + * the dashboard uses beyond POST — deleting a route, removing a project, + * revoking a key, saving Slack, editing a route — was refused by the browser + * before it left the page, while curl, which sends no preflight, worked + * perfectly. Nothing on the server side notices, because the request never + * arrives. + */ +describe('CORS preflight', () => { + it('allows every method the user API exposes', async () => { + const app = await buildApp(fakeDb()) + + for (const method of ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) { + const response = await app.inject({ + method: 'OPTIONS', + url: '/v1/routes/rt_1', + headers: { + origin: 'https://dashboard.example', + 'access-control-request-method': method, + }, + }) + + const allowed = String(response.headers['access-control-allow-methods'] ?? '') + .split(',') + .map((entry) => entry.trim()) + + expect(allowed, `${method} must survive a browser preflight`).toContain(method) + } + await app.close() + }) + + it('reflects the requesting origin', async () => { + const app = await buildApp(fakeDb()) + const response = await app.inject({ + method: 'OPTIONS', + url: '/v1/runs', + headers: { + origin: 'https://dashboard.example', + 'access-control-request-method': 'GET', + }, + }) + expect(response.headers['access-control-allow-origin']).toBe('https://dashboard.example') + await app.close() + }) +}) diff --git a/packages/cloud-api/test/heartbeat.test.ts b/packages/cloud-api/test/heartbeat.test.ts new file mode 100644 index 0000000..b9f1538 --- /dev/null +++ b/packages/cloud-api/test/heartbeat.test.ts @@ -0,0 +1,153 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest' +import { buildApp } from '../src/app.js' +import { hashKey } from '../src/auth.js' +import type { Database } from '../src/db.js' + +beforeAll(() => { + process.env.LOG_LEVEL = 'silent' +}) + +const RUNNER_KEY = 'prx_rnr_daemon' +const USER_KEY = 'prx_usr_human' + +interface Recorded { + sql: string + params: unknown[] +} + +function fakeDb(updateRowCount = 1): Database & { calls: Recorded[] } { + const calls: Recorded[] = [] + const db = { + calls, + query: vi.fn(async (sql: string, params: unknown[] = []) => { + calls.push({ sql, params }) + if (sql.includes('FROM api_keys WHERE key_hash')) { + const rows = + params[0] === hashKey(RUNNER_KEY) + ? [{ id: 'key_r', org_id: 'org_1', scope: 'runner', revoked_at: null }] + : params[0] === hashKey(USER_KEY) + ? [{ id: 'key_u', org_id: 'org_1', scope: 'user', revoked_at: null }] + : [] + return { rows, rowCount: rows.length } + } + if (sql.trimStart().startsWith('UPDATE runners')) { + return { rows: [], rowCount: updateRowCount } + } + return { rows: [], rowCount: 0 } + }), + } + return db as unknown as Database & { calls: Recorded[] } +} + +const beat = (body: unknown, key = RUNNER_KEY) => ({ + method: 'POST' as const, + url: '/v1/runner/heartbeat', + headers: { authorization: `Bearer ${key}` }, + payload: body, +}) + +describe('POST /v1/runner/heartbeat', () => { + it('records the health a runner reports', async () => { + const db = fakeDb() + const app = await buildApp(db) + + const response = await app.inject( + beat({ + name: 'cerebro', + startedAt: '2026-09-01T00:00:00.000Z', + hermesOk: true, + hermesDetail: 'hermes-4-70b', + activeRuns: 2, + lastError: null, + }) + ) + + expect(response.statusCode).toBe(200) + const update = db.calls.find((call) => call.sql.includes('SET last_seen_at = now()')) + expect(update?.params).toEqual([ + 'org_1', + 'cerebro', + '2026-09-01T00:00:00.000Z', + true, + 'hermes-4-70b', + 2, + null, + ]) + await app.close() + }) + + /** + * A runner built before the heartbeat existed sends none of these fields. It + * must still register as alive rather than failing, and its health must read + * as "not reported" rather than as a failure. + */ + it('accepts a heartbeat carrying only a name', async () => { + const db = fakeDb() + const app = await buildApp(db) + const response = await app.inject(beat({ name: 'cerebro' })) + expect(response.statusCode).toBe(200) + + const update = db.calls.find((call) => call.sql.includes('SET last_seen_at = now()')) + expect(update?.params.slice(2)).toEqual([null, null, null, null, null]) + await app.close() + }) + + it('requires a name to know which runner is speaking', async () => { + const app = await buildApp(fakeDb()) + expect((await app.inject(beat({}))).statusCode).toBe(400) + await app.close() + }) + + // Otherwise a runner whose row was deleted heartbeats into the void forever. + it('reports an unknown runner rather than silently doing nothing', async () => { + const app = await buildApp(fakeDb(0)) + const response = await app.inject(beat({ name: 'ghost' })) + expect(response.statusCode).toBe(404) + expect(response.json().error).toMatch(/not registered/) + await app.close() + }) + + it('refuses a user key', async () => { + const app = await buildApp(fakeDb()) + expect((await app.inject(beat({ name: 'cerebro' }, USER_KEY))).statusCode).toBe(401) + await app.close() + }) +}) + +describe('runner liveness', () => { + /** + * The heartbeat is not the only proof of life. The command long-poll runs + * every ~25 seconds regardless, so touching last_seen_at on any authenticated + * runner call means a runner too old to send a heartbeat still reads as + * alive — which is the case that made every runner look stale before. + */ + it('is refreshed by any authenticated runner request', async () => { + const db = fakeDb() + const app = await buildApp(db) + + await app.inject({ + method: 'GET', + url: '/v1/runner/routes', + headers: { authorization: `Bearer ${RUNNER_KEY}` }, + }) + + // Fire-and-forget, so let the microtask queue drain before asserting. + await new Promise((resolve) => setTimeout(resolve, 10)) + const touch = db.calls.find((call) => + call.sql.includes('UPDATE runners SET last_seen_at = now()') + ) + expect(touch?.params).toEqual(['org_1']) + await app.close() + }) + + it('is not refreshed by an unauthenticated request', async () => { + const db = fakeDb() + const app = await buildApp(db) + await app.inject({ method: 'GET', url: '/v1/runner/routes' }) + await new Promise((resolve) => setTimeout(resolve, 10)) + expect( + db.calls.some((call) => call.sql.includes('UPDATE runners SET last_seen_at = now()')) + ).toBe(false) + await app.close() + }) +}) diff --git a/packages/cloud-api/test/me.test.ts b/packages/cloud-api/test/me.test.ts new file mode 100644 index 0000000..6e29073 --- /dev/null +++ b/packages/cloud-api/test/me.test.ts @@ -0,0 +1,110 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest' +import { buildApp } from '../src/app.js' +import { hashKey } from '../src/auth.js' +import type { Database } from '../src/db.js' + +// buildApp logs every request at info; a route test that prints twelve lines of +// JSON per assertion buries the actual failure. +beforeAll(() => { + process.env.LOG_LEVEL = 'silent' +}) + +const USER_KEY = 'prx_usr_dashboard' +const RUNNER_KEY = 'prx_rnr_daemon' + +/** + * Answers the two queries `/v1/me` makes, keyed on the SQL rather than the + * call order, so the test does not break when an unrelated query is added. + */ +function fakeDb(): Database { + return { + query: vi.fn(async (sql: string, params: unknown[] = []) => { + if (sql.includes('FROM api_keys WHERE key_hash')) { + const rows = + params[0] === hashKey(USER_KEY) + ? [{ id: 'key_u', org_id: 'org_1', scope: 'user', revoked_at: null }] + : params[0] === hashKey(RUNNER_KEY) + ? [{ id: 'key_r', org_id: 'org_1', scope: 'runner', revoked_at: null }] + : [] + return { rows, rowCount: rows.length } + } + if (sql.includes('FROM organizations o')) { + return { + rows: [ + { + id: 'org_1', + name: 'Parallax Labs', + created_at: '2026-08-01T00:00:00.000Z', + key_name: 'dashboard', + key_prefix: 'prx_usr_dashboa', + }, + ], + rowCount: 1, + } + } + return { rows: [], rowCount: 0 } + }), + } as unknown as Database +} + +describe('GET /v1/me', () => { + it('names the organization behind a user key', async () => { + const app = await buildApp(fakeDb()) + const response = await app.inject({ + method: 'GET', + url: '/v1/me', + headers: { authorization: `Bearer ${USER_KEY}` }, + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + org: { id: 'org_1', name: 'Parallax Labs', createdAt: '2026-08-01T00:00:00.000Z' }, + key: { id: 'key_u', name: 'dashboard', prefix: 'prx_usr_dashboa', scope: 'user' }, + }) + await app.close() + }) + + // The dashboard's whole sign-in is this endpoint, so the scope boundary has + // to hold here as firmly as on any mutating route. + it('refuses a runner key', async () => { + const app = await buildApp(fakeDb()) + const response = await app.inject({ + method: 'GET', + url: '/v1/me', + headers: { authorization: `Bearer ${RUNNER_KEY}` }, + }) + expect(response.statusCode).toBe(401) + await app.close() + }) + + it('refuses an unknown key and a missing header alike', async () => { + const app = await buildApp(fakeDb()) + for (const headers of [{ authorization: 'Bearer nope' }, {}]) { + const response = await app.inject({ method: 'GET', url: '/v1/me', headers }) + expect(response.statusCode).toBe(401) + } + await app.close() + }) + + // The key is valid, so the caller is authenticated; a missing row is a data + // problem, not an auth one, and signing them out over it would be wrong. + it('falls back to the org id when the join finds no row', async () => { + const db = fakeDb() + const original = db.query as unknown as (sql: string, params?: unknown[]) => Promise + ;(db as { query: unknown }).query = async (sql: string, params?: unknown[]) => + sql.includes('FROM organizations o') + ? { rows: [], rowCount: 0 } + : await original(sql, params ?? []) + + const app = await buildApp(db) + const response = await app.inject({ + method: 'GET', + url: '/v1/me', + headers: { authorization: `Bearer ${USER_KEY}` }, + }) + + expect(response.statusCode).toBe(200) + expect(response.json().org).toEqual({ id: 'org_1', name: 'org_1', createdAt: null }) + await app.close() + }) +}) diff --git a/packages/cloud-api/test/slack.test.ts b/packages/cloud-api/test/slack.test.ts new file mode 100644 index 0000000..2cff257 --- /dev/null +++ b/packages/cloud-api/test/slack.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest' +import { RUN_STATUS, TRIGGER_TYPE, type RunRecord } from '@parallax/common' +import { buildSlackMessage } from '../src/notifications/slack.js' + +function run(overrides: Partial = {}): RunRecord { + return { + id: 'pxr_1', + routeId: 'rt_1', + routeName: 'Product review', + agentProfile: 'product', + projectId: 'taplands', + triggerType: TRIGGER_TYPE.TICKET, + triggerRef: 'LIN-123', + triggerRevision: 'r', + triggerUrl: 'https://linear.app/x/LIN-123', + title: 'Billing export', + status: RUN_STATUS.COMPLETED, + createdAt: 0, + updatedAt: 0, + ...overrides, + } +} + +describe('buildSlackMessage', () => { + it('leads with the agent and what it did', () => { + const message = buildSlackMessage('run.completed', run()) as { text: string } + expect(message.text).toContain('*product*') + expect(message.text).toContain('finished') + expect(message.text).toContain('Billing export') + }) + + it('links the trigger when there is a url, and falls back to the ref', () => { + expect((buildSlackMessage('run.started', run()) as { text: string }).text).toContain( + '' + ) + const noUrl = buildSlackMessage('run.started', run({ triggerUrl: undefined })) as { + text: string + } + expect(noUrl.text).toContain('LIN-123') + expect(noUrl.text).not.toContain(' { + const timed = run({ startedAt: 1_000, endedAt: 96_000 }) + expect((buildSlackMessage('run.completed', timed) as { text: string }).text).toContain( + 'in 1m 35s' + ) + expect((buildSlackMessage('run.started', run()) as { text: string }).text).not.toContain('in ') + }) + + it('shows the error on failure and the summary on success', () => { + const failed = run({ status: RUN_STATUS.FAILED, error: 'tool crashed', summary: 'ignored' }) + expect((buildSlackMessage('run.failed', failed) as { text: string }).text).toContain( + 'tool crashed' + ) + + const ok = run({ summary: 'Worth doing.' }) + expect((buildSlackMessage('run.completed', ok) as { text: string }).text).toContain( + 'Worth doing.' + ) + }) + + it('truncates a long detail rather than flooding the channel', () => { + const long = run({ summary: 'x'.repeat(2000) }) + const text = (buildSlackMessage('run.completed', long) as { text: string }).text + expect(text.length).toBeLessThan(900) + expect(text).toContain('…') + }) + + it('has a distinct opener for every event it handles', () => { + const events = [ + 'run.started', + 'run.completed', + 'run.failed', + 'run.needs_approval', + 'run.canceled', + 'runner.stale', + ] as const + const openers = events.map( + (event) => (buildSlackMessage(event, run()) as { text: string }).text.split('\n')[0] + ) + expect(new Set(openers).size).toBe(events.length) + }) +}) + +describe('agent avatar', () => { + it('omits blocks entirely when the agent has no avatar', () => { + const message = buildSlackMessage('run.completed', run(), {}) + expect(message.blocks).toBeUndefined() + expect(message.text).toContain('*product*') + }) + + it('renders the avatar inside the message, as an accessory', () => { + const message = buildSlackMessage('run.completed', run(), { + avatarUrl: 'https://cdn/product.png', + displayName: 'Product agent', + }) as { blocks: Array> } + + const accessory = message.blocks[0].accessory + expect(accessory).toMatchObject({ + type: 'image', + image_url: 'https://cdn/product.png', + alt_text: 'Product agent', + }) + }) + + it('never overrides the webhook app identity', () => { + const message = buildSlackMessage('run.completed', run(), { + avatarUrl: 'https://cdn/product.png', + displayName: 'Product agent', + }) + + // The Slack app owns how it appears; the avatar belongs in the body. + expect(message.username).toBeUndefined() + expect(message.icon_url).toBeUndefined() + }) + + it('keeps text alongside blocks, for notifications and previews', () => { + const message = buildSlackMessage('run.failed', run({ error: 'boom' }), { + avatarUrl: 'https://cdn/x.png', + }) as { text: string; blocks: Array> } + + expect(message.text).toContain('boom') + expect(message.blocks[0].text.text).toBe(message.text) + }) +}) diff --git a/packages/cloud-api/tsconfig.json b/packages/cloud-api/tsconfig.json new file mode 100644 index 0000000..75937fc --- /dev/null +++ b/packages/cloud-api/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "paths": {} + }, + "include": [ + "src/**/*" + ] +} diff --git a/packages/slack/vitest.config.ts b/packages/cloud-api/vitest.config.ts similarity index 78% rename from packages/slack/vitest.config.ts rename to packages/cloud-api/vitest.config.ts index 22ead24..90b971b 100644 --- a/packages/slack/vitest.config.ts +++ b/packages/cloud-api/vitest.config.ts @@ -7,7 +7,4 @@ export default defineConfig({ '@parallax/common': path.resolve(__dirname, '../common/src/index.ts'), }, }, - test: { - exclude: ['node_modules/**', 'dist/**'], - }, }) diff --git a/packages/cloud-dashboard/.env.example b/packages/cloud-dashboard/.env.example new file mode 100644 index 0000000..b1355d5 --- /dev/null +++ b/packages/cloud-dashboard/.env.example @@ -0,0 +1,3 @@ +# Where the Parallax control plane lives. Read at runtime, never baked into the +# bundle, so one built image works against any deployment. +PARALLAX_API_URL=http://127.0.0.1:8080 diff --git a/packages/cloud-dashboard/index.html b/packages/cloud-dashboard/index.html new file mode 100644 index 0000000..3087218 --- /dev/null +++ b/packages/cloud-dashboard/index.html @@ -0,0 +1,13 @@ + + + + + + + Parallax + + +
+ + + diff --git a/packages/cloud-dashboard/package.json b/packages/cloud-dashboard/package.json new file mode 100644 index 0000000..15ac03c --- /dev/null +++ b/packages/cloud-dashboard/package.json @@ -0,0 +1,31 @@ +{ + "name": "@parallax/cloud-dashboard", + "version": "0.2.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "start": "node server.mjs", + "lint": "eslint src test", + "lint:fix": "eslint src test --fix", + "test": "vitest run" + }, + "dependencies": { + "@16-bits-design/ui": "0.1.0", + "react": "19.2.8", + "react-dom": "19.2.8", + "react-router-dom": "7.9.1" + }, + "devDependencies": { + "@testing-library/jest-dom": "7.0.1", + "@testing-library/react": "16.3.3", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.5", + "@vitejs/plugin-react": "6.1.1", + "happy-dom": "20.12.0", + "vite": "8.2.2", + "vitest": "4.1.11" + } +} diff --git a/packages/cloud-dashboard/public/brand/parallax-icon.svg b/packages/cloud-dashboard/public/brand/parallax-icon.svg new file mode 100644 index 0000000..094022f --- /dev/null +++ b/packages/cloud-dashboard/public/brand/parallax-icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/cloud-dashboard/public/brand/parallax-wordmark.svg b/packages/cloud-dashboard/public/brand/parallax-wordmark.svg new file mode 100644 index 0000000..50a90fe --- /dev/null +++ b/packages/cloud-dashboard/public/brand/parallax-wordmark.svg @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/cloud-dashboard/server.mjs b/packages/cloud-dashboard/server.mjs new file mode 100644 index 0000000..c3d05e2 --- /dev/null +++ b/packages/cloud-dashboard/server.mjs @@ -0,0 +1,122 @@ +/** + * Serves the built dashboard. + * + * Deliberately dependency-free: this is a static file server plus two dynamic + * responses, and pulling a framework in would mean shipping its transitive tree + * into the runtime image for no behaviour the standard library lacks. + * + * The two dynamic responses are the whole reason a server exists rather than a + * CDN bucket: + * + * /env.js the API URL, read from the environment on every request, so + * pointing the dashboard at a different control plane is a Railway + * variable change rather than a rebuild. + * /health an unauthenticated liveness probe for Railway's health check. + * + * Everything else is the SPA: a request that matches no file falls through to + * index.html so client-side routes survive a reload or a shared link. + */ +import { createReadStream } from 'node:fs' +import { stat } from 'node:fs/promises' +import { createServer } from 'node:http' +import { extname, join, normalize, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(fileURLToPath(new URL('./dist', import.meta.url))) +const PORT = Number.parseInt(process.env.PORT ?? '8080', 10) +const API_URL = process.env.PARALLAX_API_URL ?? process.env.VITE_API_URL ?? '' + +const MIME = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.map': 'application/json; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + '.woff2': 'font/woff2', +} + +/** + * Resolves a URL path to a file inside dist, or undefined. + * + * The normalize-then-prefix-check is the traversal guard: a request for + * `/../../etc/passwd` normalizes to a path outside ROOT and is refused rather + * than served. + */ +async function resolveFile(urlPath) { + const candidate = resolve(join(ROOT, normalize(decodeURIComponent(urlPath)))) + if (candidate !== ROOT && !candidate.startsWith(ROOT + sep)) { + return undefined + } + try { + const info = await stat(candidate) + return info.isFile() ? candidate : undefined + } catch { + return undefined + } +} + +function send(response, status, body, headers = {}) { + response.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8', ...headers }) + response.end(body) +} + +const server = createServer((request, response) => { + void (async () => { + const url = new URL(request.url ?? '/', 'http://localhost') + + if (request.method !== 'GET' && request.method !== 'HEAD') { + return send(response, 405, 'Method not allowed', { Allow: 'GET, HEAD' }) + } + + if (url.pathname === '/health') { + // Reports whether the dashboard is configured, but never fails on it: a + // container that cannot serve its own pages is the outage worth + // restarting for, and a missing API URL is fixed by editing a variable. + return send(response, 200, JSON.stringify({ status: 'ok', apiConfigured: Boolean(API_URL) }), { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store', + }) + } + + if (url.pathname === '/env.js') { + return send(response, 200, `window.__PARALLAX__=${JSON.stringify({ apiUrl: API_URL })}\n`, { + 'Content-Type': 'text/javascript; charset=utf-8', + 'Cache-Control': 'no-store', + }) + } + + const file = (await resolveFile(url.pathname)) ?? (await resolveFile('/index.html')) + if (!file) { + return send(response, 500, 'The dashboard was not built. Run "pnpm build" first.') + } + + // Hashed asset filenames may be cached forever; index.html never may, or a + // deploy would not reach anyone still holding the previous one. + const immutable = file.startsWith(join(ROOT, 'assets') + sep) + response.writeHead(200, { + 'Content-Type': MIME[extname(file)] ?? 'application/octet-stream', + 'Cache-Control': immutable ? 'public, max-age=31536000, immutable' : 'no-cache', + }) + if (request.method === 'HEAD') { + return response.end() + } + createReadStream(file).pipe(response) + })().catch((error) => { + console.error(error) + if (!response.headersSent) { + send(response, 500, 'Internal error') + } + }) +}) + +server.listen(PORT, '0.0.0.0', () => { + console.log(`Dashboard on :${PORT} — API ${API_URL || '(unset: set PARALLAX_API_URL)'}`) +}) + +const shutdown = () => server.close(() => process.exit(0)) +process.on('SIGTERM', shutdown) +process.on('SIGINT', shutdown) diff --git a/packages/cloud-dashboard/src/App.tsx b/packages/cloud-dashboard/src/App.tsx new file mode 100644 index 0000000..9d38472 --- /dev/null +++ b/packages/cloud-dashboard/src/App.tsx @@ -0,0 +1,74 @@ +import type { ReactNode } from 'react' +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' +import { ThemeProvider } from '@16-bits-design/ui/theme' +import { ToastProvider } from '@16-bits-design/ui/toast' +import { AppShell } from './components/AppShell.js' +import { Loading } from './components/Loading.js' +import { SessionProvider, useSession } from './lib/session.js' +import { AccessKeys } from './screens/AccessKeys.js' +import { Agents } from './screens/Agents.js' +import { KeyNew } from './screens/KeyNew.js' +import { Login } from './screens/Login.js' +import { Overview } from './screens/Overview.js' +import { ProjectNew } from './screens/ProjectNew.js' +import { Projects } from './screens/Projects.js' +import { RouteEdit } from './screens/RouteEdit.js' +import { RouteList } from './screens/RouteList.js' +import { RouteNew } from './screens/RouteNew.js' +import { RunDetail } from './screens/RunDetail.js' +import { RunList } from './screens/RunList.js' +import { Settings } from './screens/Settings.js' + +function Authenticated(): ReactNode { + const { session, restoring } = useSession() + + // Restoring is a distinct state from signed-out: a stored key is being + // re-verified, and rendering the login form underneath it would flash a form + // the user is about to be taken past. + if (restoring) { + return + } + if (!session) { + return + } + + return ( + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ) +} + +export function App(): ReactNode { + return ( + // ToastProvider sits inside ThemeProvider so portalled toasts and dialogs + // inherit the theme rather than rendering unthemed at the document root. + // + // ThemeProvider renders a real div, which would otherwise sit between #root + // and the app with an auto height — collapsing every percentage height + // below it. px-root re-establishes the chain. + + + + + + + + + + ) +} diff --git a/packages/cloud-dashboard/src/api/client.ts b/packages/cloud-dashboard/src/api/client.ts new file mode 100644 index 0000000..67f3742 --- /dev/null +++ b/packages/cloud-dashboard/src/api/client.ts @@ -0,0 +1,84 @@ +import { API_URL } from '../config.js' + +/** + * A failed request, carrying enough for the UI to react rather than just report. + * + * `unauthorized` is separated from every other failure because it is the one + * the app must handle structurally: the stored key is no longer good, so the + * session ends. Everything else is shown in place and the session survives. + */ +export class ApiError extends Error { + readonly status: number + readonly unauthorized: boolean + + constructor(status: number, message: string) { + super(message) + this.name = 'ApiError' + this.status = status + this.unauthorized = status === 401 + } +} + +export interface RequestOptions { + method?: string + body?: unknown + signal?: AbortSignal +} + +/** + * One request to the control plane. + * + * The key is passed per call rather than held in module state so that the + * login screen can verify a key it has not adopted yet, and so no code path can + * accidentally use a key the user has since signed out of. + */ +export async function request( + key: string, + path: string, + options: RequestOptions = {} +): Promise { + let response: Response + try { + response = await fetch(`${API_URL}${path}`, { + method: options.method ?? 'GET', + signal: options.signal, + headers: { + Authorization: `Bearer ${key}`, + ...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }) + } catch (error) { + // fetch rejects for DNS, TLS, CORS and offline alike, with a message that + // names none of them. Saying which request died is the useful half. + if (error instanceof DOMException && error.name === 'AbortError') { + throw error + } + throw new ApiError(0, `Could not reach the API at ${API_URL || '(not configured)'}.`) + } + + if (response.status === 204) { + return undefined as T + } + + const text = await response.text() + let payload: unknown + try { + payload = text ? JSON.parse(text) : undefined + } catch { + payload = undefined + } + + if (!response.ok) { + const message = + (payload as { error?: string } | undefined)?.error ?? `The API returned ${response.status}.` + throw new ApiError(response.status, message) + } + + return payload as T +} + +/** Probes a key without adopting it. Used by the login screen. */ +export async function verifyKey(key: string, signal?: AbortSignal) { + return request(key, '/v1/me', { signal }) +} diff --git a/packages/cloud-dashboard/src/api/endpoints.ts b/packages/cloud-dashboard/src/api/endpoints.ts new file mode 100644 index 0000000..d1fc1e9 --- /dev/null +++ b/packages/cloud-dashboard/src/api/endpoints.ts @@ -0,0 +1,113 @@ +import { request } from './client.js' +import type { + Agent, + ApiKey, + Project, + PromptTemplate, + RouteTemplate, + RoutingRule, + Run, + RunEvent, + Runner, + SlackIntegration, +} from './types.js' + +/** + * Every call the dashboard makes, in one place. + * + * Each unwraps the API's single-key envelope (`{ runs: [...] }`) so screens + * deal in the data and not in the transport shape. + */ +export const api = { + runners: (key: string, signal?: AbortSignal) => + request<{ runners: Runner[] }>(key, '/v1/runners', { signal }).then((r) => r.runners), + + agents: (key: string, signal?: AbortSignal) => + request<{ agents: Agent[] }>(key, '/v1/agents', { signal }).then((r) => r.agents), + + projects: (key: string, signal?: AbortSignal) => + request<{ projects: Project[] }>(key, '/v1/projects', { signal }).then((r) => r.projects), + + createProject: (key: string, body: { id: string; provider: string; filters?: unknown }) => + request<{ id: string }>(key, '/v1/projects', { method: 'POST', body }), + + deleteProject: (key: string, id: string) => + request<{ ok: true }>(key, `/v1/projects/${encodeURIComponent(id)}`, { method: 'DELETE' }), + + routes: (key: string, signal?: AbortSignal) => + request<{ routes: RoutingRule[] }>(key, '/v1/routes', { signal }).then((r) => r.routes), + + route: (key: string, id: string, signal?: AbortSignal) => + request<{ route: RoutingRule }>(key, `/v1/routes/${encodeURIComponent(id)}`, { signal }).then( + (r) => r.route + ), + + createRoute: (key: string, body: unknown) => + request<{ route: RoutingRule }>(key, '/v1/routes', { method: 'POST', body }).then( + (r) => r.route + ), + + updateRoute: (key: string, id: string, body: unknown) => + request<{ route: RoutingRule }>(key, `/v1/routes/${encodeURIComponent(id)}`, { + method: 'PUT', + body, + }).then((r) => r.route), + + deleteRoute: (key: string, id: string) => + request<{ ok: true }>(key, `/v1/routes/${encodeURIComponent(id)}`, { method: 'DELETE' }), + + routeTemplates: (key: string, signal?: AbortSignal) => + request<{ templates: RouteTemplate[] }>(key, '/v1/route-templates', { signal }).then( + (r) => r.templates + ), + + promptTemplates: (key: string, signal?: AbortSignal) => + request<{ templates: PromptTemplate[]; variables: string[] }>(key, '/v1/prompt-templates', { + signal, + }), + + runs: (key: string, params: { status?: string; limit?: number } = {}, signal?: AbortSignal) => { + const query = new URLSearchParams() + if (params.status) { + query.set('status', params.status) + } + query.set('limit', String(params.limit ?? 100)) + return request<{ runs: Run[] }>(key, `/v1/runs?${query}`, { signal }).then((r) => r.runs) + }, + + run: (key: string, id: string, signal?: AbortSignal) => + request<{ run: Run }>(key, `/v1/runs/${encodeURIComponent(id)}`, { signal }).then((r) => r.run), + + runEvents: (key: string, id: string, signal?: AbortSignal) => + request<{ events: RunEvent[] }>(key, `/v1/runs/${encodeURIComponent(id)}/events`, { + signal, + }).then((r) => r.events), + + cancelRun: (key: string, id: string) => + request<{ queued: string }>(key, `/v1/runs/${encodeURIComponent(id)}/cancel`, { + method: 'POST', + }), + + resync: (key: string) => request<{ queued: string }>(key, '/v1/resync', { method: 'POST' }), + + keys: (key: string, signal?: AbortSignal) => + request<{ keys: ApiKey[] }>(key, '/v1/keys', { signal }).then((r) => r.keys), + + createKey: (key: string, body: { name: string; scope: 'runner' | 'user' }) => + request<{ id: string; key: string; scope: string; prefix: string }>(key, '/v1/keys', { + method: 'POST', + body, + }), + + revokeKey: (key: string, id: string) => + request<{ ok: true }>(key, `/v1/keys/${encodeURIComponent(id)}`, { method: 'DELETE' }), + + slack: (key: string, signal?: AbortSignal) => + request(key, '/v1/integrations/slack', { signal }), + + saveSlack: (key: string, body: { webhookUrl: string; enabled?: boolean }) => + request<{ ok: true }>(key, '/v1/integrations/slack', { method: 'PUT', body }), + + deleteSlack: (key: string) => + request<{ ok: true }>(key, '/v1/integrations/slack', { method: 'DELETE' }), +} diff --git a/packages/cloud-dashboard/src/api/types.ts b/packages/cloud-dashboard/src/api/types.ts new file mode 100644 index 0000000..96cbae2 --- /dev/null +++ b/packages/cloud-dashboard/src/api/types.ts @@ -0,0 +1,147 @@ +/** + * The shapes `packages/cloud-api` actually returns. + * + * Declared here rather than imported from `@parallax/common` on purpose: those + * are the runner's internal types, and several fields the API returns are + * snake_case Postgres columns that never appear in them. Coupling the browser + * bundle to the orchestrator's type spine would also drag Node-only + * declarations into a DOM build. + */ + +export type RunStatus = + | 'queued' + | 'running' + | 'awaiting_approval' + | 'completed' + | 'failed' + | 'canceled' + +export interface Me { + org: { id: string; name: string; createdAt: string | null } + key: { id: string; name: string | null; prefix: string | null; scope: string } +} + +export interface Runner { + id: string + name: string + hostname: string | null + version: string | null + last_seen_at: string | null + /** When the runner process started, so uptime is its age, not the row's. */ + started_at: string | null + /** Null until a runner new enough to send a heartbeat has sent one. */ + hermes_ok: boolean | null + hermes_detail: string | null + active_runs: number | null + last_error: string | null + stale: boolean +} + +export interface Agent { + id: string + profile: string + display_name: string | null + role: string | null + model: string | null + provider: string | null + toolsets: string[] | null + skills: string[] | null + github_login: string | null + avatar_url: string | null + enabled: boolean + synced_at: string | null + runner: string +} + +export interface Project { + id: string + provider: 'github' | 'linear' + filters: Record +} + +export interface Run { + id: string + route_name: string | null + agent_profile: string | null + project_id: string | null + trigger_ref: string | null + trigger_url: string | null + title: string | null + status: RunStatus + summary: string | null + error: string | null + started_at: string | null + ended_at: string | null + updated_at: string | null +} + +export interface RunEvent { + title: string | null + message: string | null + icon: string | null + level: string | null + kind: string | null + source: string | null + group_id: string | null + /** + * Epoch milliseconds — but typed to accept a string. + * + * The column is a bigint, and node-postgres returns bigints as strings + * rather than risk a silent precision loss above 2^53. `new Date()` on that + * string yields Invalid Date, so every consumer must coerce it first. + */ + ts: number | string +} + +export interface ApiKey { + id: string + name: string + scope: 'runner' | 'user' + prefix: string + created_at: string + last_used_at: string | null + revoked_at: string | null +} + +export interface SlackIntegration { + configured: boolean + enabled?: boolean + events?: string[] + created_at?: string +} + +/** A routing rule as the API stores it. Kept loose: routes are user data. */ +export interface RoutingRule { + id: string + name: string + priority: number + enabled: boolean + trigger: { type: string; provider?: string; projectId?: string } + match?: Record + target: { agentRef?: { profile?: string; githubLogin?: string } } + execution: { prompt: string; requireApproval?: boolean; timeoutSeconds?: number } + outcome?: Record + guard?: { refire?: string; markers?: boolean } +} + +export interface RouteTemplatePlaceholder { + token: string + label: string + hint: string +} + +export interface RouteTemplate { + id: string + name: string + summary: string + description: string + placeholders: RouteTemplatePlaceholder[] + route: Omit +} + +export interface PromptTemplate { + id: string + name: string + description: string + prompt: string +} diff --git a/packages/cloud-dashboard/src/components/Alert.tsx b/packages/cloud-dashboard/src/components/Alert.tsx new file mode 100644 index 0000000..ed9df95 --- /dev/null +++ b/packages/cloud-dashboard/src/components/Alert.tsx @@ -0,0 +1,44 @@ +import type { ReactNode } from 'react' + +export type AlertTone = 'info' | 'warning' | 'danger' + +const ICON: Record = { info: 'i', warning: '!', danger: '!' } + +/** + * A persistent, in-place message. + * + * The library ships toasts, but a toast disappears, and the design system's own + * guidance is that a toast must never be the only record of a blocking error. + * A failed load, a stale runner or an unconfigured API URL all need to stay on + * screen until the underlying condition changes, so they use this instead. + * + * `role="alert"` on the danger tone announces it; the softer tones do not + * interrupt a screen reader mid-sentence for something merely informational. + */ +export function Alert({ + tone = 'info', + title, + children, + action, +}: { + tone?: AlertTone + title?: ReactNode + children: ReactNode + action?: ReactNode +}): ReactNode { + return ( +
+ +
+ {title ? {title} : null} + {children} +
+ {action} +
+ ) +} diff --git a/packages/cloud-dashboard/src/components/AppShell.tsx b/packages/cloud-dashboard/src/components/AppShell.tsx new file mode 100644 index 0000000..9d1dffe --- /dev/null +++ b/packages/cloud-dashboard/src/components/AppShell.tsx @@ -0,0 +1,34 @@ +import type { ReactNode } from 'react' +import { Outlet } from 'react-router-dom' +import { api } from '../api/endpoints.js' +import { useResource } from '../lib/useResource.js' +import { Sidebar } from './Sidebar.js' + +/** + * The signed-in frame. + * + * Runners, routes and agents load here rather than in each screen: the sidebar + * shows all three, they change rarely, and fetching them per screen would mean + * the counts flicker on every navigation. Runners poll because "is the Mac Mini + * still there" is the one fact that goes stale while you watch it. + */ +export function AppShell(): ReactNode { + const runners = useResource((key, signal) => api.runners(key, signal), [], { pollMs: 30_000 }) + // Polled, not loaded once: the counts sit beside a screen that can create and + // delete the very things being counted, and a nav that disagrees with the + // table next to it reads as a bug. + const routes = useResource((key, signal) => api.routes(key, signal), [], { pollMs: 30_000 }) + const agents = useResource((key, signal) => api.agents(key, signal), [], { pollMs: 60_000 }) + + return ( +
+ +
+ +
+
+ ) +} diff --git a/packages/cloud-dashboard/src/components/CodeBlock.tsx b/packages/cloud-dashboard/src/components/CodeBlock.tsx new file mode 100644 index 0000000..6b619bc --- /dev/null +++ b/packages/cloud-dashboard/src/components/CodeBlock.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from 'react' + +/** + * Preformatted text that must not reflow — a route definition, an error, a + * prompt. Scrolls inside itself so a long line never widens the page. + */ +export function CodeBlock({ children, label }: { children: string; label?: string }): ReactNode { + return ( +
+      {children}
+    
+ ) +} diff --git a/packages/cloud-dashboard/src/components/EmptyState.tsx b/packages/cloud-dashboard/src/components/EmptyState.tsx new file mode 100644 index 0000000..59fdf6b --- /dev/null +++ b/packages/cloud-dashboard/src/components/EmptyState.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from 'react' +import { Text } from '@16-bits-design/ui/typography' + +/** + * Nothing here yet — and why, plus what to do about it. + * + * An empty table is ambiguous: it can mean the filter excluded everything, the + * runner has not reported, or the feature was never set up. Each caller says + * which, so the screen is never just blank. + */ +export function EmptyState({ + title, + children, + action, +}: { + title: string + children?: ReactNode + action?: ReactNode +}): ReactNode { + return ( +
+ + {title} + {children ? ( + + {children} + + ) : null} + {action} +
+ ) +} diff --git a/packages/cloud-dashboard/src/components/ErrorPanel.tsx b/packages/cloud-dashboard/src/components/ErrorPanel.tsx new file mode 100644 index 0000000..5e22511 --- /dev/null +++ b/packages/cloud-dashboard/src/components/ErrorPanel.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react' +import { Button } from '@16-bits-design/ui/button' +import { Alert } from './Alert.js' + +/** A failed load, with the one action that might fix it. */ +export function ErrorPanel({ + message, + onRetry, +}: { + message: string + onRetry: () => void +}): ReactNode { + return ( +
+ + retry + + } + > + {message} + +
+ ) +} diff --git a/packages/cloud-dashboard/src/components/Loading.tsx b/packages/cloud-dashboard/src/components/Loading.tsx new file mode 100644 index 0000000..2472df1 --- /dev/null +++ b/packages/cloud-dashboard/src/components/Loading.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from 'react' +import { Text } from '@16-bits-design/ui/typography' + +/** + * The wait state for a panel that has nothing to show yet. + * + * The bar scans rather than filling: the request has no measurable progress, and + * a bar that creeps toward a finish it cannot predict is a lie. The label is a + * live region so the wait is announced rather than only animated. + */ +export function Loading({ label = 'Loading' }: { label?: string }): ReactNode { + return ( +
+ + + {label}… + +
+ ) +} diff --git a/packages/cloud-dashboard/src/components/PageHeader.tsx b/packages/cloud-dashboard/src/components/PageHeader.tsx new file mode 100644 index 0000000..ac167e2 --- /dev/null +++ b/packages/cloud-dashboard/src/components/PageHeader.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from 'react' +import { Link } from 'react-router-dom' + +/** + * The title row: where you are, how you got here, and what you can do. + * + * The heading is the page's only h1. The blinking block after it is the + * design's terminal caret — decorative, and hidden from assistive tech. + */ +export function PageHeader({ + title, + parent, + actions, +}: { + title: string + parent?: { label: string; to: string } + actions?: ReactNode +}): ReactNode { + return ( +
+
+ {parent ? ( + <> + + {parent.label} + +
+ {/* + * Always rendered, even when empty. A slot that appears only on pages + * with a button makes the header — and everything under it — shift by a + * button's height as you move between sections. + */} +
{actions}
+
+ ) +} diff --git a/packages/cloud-dashboard/src/components/Panel.tsx b/packages/cloud-dashboard/src/components/Panel.tsx new file mode 100644 index 0000000..daccc8e --- /dev/null +++ b/packages/cloud-dashboard/src/components/Panel.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from 'react' + +/** The bordered frame every screen renders inside, with its one-line caption. */ +export function Panel({ + caption, + children, +}: { + caption: ReactNode + children: ReactNode +}): ReactNode { + return ( +
+
{caption}
+ {children} +
+ ) +} + +export function Section({ + title, + actions, + children, + padded = true, +}: { + title: ReactNode + actions?: ReactNode + children: ReactNode + /** Off for a section whose body is a table, which brings its own padding. */ + padded?: boolean +}): ReactNode { + return ( +
+
+

{title}

+ {actions} +
+ {padded ?
{children}
: children} +
+ ) +} diff --git a/packages/cloud-dashboard/src/components/PromptField.tsx b/packages/cloud-dashboard/src/components/PromptField.tsx new file mode 100644 index 0000000..5a281f6 --- /dev/null +++ b/packages/cloud-dashboard/src/components/PromptField.tsx @@ -0,0 +1,79 @@ +import { useRef, type ReactNode } from 'react' +import { Textarea } from '@16-bits-design/ui/textarea' +import { Text } from '@16-bits-design/ui/typography' + +/** + * The prompt, and the variables it may use. + * + * Listing them matters more than it looks: the runner leaves an unrecognised + * `{{placeholder}}` visible rather than blanking it, precisely so a typo cannot + * become a confidently wrong run — but that only helps if the writer knows + * which names are real. Clicking one inserts it at the cursor, so the exact + * spelling never has to be typed. + */ +export function PromptField({ + value, + onChange, + variables, + error, +}: { + value: string + onChange: (value: string) => void + variables: string[] + error?: string +}): ReactNode { + const field = useRef(null) + + const insert = (variable: string): void => { + const token = `{{${variable}}}` + const element = field.current + if (!element) { + onChange(value + token) + return + } + // Insert at the caret rather than appending, and leave the caret after the + // inserted token so a second click does not land back at the start. + const start = element.selectionStart ?? value.length + const end = element.selectionEnd ?? start + onChange(value.slice(0, start) + token + value.slice(end)) + requestAnimationFrame(() => { + element.focus() + element.setSelectionRange(start + token.length, start + token.length) + }) + } + + return ( +
+