diff --git a/.gitignore b/.gitignore index caf9324a..2d3a7e55 100644 --- a/.gitignore +++ b/.gitignore @@ -34,9 +34,6 @@ npm-debug.log* tmp-*.ts tmp-*.sh -# Generated databases -.squint.db - # Local agent workspace (repos, logs) workspace/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..ef007bb9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +All notable user-visible changes to CASCADE are documented here. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the project does not use strict semver for releases. + +## Unreleased + +### Added + +- **Linear PM — optional Project scope.** Operators can now narrow a Linear-backed CASCADE project to a specific Linear Project (initiative) in the PM wizard's "Board / Project Selection" step. When set, CASCADE only responds to issues that belong to that Linear Project; webhooks for issues outside the scope are silently dropped by the router (with a structured `logger.info` entry), outbound listings are scoped to the project, and newly-created issues (including checklist sub-issues) inherit the project. Leave the new selector empty to preserve existing team-wide behavior. Because Linear's data model requires every issue to belong to a team and scopes workflow states per team, status mappings stay team-scoped. For cross-team Linear Projects, CASCADE responds to the **intersection** of the configured team and project only (sibling-team issues in the same project are ignored). No migration required — existing Linear integrations are unaffected. (Spec [005](docs/specs/005-linear-project-scope.md.done).) +- **Linear status mapping — full parity with Trello and JIRA.** The Linear PM wizard's Field Mapping step now exposes all eight CASCADE stages that drive agent dispatch (backlog, splitting, planning, todo, inProgress, inReview, done, merged) in lifecycle order, instead of only four. An operator can now map a Linear workflow state to any of `splitting`, `planning`, `todo`, or `merged` and have the corresponding agent (splitting, planning, implementation, backlog-manager) dispatch on issue transitions — previously these four stages were unreachable from Linear because the wizard had no slot to save them. Existing Linear integrations upgrade in place: the four new slots render as "not set" on next wizard visit; pre-existing mappings are untouched. No migration required. The normalized `ProjectPMConfig.statuses` type widens to declare the full nine-stage vocabulary (including `debug`, reserved for a future trigger), so providers can no longer silently drift from the trigger layer's dispatch map. (Spec [003](docs/specs/003-linear-status-mapping-parity.md), plan [1/1](docs/plans/003-linear-status-mapping-parity/1-status-parity.md).) +- **Linear wizard — inline webhook signing-secret field and accurate events list.** The Webhooks step of the Linear PM wizard now renders a `ProjectSecretField` bound to `LINEAR_WEBHOOK_SECRET` directly beneath the webhook URL, so operators can paste Linear's signing secret in place instead of navigating to the Credentials tab. The "Enable events" instructions now list the three event families CASCADE actually consumes — `Issues` (status transitions), `Comments` (bot @mentions), and `Issue Labels` ("Ready to Process") — each with a one-line rationale tracing back to the registered trigger handlers. (Spec [002](docs/specs/002-linear-webhook-setup-ux.md), plan [2/2](docs/plans/002-linear-webhook-setup-ux/2-wizard-webhooks-step.md).) + +### Changed + +- **Review-agent context shape: compact diffs instead of full files.** The review agent's pre-fetched PR context now consists of compact per-file diffs (using GitHub's `file.patch`) rather than full file contents. Files that can't fit the budget — deleted, binary, oversized patch, or cumulative budget exhausted — are surfaced in a structured `SKIPPED FILES` injection that names each file with a reason and tells the agent how to fetch it on demand (`gh pr diff`, `Read`, `Grep`). This scales with PR size rather than repo size, mitigates LLM context rot, and ensures the agent is aware of (rather than blind to) the omissions. The context budget is `REVIEW_DIFF_CONTEXT_TOKEN_LIMIT` (200k tokens), replacing the prior 25k full-file cap. The `SKIPPED FILES` injection is also delivered to the four other agents that share the PR context pipeline (`respond-to-ci`, `respond-to-pr-comment`, `respond-to-review`, `resolve-conflicts`); explicit prompt guidance is added in `review.yaml` only. (Spec [001](docs/specs/001-pr-review-correctness.md), plan [2/2](docs/plans/001-pr-review-correctness/2-context-rework.md.done).) + +### Fixed + +- **Linear wizard no longer demands you re-paste your API key on every edit.** The dashboard's credential-resolution helper was picking the first provider in its registered order that declared a matching role name, so a Linear-only project's `('pm', 'api_key')` lookup returned `TRELLO_API_KEY` — which isn't configured, which surfaced as "Linear credentials not configured" in the Board / Project Selection step. Fixed by adding a required provider parameter to the helper and updating every call site. **The same fix also corrects Linear webhook signature verification in the router**, which was silently resolving to the JIRA webhook secret (and returning null on Linear-only projects, so verification was silently skipped in production). Discovery calls on a project with no PM integration row yet now return a distinguishable "No PM integration configured" error instead of the misleading "credentials not configured". (Spec [004](docs/specs/004-credential-role-provider-disambiguation.md), plan [1/1](docs/plans/004-credential-role-provider-disambiguation/1-disambiguate-role-resolver.md).) +- **JIRA `resolveLifecycleConfig` silent-drop of splitting / planning / todo.** The JIRA PM wizard accepts mappings for all eight CASCADE stages, but the normalization step that feeds them to `PMLifecycleManager` was dropping `splitting`, `planning`, and `todo` on the floor. Any agent lifecycle hook that moved JIRA issues to those statuses silently no-op'd. Now passes all eight keys through. No operator action required — existing JIRA mappings start working once the fix deploys. (Spec [003](docs/specs/003-linear-status-mapping-parity.md), plan [1/1](docs/plans/003-linear-status-mapping-parity/1-status-parity.md).) +- **Linear wizard Save — HTTP 500 on `projects.integrations.upsert`.** A check constraint (`chk_integration_category_provider`) restricted the `pm` category to `trello` or `jira`; Linear support shipped without a matching constraint update, so every attempt to save a Linear PM integration failed with SQLSTATE 23514. Migration 0049 adds `linear` to the allowed `pm` providers. (Spec [002](docs/specs/002-linear-webhook-setup-ux.md), plan [1/2](docs/plans/002-linear-webhook-setup-ux/1-save-path-fix.md).) +- **Dashboard error logs now surface DB diagnostic fields.** Unhandled errors in the Hono app error handler and tRPC error formatter now include PG error code, detail, constraint, table, and column (unwrapped from `.cause` when Drizzle wraps a pg driver error). Clients still receive a generic "Internal server error" for unexpected `INTERNAL_SERVER_ERROR` throws — real diagnostics go to stdout for operators to grep. (Spec [002](docs/specs/002-linear-webhook-setup-ux.md), plan [1/2](docs/plans/002-linear-webhook-setup-ux/1-save-path-fix.md).) + +- **Review agent on external-fork and large PRs.** PR checkouts now use the canonical `refs/pull/N/head` ref, which works for same-repo branches and external-fork branches alike. Previously, a silent `git checkout ` failure on fork PRs caused the worker to review the base branch (`dev`) while believing it was on the PR branch, producing confidently wrong reviews. Any git or HEAD-SHA mismatch now fails the run loudly rather than silently continuing. Additionally, every paginated GitHub REST endpoint used in the review setup pipeline now paginates to completion, so PRs with more than 100 changed files are no longer truncated at the first page. (Spec [001](docs/specs/001-pr-review-correctness.md), plan [1/2](docs/plans/001-pr-review-correctness/1-checkout-and-pagination.md.done).) diff --git a/CLAUDE.md b/CLAUDE.md index 97ed544f..029ef90c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,752 +1,162 @@ -# CASCADE - PM-to-Code Automation Platform +# CASCADE — PM-to-Code Automation Platform -## Quick Start +## Quick start ```bash npm install cd web && npm install && cd .. -# Start Redis (required for router/BullMQ): -# macOS: brew install redis && brew services start redis -# Linux: apt-get install redis-server && service redis-server start -# The .cascade/setup.sh script handles this automatically. -npm run dev # Router (webhook receiver, requires Redis) -npm run dev:web # Dashboard frontend (separate terminal) +# Redis required (router/BullMQ). `.cascade/setup.sh` installs + starts it. +npm run dev # Router (webhook receiver, :3000) +npm run dev:web # Dashboard frontend (:5173, separate terminal) +node dist/dashboard.js # Dashboard API (:3001, third terminal, after `npm run build`) ``` -## Architecture - -CASCADE runs as three services (no monolithic server mode): +> `npm start` runs the **router** (`dist/router/index.js`), **not** the dashboard. -1. **Router** (`src/router/index.ts`) — receives webhooks, enqueues jobs to Redis via BullMQ -2. **Worker** (`src/worker-entry.ts`) — processes one job per container, exits when done -3. **Dashboard** (`src/dashboard.ts`) — API + tRPC for web UI and CLI +## Architecture -### Trigger System +Three separate services, **no monolithic server mode**: -The extensible trigger system routes events to agents: +1. **Router** (`src/router/index.ts`) — receives webhooks, enqueues to Redis/BullMQ. +2. **Worker** (`src/worker-entry.ts`) — processes one job per container, exits. +3. **Dashboard** (`src/dashboard.ts`) — tRPC API + static frontend for web UI and CLI. -``` -Trello/JIRA/Sentry/GitHub Webhook → Router → Redis/BullMQ → Worker → TriggerRegistry → Agent → Code Changes → PR -``` +Flow: `PM/SCM/alerting webhook → Router → Redis → Worker → TriggerRegistry → Agent → Code → PR`. -- `src/router/` - Webhook receiver (enqueues jobs to Redis) -- `src/webhook/` - Shared webhook handler factory, parsers, and logging -- `src/triggers/` - Event handlers (Trello/JIRA card moves, labels, GitHub PRs, Sentry alerts) -- `src/agents/` - AI agents (splitting, planning, implementation, review, debug, alerting, backlog-manager, resolve-conflicts) -- `src/gadgets/` - Tools agents can use (PM/SCM/alerting operations, Tmux, Todo, file system) +Integration abstraction lives in `src/integrations/`. For **adding a new integration, trigger, or agent**, see @src/integrations/README.md — don't improvise, it covers all extension points. -### Multi-Project Support +## PR checkout (worker) — gotcha -Projects are configured in the PostgreSQL database (`projects` table). Each project has its own PM board, GitHub repo, and optional per-project credentials. +Worker checks out PRs via `refs/pull/N/head` (works for same-repo **and** external-fork branches). When `prNumber` is set on `AgentInput`, `setupRepository`: -## Development +1. Fetches `+refs/pull//head:refs/remotes/pr/` from `origin`. +2. Detached-checks out `pr/`. +3. If `headSha` is also set, verifies `git rev-parse HEAD` matches. -### Testing +Any non-zero git exit code **throws** — no warn-and-continue. The legacy `prBranch` field is retained for log readability but **not** used to drive checkout (fork branches don't exist on `origin` and the by-name path silently 404s). -> **For a full catalog of test helpers, factory functions, and mock objects**, see [`tests/README.md`](tests/README.md). +## Testing ```bash -npm test # Run unit tests (all 4 unit projects) -npm run test:unit # Alias for npm test -npm run test:integration # Run integration tests (requires DB — see below) -npm run test:all # Run unit + integration tests together -npm run test:coverage # Coverage report (unit tests) -npm run test:watch # Watch mode (unit tests) +npm test # Unit tests (all 4 unit projects) +npm run test:integration # Integration tests (requires Postgres — see below) +npm run test:all # Unit + integration ``` -> **Do not use `npm test -- --project integration`** — it _adds_ the integration project on top of the hardcoded unit project flags, running all 5 projects instead of filtering. Use `npm run test:integration` instead. - -> **Agent tip — integration test runs are slow (~4 min for full suite).** When a specific -> test file is failing, always target it directly: -> ```bash -> # Run one file (seconds) instead of the full suite (4+ min): -> TEST_DATABASE_URL=... npx vitest run --project integration tests/integration/.test.ts -> ``` -> Run the full suite only to confirm all tests pass before pushing. - -Integration tests require a PostgreSQL database. The setup: -1. **Auto-creates** the database when `TEST_DATABASE_URL` is set and postgres is reachable - but the database doesn't exist yet (connects to `postgres` admin DB and creates it) -2. **Auto-finds** an existing DB via (in order): `TEST_DATABASE_URL` env var → - `TEST_DATABASE_URL` in `.cascade/env` → Docker Compose at `127.0.0.1:5433` → - container IP of `cascade-postgres-test` -3. **Silently skips** all integration tests if no database is reachable at all - -On developer machines (Docker): -```bash -npm run test:db:up # start ephemeral postgres on :5433 (one-time per session) -npm run test:integration # tests auto-find it, run migrations, clean up -``` +**⚠️ Do not use `npm test -- --project integration`** — it _adds_ the integration project on top of the hardcoded unit flags, running all 5 projects. Use `npm run test:integration`. -In worker/agent environments (local postgres already running): -```bash -TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/cascade_test \ - npm run test:integration # setup auto-creates cascade_test DB if missing -``` - -### Linting +**⚠️ Full integration suite takes ~4 min.** When iterating on one file, target it directly: ```bash -npm run lint # Check -npm run lint:fix # Fix -npm run typecheck # Type check +TEST_DATABASE_URL=... npx vitest run --project integration tests/integration/.test.ts ``` -### Git Hooks - -Lefthook runs pre-commit (lint, typecheck) and pre-push (unit tests, integration tests) hooks automatically. The pre-push hook auto-starts an ephemeral PostgreSQL via Docker (`npm run test:db:up`) for integration tests — Docker must be running. +Integration test DB is auto-discovered in order: `TEST_DATABASE_URL` env → `TEST_DATABASE_URL` in `.cascade/env` → Docker Compose at `127.0.0.1:5433` → `cascade-postgres-test` container IP. If none reachable, integration tests **silently skip**. DB is auto-created if missing. -## Key Directories +Developer machines: `npm run test:db:up` once, then `npm run test:integration`. -- `src/router/` - Router entry point (webhook receiver, enqueues to Redis) -- `src/webhook/` - Shared webhook handler factory, parsers, and logging helpers -- `src/config/` - Configuration provider, caching, Zod schemas -- `src/db/` - Database client, Drizzle schema, repositories -- `src/integrations/` - **Unified integration interfaces and registry** (see below) -- `src/triggers/` - Extensible trigger system (Trello, JIRA, GitHub, Sentry) -- `src/agents/` - AI agent implementations -- `src/gadgets/` - Custom gadgets (PM, SCM, alerting, Tmux, Todo, file system) -- `src/cli/dashboard/` - Dashboard CLI commands (`cascade` binary) -- `src/cli/alerting/` - Alerting gadget commands (`cascade-tools` binary) -- `src/api/` - Dashboard API (tRPC routers, auth handlers) -- `src/github/` - GitHub client, dual-persona model (personas.ts) -- `src/trello/` - Trello API client -- `src/jira/` - JIRA API client -- `src/sentry/` - Sentry API client and integration -- `src/utils/` - Utilities (logging, repo cloning, lifecycle) -- `web/` - Dashboard frontend (React 19, Vite, Tailwind v4, TanStack Router) -- `tools/` - Developer scripts (session debugging, DB seeding, secrets management) +Full test helper/factory/mock catalog: @tests/README.md. -## Integration Architecture - -CASCADE uses a unified integration abstraction layer in `src/integrations/`. Every PM, SCM, -and alerting provider is a class implementing `IntegrationModule` (and optionally a -category-specific sub-interface). Modules register into `IntegrationRegistry` at bootstrap time. -Infrastructure (router, worker, webhook handler) looks up integrations by `type` string with no -provider-specific branching. - -### Categories - -| Category | Interface | Example providers | -|----------|-----------|-------------------| -| `pm` | `PMIntegration` (extends `IntegrationModule`) | Trello, JIRA | -| `scm` | `SCMIntegration` (extends `IntegrationModule`) | GitHub | -| `alerting` | `AlertingIntegration` (extends `IntegrationModule`) | Sentry | - -### IntegrationModule (base contract) - -All integrations implement four required members: - -- `type` — unique provider string (e.g. `'trello'`, `'github'`, `'sentry'`) -- `category` — which capability group (`'pm'`, `'scm'`, or `'alerting'`) -- `withCredentials(projectId, fn)` — set env vars for the project, call `fn`, restore on exit -- `hasIntegration(projectId)` — returns `true` if all required credentials are present - -Optional webhook methods (`parseWebhookPayload`, `isSelfAuthored`, `lookupProject`, -`extractWorkItemId`) are implemented by providers that receive webhooks. - -### IntegrationRegistry - -`integrationRegistry` (singleton in `src/integrations/registry.ts`) is populated once at -bootstrap (`src/integrations/bootstrap.ts`). Callers use: - -```typescript -integrationRegistry.get('github') // throws if missing -integrationRegistry.getOrNull('sentry') // null if missing -integrationRegistry.getByCategory('pm') // all PM integrations -``` - -PM integrations are also registered in `pmRegistry` (`src/pm/registry.ts`) for backward -compatibility with existing PM-specific callers. - -### Credential roles - -Each provider declares its credential roles in `src/config/integrationRoles.ts` via -`registerCredentialRoles(provider, category, roles)`. Roles map a logical `role` name to an -env-var key (e.g. `api_key` → `TRELLO_API_KEY`). Roles without `optional: true` are required -for `hasIntegration()` to return `true`. - -### Bootstrap - -`src/integrations/bootstrap.ts` is the single registration point for all four built-in -integrations. It is safe to import from both the router and worker — it does not pull in the -agent execution pipeline or template files. - -### Adding a new integration - -See [`src/integrations/README.md`](src/integrations/README.md) for the complete step-by-step -guide covering all extension points: interface implementation, credential roles, bootstrap -registration, webhook routes, router adapters, trigger handlers, and gadgets. - -## Environment Variables - -Required: -- `DATABASE_URL` - PostgreSQL connection string (e.g., `postgresql://user:pass@host:5432/cascade`) -- `REDIS_URL` - Redis connection string for BullMQ job queue (router + worker). Defaults to `redis://localhost:6379`. Run `.cascade/setup.sh` to install and start Redis locally. - -Optional (infrastructure): -- `PORT` - Server port (default: 3000) -- `LOG_LEVEL` - Logging level (default: info) -- `DATABASE_SSL` - Set to `false` to disable SSL for local PostgreSQL (default: enabled with certificate validation) -- `DATABASE_CA_CERT` - Path to a PEM-encoded CA certificate file for managed databases that use a private CA (e.g., AWS RDS, Azure Database, GCP Cloud SQL). When set, the certificate is read and passed as the `ca` option to `pg.Pool`, enabling TLS certificate validation against the specified CA. Example: `DATABASE_CA_CERT=/etc/ssl/certs/rds-ca.pem` -- `CLAUDE_CODE_OAUTH_TOKEN` - For Claude Code engine (subscription auth) -- `CREDENTIAL_MASTER_KEY` - 64-char hex string (32-byte AES-256 key) for encrypting credentials at rest. Generate with `npm run credentials:generate-key`. When set, all new/updated credentials are encrypted automatically; existing plaintext credentials continue to work. -- `WEBHOOK_CALLBACK_BASE_URL` - Base URL for webhook callbacks (e.g., `https://cascade.example.com`). Used by `tools/setup-webhooks.ts` and the `cascade webhooks create` CLI command to construct the full webhook URL. -- `GITHUB_WEBHOOK_SECRET` - Optional HMAC secret for GitHub webhook signature verification. When set as an integration credential (`webhook_secret` role on the GitHub SCM integration), all newly created GitHub webhooks will include the secret so GitHub signs each delivery. The router then verifies the `X-Hub-Signature-256` header on incoming payloads. -- `SENTRY_DSN` - Sentry DSN for error monitoring (router + worker) -- `SENTRY_ENVIRONMENT` - Sentry environment tag (default: NODE_ENV or 'production') -- `SENTRY_RELEASE` - Release identifier for source maps (e.g., git SHA) -- `SENTRY_TRACES_SAMPLE_RATE` - Trace sampling rate 0.0-1.0 (default: 0.1) - -**Project credentials** (`GITHUB_TOKEN_IMPLEMENTER`, `GITHUB_TOKEN_REVIEWER`, `TRELLO_API_KEY`, `TRELLO_TOKEN`, LLM API keys) are stored in the `project_credentials` table — project-scoped, encrypted at rest when `CREDENTIAL_MASTER_KEY` is set. All credentials (integration tokens and LLM keys) use the same `project_credentials` table keyed by `(projectId, envVarKey)`. There is no env var fallback — the database is the sole source of truth for project-scoped secrets. - -## Database Configuration - -CASCADE stores all project configuration in PostgreSQL. The `config/projects.json` file is only used by `npm run db:seed` (initial seeding) — it is not read at runtime. - -### Schema - -- `organizations` - Organization definitions (multi-tenant support) -- `projects` - Per-project config (repo, base branch, budget, engine, and per-project overrides for model, iterations, timeouts, progress model/interval, `squint_db_url`, `run_links_enabled`, `max_in_flight_items`) -- `project_integrations` - Integration configs per project with `category` (pm/scm), `provider` (trello/jira/github), `config` JSONB, and `triggers` JSONB. One PM + one SCM per project (enforced by unique constraint) -- `project_credentials` - Project-scoped credentials keyed by `(projectId, envVarKey)`. Stores all credential types (GitHub tokens, Trello keys, JIRA tokens, LLM API keys). Encrypted at rest when `CREDENTIAL_MASTER_KEY` is set -- `agent_configs` - Per-agent-type overrides (model, iterations, engine, `agent_engine_settings`, max_concurrency, `system_prompt`, `task_prompt`), project-scoped only (`project_id NOT NULL`) -- `agent_definitions` - Agent YAML definitions (built-in and custom). Each row stores the full definition JSONB, keyed by `agent_type` -- `agent_trigger_configs` - Configured trigger events per project/agent pair (replaces legacy `project_integrations.triggers`) -- `prompt_partials` - Org-scoped partial prompt templates for customizing agent prompts (`.eta` partials) -- `pr_work_items` - Maps PRs to work items (PR number + repo → work item ID/URL) for run-link display -- `webhook_logs` - Raw webhook payloads for debugging (source, headers, body, status, decision reason) -- `users` - Dashboard users (email, bcrypt password hash, org-scoped) -- `sessions` - Session tokens for cookie-based auth (30-day expiry) - -### Database Scripts +## Lint + typecheck ```bash -npm run db:generate # Generate migration SQL from schema changes -npm run db:migrate # Apply pending migrations -npm run db:push # Push schema directly (dev only) -npm run db:studio # Open Drizzle Studio -npm run db:seed -- --org # Seed DB from config/projects.json into an org -npm run db:bootstrap-journal # Bootstrap migration journal (one-time setup for existing DBs) +npm run lint # Check +npm run lint:fix # Fix +npm run typecheck ``` -### Migration Workflow - -Migrations are hand-written SQL files in `src/db/migrations/` tracked by drizzle-kit's journal (`meta/_journal.json`). When adding a new migration: - -1. Create `src/db/migrations/NNNN_description.sql` -2. Add a corresponding entry to `src/db/migrations/meta/_journal.json` with a unique `when` timestamp (ms since epoch) and `tag` matching the filename without `.sql` -3. Run `npm run db:migrate` to apply +## Zod version policy -For databases initially set up with `drizzle-kit push` (no migration journal), run `npm run db:bootstrap-journal` once to register existing migrations in the `drizzle.__drizzle_migrations` tracking table. +**Root and `web/` must use the same Zod major version.** Currently both on `zod@^3.25.0`. `web/tsconfig.json` includes `../src/api/**/*` and `../src/db/**/*` — if majors diverge, `z.infer<>` silently computes different types in backend vs frontend compilation. Bump both workspaces together. -### Credential Encryption at Rest +## Database -All credentials are project-scoped and stored in the `project_credentials` table keyed by `(projectId, envVarKey)`. Credentials are encrypted using AES-256-GCM when `CREDENTIAL_MASTER_KEY` is set. Encryption is transparent — all callers (config provider, tRPC, CLI, tools) are unaffected. +Projects config lives in **PostgreSQL**, not in `config/projects.json`. The JSON file is only used by `npm run db:seed` for initial seeding; it is **not** read at runtime. -- **Algorithm**: AES-256-GCM with 12-byte random IV, 16-byte auth tag, `projectId` as AAD -- **Storage format**: `enc:v1:::` in the existing `value` TEXT column -- **Automatic encryption**: `writeProjectCredential()` encrypts before DB write -- **Automatic decryption**: All resolve/list functions decrypt on read -- **Opt-in**: Without the env var, system works identically to plaintext (zero behavior change) +Migrations are **hand-written SQL** in `src/db/migrations/` tracked by drizzle-kit's journal. To add one: -```bash -npm run credentials:generate-key # Generate a new 32-byte hex key -npm run credentials:encrypt -- --dry-run # Preview migration (plaintext → encrypted) -npm run credentials:encrypt # Encrypt all existing plaintext credentials -npm run credentials:decrypt # Rollback: decrypt all back to plaintext -npm run credentials:rotate-key # Re-encrypt with CREDENTIAL_MASTER_KEY_NEW -``` +1. Create `src/db/migrations/NNNN_description.sql`. +2. Add a matching entry to `src/db/migrations/meta/_journal.json` (unique `when` ms, `tag` matches filename without `.sql`). +3. Run `npm run db:migrate`. -**Key rotation** requires both `CREDENTIAL_MASTER_KEY` (current) and `CREDENTIAL_MASTER_KEY_NEW` (new). After rotation, update the env var to the new key and restart. +For an existing DB set up via `drizzle-kit push` (no journal), run `npm run db:bootstrap-journal` once. -### GitHub Dual-Persona Model +## GitHub dual-persona model -CASCADE uses two dedicated GitHub bot accounts per project to prevent feedback loops: +Every project needs **two** bot tokens (prevents feedback loops): -- **Implementer** (`GITHUB_TOKEN_IMPLEMENTER`) — writes code, creates PRs, responds to review comments - - Agents: `implementation`, `respond-to-review`, `respond-to-ci`, `respond-to-pr-comment`, `splitting`, `planning`, `respond-to-planning-comment`, `debug`, `alerting`, `backlog-manager`, `resolve-conflicts` -- **Reviewer** (`GITHUB_TOKEN_REVIEWER`) — reviews PRs, can approve or request changes - - Agents: `review` +- `GITHUB_TOKEN_IMPLEMENTER` — writes code, opens PRs, responds to reviews. +- `GITHUB_TOKEN_REVIEWER` — reviews PRs (used only by the `review` agent). -Both tokens are **required** for each project. Store them via the dashboard (Project Settings > Credentials tab) or CLI: +Both are **required**. Set via dashboard Credentials tab or: ```bash -cascade projects credentials-set --key GITHUB_TOKEN_IMPLEMENTER --value ghp_aaa... -cascade projects credentials-set --key GITHUB_TOKEN_REVIEWER --value ghp_bbb... +cascade projects credentials-set --key GITHUB_TOKEN_IMPLEMENTER --value ghp_... +cascade projects credentials-set --key GITHUB_TOKEN_REVIEWER --value ghp_... ``` -**Bot detection**: Both persona usernames are resolved at first use and cached. Trigger handlers use `isCascadeBot(login)` to check if an event came from either persona, preventing self-triggered loops. - -**Loop prevention rules**: -- `respond-to-review` ONLY fires when the **reviewer** persona submits a `changes_requested` review -- `respond-to-pr-comment` skips @mentions from **any** known persona -- `check-suite-success` checks reviews from the **reviewer** persona specifically - -### Webhook Signature Verification - -CASCADE supports opt-in HMAC-SHA256 signature verification for GitHub webhook payloads. - -1. Store a `GITHUB_WEBHOOK_SECRET` credential as a project credential: - ```bash - cascade projects credentials-set --key GITHUB_WEBHOOK_SECRET --value - ``` -2. Create (or recreate) the GitHub webhook — CASCADE will include the secret automatically: - ```bash - cascade webhooks create [--callback-url URL] - # or via the setup tool: - npx tsx tools/setup-webhooks.ts create - ``` -3. GitHub signs every delivery with `X-Hub-Signature-256`; the CASCADE router verifies it before processing. - -If no `webhook_secret` credential is configured, webhook creation works exactly as before (no secret, no signature verification). Existing webhooks without a secret continue to work unaffected. +**Loop-prevention rules (behavioral invariants):** -### Integration Credential Resolution +- `respond-to-review` fires **only** when the **reviewer** persona submits `changes_requested`. +- `respond-to-pr-comment` skips @mentions from **any** known persona. +- `check-suite-success` checks reviews from the **reviewer** persona specifically. +- All trigger handlers use `isCascadeBot(login)` to filter self-events. -Integration credentials are resolved by `(projectId, category, role)`: +## Agent triggers -```typescript -// Get a specific integration credential -const trelloKey = await getIntegrationCredential(projectId, 'pm', 'api_key'); +Trigger format is category-prefixed: `{category}:{event}` +(e.g. `pm:status-changed`, `scm:check-suite-success`, `alerting:issue-created`). -// Get all integration credentials + org defaults as flat env-var-key map (for worker environments) -const allCreds = await getAllProjectCredentials(projectId); - -// Non-integration org-scoped credentials (LLM API keys) -const openrouterKey = await getOrgCredential(projectId, 'OPENROUTER_API_KEY'); -``` - -Role definitions and env-var-key mappings are in `src/config/integrationRoles.ts`. - -### Agent Trigger Configuration - -Triggers define which events activate which agents. Configuration is stored in the `agent_trigger_configs` table and managed via the unified `trigger-set` command. - -#### Trigger Format - -Triggers use a category-prefixed event format: `{category}:{event-name}` -- PM triggers: `pm:status-changed`, `pm:label-added` -- SCM triggers: `scm:check-suite-success`, `scm:check-suite-failure`, `scm:pr-review-submitted` -- Alerting triggers: `alerting:issue-created`, `alerting:metric-alert` - -#### Setting Triggers +Configs live in the `agent_trigger_configs` table. Manage via: ```bash -# Discover available triggers for an agent -cascade projects trigger-discover --agent review - -# List configured triggers for a project +cascade projects trigger-discover --agent cascade projects trigger-list - -# Configure a trigger (unified command) -cascade projects trigger-set --agent review --event scm:check-suite-success --enable -cascade projects trigger-set --agent review --event scm:check-suite-success --params '{"authorMode":"own"}' - -# Enable implementation trigger for PM status change -cascade projects trigger-set --agent implementation --event pm:status-changed --enable -``` - -In the **Agent Configs** tab, each agent shows toggles for its supported triggers. Triggers with parameters (like `authorMode` for review) show additional input fields when enabled. - -When merging to `dev` or `main`, legacy trigger configs from `project_integrations.triggers` are automatically migrated to the new `agent_trigger_configs` table. The migration is idempotent. - -### Review Agent Trigger Modes - -| Event | Description | -|-------|-------------| -| `scm:check-suite-success` | Trigger review when CI passes (use `authorMode` parameter: `own` or `external`) | -| `scm:review-requested` | Trigger review when a CASCADE persona is explicitly requested as reviewer | -| `scm:pr-opened` | Trigger review when a PR is opened | - -```bash -# Enable review for implementer PRs only (most common) -cascade projects trigger-set --agent review --event scm:check-suite-success --enable --params '{"authorMode":"own"}' - -# Enable review when explicitly requested -cascade projects trigger-set --agent review --event scm:review-requested --enable -``` - -### PM Agent Trigger Modes - -| Event | Providers | Description | -|-------|-----------|-------------| -| `pm:status-changed` | Trello, JIRA | Trigger when card/issue moves to agent's target status | -| `pm:label-added` | All | Trigger when Ready to Process label is added | - -```bash -cascade projects trigger-set --agent implementation --event pm:status-changed --enable -cascade projects trigger-set --agent splitting --event pm:label-added --enable -``` - -## Claude Code Engine - -CASCADE uses the Claude Code SDK as the default agent engine. Configure per-project via the CLI or dashboard: - -```bash -cascade projects update --agent-engine claude-code - -# Or override per agent type: -cascade agents create --agent-type implementation --project-id --engine claude-code +cascade projects trigger-set --agent --event --enable [--params JSON] ``` -### Authentication +Some triggers take params (e.g. `review` + `scm:check-suite-success` accepts `{"authorMode":"own"|"external"}`). Legacy configs on `project_integrations.triggers` are auto-migrated on merge to `dev`/`main`. -**Claude Max Subscription via `CLAUDE_CODE_OAUTH_TOKEN`:** +## Review agent — context shape (debugging) -1. Install Claude Code CLI: `npm install -g @anthropic-ai/claude-code` -2. Authenticate: `claude login` -> select "Log in with your subscription account" -3. Generate a token: `claude setup-token` -4. Set `CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...` in your environment. +Review agent receives a **compact per-file diff context**, not full file contents. Each changed file is a `### (, +N -M)` section with a unified diff hunk. Budget: `REVIEW_DIFF_CONTEXT_TOKEN_LIMIT` = 200k tokens, per-file cap 10%. -CASCADE creates `~/.claude.json` with `{"hasCompletedOnboarding": true}` to skip interactive onboarding in headless environments. +Files that can't fit (deleted, binary, oversized patch, or budget exhausted) are injected as `SKIPPED FILES` with instructions to fetch on demand via `gh pr diff`, `Read`, or `Grep`. -**Docker verification test:** -```bash -bash tests/docker/claude-code-auth/run-test.sh -``` - -## Codex Engine - -CASCADE supports OpenAI's Codex CLI as an alternative agent engine: - -```bash -cascade projects update --agent-engine codex -``` +When review output misses something, check the `PR context prepared` log entry for `included` / `skipped` / `skipReasons` to confirm whether the file was visible to the agent. -**API key (`OPENAI_API_KEY`):** Store as an org-scoped credential. No extra setup needed. +## Engines -**Subscription (ChatGPT Plus/Pro via `CODEX_AUTH_JSON`):** +Default engine: `claude-code`. Alternatives: `codex`, `opencode`. ```bash -# 1. On a machine with a browser: -codex login - -# 2. Store the auth token in CASCADE: -cascade projects credentials-set \ - --key CODEX_AUTH_JSON \ - --value "$(cat ~/.codex/auth.json)" \ - --name "Codex Subscription Auth" - -# 3. Set the engine: -cascade projects update --agent-engine codex +cascade projects update --agent-engine claude-code +# per-agent override: +cascade agents create --agent-type implementation --project-id --engine codex ``` -CASCADE writes `~/.codex/auth.json` before each run and captures any post-run token refreshes back to the database automatically. +Auth: -## OpenCode Engine +- **Claude Code subscription**: `CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...` (from `claude setup-token`). CASCADE writes `~/.claude.json` before run. +- **Codex subscription**: store `CODEX_AUTH_JSON` credential (contents of `~/.codex/auth.json` after `codex login`). CASCADE persists refreshed tokens back to the DB after each run. +- **API-key providers**: store `OPENAI_API_KEY` / other keys as project credentials. -CASCADE supports the OpenCode engine as an alternative: +## Environment -```bash -cascade projects update --agent-engine opencode -``` - -The OpenCode engine is implemented in `src/backends/opencode/`. Configure with `cascade agents create --engine opencode` or via the Agent Configs tab in the dashboard. - -## Sentry / Alerting Integration - -CASCADE integrates with Sentry for alert-driven automation. When Sentry issues or metric alerts arrive, they are routed to the `alerting` agent. - -- **Sentry client**: `src/sentry/` — API client and integration helpers -- **Triggers**: `src/triggers/sentry/` — `alerting-issue.ts`, `alerting-metric.ts` -- **Gadgets**: `src/gadgets/sentry/` — `GetAlertingIssue`, `GetAlertingEventDetail`, `ListAlertingEvents` -- **CLI tools**: `src/cli/alerting/` — alerting-specific commands available via `cascade-tools` -- **Agent definition**: `src/agents/definitions/alerting.yaml` - -Configure the Sentry integration via the dashboard (Settings > Integrations > Alerting) or CLI. - -## Dashboard - -CASCADE includes a web dashboard for exploring agent runs, logs, LLM calls, and debug analyses. - -### Running the Dashboard - -```bash -npm run dev # Router on :3000 (webhook receiver, tsx watch) -npm run dev:web # Frontend on :5173 (Vite, proxies /trpc + /api to :3001) -``` - -> **Note:** The dashboard API (`:3001`) and router (`:3000`) are separate services. Run `npm run build && node --env-file=.env dist/dashboard.js` in a third terminal for the dashboard API. - -### Production Build - -```bash -npm run build:web # Vite builds frontend to dist/web/ -npm run build # tsc compiles backend to dist/ -node dist/router/index.js # Starts the router (webhook receiver) — this is npm start -node dist/dashboard.js # Starts the dashboard API on :3001 -``` - -> **Note:** `npm start` runs `dist/router/index.js` (the router), **not** the dashboard. Run `node dist/dashboard.js` separately for the dashboard API. - -### Architecture - -The dashboard is a single-process deployment. The Hono server mounts tRPC routes (`/trpc/*`), auth routes (`/api/auth/*`), and in production serves the built frontend as static files. - -- **API**: tRPC v11 via `@hono/trpc-server` for end-to-end type safety -- **Auth**: Session cookies (HTTP-only, 30-day expiry) with bcrypt password hashing -- **Frontend**: React 19 + Vite + Tailwind CSS v4 + shadcn/ui + TanStack Router -- **Type sharing**: Frontend imports `type AppRouter` from the backend (type-only, no server code in bundle) - -### Key Files - -- `src/api/trpc.ts` - tRPC context, procedures, auth middleware -- `src/api/router.ts` - Root router composition (exports `type AppRouter`) -- `src/api/routers/` - tRPC routers (auth, runs, projects) -- `src/api/auth/` - Login/logout Hono handlers, session resolution -- `web/src/lib/trpc.ts` - Frontend tRPC client (type-safe via AppRouter import) - -## CLI (`cascade`) - -CASCADE includes a `cascade` CLI for managing the platform from the terminal. It consumes the same tRPC endpoints as the web dashboard — no business logic duplication, full type safety. - -### Running the CLI - -In production the `cascade` binary is available globally. In development: - -```bash -npm run build # Compile TypeScript (required before first use) -node bin/cascade.js # Run any CLI command -``` - -Config is stored in `~/.cascade/cli.json`. Override with env vars for CI/scripts: - -```bash -export CASCADE_SERVER_URL=http://localhost:3001 -export CASCADE_SESSION_TOKEN= -``` - -
-Full Command Reference - -```bash -# Auth -cascade login --server URL --email X --password Y -cascade logout -cascade whoami - -# Runs -cascade runs list [--project ID] [--status running,failed] [--agent-type impl] [--limit 20] -cascade runs show -cascade runs logs # Pipe: cascade runs logs ID | grep error -cascade runs llm-calls -cascade runs llm-call -cascade runs debug # View debug analysis -cascade runs debug --analyze # Trigger new debug analysis -cascade runs debug --analyze --wait # Trigger and wait for completion -cascade runs trigger --project --agent-type [--work-item-id ID] [--model MODEL] -cascade runs retry [--model MODEL] -cascade runs cancel [--reason "..."] # Cancel a running agent run - -# Projects -cascade projects list -cascade projects show -cascade projects create --id my-project --name "My Project" --repo owner/repo -cascade projects update --model claude-sonnet-4-5-20250929 -cascade projects update --agent-engine llmist -cascade projects update --work-item-budget 10 --watchdog-timeout 1800000 -cascade projects update --progress-model openrouter:google/gemini-2.5-flash-lite --progress-interval 5 -cascade projects update --run-links-enabled --max-in-flight-items 3 -cascade projects delete --yes -cascade projects integrations -cascade projects integration-set --category pm --provider trello --config '{"boardId":"..."}' -cascade projects credentials-list -cascade projects credentials-set --key GITHUB_TOKEN_IMPLEMENTER --value ghp_aaa... -cascade projects credentials-delete --key GITHUB_TOKEN_IMPLEMENTER -cascade projects trigger-discover --agent -cascade projects trigger-list [--agent ] -cascade projects trigger-set --agent --event [--enable|--disable] [--params JSON] - -# Users -cascade users list -cascade users create --email X --password Y --name Z [--role member|admin|superadmin] -cascade users update [--name Z] [--email X] [--role member|admin|superadmin] [--password Y] -cascade users delete --yes - -# Organization -cascade org show -cascade org update --name "My Org" - -# Agent Configs -cascade agents list --project-id ID -cascade agents create --agent-type implementation --model claude-sonnet-4-5-20250929 --project-id ID --engine llmist -cascade agents update --max-iterations 30 -cascade agents delete --yes - -# Agent Definitions (YAML-based agent definitions) -cascade definitions list -cascade definitions show -cascade definitions create --agent-type my-agent --file definition.yaml -cascade definitions update --file definition.yaml -cascade definitions delete -cascade definitions export # Export definition as YAML to stdout -cascade definitions import --file definition.yaml # Import/upsert from YAML file -cascade definitions reset # Reset custom definition to built-in -cascade definitions triggers # List supported triggers for an agent type - -# Prompts (prompt partial customization) -cascade prompts default --agent-type # Print default .eta template for an agent type -cascade prompts default-partial # Print default content for a named partial -cascade prompts variables --agent-type # List available template variables -cascade prompts list-partials # List all configured prompt partials -cascade prompts get-partial # Get a specific prompt partial -cascade prompts set-partial --content "..." # Create/update a prompt partial -cascade prompts reset-partial # Delete a custom partial (reverts to default) -cascade prompts validate --agent-type # Validate current prompt template - -# Webhook Logs (payload debugging) -cascade webhooklogs list [--source trello|github|jira] [--event-type X] [--limit 50] -cascade webhooklogs show - -# Webhooks -cascade webhooks list [--github-token ghp_xxx] -cascade webhooks create [--callback-url URL] [--github-token ghp_xxx] -cascade webhooks delete [--callback-url URL] [--github-token ghp_xxx] -``` - -Global flags: `--json` (machine-readable output), `--server URL` (override server URL). - -
- -### Architecture - -Each command is a thin adapter: **parse flags → call tRPC → format output**. All business logic lives server-side. - -``` -src/cli/dashboard/ -├── _shared/ # Config, tRPC client, base class, formatters -├── login.ts # Auth (HTTP, not tRPC) -├── logout.ts -├── whoami.ts -├── runs/ # 9 commands (list, show, logs, llm-calls, llm-call, debug, trigger, retry, cancel) -├── projects/ # 13 commands -├── users/ # 4 commands -├── org/ # 2 commands -├── agents/ # 4 commands -├── definitions/ # 9 commands (list, show, create, update, delete, export, import, reset, triggers) -├── prompts/ # 8 commands (default, default-partial, variables, list-partials, get-partial, set-partial, reset-partial, validate) -├── webhooklogs/ # 2 commands (list, show) -└── webhooks/ # 3 commands -``` - -The `cascade` binary is separate from `cascade-tools` (which is for agents). The `cascade-tools` binary uses a custom oclif config in `bin/cascade-tools.js` to discover all non-dashboard agent tool commands (everything under `dist/cli/` except dashboard), while `cascade` discovers only dashboard commands (`dist/cli/dashboard/`). - -## User Management - -Users can be managed via the CLI (recommended) or the dashboard at `/settings/users`: - -```bash -cascade users create --email user@example.com --password secret --name "User Name" --role admin -cascade users list -cascade users update --name "New Name" --role member -cascade users delete --yes -``` - -As a fallback when the CLI and dashboard are unavailable: - -```bash -# Generate bcrypt hash -node -e "import('bcrypt').then(b => b.default.hash('password', 10).then(console.log))" - -# Insert user -psql $DATABASE_URL -c "INSERT INTO users (org_id, email, password_hash, name, role) VALUES ('my-org', 'user@example.com', '\$2b\$10\$...', 'User Name', 'admin');" -``` - -## Adding New Triggers - -1. Create trigger handler in `src/triggers//` -2. Implement `TriggerHandler` interface -3. Register in `src/triggers/builtins.ts` via `registerBuiltInTriggers()` - -See [`src/integrations/README.md`](src/integrations/README.md) (Step 6) for a detailed walkthrough. - -## Adding New Agents - -Agents are defined using YAML definition files. Built-in definitions live in `src/agents/definitions/`. Custom agents can be added via the dashboard or CLI without touching source code. - -1. **Write a YAML definition** — model your file on an existing one in `src/agents/definitions/` (e.g. `implementation.yaml`). The definition specifies the agent identity, supported triggers, prompt templates, and tool manifests. -2. **Import the definition** — via CLI (`cascade definitions import --file my-agent.yaml`) or dashboard (**Agent Definitions** tab). -3. **Create an `agent_configs` row** — agents require an explicit `agent_configs` entry to be enabled for a project: - ```bash - cascade agents create --agent-type my-agent --project-id - ``` -4. **Configure triggers** — enable the events that should activate the agent: - ```bash - cascade projects trigger-set --agent my-agent --event pm:status-changed --enable - ``` - -> **Note:** Built-in agent types (`implementation`, `review`, `splitting`, etc.) ship pre-loaded as built-in definitions. Custom agents added via `cascade definitions create` are stored in the `agent_definitions` table. - -## Agent Resilience Features - -CASCADE integrates llmist's resilience features to ensure reliable operation during long-running sessions: - -### Rate Limiting (Proactive) -- Model-specific rate limits with safety margins (80-90%) -- Tracks requests per minute (RPM), tokens per minute (TPM), and daily token usage -- Prevents 429 errors by throttling requests before hitting API limits -- Configured in `src/config/rateLimits.ts` - -### Retry Strategy (Reactive) -- Handles transient failures (rate limits, 5xx errors, timeouts, connection issues) -- 5 retry attempts with exponential backoff (1s → 60s max) -- Respects `Retry-After` headers from providers -- Jitter randomization prevents thundering herd problems -- Configured in `src/config/retryConfig.ts` - -### Context Compaction -- Prevents context window overflow on long-running sessions -- **All agents**: Triggers at 80% context usage, reduces to 50%, preserves 5 recent turns -- Hybrid strategy: intelligently mixes summarization and sliding-window -- Configured in `src/config/compactionConfig.ts` - -### Iteration Hints -- Ephemeral trailing messages showing iteration progress -- Urgency warnings at >80%: "⚠️ ITERATION BUDGET: 17/20 - Only 3 remaining!" -- Helps LLM prioritize and wrap up before hitting iteration limits -- Configured in `src/config/hintConfig.ts` - -**Monitoring**: Check `llmist-*.log` for rate limiting events. Compaction events are logged to main agent logs with details (tokens saved, reduction percentage, messages removed). - -## Debugging Production Sessions - -### Manual Session Download - -Download session logs and card data from a Trello card for debugging: - -```bash -npm run tool:download-session https://trello.com/c/abc123/card-name -# or just the card ID -npm run tool:download-session abc123 -``` - -This downloads all `.gz` log attachments (ungzipped), plus card description, checklists, and comments into a temp directory. +Required: -### Automatic Debug Analysis +- `DATABASE_URL` — PostgreSQL connection string. +- `REDIS_URL` — BullMQ queue, defaults to `redis://localhost:6379`. -CASCADE includes a debug agent that automatically analyzes agent session logs: +Optional: -1. **Automatic Trigger**: When an agent uploads a session log (`.zip` file) to a Trello card, the debug agent automatically triggers -2. **Log Analysis**: The debug agent downloads, extracts, and analyzes the session logs to identify: - - Errors and exceptions - - Failed gadget calls - - Iteration loops and inefficiencies - - Excessive LLM calls - - Scope creep or confusion patterns -3. **Debug Card Creation**: Creates a new card in the DEBUG list with: - - Title: `{agent-type} - {original card name}` - - Executive summary of what went wrong - - Key issues found - - Timeline of events - - Actionable recommendations - - Link back to the original card +- `DATABASE_SSL=false` to disable SSL locally; `DATABASE_CA_CERT` for managed DBs with a private CA. +- `CREDENTIAL_MASTER_KEY` — 64-char hex (AES-256 key) to encrypt project credentials at rest. Without it, credentials are stored as plaintext; both modes coexist. +- `GITHUB_WEBHOOK_SECRET` — opt-in HMAC verification; store as the `webhook_secret` role on the GitHub SCM integration. +- `SENTRY_DSN`, `SENTRY_ENVIRONMENT`, `SENTRY_RELEASE`, `SENTRY_TRACES_SAMPLE_RATE` — observability. -**Setup**: Add a `debug` list to your Trello board and configure it via the dashboard or CLI: +**Project credentials (GitHub tokens, Trello/JIRA/Linear keys, LLM API keys) live in the `project_credentials` table.** The DB is the **sole source of truth** — there is no env var fallback for project-scoped secrets. -```bash -node bin/cascade.js projects integration-set \ - --category pm --provider trello \ - --config '{"boardId":"BOARD_ID","lists":{"todo":"LIST_ID","inProgress":"LIST_ID","inReview":"LIST_ID","debug":"YOUR_DEBUG_LIST_ID"},...}' -``` +## Git hooks -The debug agent only analyzes logs uploaded by the authenticated CASCADE user and matching the pattern `{agent-type}-{timestamp}.zip`. +Lefthook runs pre-commit (lint, typecheck) and pre-push (unit + integration tests) hooks automatically. Pre-push auto-starts an ephemeral Postgres via `npm run test:db:up` — Docker must be running. diff --git a/Dockerfile.worker b/Dockerfile.worker index 4c2484d6..c4c44e69 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -15,8 +15,8 @@ RUN npm run build FROM node:22-bookworm AS production WORKDIR /app -# Install pnpm and squint globally (some repos use pnpm, squint for codebase analysis) -RUN npm install -g pnpm @zbigniewsobiecki/squint@^1.10.2 --force +# Install pnpm globally (some repos use pnpm) +RUN npm install -g pnpm --force # Install system packages needed by agent runtime RUN apt-get update && apt-get install -y \ diff --git a/bin/cascade.js b/bin/cascade.js index c194df91..886f324d 100755 --- a/bin/cascade.js +++ b/bin/cascade.js @@ -1,3 +1,4 @@ #!/usr/bin/env node import { execute } from '@oclif/core'; + await execute({ dir: import.meta.url }); diff --git a/biome.json b/biome.json index 6a7fcf8f..82845f9e 100644 --- a/biome.json +++ b/biome.json @@ -1,13 +1,11 @@ { - "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.10/schema.json", "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, - "organizeImports": { - "enabled": true - }, + "assist": { "actions": { "source": { "organizeImports": "on" } } }, "linter": { "enabled": true, "rules": { @@ -34,6 +32,25 @@ } }, "files": { - "ignore": ["node_modules", "dist", "coverage", "*.json"] - } + "includes": [ + "**", + "!**/node_modules", + "!**/dist", + "!**/coverage", + "!**/*.json", + "!web/src/index.css" + ] + }, + "overrides": [ + { + "includes": ["tests/**", "src/**"], + "linter": { + "rules": { + "performance": { + "noDelete": "off" + } + } + } + } + ] } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 00000000..6562a2ef --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,137 @@ +# CASCADE Architecture + +CASCADE is a PM-to-Code automation platform that connects project management tools (Trello, JIRA), source control (GitHub), and monitoring (Sentry) to AI-powered agents that autonomously implement features, review PRs, debug failures, and manage backlogs. Webhooks from external providers flow through a router, get queued in Redis, and are processed by ephemeral worker containers that run agents against cloned repositories. + +> **Relationship to CLAUDE.md**: `CLAUDE.md` is the operational reference (commands, env vars, how-to). This document and its deep-dives cover the *system design* — how components fit together and why. + +## System Overview + +```mermaid +graph TB + subgraph External["External Providers"] + Trello + JIRA + GitHub + Sentry + end + + subgraph CASCADE["CASCADE Platform"] + Router["Router :3000
Webhook receiver"] + Redis[(Redis / BullMQ)] + Worker["Worker containers
One job per container"] + Dashboard["Dashboard :3001
API + tRPC"] + DB[(PostgreSQL)] + end + + subgraph Clients + WebUI["Dashboard UI"] + CLI["cascade CLI"] + end + + Trello -->|webhook| Router + JIRA -->|webhook| Router + GitHub -->|webhook| Router + Sentry -->|webhook| Router + + Router -->|enqueue job| Redis + Redis -->|dequeue job| Worker + + Worker -->|PRs, comments| GitHub + Worker -->|status updates| Trello + Worker -->|status updates| JIRA + + Router <--> DB + Worker <--> DB + Dashboard <--> DB + Dashboard <--> Redis + + WebUI <--> Dashboard + CLI <--> Dashboard +``` + +See also: [`docs/architecture.d2`](architecture.d2) for the D2 source diagram. + +## Service Topology + +| Service | Entry Point | Default Port | Responsibility | +|---------|-------------|-------------|----------------| +| **Router** | `src/router/index.ts` | 3000 | Receive webhooks, verify signatures, run trigger dispatch, enqueue jobs to Redis, manage worker containers | +| **Worker** | `src/worker-entry.ts` | N/A (ephemeral) | Process one job per container — run trigger handlers, execute agents, exit on completion | +| **Dashboard** | `src/dashboard.ts` | 3001 | tRPC API for web UI and CLI, session auth, serve frontend static files in self-hosted mode | + +## End-to-End Request Flow + +The canonical path from webhook to pull request: + +```mermaid +sequenceDiagram + participant P as Provider
(Trello/GitHub/JIRA/Sentry) + participant R as Router + participant Q as Redis/BullMQ + participant W as Worker + participant A as Agent Engine + + P->>R: POST /provider/webhook + R->>R: Parse, verify signature, dedup + R->>R: Lookup project, dispatch triggers + R->>R: Check concurrency, post ack comment + R->>Q: Enqueue job + Q->>W: Spawn container with job env vars + W->>W: Bootstrap integrations, dispatch by job type + W->>W: Match trigger, resolve agent definition + W->>A: Execute agent (clone repo, run engine) + A->>A: LLM loop: read, edit, test, commit + A-->>P: Create PR / post comments / update status + W->>W: Finalize run record, cleanup, exit +``` + +## Architectural Patterns + +**Registry pattern** — Integrations, triggers, engines, PM providers, and capabilities all use registries (singleton maps populated at bootstrap). Infrastructure code looks up by key with no provider-specific branching. + +**Capability-driven tool resolution** — Agent YAML definitions declare required capabilities (`fs:read`, `pm:write`, `scm:pr`). At runtime, capabilities are resolved against available integrations to determine which gadgets (tools) the agent receives. + +**Two-tier credential resolution** — In the router and dashboard, credentials are read from the `project_credentials` database table. In workers, the router pre-loads credentials as environment variables to avoid giving workers direct DB access to secrets. + +**Dual-persona GitHub model** — Each project uses two GitHub bot accounts (implementer and reviewer) to prevent feedback loops. Agent type determines which persona token is used. + +**YAML-based agent definitions** — Agents are defined declaratively in YAML files specifying identity, capabilities, triggers, prompts, and lifecycle hooks. Definitions resolve via three tiers: in-memory cache, database, then YAML files on disk. + +**AsyncLocalStorage credential scoping** — Provider clients (GitHub, Trello, JIRA) use Node.js `AsyncLocalStorage` to scope credentials per-request, preventing cross-request credential leakage. + +## Directory Map + +| Directory | Purpose | +|-----------|---------| +| `src/router/` | Webhook receiver, BullMQ producer, worker container management | +| `src/webhook/` | Shared webhook handler factory, parsers, signature verification, logging | +| `src/triggers/` | Event-to-agent routing: TriggerRegistry, TriggerHandler implementations | +| `src/agents/` | Agent definitions (YAML), profiles, capabilities, prompt templates | +| `src/backends/` | LLM execution engines: Claude Code, LLMist, Codex, OpenCode | +| `src/gadgets/` | Tool implementations agents use (file ops, PM, SCM, alerting, shell) | +| `src/integrations/` | Unified integration interfaces, registry, bootstrap | +| `src/pm/` | PM abstraction layer: provider interface, Trello/JIRA adapters, lifecycle | +| `src/github/` | GitHub API client, dual-persona model, PR operations | +| `src/trello/` | Trello API client | +| `src/jira/` | JIRA API client (jira.js wrapper) | +| `src/sentry/` | Sentry API client, alerting integration | +| `src/config/` | Configuration provider, caching, credential resolution, integration roles | +| `src/db/` | Drizzle ORM schema, repositories, migrations | +| `src/api/` | tRPC routers for dashboard API | +| `src/cli/` | Two CLIs: `cascade` (dashboard) and `cascade-tools` (agent tools) | +| `src/utils/` | Logging, repo cloning, lifecycle/watchdog, env scrubbing | +| `src/types/` | Shared TypeScript types | +| `src/queue/` | BullMQ queue helpers | + +## Deep-Dive Documents + +1. [Services and Deployment](./architecture/01-services.md) — Three-service architecture, startup sequences, container model +2. [Webhook Pipeline](./architecture/02-webhook-pipeline.md) — Handler factory, platform adapters, processing pipeline +3. [Trigger System](./architecture/03-trigger-system.md) — TriggerRegistry, handlers, config resolution, context pipeline +4. [Agent System](./architecture/04-agent-system.md) — YAML definitions, profiles, capabilities, prompts, hooks +5. [Engine Backends](./architecture/05-engine-backends.md) — AgentEngine interface, archetypes, execution adapter +6. [Integration Layer](./architecture/06-integration-layer.md) — IntegrationModule, registry, categories, provider implementations +7. [Gadgets](./architecture/07-gadgets.md) — Capability-to-gadget mapping, built-in tools, cascade-tools CLI +8. [Configuration and Credentials](./architecture/08-config-credentials.md) — Config provider, credential resolution, encryption +9. [Database](./architecture/09-database.md) — Schema, ER diagram, repositories, migrations +10. [Resilience](./architecture/10-resilience.md) — Watchdog, concurrency controls, rate limiting, retry, loop prevention diff --git a/docs/adding-engines.md b/docs/adding-engines.md index 170cfeca..f7a872f6 100644 --- a/docs/adding-engines.md +++ b/docs/adding-engines.md @@ -113,9 +113,6 @@ export const ALLOWED_ENV_EXACT = new Set([ // My Engine auth 'MY_ENGINE_API_KEY', - - // Squint (pass through so agents can use AST tooling) - 'SQUINT_DB_PATH', ]); ``` diff --git a/docs/architecture/01-services.md b/docs/architecture/01-services.md new file mode 100644 index 00000000..f2769834 --- /dev/null +++ b/docs/architecture/01-services.md @@ -0,0 +1,172 @@ +# Services and Deployment + +CASCADE runs as three independent services. There is no monolithic server mode — each service has a distinct entry point, lifecycle, and scaling model. + +```mermaid +graph LR + subgraph Router["Router Container"] + R_Hono["Hono :3000"] + R_BullMQ["BullMQ Producer"] + R_WM["Worker Manager"] + end + + subgraph Workers["Worker Containers (ephemeral)"] + W1["Worker 1"] + W2["Worker 2"] + WN["Worker N"] + end + + subgraph Dashboard["Dashboard Container"] + D_Hono["Hono :3001"] + D_tRPC["tRPC Router"] + end + + Redis[(Redis)] + DB[(PostgreSQL)] + + R_Hono --> R_BullMQ --> Redis + R_WM --> Workers + Redis --> R_WM + + D_Hono --> D_tRPC + Dashboard <--> DB + Router <--> DB + Workers <--> DB +``` + +## Router + +**Entry point**: `src/router/index.ts` +**Default port**: 3000 + +The router is the webhook ingestion point. It receives HTTP POST requests from external providers, processes them through a multi-step pipeline, and enqueues jobs to Redis for worker containers. + +### Webhook endpoints + +| Route | Provider | Notes | +|-------|----------|-------| +| `POST /trello/webhook` | Trello | HEAD/GET returns 200 for Trello's verification | +| `POST /github/webhook` | GitHub | Injects `X-GitHub-Event` header into payload | +| `POST /jira/webhook` | JIRA | HEAD/GET returns 200 for JIRA verification | +| `POST /sentry/webhook/:projectId` | Sentry | Project ID in URL for unambiguous routing | +| `GET /health` | Internal | Queue stats, active worker count | + +### Startup sequence + +Module-load phase (runs at import time, before `startRouter()`): +1. `registerBuiltInEngines()` — register engine settings schemas (required before any `loadConfig()`) +2. `createTriggerRegistry()` + `registerBuiltInTriggers()` — populate trigger handlers + +`startRouter()` async phase: +3. `seedAgentDefinitions()` — sync built-in YAML definitions to database +4. `initAgentMessages()` — load ack message templates +5. `initPrompts()` — load prompt templates +6. `startCancelListener()` — listen for run cancellation requests +7. `startWorkerProcessor()` — begin polling BullMQ for jobs and spawning containers +8. `serve()` — start Hono HTTP server + +### Key modules + +| File | Purpose | +|------|---------| +| `webhook-processor.ts` | Generic 12-step pipeline (see [02-webhook-pipeline](./02-webhook-pipeline.md)) | +| `platform-adapter.ts` | `RouterPlatformAdapter` interface | +| `adapters/` | Per-provider adapter implementations | +| `worker-manager.ts` | Spawns/monitors Docker worker containers | +| `queue.ts` | BullMQ `addJob()`, queue stats | +| `action-dedup.ts` | In-memory deduplication of webhook deliveries | +| `work-item-lock.ts` | Prevents concurrent agents on the same work item | +| `agent-type-lock.ts` | Agent-type concurrency limits | +| `cancel-listener.ts` | Listens for run cancellation via BullMQ events | +| `webhookVerification.ts` | HMAC signature verification per provider | + +## Worker + +**Entry point**: `src/worker-entry.ts` +**Port**: None (ephemeral container, no HTTP server) + +Workers are stateless, one-job-per-container processes spawned by the router's worker manager. Each worker reads its job from environment variables, processes it, and exits. + +### Environment variables + +The router passes job data to workers via Docker container env vars: + +| Variable | Purpose | +|----------|---------| +| `JOB_ID` | Unique job identifier | +| `JOB_TYPE` | `trello`, `github`, `jira`, `sentry`, `manual-run`, `retry-run`, `debug-analysis` | +| `JOB_DATA` | JSON-encoded job payload | +| `CASCADE_CREDENTIAL_KEYS` | Comma-separated list of credential env var names | +| Individual credential vars | Pre-loaded project credentials (e.g., `GITHUB_TOKEN_IMPLEMENTER`) | + +### Job types + +```typescript +type JobData = + | TrelloJobData // Trello webhook payload + | GitHubJobData // GitHub webhook payload + | JiraJobData // JIRA webhook payload + | SentryJobData // Sentry webhook payload + | ManualRunJobData // Dashboard-initiated run + | RetryRunJobData // Retry a failed run + | DebugAnalysisJobData; // Post-mortem debug analysis +``` + +### Startup sequence + +1. `loadEnvConfigSafe()` — load `.cascade/env` if present +2. `getDb()` — eagerly initialize DB connection (caches pool before env scrub) +3. `registerBuiltInEngines()` — register engine settings schemas (before `loadConfig()`) +4. `loadConfig()` — cache project config from database +5. `seedAgentDefinitions()` — sync built-in YAML definitions to database +6. `initAgentMessages()` — load ack message templates +7. `initPrompts()` — load prompt templates +8. `scrubSensitiveEnv()` — remove `DATABASE_URL` and other secrets from `process.env` +9. `createTriggerRegistry()` + `registerBuiltInTriggers()` — populate trigger handlers +10. `dispatchJob()` — route to the appropriate handler based on `JOB_TYPE` + +The security scrub in step 8 prevents agent engines (which execute arbitrary LLM-generated commands) from accessing database credentials. Note that trigger registration (step 9) happens after the scrub — it only needs the in-memory config, not the database. + +### Dispatch flow + +`dispatchJob()` switches on the job type: +- **Webhook jobs** (`trello`, `github`, `jira`, `sentry`) — call the provider-specific webhook processor, which re-runs trigger dispatch and executes the matched agent +- **Dashboard jobs** (`manual-run`, `retry-run`, `debug-analysis`) — call `processDashboardJob()`, which loads project config and invokes the appropriate runner + +## Dashboard + +**Entry point**: `src/dashboard.ts` +**Default port**: 3001 + +The dashboard serves the tRPC API consumed by both the web frontend and the `cascade` CLI. In self-hosted mode, it also serves the built frontend as static files. + +### Routes + +| Route | Purpose | +|-------|---------| +| `POST /api/auth/login` | Email/password authentication | +| `POST /api/auth/logout` | Session invalidation | +| `/trpc/*` | tRPC API endpoints | +| `GET /health` | Service health check | +| `/*` (static) | Frontend files from `dist/web/` (self-hosted mode only) | + +### Startup sequence + +Module-load phase (runs at import time, before `startDashboard()`): +1. `registerBuiltInEngines()` — register engine settings schemas +2. CORS middleware, logging middleware registered on Hono app +3. Auth routes mounted (`/api/auth/login`, `/api/auth/logout`) +4. tRPC router mounted with session-based context resolution +5. Static file serving (if `dist/web/` exists) + +`startDashboard()` async phase: +6. `initPrompts()` — load prompt templates +7. `serve()` — start Hono HTTP server + +### tRPC context + +Every tRPC request builds a context containing: +- `user` — resolved from session cookie via `resolveUserFromSession()` +- `effectiveOrgId` — computed from user's org membership or `x-org-context` header + +Procedure types enforce auth levels: `publicProcedure`, `protectedProcedure`, `adminProcedure`, `superAdminProcedure`. diff --git a/docs/architecture/02-webhook-pipeline.md b/docs/architecture/02-webhook-pipeline.md new file mode 100644 index 00000000..dfd929ed --- /dev/null +++ b/docs/architecture/02-webhook-pipeline.md @@ -0,0 +1,149 @@ +# Webhook Pipeline + +Webhooks from external providers (Trello, GitHub, JIRA, Sentry) are processed through a two-layer system: a **webhook handler factory** that handles HTTP concerns, and a **router platform adapter** that implements the business logic pipeline. + +## Webhook Handler Factory + +`src/webhook/webhookHandlers.ts` — `createWebhookHandler()` + +The factory creates Hono route handlers with a standard lifecycle: + +``` +HTTP POST → Parse payload → Verify signature → Process webhook → Log result → Return 200/4xx +``` + +Each webhook endpoint provides a `WebhookHandlerConfig`: + +```typescript +interface WebhookHandlerConfig { + source: string; // 'trello' | 'github' | 'jira' | 'sentry' + parsePayload: (c: Context) => ParseResult; + verifySignature?: (ctx, rawBody, projectId?) => VerificationResult | null; + processWebhook: (payload, eventType?, headers?) => Promise; +} +``` + +The factory handles: +- Payload parsing with per-provider parsers (`src/webhook/webhookParsers.ts`) +- Optional signature verification (`src/webhook/signatureVerification.ts`) +- Fire-and-forget acknowledgment reactions +- Webhook logging to `webhook_logs` table (`src/webhook/webhookLogging.ts`) +- Error handling (parse failures → 400, signature failures → 401) + +### Platform Parsers + +| Parser | Source | Event type extraction | +|--------|--------|----------------------| +| `parseGitHubPayload()` | JSON or form-encoded body | `X-GitHub-Event` header | +| `parseTrelloPayload()` | JSON body | `action.type` field | +| `parseJiraPayload()` | JSON body | `webhookEvent` field | +| `parseSentryPayload()` | JSON body | `Sentry-Hook-Resource` header | + +## Platform Adapters + +`src/router/platform-adapter.ts` — `RouterPlatformAdapter` interface + +Each provider implements this interface to plug into the generic `processRouterWebhook()` pipeline: + +```typescript +interface RouterPlatformAdapter { + readonly type: string; + parseWebhook(payload: unknown): Promise; + isProcessableEvent(event: ParsedWebhookEvent): boolean; + isSelfAuthored(event: ParsedWebhookEvent, payload: unknown): Promise; + sendReaction(event: ParsedWebhookEvent, payload: unknown): void; + resolveProject(event: ParsedWebhookEvent): Promise; + dispatchWithCredentials(event, payload, project, triggerRegistry): Promise; + postAck(event, payload, project, agentType, triggerResult): Promise; + buildJob(event, payload, project, triggerResult, ackResult): CascadeJob; + firePreActions?(job, payload): void; +} +``` + +### Normalized event + +All platforms normalize their webhook payload into a `ParsedWebhookEvent`: + +```typescript +interface ParsedWebhookEvent { + projectIdentifier: string; // Board ID, repo name, JIRA project key + eventType: string; // Human-readable event descriptor + workItemId?: string; // Card ID, PR number, issue key + isCommentEvent: boolean; // Whether this needs ack reaction + actionId?: string; // Platform-specific ID for dedup +} +``` + +### Provider adapters + +| Adapter | File | Project lookup key | +|---------|------|--------------------| +| `TrelloRouterAdapter` | `src/router/adapters/trello.ts` | `boardId` | +| `GitHubRouterAdapter` | `src/router/adapters/github.ts` | `repoFullName` | +| `JiraRouterAdapter` | `src/router/adapters/jira.ts` | JIRA project key | +| `SentryRouterAdapter` | `src/router/adapters/sentry.ts` | CASCADE `projectId` (from URL) | + +## The 12-Step Pipeline + +`src/router/webhook-processor.ts` — `processRouterWebhook()` + +```mermaid +flowchart TD + A[1. Parse payload] --> B{2. Duplicate?} + B -->|Yes| SKIP1[Skip: duplicate action] + B -->|No| C{3. Processable event?} + C -->|No| SKIP2[Skip: event type not processable] + C -->|Yes| D{4. Self-authored?} + D -->|Yes| SKIP3[Skip: loop prevention] + D -->|No| E[5. Fire ack reaction] + E --> F{6. Resolve project config} + F -->|Not found| SKIP4[Skip: no project config] + F -->|Found| G[7. Dispatch triggers with credentials] + G -->|No match| SKIP5[Skip: no trigger matched] + G -->|Matched| H{8. Work-item / agent-type lock} + H -->|Locked| SKIP6[Skip: concurrency limit] + H -->|Free| I[9. Post ack comment] + I --> J[10. Build job] + J --> K[11. Fire pre-actions] + K --> L[12. Enqueue to Redis] +``` + +### Step details + +1. **Parse** — Adapter normalizes raw payload into `ParsedWebhookEvent` +2. **Dedup** — Check in-memory set of recently processed `actionId`s (`action-dedup.ts`) +3. **Filter** — Adapter's `isProcessableEvent()` checks event type relevance +4. **Self-check** — Adapter's `isSelfAuthored()` detects bot's own actions (loop prevention) +5. **Reaction** — Fire-and-forget emoji reaction on the source event +6. **Resolve config** — Look up project by platform identifier (board ID, repo, etc.) +7. **Dispatch triggers** — Within credential scope, call `TriggerRegistry.dispatch()` to find matching agent +8. **Concurrency** — Check work-item lock (`work-item-lock.ts`) and agent-type concurrency (`agent-type-lock.ts`) +9. **Ack comment** — Post an acknowledgment comment to the work item or PR +10. **Build job** — Package trigger result + payload + ack info into a `CascadeJob` +11. **Pre-actions** — Optional fire-and-forget actions (e.g., GitHub eyes reaction) +12. **Enqueue** — Add job to BullMQ Redis queue; mark work item and agent type as enqueued + +### Concurrency controls + +| Mechanism | File | Purpose | +|-----------|------|---------| +| Action dedup | `action-dedup.ts` | Prevent processing same webhook delivery twice | +| Work-item lock | `work-item-lock.ts` | Prevent concurrent agents on the same card/issue | +| Agent-type lock | `agent-type-lock.ts` | Configurable `max_concurrency` per agent type per project | + +All locks are in-memory with TTL expiry. They are conservative (enqueue-time only) — the worker performs its own verification before executing. + +## Signature Verification + +`src/router/webhookVerification.ts` + +Each provider's verification function checks for a stored `webhook_secret` credential and validates the signature header: + +| Provider | Header | Algorithm | +|----------|--------|-----------| +| GitHub | `X-Hub-Signature-256` | HMAC-SHA256 | +| Trello | Custom verification | Trello-specific | +| JIRA | `X-Hub-Signature` | HMAC-SHA256 | +| Sentry | `Sentry-Hook-Signature` | HMAC-SHA256 | + +If no webhook secret is configured for a project, verification is skipped (returns `null`). diff --git a/docs/architecture/03-trigger-system.md b/docs/architecture/03-trigger-system.md new file mode 100644 index 00000000..54b2ba70 --- /dev/null +++ b/docs/architecture/03-trigger-system.md @@ -0,0 +1,179 @@ +# Trigger System + +The trigger system routes webhook events to the appropriate agent. When a webhook arrives, the router builds a `TriggerContext` and calls `TriggerRegistry.dispatch()` to find the first matching handler. The matched handler returns a `TriggerResult` specifying which agent to run and with what input. + +## TriggerRegistry + +`src/triggers/registry.ts` + +A simple ordered list of handlers with first-match-wins dispatch: + +```typescript +class TriggerRegistry { + register(handler: TriggerHandler): void; + dispatch(ctx: TriggerContext): Promise; + getHandlers(): TriggerHandler[]; +} +``` + +`dispatch()` iterates handlers in registration order. For each handler: +1. Call `matches(ctx)` — if `false`, skip +2. Call `handle(ctx)` — if it returns a `TriggerResult`, return it +3. If `handle()` returns `null`, continue to next handler + +## TriggerHandler + +`src/triggers/types.ts` + +```typescript +interface TriggerHandler { + name: string; + description: string; + matches(ctx: TriggerContext): boolean; + handle(ctx: TriggerContext): Promise; +} +``` + +### TriggerContext + +```typescript +interface TriggerContext { + project: ProjectConfig; + source: TriggerSource; // 'trello' | 'github' | 'jira' | 'sentry' + payload: unknown; // Raw webhook payload + personaIdentities?: PersonaIdentities; // GitHub bot identities +} +``` + +### TriggerResult + +```typescript +interface TriggerResult { + agentType: string | null; // Which agent to run + agentInput: AgentInput; // Input data for the agent + workItemId?: string; + workItemUrl?: string; + workItemTitle?: string; + prNumber?: number; + prUrl?: string; + prTitle?: string; + waitForChecks?: boolean; // Poll CI before starting + onBlocked?: () => void; // Cleanup if job can't be enqueued +} +``` + +## Built-in Triggers + +Registration happens in `src/triggers/builtins.ts`, which delegates to per-platform `register.ts` files: + +```typescript +function registerBuiltInTriggers(registry: TriggerRegistry): void { + registerTrelloTriggers(registry); + registerJiraTriggers(registry); + registerGitHubTriggers(registry); + registerSentryTriggers(registry); +} +``` + +### Trello triggers (`src/triggers/trello/`) + +| Handler | Event | Agent | +|---------|-------|-------| +| `TrelloCommentMentionTrigger` | Bot mentioned in comment | Varies by context | +| `TrelloStatusChangedSplittingTrigger` | Card → Splitting list | `splitting` | +| `TrelloStatusChangedPlanningTrigger` | Card → Planning list | `planning` | +| `TrelloStatusChangedTodoTrigger` | Card → Todo list | `implementation` | +| `TrelloStatusChangedBacklogTrigger` | Card → Backlog list | `backlog-manager` | +| `TrelloStatusChangedMergedTrigger` | Card → Merged list | `backlog-manager` | +| `ReadyToProcessLabelTrigger` | "cascade-ready" label added | `splitting` | + +### JIRA triggers (`src/triggers/jira/`) + +| Handler | Event | Agent | +|---------|-------|-------| +| `JiraCommentMentionTrigger` | Bot mentioned in comment | Varies | +| `JiraStatusChangedTrigger` | Issue status transition | Per-status mapping | +| `JiraLabelAddedTrigger` | "cascade-ready" label added | `splitting` | + +### GitHub triggers (`src/triggers/github/`) + +| Handler | Event | Agent | +|---------|-------|-------| +| `CheckSuiteSuccessTrigger` | CI passed | `review` (with `authorMode` param) | +| `CheckSuiteFailureTrigger` | CI failed | `respond-to-ci` | +| `PrReviewSubmittedTrigger` | Review with changes_requested | `respond-to-review` | +| `ReviewRequestedTrigger` | Bot requested as reviewer | `review` | +| `PrOpenedTrigger` | PR opened | `review` | +| `PrCommentMentionTrigger` | Bot @mentioned in PR comment | `respond-to-pr-comment` | +| `PrMergedTrigger` | PR merged | PM status update (no agent) | +| `PrReadyToMergeTrigger` | PR approved + checks pass | PM status update (no agent) | +| `PrConflictDetectedTrigger` | Merge conflict on PR | `resolve-conflicts` | + +### Sentry triggers (`src/triggers/sentry/`) + +| Handler | Event | Agent | +|---------|-------|-------| +| `AlertingIssueTrigger` | Sentry issue alert | `alerting` | +| `AlertingMetricTrigger` | Sentry metric alert | `alerting` | + +## Trigger Configuration + +### Event format + +Triggers use category-prefixed events: `{category}:{event-name}` +- `pm:status-changed`, `pm:label-added` +- `scm:check-suite-success`, `scm:pr-review-submitted`, `scm:review-requested` +- `alerting:issue-created`, `alerting:metric-alert` + +### Config resolution + +`src/triggers/config-resolver.ts` + +Each trigger handler calls `isTriggerEnabled()` to check if it should fire. Resolution follows a three-tier cascade: + +1. **Database overrides** — `agent_trigger_configs` table entries per project/agent/event +2. **Definition defaults** — `defaultEnabled` and default parameters from YAML definitions +3. **Legacy fallback** — `project_integrations.triggers` JSONB (migrated automatically) + +### Context pipeline + +Each trigger in a YAML agent definition can declare a `contextPipeline` — an ordered list of context-fetching steps that run before the agent starts: + +| Step | Purpose | +|------|---------| +| `directoryListing` | List repository file structure | +| `contextFiles` | Read key project files (README, etc.) | +| `workItem` | Fetch work item details from PM tool | +| `prepopulateTodos` | Pre-populate todo list from work item checklists | +| `prContext` | Fetch PR details, compact per-file diffs, CI checks; emit a `SKIPPED FILES` injection when files are omitted (over budget, deleted, binary) | +| `prConversation` | Fetch PR comments and review threads | +| `pipelineSnapshot` | Fetch CI pipeline status | +| `alertingIssue` | Fetch Sentry issue and event details | + +## Shared Agent Execution + +`src/triggers/shared/agent-execution.ts` + +After a trigger matches, the shared execution layer handles the agent lifecycle: + +```mermaid +flowchart TD + A[Trigger matched] --> B[PM lifecycle: prepareForAgent] + B --> C[Check budget] + C -->|Over budget| D[Post budget warning, skip] + C -->|Within budget| E[Resolve agent definition] + E --> F[Set credential scope] + F --> G[Run agent via engine] + G -->|Success| H[PM lifecycle: handleSuccess] + G -->|Failure| I[PM lifecycle: handleFailure] + H --> J[Trigger debug analysis if configured] + I --> J +``` + +This includes: +- PM lifecycle management (move card to "In Progress", post labels) +- Budget checking (`workItemBudgetUsd`) +- Credential scoping via `withCredentials()` +- Agent execution via `runAgent()` (see [05-engine-backends](./05-engine-backends.md)) +- Post-run lifecycle (move card to "In Review", link PR, sync checklists) +- Debug analysis triggering on failure diff --git a/docs/architecture/04-agent-system.md b/docs/architecture/04-agent-system.md new file mode 100644 index 00000000..df4a7d0e --- /dev/null +++ b/docs/architecture/04-agent-system.md @@ -0,0 +1,250 @@ +# Agent System + +Agents are the core automation units in CASCADE. Each agent is defined declaratively in YAML, specifying its identity, capabilities, triggers, prompts, and lifecycle hooks. At runtime, definitions are compiled into profiles that determine which tools the agent receives and how it interacts with the PM/SCM systems. + +## Agent Definitions + +`src/agents/definitions/` + +### YAML structure + +Each built-in agent is a YAML file in `src/agents/definitions/`. Custom agents are stored in the `agent_definitions` database table. The schema is defined in `src/agents/definitions/schema.ts`. + +```yaml +identity: + emoji: "..." + label: "Implementation" + roleHint: "Writes code, runs tests, and prepares a pull request" + initialMessage: "**Implementing changes** — ..." + +integrations: + required: [pm, scm] # Fail if not configured + optional: [alerting] # Use if available + +capabilities: + required: + - fs:read + - fs:write + - shell:exec + - session:ctrl + - pm:read + - pm:write + - scm:pr + optional: + - pm:checklist + +triggers: + - event: pm:status-changed + label: "Status Changed to Todo" + defaultEnabled: false + parameters: + - name: targetStatus + type: select + options: [todo] + defaultValue: todo + contextPipeline: [directoryListing, contextFiles, workItem, prepopulateTodos] + +prompts: + taskPrompt: | + Analyze and process the work item with ID: <%= it.workItemId %>. + +hooks: + trailing: + scm: + gitStatus: true + prStatus: true + builtin: + diagnostics: true + todoProgress: true + reminder: true + finish: + scm: + requiresPR: true + lifecycle: + moveOnPrepare: inProgress + moveOnSuccess: inReview + linkPR: true + syncChecklist: true + +hint: >- + Complete the current todo in as few iterations as possible. +``` + +### Key schema fields + +| Field | Purpose | +|-------|---------| +| `identity` | Agent display info (emoji, label, role hint, initial message) | +| `integrations` | Explicit integration requirements (required/optional categories) | +| `capabilities` | Required and optional capabilities that determine tool access | +| `triggers` | Events that activate this agent, with parameters and context pipelines | +| `prompts.taskPrompt` | Eta template for the agent's task prompt | +| `hooks.trailing` | Info appended to each LLM turn (git status, PR status, diagnostics) | +| `hooks.finish` | Completion requirements (must have PR, must have review, etc.) | +| `hooks.lifecycle` | PM card movement on prepare/success, PR linking, checklist sync | +| `hint` | Persistent guidance injected into the LLM context | +| `strategies` | Engine-specific strategy overrides | +| `gadgetOptions` | Special gadget builder flags (e.g., `includeReviewComments`) | + +### Three-tier definition resolution + +`src/agents/definitions/loader.ts` + +``` +1. In-memory cache (fastest, populated on first load) + ↓ miss +2. Database lookup (agent_definitions table — custom agents) + ↓ miss +3. YAML file on disk (src/agents/definitions/*.yaml — built-in agents) +``` + +Key functions: +- `resolveAgentDefinition(agentType)` — single agent, three-tier +- `resolveAllAgentDefinitions()` — merge DB + YAML +- `resolveKnownAgentTypes()` — list all known types + +## Built-in Agents + +| Agent | Capabilities | Persona | Key Triggers | +|-------|-------------|---------|--------------| +| `implementation` | fs, shell, session, pm, scm:pr | Implementer | `pm:status-changed` (todo) | +| `splitting` | fs, session, pm | Implementer | `pm:status-changed`, `pm:label-added` | +| `planning` | fs, session, pm | Implementer | `pm:status-changed` (planning) | +| `review` | fs, shell, scm:read, scm:review | Reviewer | `scm:check-suite-success`, `scm:review-requested` | +| `respond-to-review` | fs, shell, session, pm, scm | Implementer | `scm:pr-review-submitted` | +| `respond-to-ci` | fs, shell, session, scm | Implementer | `scm:check-suite-failure` | +| `respond-to-pr-comment` | fs, shell, session, scm | Implementer | `scm:pr-comment-mention` | +| `respond-to-planning-comment` | fs, session, pm | Implementer | `pm:comment-mention` | +| `backlog-manager` | fs, session, pm, scm:read | Implementer | `pm:status-changed` (backlog, merged) | +| `resolve-conflicts` | fs, shell, session, scm | Implementer | `scm:pr-conflict-detected` | +| `alerting` | fs, shell, session, alerting, scm | Implementer | `alerting:issue-created`, `alerting:metric-alert` | +| `debug` | fs, session, pm | Implementer | `internal:debug-analysis` | + +## Capabilities + +`src/agents/capabilities/` + +Capabilities are the bridge between agent definitions and concrete tools. The system maps capabilities to gadgets (for SDK engines) and SDK tools (for native-tool engines). + +### Registry + +`src/agents/capabilities/registry.ts` + +```typescript +const CAPABILITIES = [ + // Built-in (always available) + 'fs:read', 'fs:write', 'shell:exec', 'session:ctrl', + // PM integration + 'pm:read', 'pm:write', 'pm:checklist', + // SCM integration + 'scm:read', 'scm:ci-logs', 'scm:comment', 'scm:review', 'scm:pr', + // Alerting integration + 'alerting:read', +] as const; +``` + +Each capability maps to a `CapabilityDefinition`: + +```typescript +interface CapabilityDefinition { + integration: IntegrationCategory | null; // null = built-in + description: string; + gadgetNames: string[]; // LLMist gadget classes + sdkToolNames: string[]; // Claude Code SDK tool names + cliToolNames: string[]; // cascade-tools CLI commands +} +``` + +### Resolution flow + +`src/agents/capabilities/resolver.ts` + +```mermaid +flowchart TD + A["Agent definition
(capabilities.required + optional)"] --> B[Create integration checker] + B --> C["integrationRegistry.getByCategory(cat)
.hasIntegration(projectId)
for pm, scm, alerting"] + C --> D[resolveEffectiveCapabilities] + D --> E["Built-in caps: always included"] + D --> F["Integration caps: only if provider configured"] + E --> G[buildGadgetsFromCapabilities] + F --> G + G --> H["Instantiate gadget classes
via GADGET_CONSTRUCTORS"] + H --> I["Gadget[] passed to engine"] +``` + +- Built-in capabilities (`fs:*`, `shell:*`, `session:*`) are always available +- Integration capabilities (`pm:*`, `scm:*`, `alerting:*`) require the corresponding integration to be configured for the project +- Optional capabilities degrade gracefully — missing integrations are noted in the system prompt + +## Prompts + +`src/agents/prompts/` + +Agent prompts are built using the [Eta](https://eta.js.org/) template engine. + +### Template context + +The `PromptContext` object passed to templates includes: +- `workItemId`, `workItemUrl`, `workItemTitle` — from trigger result +- `prNumber`, `prUrl`, `prBranch` — for SCM-focused agents +- `projectConfig` — full project configuration +- `agentType` — the running agent type +- `capabilities` — resolved capability list +- `hint` — persistent guidance from definition + +### Prompt partials + +Organizations can customize agent prompts via **prompt partials** — named template fragments stored in the `prompt_partials` database table. Partials are Eta includes (`<%~ include('partialName') %>`) that override default content when a custom version exists. + +Managed via: +- Dashboard: Settings > Prompts +- CLI: `cascade prompts set-partial`, `cascade prompts reset-partial` + +## Hooks + +### Trailing hooks + +Appended to each LLM turn as ephemeral context: + +| Hook | Purpose | +|------|---------| +| `scm.gitStatus` | Current git status (uncommitted changes) | +| `scm.prStatus` | PR state, review status, CI checks | +| `builtin.diagnostics` | TypeScript/lint errors in recently edited files | +| `builtin.todoProgress` | Current todo list progress | +| `builtin.reminder` | Iteration budget and guidance reminders | + +### Finish hooks + +Completion requirements verified before the agent can finish: + +| Hook | Purpose | +|------|---------| +| `scm.requiresPR` | Agent must have created/updated a PR | +| `scm.requiresReview` | Agent must have submitted a review | +| `scm.requiresPushedChanges` | Agent must have pushed commits | + +### Lifecycle hooks + +PM card management during agent execution: + +| Hook | Purpose | +|------|---------| +| `moveOnPrepare` | Move card to status on agent start (e.g., "In Progress") | +| `moveOnSuccess` | Move card to status on success (e.g., "In Review") | +| `linkPR` | Link the created PR to the work item | +| `syncChecklist` | Sync todo list back to PM card checklists | + +## Agent Profiles + +`src/agents/definitions/profiles.ts` + +At runtime, a definition is compiled into an `AgentProfile` — the operational interface used by the execution pipeline: + +- `filterTools(allTools)` — filter available tools based on capabilities +- `allCapabilities` — resolved capability list +- `fetchContext(params)` — run context pipeline steps +- `buildTaskPrompt(input)` — render Eta task prompt template +- `getLlmistGadgets()` — instantiate gadgets for LLMist engine +- `finishHooks` — PR/review/push requirements +- `lifecycleHooks` — PM card movement rules diff --git a/docs/architecture/05-engine-backends.md b/docs/architecture/05-engine-backends.md new file mode 100644 index 00000000..fe638fe8 --- /dev/null +++ b/docs/architecture/05-engine-backends.md @@ -0,0 +1,154 @@ +# Engine Backends + +CASCADE abstracts LLM execution behind the `AgentEngine` interface. Multiple engines (Claude Code, LLMist, Codex, OpenCode) implement this interface, and a shared execution adapter orchestrates the full lifecycle around any engine. + +## AgentEngine Interface + +`src/backends/types.ts` + +```typescript +interface AgentEngine { + readonly definition: AgentEngineDefinition; + + execute(plan: AgentExecutionPlan): Promise; + supportsAgentType(agentType: string): boolean; + + // Optional hooks + resolveModel?(cascadeModel: string): string; + getSettingsSchema?(): ZodType>; + beforeExecute?(plan: AgentExecutionPlan): Promise; + afterExecute?(plan: AgentExecutionPlan, result: AgentEngineResult): Promise; +} +``` + +### AgentEngineDefinition + +Describes engine capabilities and configuration: + +```typescript +interface AgentEngineDefinition { + readonly id: string; // 'claude-code', 'llmist', 'codex', 'opencode' + readonly label: string; // Display name + readonly archetype: 'sdk' | 'native-tool'; + readonly capabilities: string[]; + readonly modelSelection: { type: 'free-text' } | { type: 'select', options: [...] }; + readonly logLabel: string; + readonly settings?: AgentEngineSettingsDefinition; +} +``` + +### AgentExecutionPlan + +The fully resolved plan passed to `engine.execute()`, combining context, prompts, and policy: + +```typescript +interface AgentExecutionPlan + extends AgentExecutionContext, // repoDir, project, agentInput, logWriter, etc. + AgentPromptSpec, // systemPrompt, taskPrompt, availableTools, contextInjections + AgentEnginePolicy { // maxIterations, model, budgetUsd, engineSettings + cliToolsDir: string; + nativeToolShimDir?: string; + completionRequirements?: CompletionRequirements; +} +``` + +## Two Engine Archetypes + +### `native-tool` — Subprocess-based CLI tools + +Used when the engine runs as an external CLI process with its own built-in file/bash tools. + +**Base class**: `NativeToolEngine` (`src/backends/shared/NativeToolEngine.ts`) + +Provides: +- `buildEngineEnv()` — construct subprocess environment with allowlisted env vars and project secrets +- `resolveModel()` delegation to `resolveEngineModel()` +- `afterExecute()` cleanup for offloaded context files + +**Implementations**: Claude Code (`src/backends/claude-code/`), Codex (`src/backends/codex/`), OpenCode (`src/backends/opencode/`) + +Native-tool engines invoke CASCADE domain tools (PM, SCM, alerting) via the `cascade-tools` CLI binary through Bash commands. File operations use the engine's built-in tools (Read, Write, Edit, Bash, Glob, Grep). + +### `sdk` — In-process SDK integrations + +Used when the engine runs in-process and manages its own LLM API calls. + +**Implementation**: LLMist (`src/backends/llmist/`) + +SDK engines invoke gadgets server-side as synthetic tool calls — the engine calls the gadget function directly and injects the result into the LLM context. + +## Engine Registry + +`src/backends/registry.ts` + +```typescript +function registerEngine(engine: AgentEngine): void; +function getEngine(name: string): AgentEngine; +function getEngineCatalog(): AgentEngineDefinition[]; +``` + +Engines are registered at bootstrap (`src/backends/bootstrap.ts`) before any config loading or webhook processing begins. + +### Engine resolution + +When an agent runs, the engine is resolved in order: +1. Agent-type override (from `agent_configs.agent_engine` for this project + agent type) +2. Project-level default (`project.agentEngine.default`) +3. Global fallback: `'claude-code'` + +## Execution Adapter + +`src/backends/adapter.ts` — `executeWithEngine()` + +This is the central orchestration function that wraps every engine call. It handles everything that is common across engines: + +```mermaid +sequenceDiagram + participant C as Caller + participant A as Adapter + participant S as Secret Orchestrator + participant E as Engine + participant D as Database + + C->>A: executeWithEngine(engine, agentType, input) + A->>A: Setup repo directory (clone if needed) + A->>A: Create FileLogger + LogWriter + A->>D: Create run record + A->>S: Build AgentExecutionPlan + S->>S: Resolve model, fetch context, build prompts + S->>S: Resolve project secrets, engine settings + A->>A: Start progress monitor + A->>E: engine.beforeExecute(plan) + A->>E: engine.execute(plan) + E-->>A: AgentEngineResult + A->>E: engine.afterExecute(plan, result) + A->>A: Post-process result (extract PR evidence) + A->>A: Run continuation loop if needed + A->>D: Finalize run record (status, cost, logs) + A->>A: Cleanup (repo deletion, temp files) + A-->>C: AgentResult +``` + +### Key stages + +1. **Repo setup** — Clone repository or use existing working directory +2. **Run record** — Create `agent_runs` database entry with `running` status +3. **Plan building** (`src/backends/secretOrchestrator.ts`) — Resolve model, fetch context injections, build system/task prompts, gather project secrets, merge engine settings +4. **Progress monitoring** (`src/backends/progressMonitor.ts`) — Timer-based progress updates posted to PM card and/or GitHub PR comment +5. **Engine execution** — `beforeExecute()` → `execute()` → `afterExecute()` +6. **Completion verification** (`src/backends/completion.ts`) — Check sidecar files for PR/review/push evidence +7. **Continuation loop** (`src/backends/shared/continuationLoop.ts`) — Re-invoke engine if completion requirements not met +8. **Finalization** — Update run record with status, duration, cost, logs; upload logs + +### LLM call logging + +`src/backends/shared/llmCallLogger.ts` + +All LLM requests and responses are logged to the `agent_run_llm_calls` table, tracking: +- Request/response content +- Token counts (input, output, cached) +- Cost (USD) +- Duration +- Tool calls made + +For further details on adding a new engine, see [`docs/adding-engines.md`](../adding-engines.md). diff --git a/docs/architecture/06-integration-layer.md b/docs/architecture/06-integration-layer.md new file mode 100644 index 00000000..c9939274 --- /dev/null +++ b/docs/architecture/06-integration-layer.md @@ -0,0 +1,184 @@ +# Integration Layer + +CASCADE uses a unified integration abstraction so that infrastructure code (router, worker, webhook handlers) never branches on provider type. Every PM, SCM, and alerting provider is a class implementing `IntegrationModule`, registered into a singleton `IntegrationRegistry` at bootstrap. + +## IntegrationModule + +`src/integrations/types.ts` + +The base contract for all integrations: + +```typescript +interface IntegrationModule { + readonly type: string; // 'trello', 'jira', 'linear', 'github', 'sentry' + readonly category: IntegrationCategory; // 'pm' | 'scm' | 'alerting' + + withCredentials(projectId: string, fn: () => Promise): Promise; + hasIntegration(projectId: string): Promise; + + // Optional webhook methods + parseWebhookPayload?(raw: unknown): IntegrationWebhookEvent | null; + isSelfAuthored?(event: unknown, projectId: string): Promise; + lookupProject?(identifier: string): Promise<{ project; config } | null>; + extractWorkItemId?(text: string): string | null; +} +``` + +### Credential scoping + +`withCredentials()` uses `AsyncLocalStorage` to set provider-specific env vars for the duration of a callback, then restores the original values. This provides per-request credential isolation without global state mutation. + +### Integration checking + +`hasIntegration()` checks that all required credential roles for the provider are configured for the given project. Role definitions come from `src/config/integrationRoles.ts`. + +## IntegrationRegistry + +`src/integrations/registry.ts` + +```typescript +class IntegrationRegistry { + register(integration: IntegrationModule): void; + get(type: string): IntegrationModule; // throws if missing + getOrNull(type: string): IntegrationModule | null; + getByCategory(category: IntegrationCategory): IntegrationModule[]; + all(): IntegrationModule[]; +} + +const integrationRegistry: IntegrationRegistry; // singleton +``` + +## Category Interfaces + +### PMIntegration + +`src/pm/integration.ts` — extends `IntegrationModule` with PM-specific methods: + +- `createProvider(project)` — create a `PMProvider` instance for CRUD operations +- `resolveLifecycleConfig(project)` — extract labels, statuses, list IDs from project config +- `postAckComment(projectId, workItemId, message)` — post acknowledgment comment +- `deleteAckComment(projectId, workItemId, commentId)` — remove ack comment +- `sendReaction(projectId, event)` — add emoji reaction to source event +- `lookupProject(identifier)` — find project by board ID or project key +- `extractWorkItemId(text)` — parse work item ID from text (e.g., Trello URL, JIRA key) + +### SCMIntegration + +`src/integrations/scm.ts` — extends `IntegrationModule` with SCM-specific methods for webhook payload parsing and project lookup by repository name. + +### AlertingIntegration + +`src/integrations/alerting.ts` — extends `IntegrationModule` with alerting-specific methods. + +## Bootstrap + +`src/integrations/bootstrap.ts` + +Single, idempotent registration point for all five built-in integrations. Safe to import from router, worker, and dashboard — it does not pull in the agent execution pipeline or template files. + +``` +TrelloIntegration → integrationRegistry + pmRegistry +JiraIntegration → integrationRegistry + pmRegistry +LinearIntegration → integrationRegistry + pmRegistry +GitHubSCMIntegration → integrationRegistry +SentryAlertingIntegration → integrationRegistry +``` + +## Credential Roles + +`src/config/integrationRoles.ts` + +Each provider declares its credential roles — the mapping from logical role names to environment variable keys: + +| Provider | Category | Required Roles | Optional Roles | +|----------|----------|---------------|----------------| +| Trello | pm | `api_key` → `TRELLO_API_KEY`, `token` → `TRELLO_TOKEN` | `api_secret` | +| JIRA | pm | `email` → `JIRA_EMAIL`, `api_token` → `JIRA_API_TOKEN` | `webhook_secret` | +| Linear | pm | `api_key` → `LINEAR_API_KEY` | `webhook_secret` → `LINEAR_WEBHOOK_SECRET` | +| GitHub | scm | `implementer_token` → `GITHUB_TOKEN_IMPLEMENTER`, `reviewer_token` → `GITHUB_TOKEN_REVIEWER` | `webhook_secret` | +| Sentry | alerting | `api_token` → `SENTRY_API_TOKEN` | `webhook_secret` | + +## Provider Implementations + +### Trello (`src/pm/trello/`, `src/trello/`) + +- `TrelloIntegration` implements `PMIntegration` +- `TrelloPMProvider` implements `PMProvider` (card CRUD, comments, labels, checklists) +- `trelloClient` — Octokit-style client with AsyncLocalStorage credential scoping +- Media extraction from markdown in card descriptions/comments +- Status = list ID (cards grouped by lists) + +### JIRA (`src/pm/jira/`, `src/jira/`) + +- `JiraIntegration` implements `PMIntegration` +- `JiraPMProvider` implements `PMProvider` (issue CRUD, transitions, comments) +- `jiraClient` — wraps `jira.js` Version3Client with AsyncLocalStorage scoping +- ADF (Atlassian Document Format) ↔ markdown conversion (`src/pm/jira/adf.ts`) +- Status transitions via JIRA transition ID lookup +- Issue key extraction via regex: `[A-Z][A-Z0-9]+-\d+` + +### Linear (`src/pm/linear/`, `src/linear/`) + +- `LinearIntegration` implements `PMIntegration` +- `LinearPMProvider` implements `PMProvider` (issue CRUD, comments, labels, state transitions) +- `linearClient` — GraphQL/REST client with AsyncLocalStorage credential scoping +- Status transitions via Linear state ID lookup +- Issue identifier extraction via regex: `[A-Z][A-Z0-9]*-\d+` (e.g. `TEAM-123`) +- Work item URL format: `https://linear.app//issue/` + +### GitHub (`src/github/`) + +- `GitHubSCMIntegration` implements `SCMIntegration` +- `githubClient` — Octokit wrapper with `withGitHubToken()` AsyncLocalStorage scoping +- **Dual-persona model** (`src/github/personas.ts`): + - **Implementer** — writes code, creates PRs (used by most agents) + - **Reviewer** — reviews PRs, can approve or request changes (used by `review` agent) + - `isCascadeBot(login)` — checks if a GitHub login belongs to either persona + - `resolvePersonaIdentities()` — resolves both tokens to usernames (cached 60s per project) +- Loop prevention: `respond-to-review` only fires on reviewer's `changes_requested`; comment triggers skip @mentions from any known persona + +### Sentry (`src/sentry/`) + +- `SentryAlertingIntegration` implements `AlertingIntegration` +- `sentryClient` — REST API client with Bearer token auth +- Supports issue alerts, metric alerts, and issue lifecycle webhooks +- Config: `organizationSlug` stored in `project_integrations.config` JSONB + +## PM Abstraction + +`src/pm/` + +### PMProvider interface + +Lower-level data operations consumed by gadgets and lifecycle hooks: + +```typescript +interface PMProvider { + getWorkItem(id: string): Promise; + listWorkItems(filter?): Promise; + createWorkItem(config): Promise; + updateWorkItem(id, updates): Promise; + moveToStatus(id, status): Promise; + addComment(id, text): Promise; + getChecklists(id): Promise; + addLabel(id, label): Promise; + removeLabel(id, label): Promise; + linkPR(id, prUrl): Promise; + // ... more operations +} +``` + +### PMRegistry + +`src/pm/registry.ts` — backward-compatible PM-specific registry. Maps PM type to integration instance. Used by trigger handlers and gadgets that need PM operations. + +### PM Lifecycle Manager + +`src/pm/lifecycle.ts` — orchestrates card/issue state during agent execution: + +- `prepareForAgent()` — add processing label, move to "In Progress" +- `handleSuccess()` — add processed label, move to "In Review", link PR +- `handleFailure()` — add error label, post error comment +- `cleanupProcessing()` — remove processing label + +For the complete step-by-step guide to adding a new integration, see [`src/integrations/README.md`](../../src/integrations/README.md). diff --git a/docs/architecture/07-gadgets.md b/docs/architecture/07-gadgets.md new file mode 100644 index 00000000..3d1ab228 --- /dev/null +++ b/docs/architecture/07-gadgets.md @@ -0,0 +1,119 @@ +# Gadgets + +Gadgets are the tool implementations that agents use to interact with their environment. They are the concrete operations behind capabilities — when an agent definition declares `fs:write`, the capability registry maps that to gadgets like `WriteFile`, `FileSearchAndReplace`, and `FileMultiEdit`. + +## Capability-to-Gadget Mapping + +The `CAPABILITY_REGISTRY` in `src/agents/capabilities/registry.ts` is the single source of truth: + +``` +Agent YAML definition + → capabilities.required + optional + → CAPABILITY_REGISTRY lookup + → gadgetNames[] per capability + → GADGET_CONSTRUCTORS instantiation + → Gadget[] passed to engine +``` + +For **SDK engines** (LLMist): gadgets are instantiated as server-side classes and invoked directly when the LLM makes a tool call. + +For **native-tool engines** (Claude Code, Codex, OpenCode): the engine uses its own built-in tools for file/shell operations. Domain tools (PM, SCM, alerting) are invoked via the `cascade-tools` CLI binary through Bash commands. + +## Built-in Gadgets + +### File system (`fs:read`, `fs:write`) + +| Gadget | Capability | Purpose | +|--------|-----------|---------| +| `ListDirectory` | `fs:read` | List directory contents | +| `ReadFile` | `fs:read` | Read file contents | +| `RipGrep` | `fs:read` | Regex code search | +| `AstGrep` | `fs:read` | AST-based code search | +| `WriteFile` | `fs:write` | Write file contents | +| `FileSearchAndReplace` | `fs:write` | Search and replace in files | +| `FileMultiEdit` | `fs:write` | Multiple edits in a single file | +| `VerifyChanges` | `fs:write` | Verify edits produce expected results | + +All file gadgets validate paths against allowed directories (working directory + `/tmp`). Write gadgets run post-edit diagnostics to catch syntax errors immediately. + +### Shell (`shell:exec`) + +| Gadget | Capability | Purpose | +|--------|-----------|---------| +| `Tmux` | `shell:exec` | Execute shell commands in a tmux session | +| `Sleep` | `shell:exec` | Wait for a specified duration | + +### Session (`session:ctrl`) + +| Gadget | Capability | Purpose | +|--------|-----------|---------| +| `Finish` | `session:ctrl` | Signal task completion | +| `TodoUpsert` | `session:ctrl` | Create or update a todo item | +| `TodoUpdateStatus` | `session:ctrl` | Mark todo as pending/in_progress/done | +| `TodoDelete` | `session:ctrl` | Remove a todo item | + +Todos are stored in `.claude/todos.json` within the repo working directory. + +### PM (`pm:read`, `pm:write`, `pm:checklist`) + +| Gadget | Capability | Purpose | +|--------|-----------|---------| +| `ReadWorkItem` | `pm:read` | Fetch work item details | +| `ListWorkItems` | `pm:read` | List work items with filters | +| `UpdateWorkItem` | `pm:write` | Update work item fields | +| `CreateWorkItem` | `pm:write` | Create new work item | +| `MoveWorkItem` | `pm:write` | Move work item to a status/list | +| `PostComment` | `pm:write` | Post comment on work item | +| `AddChecklist` | `pm:write` | Add checklist to work item | +| `PMUpdateChecklistItem` | `pm:checklist` | Update checklist item status | +| `PMDeleteChecklistItem` | `pm:checklist` | Delete checklist item | + +PM gadgets use the active `PMProvider` from `AsyncLocalStorage` context, making them provider-agnostic. + +### SCM (`scm:read`, `scm:ci-logs`, `scm:comment`, `scm:review`, `scm:pr`) + +| Gadget | Capability | Purpose | +|--------|-----------|---------| +| `GetPRDetails` | `scm:read` | Fetch PR metadata and state | +| `GetPRDiff` | `scm:read` | Get PR diff (additions/deletions) | +| `GetPRChecks` | `scm:read` | Get CI check status | +| `GetCIRunLogs` | `scm:ci-logs` | Download failed CI job logs | +| `PostPRComment` | `scm:comment` | Post issue comment on PR | +| `UpdatePRComment` | `scm:comment` | Update existing comment | +| `GetPRComments` | `scm:comment` | List PR comments | +| `ReplyToReviewComment` | `scm:comment` | Reply to inline review comment | +| `CreatePRReview` | `scm:review` | Submit code review | +| `CreatePR` | `scm:pr` | Create pull request | + +### Alerting (`alerting:read`) + +| Gadget | Capability | Purpose | +|--------|-----------|---------| +| `GetAlertingIssue` | `alerting:read` | Fetch Sentry issue details | +| `GetAlertingEventDetail` | `alerting:read` | Fetch specific event with stacktrace | +| `ListAlertingEvents` | `alerting:read` | List recent events for an issue | + +## cascade-tools CLI + +`src/cli/` — the `cascade-tools` binary + +Native-tool engines cannot invoke gadget classes directly (they run as subprocesses). Instead, they call `cascade-tools` via Bash commands. The CLI is organized by category: + +| Category | Commands | Example | +|----------|----------|---------| +| PM | `cascade-tools pm read-card`, `list-cards`, `update-card`, etc. | `cascade-tools pm read-card --cardId=abc123 --raw-json` | +| SCM | `cascade-tools github get-pr-details`, `get-diff`, `post-comment`, etc. | `cascade-tools github get-pr-details --pr-number=42` | +| Alerting | `cascade-tools sentry get-issue`, `list-events`, etc. | `cascade-tools sentry get-issue --issue-id=12345` | +| Session | `cascade-tools session todo-upsert`, `todo-status`, etc. | `cascade-tools session todo-upsert --id=1 --title="Fix tests"` | + +The `cascade-tools` binary uses a separate oclif config (`bin/cascade-tools.js`) that discovers all non-dashboard commands, while `cascade` discovers only dashboard commands. + +## Session State + +`src/gadgets/sessionState.ts` + +Gadgets communicate session-level state via a shared `SessionState` object: +- Progress comment ID (for updating in-place ack comments) +- GitHub auth mode (which persona is active) +- Read tracking — which files have been read (avoids re-reads) +- Edited files tracking — for post-edit diagnostics diff --git a/docs/architecture/08-config-credentials.md b/docs/architecture/08-config-credentials.md new file mode 100644 index 00000000..700548c5 --- /dev/null +++ b/docs/architecture/08-config-credentials.md @@ -0,0 +1,153 @@ +# Configuration and Credentials + +CASCADE stores all project configuration in PostgreSQL. There are no config files read at runtime — the database is the sole source of truth. + +## Config Provider + +`src/config/provider.ts` + +The config provider loads project configuration from the database with in-memory caching. + +### Loading functions + +| Function | Lookup key | Returns | +|----------|-----------|---------| +| `loadConfig()` | All projects | `CascadeConfig` (all projects in org) | +| `loadProjectConfigByBoardId(boardId)` | Trello board ID | `{ project, config }` | +| `loadProjectConfigByRepo(repo)` | GitHub `owner/repo` | `{ project, config }` | +| `loadProjectConfigByJiraProjectKey(key)` | JIRA project key | `{ project, config }` | +| `loadProjectConfigById(id)` | CASCADE project ID | `{ project, config }` | + +### Caching + +`src/config/configCache.ts` — in-memory cache with TTL populated at service startup. Caches: +- Full config object +- Per-project lookups by board ID, repo, JIRA key +- Invalidated on config writes (via tRPC mutations) + +## Config Schema + +`src/config/schema.ts` + +Project configuration is validated with Zod schemas. Key fields: + +```typescript +interface ProjectConfig { + id: string; + orgId: string; + name: string; + repo?: string; // GitHub owner/repo + baseBranch: string; // default: 'main' + branchPrefix: string; // default: 'feature/' + model: string; // LLM model identifier + maxIterations: number; // default: 50 + watchdogTimeoutMs: number; // default: 30 min + workItemBudgetUsd: number; // default: $5 + progressModel: string; + progressIntervalMinutes: number; // default: 5 + agentEngine?: { default: string; overrides: Record }; + engineSettings?: EngineSettings; + agentEngineSettings?: Record; + runLinksEnabled: boolean; + maxInFlightItems?: number; + // ... PM config (trello/jira), agent models, snapshot settings +} +``` + +## Credential Resolution + +CASCADE uses a two-tier credential resolution system, selecting the appropriate resolver based on execution context. + +### Router / Dashboard context + +Uses `DbCredentialResolver` — reads credentials from the `project_credentials` database table: + +```typescript +getIntegrationCredential(projectId, category, role) // e.g., ('proj1', 'pm', 'api_key') +getAllProjectCredentials(projectId) // All credentials as env-var-key map +``` + +### Worker context + +Uses `EnvCredentialResolver` — reads from `process.env` (pre-loaded by the router's `worker-env.ts`): + +The router builds the worker's environment by: +1. Loading all project credentials from the database +2. Setting them as individual env vars on the Docker container +3. Setting `CASCADE_CREDENTIAL_KEYS` with a comma-separated list of the env var names + +When the worker starts, it detects `CASCADE_CREDENTIAL_KEYS` and uses `EnvCredentialResolver` instead of hitting the database. + +### Auto-selection + +```typescript +// If CASCADE_CREDENTIAL_KEYS is set → worker context (env resolver) +// Otherwise → router/dashboard context (DB resolver) +``` + +### AsyncLocalStorage scoping + +Provider clients use `AsyncLocalStorage` for per-request credential isolation: + +```typescript +// GitHub +await withGitHubToken(token, async () => { + // All GitHub API calls in this scope use this token +}); + +// Trello +await withTrelloCredentials({ apiKey, token }, async () => { + // All Trello API calls use these credentials +}); + +// JIRA +await withJiraCredentials({ email, apiToken, baseUrl }, async () => { + // All JIRA API calls use these credentials +}); +``` + +## Credential Encryption + +`src/db/crypto.ts` + +When `CREDENTIAL_MASTER_KEY` is set (64-char hex string = 32-byte AES-256 key), credentials are encrypted at rest. + +- **Algorithm**: AES-256-GCM with 12-byte random IV and 16-byte auth tag +- **AAD**: `projectId` (additional authenticated data) +- **Storage format**: `enc:v1:::` +- **Transparent**: `writeProjectCredential()` encrypts before DB write; read functions decrypt automatically +- **Opt-in**: Without the env var, credentials are stored and read as plaintext + +### Key management + +```bash +npm run credentials:generate-key # Generate new 32-byte key +npm run credentials:encrypt # Encrypt all existing plaintext credentials +npm run credentials:decrypt # Rollback to plaintext +npm run credentials:rotate-key # Re-encrypt with CREDENTIAL_MASTER_KEY_NEW +``` + +## Integration Roles + +`src/config/integrationRoles.ts` + +Maps provider → category → credential roles. Each role maps a logical name to an env var key: + +```typescript +registerCredentialRoles('trello', 'pm', [ + { role: 'api_key', label: 'API Key', envVarKey: 'TRELLO_API_KEY' }, + { role: 'token', label: 'Token', envVarKey: 'TRELLO_TOKEN' }, +]); +``` + +`hasIntegration()` returns `true` only if all non-optional roles have values stored. + +## Engine Settings + +`src/config/engineSettings.ts` + +Per-engine configuration schemas registered dynamically at bootstrap. Settings are merged at execution time: +1. Project-level `engineSettings` (base) +2. Agent-config-level `agentEngineSettings[agentType]` (override) + +Each engine optionally provides a `getSettingsSchema()` method that returns a Zod schema, registered via `registerEngineSettingsSchema()`. diff --git a/docs/architecture/09-database.md b/docs/architecture/09-database.md new file mode 100644 index 00000000..55ba8f08 --- /dev/null +++ b/docs/architecture/09-database.md @@ -0,0 +1,197 @@ +# Database + +CASCADE uses PostgreSQL with [Drizzle ORM](https://orm.drizzle.team/) for type-safe database access. All data access goes through repository modules — no raw SQL in application code. + +## Schema + +`src/db/schema/` + +```mermaid +erDiagram + organizations ||--o{ projects : "has" + organizations ||--o{ users : "has" + organizations ||--o{ prompt_partials : "has" + + projects ||--o{ project_integrations : "has" + projects ||--o{ project_credentials : "has" + projects ||--o{ agent_configs : "has" + projects ||--o{ agent_definitions : "has" + projects ||--o{ agent_trigger_configs : "has" + projects ||--o{ agent_runs : "tracks" + projects ||--o{ pr_work_items : "maps" + + agent_runs ||--o| agent_run_logs : "has" + agent_runs ||--o{ agent_run_llm_calls : "logs" + agent_runs ||--o| debug_analyses : "analyzed by" + + users ||--o{ sessions : "has" + + organizations { + text id PK + text name + jsonb settings + } + + projects { + text id PK + text org_id FK + text name + text repo + text base_branch + text model + integer max_iterations + integer watchdog_timeout_ms + numeric work_item_budget_usd + jsonb agent_engine + jsonb engine_settings + } + + project_integrations { + uuid id PK + text project_id FK + text category + text provider + jsonb config + jsonb triggers + } + + project_credentials { + uuid id PK + text project_id FK + text env_var_key + text value + } + + agent_configs { + uuid id PK + text project_id FK + text agent_type + text model + integer max_iterations + text agent_engine + jsonb agent_engine_settings + integer max_concurrency + text system_prompt + text task_prompt + } + + agent_trigger_configs { + uuid id PK + text project_id FK + text agent_type + text event + boolean enabled + jsonb parameters + } + + agent_runs { + uuid id PK + text project_id FK + text agent_type + text status + text model + integer llm_iterations + integer gadget_calls + numeric cost_usd + integer duration_ms + text pr_url + text work_item_id + text error + } + + agent_run_logs { + uuid id PK + uuid run_id FK + text cascade_log + text engine_log + } + + agent_run_llm_calls { + uuid id PK + uuid run_id FK + integer call_number + jsonb request + jsonb response + integer input_tokens + integer output_tokens + numeric cost_usd + integer duration_ms + } +``` + +### Key tables + +| Table | Purpose | Key constraints | +|-------|---------|-----------------| +| `organizations` | Multi-tenant organization definitions | — | +| `projects` | Per-project config (repo, model, budget, engine) | `repo` UNIQUE | +| `project_integrations` | Integration configs with category/provider | UNIQUE(`project_id`, `category`) | +| `project_credentials` | Encrypted credentials keyed by env var name | UNIQUE(`project_id`, `env_var_key`) | +| `agent_configs` | Per-agent-type overrides per project | UNIQUE(`project_id`, `agent_type`), `project_id NOT NULL` | +| `agent_definitions` | Agent YAML definitions (built-in + custom) | UNIQUE(`agent_type`) | +| `agent_trigger_configs` | Trigger enable/disable + parameters per project/agent/event | UNIQUE(`project_id`, `agent_type`, `event`) | +| `agent_runs` | Agent execution records with status, cost, duration | Indexed on `project_id`, `status`, `started_at` | +| `agent_run_logs` | Cascade log + engine log per run | One-to-one with `agent_runs` | +| `agent_run_llm_calls` | LLM request/response pairs with token/cost tracking | — | +| `prompt_partials` | Org-scoped prompt template customizations | UNIQUE(`org_id`, `name`) | +| `pr_work_items` | Maps PRs to work items for run-link display | — | +| `webhook_logs` | Raw webhook payloads for debugging | — | +| `users` | Dashboard users (email, bcrypt hash, role) | Org-scoped | +| `sessions` | Session tokens for cookie auth (30-day expiry) | — | +| `debug_analyses` | AI debug analysis results | — | + +## Repositories + +`src/db/repositories/` + +Each table has a dedicated repository providing typed query methods. Key repositories: + +| Repository | Purpose | +|------------|---------| +| `configRepository` | Load full project config from DB, merge integrations + credentials | +| `configMapper` | Transform raw DB rows to typed `ProjectConfig` objects | +| `credentialsRepository` | Credential CRUD with transparent encryption/decryption | +| `runsRepository` | Run lifecycle (create, update status, query by project/status) | +| `runLogsRepository` | Store and retrieve cascade + engine logs | +| `llmCallsRepository` | Log and query LLM request/response pairs | +| `agentConfigsRepository` | Per-agent settings CRUD | +| `agentDefinitionsRepository` | Agent definition CRUD (YAML ↔ JSONB) | +| `agentTriggerConfigsRepository` | Trigger enable/disable/params per project/agent/event | +| `integrationsRepository` | Query integration configuration | +| `projectsRepository` | Project CRUD | +| `organizationsRepository` | Organization CRUD | +| `usersRepository` | User management | +| `partialsRepository` | Prompt partial CRUD | +| `prWorkItemsRepository` | PR ↔ work item mapping | +| `webhookLogsRepository` | Webhook audit trail | +| `debugAnalysisRepository` | Debug analysis results | + +## Connection Management + +`src/db/client.ts` + +- `DatabaseContext` class wraps Drizzle instance + `pg.Pool` +- `getDb()` returns a singleton, lazily initialized from `DATABASE_URL` +- SSL support with optional CA certificate (`DATABASE_CA_CERT`) +- In workers, the DB connection is initialized eagerly (before env scrub removes `DATABASE_URL`) + +## Migrations + +Migrations are hand-written SQL files in `src/db/migrations/`, tracked by drizzle-kit's journal (`meta/_journal.json`). + +### Adding a migration + +1. Create `src/db/migrations/NNNN_description.sql` +2. Add entry to `src/db/migrations/meta/_journal.json` with unique `when` timestamp and `tag` matching filename +3. Run `npm run db:migrate` + +### Scripts + +| Command | Purpose | +|---------|---------| +| `npm run db:migrate` | Apply pending migrations | +| `npm run db:generate` | Generate migration SQL from schema changes | +| `npm run db:push` | Push schema directly (dev only) | +| `npm run db:studio` | Open Drizzle Studio | +| `npm run db:seed` | Seed from `config/projects.json` | +| `npm run db:bootstrap-journal` | Register existing migrations (one-time for `push`-initialized DBs) | diff --git a/docs/architecture/10-resilience.md b/docs/architecture/10-resilience.md new file mode 100644 index 00000000..8123e192 --- /dev/null +++ b/docs/architecture/10-resilience.md @@ -0,0 +1,141 @@ +# Resilience + +CASCADE runs long-lived agent sessions (up to 30+ minutes) against external LLM APIs. The resilience layer ensures reliable operation through watchdog timers, concurrency controls, rate limiting, retry strategies, and loop prevention. + +## Watchdog + +`src/utils/lifecycle.ts` + +Each worker container has a configurable watchdog timer that force-exits the process if the agent exceeds its timeout: + +- **Timeout**: Configurable per project via `watchdogTimeoutMs` (default: 30 minutes) +- **Cleanup**: A cleanup callback is registered via `setWatchdogCleanup()` and called before force exit (with a 10-second cap) +- **Router-side buffer**: The router's worker manager adds a 2-minute buffer on top of the worker watchdog before considering a container orphaned + +```typescript +startWatchdog(timeoutMs, () => { + // cleanup callback: finalize run record, upload logs +}); +``` + +## Concurrency Controls + +### Work-item lock + +`src/router/work-item-lock.ts` + +Prevents multiple agents from working on the same card/issue simultaneously. The lock is in-memory (router process) with TTL expiry. + +- Checked at webhook processing time (step 8 of the pipeline) +- Marked when job is enqueued, cleared when worker completes +- Key: `(projectId, workItemId, agentType)` + +### Agent-type concurrency limit + +`src/router/agent-type-lock.ts` + +Configurable `max_concurrency` per agent type per project (set via `agent_configs.max_concurrency`). Prevents too many instances of the same agent type running simultaneously. + +- Tracks enqueued + running counts +- Blocks new jobs when limit reached +- Includes a "recently dispatched" window to prevent race conditions between enqueueing and worker startup + +### Max in-flight items + +`projects.max_in_flight_items` — project-level cap on total concurrent agent runs. Checked during trigger dispatch. + +### BullMQ concurrency + +The router's worker manager limits how many Docker containers run in parallel via `routerConfig.maxWorkers`. + +## Rate Limiting + +`src/config/rateLimits.ts` + +Proactive, model-specific rate limits prevent hitting LLM provider quotas. Configured per model with safety margins (80-90% of actual limits): + +- **RPM** (requests per minute) +- **TPM** (tokens per minute) +- **Daily token limit** + +Rate limits are enforced by the LLMist SDK for `sdk`-archetype engines. Native-tool engines (Claude Code, Codex) handle rate limiting internally. + +## Retry Strategy + +`src/config/retryConfig.ts` + +Handles transient LLM API failures: + +- **5 retry attempts** with exponential backoff (1s base, 60s max) +- **Jitter** randomization prevents thundering herd +- **Respects `Retry-After` headers** (capped at 2 minutes) +- **Custom detection** for undici/fetch stream termination errors +- **Logging** and Sentry breadcrumbs on each retry and exhaustion + +Retries cover: HTTP 429 (rate limit), 5xx (server errors), timeouts, and connection failures. + +## Context Compaction + +`src/config/compactionConfig.ts` + +Prevents context window overflow during long-running agent sessions: + +- **Trigger**: 80% context usage +- **Target**: Reduce to 50% +- **Preserve**: 5 most recent turns +- **Strategy**: Hybrid summarization + sliding window +- Summarization preserves: task goals, key decisions, discovered facts, errors, and failed approaches (to avoid repeating them) +- Clears read-tracking state after compaction + +## Iteration Hints + +`src/config/hintConfig.ts` + +Ephemeral trailing messages showing the agent its iteration budget: + +- Displayed at configurable thresholds +- Urgency warnings at >80%: "ITERATION BUDGET: 17/20 - Only 3 remaining!" +- Helps the LLM prioritize and wrap up before hitting limits + +## Loop Prevention + +### Bot identity detection + +`src/github/personas.ts` — `isCascadeBot(login)` + +Both GitHub persona usernames (implementer + reviewer) are resolved and cached. Event handlers check if the event author is a known persona to prevent self-triggered loops: + +- `respond-to-review` only fires when the **reviewer** persona submits `changes_requested` +- `respond-to-pr-comment` skips @mentions from **any** known persona +- Trello/JIRA handlers check their bot member/account IDs similarly + +### Self-authored event filtering + +Each `RouterPlatformAdapter.isSelfAuthored()` checks the webhook payload author against known bot identities. Self-authored events are logged and discarded at step 4 of the webhook pipeline. + +## Security + +### Environment scrubbing + +`src/utils/envScrub.ts` — `scrubSensitiveEnv()` + +After the worker initializes its DB connection and caches config, sensitive env vars (`DATABASE_URL`, master keys) are removed from `process.env`. This prevents LLM-generated shell commands (executed by agents) from accessing database credentials. + +### Credential encryption at rest + +See [08-config-credentials](./08-config-credentials.md) — AES-256-GCM encryption with transparent encrypt/decrypt. + +## Orphan Cleanup + +`src/router/orphan-cleanup.ts` + +Periodic scan for Docker containers that outlived their expected lifetime (watchdog timeout + buffer). Orphans are killed and their run records marked as failed. + +## Snapshot Management + +`src/router/snapshot-manager.ts`, `src/router/snapshot-cleanup.ts` + +Optional container snapshots for warm restarts: +- After a worker completes, its container state can be snapshotted +- Subsequent runs for the same project reuse the snapshot (faster startup, cached dependencies) +- Snapshots have a configurable TTL (`snapshotTtlMs`) and are cleaned up periodically diff --git a/docs/plans/001-pr-review-correctness/1-checkout-and-pagination.md.done b/docs/plans/001-pr-review-correctness/1-checkout-and-pagination.md.done new file mode 100644 index 00000000..0ce14278 --- /dev/null +++ b/docs/plans/001-pr-review-correctness/1-checkout-and-pagination.md.done @@ -0,0 +1,245 @@ +--- +id: 001 +slug: pr-review-correctness +plan: 1 +plan_slug: checkout-and-pagination +level: plan +parent_spec: docs/specs/001-pr-review-correctness.md +depends_on: [] +status: done +--- + +# 001/1: Checkout Correctness & Pagination + +> Part 1 of 2 in the 001-pr-review-correctness plan. See [parent spec](../../specs/001-pr-review-correctness.md). + +## Summary + +This plan delivers the foundation: the worker correctly places the working tree at the PR head commit for **every** PR (internal or external-fork), it surfaces every step failure as a failed run instead of silently continuing, and it enumerates **every** changed file in the PR rather than the first 100. It also paginates every other GitHub endpoint that the review setup pipeline touches, so we don't carry the same latent bug class forward. + +After this plan ships, the specific PR #1092 hallucination class is substantially blocked even before plan 2 lands: with the working tree on the correct commit, any `Read` the agent does to "verify" a claim returns the actual PR-branch content, and the file enumeration is no longer truncated. What plan 1 does **not** fix is the 25K-token full-file-contents cap on the agent's pre-fetched view — that's plan 2. + +**Components delivered:** +- New `fetchAndCheckoutPR` helper in `src/agents/shared/repository.ts` using `+refs/pull/N/head:refs/remotes/pr/N` fetch spec, detached checkout, and `git rev-parse HEAD` SHA verification against the PR's head SHA. +- `setupRepository` and `refreshSnapshotWorkspace` reworked to call the helper when `prNumber` is set; both now throw on any non-zero git exit code. +- `SetupRepositoryOptions` and `AgentInput` extended with `prNumber` and `prHeadSha` (`prBranch` retained for human-readable logging only). +- `AgentInput` populated with `prHeadSha` at every trigger handler that constructs it for review runs. +- All seven `per_page: 100` call sites in `src/github/client.ts` converted to paginate to completion. +- New structured log fields: `Fetched PR ref`, `Resolved HEAD SHA`, `Total changed files`. + +**Deferred to plan 2:** +- Pre-fetch swap from full file contents to compact diffs. +- Skipped-file structured contract with the agent. +- Agent prompt template updates for the new context shape. +- Logging for `included / skipped + per-skip reason` (plan 1 only logs total file count from pagination). + +--- + +## Spec ACs satisfied by this plan + +- Spec AC #1 (External-fork → working tree at PR head) — **full** +- Spec AC #2 (>100 files → all enumerated) — **full** +- Spec AC #3 (Setup failure → run failed) — **full** +- Spec AC #4 (HEAD SHA mismatch → run failed before review) — **full** +- Spec AC #7 (Other paginated endpoints read to completion) — **full** +- Spec AC #8 (Operator log visibility) — **partial** (this plan adds: ref fetched, resolved HEAD SHA, total changed-file count; plan 2 adds: included vs skipped counts with per-skip reasons) +- Spec AC #9 (PR #1092 reproduction case produces no fabrications) — **partial** (this plan delivers correct working tree + complete file enumeration; plan 2 delivers the diff-shape that prevents partial-context hallucinations on very large PRs) + +--- + +## Depends On + +- _None._ This is the foundation plan. + +--- + +## Detailed Task List (TDD) + +### 1. Type extensions + +**Divergence from initial draft**: `AgentInput.headSha` already exists in `src/types/index.ts` and is widely populated by trigger handlers from `payload.pull_request.head.sha`. We reuse it instead of introducing a redundant `prHeadSha` field. Inside the helper signature we name the parameter `prHeadSha` for clarity at call site, fed from `input.headSha`. + +**Tests first** (extend `tests/unit/agents/shared/repository.test.ts`): +- `SetupRepositoryOptions accepts prNumber and prHeadSha` — type-only test using `expectTypeOf` from vitest. + +**Implementation** (`src/agents/shared/repository.ts`): +- Extend `SetupRepositoryOptions` interface: add `prNumber?: number` and `prHeadSha?: string`. + +**Implementation** (`src/types/index.ts`): +- Update the comment on `headSha` from "PR context fields for check-failure flow" to "PR's head SHA at trigger time — used for check-failure flow AND for post-checkout HEAD verification". No structural change. + +### 2. Helper: `fetchAndCheckoutPR` + +**Tests first** (`tests/unit/agents/shared/repository.test.ts` — extend existing 528-line file): +- `fetchAndCheckoutPR fetches refs/pull/N/head with the + force-update prefix` — mock `runCommand`; assert the first call's args are `['fetch', 'origin', '+refs/pull/1092/head:refs/remotes/pr/1092']`. +- `fetchAndCheckoutPR checks out detached pr/N after fetch` — assert second call: `['checkout', '--detach', 'pr/1092']`. +- `fetchAndCheckoutPR throws when fetch returns non-zero exit code` — mock fetch to return `{exitCode: 128, stderr: 'fatal: ...'}`; expect rejection with stderr substring in message. +- `fetchAndCheckoutPR throws when checkout returns non-zero exit code` — mock fetch ok, checkout fail; expect rejection. +- `fetchAndCheckoutPR verifies HEAD SHA matches expected when prHeadSha provided` — mock `git rev-parse HEAD` to return matching SHA; expect resolution. +- `fetchAndCheckoutPR throws on HEAD SHA mismatch` — `rev-parse` returns different SHA; expect rejection with both SHAs in message. +- `fetchAndCheckoutPR skips SHA check when prHeadSha is undefined` — no `rev-parse` call should occur; resolves cleanly. +- `fetchAndCheckoutPR logs fetched ref and resolved HEAD SHA on success` — assert `log.info` was called with both fields. + +**Implementation** (`src/agents/shared/repository.ts`): +- New `async function fetchAndCheckoutPR(repoDir: string, prNumber: number, prHeadSha: string | undefined, scmProvider: 'github' | 'gitlab' | undefined, log: AgentLogger): Promise`. +- Sequence: `git fetch origin ` → `git checkout --detach pr/${prNumber}` → optionally `git rev-parse HEAD` → compare with `prHeadSha`. +- The `` is provider-aware: GitHub → `+refs/pull/${prNumber}/head:refs/remotes/pr/${prNumber}`. The GitLab branch is **out of scope this plan** (depends on PR #1092 landing); for now, when `scmProvider !== 'github'`, throw an explicit `Error('fetchAndCheckoutPR: only GitHub is currently supported; GitLab support follows PR #1092 merge')`. The provider parameter is in the signature so the GitLab extension is a one-line follow-up. +- Each step: check `result.exitCode !== 0`, throw `new Error(...)` with stderr (last 500 chars). +- On HEAD SHA mismatch: throw `new Error(\`HEAD SHA mismatch after PR checkout: expected ${prHeadSha}, got ${actualSha}\`)`. +- On success: `log.info('PR checked out', { prNumber, ref: 'refs/pull/${prNumber}/head', headSha: prHeadSha ?? '(unverified)' })`. + +### 3. `setupRepository` cold-start path rework + +**Tests first** (extend `tests/unit/agents/shared/repository.test.ts`): +- `setupRepository calls fetchAndCheckoutPR when prNumber is set` — mock the helper; verify it's called with the right args. +- `setupRepository does not call git checkout ` — verify no `runCommand` call with args matching `['checkout', 'claude/cranky-johnson']` or similar. +- `setupRepository propagates fetchAndCheckoutPR errors` — helper throws; expect `setupRepository` to reject. +- `setupRepository works when prNumber is not set (non-PR runs)` — uses base branch from clone, no PR-ref fetch. + +**Implementation** (`src/agents/shared/repository.ts:151-155`): +- Replace the existing `if (prBranch) { ... await runCommand('git', ['checkout', prBranch], ...) }` block with `if (prNumber) { await fetchAndCheckoutPR(repoDir, prNumber, prHeadSha, log) }`. +- Update destructuring at line 111: `const { project, log, agentType, prNumber, prHeadSha, warmTsCache } = options;` (drop `prBranch` from active use; can still log it earlier if present). + +### 4. `refreshSnapshotWorkspace` (snapshot-reuse path) rework + +**Tests first** (extend `tests/unit/agents/shared/repository.test.ts`): +- `refreshSnapshotWorkspace fetches origin then PR ref when prNumber is set` — mock `runCommand`; assert sequence. +- `refreshSnapshotWorkspace throws on non-zero git fetch exit (no warn-and-continue)` — assert rejection, not warning. +- `refreshSnapshotWorkspace throws on non-zero git reset exit` — assert rejection. +- `refreshSnapshotWorkspace uses fetchAndCheckoutPR when prNumber is set instead of branch checkout` — assert helper is called. +- `refreshSnapshotWorkspace works for non-PR runs (uses baseBranch fetch + reset)` — assert legacy path. + +**Implementation** (`src/agents/shared/repository.ts:49-88`): +- Change all three "warn and continue" blocks (lines 62-67, 71-76, 80-85) to throw on non-zero exit code with the stderr in the error message. +- When `prNumber` is set, call `fetchAndCheckoutPR(repoDir, prNumber, prHeadSha, log)` instead of the existing `git reset --hard origin/${branch}` + `git checkout branch` pair. +- When `prNumber` is not set (non-PR runs), keep existing behavior but with throw-on-failure semantics. +- Pass `prHeadSha` through `refreshSnapshotWorkspace`'s signature. + +### 5. Plumb `prNumber` + `prHeadSha` through `setupRepository` callers + +**Tests first** (extend `tests/unit/backends/adapter.test.ts` if it exists, else create): +- `executeWithEngine forwards prNumber and headSha from input to setupRepository as prHeadSha` — mock `setupRepository`; verify call args include `prNumber: input.prNumber` and `prHeadSha: input.headSha`. + +**Implementation** (`src/backends/adapter.ts:27-33`): +- Update the `setupRepository({ ... })` call to forward `prNumber: input.prNumber` and `prHeadSha: input.headSha`. + +### 6. Verify `headSha` is populated on `AgentInput` at every PR trigger site + +**Divergence from initial draft**: since we reuse `headSha` (already populated by most PR triggers), this task narrows to a verification + gap-fill exercise rather than a wholesale field addition. + +**Tests first** (extend per-trigger test files in `tests/unit/triggers/github/`): +- For each PR-related trigger handler that constructs an `AgentInput`-shaped result: assert the test asserts `result.agentInput.headSha === payload.pull_request.head.sha` (or equivalent webhook field). +- Add a test only where the assertion is missing today. + +**Implementation**: +- `grep -rn "prNumber:" src/triggers/` — for every site that sets `prNumber` on an `AgentInput`-shaped object, confirm `headSha` is also set (sourced from the webhook payload's `pull_request.head.sha`, `check_suite.head_sha`, or equivalent). +- Add `headSha` where missing. +- Preliminary list (verify via grep during implementation): `src/triggers/github/check-suite-success.ts`, `src/triggers/github/pr-opened.ts`, `src/triggers/github/pr-review-submitted.ts`, `src/triggers/github/review-requested.ts`, `src/triggers/github/pr-comment-mention.ts`, `src/triggers/github/check-suite-failure.ts`. + +### 7. Pagination of all paginated GitHub client endpoints + +**Tests first** (`tests/unit/github/client.test.ts` — new file if absent, else extend): +- `getPRDiff paginates beyond 100 files` — stub Octokit to return 100 on page 1, 29 on page 2, `[]` on page 3; assert returned array has 129 entries. +- `getPRDiff stops when a page returns fewer than per_page` — stub to return 100, then 50; assert exactly 2 page requests, 150 total entries. +- `getPRReviewComments paginates to completion` — analogous test. +- `getPRIssueComments paginates to completion` — analogous test. +- `getPRReviews paginates to completion` — analogous test. +- `getCheckSuiteStatus paginates listWorkflowRunsForRepo` — stub multiple pages; assert all runs returned. +- `getCheckSuiteStatus paginates listJobsForWorkflowRun for each run` — assert per-run jobs list is complete. +- `getFailedWorkflowJobs paginates listWorkflowRunsForRepo and listJobsForWorkflowRun` — analogous. + +**Implementation** (`src/github/client.ts`): +- Replace each `const { data } = await getClient().({ ..., per_page: 100 })` with the Octokit `paginate` helper: `const data = await getClient().paginate(getClient()., { ..., per_page: 100 })`. +- Affected lines (per Phase 5 reconnaissance): 151 (`getPRReviewComments`), 272 (`getPRIssueComments`), 295 + 305 (`getCheckSuiteStatus` — 2 sites), 342 (`getPRDiff`), 428 + 446 (`getFailedWorkflowJobs` — 2 sites). Also page through `getPRReviews` at line 243-style call (verify exact line during implementation). +- For the `Promise.all(runs.map(...))` patterns at lines 299-308 and 439-450, the inner per-run jobs call also needs `paginate`. + +### 8. Structured logging for setup observability + +**Tests first** (extend `tests/unit/agents/shared/repository.test.ts`): +- `setupRepository logs fetched-ref, resolved-HEAD-SHA when PR checkout succeeds` — assert log.info call with structured fields. + +**Implementation** (`src/agents/shared/repository.ts`, `src/agents/definitions/contextSteps.ts`): +- After successful `fetchAndCheckoutPR`, log `Fetched PR ref` and `Resolved HEAD SHA` (already covered by helper in step 2). +- After `getPRDiff` returns the (now complete) file list at `contextSteps.ts:170`, log `Total changed files` with the count. Replace existing `Reading PR file contents { fileCount: prDiff.length }` log message — same field, but now after pagination the count is accurate. + +--- + +## Test Plan + +### Unit tests + +- [ ] `tests/unit/types/agentInput.test.ts`: 2 type-only tests +- [ ] `tests/unit/agents/shared/repository.test.ts`: ~14 new tests (`fetchAndCheckoutPR` helper + `setupRepository` + `refreshSnapshotWorkspace`) +- [ ] `tests/unit/backends/adapter.test.ts`: 1 new test (forwarding prNumber/prHeadSha) +- [ ] `tests/unit/github/client.test.ts`: ~9 new tests (one per paginated endpoint plus boundary cases) +- [ ] Per-trigger test files under `tests/unit/triggers/github/`: ~5-7 new tests (one per handler that builds `AgentInput` for PR runs) + +### Integration tests + +- [ ] _(Optional)_ `tests/integration/agents/external-pr-checkout.test.ts`: stand up a bare git repo with a `refs/pull/42/head` ref pointing to a commit not on any local branch; run `setupRepository` with `prNumber: 42`; assert the working tree HEAD matches the expected SHA. + +### Acceptance tests + +- [ ] Per-plan AC #1: external-fork PR test repo → checkout succeeds at PR head. +- [ ] Per-plan AC #2: 129-file fixture PR → `getPRDiff` returns 129 entries. +- [ ] Per-plan AC #3: stub `runCommand` to fail → run rejects with descriptive error. +- [ ] Per-plan AC #4: stub `git rev-parse` to return wrong SHA → rejects with both SHAs in error. +- [ ] Per-plan AC #5: every paginated endpoint test green. + +--- + +## Acceptance Criteria (per-plan, testable) + +1. Given an `AgentInput` with `prNumber: 1092` and `prHeadSha: '96f5136...'`, `setupRepository` produces a working tree whose `git rev-parse HEAD` equals `96f5136...`, regardless of whether `claude/cranky-johnson` exists on `origin`. +2. Given a PR with 129 changed files, `getPRDiff` returns an array of 129 entries. +3. Given any non-zero exit code from any git command in the setup pipeline, `setupRepository` rejects with an error message containing the failing command's stderr. +4. Given a `prHeadSha` that does not match the post-checkout `git rev-parse HEAD` output, `setupRepository` rejects with an error message containing both SHAs. +5. Each of the seven previously-truncated `per_page: 100` call sites in `src/github/client.ts` returns the full result set across multiple pages when stubbed to return non-trivial multi-page data. +6. The worker log for a successful PR review setup contains structured entries for: the fetched ref (e.g. `refs/pull/1092/head`), the resolved HEAD SHA, and the total changed-files count. +7. All new and modified code has corresponding tests. +8. `npm run build` passes. +9. `npm test` passes (unit suite). +10. `npm run lint` and `npm run typecheck` pass. +11. All documentation listed in Documentation Impact has been updated. + +--- + +## Documentation Impact (this plan only) + +| File | Change | +|---|---| +| `CLAUDE.md` | Add a paragraph in the "Development" section noting that PR review runs use `refs/pull/N/head` and verify HEAD SHA after checkout; document the fail-loud contract for setup errors. | +| `CHANGELOG.md` | Entry: `fix(review): correctly check out external-fork PRs and paginate all GitHub list endpoints (#001/1)`. Brief description of behavior change visible in run logs. | + +--- + +## Out of Scope (this plan) + +- **GitLab MR ref support in `fetchAndCheckoutPR`** — depends on PR #1092 (suda's GitLab SCM provider) landing. Once it merges, extending the helper to use `refs/merge-requests/N/head` for `scmProvider === 'gitlab'` is a one-line follow-up (the signature already accepts the parameter). +- Pre-fetch swap from full file contents to compact diffs (→ plan 2). +- Skipped-file structured contract with the agent (→ plan 2). +- Agent prompt template updates for the new context shape (→ plan 2). +- Logging of `included / skipped + per-skip reason` (→ plan 2). +- Ops-layer detection of historical runs where final HEAD ≠ PR head (spec OOS). +- Agentic multi-hop context gathering (spec OOS). +- Codebase-wide indexing (spec OOS). +- Running review against PR's merge commit instead of head (spec OOS). +- Multi-agent review orchestration (spec OOS). +- Changes to the review agent's model, prompt template, or output format (spec OOS — but plan 2 makes minor prompt changes that are scoped there). + +--- + +## Progress + + +- [x] AC #1 — refs/pull/N/head fetch + detached checkout verified by `setupRepository fetches and checks out PR via refs/pull/N/head when prNumber is provided` and `setupRepository — snapshot-reuse path uses fetchAndCheckoutPR (refs/pull/N/head) when prNumber is provided` +- [x] AC #2 — `getPRDiff uses octokit paginate (paginates beyond 100 files)` returns 129 +- [x] AC #3 — `setupRepository throws and stops when PR fetch fails (no silent continuation)`, snapshot-reuse `throws (no longer warns-and-continues) when git fetch/reset/checkout exits non-zero` +- [x] AC #4 — `setupRepository throws on HEAD SHA mismatch` (cold + snapshot paths) +- [x] AC #5 — `pagination — all paginated endpoints use octokit paginate` describe block (5 endpoints + 2 inner-loop calls) +- [x] AC #6 — helper logs `PR checked out` with `prNumber`, `ref`, `headSha`; `Total changed files in PR` log entry in contextSteps +- [x] AC #7 — TDD discipline; new tests for every behavior change +- [x] AC #8 — `npm run build` passes +- [x] AC #9 — `npm test` passes (7513 tests, 0 failures) +- [x] AC #10 — typecheck ✅; lint ✅ after `npm ci` synced biome to 2.4.10 (stale `node_modules` was holding 1.9.4 against a v2-syntax `biome.json`) +- [x] AC #11 — `CLAUDE.md` updated with new "PR Checkout (worker)" subsection; new `CHANGELOG.md` created with an Unreleased entry describing the fix diff --git a/docs/plans/001-pr-review-correctness/2-context-rework.md.done b/docs/plans/001-pr-review-correctness/2-context-rework.md.done new file mode 100644 index 00000000..98f0943d --- /dev/null +++ b/docs/plans/001-pr-review-correctness/2-context-rework.md.done @@ -0,0 +1,234 @@ +--- +id: 001 +slug: pr-review-correctness +plan: 2 +plan_slug: context-rework +level: plan +parent_spec: docs/specs/001-pr-review-correctness.md +depends_on: [1-checkout-and-pagination.md] +status: done +--- + +# 001/2: Context Pre-Fetch Rework — Compact Diffs & Skipped-File Contract + +> Part 2 of 2 in the 001-pr-review-correctness plan. See [parent spec](../../specs/001-pr-review-correctness.md). + +## Summary + +This plan re-shapes the data the review agent receives. The current pre-fetch echoes **full file contents** of changed files up to a 25K-token cap; this plan replaces it with **compact per-file diffs** (using GitHub's `file.patch` from the now-paginated diff endpoint), which scales with PR size rather than repo size and aligns with industry best practice for LLM code review. + +It also delivers the **skipped-file contract**: whenever any changed file's diff or content cannot be included in the agent's context (over budget, deleted, binary, missing patch), the agent receives an explicit, structured list of those filenames with a per-file reason and prompt guidance to fetch them on demand via its existing `Read`, `Grep`, and `Bash` (`gh pr diff`) tools. + +After this plan ships, the spec is fully delivered: external-fork PRs of any size produce reviews that reflect the actual change set, and the agent has both the compact view it needs and clear awareness of what it doesn't have. + +**Components delivered:** +- `readPRFileContents` removed; replaced by `extractPRDiffs` that produces per-file compact diffs from `PRDiffFile.patch`. +- `REVIEW_FILE_CONTENT_TOKEN_LIMIT` (full-file budget) replaced by `REVIEW_DIFF_CONTEXT_TOKEN_LIMIT` (compact-diff budget; larger ceiling appropriate for diff content). +- New `SkippedFile` type and structured "skipped files" injection format. +- `fetchPRContextStep` rewritten to inject diffs + skipped-file list (instead of the current full-file inclusion loop). +- `review.yaml` agent definition updated: prompt instructs the agent on the new context shape, names the `SKIPPED FILES` injection, and tells it to fetch those files via `Read` or `gh pr diff` when relevant. +- `formatPRDiff` enhanced to render compact per-file headers consistent with the new diff context. +- Logging: `included`, `skipped`, and per-skip `reason` surfaced in the run log. + +**Deferred to follow-up specs (already spec OOS):** +- Agentic multi-hop context gathering (reading dependent files automatically). +- Codebase-wide indexing. +- Adaptive diff-vs-full-file based on file size or change ratio. + +--- + +## Spec ACs satisfied by this plan + +- Spec AC #5 (Primary view = compact diffs) — **full** +- Spec AC #6 (Skipped files → structured list + guidance) — **full** +- Spec AC #8 (Operator log visibility) — **partial** (this plan adds: included vs skipped counts with per-skip reasons; plan 1 already added: ref, HEAD SHA, total changed-file count) +- Spec AC #9 (PR #1092 reproduction case produces no fabrications) — **partial** (this plan delivers the diff-shape that prevents partial-context hallucinations on very large PRs; plan 1 already delivered correct working tree + complete file enumeration) + +--- + +## Depends On + +- Plan 1 (`checkout-and-pagination`) — provides the complete, paginated changed-files list that this plan iterates over to produce compact diffs. + +--- + +## Detailed Task List (TDD) + +### 1. Configuration: replace token-limit constant + +**Tests first** (`tests/unit/config/reviewConfig.test.ts` — new file or extend): +- `REVIEW_DIFF_CONTEXT_TOKEN_LIMIT exists and is a positive number` — sanity import test. +- `the constant is sized for diff content not full files` — assert it is at least 100_000 (diffs are dense, model is opus 4.6 with 1M-token window). + +**Implementation** (`src/config/reviewConfig.ts`): +- Remove `export const REVIEW_FILE_CONTENT_TOKEN_LIMIT = 25_000;`. +- Add `export const REVIEW_DIFF_CONTEXT_TOKEN_LIMIT = 200_000;` (number is illustrative; tune via load test in implementation; goal: comfortably fit median PR diff content). + +### 2. New types: `SkippedFile` and `PRDiffContext` + +**Tests first** (`tests/unit/agents/shared/prFormatting.test.ts` — extend existing 287-line file): +- `SkippedFile shape includes filename and reason` — type test using `expectTypeOf`. +- `PRDiffContext shape includes included diffs and skipped list` — type test. + +**Implementation** (`src/agents/shared/prFormatting.ts`): +- Add type: + ```typescript + export interface SkippedFile { + filename: string; + reason: 'over-budget' | 'no-patch' | 'binary' | 'deleted' | 'patch-too-large'; + } + export interface PRDiffContext { + included: Array<{ path: string; status: PRDiffFile['status']; diff: string }>; + skipped: SkippedFile[]; + } + ``` + +### 3. New function: `extractPRDiffs` + +**Tests first** (extend `tests/unit/agents/shared/prFormatting.test.ts`): +- `extractPRDiffs returns a diff for each PR file with a patch` — input: `[{filename: 'a.ts', patch: '@@ ...'}, {filename: 'b.ts', patch: '@@ ...'}]`; expect both in `included`. +- `extractPRDiffs marks files with status "removed" as skipped with reason "deleted"` — verify. +- `extractPRDiffs marks files without a patch (binary, large blob) as skipped with reason "no-patch"` — verify. +- `extractPRDiffs marks files with a patch larger than per-file cap as skipped with reason "patch-too-large"` — verify. +- `extractPRDiffs respects total-budget cap; once exceeded, remaining files go to skipped with reason "over-budget"` — verify ordering and cutoff. +- `extractPRDiffs returns deterministic ordering` — same input → same output (stable sort). +- `extractPRDiffs handles empty PR diff input` — returns `{included: [], skipped: []}`. + +**Implementation** (`src/agents/shared/prFormatting.ts`): +- Replace `readPRFileContents` with `extractPRDiffs(prDiff: PRDiffFile[]): PRDiffContext`. +- Iterate over `prDiff`, applying skip rules in order: deleted → no patch → patch over per-file cap → would exceed budget. Otherwise add to `included`. +- No filesystem reads — operates purely on the API response from `getPRDiff` (which already includes `patch` strings). +- Per-file compact diff format: a short header (`### {filename} ({status}, +N -M)`) followed by the unified diff hunk(s) from `file.patch`. + +### 4. Pre-fetch step rewrite + +**Tests first** (extend `tests/unit/agents/definitions/contextSteps.test.ts` if it exists, else create): +- `fetchPRContextStep injects compact diff context (not full files)` — mock `getPRDiff` to return PR-shaped data; assert one injection labeled "Pre-fetched PR diff context" containing per-file headers. +- `fetchPRContextStep injects a SKIPPED FILES section when files are skipped` — mock with files exceeding budget; assert injection labeled "Skipped files (fetch via Read or gh pr diff if relevant)" containing the filenames + reasons. +- `fetchPRContextStep does NOT inject the SKIPPED FILES section when nothing is skipped` — assert only the diff injection is present. +- `fetchPRContextStep logs included and skipped counts and per-skip reasons` — assert structured `log.info` call. + +**Implementation** (`src/agents/definitions/contextSteps.ts:198-204` and surrounding): +- Remove the `readPRFileContents` call and the loop at lines 206+ that pushes per-file injections. +- Call `extractPRDiffs(prDiff)` once. +- Push **one** injection containing the compact diff context (all included diffs in one block, with per-file headers). +- If `skipped.length > 0`, push a second injection labeled "Skipped files" that lists each filename + reason in a structured format the prompt template can reference. +- Log: `params.logWriter('INFO', 'PR context prepared', { included: ctx.included.length, skipped: ctx.skipped.length, skipReasons: countByReason(ctx.skipped) })`. + +### 5. Agent prompt template updates + +**Tests first** (`tests/unit/agents/definitions/review.yaml.test.ts` — new file, or extend existing prompt-validation tests): +- `review.yaml prompt mentions compact-diff context` — load template, assert it references the diff context name. +- `review.yaml prompt mentions SKIPPED FILES section and how to fetch` — assert presence of guidance text. +- `review.yaml prompt does not reference the old "full file contents" pre-fetch` — assert absence. + +**Implementation** (`src/agents/definitions/review.yaml`): +- Update the agent's instructions section to: + - Describe the new context shape: "You will receive compact per-file diffs labeled `### {filename}`." + - Describe the skipped-file contract: "If a `SKIPPED FILES` injection is present, those files were omitted to keep the context compact. When any of them is relevant to your review, fetch the patch via `gh pr diff -- ` or read the post-PR file content with `Read`." + - Remove any text that references "full file contents" pre-fetch (if present). +- Keep model, output format, and tool list unchanged. + +### 6. Cleanup: remove the old `readPRFileContents` + +**Tests first**: +- `readPRFileContents is no longer exported from prFormatting` — type-only test asserts the export is gone. +- `tests/unit/agents/shared/prFormatting.test.ts` — delete or rewrite the existing tests that exercised `readPRFileContents`; replace with `extractPRDiffs` tests in step 3. + +**Implementation**: +- Remove `readPRFileContents` and `PRFileContents` from `src/agents/shared/prFormatting.ts`. +- `grep -rn "readPRFileContents\|PRFileContents" src/ tests/` — confirm no remaining references; if any, update them. + +### 7. Logging for observability completion + +**Tests first** (extend `tests/unit/agents/definitions/contextSteps.test.ts`): +- `fetchPRContextStep emits a structured log entry containing included, skipped, and skipReasons map` — assert exact structure. + +**Implementation** (`src/agents/definitions/contextSteps.ts`): +- Replace `params.logWriter('INFO', 'File contents loaded', {...})` (the current line ~201) with the new `'PR context prepared'` log entry described in step 4. + +--- + +## Test Plan + +### Unit tests + +- [ ] `tests/unit/config/reviewConfig.test.ts`: 2 tests +- [ ] `tests/unit/agents/shared/prFormatting.test.ts`: ~10 new tests (`extractPRDiffs` happy path + each skip reason + budget cutoff + empty input + ordering) +- [ ] `tests/unit/agents/definitions/contextSteps.test.ts`: 4 new tests (diff injection, skipped-files injection, no-skipped path, logging) +- [ ] `tests/unit/agents/definitions/review.yaml.test.ts`: 3 prompt-template tests +- [ ] Existing `prFormatting.test.ts` tests for `readPRFileContents`: deleted (no longer applicable) + +### Integration tests + +- [ ] _(Optional)_ End-to-end against a fixture PR: paginated diff → `extractPRDiffs` → injection → assert agent context shape includes diff + skipped sections. + +### Acceptance tests + +- [ ] Per-plan AC #1: 200-file PR fixture → diff injection contains all files that fit; remaining listed as skipped with `over-budget` reason. +- [ ] Per-plan AC #2: PR with one binary file → skipped with `no-patch` reason. +- [ ] Per-plan AC #3: load `review.yaml`, assert prompt explicitly names the SKIPPED FILES injection. + +--- + +## Acceptance Criteria (per-plan, testable) + +1. Given a PR with files containing `patch` data, `extractPRDiffs` produces an `included` entry per file containing the patch text, and an empty `skipped` list (assuming the budget is not exceeded). +2. Given a PR file with `status: 'removed'`, `extractPRDiffs` places it in `skipped` with reason `deleted`. +3. Given a PR file with no `patch` field, `extractPRDiffs` places it in `skipped` with reason `no-patch`. +4. Given a sequence of files whose cumulative diff tokens exceed `REVIEW_DIFF_CONTEXT_TOKEN_LIMIT`, `extractPRDiffs` places the overflow into `skipped` with reason `over-budget` in deterministic order. +5. `fetchPRContextStep` injects exactly one diff context block plus, if-and-only-if any files are skipped, a SKIPPED FILES injection containing each skipped filename and its reason. +6. `fetchPRContextStep` logs a single `PR context prepared` entry with `included`, `skipped`, and `skipReasons` fields. +7. `review.yaml`'s prompt text explicitly names the SKIPPED FILES injection and tells the agent to fetch those files via `Read` or `gh pr diff` when relevant. +8. The symbol `readPRFileContents` is no longer exported from `src/agents/shared/prFormatting.ts`, and no production code references `REVIEW_FILE_CONTENT_TOKEN_LIMIT` or `PRFileContents`. +9. All new and modified code has corresponding tests. +10. `npm run build` passes. +11. `npm test` passes (unit suite). +12. `npm run lint` and `npm run typecheck` pass. +13. All documentation listed in Documentation Impact has been updated. + +--- + +## Documentation Impact (this plan only) + +| File | Change | +|---|---| +| `CLAUDE.md` | Update the "Debugging Production Sessions" section: describe the new compact-diff context shape and the SKIPPED FILES injection. Add a note that the review agent will Read or `gh pr diff` skipped files on demand. | +| `docs/ARCHITECTURE.md` and/or `docs/architecture/` | Update the trigger → worker → agent context-flow narrative to reflect compact-diff pre-fetch (replacing the prior full-file-contents description). Include the skipped-file contract in the data-flow description. | +| `docs/adding-engines.md` | If the doc describes the context shape passed to a backend engine, update the section to reference compact diffs + skipped-file injections. If no such section exists, no change. | +| `CHANGELOG.md` | Entry: `feat(review): swap full-file pre-fetch for compact diffs and add SKIPPED FILES contract (#001/2)`. Brief description of agent-visible behavior change. | + +--- + +## Out of Scope (this plan) + +- Adaptive logic that selects diff-vs-full-content per file based on size or change ratio (potential future spec). +- Agentic multi-hop context gathering (spec OOS). +- Codebase-wide indexing (spec OOS). +- Ops-layer detection of historical runs where final HEAD ≠ PR head (spec OOS). +- Running review against PR's merge commit instead of head (spec OOS). +- Multi-agent review orchestration (spec OOS). +- Changes to the review agent's model or output format (spec OOS). +- Backporting the new context shape to other agent types (`implementation`, `respond-to-review`, etc.) — out of scope unless they share the literal `fetchPRContextStep` (plan must check during implementation; if shared, the cross-agent impact is documented but the prompt updates here apply only to `review.yaml`). + +--- + +## Progress + + +- [x] AC #1 — `extractPRDiffs returns an included entry per file with a patch` +- [x] AC #2 — `marks deleted files as skipped with reason "deleted"` +- [x] AC #3 — `marks files without a patch as skipped with reason "no-patch"` +- [x] AC #4 — `respects total-budget cap; overflow files go to skipped with reason "over-budget"` +- [x] AC #5 — `injects compact diff context (not full files)` + `does NOT inject a SKIPPED FILES section when nothing is skipped` +- [x] AC #6 — `logs PR context prepared with included, skipped, and skipReasons map` +- [x] AC #7 — `review.yaml prompt contract` describe block (4 tests) +- [x] AC #8 — `readPRFileContents`, `PRFileContents`, `REVIEW_FILE_CONTENT_TOKEN_LIMIT` removed; grep confirms no `src/` references +- [x] AC #9 — TDD discipline; new tests for every behavior change +- [x] AC #10 — `npm run build` passes +- [x] AC #11 — `npm test` passes (7533 tests, 0 failures; +20 from plan 1's 7513) +- [x] AC #12 — `npm run lint` and `npm run typecheck` clean (3 pre-existing complexity warnings in unrelated files) +- [x] AC #13 — `CLAUDE.md` (Debugging Production Sessions), `CHANGELOG.md` (Unreleased entry), `docs/architecture/03-trigger-system.md` (prContext table row) updated + +**Plan-divergence notes from execution:** +- Cross-agent impact handled inline: the SKIPPED FILES injection payload is self-documenting (lists files + reasons + fetch guidance), so the four other agents that consume `prContext` (respond-to-ci, respond-to-pr-comment, respond-to-review, resolve-conflicts) get the contract automatically without per-YAML prompt edits. Only `review.yaml` was updated explicitly per AC #7. diff --git a/docs/plans/001-pr-review-correctness/_coverage.md b/docs/plans/001-pr-review-correctness/_coverage.md new file mode 100644 index 00000000..d92b94dd --- /dev/null +++ b/docs/plans/001-pr-review-correctness/_coverage.md @@ -0,0 +1,56 @@ +# Coverage map for spec 001-pr-review-correctness + +Auto-generated by /plan. Tracks which plans satisfy which spec ACs. + +## Spec ACs + +| # | Spec AC (short) | Satisfied by | Status | +|---|---|---|---| +| 1 | External-fork PR → working tree at PR head commit | plan 1 (checkout-and-pagination) | full | +| 2 | PR with >100 changed files → agent's enumeration includes every file | plan 1 (checkout-and-pagination) | full | +| 3 | Setup pipeline failure → run marked failed (no silent continuation) | plan 1 (checkout-and-pagination) | full | +| 4 | HEAD SHA mismatch after checkout → run failed before review begins | plan 1 (checkout-and-pagination) | full | +| 5 | Agent's primary view is compact per-file diffs (not full file contents) | plan 2 (context-rework) | full | +| 6 | Skipped files surfaced as structured list with per-file reason + fetch guidance | plan 2 (context-rework) | full | +| 7 | All paginated endpoints in review setup pipeline read to completion | plan 1 (checkout-and-pagination) | full | +| 8 | Operator can determine from logs: ref fetched, HEAD SHA, total file count, included/skipped counts with reasons | plan 1 (ref + SHA + total count) + plan 2 (included/skipped + reasons) | partial chain | +| 9 | PR #1092 reproduction case: external + 129 files → correct HEAD, all files enumerated, no fabrications | plan 1 (correct HEAD + complete enumeration) + plan 2 (correct context shape prevents fabrications on large PRs) | partial chain | + +## Coverage summary + +- **9 spec ACs** mapped to **2 plans** +- **7 plans-AC mappings** are full coverage (one plan delivers the AC end-to-end) +- **2 plans-AC mappings** are partial chains (AC #8 and AC #9 require both plans to be done before the spec AC is fully delivered) +- Every spec AC is mapped to at least one plan; no AC is dropped +- Both plans satisfy at least one full-coverage spec AC, so each is independently testable for forward progress against the spec + +## Plan dependency graph + +``` +1-checkout-and-pagination ──→ 2-context-rework +``` + +Linear DAG, no cycles. Plan 1 ships independent value (substantially mitigates the PR #1092 incident class) before plan 2 lands. Plan 2 cannot be implemented or tested in isolation — it depends on plan 1's complete file enumeration to produce a non-truncated diff context. + +## Strategic decisions traceability (from spec) + +| Spec strategic decision | Implemented in | +|---|---| +| 1. Canonical `refs/pull/N/head` for all PRs | plan 1, task 2 (`fetchAndCheckoutPR`) | +| 2. Fail-loud philosophy across setup | plan 1, tasks 2-4 | +| 3. Mandatory HEAD SHA verification | plan 1, task 2 | +| 4. Compact diffs as default agent view | plan 2, tasks 1-4 | +| 5. Structured skipped-file contract | plan 2, tasks 2-5 | +| 6. Pagination on every paginated endpoint | plan 1, task 7 | +| 7. Scope boundary (ops/agentic/indexing OOS) | both plans (carried through "Out of Scope" sections) | + +## Out-of-scope items (spec-level, repeated for clarity) + +These are not addressed by either plan: + +- Ops-layer detection of historical runs where final HEAD ≠ PR head +- Agentic multi-hop context gathering (e.g., following symbol references) +- Codebase-wide indexing (Greptile-style) +- Running review against PR's merge commit instead of head +- Multi-agent review orchestration +- Changes to the review agent's model or output format diff --git a/docs/plans/002-linear-webhook-setup-ux/1-save-path-fix.md.done b/docs/plans/002-linear-webhook-setup-ux/1-save-path-fix.md.done new file mode 100644 index 00000000..97be5c94 --- /dev/null +++ b/docs/plans/002-linear-webhook-setup-ux/1-save-path-fix.md.done @@ -0,0 +1,224 @@ +--- +id: 002 +slug: linear-webhook-setup-ux +plan: 1 +plan_slug: save-path-fix +level: plan +parent_spec: docs/specs/002-linear-webhook-setup-ux.md +depends_on: [] +status: done +--- + +# 002/1: Linear Wizard Save — Unblock, Diagnose, Harden + +> Part 1 of 2 in the 002-linear-webhook-setup-ux plan. See [parent spec](../../specs/002-linear-webhook-setup-ux.md). + +## Summary + +Plan 1 fixes the `POST /trpc/projects.integrations.upsert` 500 that currently prevents anyone from completing the Linear wizard on the dev environment, and hardens the tRPC / Hono error path so the next failure of this shape is **immediately diagnosable from server logs** instead of invisible behind a generic "Internal Server Error". + +The work has three phases in order: + +1. **Surface** — change the dashboard server error path (`app.onError` in `src/dashboard.ts`) and the tRPC error formatter so that when a `.mutation(...)` throws a DB error, the server log contains the real message (and, for PG errors, the constraint / detail fields), not just `String(err)`. This must land before step 2 — otherwise we are guessing. +2. **Diagnose** — once step 1 is deployed to dev, provoke the failing upsert for project `llmist` via the dashboard (or the `ssh satellite` curl path) and read the actual error from dev container logs. This is a short root-cause spike, not a re-architecture. +3. **Fix** — write the failing test that reproduces the error observed in step 2 against a real PG integration test fixture, then write the code fix in `src/db/repositories/integrationsRepository.ts` (or wherever the surfaced error points) that makes the test pass. + +Plan 1 ships **no user-visible UI change**. Its value is: the Linear wizard's Save button stops returning 500 on the dev environment, and any future server-side failure along the credential/integration save path has an actionable log line. Plan 2 (wizard UX) depends on this so it can be verified end-to-end in a browser. + +**Components delivered:** +- Updated Hono `app.onError` handler in `src/dashboard.ts` with structured error logging (message + stack + PG `code`/`detail`/`constraint` when present). +- Updated tRPC instance in `src/api/trpc.ts` with an `errorFormatter` that preserves server-side diagnostic fields in the server log while returning only a safe message to the client. +- Whatever specific fix the diagnosis uncovers in `src/db/repositories/integrationsRepository.ts` or `src/api/routers/projects.ts` (exact shape finalized in step 2). +- Log-hygiene assertion: a test that exercises the upsert and credential-save path and asserts plaintext credentials never land in logs. + +**Deferred to later plans in this spec:** +- All wizard UX changes (events list copy, inline `ProjectSecretField`, step composition) — plan 2. +- End-to-end browser walk-through of the full wizard flow — plan 2 owns the integration test for that. + +--- + +## Spec ACs satisfied by this plan + +- Spec AC #5 (Save succeeds end-to-end on a fresh project) — **full** +- Spec AC #6 (Save succeeds on re-configuration) — **full** +- Spec AC #7 (Save failures diagnosable via server log) — **full** +- Spec AC #8 (No secret leakage in plaintext logs) — **full** + +--- + +## Depends On + +- Nothing — plan 1 is the foundation layer. + +Context to lift from the spec (do not re-argue): +- Signing secret stays **optional**; verification behavior is unchanged (Strategic decision #3). +- No schema change (Constraint: "No breaking change to project credentials, DB schema, or configuration surface."). +- Root cause is unknown until step 2; do not pre-commit to a guess (Strategic decision #5: "Diagnosability is a first-class outcome."). + +--- + +## Detailed Task List (TDD) + +### 1. Surface: structured error logging in the tRPC + Hono pipeline + +**Tests first** (`tests/unit/api/error-logging.test.ts` — new file): + +- `logs full message + stack for thrown Error in mutation` — stub a procedure that throws `new Error('boom')`, assert the `console.error` spy receives an object containing `message: 'boom'` and a `stack` string. +- `logs PG error code + detail + constraint when Drizzle wraps a pg error` — stub a procedure that throws an object shaped like a PG error (`{ code: '23505', detail: 'Key (x)=(y) already exists.', constraint: 'c', message: '...' }`), assert those fields are present in the logged payload. +- `does not log credential values when the error references them` — stub a procedure that throws `new Error('failed saving LINEAR_WEBHOOK_SECRET=lin_wh_plaintext')`, assert the `console.error` spy's serialized output never contains `lin_wh_plaintext`. (Implementation: redact known credential-value-shaped fragments, OR — simpler and adequate — never include the raw `stack`/`message` when they contain the env-var value; rely on the stored-credential pipeline not interpolating secrets into errors in the first place, and make this test a tripwire if it ever starts doing so.) +- `returns a safe client payload when a server error occurs` — assert the tRPC response body for a thrown non-TRPCError contains a generic message (not the raw error) while the log still has the full error. + +**Implementation** (`src/dashboard.ts` + `src/api/trpc.ts`): + +- In `src/dashboard.ts`, replace the `app.onError((err, c) => { console.error('Unhandled error', { error: String(err), path: c.req.path }); ... })` block. New shape: + - Build a `payload` object: `{ path: c.req.path, method: c.req.method, message: err.message, name: err.name, stack: err.stack }`. + - If `err` (or `err.cause`) has PG-error-shaped fields (`code: string`, `detail?: string`, `constraint?: string`, `table?: string`, `column?: string`), copy them onto the payload. + - Call `console.error('Unhandled error', payload)`. + - Continue calling `captureException` as before. +- In `src/api/trpc.ts`, change `initTRPC.context().create()` to pass an `errorFormatter({ shape, error })` callback: + - Shape returned to client: unchanged structure; `message` replaced with a generic `'Internal server error'` when `error.code === 'INTERNAL_SERVER_ERROR'` and the cause is not a `TRPCError`. For other tRPC codes (`UNAUTHORIZED`, `NOT_FOUND`, etc.), pass through the original message. + - Server-side: `console.error('tRPC error', { code: shape.code, path: shape.data.path, message: error.message, cause: error.cause, stack: error.stack })`. The `cause` unpacking covers Drizzle wrapping a `pg` error — inspect it for the same PG fields as above and include them explicitly. +- Typescript: define a `PgErrorFields` shape and a narrow `isPgLikeError(e: unknown): e is PgErrorFields` helper co-located in `src/api/trpc.ts` (not exported). No external types. + +### 2. Diagnose: root-cause spike + +**This step is not a test or code change — it is a procedural step.** Execute in order: + +1. Ensure step 1 is merged to `dev` and the `cascade-dashboard-dev` container is rebuilt and serving the new error path. +2. From a browser signed into the dev dashboard as the org `mongrel`, open project `llmist`, run the PM wizard for Linear through to the Save step, and click "Update Integration". +3. Capture the server log on `satellite`: + ``` + ssh satellite "docker logs cascade-dashboard-dev --since 2m 2>&1" | grep -A 5 -i 'tRPC error\|Unhandled error' + ``` +4. Record the specific error: constraint name, PG code, message, and any referenced column / value. Paste into the plan's "Diagnosis" sub-section below. +5. If the error is a PG constraint violation, cross-reference `src/db/schema/integrations.ts` for the relevant column or constraint. If it is a Zod input validation error surfaced as 500, cross-reference `src/api/routers/projects.ts`'s `upsert` input schema. +6. Do not guess. Do not patch without a named error. Exit this step with a one-line statement of the root cause. + +**Diagnosis** (populated 2026-04-15 during /implement): + +> **Root cause:** CHECK constraint violation (`23514`) on `chk_integration_category_provider`. +> +> The constraint, defined originally in `0047_add_alerting_integration.sql`, allows `pm` × `{trello, jira}` only — **`linear` is not in the allowed set**. When Linear support landed (PRs #1107/#1108/#1112), the corresponding migration was never shipped. Any `INSERT` with `category='pm', provider='linear'` fails on this constraint, surfacing as HTTP 500 from `projects.integrations.upsert`. +> +> Verified on dev by querying Supabase from inside `cascade-dashboard-dev` (org `mongrel`, project `llmist` exists; `project_integrations` already has rows with `provider='trello'` and `provider='github'` — the constraint is genuinely blocking `linear`). +> +> **Fix:** ship migration `0049_allow_linear_pm_provider.sql` that drops and re-creates the check constraint with `linear` added to the `pm` branch. Forward-only; no data migration needed. + +Pre-commit guidance for `/implement`: the most likely culprits, ranked, are — +1. FK violation on `project_id` (project `llmist` may not exist in `projects` table under org `mongrel`). +2. JSON schema mismatch on `triggers` (column constraint expects specific shape; `{}` is passed). +3. Zod parsing of `config` rejects something the frontend sends for Linear (e.g. `statuses` nested shape). +4. Missing migration on dev (new column expected but not yet applied). + +The spike resolves which one. + +### 3. Fix + +**Tests first** (`tests/integration/db/upsertProjectIntegration.test.ts` — new file OR augment an existing one): + +Shape depends on root cause. Template: + +- `upserts a new Linear PM integration for an existing project` — create a project row, call `upsertProjectIntegration(projectId, 'pm', 'linear', { teamId, statuses, labels })` with no `triggers`, assert a row exists with the right fields and `triggers` defaults to `{}`. +- `upserts on top of an existing row, preserving triggers when omitted` — seed an integration with `triggers: { foo: true }`, call upsert without triggers, assert `triggers` is still `{ foo: true }`. +- `upserts on top of an existing row, overwriting triggers when provided` — same seed, call upsert with `triggers: { bar: false }`, assert the row now has `triggers: { bar: false }`. +- `` — seeded state that previously triggered the 500 now succeeds. Exact shape pending diagnosis. + +**Implementation** (path pending diagnosis, most likely one of): + +- `src/db/repositories/integrationsRepository.ts` — adjust `upsertProjectIntegration` if the root cause is data-shape related. +- `src/api/routers/projects.ts` — adjust the `upsert` input schema if the root cause is overly-strict Zod validation. +- `src/db/migrations/NNNN_*.sql` + `_journal.json` — add a missing migration if the root cause is schema drift. (Per spec non-goal #6, avoid schema redesign; a conservative forward-only migration to reconcile dev is acceptable.) +- `web/src/components/projects/pm-wizard-hooks.ts` — adjust the payload the wizard sends if the root cause is a bad shape on the client side. + +The fix must: +- Not introduce a "silently retry with default on error" path. Failure modes should remain loud — just diagnosable. +- Not broaden the Zod schema to accept inputs that should not be accepted. +- Not change the `triggers` column type, default, or semantics (spec non-goal). + +### 4. Log-hygiene assertion + +**Tests first** (`tests/integration/api/no-secret-leakage.test.ts` — new file): + +- `credential save path does not log plaintext value` — call `projects.credentials.set` with `envVarKey: 'LINEAR_WEBHOOK_SECRET'` and a sentinel value `'SECRET_SENTINEL_xyz'`; spy on `console.error` and `console.log` during the call; assert neither captured output contains the sentinel. +- `integration upsert path does not log config contents when successful` — success path should not log the `config` blob at info level. (Debug level logging of config is fine if it's behind an env flag; default should be quiet.) +- `integration upsert failure path does not leak credential env values` — force a failure (e.g. FK violation), assert the resulting error log payload does not include any known credential env-var value previously set on the project. + +**Implementation:** + +- No new code unless the tests above fail. If they pass against the step 1 implementation, this is purely an assertion that the new logging path does not regress secrecy. If they fail, redact the offending field in the `app.onError` payload builder. + +--- + +## Test Plan + +### Unit tests +- [ ] `tests/unit/api/error-logging.test.ts`: 4 tests covering structured error output (Error, PG-shaped, secret redaction, safe client payload). + +### Integration tests +- [ ] `tests/integration/db/upsertProjectIntegration.test.ts`: 3–4 tests covering new + re-configuration upsert paths and the specific reproduction of the diagnosed bug. +- [ ] `tests/integration/api/no-secret-leakage.test.ts`: 3 tests covering credential-save, successful-upsert, and failing-upsert log hygiene. + +### Acceptance tests +- [ ] Manual on `dev`: complete the Linear wizard Save step on project `llmist`, confirm HTTP 200 and an integration row in `project_integrations`. +- [ ] Manual on `dev`: provoke a failure (e.g. temporarily hit upsert with a bogus `projectId`), read `docker logs cascade-dashboard-dev`, confirm the PG / tRPC error details are present. + +--- + +## Acceptance Criteria (per-plan, testable) + +1. Running the Linear wizard to Save on a fresh project returns HTTP 200 and persists a `project_integrations` row with `provider='linear'` and `category='pm'`. +2. Re-running the Linear wizard on a project that already has a Linear PM integration row succeeds and updates the row (same 200 outcome). +3. When `projects.integrations.upsert` throws a server-side error, the dashboard server log (stdout of `cascade-dashboard-dev`) contains, in a single structured entry: the request path, the error message, and — when the cause is a PG error — its `code`, `detail`, and `constraint` fields. +4. When a credential is saved via `projects.credentials.set`, no captured stdout/stderr contains the plaintext value. Verified by a spy-based integration test. +5. A forced failure of the upsert (bogus `projectId`) produces a server log line with the true DB error (FK violation) and does not produce an entry that reveals any stored credential value for that project. +6. The client receives a safe, non-leaking error payload — generic `'Internal server error'` for unexpected throws, passed-through message for deliberate `TRPCError` codes. +7. All new/modified code has corresponding tests. +8. `npm run build` passes. +9. `npm test` and `npm run test:integration` pass. +10. `npm run lint` passes. +11. `npm run typecheck` passes. + +**Partial-state criterion** (dormant UI): +- No wizard UX changes are shipped in this plan. The Webhooks step still shows the old "Issues (created, updated, removed)" instructions and still directs users to the Credentials tab for `LINEAR_WEBHOOK_SECRET`. Plan 2 replaces those. + +--- + +## Documentation Impact (this plan only) + +| File | Change | +|---|---| +| `CLAUDE.md` | Add a short bullet under the existing "Review agent — context shape" / "Engines" style list: note that unhandled dashboard errors now log full PG fields when available, and that the generic 500 response is accompanied by a structured server log entry operators can grep. | +| `CHANGELOG.md` | Entry: "fix(dashboard): surface full DB error details in logs for tRPC mutations; unblock Linear wizard Save on dev". | + +Not touched in this plan (owned by plan 2): +- `src/integrations/README.md` — Linear setup instructions. + +--- + +## Out of Scope (this plan) + +- Wizard UX copy changes, the new `ProjectSecretField` in the Webhooks step, the events-list rewrite. Plan 2 owns these. +- New Linear trigger handlers (Reaction / Document / etc.) — spec non-goal. +- Redesigning the `project_integrations.triggers` column type, default, or semantics — spec non-goal. +- Automating Linear webhook creation — spec non-goal. +- Observability beyond `console.error` + existing Sentry capture (no new telemetry pipeline) — spec constraint. + +--- + +## Progress + + +- [x] AC #1 — Save succeeds, fresh project +- [x] AC #2 — Save succeeds, re-configuration +- [x] AC #3 — Structured DB error in server log +- [x] AC #4 — No plaintext credentials in logs (happy path) +- [x] AC #5 — No plaintext credentials in logs (error path) +- [x] AC #6 — Safe client error payload +- [x] AC #7 — Tests exist for all new/modified code +- [x] AC #8 — Build passes (typecheck + lint clean) +- [x] AC #9 — Unit + integration tests pass (7568 + 522 green) +- [x] AC #10 — Lint passes +- [x] AC #11 — Typecheck passes + +**Caveats:** +- CLAUDE.md doc update deferred — user has an in-progress CLAUDE.md rewrite (pre-existing uncommitted diff at session start). Adding a bullet now would conflict. Plan 1's doc impact is met by the CHANGELOG entry (two "Fixed" bullets covering the save-path fix and the structured error logging). diff --git a/docs/plans/002-linear-webhook-setup-ux/2-wizard-webhooks-step.md.done b/docs/plans/002-linear-webhook-setup-ux/2-wizard-webhooks-step.md.done new file mode 100644 index 00000000..750ca2d7 --- /dev/null +++ b/docs/plans/002-linear-webhook-setup-ux/2-wizard-webhooks-step.md.done @@ -0,0 +1,263 @@ +--- +id: 002 +slug: linear-webhook-setup-ux +plan: 2 +plan_slug: wizard-webhooks-step +level: plan +parent_spec: docs/specs/002-linear-webhook-setup-ux.md +depends_on: [1-save-path-fix.md] +status: done +--- + +# 002/2: Linear Wizard Webhooks Step — Correct Events List + Inline Signing Secret + +> Part 2 of 2 in the 002-linear-webhook-setup-ux plan. See [parent spec](../../specs/002-linear-webhook-setup-ux.md). + +## Summary + +Plan 2 rewrites the Linear Webhooks step of the PM wizard so the setup instructions match what CASCADE actually consumes, and adds an inline signing-secret input next to the webhook URL. The user flow becomes: paste CASCADE's webhook URL into Linear, enable the three event families CASCADE handles, copy Linear's signing secret back into the adjacent CASCADE field, done — no detour through the Credentials tab. + +Concretely, this plan modifies `LinearWebhookInfoPanel` in `web/src/components/projects/pm-wizard-common-steps.tsx` to: + +1. Replace the `Enable events: Issues (created, updated, removed)` line with an explicit list — `Issues` (status transitions drive agent selection), `Comments` (bot @mentions trigger responses), `Issue Labels` (the "Ready to Process" label starts an agent). Each bullet carries a one-line rationale so a reviewer can verify the copy matches `src/triggers/linear/register.ts`. +2. Drop the "Optionally set a webhook secret and store it as `LINEAR_WEBHOOK_SECRET` in project credentials" trailing bullet, and instead render a `ProjectSecretField` with `envVarKey="LINEAR_WEBHOOK_SECRET"` directly underneath the webhook URL — identical in shape to the Sentry alerting tab's usage of the same component. + +End-to-end verification depends on plan 1 having unblocked the Save step: after this plan lands, completing the Linear wizard in a browser must result in a working integration row AND (optionally) a stored webhook secret without any visit to the Credentials tab. + +**Components delivered:** +- Modified `LinearWebhookInfoPanel` props and render tree in `web/src/components/projects/pm-wizard-common-steps.tsx`. +- New prop drilling from `PMWizard` → `WebhookStep` → `LinearWebhookInfoPanel` to pass the project ID and the existing `LINEAR_WEBHOOK_SECRET` credential metadata. +- Component-level tests for `LinearWebhookInfoPanel` covering event-list copy and secret-field presence / persistence / reflection. +- An integration test asserting Trello and JIRA wizard Webhooks steps are visually unchanged. +- Documentation update: `src/integrations/README.md` Linear section reflects the three-events list. + +**Deferred to later plans in this spec:** +- Nothing. Plan 2 closes out the spec. + +--- + +## Spec ACs satisfied by this plan + +- Spec AC #1 (Instructions list matches handlers) — **full** +- Spec AC #2 (Inline secret input present in Webhooks step) — **full** +- Spec AC #3 (Pasting the secret persists it as `LINEAR_WEBHOOK_SECRET`) — **full** +- Spec AC #4 (Credentials tab stays in sync) — **full** +- Spec AC #9 (No regression to Trello/JIRA wizards) — **full** +- Spec AC #10 (Other providers' webhook-secret UX unchanged) — **full** + +--- + +## Depends On + +- Plan 1 (`1-save-path-fix.md`) — provides a working `projects.integrations.upsert` on dev, without which this plan's end-to-end acceptance cannot be verified in a browser. + +Context to lift from the spec (do not re-argue): +- Webhook secret lives in the Webhooks step, not the Credentials step (Strategic decision #2). +- Signing secret remains optional (Strategic decision #3). +- Recommend exactly `Issues`, `Comments`, `Issue Labels` (Strategic decision #1) — do **not** add Reaction/Document/etc. in this plan. +- Reuse `ProjectSecretField`; do not invent a new component (Strategic decision #6). + +--- + +## Detailed Task List (TDD) + +### Testing approach (note on plan divergence 2026-04-15) + +`web/` has **no React component-testing infrastructure** — no `@testing-library/react`, no jsdom, no `.test.tsx` files anywhere. Existing web-ui tests (e.g. `tests/unit/web/pm-wizard-state.test.ts`) are Node-side `.test.ts` files that exercise pure reducer/utility code under the root's `unit-core` vitest project. + +Rather than add new test infrastructure (out of scope for a UX-polish plan), these plan-2 tests use **`react-dom/server`'s `renderToStaticMarkup`** to render components to an HTML string in plain Node, then assert against the string. This is adequate for the copy / presence / absence assertions the ACs demand; it cannot cover interactive behavior (typing, clicking, mutation firing), which is dropped from the plan here. + +**Test locations:** +- `tests/unit/web/linear-webhook-info-panel.test.ts` — renders `LinearWebhookInfoPanel` with `renderToStaticMarkup`; asserts copy, events list, absence of deprecated bullets, presence of `ProjectSecretField` shape. +- `tests/unit/web/pm-wizard-webhooks-step.test.ts` — renders `WebhookStep` for each provider (`linear`, `trello`, `jira`); asserts Linear shows the secret field, Trello/JIRA do not. + +Interactive behavior (clicking Save, mutation invocation, query invalidation) is **out of scope for automated tests in this plan** — the `ProjectSecretField` component itself is untested today under the same constraint, and re-verifying `ProjectSecretField`'s internals is spec non-goal ("Any modification to `ProjectSecretField` itself — the existing component must be used as-is"). Interactive verification lives in the manual acceptance-tests section below. + +### 1. Update `LinearWebhookInfoPanel` — events list copy + +**Tests first** (`tests/unit/web/linear-webhook-info-panel.test.ts` — new file, Node SSR style): + +- `renders a three-item events list: Issues, Comments, Issue Labels` — SSR-render with a `webhookUrl`, assert the HTML contains `Issues`, `Comments`, `Issue Labels` exactly once each. +- `each events-list item has a one-line rationale matching a registered trigger` — assert the rendered HTML contains the phrases "status transitions" (for Issues), "mention" (for Comments, case-insensitive), and "Ready to Process" (for Issue Labels). Traces back to `src/triggers/linear/register.ts`. +- `does not mention Documents, Emoji reactions, Customer requests, Cycles, Users, Initiatives, Project updates, Projects, Issue SLA, or Issue attachments` — assert none of those strings appear in the rendered HTML. Prevents copy drift. +- `keeps the manual-setup-required blue info block` — assert the "Manual Webhook Setup Required" string is still present. Protects against collateral deletion. + +**Implementation** (`web/src/components/projects/pm-wizard-common-steps.tsx`, `LinearWebhookInfoPanel`): + +- Replace the single list item (line ~110–112) with three items: + ```tsx +
  • + Enable events: +
      +
    • Issues — status transitions drive CASCADE's splitting / planning / implementation agents.
    • +
    • Comments — @mentions of the CASCADE bot trigger a response agent.
    • +
    • Issue Labels — adding the "Ready to Process" label starts an agent on the issue.
    • +
    +
  • + ``` +- Delete the trailing bullet "Optionally set a webhook secret and store it as `LINEAR_WEBHOOK_SECRET` in project credentials" — replaced by the inline field in the next task. +- Update the `
  • ` that says "Select your team and save" to come *after* the events list and to now say "Select your team and save — webhooks are team-scoped in Linear". + +### 2. Add inline `ProjectSecretField` for `LINEAR_WEBHOOK_SECRET` + +**Tests first** (same test file): + +- `renders a signing-secret input labelled "Webhook Signing Secret (optional)"` — SSR-render with `projectId` and no credential; assert the rendered HTML contains the label "Webhook Signing Secret (optional)" and a placeholder matching `lin_wh_`. +- `shows masked-configured state when a credential is already set` — SSR-render with `credential={{ envVarKey: 'LINEAR_WEBHOOK_SECRET', name: 'Linear Webhook Secret', isConfigured: true, maskedValue: '...abcd' }}`; assert the masked value `...abcd` is present in the HTML. +- ~~`saves the secret via the existing credential mutation when submitted`~~ — **dropped as plan divergence**: interactive mutation firing requires jsdom + testing-library which the project does not ship. `ProjectSecretField` already calls `trpcClient.projects.credentials.set.mutate({projectId, envVarKey, value, name: label})` — verified by code inspection. Spec AC #4 is met because the inline field composes `ProjectSecretField` with `envVarKey="LINEAR_WEBHOOK_SECRET"` and `label="Webhook Signing Secret (optional)"`, which the component uses as the `name` argument. Manual acceptance test below covers end-to-end firing. +- `does not render the old "store as LINEAR_WEBHOOK_SECRET in project credentials" bullet` — assert the string "in project credentials" is absent from the rendered HTML. + +**Implementation** (`web/src/components/projects/pm-wizard-common-steps.tsx`): + +- Change `LinearWebhookInfoPanel`'s signature: + ```ts + export function LinearWebhookInfoPanel({ + webhookUrl, + projectId, + webhookSecretCredential, + }: { + webhookUrl: string; + projectId: string; + webhookSecretCredential: ProjectCredentialMeta | null; + }) { ... } + ``` +- Import `ProjectSecretField` + `ProjectCredentialMeta` from `./project-secret-field.js`. +- Insert a `` block immediately after the webhook URL block and before the setup instructions list: + ```tsx + + ``` +- Update `WebhookStep` (same file, same module) to pass the new props through: + ```tsx + if (state.provider === 'linear') { + return ( + + ); + } + ``` +- Add `projectId: string` and `linearWebhookSecretCredential: ProjectCredentialMeta | null` to the `WebhookStep` props type at the top of the step-renderer block (existing interface). +- Update the callers of `WebhookStep` in `web/src/components/projects/pm-wizard.tsx` to thread `projectId` and resolve `linearWebhookSecretCredential` from the existing `credentials.list` query result (filter where `envVarKey === 'LINEAR_WEBHOOK_SECRET'`). + +### 3. Wizard-level wiring + +**Tests first** (`tests/unit/web/pm-wizard-webhooks-step.test.ts` — new file, Node SSR style): + +- `Linear Webhooks step receives and threads a LINEAR_WEBHOOK_SECRET credential into the panel` — SSR-render `WebhookStep` with `state.provider='linear'`, `state.projectId='p1'`, and `linearWebhookSecretCredential={...configured with maskedValue ...abcd}`; assert `...abcd` appears in the rendered HTML. +- `Trello Webhooks step does not render a LINEAR_WEBHOOK_SECRET input` — SSR-render with `state.provider='trello'`; assert `LINEAR_WEBHOOK_SECRET` string and `Webhook Signing Secret` label do not appear. +- `JIRA Webhooks step does not render a LINEAR_WEBHOOK_SECRET input` — same for `jira`. +- ~~`user types and credentials.set is called`~~ — interactive, dropped (see divergence note). + +**Implementation** (`web/src/components/projects/pm-wizard.tsx`): + +- Identify the `credentials.list` query (already fetched in `pm-wizard-hooks.ts` or directly in the wizard). Compute `const linearWebhookSecretCredential = credentials.find(c => c.envVarKey === 'LINEAR_WEBHOOK_SECRET') ?? null;` inside the component. +- Pass `linearWebhookSecretCredential` and `projectId` (already available via wizard state) to `` at the render-call site (~lines 373-388 of `pm-wizard.tsx`). + +### 4. Cross-check no regression for Trello and JIRA + +**Tests first** (same test file, `tests/unit/web/pm-wizard-webhooks-step.test.ts`): + +- `Trello Webhooks step still renders curl command block` — SSR-render with `state.provider='trello'`; assert the rendered HTML contains `curl -X POST` and `api.trello.com`. +- `JIRA Webhooks step still renders curl command block` — SSR-render with `state.provider='jira'`; assert HTML contains `curl -X POST` and `/rest/webhooks/1.0/webhook`. + +**Implementation:** No code change unless tests fail. If they fail, investigate the prop drilling in task 3 to ensure Linear-only props aren't leaking into other provider branches. + +### 5. Documentation + +**Implementation** (`src/integrations/README.md`): + +- Find the Linear-related section (if present). If the setup instructions are duplicated there, rewrite them to match the new three-event list and the inline-secret flow. +- If the README only references Linear generically (no copy duplication), add a short note that the setup instructions live in the dashboard wizard and point to the component path. + +**Implementation** (`CHANGELOG.md`): + +- Add an entry: `feat(dashboard): Linear wizard — accurate events list and inline webhook signing secret (#XXXX)`. + +--- + +## Test Plan + +### Unit tests +- [ ] `tests/unit/web/linear-webhook-info-panel.test.ts` (Node SSR): ~7 tests covering events-list copy, secret-field presence, masked state, absence of deprecated bullet, preservation of manual-setup block, absence assertions. +- [ ] `tests/unit/web/pm-wizard-webhooks-step.test.ts` (Node SSR): ~5 tests covering Linear credential threading, Trello/JIRA non-regression. + +### Integration tests +- [ ] None added in this plan — plan 1 already covers the backend save path with integration tests. The wizard-to-save integration happens via the manual end-to-end step below. + +### Acceptance tests +- [ ] Manual on `dev`: complete the full Linear wizard on a fresh project including pasting a dummy signing secret into the new inline field; confirm the credential appears in the Credentials tab as `LINEAR_WEBHOOK_SECRET` and the integration row is persisted. +- [ ] Manual on `dev`: open the wizard on an existing project that already has `LINEAR_WEBHOOK_SECRET`; confirm the inline field shows the masked-configured state on load. +- [ ] Manual on `dev`: walk the Trello and JIRA wizards end-to-end; confirm visual and behavioral equivalence with the pre-change state. + +--- + +## Acceptance Criteria (per-plan, testable) + +1. The Linear Webhooks step renders a three-item events list (`Issues`, `Comments`, `Issue Labels`) with one-line rationales matching registered trigger handlers. +2. The Linear Webhooks step does not mention any event family CASCADE does not currently consume (specifically: Documents, Emoji reactions, Customer requests, Cycles, Users, Initiatives, Project updates, Projects, Issue SLA, Issue attachments). +3. The Linear Webhooks step renders a `ProjectSecretField` bound to `LINEAR_WEBHOOK_SECRET`, directly beneath the webhook URL. +4. Pasting a value into the inline secret input calls `projects.credentials.set` with exactly `{ projectId, envVarKey: 'LINEAR_WEBHOOK_SECRET', value, name }` and no other credential-save path. +5. When a `LINEAR_WEBHOOK_SECRET` credential already exists for the project, the inline field renders the masked-configured state on initial mount — no extra fetch required beyond the already-available `credentials.list` data. +6. Setting the credential via the Credentials tab and re-opening the wizard shows the masked-configured state on the inline field; setting it via the inline field and then opening the Credentials tab shows the same masked row — both surfaces read the same underlying credential row. +7. The Trello and JIRA Webhooks steps render byte-identically to the pre-change state (no new secret field, no changed copy, no changed curl block). +8. The Sentry alerting tab is unchanged (no imports or props altered in `integration-alerting-tab.tsx`). +9. All new/modified code has corresponding tests. +10. `npm run build` passes (root and `web/` if separate). +11. `npm test` passes. +12. `npm run lint` passes. +13. `npm run typecheck` passes. +14. The `src/integrations/README.md` Linear section reflects the three-event list, or explicitly defers to the dashboard wizard as the source of truth. + +--- + +## Documentation Impact (this plan only) + +| File | Change | +|---|---| +| `src/integrations/README.md` | Update the Linear setup reference to list the three consumed event families and to note that the signing secret is entered inline in the dashboard wizard. If the file doesn't currently duplicate the setup copy, add a one-line pointer to the wizard component. | +| `CHANGELOG.md` | Entry: "feat(dashboard): Linear wizard — accurate events list and inline webhook signing secret". | + +Not touched in this plan (already owned by plan 1): +- `CLAUDE.md` — plan 1 adds the error-logging bullet. + +--- + +## Out of Scope (this plan) + +- Backend save-path changes and error-logging hardening — plan 1 owns these. +- Adding new Linear trigger handlers (Reaction, Document, Issue.create, Issue.remove, IssueLabel.remove) — spec non-goal. +- Automating Linear webhook creation — spec non-goal. +- Changes to Trello or JIRA wizard steps (beyond confirming they remain unchanged) — spec non-goal. +- Changes to the Sentry alerting tab — spec non-goal. +- Making the webhook secret mandatory — spec strategic decision #3. +- Any modification to `ProjectSecretField` itself — the existing component must be used as-is. +- A "send test event" button or similar post-setup validation UI — spec non-goal / out of scope. + +--- + +## Progress + + +- [x] AC #1 — three-item events list +- [x] AC #2 — no unused event families mentioned +- [x] AC #3 — ProjectSecretField renders for Linear +- [x] AC #4 — save mutation called with correct args (*verified by code inspection; interactive test requires jsdom — see divergence note*) +- [x] AC #5 — masked state on initial mount +- [x] AC #6 — inline and Credentials-tab surfaces stay in sync (by construction — both read `project_credentials`) +- [x] AC #7 — Trello/JIRA unchanged +- [x] AC #8 — Sentry alerting tab unchanged (file unmodified) +- [x] AC #9 — tests for all new code (17 new SSR tests) +- [x] AC #10 — build passes (root + web typecheck clean) +- [x] AC #11 — tests pass (7585 unit + 522 integration) +- [x] AC #12 — lint passes +- [x] AC #13 — typecheck passes +- [x] AC #14 — integrations/README.md adds "Linear — operator setup" pointer to the wizard diff --git a/docs/plans/002-linear-webhook-setup-ux/_coverage.md b/docs/plans/002-linear-webhook-setup-ux/_coverage.md new file mode 100644 index 00000000..92855edf --- /dev/null +++ b/docs/plans/002-linear-webhook-setup-ux/_coverage.md @@ -0,0 +1,38 @@ +# Coverage map for spec 002-linear-webhook-setup-ux + +Auto-generated by /plan. Tracks which plans satisfy which spec ACs. + +## Spec ACs + +| # | Spec AC (short) | Satisfied by | Status | +|---|---|---|---| +| 1 | Instructions list matches registered Linear trigger handlers | plan 2 (wizard-webhooks-step) | full | +| 2 | Inline webhook signing secret input present in the Webhooks step | plan 2 (wizard-webhooks-step) | full | +| 3 | Pasting the secret persists it as `LINEAR_WEBHOOK_SECRET` | plan 2 (wizard-webhooks-step) | full | +| 4 | Credentials tab and wizard inline field stay in sync | plan 2 (wizard-webhooks-step) | full | +| 5 | Save succeeds end-to-end on a fresh project | plan 1 (save-path-fix) | full | +| 6 | Save succeeds on re-configuration | plan 1 (save-path-fix) | full | +| 7 | Save failures diagnosable in server log | plan 1 (save-path-fix) | full | +| 8 | No secret leakage in plaintext logs | plan 1 (save-path-fix) | full | +| 9 | No regression to Trello / JIRA wizards | plan 2 (wizard-webhooks-step) | full | +| 10 | Other providers' webhook-secret UX (Sentry alerting) unchanged | plan 2 (wizard-webhooks-step) | full | + +## Coverage summary + +- **10 spec ACs** mapped to **2 plans** +- **10 / 10** full-coverage ACs (each ACs is fully delivered by a single plan — no partial chains) +- **0 partial-coverage ACs** — the plans are cleanly orthogonal: plan 1 is pure backend save-path + logging, plan 2 is pure wizard UI. Secret leakage (AC #8) is verified in plan 1 because the logging invariant is established there and exercised by the backend tests; plan 2 does not touch logging. + +## Plan dependency graph + +``` +1-save-path-fix ──→ 2-wizard-webhooks-step +``` + +Linear: plan 2 depends on plan 1 for end-to-end browser verification (the Save step must return 200 before the wizard can be walked through successfully on `dev`). + +## Notes + +- Plan 1 ships no user-visible UI change on its own; its value is "Linear wizard Save stops failing on `dev`" plus a permanent diagnostic improvement to the dashboard error path. +- Plan 2 ships the visible UX polish and closes the spec. +- Build/test/lint/typecheck are per-plan hygiene criteria, not counted against spec ACs above. diff --git a/docs/plans/003-linear-status-mapping-parity/1-status-parity.md.done b/docs/plans/003-linear-status-mapping-parity/1-status-parity.md.done new file mode 100644 index 00000000..e824de3f --- /dev/null +++ b/docs/plans/003-linear-status-mapping-parity/1-status-parity.md.done @@ -0,0 +1,352 @@ +--- +id: 003 +slug: linear-status-mapping-parity +plan: 1 +plan_slug: status-parity +level: plan +parent_spec: docs/specs/003-linear-status-mapping-parity.md +depends_on: [] +status: done +--- + +# 003/1: Linear + JIRA Status Mapping Parity — Full Stage Vocabulary End-to-End + +> Part 1 of 1 in the 003-linear-status-mapping-parity plan. See [parent spec](../../specs/003-linear-status-mapping-parity.md). + +## Summary + +Single coherent plan that delivers spec 003 in its entirety: widens the canonical `ProjectPMConfig.statuses` type to cover all 9 CASCADE stages; expands the Linear wizard's Field Mapping step from 4 slots to 8 (backlog, splitting, planning, todo, inProgress, inReview, done, merged); extends `LinearIntegration.resolveLifecycleConfig` to pass those 4 new keys through; and fixes `JiraIntegration.resolveLifecycleConfig` to stop silently dropping splitting/planning/todo that its wizard already accepts. + +Why one plan, not several: the type widening is 4 additive optional keys and has no downstream that rejects it — splitting would ship dead weight in plan 1 with no consumer. The Linear wizard UI and Linear's `resolveLifecycleConfig` are tightly coupled (both must ship together for a user to actually persist and act on new mappings). JIRA's backend fix is a 3-key add to one object literal and reads naturally alongside Linear's identical change. All of it reviews and reverts as a single logical unit. + +**Components delivered:** +- `src/pm/lifecycle.ts` — widen `ProjectPMConfig.statuses` to the full 9 keys (all optional). +- `src/pm/linear/integration.ts` — `resolveLifecycleConfig` returns `splitting`, `planning`, `todo`, `merged` in addition to the current four. +- `src/pm/jira/integration.ts` — `resolveLifecycleConfig` returns `splitting`, `planning`, `todo` in addition to the current five. +- `web/src/components/projects/pm-wizard-linear-steps.tsx` — `LINEAR_STATUS_SLOTS` grows from 4 to 8 (lifecycle order). +- `web/src/components/projects/pm-wizard-state.ts` — if `linearStatuses` has a known-narrow-type, widen it to accept the new keys. +- Tests under `tests/unit/pm/`, `tests/unit/triggers/`, `tests/unit/web/`. +- `CHANGELOG.md` — one Unreleased entry describing the parity fix. + +**Deferred to later plans in this spec:** +- Nothing. Plan 1 closes out spec 003. + +--- + +## Spec ACs satisfied by this plan + +- Spec AC #1 (Linear wizard surface — 8 rows, lifecycle order) — **full** +- Spec AC #2 (Persistence) — **full** +- Spec AC #3 (Agent dispatch end-to-end on Linear transitions for the 4 newly-reachable stages) — **full** +- Spec AC #4 (Canonical type coverage — 9 keys declared) — **full** +- Spec AC #5 (Trello parity preserved) — **full** +- Spec AC #6 (JIRA agent dispatch for splitting/planning/todo works) — **full** +- Spec AC #7 (Existing Linear integrations upgrade cleanly) — **full** +- Spec AC #8 (No required migration) — **full** (change is type-widening + JSONB-additive) +- Spec AC #9 (Unmapped transitions are a no-op) — **full** +- Spec AC #10 (Zod / tRPC validation accepts any subset) — **full** +- Spec AC #11 (Lint / typecheck / tests green) — **full** + +--- + +## Depends On + +- Nothing external. Plan is foundation + target + UI in one hop. + +Context to lift from the spec: +- Linear wizard exposes **8** slots; canonical type declares **9** (`debug` is declared but not UI-surfaced — Strategic decision #2). +- Manual-only mapping; no auto-discovery (Strategic decision #3). +- Existing Linear rows upgrade in place — new slots render "not set" (Strategic decision #4). +- Out of scope: broadening `STATUS_TO_AGENT`, label mappings, Sentry, JIRA wizard UI. + +--- + +## Detailed Task List (TDD) + +### 1. Widen the canonical type + +**Tests first** (`tests/unit/pm/lifecycle-config-shape.test.ts` — new file): + +- `ProjectPMConfig.statuses accepts all 9 canonical CASCADE stages as optional keys` — type-level assertion: construct an object typed as `ProjectPMConfig` with every one of `backlog, splitting, planning, todo, inProgress, inReview, done, merged, debug` set to a string; assert TypeScript accepts it. (Use `satisfies` or an explicit typed const; compile-time check.) +- `ProjectPMConfig.statuses accepts an empty object (all keys optional)` — compile-time check. +- `Every key in STATUS_TO_AGENT is a declared key of ProjectPMConfig.statuses` — runtime check: iterate `Object.keys(STATUS_TO_AGENT)`, assert each is a valid `ProjectPMConfig['statuses']` key (use a helper that treats the keys as a string union). + +**Implementation** (`src/pm/lifecycle.ts`, `ProjectPMConfig` interface): + +Expand the `statuses` shape from: +```ts +statuses: { + backlog?: string; + inProgress?: string; + inReview?: string; + done?: string; + merged?: string; +}; +``` +to: +```ts +statuses: { + backlog?: string; + splitting?: string; + planning?: string; + todo?: string; + inProgress?: string; + inReview?: string; + done?: string; + merged?: string; + debug?: string; +}; +``` + +All new keys optional. Order matches CASCADE lifecycle. No other changes in this file. + +**Stale-reference check:** grep for any narrowed type that derives from `ProjectPMConfig.statuses` (e.g. `keyof ProjectPMConfig['statuses']`). If a consumer exhaustively switches on the 5-key union, it needs a `default` case or must add branches — surface before editing. + +### 2. Linear resolveLifecycleConfig — pass the new keys through + +**Tests first** (`tests/unit/pm/linear-integration.test.ts` — augment existing or new file): + +- `resolveLifecycleConfig returns all 8 status keys from pm.config.statuses` — given `project.pm.config.statuses = { backlog: 's-bl', splitting: 's-sp', planning: 's-pl', todo: 's-td', inProgress: 's-ip', inReview: 's-ir', done: 's-dn', merged: 's-mg' }`, assert the returned `ProjectPMConfig.statuses` contains each key with the corresponding value. +- `resolveLifecycleConfig preserves undefined for keys not provided` — given a partial input (only `inProgress` set), assert only `inProgress` is present on the output, others are `undefined`. +- `resolveLifecycleConfig does not read or return debug` — Linear UI does not surface `debug`; integration must not write it to the canonical shape from Linear-side config. Assert it's not present on the returned `statuses` object. + +**Implementation** (`src/pm/linear/integration.ts`, `resolveLifecycleConfig`): + +Expand the `statuses` object from: +```ts +statuses: { + backlog: linearConfig?.statuses?.backlog, + inProgress: linearConfig?.statuses?.inProgress, + inReview: linearConfig?.statuses?.inReview, + done: linearConfig?.statuses?.done, + merged: linearConfig?.statuses?.merged, +} +``` +to: +```ts +statuses: { + backlog: linearConfig?.statuses?.backlog, + splitting: linearConfig?.statuses?.splitting, + planning: linearConfig?.statuses?.planning, + todo: linearConfig?.statuses?.todo, + inProgress: linearConfig?.statuses?.inProgress, + inReview: linearConfig?.statuses?.inReview, + done: linearConfig?.statuses?.done, + merged: linearConfig?.statuses?.merged, +} +``` + +Linear's config-shape type (e.g. `LinearConfig.statuses`, likely in `src/types/index.ts` or `src/pm/linear/types.ts`) must permit those keys. Grep for the type before editing; widen it the same way as `ProjectPMConfig.statuses` (optional new keys). No `debug` key on Linear — Linear doesn't surface it. + +### 3. JIRA resolveLifecycleConfig — stop dropping splitting/planning/todo + +**Tests first** (`tests/unit/pm/jira-integration.test.ts` — augment existing or new file): + +- `resolveLifecycleConfig returns splitting, planning, todo when present in jira config` — given `jiraConfig.statuses = { splitting: 'SPL', planning: 'PLAN', todo: 'TODO', inProgress: 'IP', done: 'DN' }`, assert each is present on the returned `ProjectPMConfig.statuses`. +- `resolveLifecycleConfig still returns backlog / inProgress / inReview / done / merged` — regression guard: the 5 keys that worked pre-spec still work. + +**Implementation** (`src/pm/jira/integration.ts`, `resolveLifecycleConfig`): + +Expand the `statuses` object from: +```ts +statuses: { + backlog: jiraConfig?.statuses?.backlog, + inProgress: jiraConfig?.statuses?.inProgress, + inReview: jiraConfig?.statuses?.inReview, + done: jiraConfig?.statuses?.done, + merged: jiraConfig?.statuses?.merged, +} +``` +to: +```ts +statuses: { + backlog: jiraConfig?.statuses?.backlog, + splitting: jiraConfig?.statuses?.splitting, + planning: jiraConfig?.statuses?.planning, + todo: jiraConfig?.statuses?.todo, + inProgress: jiraConfig?.statuses?.inProgress, + inReview: jiraConfig?.statuses?.inReview, + done: jiraConfig?.statuses?.done, + merged: jiraConfig?.statuses?.merged, +} +``` + +Check `JiraConfig` type (`src/types/index.ts` or `src/pm/jira/types.ts`): its `statuses` shape likely already accepts arbitrary string keys or already includes splitting/planning/todo (since the wizard writes them). If it doesn't, widen it. + +### 4. Linear wizard — expand the slot list + +**Tests first** (`tests/unit/web/linear-field-mapping-step.test.ts` — new file, Node SSR style, same pattern as `linear-webhook-info-panel.test.ts`): + +- `renders 8 status mapping rows in lifecycle order` — SSR-render `LinearFieldMappingStep` with a minimal wizard state containing an `availableStatuses` list. Assert the rendered HTML contains, in order: `backlog`, `splitting`, `planning`, `todo`, `inProgress`, `inReview`, `done`, `merged`. Assert the `inverse` order is not present (guard against alphabetical). +- `does not render a debug row` — assert the string `debug` is not present among rendered status slot labels. +- `each row has a dropdown and a manual-entry fallback` — assert that for each of the 8 slots a ` { + const html = render({ + linearStatusMappings: { + splitting: 'st-sp', + planning: 'st-pl', + }, + }); + // The persisted values should appear as selected option values. + expect(html).toContain('value="st-sp"'); + expect(html).toContain('value="st-pl"'); + }); + + // Regression: Linear webhooks deliver workflow-state UUIDs in `data.stateId`, + // not display names. Storing names in the mapping makes the trigger handler's + // strict equality check (src/triggers/linear/status-changed.ts) silently no-op. + it('uses state IDs (not names) as dropdown option values', () => { + const html = render(); + // Each Linear workflow state's ID must appear as an option value. + for (const id of ['st-bl', 'st-sp', 'st-pl', 'st-td', 'st-ip', 'st-ir', 'st-dn', 'st-mg']) { + expect(html, `option value="${id}" missing`).toContain(`value="${id}"`); + } + // State names must NOT appear as option values (they may still be option labels). + for (const name of [ + 'Backlog', + 'Splitting', + 'Planning', + 'Todo', + 'In Progress', + 'In Review', + 'Done', + 'Merged', + ]) { + expect(html, `state name "${name}" must not be a value`).not.toContain(`value="${name}"`); + } + }); +}); + +describe('LinearFieldMappingStep — label slots', () => { + function renderWithLabels( + labels: Array<{ id: string; name: string; color: string }>, + persisted: Record = {}, + onCreateLabel?: (slot: string) => void, + onCreateAllMissingLabels?: () => void, + ): string { + const state = makeState({ + linearTeamDetails: { + states: [], + labels, + }, + linearLabels: persisted, + }); + return renderToStaticMarkup( + createElement(LinearFieldMappingStep, { + state, + dispatch: () => {}, + onCreateLabel, + onCreateAllMissingLabels, + }), + ); + } + + it('renders label dropdowns sourced from linearTeamDetails.labels (ID-backed options)', () => { + const html = renderWithLabels([ + { id: 'lbl-proc-uuid', name: 'cascade-processing', color: '#2563EB' }, + { id: 'lbl-done-uuid', name: 'cascade-processed', color: '#16A34A' }, + ]); + // The label dropdown must expose each Linear label's UUID as an option value. + expect(html).toContain('value="lbl-proc-uuid"'); + expect(html).toContain('value="lbl-done-uuid"'); + // Display names should NOT appear as option values (they can still be in the label text). + expect(html).not.toContain('value="cascade-processing"'); + }); + + it('shows the "Create" affordance for slots with no mapping and no existing matching label', () => { + const html = renderWithLabels( + [], + {}, + () => {}, + () => {}, + ); + // A dedicated create button per slot — look for the batch button text too. + expect(html).toMatch(/Create All Missing/); + }); + + it('hides the per-slot Create button when the default label already exists on the team', () => { + const html = renderWithLabels( + [ + { id: 'lbl-ready', name: 'cascade-ready', color: '#0284C7' }, + { id: 'lbl-proc', name: 'cascade-processing', color: '#2563EB' }, + { id: 'lbl-procd', name: 'cascade-processed', color: '#16A34A' }, + { id: 'lbl-err', name: 'cascade-error', color: '#DC2626' }, + { id: 'lbl-auto', name: 'cascade-auto', color: '#9333EA' }, + ], + {}, + () => {}, + () => {}, + ); + // With every default present, there's nothing left to create → batch button hidden. + expect(html).not.toMatch(/Create All Missing/); + }); + + it('reflects persisted label mappings as selected dropdown values', () => { + const html = renderWithLabels( + [{ id: 'lbl-proc-uuid', name: 'cascade-processing', color: '#2563EB' }], + { processing: 'lbl-proc-uuid' }, + ); + expect(html).toContain('value="lbl-proc-uuid"'); + }); +}); diff --git a/tests/unit/web/linear-team-step.test.ts b/tests/unit/web/linear-team-step.test.ts new file mode 100644 index 00000000..c48fa547 --- /dev/null +++ b/tests/unit/web/linear-team-step.test.ts @@ -0,0 +1,118 @@ +/** + * SSR tests for LinearTeamStep — verify team + project selector rendering + * and the new optional project-scope selector behavior. + */ + +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; +import { LinearTeamStep } from '../../../web/src/components/projects/pm-wizard-linear-steps.js'; +import type { WizardState } from '../../../web/src/components/projects/pm-wizard-state.js'; + +function makeState(overrides: Partial = {}): WizardState { + return { + provider: 'linear', + linearApiKey: 'lin_api_test', + linearTeamId: '', + linearTeams: [ + { id: 'team-1', name: 'Engineering', key: 'ENG' }, + { id: 'team-2', name: 'Design', key: 'DES' }, + ], + linearTeamDetails: null, + linearStatusMappings: {}, + linearLabels: {}, + linearProjectId: '', + linearProjects: [], + isEditing: false, + hasStoredCredentials: false, + ...overrides, + } as unknown as WizardState; +} + +function pendingMutation(): { + isPending: boolean; + isError: boolean; + error: null; + mutate: () => void; +} { + return { isPending: false, isError: false, error: null, mutate: vi.fn() }; +} + +function render(extra: Partial = {}): string { + const state = makeState(extra); + return renderToStaticMarkup( + createElement(LinearTeamStep, { + state, + onTeamSelect: () => {}, + dispatch: () => {}, + // biome-ignore lint/suspicious/noExplicitAny: test stub for tanstack mutation object + linearTeamsMutation: pendingMutation() as any, + // biome-ignore lint/suspicious/noExplicitAny: test stub for tanstack mutation object + linearDetailsMutation: pendingMutation() as any, + // biome-ignore lint/suspicious/noExplicitAny: test stub for tanstack mutation object + linearProjectsMutation: pendingMutation() as any, + }), + ); +} + +describe('LinearTeamStep — project selector', () => { + it('does not render the Linear Project selector when no team is selected', () => { + const html = render({ linearTeamId: '' }); + expect(html).not.toContain('Linear Project'); + }); + + it('renders the Linear Project selector when a team is selected', () => { + const html = render({ + linearTeamId: 'team-1', + linearProjects: [ + { id: 'P1', name: 'Alpha', icon: null, color: null }, + { id: 'P2', name: 'Beta', icon: null, color: null }, + ], + }); + expect(html).toContain('Linear Project'); + }); + + it('populates the selector options from state.linearProjects', () => { + const html = render({ + linearTeamId: 'team-1', + linearProjects: [ + { id: 'P1', name: 'Alpha', icon: null, color: null }, + { id: 'P2', name: 'Beta', icon: null, color: null }, + ], + }); + expect(html).toContain('Alpha'); + expect(html).toContain('Beta'); + expect(html).toContain('value="P1"'); + expect(html).toContain('value="P2"'); + }); + + it('pre-selects the stored projectId when set', () => { + const html = render({ + linearTeamId: 'team-1', + linearProjectId: 'P2', + linearProjects: [ + { id: 'P1', name: 'Alpha', icon: null, color: null }, + { id: 'P2', name: 'Beta', icon: null, color: null }, + ], + }); + // Native setAgentEngine(v === '_none' ? '' : v)} + > + + + + + Inherit from project ({inheritedEngine}) + {engines.map((engine) => ( + + {engine.label} + + ))} + + + +
    + + +
    + {effectiveEngine && ( + + )} +
    +
    + + setMaxIterations(e.target.value)} + placeholder={ + inheritedMaxIterations !== undefined + ? `${inheritedMaxIterations} (inherited)` + : 'Optional' + } + /> +
    +
    + + setMaxConcurrency(e.target.value)} + placeholder="Optional" + /> +
    +
    + + + {/* Prompts Tab */} + + { + setSystemPrompt(v); + // User is editing manually — cancel any pending clear + setSystemPromptCleared(false); + }} + taskPrompt={taskPrompt} + onTaskPromptChange={(v) => { + setTaskPrompt(v); + // User is editing manually — cancel any pending clear + setTaskPromptCleared(false); + }} + onSystemPromptClear={() => setSystemPromptCleared(true)} + onTaskPromptClear={() => setTaskPromptCleared(true)} + /> + + + {/* Triggers Tab */} + + {(['pm', 'scm', 'internal'] as const).map((category) => { + const categoryTriggers = triggersByCategory[category]; + if (categoryTriggers.length === 0) return null; + + return ( +
    +

    + {CATEGORY_LABELS[category] ?? category} Triggers +

    + onTriggerToggle(agentType, event, enabled)} + onParamChange={(event, params) => { + // Find the current trigger to get its enabled state + const currentTrigger = categoryTriggers.find((t) => t.event === event); + onTriggerParamChange(agentType, event, params, currentTrigger?.enabled ?? true); + }} + idPrefix={`${agentType}-${category}`} + /> +
    + ); + })} + + {!hasTriggers && ( +

    + No trigger configuration for this agent. +

    + )} +
    + + + {/* Footer actions — outside tabs, applies globally */} +
    +
    + + + {saved && Saved} +
    + {config && ( + + )} +
    + + ); +} + +export function AgentDetailView({ + agentType, + projectId, + config, + triggers, + integrations, + engines, + isSaving, + onSaveConfig, + saveSuccessNonce, + onDeleteConfig, + onTriggerToggle, + onTriggerParamChange, + onBack, + projectModel, + projectEngine, + projectMaxIterations, + systemDefaults, +}: AgentDetailViewProps) { + const label = (AGENT_LABELS as Record)[agentType] ?? agentType; + + return ( +
    +
    + +
    +
    +

    {label}

    +

    + Configure model, engine, and trigger settings for the {label} agent. +

    +
    + { + onDeleteConfig(id); + onBack(); + }} + onTriggerToggle={onTriggerToggle} + onTriggerParamChange={onTriggerParamChange} + projectModel={projectModel} + projectEngine={projectEngine} + projectMaxIterations={projectMaxIterations} + systemDefaults={systemDefaults} + /> +
    + ); +} diff --git a/web/src/components/projects/agent-config-list.tsx b/web/src/components/projects/agent-config-list.tsx new file mode 100644 index 00000000..bb5c1d72 --- /dev/null +++ b/web/src/components/projects/agent-config-list.tsx @@ -0,0 +1,275 @@ +/** + * Agent list view components: AgentRow and AgentListView. + * Renders the table of configured agents and the list of available agents to enable. + */ +import { AlertTriangle, ChevronRight, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog.js'; +import { Badge } from '@/components/ui/badge.js'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table.js'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip.js'; +import { AGENT_LABELS } from '@/lib/trigger-agent-mapping.js'; +import type { AgentListViewProps, AgentRowProps } from './agent-config-types.js'; +import { countActiveTriggers, engineHasCredentials } from './agent-config-utils.js'; + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: table row with multiple computed display values (model, engine, trigger count) and layered inheritance fallbacks +export function AgentRow({ + type, + config, + triggers, + integrations, + onSelect, + onDeleteRequest, + projectModel, + projectEngine, + systemDefaults, + configuredCredentialKeys, +}: AgentRowProps) { + const label = (AGENT_LABELS as Record)[type] ?? type; + const activeTriggerCount = countActiveTriggers(triggers, integrations); + const modelInfo = config?.model ?? null; + const engineInfo = config?.agentEngine ?? null; + const hasCustomEngineSettings = + config?.agentEngineSettings != null && Object.keys(config.agentEngineSettings).length > 0; + + // Fallback display: show inherited model/engine when agent has no specific override + const inheritedModel = projectModel ?? systemDefaults?.model ?? null; + const inheritedEngine = projectEngine ?? systemDefaults?.agentEngine ?? null; + const displayModel = modelInfo ?? (inheritedModel ? `${inheritedModel} (inherited)` : null); + const displayEngine = engineInfo ?? (inheritedEngine ? `${inheritedEngine} (inherited)` : null); + + // Check if the agent's effective engine has credentials configured + // Only check when there is an explicit agent-level engine override + const agentEngineId = config?.agentEngine ?? null; + const hasMissingCredentials = + agentEngineId !== null && !engineHasCredentials(agentEngineId, configuredCredentialKeys); + + return ( + onSelect(type)}> + {label} + + {activeTriggerCount === 0 ? ( + + Inactive + + ) : config ? ( +
    + + Configured + + {hasMissingCredentials && ( + + + + + Missing credentials + + + + This agent uses the {agentEngineId} engine but no credentials are configured for + it. Configure credentials on the Harness tab. + + + )} +
    + ) : ( + + Default + + )} +
    + + {displayModel || displayEngine ? ( + + {displayEngine && {displayEngine}} + {displayEngine && displayModel && · } + {displayModel && {displayModel}} + {hasCustomEngineSettings && ( + + Custom settings + + )} + + ) : ( + + )} + + + {activeTriggerCount > 0 ? ( + {activeTriggerCount} active + ) : ( + + + + + None + + + + No triggers configured — this agent won't process any events + + + )} + + +
    + {config && ( + + )} + +
    +
    +
    + ); +} + +export function AgentListView({ + enabledAgentTypes, + availableAgentTypes, + configByAgent, + triggersByAgent, + integrations, + onSelect, + onDelete, + onEnable, + isDeleting, + isEnabling, + projectModel, + projectEngine, + systemDefaults, + configuredCredentialKeys, +}: AgentListViewProps) { + const [deleteTarget, setDeleteTarget] = useState<{ id: number; label: string } | null>(null); + + return ( + <> + {enabledAgentTypes.length === 0 ? ( +
    + No agents enabled. Enable agents below to start processing. +
    + ) : ( +
    + + + + + Agent + Status + Engine / Model + Active Triggers + + + + + {enabledAgentTypes.map((type) => ( + setDeleteTarget({ id, label })} + projectModel={projectModel} + projectEngine={projectEngine} + systemDefaults={systemDefaults} + configuredCredentialKeys={configuredCredentialKeys} + /> + ))} + +
    +
    +
    + )} + + {availableAgentTypes.length > 0 && ( +
    +

    Available Agents

    +
    + {availableAgentTypes.map((agentType) => { + const label = + (AGENT_LABELS as Record)[agentType] ?? agentType; + return ( +
    + {label} + +
    + ); + })} +
    +
    + )} + + !open && setDeleteTarget(null)}> + + + Delete Agent Config + + Are you sure you want to delete the config for {deleteTarget?.label}? + The agent will be disabled and no longer process any events. This action cannot be + undone. + + + + Cancel + { + if (deleteTarget) { + onDelete(deleteTarget.id); + setDeleteTarget(null); + } + }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {isDeleting ? 'Deleting...' : 'Delete'} + + + + + + ); +} diff --git a/web/src/components/projects/agent-config-types.ts b/web/src/components/projects/agent-config-types.ts new file mode 100644 index 00000000..ac8d4ebd --- /dev/null +++ b/web/src/components/projects/agent-config-types.ts @@ -0,0 +1,167 @@ +/** + * Shared types for the agent configuration components. + * + * Extracted from project-agent-configs.tsx so each sub-module can import + * only what it needs without circular dependencies. + */ + +import type { ResolvedTrigger } from '@/components/shared/definition-trigger-toggles.js'; +import type { TriggerParameterValue } from '@/lib/trigger-agent-mapping.js'; + +export interface AgentConfig { + id: number; + agentType: string; + model: string | null; + maxIterations: number | null; + agentEngine: string | null; + agentEngineSettings: Record> | null; + maxConcurrency: number | null; + systemPrompt: string | null; + taskPrompt: string | null; +} + +interface EngineSettingFieldOption { + value: string; + label: string; +} + +export type EngineSettingField = + | { + key: string; + label: string; + type: 'select'; + description?: string; + options: EngineSettingFieldOption[]; + } + | { key: string; label: string; type: 'boolean'; description?: string } + | { + key: string; + label: string; + type: 'number'; + description?: string; + min?: number; + max?: number; + step?: number; + }; + +export interface Engine { + id: string; + label: string; + settings?: { + title?: string; + description?: string; + fields: EngineSettingField[]; + }; +} + +export interface SaveConfigValues { + model: string; + maxIterations: string; + agentEngine: string; + maxConcurrency: string; + engineSettings: Record> | undefined; + systemPrompt: string; + taskPrompt: string; + /** True when the user explicitly cleared the system prompt override (send null, not the fallback text). */ + systemPromptCleared: boolean; + /** True when the user explicitly cleared the task prompt override (send null, not the fallback text). */ + taskPromptCleared: boolean; +} + +export interface SystemDefaults { + model: string; + maxIterations: number; + agentEngine: string; + engineSettings: Record>; +} + +export interface DefinitionAgentSectionProps { + agentType: string; + projectId: string; + config: AgentConfig | null; + triggers: ResolvedTrigger[]; + integrations: { + pm: string | null; + scm: string | null; + }; + engines: Engine[]; + isSaving: boolean; + onSaveConfig: (agentType: string, configId: number | null, values: SaveConfigValues) => void; + saveSuccessNonce: number; + onDeleteConfig: (id: number) => void; + onTriggerToggle: (agentType: string, event: string, enabled: boolean) => void; + onTriggerParamChange: ( + agentType: string, + event: string, + parameters: Record, + currentEnabled: boolean, + ) => void; + /** Project-level model (null = use system default). */ + projectModel: string | null; + /** Project-level engine (null = use system default). */ + projectEngine: string | null; + /** Project-level maxIterations (null = use system default). */ + projectMaxIterations: number | null; + /** System-level defaults from the backend. */ + systemDefaults: SystemDefaults | undefined; +} + +export interface AgentRowProps { + type: string; + config: AgentConfig | null; + triggers: ResolvedTrigger[]; + integrations: { pm: string | null; scm: string | null }; + onSelect: (agentType: string) => void; + onDeleteRequest: (id: number, label: string) => void; + /** Project-level model to show as "inherited" when agent has no override. */ + projectModel: string | null; + /** Project-level engine to show as "inherited" when agent has no override. */ + projectEngine: string | null; + /** System-level defaults. */ + systemDefaults: SystemDefaults | undefined; + /** Set of credential env-var keys that are configured for this project. */ + configuredCredentialKeys: Set; +} + +export interface AgentListViewProps { + enabledAgentTypes: string[]; + availableAgentTypes: string[]; + configByAgent: Map; + triggersByAgent: Map; + integrations: { pm: string | null; scm: string | null }; + onSelect: (agentType: string) => void; + onDelete: (id: number) => void; + onEnable: (agentType: string) => void; + isDeleting: boolean; + isEnabling: boolean; + projectModel: string | null; + projectEngine: string | null; + systemDefaults: SystemDefaults | undefined; + /** Set of credential env-var keys that are configured for this project. */ + configuredCredentialKeys: Set; +} + +export interface AgentDetailViewProps { + agentType: string; + projectId: string; + config: AgentConfig | null; + triggers: ResolvedTrigger[]; + integrations: { pm: string | null; scm: string | null }; + engines: Engine[]; + isSaving: boolean; + onSaveConfig: (agentType: string, configId: number | null, values: SaveConfigValues) => void; + saveSuccessNonce: number; + onDeleteConfig: (id: number) => void; + onTriggerToggle: (agentType: string, event: string, enabled: boolean) => void; + onTriggerParamChange: ( + agentType: string, + event: string, + parameters: Record, + currentEnabled: boolean, + ) => void; + onBack: () => void; + projectModel: string | null; + projectEngine: string | null; + projectMaxIterations: number | null; + systemDefaults: SystemDefaults | undefined; +} diff --git a/web/src/components/projects/agent-config-utils.ts b/web/src/components/projects/agent-config-utils.ts new file mode 100644 index 00000000..80296d2c --- /dev/null +++ b/web/src/components/projects/agent-config-utils.ts @@ -0,0 +1,40 @@ +/** + * Pure utility functions for agent configuration components. + * These functions are free of React and UI dependencies — easy to unit-test. + */ + +import type { ResolvedTrigger } from '../shared/definition-trigger-toggles.js'; +import { engineCredentialKeys } from './engine-secrets.js'; + +/** + * Returns true when the given engine has at least one credential key configured. + * Derived from ENGINE_SECRETS in engine-secrets.ts — no separate mapping to maintain. + * If the engine is not in the map, we conservatively assume credentials are present. + */ +export function engineHasCredentials( + engineId: string, + configuredCredentialKeys: Set, +): boolean { + const requiredKeys = engineCredentialKeys[engineId]; + if (!requiredKeys) return true; // Unknown engine — assume ok + return requiredKeys.some((key) => configuredCredentialKeys.has(key)); +} + +/** + * Counts the number of active triggers for an agent, filtering by provider + * when the trigger has provider restrictions. + */ +export function countActiveTriggers( + triggers: ResolvedTrigger[], + integrations: { pm: string | null; scm: string | null }, +): number { + return triggers.filter((t) => { + if (!t.enabled) return false; + const [category] = t.event.split(':'); + if (t.providers && t.providers.length > 0) { + const activeProvider = integrations[category as keyof typeof integrations]; + return t.providers.some((p) => p === activeProvider); + } + return true; + }).length; +} diff --git a/web/src/components/projects/agent-prompt-overrides.tsx b/web/src/components/projects/agent-prompt-overrides.tsx index 94770753..b61b57f0 100644 --- a/web/src/components/projects/agent-prompt-overrides.tsx +++ b/web/src/components/projects/agent-prompt-overrides.tsx @@ -1,3 +1,5 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; import { PromptSectionTab, ValidationStatus, @@ -10,8 +12,6 @@ import { ReferencePanel } from '@/components/settings/prompt-editor.js'; */ import { Badge } from '@/components/ui/badge.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; -import { useMutation, useQuery } from '@tanstack/react-query'; -import { useEffect, useState } from 'react'; interface AgentPromptOverridesProps { projectId: string; @@ -255,7 +255,7 @@ type BadgeType = 'custom' | 'inherited' | 'default'; function getInheritanceBadge({ projectOverride, globalPrompt, - defaultPrompt, + defaultPrompt: _defaultPrompt, }: { projectOverride: string | null; globalPrompt: string | null; diff --git a/web/src/components/projects/integration-alerting-tab.tsx b/web/src/components/projects/integration-alerting-tab.tsx new file mode 100644 index 00000000..d36745ed --- /dev/null +++ b/web/src/components/projects/integration-alerting-tab.tsx @@ -0,0 +1,198 @@ +/** + * Alerting (Sentry) integration tab component. + */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { Input } from '@/components/ui/input.js'; +import { Label } from '@/components/ui/label.js'; +import { API_URL } from '@/lib/api.js'; +import { trpc, trpcClient } from '@/lib/trpc.js'; +import { CopyButton } from './integration-scm-tab.js'; +import { ProjectSecretField } from './project-secret-field.js'; + +// ============================================================================ +// Alerting Tab (Sentry) +// ============================================================================ + +interface AlertingTabProps { + projectId: string; + alertingIntegration?: Record; +} + +export function AlertingTab({ projectId, alertingIntegration }: AlertingTabProps) { + const queryClient = useQueryClient(); + + const existingConfig = (alertingIntegration?.config as Record) ?? {}; + const [organizationSlug, setOrganizationSlug] = useState( + (existingConfig.organizationSlug as string) ?? '', + ); + + const [verifyResult, setVerifyResult] = useState<{ + id: string; + name: string; + slug: string; + } | null>(null); + const [verifyError, setVerifyError] = useState(null); + const [isVerifying, setIsVerifying] = useState(false); + + const callbackBaseUrl = + API_URL || + (typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':3000') : ''); + + const sentryWebhookUrl = callbackBaseUrl + ? `${callbackBaseUrl}/sentry/webhook/${projectId}` + : `/sentry/webhook/${projectId}`; + + const credentialsQuery = useQuery(trpc.projects.credentials.list.queryOptions({ projectId })); + const credentials = credentialsQuery.data ?? []; + const apiTokenCred = credentials.find((c) => c.envVarKey === 'SENTRY_API_TOKEN'); + const webhookSecretCred = credentials.find((c) => c.envVarKey === 'SENTRY_WEBHOOK_SECRET'); + + const handleVerify = async (rawToken: string) => { + if (!rawToken) { + setVerifyError('Enter the API token value to verify it'); + return; + } + if (!organizationSlug) { + setVerifyError('Enter the organization slug to verify it'); + return; + } + setIsVerifying(true); + setVerifyError(null); + setVerifyResult(null); + try { + const result = await trpcClient.integrationsDiscovery.verifySentry.mutate({ + apiToken: rawToken, + organizationSlug, + }); + setVerifyResult(result); + } catch (err) { + setVerifyError(err instanceof Error ? err.message : String(err)); + } finally { + setIsVerifying(false); + } + }; + + const saveMutation = useMutation({ + mutationFn: async () => { + return trpcClient.projects.integrations.upsert.mutate({ + projectId, + category: 'alerting', + provider: 'sentry', + config: { organizationSlug }, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, + }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async () => { + return trpcClient.projects.integrations.delete.mutate({ + projectId, + category: 'alerting', + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, + }); + }, + }); + + return ( +
    + {/* Organization Slug */} +
    + +

    + Your Sentry organization slug (found in your Sentry URL:{' '} + sentry.io/organizations/<slug>/). +

    + setOrganizationSlug(e.target.value)} + placeholder="my-organization" + /> +
    + +
    + + {/* Credentials */} +
    + + + +
    + +
    + + {/* Sentry Webhook URL */} +
    + +

    + Configure this URL in your Sentry project's webhook settings to receive alerts. +

    +
    + {sentryWebhookUrl} + +
    +
    + +
    + + {/* Save / Delete */} +
    + + {saveMutation.isSuccess && Saved} + {saveMutation.isError && ( + {saveMutation.error.message} + )} + {alertingIntegration && ( + + )} + {deleteMutation.isError && ( + {deleteMutation.error.message} + )} +
    +
    + ); +} diff --git a/web/src/components/projects/integration-form.tsx b/web/src/components/projects/integration-form.tsx index 8e520258..328bed69 100644 --- a/web/src/components/projects/integration-form.tsx +++ b/web/src/components/projects/integration-form.tsx @@ -1,624 +1,12 @@ -import { Input } from '@/components/ui/input.js'; -import { Label } from '@/components/ui/label.js'; -import { API_URL } from '@/lib/api.js'; -import { trpc, trpcClient } from '@/lib/trpc.js'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { - AlertCircle, - AlertTriangle, - Check, - Clipboard, - ExternalLink, - Info, - Loader2, - RefreshCw, - Trash2, -} from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useState } from 'react'; +import { trpc } from '@/lib/trpc.js'; +import { AlertingTab } from './integration-alerting-tab.js'; +import { SCMTab } from './integration-scm-tab.js'; import { PMWizard } from './pm-wizard.js'; -import { ProjectSecretField } from './project-secret-field.js'; type IntegrationCategory = 'pm' | 'scm' | 'alerting'; -// ============================================================================ -// GitHub Credential Slots (replaces the old CredentialSelector dropdowns) -// ============================================================================ - -function GitHubCredentialSlots({ projectId }: { projectId: string }) { - const credentialsQuery = useQuery(trpc.projects.credentials.list.queryOptions({ projectId })); - - const [verifiedLogins, setVerifiedLogins] = useState>({}); - const [verifyErrors, setVerifyErrors] = useState>({}); - const [verifyingRoles, setVerifyingRoles] = useState>({}); - - const credentials = credentialsQuery.data ?? []; - const implementerCred = credentials.find((c) => c.envVarKey === 'GITHUB_TOKEN_IMPLEMENTER'); - const reviewerCred = credentials.find((c) => c.envVarKey === 'GITHUB_TOKEN_REVIEWER'); - - const handleVerify = async (role: string, rawValue: string) => { - // If no new value entered, we can't verify (we never return plaintext to browser) - if (!rawValue) { - setVerifyErrors((prev) => ({ - ...prev, - [role]: 'Enter the token value to verify it', - })); - return; - } - setVerifyingRoles((prev) => ({ ...prev, [role]: true })); - try { - const result = await trpcClient.integrationsDiscovery.verifyGithubToken.mutate({ - token: rawValue, - }); - setVerifiedLogins((prev) => ({ ...prev, [role]: result.login })); - setVerifyErrors((prev) => ({ ...prev, [role]: null })); - } catch (err) { - setVerifiedLogins((prev) => ({ ...prev, [role]: null })); - setVerifyErrors((prev) => ({ - ...prev, - [role]: err instanceof Error ? err.message : String(err), - })); - } finally { - setVerifyingRoles((prev) => ({ ...prev, [role]: false })); - } - }; - - return ( -
    - - handleVerify('implementer', val)} - isVerifying={verifyingRoles.implementer} - verifyError={verifyErrors.implementer} - /> - handleVerify('reviewer', val)} - isVerifying={verifyingRoles.reviewer} - verifyError={verifyErrors.reviewer} - /> -
    - ); -} - -// ============================================================================ -// GitHub Webhook Management -// ============================================================================ - -function CopyButton({ text }: { text: string }) { - const [copied, setCopied] = useState(false); - const handleCopy = async () => { - await navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - return ( - - ); -} - -function GitHubWebhookSection({ projectId }: { projectId: string }) { - const queryClient = useQueryClient(); - - const callbackBaseUrl = - API_URL || - (typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':3000') : ''); - - const webhooksQuery = useQuery(trpc.webhooks.list.queryOptions({ projectId })); - - const createGithubWebhookMutation = useMutation({ - mutationFn: () => - trpcClient.webhooks.create.mutate({ - projectId, - callbackBaseUrl, - githubOnly: true, - }), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: trpc.webhooks.list.queryOptions({ projectId }).queryKey, - }); - }, - }); - - const deleteGithubWebhookMutation = useMutation({ - mutationFn: (deleteCallbackBaseUrl: string) => - trpcClient.webhooks.delete.mutate({ - projectId, - callbackBaseUrl: deleteCallbackBaseUrl, - githubOnly: true, - }), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: trpc.webhooks.list.queryOptions({ projectId }).queryKey, - }); - }, - }); - - const activeGithubWebhooks = (webhooksQuery.data?.github ?? []).map((w) => ({ - id: String(w.id), - url: w.config.url ?? '', - active: w.active, - })); - - const webhookCallbackUrl = callbackBaseUrl - ? `${callbackBaseUrl}/github/webhook` - : '/github/webhook'; - const githubCurlCommand = [ - 'curl -X POST "https://api.github.com/repos///hooks" \\', - ' -H "Authorization: Bearer " \\', - ' -H "Content-Type: application/json" \\', - " -d '{", - ' "name": "web",', - ' "active": true,', - ' "events": ["push", "pull_request", "check_suite", "pull_request_review"],', - ' "config": {', - ` "url": "${webhookCallbackUrl}",`, - ' "content_type": "json"', - ' }', - " }'", - ].join('\n'); - - return ( -
    -
    - -

    - Manage GitHub webhooks for receiving push events, PR updates, and CI status notifications. -

    -
    - - {/* GitHub-specific error */} - {webhooksQuery.data?.errors?.github && ( -
    - -
    - GitHub - - : {String(webhooksQuery.data.errors.github)} - -
    - -
    - )} - - {/* Active webhooks list */} - {webhooksQuery.isLoading ? ( -
    - Loading webhooks... -
    - ) : activeGithubWebhooks.length > 0 ? ( -
    - {activeGithubWebhooks.map((w) => ( -
    -
    - - {w.url} -
    - -
    - ))} -
    - ) : ( -
    - - No GitHub webhooks configured for this project. -
    - )} - - {/* curl instructions for manual GitHub webhook creation (collapsible) */} -
    - - -

    - Manual webhook creation (alternative: if the button below doesn't work) -

    -
    -
    -

    - Use the following curl command to create the GitHub webhook manually. Requires a token - with admin:repo_hook scope. -

    -
    -
    - -
    -
    -							{githubCurlCommand}
    -						
    -
    -
    -
    - - {/* Create webhook button */} -
    - - {createGithubWebhookMutation.isError && ( -

    {createGithubWebhookMutation.error.message}

    - )} - {createGithubWebhookMutation.isSuccess && ( -

    - GitHub webhook created successfully. -

    - )} -
    -
    - ); -} - -// ============================================================================ -// SCM Tab (GitHub) -// ============================================================================ - -interface SCMTabProject { - repo?: string | null; - baseBranch?: string | null; - branchPrefix?: string | null; -} - -function SCMTab({ - projectId, - project, -}: { - projectId: string; - project?: SCMTabProject; -}) { - const queryClient = useQueryClient(); - - // Project-level SCM fields - const [repo, setRepo] = useState(project?.repo ?? ''); - const [baseBranch, setBaseBranch] = useState(project?.baseBranch ?? 'main'); - const [branchPrefix, setBranchPrefix] = useState(project?.branchPrefix ?? 'feature/'); - - useEffect(() => { - setRepo(project?.repo ?? ''); - setBaseBranch(project?.baseBranch ?? 'main'); - setBranchPrefix(project?.branchPrefix ?? 'feature/'); - }, [project?.repo, project?.baseBranch, project?.branchPrefix]); - - const saveMutation = useMutation({ - mutationFn: async () => { - // Save project-level SCM fields - await trpcClient.projects.update.mutate({ - id: projectId, - repo: repo || undefined, - baseBranch, - branchPrefix, - }); - - // Note: triggers are intentionally omitted — they are managed via the Agent Configs tab - const result = await trpcClient.projects.integrations.upsert.mutate({ - projectId, - category: 'scm', - provider: 'github', - config: {}, - }); - - return result; - }, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: trpc.projects.getById.queryOptions({ id: projectId }).queryKey, - }); - queryClient.invalidateQueries({ - queryKey: trpc.projects.listFull.queryOptions().queryKey, - }); - queryClient.invalidateQueries({ - queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, - }); - }, - }); - - return ( -
    - {/* Repository Settings */} -
    - -
    - - setRepo(e.target.value)} - placeholder="owner/repo" - /> -
    -
    -
    - - setBaseBranch(e.target.value)} - placeholder="main" - /> -
    -
    - - setBranchPrefix(e.target.value)} - placeholder="feature/" - /> -
    -
    -
    - -
    - -

    - CASCADE uses two separate GitHub bot accounts to prevent feedback loops. The{' '} - implementer writes code and creates PRs. The reviewer{' '} - reviews PRs and can approve or request changes. -

    - - - -

    - Trigger configuration has moved to the Agents tab. -

    - -
    - - {saveMutation.isSuccess && Saved} - {saveMutation.isError && ( - {saveMutation.error.message} - )} -
    - -
    - - -
    - ); -} - -// ============================================================================ -// Alerting Tab (Sentry) -// ============================================================================ - -interface AlertingTabProps { - projectId: string; - alertingIntegration?: Record; -} - -function AlertingTab({ projectId, alertingIntegration }: AlertingTabProps) { - const queryClient = useQueryClient(); - - const existingConfig = (alertingIntegration?.config as Record) ?? {}; - const [organizationSlug, setOrganizationSlug] = useState( - (existingConfig.organizationSlug as string) ?? '', - ); - - const [verifyResult, setVerifyResult] = useState<{ - id: string; - name: string; - slug: string; - } | null>(null); - const [verifyError, setVerifyError] = useState(null); - const [isVerifying, setIsVerifying] = useState(false); - - const callbackBaseUrl = - API_URL || - (typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':3000') : ''); - - const sentryWebhookUrl = callbackBaseUrl - ? `${callbackBaseUrl}/sentry/webhook/${projectId}` - : `/sentry/webhook/${projectId}`; - - const credentialsQuery = useQuery(trpc.projects.credentials.list.queryOptions({ projectId })); - const credentials = credentialsQuery.data ?? []; - const apiTokenCred = credentials.find((c) => c.envVarKey === 'SENTRY_API_TOKEN'); - const webhookSecretCred = credentials.find((c) => c.envVarKey === 'SENTRY_WEBHOOK_SECRET'); - - const handleVerify = async (rawToken: string) => { - if (!rawToken) { - setVerifyError('Enter the API token value to verify it'); - return; - } - if (!organizationSlug) { - setVerifyError('Enter the organization slug to verify it'); - return; - } - setIsVerifying(true); - setVerifyError(null); - setVerifyResult(null); - try { - const result = await trpcClient.integrationsDiscovery.verifySentry.mutate({ - apiToken: rawToken, - organizationSlug, - }); - setVerifyResult(result); - } catch (err) { - setVerifyError(err instanceof Error ? err.message : String(err)); - } finally { - setIsVerifying(false); - } - }; - - const saveMutation = useMutation({ - mutationFn: async () => { - return trpcClient.projects.integrations.upsert.mutate({ - projectId, - category: 'alerting', - provider: 'sentry', - config: { organizationSlug }, - }); - }, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, - }); - }, - }); - - const deleteMutation = useMutation({ - mutationFn: async () => { - return trpcClient.projects.integrations.delete.mutate({ - projectId, - category: 'alerting', - }); - }, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, - }); - }, - }); - - return ( -
    - {/* Organization Slug */} -
    - -

    - Your Sentry organization slug (found in your Sentry URL:{' '} - sentry.io/organizations/<slug>/). -

    - setOrganizationSlug(e.target.value)} - placeholder="my-organization" - /> -
    - -
    - - {/* Credentials */} -
    - - - -
    - -
    - - {/* Sentry Webhook URL */} -
    - -

    - Configure this URL in your Sentry project's webhook settings to receive alerts. -

    -
    - {sentryWebhookUrl} - -
    -
    - -
    - - {/* Save / Delete */} -
    - - {saveMutation.isSuccess && Saved} - {saveMutation.isError && ( - {saveMutation.error.message} - )} - {alertingIntegration && ( - - )} - {deleteMutation.isError && ( - {deleteMutation.error.message} - )} -
    -
    - ); -} - // ============================================================================ // Helpers // ============================================================================ diff --git a/web/src/components/projects/integration-scm-tab.tsx b/web/src/components/projects/integration-scm-tab.tsx new file mode 100644 index 00000000..81233f54 --- /dev/null +++ b/web/src/components/projects/integration-scm-tab.tsx @@ -0,0 +1,434 @@ +/** + * SCM (GitHub) integration tab components. + * Contains: CopyButton, GitHubCredentialSlots, GitHubWebhookSection, SCMTab. + * CopyButton is co-located here and also exported for use by AlertingTab. + */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + AlertCircle, + AlertTriangle, + Check, + Clipboard, + ExternalLink, + Info, + Loader2, + RefreshCw, + Trash2, +} from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { Input } from '@/components/ui/input.js'; +import { Label } from '@/components/ui/label.js'; +import { API_URL } from '@/lib/api.js'; +import { trpc, trpcClient } from '@/lib/trpc.js'; +import { ProjectSecretField } from './project-secret-field.js'; + +// ============================================================================ +// CopyButton (shared with AlertingTab) +// ============================================================================ + +export function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + const handleCopy = async () => { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + return ( + + ); +} + +// ============================================================================ +// GitHub Credential Slots (replaces the old CredentialSelector dropdowns) +// ============================================================================ + +function GitHubCredentialSlots({ projectId }: { projectId: string }) { + const credentialsQuery = useQuery(trpc.projects.credentials.list.queryOptions({ projectId })); + + const [verifiedLogins, setVerifiedLogins] = useState>({}); + const [verifyErrors, setVerifyErrors] = useState>({}); + const [verifyingRoles, setVerifyingRoles] = useState>({}); + + const credentials = credentialsQuery.data ?? []; + const implementerCred = credentials.find((c) => c.envVarKey === 'GITHUB_TOKEN_IMPLEMENTER'); + const reviewerCred = credentials.find((c) => c.envVarKey === 'GITHUB_TOKEN_REVIEWER'); + + const handleVerify = async (role: string, rawValue: string) => { + // If no new value entered, we can't verify (we never return plaintext to browser) + if (!rawValue) { + setVerifyErrors((prev) => ({ + ...prev, + [role]: 'Enter the token value to verify it', + })); + return; + } + setVerifyingRoles((prev) => ({ ...prev, [role]: true })); + try { + const result = await trpcClient.integrationsDiscovery.verifyGithubToken.mutate({ + token: rawValue, + }); + setVerifiedLogins((prev) => ({ ...prev, [role]: result.login })); + setVerifyErrors((prev) => ({ ...prev, [role]: null })); + } catch (err) { + setVerifiedLogins((prev) => ({ ...prev, [role]: null })); + setVerifyErrors((prev) => ({ + ...prev, + [role]: err instanceof Error ? err.message : String(err), + })); + } finally { + setVerifyingRoles((prev) => ({ ...prev, [role]: false })); + } + }; + + return ( +
    + + handleVerify('implementer', val)} + isVerifying={verifyingRoles.implementer} + verifyError={verifyErrors.implementer} + /> + handleVerify('reviewer', val)} + isVerifying={verifyingRoles.reviewer} + verifyError={verifyErrors.reviewer} + /> +
    + ); +} + +// ============================================================================ +// GitHub Webhook Management +// ============================================================================ + +function GitHubWebhookSection({ projectId }: { projectId: string }) { + const queryClient = useQueryClient(); + + const callbackBaseUrl = + API_URL || + (typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':3000') : ''); + + const webhooksQuery = useQuery(trpc.webhooks.list.queryOptions({ projectId })); + + const createGithubWebhookMutation = useMutation({ + mutationFn: () => + trpcClient.webhooks.create.mutate({ + projectId, + callbackBaseUrl, + githubOnly: true, + }), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.webhooks.list.queryOptions({ projectId }).queryKey, + }); + }, + }); + + const deleteGithubWebhookMutation = useMutation({ + mutationFn: (deleteCallbackBaseUrl: string) => + trpcClient.webhooks.delete.mutate({ + projectId, + callbackBaseUrl: deleteCallbackBaseUrl, + githubOnly: true, + }), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.webhooks.list.queryOptions({ projectId }).queryKey, + }); + }, + }); + + const activeGithubWebhooks = (webhooksQuery.data?.github ?? []).map((w) => ({ + id: String(w.id), + url: w.config.url ?? '', + active: w.active, + })); + + const webhookCallbackUrl = callbackBaseUrl + ? `${callbackBaseUrl}/github/webhook` + : '/github/webhook'; + const githubCurlCommand = [ + 'curl -X POST "https://api.github.com/repos///hooks" \\', + ' -H "Authorization: Bearer " \\', + ' -H "Content-Type: application/json" \\', + " -d '{", + ' "name": "web",', + ' "active": true,', + ' "events": ["push", "pull_request", "check_suite", "pull_request_review"],', + ' "config": {', + ` "url": "${webhookCallbackUrl}",`, + ' "content_type": "json"', + ' }', + " }'", + ].join('\n'); + + return ( +
    +
    + +

    + Manage GitHub webhooks for receiving push events, PR updates, and CI status notifications. +

    +
    + + {/* GitHub-specific error */} + {webhooksQuery.data?.errors?.github && ( +
    + +
    + GitHub + + : {String(webhooksQuery.data.errors.github)} + +
    + +
    + )} + + {/* Active webhooks list */} + {webhooksQuery.isLoading ? ( +
    + Loading webhooks... +
    + ) : activeGithubWebhooks.length > 0 ? ( +
    + {activeGithubWebhooks.map((w) => ( +
    +
    + + {w.url} +
    + +
    + ))} +
    + ) : ( +
    + + No GitHub webhooks configured for this project. +
    + )} + + {/* curl instructions for manual GitHub webhook creation (collapsible) */} +
    + + +

    + Manual webhook creation (alternative: if the button below doesn't work) +

    +
    +
    +

    + Use the following curl command to create the GitHub webhook manually. Requires a token + with admin:repo_hook scope. +

    +
    +
    + +
    +
    +							{githubCurlCommand}
    +						
    +
    +
    +
    + + {/* Create webhook button */} +
    + + {createGithubWebhookMutation.isError && ( +

    {createGithubWebhookMutation.error.message}

    + )} + {createGithubWebhookMutation.isSuccess && ( +

    + GitHub webhook created successfully. +

    + )} +
    +
    + ); +} + +// ============================================================================ +// SCM Tab (GitHub) +// ============================================================================ + +interface SCMTabProject { + repo?: string | null; + baseBranch?: string | null; + branchPrefix?: string | null; +} + +export function SCMTab({ projectId, project }: { projectId: string; project?: SCMTabProject }) { + const queryClient = useQueryClient(); + + // Project-level SCM fields + const [repo, setRepo] = useState(project?.repo ?? ''); + const [baseBranch, setBaseBranch] = useState(project?.baseBranch ?? 'main'); + const [branchPrefix, setBranchPrefix] = useState(project?.branchPrefix ?? 'feature/'); + + useEffect(() => { + setRepo(project?.repo ?? ''); + setBaseBranch(project?.baseBranch ?? 'main'); + setBranchPrefix(project?.branchPrefix ?? 'feature/'); + }, [project?.repo, project?.baseBranch, project?.branchPrefix]); + + const saveMutation = useMutation({ + mutationFn: async () => { + // Save project-level SCM fields + await trpcClient.projects.update.mutate({ + id: projectId, + repo: repo || undefined, + baseBranch, + branchPrefix, + }); + + // Note: triggers are intentionally omitted — they are managed via the Agent Configs tab + const result = await trpcClient.projects.integrations.upsert.mutate({ + projectId, + category: 'scm', + provider: 'github', + config: {}, + }); + + return result; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.projects.getById.queryOptions({ id: projectId }).queryKey, + }); + queryClient.invalidateQueries({ + queryKey: trpc.projects.listFull.queryOptions().queryKey, + }); + queryClient.invalidateQueries({ + queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, + }); + }, + }); + + return ( +
    + {/* Repository Settings */} +
    + +
    + + setRepo(e.target.value)} + placeholder="owner/repo" + /> +
    +
    +
    + + setBaseBranch(e.target.value)} + placeholder="main" + /> +
    +
    + + setBranchPrefix(e.target.value)} + placeholder="feature/" + /> +
    +
    +
    + +
    + +

    + CASCADE uses two separate GitHub bot accounts to prevent feedback loops. The{' '} + implementer writes code and creates PRs. The reviewer{' '} + reviews PRs and can approve or request changes. +

    + + + +

    + Trigger configuration has moved to the Agents tab. +

    + +
    + + {saveMutation.isSuccess && Saved} + {saveMutation.isError && ( + {saveMutation.error.message} + )} +
    + +
    + + +
    + ); +} diff --git a/web/src/components/projects/pm-wizard-common-steps.tsx b/web/src/components/projects/pm-wizard-common-steps.tsx index 330ff73a..428df778 100644 --- a/web/src/components/projects/pm-wizard-common-steps.tsx +++ b/web/src/components/projects/pm-wizard-common-steps.tsx @@ -2,7 +2,7 @@ * Provider-agnostic step renderer components for PMWizard: * WebhookStep and SaveStep. */ -import { Label } from '@/components/ui/label.js'; + import type { UseMutationResult } from '@tanstack/react-query'; import { AlertCircle, @@ -16,7 +16,9 @@ import { Trash2, } from 'lucide-react'; import { useState } from 'react'; +import { Label } from '@/components/ui/label.js'; import type { WizardState } from './pm-wizard-state.js'; +import { type ProjectCredentialMeta, ProjectSecretField } from './project-secret-field.js'; // ============================================================================ // WebhookStep @@ -50,12 +52,116 @@ function CopyButton({ text }: { text: string }) { className="inline-flex items-center gap-1 shrink-0 rounded px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-accent transition-colors" title="Copy to clipboard" > - {copied ? : } + {copied ? ( + + ) : ( + + )} {copied ? 'Copied' : 'Copy'} ); } +// ============================================================================ +// LinearWebhookInfoPanel +// ============================================================================ + +export function LinearWebhookInfoPanel({ + webhookUrl, + projectId, + webhookSecretCredential, +}: { + webhookUrl: string; + projectId: string; + webhookSecretCredential?: ProjectCredentialMeta; +}) { + return ( +
    +
    +
    + +
    +

    + Manual Webhook Setup Required +

    +

    + Linear webhooks must be configured manually in your Linear team settings. CASCADE + cannot create them programmatically. +

    +
    +
    +
    + +
    + +
    + {webhookUrl} + +
    +
    + + + +
    +

    Setup instructions:

    +
      +
    1. + Go to{' '} + + linear.app/settings/api + {' '} + and navigate to Webhooks +
    2. +
    3. Click "New webhook" and enter the URL above
    4. +
    5. + Enable these events (each maps to a CASCADE trigger handler): +
        +
      • + Issues — status transitions drive CASCADE's splitting, + planning, and implementation agents +
      • +
      • + Comments — @mentions of the CASCADE bot trigger a response agent +
      • +
      • + Issue Labels — adding the "Ready to Process" label starts + an agent on the issue +
      • +
      +
    6. +
    7. Select your team and save — webhooks are team-scoped in Linear
    8. +
    9. + If you set a signing secret in Linear, paste it into the field above so CASCADE can + verify webhook authenticity +
    10. +
    +
    + +

    + If you also set a Linear project scope in the Board / Project Selection + step, CASCADE applies that filter on its side after receiving each webhook — your Linear + webhook configuration stays team-scoped and unchanged. +

    +
    + ); +} + +// ============================================================================ +// WebhookStep +// ============================================================================ + export function WebhookStep({ state, webhooksQuery, @@ -63,6 +169,9 @@ export function WebhookStep({ callbackBaseUrl, createWebhookMutation, deleteWebhookMutation, + linearWebhookUrl, + projectId, + linearWebhookSecretCredential, }: { state: WizardState; webhooksQuery: WebhooksQueryProps; @@ -70,7 +179,21 @@ export function WebhookStep({ callbackBaseUrl: string; createWebhookMutation: UseMutationResult; deleteWebhookMutation: UseMutationResult; + linearWebhookUrl?: string; + projectId: string; + linearWebhookSecretCredential?: ProjectCredentialMeta; }) { + // Linear uses a display-only panel — no create/delete buttons + if (state.provider === 'linear') { + return ( + + ); + } + const isTrello = state.provider === 'trello'; const providerName = isTrello ? 'Trello' : 'JIRA'; diff --git a/web/src/components/projects/pm-wizard-hooks.ts b/web/src/components/projects/pm-wizard-hooks.ts index 45b138b9..21ee4b81 100644 --- a/web/src/components/projects/pm-wizard-hooks.ts +++ b/web/src/components/projects/pm-wizard-hooks.ts @@ -2,11 +2,20 @@ * Custom hooks for PM Wizard mutations and side-effects. * Each hook encapsulates one concern to keep the main orchestrator thin. */ -import { API_URL } from '@/lib/api.js'; -import { trpc, trpcClient } from '@/lib/trpc.js'; + import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useEffect } from 'react'; -import type { WizardAction, WizardState } from './pm-wizard-state.js'; +import { API_URL } from '@/lib/api.js'; +import { trpc, trpcClient } from '@/lib/trpc.js'; +import { getCredentialRoles } from '../../../../src/config/integrationRoles.js'; +import type { + LinearProjectOption, + LinearTeamDetails, + LinearTeamOption, + WizardAction, + WizardState, +} from './pm-wizard-state.js'; +import { buildLinearIntegrationConfig } from './pm-wizard-state.js'; // ============================================================================ // Trello Discovery @@ -186,6 +195,131 @@ export function useJiraDiscovery( return { jiraProjectsMutation, jiraDetailsMutation, handleProjectSelect }; } +// ============================================================================ +// Linear Discovery +// ============================================================================ + +export function useLinearDiscovery( + state: WizardState, + dispatch: React.Dispatch, + advanceToStep: (step: number) => void, + projectId: string, +) { + const linearTeamsMutation = useMutation({ + mutationFn: () => { + if (state.isEditing && state.hasStoredCredentials && !state.linearApiKey) { + return trpcClient.integrationsDiscovery.linearTeamsByProject.mutate({ projectId }); + } + if (!state.linearApiKey) { + throw new Error('Enter your API key before fetching teams'); + } + return trpcClient.integrationsDiscovery.linearTeams.mutate({ + apiKey: state.linearApiKey, + }); + }, + onSuccess: (teams) => + dispatch({ + type: 'SET_LINEAR_TEAMS', + teams: teams as LinearTeamOption[], + }), + }); + + const linearDetailsMutation = useMutation({ + mutationFn: (teamId: string) => { + if (state.isEditing && state.hasStoredCredentials && !state.linearApiKey) { + return trpcClient.integrationsDiscovery.linearTeamDetailsByProject.mutate({ + projectId, + teamId, + }); + } + if (!state.linearApiKey) { + throw new Error('Enter your API key before fetching team details'); + } + return trpcClient.integrationsDiscovery.linearTeamDetails.mutate({ + apiKey: state.linearApiKey, + teamId, + }); + }, + onSuccess: (details) => { + dispatch({ + type: 'SET_LINEAR_TEAM_DETAILS', + details: details as LinearTeamDetails, + }); + advanceToStep(4); + }, + }); + + const linearProjectsMutation = useMutation({ + mutationFn: (teamId: string) => { + if (state.isEditing && state.hasStoredCredentials && !state.linearApiKey) { + return trpcClient.integrationsDiscovery.linearProjectsByProject.mutate({ + projectId, + teamId, + }); + } + if (!state.linearApiKey) { + throw new Error('Enter your API key before fetching projects'); + } + return trpcClient.integrationsDiscovery.linearProjects.mutate({ + apiKey: state.linearApiKey, + teamId, + }); + }, + onSuccess: (projects) => + dispatch({ + type: 'SET_LINEAR_PROJECTS', + projects: projects as LinearProjectOption[], + }), + }); + + const handleTeamSelect = (teamId: string) => { + dispatch({ type: 'SET_LINEAR_TEAM_ID', id: teamId }); + if (teamId) { + linearDetailsMutation.mutate(teamId); + linearProjectsMutation.mutate(teamId); + } + }; + + // Auto-fetch teams when verification result changes + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger only on verification result change + useEffect(() => { + if (!state.verificationResult || state.provider !== 'linear') return; + if (state.linearTeams.length === 0 && !linearTeamsMutation.isPending) { + linearTeamsMutation.mutate(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.verificationResult]); + + // In edit mode, auto-fetch team list and details + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger on edit mode and stored creds + useEffect(() => { + if (!state.isEditing || state.provider !== 'linear') return; + const canFetch = state.linearApiKey ? true : state.hasStoredCredentials; + if (canFetch && state.linearTeams.length === 0 && !linearTeamsMutation.isPending) { + linearTeamsMutation.mutate(); + } + if ( + state.linearTeamId && + !state.linearTeamDetails && + canFetch && + !linearDetailsMutation.isPending + ) { + linearDetailsMutation.mutate(state.linearTeamId); + } + if ( + state.linearTeamId && + state.linearProjects.length === 0 && + canFetch && + !linearProjectsMutation.isPending + ) { + linearProjectsMutation.mutate(state.linearTeamId); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.isEditing, state.linearTeamId, state.hasStoredCredentials]); + + return { linearTeamsMutation, linearDetailsMutation, linearProjectsMutation, handleTeamSelect }; +} + // ============================================================================ // Verification // ============================================================================ @@ -208,6 +342,15 @@ export function useVerification( }); return { provider: 'trello' as const, result }; } + if (provider === 'linear') { + if (!state.linearApiKey) { + throw new Error('Enter your API key before verifying'); + } + const result = await trpcClient.integrationsDiscovery.verifyLinear.mutate({ + apiKey: state.linearApiKey, + }); + return { provider: 'linear' as const, result }; + } if (!state.jiraEmail || !state.jiraApiToken) { throw new Error('Enter both credentials before verifying'); } @@ -227,6 +370,12 @@ export function useVerification( type: 'SET_VERIFICATION', result: { provider: 'trello', display: `@${r.username} (${r.fullName})` }, }); + } else if (provider === 'linear') { + const r = result as { name: string; displayName: string }; + dispatch({ + type: 'SET_VERIFICATION', + result: { provider: 'linear', display: r.displayName || r.name }, + }); } else { const r = result as { displayName: string; emailAddress: string }; dispatch({ @@ -295,6 +444,22 @@ export function useWebhookManagement(projectId: string, state: WizardState) { }; } +// ============================================================================ +// Linear Webhook Info (display-only) +// ============================================================================ + +export function useLinearWebhookInfo() { + const callbackBaseUrl = + API_URL || + (typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':3000') : ''); + + const webhookUrl = callbackBaseUrl + ? `${callbackBaseUrl}/linear/webhook` + : '/linear/webhook'; + + return { webhookUrl }; +} + // ============================================================================ // Trello Label Creation // ============================================================================ @@ -453,7 +618,7 @@ export function useSaveMutation(projectId: string, state: WizardState) { const queryClient = useQueryClient(); const saveMutation = useMutation({ - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: handles two provider types + credential persisting + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: handles three provider types + credential persisting mutationFn: async () => { let config: Record; if (state.provider === 'trello') { @@ -463,6 +628,8 @@ export function useSaveMutation(projectId: string, state: WizardState) { labels: state.trelloLabelMappings, ...(state.trelloCostFieldId ? { customFields: { cost: state.trelloCostFieldId } } : {}), }; + } else if (state.provider === 'linear') { + config = buildLinearIntegrationConfig(state); } else { config = { projectKey: state.jiraProjectKey, @@ -501,6 +668,15 @@ export function useSaveMutation(projectId: string, state: WizardState) { name: 'Trello Token', }); } + } else if (state.provider === 'linear') { + if (state.linearApiKey) { + await trpcClient.projects.credentials.set.mutate({ + projectId, + envVarKey: 'LINEAR_API_KEY', + value: state.linearApiKey, + name: 'Linear API Key', + }); + } } else { if (state.jiraEmail) { await trpcClient.projects.credentials.set.mutate({ @@ -532,6 +708,16 @@ export function useSaveMutation(projectId: string, state: WizardState) { }); } + // If the user switched provider mid-edit, clean up the old provider's credentials. + if (state.previousProvider && state.previousProvider !== state.provider) { + const oldKeys = getCredentialRoles(state.previousProvider).map((r) => r.envVarKey); + await Promise.all( + oldKeys.map((envVarKey) => + trpcClient.projects.credentials.delete.mutate({ projectId, envVarKey }), + ), + ); + } + return result; }, onSuccess: () => { @@ -549,3 +735,65 @@ export function useSaveMutation(projectId: string, state: WizardState) { return { saveMutation }; } + +// ============================================================================ +// Linear Label Creation +// ============================================================================ + +export function useLinearLabelCreation(state: WizardState, dispatch: React.Dispatch) { + const createLabelMutation = useMutation({ + mutationFn: (vars: { name: string; color?: string; slot: string }) => { + if (!state.linearApiKey || !state.linearTeamId) { + throw new Error('Missing credentials or team selection'); + } + return trpcClient.integrationsDiscovery.createLinearLabel.mutate({ + apiKey: state.linearApiKey, + teamId: state.linearTeamId, + name: vars.name, + color: vars.color, + }); + }, + onSuccess: (label, vars) => { + dispatch({ type: 'ADD_LINEAR_TEAM_LABEL', label }); + dispatch({ type: 'SET_LINEAR_LABEL', key: vars.slot, value: label.id }); + }, + onError: (error) => { + console.error('Failed to create Linear label:', error); + alert(`Failed to create label: ${error instanceof Error ? error.message : String(error)}`); + }, + }); + + const createMissingLabelsMutation = useMutation({ + mutationFn: (labelsToCreate: Array<{ slot: string; name: string; color?: string }>) => { + if (!state.linearApiKey || !state.linearTeamId) { + throw new Error('Missing credentials or team selection'); + } + return trpcClient.integrationsDiscovery.createLinearLabels.mutate({ + apiKey: state.linearApiKey, + teamId: state.linearTeamId, + labels: labelsToCreate.map(({ name, color }) => ({ name, color })), + }); + }, + onSuccess: (result, labelsToCreate) => { + for (const label of result.successes) { + const slot = labelsToCreate.find((l) => l.name === label.name)?.slot; + if (slot) { + dispatch({ type: 'ADD_LINEAR_TEAM_LABEL', label }); + dispatch({ type: 'SET_LINEAR_LABEL', key: slot, value: label.id }); + } + } + if (result.errors.length > 0) { + const errorMsg = result.errors.map((e) => `${e.name}: ${e.error}`).join('\n'); + alert( + `Some labels failed to create:\n${errorMsg}\n\n${result.successes.length} label(s) created successfully.`, + ); + } + }, + onError: (error) => { + console.error('Failed to create Linear labels:', error); + alert(`Failed to create labels: ${error instanceof Error ? error.message : String(error)}`); + }, + }); + + return { createLabelMutation, createMissingLabelsMutation }; +} diff --git a/web/src/components/projects/pm-wizard-jira-steps.tsx b/web/src/components/projects/pm-wizard-jira-steps.tsx index cdd5042f..66205219 100644 --- a/web/src/components/projects/pm-wizard-jira-steps.tsx +++ b/web/src/components/projects/pm-wizard-jira-steps.tsx @@ -1,11 +1,12 @@ /** * JIRA-specific step renderer components for PMWizard. */ + +import type { UseMutationResult } from '@tanstack/react-query'; +import { Loader2, Plus } from 'lucide-react'; import { Button } from '@/components/ui/button.js'; import { Input } from '@/components/ui/input.js'; import { Label } from '@/components/ui/label.js'; -import type { UseMutationResult } from '@tanstack/react-query'; -import { Loader2, Plus } from 'lucide-react'; import type { WizardAction, WizardState } from './pm-wizard-state.js'; import { FieldMappingRow, SearchableSelect } from './wizard-shared.js'; diff --git a/web/src/components/projects/pm-wizard-linear-steps.tsx b/web/src/components/projects/pm-wizard-linear-steps.tsx new file mode 100644 index 00000000..fc96e6c3 --- /dev/null +++ b/web/src/components/projects/pm-wizard-linear-steps.tsx @@ -0,0 +1,320 @@ +/** + * Linear-specific step renderer components for PMWizard. + */ + +import type { UseMutationResult } from '@tanstack/react-query'; +import { CheckCircle2, Loader2, Plus } from 'lucide-react'; +import { Button } from '@/components/ui/button.js'; +import { Input } from '@/components/ui/input.js'; +import { Label } from '@/components/ui/label.js'; +import type { WizardAction, WizardState } from './pm-wizard-state.js'; +import { FieldMappingRow, SearchableSelect } from './wizard-shared.js'; + +// ============================================================================ +// Slot definitions +// ============================================================================ + +const LINEAR_STATUS_SLOTS = [ + 'backlog', + 'splitting', + 'planning', + 'todo', + 'inProgress', + 'inReview', + 'done', + 'merged', +] as const; + +const LINEAR_LABEL_SLOTS = ['processing', 'processed', 'error', 'readyToProcess', 'auto']; + +/** + * Default CASCADE label names + hex colors used when the operator clicks + * "Create" on an unmapped slot. Linear expects hex color strings on + * issueLabelCreate; picked to roughly match the Trello named-color palette. + */ +export const LINEAR_LABEL_DEFAULTS: Record = { + readyToProcess: { name: 'cascade-ready', color: '#0284C7' }, + processing: { name: 'cascade-processing', color: '#2563EB' }, + processed: { name: 'cascade-processed', color: '#16A34A' }, + error: { name: 'cascade-error', color: '#DC2626' }, + auto: { name: 'cascade-auto', color: '#9333EA' }, +}; + +// ============================================================================ +// LinearCredentialsStep +// ============================================================================ + +export function LinearCredentialsStep({ + state, + dispatch, +}: { + state: WizardState; + dispatch: React.Dispatch; +}) { + return ( +
    + {state.isEditing && state.hasStoredCredentials && !state.linearApiKey && ( +
    + + Credentials stored — enter new values below to replace them. +
    + )} +

    + Enter your Linear API key. This will be saved securely to the project. +

    +
    + + dispatch({ type: 'SET_LINEAR_API_KEY', value: e.target.value })} + placeholder="lin_api_..." + autoComplete="off" + /> +

    + Generate a Personal API key at{' '} + + linear.app/settings/api + +

    +
    +
    + ); +} + +// ============================================================================ +// LinearTeamStep +// ============================================================================ + +export function LinearTeamStep({ + state, + onTeamSelect, + dispatch, + linearTeamsMutation, + linearDetailsMutation, + linearProjectsMutation, +}: { + state: WizardState; + onTeamSelect: (id: string) => void; + dispatch: React.Dispatch; + linearTeamsMutation: UseMutationResult; + linearDetailsMutation: UseMutationResult; + linearProjectsMutation: UseMutationResult; +}) { + return ( +
    +
    + + ({ + label: t.name, + value: t.id, + detail: t.key, + }))} + value={state.linearTeamId} + onChange={onTeamSelect} + placeholder="Select a Linear team..." + isLoading={linearTeamsMutation.isPending} + error={linearTeamsMutation.isError ? (linearTeamsMutation.error as Error).message : null} + onRetry={() => + (linearTeamsMutation as UseMutationResult).mutate() + } + /> + {state.linearTeamId && linearDetailsMutation.isPending && ( +
    + Loading team details... +
    + )} +
    + + {state.linearTeamId && ( +
    + + ({ + label: p.name, + value: p.id, + }))} + value={state.linearProjectId} + onChange={(v) => dispatch({ type: 'SET_LINEAR_PROJECT_ID', value: v })} + placeholder="No project scope — all team issues" + isLoading={linearProjectsMutation.isPending} + error={ + linearProjectsMutation.isError + ? (linearProjectsMutation.error as Error).message + : null + } + onRetry={() => linearProjectsMutation.mutate(state.linearTeamId)} + /> +

    + Optional — leave empty to process all issues in this team. When set, CASCADE only + responds to issues that belong to this Linear Project. +

    +
    + )} +
    + ); +} + +// ============================================================================ +// LinearFieldMappingStep +// ============================================================================ + +export function LinearFieldMappingStep({ + state, + dispatch, + onCreateLabel, + onCreateAllMissingLabels, + creatingSlot, +}: { + state: WizardState; + dispatch: React.Dispatch; + onCreateLabel?: (slot: string) => void; + onCreateAllMissingLabels?: () => void; + creatingSlot?: string | null; +}) { + const existingLabelNames = new Set( + (state.linearTeamDetails?.labels ?? []).map((l) => l.name.toLowerCase()), + ); + + const missingSlots = LINEAR_LABEL_SLOTS.filter((slot) => { + if (state.linearLabels[slot]) return false; + const defaultName = LINEAR_LABEL_DEFAULTS[slot]?.name ?? ''; + return !existingLabelNames.has(defaultName.toLowerCase()); + }); + + return ( +
    + {/* Status mappings */} +
    + +

    + Map each CASCADE status to a Linear workflow state in the team. +

    + {state.linearTeamDetails ? ( + LINEAR_STATUS_SLOTS.map((slot) => ( + ({ + label: s.name, + value: s.id, + })) ?? [] + } + value={state.linearStatusMappings[slot] ?? ''} + onChange={(v) => + dispatch({ + type: 'SET_LINEAR_STATUS_MAPPING', + key: slot, + value: v, + }) + } + manualFallback + /> + )) + ) : ( +

    + Select a team first to populate status options. +

    + )} +
    + + {/* Label mappings */} +
    +
    + + {state.linearTeamDetails && missingSlots.length > 0 && onCreateAllMissingLabels && ( + + )} +
    +

    + Map each CASCADE label to a Linear label on the team. Click "Create" to add missing ones. +

    + {state.linearTeamDetails ? ( + LINEAR_LABEL_SLOTS.map((slot) => { + const isMapped = !!state.linearLabels[slot]; + const defaultInfo = LINEAR_LABEL_DEFAULTS[slot]; + const alreadyExists = + defaultInfo && existingLabelNames.has(defaultInfo.name.toLowerCase()); + const showCreateButton = !isMapped && !alreadyExists && onCreateLabel && defaultInfo; + + return ( +
    +
    + l.name) + .map((l) => ({ + label: `${l.name} (${l.color})`, + value: l.id, + })) ?? [] + } + value={state.linearLabels[slot] ?? ''} + onChange={(v) => + dispatch({ + type: 'SET_LINEAR_LABEL', + key: slot, + value: v, + }) + } + manualFallback + /> +
    + {showCreateButton && ( + + )} +
    + ); + }) + ) : ( +

    + Select a team first to populate label options. +

    + )} +
    +
    + ); +} diff --git a/web/src/components/projects/pm-wizard-state.ts b/web/src/components/projects/pm-wizard-state.ts index 8be4e6d3..99685951 100644 --- a/web/src/components/projects/pm-wizard-state.ts +++ b/web/src/components/projects/pm-wizard-state.ts @@ -31,7 +31,25 @@ export interface JiraProjectDetails { fields: Array<{ id: string; name: string; custom: boolean }>; } -export type Provider = 'trello' | 'jira'; +export interface LinearTeamOption { + id: string; + name: string; + key: string; +} + +export interface LinearProjectOption { + id: string; + name: string; + icon: string | null; + color: string | null; +} + +export interface LinearTeamDetails { + states: Array<{ id: string; name: string; type: string }>; + labels: Array<{ id: string; name: string; color: string }>; +} + +export type Provider = 'trello' | 'jira' | 'linear'; export interface WizardState { provider: Provider; @@ -41,6 +59,7 @@ export interface WizardState { jiraEmail: string; jiraApiToken: string; jiraBaseUrl: string; + linearApiKey: string; verificationResult: { provider: Provider; display: string } | null; verifyError: string | null; // Step 3: Board/Project @@ -48,9 +67,14 @@ export interface WizardState { trelloBoards: TrelloBoardOption[]; jiraProjectKey: string; jiraProjects: JiraProjectOption[]; + linearTeamId: string; + linearTeams: LinearTeamOption[]; + linearProjectId: string; + linearProjects: LinearProjectOption[]; // Step 4: Field mapping trelloBoardDetails: TrelloBoardDetails | null; jiraProjectDetails: JiraProjectDetails | null; + linearTeamDetails: LinearTeamDetails | null; // Trello mappings trelloListMappings: Record; trelloLabelMappings: Record; @@ -60,9 +84,18 @@ export interface WizardState { jiraIssueTypes: Record; jiraLabels: Record; jiraCostFieldId: string; + // Linear mappings + linearStatusMappings: Record; + linearLabels: Record; // Editing mode isEditing: boolean; hasStoredCredentials: boolean; // true in edit mode when provider credentials exist in project_credentials + /** + * Provider that was loaded from the server at INIT_EDIT time. Used by the save flow + * to clean up the prior provider's credentials when the user switches provider + * mid-edit. Undefined on first-time setup. + */ + previousProvider?: Provider; } export type WizardAction = @@ -72,6 +105,7 @@ export type WizardAction = | { type: 'SET_JIRA_EMAIL'; value: string } | { type: 'SET_JIRA_API_TOKEN'; value: string } | { type: 'SET_JIRA_BASE_URL'; url: string } + | { type: 'SET_LINEAR_API_KEY'; value: string } | { type: 'SET_VERIFICATION'; result: { provider: Provider; display: string } | null; @@ -81,6 +115,11 @@ export type WizardAction = | { type: 'SET_TRELLO_BOARD_ID'; id: string } | { type: 'SET_JIRA_PROJECTS'; projects: JiraProjectOption[] } | { type: 'SET_JIRA_PROJECT_KEY'; key: string } + | { type: 'SET_LINEAR_TEAMS'; teams: LinearTeamOption[] } + | { type: 'SET_LINEAR_TEAM_ID'; id: string } + | { type: 'SET_LINEAR_TEAM_DETAILS'; details: LinearTeamDetails | null } + | { type: 'SET_LINEAR_PROJECTS'; projects: LinearProjectOption[] } + | { type: 'SET_LINEAR_PROJECT_ID'; value: string } | { type: 'SET_TRELLO_BOARD_DETAILS'; details: TrelloBoardDetails | null } | { type: 'SET_JIRA_PROJECT_DETAILS'; details: JiraProjectDetails | null } | { type: 'SET_TRELLO_LIST_MAPPING'; key: string; value: string } @@ -90,8 +129,11 @@ export type WizardAction = | { type: 'SET_JIRA_ISSUE_TYPE'; key: string; value: string } | { type: 'SET_JIRA_LABEL'; key: string; value: string } | { type: 'SET_JIRA_COST_FIELD'; id: string } + | { type: 'SET_LINEAR_STATUS_MAPPING'; key: string; value: string } + | { type: 'SET_LINEAR_LABEL'; key: string; value: string } | { type: 'INIT_EDIT'; state: Partial } | { type: 'ADD_TRELLO_BOARD_LABEL'; label: { id: string; name: string; color: string } } + | { type: 'ADD_LINEAR_TEAM_LABEL'; label: { id: string; name: string; color: string } } | { type: 'ADD_TRELLO_BOARD_CUSTOM_FIELD'; customField: { id: string; name: string; type: string }; @@ -110,6 +152,14 @@ export const INITIAL_JIRA_LABELS: Record = { auto: 'cascade-auto', }; +/** + * Linear label mappings store workflow-label **UUIDs**, not names, because + * Linear's GraphQL API rejects names for issueUpdate.labelIds. The wizard + * populates these from the team's existing labels or via the create-label + * button. Initial state is therefore empty — operators pick or create. + */ +export const INITIAL_LINEAR_LABELS: Record = {}; + export function createInitialState(): WizardState { return { provider: 'trello', @@ -118,14 +168,20 @@ export function createInitialState(): WizardState { jiraEmail: '', jiraApiToken: '', jiraBaseUrl: '', + linearApiKey: '', verificationResult: null, verifyError: null, trelloBoardId: '', trelloBoards: [], jiraProjectKey: '', jiraProjects: [], + linearTeamId: '', + linearTeams: [], + linearProjectId: '', + linearProjects: [], trelloBoardDetails: null, jiraProjectDetails: null, + linearTeamDetails: null, trelloListMappings: {}, trelloLabelMappings: {}, trelloCostFieldId: '', @@ -133,6 +189,8 @@ export function createInitialState(): WizardState { jiraIssueTypes: {}, jiraLabels: { ...INITIAL_JIRA_LABELS }, jiraCostFieldId: '', + linearStatusMappings: {}, + linearLabels: { ...INITIAL_LINEAR_LABELS }, isEditing: false, hasStoredCredentials: false, }; @@ -145,9 +203,13 @@ export function createInitialState(): WizardState { export const wizardReducer: Reducer = (state, action) => { switch (action.type) { case 'SET_PROVIDER': + // Preserve edit-mode flags so a provider switch on an existing integration + // still knows which provider to clean up at save time. return { ...createInitialState(), provider: action.provider, + isEditing: state.isEditing, + previousProvider: state.previousProvider, }; case 'SET_TRELLO_API_KEY': return { @@ -179,6 +241,13 @@ export const wizardReducer: Reducer = (state, action) }; case 'SET_JIRA_BASE_URL': return { ...state, jiraBaseUrl: action.url, verificationResult: null, verifyError: null }; + case 'SET_LINEAR_API_KEY': + return { + ...state, + linearApiKey: action.value, + verificationResult: null, + verifyError: null, + }; case 'SET_VERIFICATION': return { ...state, verificationResult: action.result, verifyError: action.error ?? null }; case 'SET_TRELLO_BOARDS': @@ -203,6 +272,26 @@ export const wizardReducer: Reducer = (state, action) jiraIssueTypes: {}, jiraCostFieldId: '', }; + case 'SET_LINEAR_TEAMS': + return { ...state, linearTeams: action.teams }; + case 'SET_LINEAR_TEAM_ID': + return { + ...state, + linearTeamId: action.id, + linearTeamDetails: null, + linearStatusMappings: {}, + // A new team invalidates the project list and any chosen project — + // Linear projects are team-scoped, so the previous selection is + // not guaranteed to belong to the new team. + linearProjectId: '', + linearProjects: [], + }; + case 'SET_LINEAR_PROJECTS': + return { ...state, linearProjects: action.projects }; + case 'SET_LINEAR_PROJECT_ID': + return { ...state, linearProjectId: action.value }; + case 'SET_LINEAR_TEAM_DETAILS': + return { ...state, linearTeamDetails: action.details }; case 'SET_TRELLO_BOARD_DETAILS': return { ...state, trelloBoardDetails: action.details }; case 'SET_JIRA_PROJECT_DETAILS': @@ -236,8 +325,22 @@ export const wizardReducer: Reducer = (state, action) }; case 'SET_JIRA_COST_FIELD': return { ...state, jiraCostFieldId: action.id }; - case 'INIT_EDIT': - return { ...state, ...action.state, isEditing: true }; + case 'SET_LINEAR_STATUS_MAPPING': + return { + ...state, + linearStatusMappings: { ...state.linearStatusMappings, [action.key]: action.value }, + }; + case 'SET_LINEAR_LABEL': + return { + ...state, + linearLabels: { ...state.linearLabels, [action.key]: action.value }, + }; + case 'INIT_EDIT': { + const merged = { ...state, ...action.state, isEditing: true }; + // Snapshot the loaded provider so a later SET_PROVIDER knows what to clean up. + merged.previousProvider = merged.provider; + return merged; + } case 'ADD_TRELLO_BOARD_LABEL': if (!state.trelloBoardDetails) return state; return { @@ -247,6 +350,15 @@ export const wizardReducer: Reducer = (state, action) labels: [...state.trelloBoardDetails.labels, action.label], }, }; + case 'ADD_LINEAR_TEAM_LABEL': + if (!state.linearTeamDetails) return state; + return { + ...state, + linearTeamDetails: { + ...state.linearTeamDetails, + labels: [...state.linearTeamDetails.labels, action.label], + }, + }; case 'ADD_TRELLO_BOARD_CUSTOM_FIELD': if (!state.trelloBoardDetails) return state; return { @@ -323,6 +435,17 @@ export function buildEditState( editState.hasStoredCredentials = configuredKeys.has('JIRA_EMAIL') && configuredKeys.has('JIRA_API_TOKEN'); + } else if (provider === 'linear') { + editState.linearTeamId = (initialConfig.teamId as string) ?? ''; + editState.linearProjectId = (initialConfig.projectId as string) ?? ''; + + const statuses = initialConfig.statuses as Record | undefined; + if (statuses) editState.linearStatusMappings = statuses; + + const labels = initialConfig.labels as Record | undefined; + if (labels) editState.linearLabels = labels; + + editState.hasStoredCredentials = configuredKeys.has('LINEAR_API_KEY'); } return editState; @@ -341,22 +464,72 @@ export function isStep2Complete(state: WizardState): boolean { const credsReady = state.provider === 'trello' ? !!(state.trelloApiKey && state.trelloToken) - : !!(state.jiraEmail && state.jiraApiToken && state.jiraBaseUrl); + : state.provider === 'jira' + ? !!(state.jiraEmail && state.jiraApiToken && state.jiraBaseUrl) + : !!state.linearApiKey; return credsReady && !!state.verificationResult; } export function isStep3Complete(state: WizardState): boolean { - return state.provider === 'trello' ? !!state.trelloBoardId : !!state.jiraProjectKey; + if (state.provider === 'trello') return !!state.trelloBoardId; + if (state.provider === 'jira') return !!state.jiraProjectKey; + return !!state.linearTeamId; } export function isStep4Complete(state: WizardState): boolean { - return state.provider === 'trello' - ? Object.keys(state.trelloListMappings).length > 0 - : Object.keys(state.jiraStatusMappings).length > 0; + if (state.provider === 'trello') return Object.keys(state.trelloListMappings).length > 0; + if (state.provider === 'jira') return Object.keys(state.jiraStatusMappings).length > 0; + return Object.keys(state.linearStatusMappings).length > 0; } export function areCredentialsReady(state: WizardState): boolean { - return state.provider === 'trello' - ? !!(state.trelloApiKey && state.trelloToken) - : !!(state.jiraEmail && state.jiraApiToken && state.jiraBaseUrl); + if (state.provider === 'trello') return !!(state.trelloApiKey && state.trelloToken); + if (state.provider === 'jira') + return !!(state.jiraEmail && state.jiraApiToken && state.jiraBaseUrl); + return !!state.linearApiKey; +} + +/** + * Build the Linear integration config payload from wizard state. + * Pure function so it can be unit-tested without the React runtime. + */ +export function buildLinearIntegrationConfig(state: WizardState): Record { + return { + teamId: state.linearTeamId, + ...(state.linearProjectId ? { projectId: state.linearProjectId } : {}), + statuses: state.linearStatusMappings, + ...(Object.keys(state.linearLabels).length > 0 ? { labels: state.linearLabels } : {}), + }; +} + +/** + * Map the provider's webhook listing into the shape expected by `WebhookStep`. + * Linear webhooks are configured manually outside the wizard; Trello/JIRA come + * from the corresponding API listing. + */ +export function deriveActiveWebhooks( + provider: Provider, + webhooksData: + | { + trello?: ReadonlyArray<{ id: string | number; callbackURL: string; active: boolean }>; + jira?: ReadonlyArray<{ id: string | number; url: string; enabled: boolean }>; + } + | undefined, +): Array<{ id: string; url: string; active: boolean }> { + if (provider === 'trello') { + return (webhooksData?.trello ?? []).map((w) => ({ + id: String(w.id), + url: w.callbackURL, + active: w.active, + })); + } + if (provider === 'jira') { + return (webhooksData?.jira ?? []).map((w) => ({ + id: String(w.id), + url: w.url, + active: w.enabled, + })); + } + // Linear: webhooks are configured manually + return []; } diff --git a/web/src/components/projects/pm-wizard-trello-steps.tsx b/web/src/components/projects/pm-wizard-trello-steps.tsx index d381f370..94ab9496 100644 --- a/web/src/components/projects/pm-wizard-trello-steps.tsx +++ b/web/src/components/projects/pm-wizard-trello-steps.tsx @@ -1,13 +1,14 @@ /** * Trello-specific step renderer components for PMWizard. */ -import { Button } from '@/components/ui/button.js'; -import { Input } from '@/components/ui/input.js'; -import { Label } from '@/components/ui/label.js'; + import type { UseMutationResult } from '@tanstack/react-query'; import { CheckCircle2, Loader2, Plus } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; +import { Button } from '@/components/ui/button.js'; +import { Input } from '@/components/ui/input.js'; +import { Label } from '@/components/ui/label.js'; import type { WizardAction, WizardState } from './pm-wizard-state.js'; import { FieldMappingRow, SearchableSelect } from './wizard-shared.js'; @@ -119,8 +120,8 @@ export function TrelloCredentialsStep({ {state.trelloToken ? (
    - - Token set + + Token set ))}
    @@ -251,6 +305,8 @@ export function PMWizard({ > {state.provider === 'trello' ? ( + ) : state.provider === 'linear' ? ( + ) : ( )} @@ -301,6 +357,15 @@ export function PMWizard({ boardsMutation={boardsMutation} boardDetailsMutation={boardDetailsMutation} /> + ) : state.provider === 'linear' ? ( + ) : ( + ) : state.provider === 'linear' ? ( + ) : ( diff --git a/web/src/components/projects/project-agent-configs.tsx b/web/src/components/projects/project-agent-configs.tsx index abf302bd..34d6787c 100644 --- a/web/src/components/projects/project-agent-configs.tsx +++ b/web/src/components/projects/project-agent-configs.tsx @@ -1,882 +1,17 @@ -import { engineCredentialKeys } from '@/components/projects/engine-secrets.js'; -import { EngineSettingsFields } from '@/components/settings/engine-settings-fields.js'; -import { ModelField } from '@/components/settings/model-field.js'; -import { - DefinitionTriggerToggles, - type ResolvedTrigger, -} from '@/components/shared/definition-trigger-toggles.js'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog.js'; -import { Badge } from '@/components/ui/badge.js'; -import { Input } from '@/components/ui/input.js'; -import { Label } from '@/components/ui/label.js'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select.js'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table.js'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs.js'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip.js'; -import { - AGENT_LABELS, - CATEGORY_LABELS, - type TriggerParameterValue, -} from '@/lib/trigger-agent-mapping.js'; -import { trpc, trpcClient } from '@/lib/trpc.js'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { AlertTriangle, ArrowLeft, ChevronRight, Trash2 } from 'lucide-react'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useState } from 'react'; import { toast } from 'sonner'; -import { AgentPromptOverrides } from './agent-prompt-overrides.js'; - -interface AgentConfig { - id: number; - agentType: string; - model: string | null; - maxIterations: number | null; - agentEngine: string | null; - agentEngineSettings: Record> | null; - maxConcurrency: number | null; - systemPrompt: string | null; - taskPrompt: string | null; -} - -interface EngineSettingFieldOption { - value: string; - label: string; -} - -type EngineSettingField = - | { - key: string; - label: string; - type: 'select'; - description?: string; - options: EngineSettingFieldOption[]; - } - | { key: string; label: string; type: 'boolean'; description?: string } - | { - key: string; - label: string; - type: 'number'; - description?: string; - min?: number; - max?: number; - step?: number; - }; - -interface Engine { - id: string; - label: string; - settings?: { - title?: string; - description?: string; - fields: EngineSettingField[]; - }; -} - -// ============================================================================ -// Definition-Based Agent Section (New) -// ============================================================================ - -interface SaveConfigValues { - model: string; - maxIterations: string; - agentEngine: string; - maxConcurrency: string; - engineSettings: Record> | undefined; - systemPrompt: string; - taskPrompt: string; - /** True when the user explicitly cleared the system prompt override (send null, not the fallback text). */ - systemPromptCleared: boolean; - /** True when the user explicitly cleared the task prompt override (send null, not the fallback text). */ - taskPromptCleared: boolean; -} - -interface SystemDefaults { - model: string; - maxIterations: number; - agentEngine: string; - engineSettings: Record>; -} - -interface DefinitionAgentSectionProps { - agentType: string; - projectId: string; - config: AgentConfig | null; - triggers: ResolvedTrigger[]; - integrations: { - pm: string | null; - scm: string | null; - }; - engines: Engine[]; - isSaving: boolean; - onSaveConfig: (agentType: string, configId: number | null, values: SaveConfigValues) => void; - saveSuccessNonce: number; - onDeleteConfig: (id: number) => void; - onTriggerToggle: (agentType: string, event: string, enabled: boolean) => void; - onTriggerParamChange: ( - agentType: string, - event: string, - parameters: Record, - currentEnabled: boolean, - ) => void; - /** Project-level model (null = use system default). */ - projectModel: string | null; - /** Project-level engine (null = use system default). */ - projectEngine: string | null; - /** Project-level maxIterations (null = use system default). */ - projectMaxIterations: number | null; - /** System-level defaults from the backend. */ - systemDefaults: SystemDefaults | undefined; -} - -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: tabbed detail panel managing Engine/Prompts/Triggers tabs with per-tab state, mutations, and trigger category grouping -function DefinitionAgentSection({ - agentType, - projectId, - config, - triggers, - integrations, - engines, - isSaving, - onSaveConfig, - saveSuccessNonce, - onDeleteConfig, - onTriggerToggle, - onTriggerParamChange, - projectModel, - projectEngine, - projectMaxIterations, - systemDefaults, -}: DefinitionAgentSectionProps) { - const [saved, setSaved] = useState(false); - const savedTimerRef = useRef | null>(null); - // Tracks whether a successful save is in flight (prevents config sync from clearing "Saved") - const justSavedRef = useRef(false); - - // Local form state — engine fields - const [model, setModel] = useState(config?.model ?? ''); - const [maxIterations, setMaxIterations] = useState(config?.maxIterations?.toString() ?? ''); - const [agentEngine, setAgentEngine] = useState(config?.agentEngine ?? ''); - const [maxConcurrency, setMaxConcurrency] = useState(config?.maxConcurrency?.toString() ?? ''); - const [engineSettings, setEngineSettings] = useState< - Record> | undefined - >(config?.agentEngineSettings ?? undefined); - - // Local form state — prompt fields (initialized by AgentPromptOverrides component) - const [systemPrompt, setSystemPrompt] = useState(config?.systemPrompt ?? ''); - const [taskPrompt, setTaskPrompt] = useState(config?.taskPrompt ?? ''); - // Track whether the user explicitly cleared a prompt override so we can send null on save - // instead of the fallback display text (which would create a duplicate "custom" override). - const [systemPromptCleared, setSystemPromptCleared] = useState(false); - const [taskPromptCleared, setTaskPromptCleared] = useState(false); - - const effectiveEngineId = agentEngine || ''; - const effectiveEngine = engines.find((engine) => engine.id === effectiveEngineId); - - // Resolved inherited engine — project override or system default - const inheritedEngine = projectEngine ?? systemDefaults?.agentEngine ?? 'claude-code'; - // Per-field engine defaults for the EngineSettingsFields component - const engineDefaults = - systemDefaults && effectiveEngineId - ? systemDefaults.engineSettings[effectiveEngineId] - : undefined; - - // Resolved inherited model and iterations (walk the chain: project → system) - const inheritedModel = projectModel ?? systemDefaults?.model; - const inheritedMaxIterations = projectMaxIterations ?? systemDefaults?.maxIterations; - - // Sync form state when config changes (e.g. after invalidateQueries refetch) - // Skip clearing "Saved" if we just saved — the nonce effect will handle the timer - useEffect(() => { - setModel(config?.model ?? ''); - setMaxIterations(config?.maxIterations?.toString() ?? ''); - setAgentEngine(config?.agentEngine ?? ''); - setMaxConcurrency(config?.maxConcurrency?.toString() ?? ''); - setEngineSettings(config?.agentEngineSettings ?? undefined); - setSystemPrompt(config?.systemPrompt ?? ''); - setTaskPrompt(config?.taskPrompt ?? ''); - setSystemPromptCleared(false); - setTaskPromptCleared(false); - if (justSavedRef.current) { - justSavedRef.current = false; - } else { - setSaved(false); - } - }, [config]); - - // Show "Saved" indicator only after confirmed persistence (nonce increments on each success) - useEffect(() => { - if (saveSuccessNonce === 0) return; - // Mark that a save just completed so the config sync effect won't clear the indicator - justSavedRef.current = true; - if (savedTimerRef.current !== null) { - clearTimeout(savedTimerRef.current); - } - setSaved(true); - savedTimerRef.current = setTimeout(() => setSaved(false), 2000); - }, [saveSuccessNonce]); - - // Clean up the "Saved" timer on unmount to avoid state updates on unmounted component - useEffect(() => { - return () => { - if (savedTimerRef.current !== null) { - clearTimeout(savedTimerRef.current); - } - }; - }, []); - - // Group triggers by category and filter by active integrations - const triggersByCategory = useMemo(() => { - const groups: Record = { - pm: [], - scm: [], - internal: [], - }; - - for (const trigger of triggers) { - // Extract category from event (e.g., "pm:card-moved" -> "pm") - const [category] = trigger.event.split(':'); - if (category in groups) { - // Filter by provider if the trigger has provider restrictions - if (trigger.providers && trigger.providers.length > 0) { - const activeProvider = integrations[category as keyof typeof integrations]; - const matchesProvider = trigger.providers.some((p) => p === activeProvider); - if (!matchesProvider) continue; - } - groups[category].push(trigger); - } - } - - return groups; - }, [triggers, integrations]); - - const hasTriggers = - triggersByCategory.pm.length > 0 || - triggersByCategory.scm.length > 0 || - triggersByCategory.internal.length > 0; - - const handleSave = () => { - onSaveConfig(agentType, config?.id ?? null, { - model, - maxIterations, - agentEngine, - maxConcurrency, - engineSettings, - systemPrompt, - taskPrompt, - systemPromptCleared, - taskPromptCleared, - }); - }; - - const handleCancel = () => { - setModel(config?.model ?? ''); - setMaxIterations(config?.maxIterations?.toString() ?? ''); - setAgentEngine(config?.agentEngine ?? ''); - setMaxConcurrency(config?.maxConcurrency?.toString() ?? ''); - setEngineSettings(config?.agentEngineSettings ?? undefined); - setSystemPrompt(config?.systemPrompt ?? ''); - setTaskPrompt(config?.taskPrompt ?? ''); - setSystemPromptCleared(false); - setTaskPromptCleared(false); - }; - - const handleDelete = () => { - if (config && window.confirm('Delete this agent config?')) { - onDeleteConfig(config.id); - } - }; - - return ( -
    - - - Engine - Prompts - Triggers - - - {/* Engine Tab */} - -
    - - -
    -
    - - -
    - {effectiveEngine && ( - - )} -
    -
    - - setMaxIterations(e.target.value)} - placeholder={ - inheritedMaxIterations !== undefined - ? `${inheritedMaxIterations} (inherited)` - : 'Optional' - } - /> -
    -
    - - setMaxConcurrency(e.target.value)} - placeholder="Optional" - /> -
    -
    -
    - - {/* Prompts Tab */} - - { - setSystemPrompt(v); - // User is editing manually — cancel any pending clear - setSystemPromptCleared(false); - }} - taskPrompt={taskPrompt} - onTaskPromptChange={(v) => { - setTaskPrompt(v); - // User is editing manually — cancel any pending clear - setTaskPromptCleared(false); - }} - onSystemPromptClear={() => setSystemPromptCleared(true)} - onTaskPromptClear={() => setTaskPromptCleared(true)} - /> - - - {/* Triggers Tab */} - - {(['pm', 'scm', 'internal'] as const).map((category) => { - const categoryTriggers = triggersByCategory[category]; - if (categoryTriggers.length === 0) return null; - - return ( -
    -

    - {CATEGORY_LABELS[category] ?? category} Triggers -

    - onTriggerToggle(agentType, event, enabled)} - onParamChange={(event, params) => { - // Find the current trigger to get its enabled state - const currentTrigger = categoryTriggers.find((t) => t.event === event); - onTriggerParamChange(agentType, event, params, currentTrigger?.enabled ?? true); - }} - idPrefix={`${agentType}-${category}`} - /> -
    - ); - })} - - {!hasTriggers && ( -

    - No trigger configuration for this agent. -

    - )} -
    -
    - - {/* Footer actions — outside tabs, applies globally */} -
    -
    - - - {saved && Saved} -
    - {config && ( - - )} -
    -
    - ); -} - -/** - * Returns true when the given engine has at least one credential key configured. - * Derived from ENGINE_SECRETS in engine-secrets.ts — no separate mapping to maintain. - * If the engine is not in the map, we conservatively assume credentials are present. - */ -function engineHasCredentials(engineId: string, configuredCredentialKeys: Set): boolean { - const requiredKeys = engineCredentialKeys[engineId]; - if (!requiredKeys) return true; // Unknown engine — assume ok - return requiredKeys.some((key) => configuredCredentialKeys.has(key)); -} - -// ============================================================================ -// Agent List View -// ============================================================================ - -function countActiveTriggers( - triggers: ResolvedTrigger[], - integrations: { pm: string | null; scm: string | null }, -): number { - return triggers.filter((t) => { - if (!t.enabled) return false; - const [category] = t.event.split(':'); - if (t.providers && t.providers.length > 0) { - const activeProvider = integrations[category as keyof typeof integrations]; - return t.providers.some((p) => p === activeProvider); - } - return true; - }).length; -} - -interface AgentRowProps { - type: string; - config: AgentConfig | null; - triggers: ResolvedTrigger[]; - integrations: { pm: string | null; scm: string | null }; - onSelect: (agentType: string) => void; - onDeleteRequest: (id: number, label: string) => void; - /** Project-level model to show as "inherited" when agent has no override. */ - projectModel: string | null; - /** Project-level engine to show as "inherited" when agent has no override. */ - projectEngine: string | null; - /** System-level defaults. */ - systemDefaults: SystemDefaults | undefined; - /** Set of credential env-var keys that are configured for this project. */ - configuredCredentialKeys: Set; -} - -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: table row with multiple computed display values (model, engine, trigger count) and layered inheritance fallbacks -function AgentRow({ - type, - config, - triggers, - integrations, - onSelect, - onDeleteRequest, - projectModel, - projectEngine, - systemDefaults, - configuredCredentialKeys, -}: AgentRowProps) { - const label = (AGENT_LABELS as Record)[type] ?? type; - const activeTriggerCount = countActiveTriggers(triggers, integrations); - const modelInfo = config?.model ?? null; - const engineInfo = config?.agentEngine ?? null; - const hasCustomEngineSettings = - config?.agentEngineSettings != null && Object.keys(config.agentEngineSettings).length > 0; - - // Fallback display: show inherited model/engine when agent has no specific override - const inheritedModel = projectModel ?? systemDefaults?.model ?? null; - const inheritedEngine = projectEngine ?? systemDefaults?.agentEngine ?? null; - const displayModel = modelInfo ?? (inheritedModel ? `${inheritedModel} (inherited)` : null); - const displayEngine = engineInfo ?? (inheritedEngine ? `${inheritedEngine} (inherited)` : null); - - // Check if the agent's effective engine has credentials configured - // Only check when there is an explicit agent-level engine override - const agentEngineId = config?.agentEngine ?? null; - const hasMissingCredentials = - agentEngineId !== null && !engineHasCredentials(agentEngineId, configuredCredentialKeys); - - return ( - onSelect(type)}> - {label} - - {activeTriggerCount === 0 ? ( - - Inactive - - ) : config ? ( -
    - - Configured - - {hasMissingCredentials && ( - - - - - Missing credentials - - - - This agent uses the {agentEngineId} engine but no credentials are configured for - it. Configure credentials on the Harness tab. - - - )} -
    - ) : ( - - Default - - )} -
    - - {displayModel || displayEngine ? ( - - {displayEngine && {displayEngine}} - {displayEngine && displayModel && · } - {displayModel && {displayModel}} - {hasCustomEngineSettings && ( - - Custom settings - - )} - - ) : ( - - )} - - - {activeTriggerCount > 0 ? ( - {activeTriggerCount} active - ) : ( - - - - - None - - - - No triggers configured — this agent won't process any events - - - )} - - -
    - {config && ( - - )} - -
    -
    -
    - ); -} - -interface AgentListViewProps { - enabledAgentTypes: string[]; - availableAgentTypes: string[]; - configByAgent: Map; - triggersByAgent: Map; - integrations: { pm: string | null; scm: string | null }; - onSelect: (agentType: string) => void; - onDelete: (id: number) => void; - onEnable: (agentType: string) => void; - isDeleting: boolean; - isEnabling: boolean; - projectModel: string | null; - projectEngine: string | null; - systemDefaults: SystemDefaults | undefined; - /** Set of credential env-var keys that are configured for this project. */ - configuredCredentialKeys: Set; -} - -function AgentListView({ - enabledAgentTypes, - availableAgentTypes, - configByAgent, - triggersByAgent, - integrations, - onSelect, - onDelete, - onEnable, - isDeleting, - isEnabling, - projectModel, - projectEngine, - systemDefaults, - configuredCredentialKeys, -}: AgentListViewProps) { - const [deleteTarget, setDeleteTarget] = useState<{ id: number; label: string } | null>(null); - - return ( - <> - {enabledAgentTypes.length === 0 ? ( -
    - No agents enabled. Enable agents below to start processing. -
    - ) : ( -
    - - - - - Agent - Status - Engine / Model - Active Triggers - - - - - {enabledAgentTypes.map((type) => ( - setDeleteTarget({ id, label })} - projectModel={projectModel} - projectEngine={projectEngine} - systemDefaults={systemDefaults} - configuredCredentialKeys={configuredCredentialKeys} - /> - ))} - -
    -
    -
    - )} - - {availableAgentTypes.length > 0 && ( -
    -

    Available Agents

    -
    - {availableAgentTypes.map((agentType) => { - const label = - (AGENT_LABELS as Record)[agentType] ?? agentType; - return ( -
    - {label} - -
    - ); - })} -
    -
    - )} - - !open && setDeleteTarget(null)}> - - - Delete Agent Config - - Are you sure you want to delete the config for {deleteTarget?.label}? - The agent will be disabled and no longer process any events. This action cannot be - undone. - - - - Cancel - { - if (deleteTarget) { - onDelete(deleteTarget.id); - setDeleteTarget(null); - } - }} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - {isDeleting ? 'Deleting...' : 'Delete'} - - - - - - ); -} - -// ============================================================================ -// Agent Detail View -// ============================================================================ - -interface AgentDetailViewProps { - agentType: string; - projectId: string; - config: AgentConfig | null; - triggers: ResolvedTrigger[]; - integrations: { pm: string | null; scm: string | null }; - engines: Engine[]; - isSaving: boolean; - onSaveConfig: (agentType: string, configId: number | null, values: SaveConfigValues) => void; - saveSuccessNonce: number; - onDeleteConfig: (id: number) => void; - onTriggerToggle: (agentType: string, event: string, enabled: boolean) => void; - onTriggerParamChange: ( - agentType: string, - event: string, - parameters: Record, - currentEnabled: boolean, - ) => void; - onBack: () => void; - projectModel: string | null; - projectEngine: string | null; - projectMaxIterations: number | null; - systemDefaults: SystemDefaults | undefined; -} - -function AgentDetailView({ - agentType, - projectId, - config, - triggers, - integrations, - engines, - isSaving, - onSaveConfig, - saveSuccessNonce, - onDeleteConfig, - onTriggerToggle, - onTriggerParamChange, - onBack, - projectModel, - projectEngine, - projectMaxIterations, - systemDefaults, -}: AgentDetailViewProps) { - const label = (AGENT_LABELS as Record)[agentType] ?? agentType; - - return ( -
    -
    - -
    -
    -

    {label}

    -

    - Configure model, engine, and trigger settings for the {label} agent. -

    -
    - { - onDeleteConfig(id); - onBack(); - }} - onTriggerToggle={onTriggerToggle} - onTriggerParamChange={onTriggerParamChange} - projectModel={projectModel} - projectEngine={projectEngine} - projectMaxIterations={projectMaxIterations} - systemDefaults={systemDefaults} - /> -
    - ); -} +import type { ResolvedTrigger } from '@/components/shared/definition-trigger-toggles.js'; +import type { TriggerParameterValue } from '@/lib/trigger-agent-mapping.js'; +import { trpc, trpcClient } from '@/lib/trpc.js'; +import { AgentDetailView } from './agent-config-detail.js'; +import { AgentListView } from './agent-config-list.js'; +import type { + AgentConfig, + Engine, + SaveConfigValues, + SystemDefaults, +} from './agent-config-types.js'; // ============================================================================ // Main Component @@ -1055,7 +190,7 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { // Project-level and system-level defaults for inheritance display const projectData = projectQuery.data; - const systemDefaults = defaultsQuery.data + const systemDefaults: SystemDefaults | undefined = defaultsQuery.data ? { model: defaultsQuery.data.model, maxIterations: defaultsQuery.data.maxIterations, diff --git a/web/src/components/projects/project-form-dialog.tsx b/web/src/components/projects/project-form-dialog.tsx index 993981fe..fd3b67c4 100644 --- a/web/src/components/projects/project-form-dialog.tsx +++ b/web/src/components/projects/project-form-dialog.tsx @@ -1,9 +1,9 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog.js'; import { Input } from '@/components/ui/input.js'; import { Label } from '@/components/ui/label.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useState } from 'react'; function slugify(name: string): string { return name diff --git a/web/src/components/projects/project-general-form.tsx b/web/src/components/projects/project-general-form.tsx index 700d6f09..f46b0c48 100644 --- a/web/src/components/projects/project-general-form.tsx +++ b/web/src/components/projects/project-general-form.tsx @@ -1,3 +1,8 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; +import { HelpCircle } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { toast } from 'sonner'; import { ProjectSecretField } from '@/components/projects/project-secret-field.js'; import { useProjectUpdate } from '@/components/projects/use-project-update.js'; import { OpenRouterModelCombobox } from '@/components/settings/openrouter-model-combobox.js'; @@ -21,11 +26,6 @@ import { TooltipTrigger, } from '@/components/ui/tooltip.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useNavigate } from '@tanstack/react-router'; -import { HelpCircle } from 'lucide-react'; -import { useMemo, useState } from 'react'; -import { toast } from 'sonner'; interface Project { id: string; @@ -453,10 +453,7 @@ export function ProjectGeneralForm({ project }: { project: Project }) { Cancel - deleteMutation.mutate()} - className="bg-destructive text-white hover:bg-destructive/90" - > + deleteMutation.mutate()} variant="destructive"> {deleteMutation.isPending ? 'Deleting...' : 'Delete'} diff --git a/web/src/components/projects/project-harness-form.tsx b/web/src/components/projects/project-harness-form.tsx index 31f3e085..b895cc35 100644 --- a/web/src/components/projects/project-harness-form.tsx +++ b/web/src/components/projects/project-harness-form.tsx @@ -1,3 +1,6 @@ +import { useQuery } from '@tanstack/react-query'; +import { HelpCircle } from 'lucide-react'; +import { useState } from 'react'; import { ENGINE_SECRETS } from '@/components/projects/engine-secrets.js'; import { ProjectSecretField } from '@/components/projects/project-secret-field.js'; import { useProjectUpdate } from '@/components/projects/use-project-update.js'; @@ -22,9 +25,6 @@ import { TooltipTrigger, } from '@/components/ui/tooltip.js'; import { trpc } from '@/lib/trpc.js'; -import { useQuery } from '@tanstack/react-query'; -import { HelpCircle } from 'lucide-react'; -import { useState } from 'react'; interface Project { id: string; @@ -42,7 +42,6 @@ function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: multiple query dependencies and per-engine tab rendering for credentials and settings export function ProjectHarnessForm({ project }: { project: Project }) { const updateMutation = useProjectUpdate(project.id); const enginesQuery = useQuery(trpc.agentConfigs.engines.queryOptions()); diff --git a/web/src/components/projects/project-lifecycle-automations.tsx b/web/src/components/projects/project-lifecycle-automations.tsx index 7ce5d507..05650674 100644 --- a/web/src/components/projects/project-lifecycle-automations.tsx +++ b/web/src/components/projects/project-lifecycle-automations.tsx @@ -1,8 +1,8 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { TriggerToggles } from '@/components/shared/trigger-toggles.js'; import { LIFECYCLE_TRIGGERS } from '@/lib/trigger-agent-mapping.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useEffect, useMemo, useRef, useState } from 'react'; export function ProjectLifecycleAutomations({ projectId }: { projectId: string }) { const queryClient = useQueryClient(); @@ -21,7 +21,10 @@ export function ProjectLifecycleAutomations({ projectId }: { projectId: string } mutationFn: ({ category, triggers, - }: { category: 'pm' | 'scm'; triggers: Record }) => + }: { + category: 'pm' | 'scm'; + triggers: Record; + }) => trpcClient.projects.integrations.updateTriggers.mutate({ projectId, category, diff --git a/web/src/components/projects/project-secret-field.tsx b/web/src/components/projects/project-secret-field.tsx index 8e834ec6..cd1c58f5 100644 --- a/web/src/components/projects/project-secret-field.tsx +++ b/web/src/components/projects/project-secret-field.tsx @@ -2,13 +2,14 @@ * Reusable project-scoped secret input field. * Write-only — shows masked metadata when configured, never exposes plaintext. */ + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { CheckCircle, Loader2, Trash2, XCircle } from 'lucide-react'; +import { useState } from 'react'; import { Badge } from '@/components/ui/badge.js'; import { Input } from '@/components/ui/input.js'; import { Label } from '@/components/ui/label.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { CheckCircle, Loader2, Trash2, XCircle } from 'lucide-react'; -import { useState } from 'react'; export interface ProjectCredentialMeta { envVarKey: string; diff --git a/web/src/components/projects/project-work-table.tsx b/web/src/components/projects/project-work-table.tsx index 8f8bb7d4..1389bc37 100644 --- a/web/src/components/projects/project-work-table.tsx +++ b/web/src/components/projects/project-work-table.tsx @@ -1,8 +1,8 @@ -import { agentTypeLabel, getAgentColor } from '@/lib/chart-colors.js'; -import { formatCostSummary } from '@/lib/utils.js'; import { useNavigate } from '@tanstack/react-router'; -import { Link } from '@tanstack/react-router'; import { ClipboardList, ExternalLink, GitPullRequest } from 'lucide-react'; +import { agentTypeLabel } from '@/lib/chart-colors.js'; +import { useChartColors } from '@/lib/use-chart-colors.js'; +import { formatCostSummary } from '@/lib/utils.js'; import { WorkItemDurationBar } from './work-item-duration-bar.js'; interface WorkItemRun { @@ -235,6 +235,7 @@ export function ProjectWorkTable({ onPageChange, projectAvgDurationMs, }: ProjectWorkTableProps) { + const getAgentColor = useChartColors(); const total = items.length; const totalPages = Math.ceil(total / limit); const currentPage = Math.floor(offset / limit) + 1; diff --git a/web/src/components/projects/projects-table.tsx b/web/src/components/projects/projects-table.tsx index eb9576be..012852bb 100644 --- a/web/src/components/projects/projects-table.tsx +++ b/web/src/components/projects/projects-table.tsx @@ -1,3 +1,7 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; +import { FolderGit2, Trash2 } from 'lucide-react'; +import { useState } from 'react'; import { AlertDialog, AlertDialogAction, @@ -18,10 +22,6 @@ import { TableRow, } from '@/components/ui/table.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useNavigate } from '@tanstack/react-router'; -import { FolderGit2, Trash2 } from 'lucide-react'; -import { useState } from 'react'; interface Project { id: string; @@ -145,7 +145,7 @@ export function ProjectsTable({ Cancel deleteId && deleteMutation.mutate(deleteId)} - className="bg-destructive text-white hover:bg-destructive/90" + variant="destructive" > Delete diff --git a/web/src/components/projects/stats-filters.tsx b/web/src/components/projects/stats-filters.tsx index 862d2d62..ca152d7d 100644 --- a/web/src/components/projects/stats-filters.tsx +++ b/web/src/components/projects/stats-filters.tsx @@ -33,7 +33,7 @@ interface StatsFiltersProps { } const selectClass = - 'h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring sm:w-auto'; + 'h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring dark:bg-card dark:text-foreground [&_option]:bg-card sm:w-auto'; export function StatsFiltersBar({ filters, onFilterChange }: StatsFiltersProps) { return ( diff --git a/web/src/components/projects/use-project-update.ts b/web/src/components/projects/use-project-update.ts index 9d175003..dfcd324c 100644 --- a/web/src/components/projects/use-project-update.ts +++ b/web/src/components/projects/use-project-update.ts @@ -1,5 +1,5 @@ -import { trpc, trpcClient } from '@/lib/trpc.js'; import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { trpc, trpcClient } from '@/lib/trpc.js'; type ProjectUpdateInput = Parameters[0]; diff --git a/web/src/components/projects/wizard-shared.tsx b/web/src/components/projects/wizard-shared.tsx index 1b2510f1..44e68657 100644 --- a/web/src/components/projects/wizard-shared.tsx +++ b/web/src/components/projects/wizard-shared.tsx @@ -2,9 +2,10 @@ * Shared wizard UI components used across pm-wizard and email-wizard. * Extracted to eliminate ~250 lines of verbatim duplication. */ -import { Input } from '@/components/ui/input.js'; + import { AlertCircle, Check, ChevronDown, ChevronRight, Loader2, RefreshCw } from 'lucide-react'; import { useState } from 'react'; +import { Input } from '@/components/ui/input.js'; // ============================================================================ // WizardStep Shell @@ -142,7 +143,7 @@ export function SearchableSelect onChange(e.target.value)} - className="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm" + className="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm dark:bg-card dark:text-foreground [&_option]:bg-card" > {filtered.map((o) => ( @@ -208,7 +209,7 @@ export function FieldMappingRow({ toggleCapability(cap, e.target.value === 'required')} - className="h-6 rounded border border-input bg-background px-1 text-xs" + className="h-6 rounded border border-input bg-background px-1 text-xs dark:bg-card dark:text-foreground [&_option]:bg-card" > diff --git a/web/src/components/settings/agent-definition-shared.tsx b/web/src/components/settings/agent-definition-shared.tsx index 5793f673..63c8ad08 100644 --- a/web/src/components/settings/agent-definition-shared.tsx +++ b/web/src/components/settings/agent-definition-shared.tsx @@ -3,6 +3,9 @@ * Extracted from agent-definition-editor.tsx to serve as the foundational leaf * of the import graph — this file must NOT import from any sibling agent-definition-* file. */ + +import type { inferRouterOutputs } from '@trpc/server'; +import { Info } from 'lucide-react'; import type { AppRouter } from '@/../../src/api/router.js'; import type { KnownTriggerEvent } from '@/../../src/api/routers/_shared/triggerTypes.js'; import { Badge } from '@/components/ui/badge.js'; @@ -12,8 +15,6 @@ import { TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip.js'; -import type { inferRouterOutputs } from '@trpc/server'; -import { Info } from 'lucide-react'; // ───────────────────────────────────────────────────────────────────────────── // Type aliases @@ -134,7 +135,7 @@ export function Toggle({ }`} > diff --git a/web/src/components/settings/agent-definition-table.tsx b/web/src/components/settings/agent-definition-table.tsx index dd6fd776..c80d41b1 100644 --- a/web/src/components/settings/agent-definition-table.tsx +++ b/web/src/components/settings/agent-definition-table.tsx @@ -1,3 +1,6 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { inferRouterOutputs } from '@trpc/server'; +import { Pencil, RotateCcw, Trash2 } from 'lucide-react'; import type { AppRouter } from '@/../../src/api/router.js'; import { Badge } from '@/components/ui/badge.js'; import { @@ -9,9 +12,6 @@ import { TableRow, } from '@/components/ui/table.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import type { inferRouterOutputs } from '@trpc/server'; -import { Pencil, RotateCcw, Trash2 } from 'lucide-react'; type RouterOutput = inferRouterOutputs; type DefinitionRow = RouterOutput['agentDefinitions']['list'][number]; diff --git a/web/src/components/settings/model-field.tsx b/web/src/components/settings/model-field.tsx index f6aa2772..5af512dc 100644 --- a/web/src/components/settings/model-field.tsx +++ b/web/src/components/settings/model-field.tsx @@ -1,3 +1,4 @@ +import { useQuery } from '@tanstack/react-query'; import { OpenRouterModelCombobox } from '@/components/settings/openrouter-model-combobox.js'; import { Input } from '@/components/ui/input.js'; import { @@ -8,7 +9,6 @@ import { SelectValue, } from '@/components/ui/select.js'; import { trpc } from '@/lib/trpc.js'; -import { useQuery } from '@tanstack/react-query'; interface ModelFieldProps { value: string; diff --git a/web/src/components/settings/openrouter-model-combobox.tsx b/web/src/components/settings/openrouter-model-combobox.tsx index e296ce2d..bc8ee957 100644 --- a/web/src/components/settings/openrouter-model-combobox.tsx +++ b/web/src/components/settings/openrouter-model-combobox.tsx @@ -1,9 +1,9 @@ +import { useQuery } from '@tanstack/react-query'; import type { ComboboxOption } from '@/components/ui/combobox.js'; import { Combobox } from '@/components/ui/combobox.js'; import { Input } from '@/components/ui/input.js'; -import { OPENROUTER_PREFIX, addPrefix, modelDetail, modelGroup } from '@/lib/openrouter-utils.js'; +import { addPrefix, modelDetail, modelGroup, OPENROUTER_PREFIX } from '@/lib/openrouter-utils.js'; import { trpc } from '@/lib/trpc.js'; -import { useQuery } from '@tanstack/react-query'; interface OpenRouterModelComboboxProps { projectId: string; diff --git a/web/src/components/settings/org-form.tsx b/web/src/components/settings/org-form.tsx index ab88cd76..db4e10d8 100644 --- a/web/src/components/settings/org-form.tsx +++ b/web/src/components/settings/org-form.tsx @@ -1,8 +1,8 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; import { Input } from '@/components/ui/input.js'; import { Label } from '@/components/ui/label.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useEffect, useState } from 'react'; export function OrgForm() { const queryClient = useQueryClient(); diff --git a/web/src/components/settings/prompt-editor.tsx b/web/src/components/settings/prompt-editor.tsx index aefb0caa..28f953d2 100644 --- a/web/src/components/settings/prompt-editor.tsx +++ b/web/src/components/settings/prompt-editor.tsx @@ -1,8 +1,8 @@ -import { Badge } from '@/components/ui/badge.js'; -import { trpc, trpcClient } from '@/lib/trpc.js'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { ChevronDown, ChevronRight } from 'lucide-react'; import { useEffect, useState } from 'react'; +import { Badge } from '@/components/ui/badge.js'; +import { trpc, trpcClient } from '@/lib/trpc.js'; interface PromptEditorProps { target: { name: string }; @@ -13,7 +13,7 @@ export function PromptEditor({ target, onClose }: PromptEditorProps) { return ; } -function PartialEditor({ name, onClose }: { name: string; onClose: () => void }) { +function PartialEditor({ name, onClose: _onClose }: { name: string; onClose: () => void }) { const queryClient = useQueryClient(); const [content, setContent] = useState(''); const [validationStatus, setValidationStatus] = useState(null); diff --git a/web/src/components/settings/useDefinitionEditor.ts b/web/src/components/settings/useDefinitionEditor.ts index f29e8b4e..6c960726 100644 --- a/web/src/components/settings/useDefinitionEditor.ts +++ b/web/src/components/settings/useDefinitionEditor.ts @@ -3,9 +3,10 @@ * agent definition editor. Extracted from agent-definition-editor.tsx to keep * the main component as a thin orchestrator. */ -import { trpc, trpcClient } from '@/lib/trpc.js'; + import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useState } from 'react'; +import { trpc, trpcClient } from '@/lib/trpc.js'; import { type AgentDefinition, type DefinitionRow, diff --git a/web/src/components/settings/user-form-dialog.tsx b/web/src/components/settings/user-form-dialog.tsx index 8632c9c6..5562e8f0 100644 --- a/web/src/components/settings/user-form-dialog.tsx +++ b/web/src/components/settings/user-form-dialog.tsx @@ -1,9 +1,9 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog.js'; import { Input } from '@/components/ui/input.js'; import { Label } from '@/components/ui/label.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useState } from 'react'; interface User { id: string; @@ -118,7 +118,7 @@ export function UserFormDialog({ open, onOpenChange, user }: UserFormDialogProps id="user-role" value={role} onChange={(e) => setRole(e.target.value as 'member' | 'admin')} - className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring dark:bg-card dark:text-foreground [&_option]:bg-card" > diff --git a/web/src/components/settings/users-table.tsx b/web/src/components/settings/users-table.tsx index 66097441..da96fbb1 100644 --- a/web/src/components/settings/users-table.tsx +++ b/web/src/components/settings/users-table.tsx @@ -1,3 +1,6 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Pencil, Trash2 } from 'lucide-react'; +import { useState } from 'react'; import { AlertDialog, AlertDialogAction, @@ -18,9 +21,6 @@ import { TableRow, } from '@/components/ui/table.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { Pencil, Trash2 } from 'lucide-react'; -import { useState } from 'react'; import { UserFormDialog } from './user-form-dialog.js'; interface User { @@ -123,7 +123,7 @@ export function UsersTable({ users }: { users: User[] }) { Cancel deleteId && deleteMutation.mutate(deleteId)} - className="bg-destructive text-white hover:bg-destructive/90" + variant="destructive" > Delete diff --git a/web/src/components/shared/trigger-toggles.tsx b/web/src/components/shared/trigger-toggles.tsx index 6c9576dc..0323e8bb 100644 --- a/web/src/components/shared/trigger-toggles.tsx +++ b/web/src/components/shared/trigger-toggles.tsx @@ -1,5 +1,5 @@ import { Label } from '@/components/ui/label.js'; -import { type TriggerDef, getTriggerValue, setTriggerValue } from '@/lib/trigger-agent-mapping.js'; +import { getTriggerValue, setTriggerValue, type TriggerDef } from '@/lib/trigger-agent-mapping.js'; export type { TriggerDef }; diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx index 5967b1ed..06916808 100644 --- a/web/src/components/ui/badge.tsx +++ b/web/src/components/ui/badge.tsx @@ -1,4 +1,4 @@ -import { type VariantProps, cva } from 'class-variance-authority'; +import { cva, type VariantProps } from 'class-variance-authority'; import { Slot } from 'radix-ui'; import type * as React from 'react'; diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx index 469e41b7..c305fa12 100644 --- a/web/src/components/ui/button.tsx +++ b/web/src/components/ui/button.tsx @@ -1,4 +1,4 @@ -import { type VariantProps, cva } from 'class-variance-authority'; +import { cva, type VariantProps } from 'class-variance-authority'; import { Slot } from 'radix-ui'; import type * as React from 'react'; diff --git a/web/src/components/ui/card.tsx b/web/src/components/ui/card.tsx index 273b28d9..7891723c 100644 --- a/web/src/components/ui/card.tsx +++ b/web/src/components/ui/card.tsx @@ -72,4 +72,4 @@ function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { ); } -export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }; +export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }; diff --git a/web/src/components/ui/combobox.tsx b/web/src/components/ui/combobox.tsx index ab8dd6aa..787fccb6 100644 --- a/web/src/components/ui/combobox.tsx +++ b/web/src/components/ui/combobox.tsx @@ -1,9 +1,9 @@ -import { Button } from '@/components/ui/button.js'; -import { cn } from '@/lib/utils.js'; import { Command as CommandPrimitive } from 'cmdk'; import { Check, ChevronsUpDown } from 'lucide-react'; import { Popover as PopoverPrimitive } from 'radix-ui'; import * as React from 'react'; +import { Button } from '@/components/ui/button.js'; +import { cn } from '@/lib/utils.js'; export interface ComboboxOption { value: string; diff --git a/web/src/components/ui/form.tsx b/web/src/components/ui/form.tsx index eb0b6c40..9de28481 100644 --- a/web/src/components/ui/form.tsx +++ b/web/src/components/ui/form.tsx @@ -139,12 +139,12 @@ function FormMessage({ className, ...props }: React.ComponentProps<'p'>) { } export { - useFormField, Form, - FormItem, - FormLabel, FormControl, FormDescription, - FormMessage, FormField, + FormItem, + FormLabel, + FormMessage, + useFormField, }; diff --git a/web/src/components/ui/sonner.tsx b/web/src/components/ui/sonner.tsx index b2b19f32..a8c2ee35 100644 --- a/web/src/components/ui/sonner.tsx +++ b/web/src/components/ui/sonner.tsx @@ -24,9 +24,9 @@ const Toaster = ({ ...props }: ToasterProps) => { }} style={ { - '--normal-bg': 'var(--popover)', - '--normal-text': 'var(--popover-foreground)', - '--normal-border': 'var(--border)', + '--normal-bg': 'var(--color-popover)', + '--normal-text': 'var(--color-popover-foreground)', + '--normal-border': 'var(--color-border)', '--border-radius': 'var(--radius)', } as React.CSSProperties } diff --git a/web/src/components/ui/table.tsx b/web/src/components/ui/table.tsx index 53a7a71e..41ada581 100644 --- a/web/src/components/ui/table.tsx +++ b/web/src/components/ui/table.tsx @@ -89,4 +89,4 @@ function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) ); } -export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }; +export { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow }; diff --git a/web/src/components/ui/tabs.tsx b/web/src/components/ui/tabs.tsx index 178dd818..cc369e28 100644 --- a/web/src/components/ui/tabs.tsx +++ b/web/src/components/ui/tabs.tsx @@ -1,6 +1,6 @@ 'use client'; -import { type VariantProps, cva } from 'class-variance-authority'; +import { cva, type VariantProps } from 'class-variance-authority'; import { Tabs as TabsPrimitive } from 'radix-ui'; import type * as React from 'react'; @@ -78,4 +78,4 @@ function TabsContent({ className, ...props }: React.ComponentProps = { planning: 0, implementation: 1, @@ -39,21 +52,26 @@ const KNOWN_AGENT_TYPES: Record = { 'respond-to-planning-comment': 6, }; -/** - * Returns a color string for the given agent type. - * Falls back to a consistent color based on the string hash for unknown types. - */ -export function getAgentColor(agentType: string): string { +function pickColor(agentType: string, palette: string[]): string { const idx = KNOWN_AGENT_TYPES[agentType]; if (idx !== undefined) { - return CHART_PALETTE[idx]; + return palette[idx]; } // Hash-based fallback for unknown agent types let hash = 0; for (let i = 0; i < agentType.length; i++) { - hash = (hash * 31 + agentType.charCodeAt(i)) % CHART_PALETTE.length; + hash = (hash * 31 + agentType.charCodeAt(i)) % palette.length; } - return CHART_PALETTE[Math.abs(hash) % CHART_PALETTE.length]; + return palette[Math.abs(hash) % palette.length]; +} + +/** + * Returns a color string for the given agent type using the light-mode palette. + * For theme-aware colors, use the useChartColors hook from use-chart-colors.ts. + * Falls back to a consistent color based on the string hash for unknown types. + */ +export function getAgentColor(agentType: string, palette?: string[]): string { + return pickColor(agentType, palette ?? CHART_PALETTE_LIGHT); } /** diff --git a/web/src/lib/org-context.tsx b/web/src/lib/org-context.tsx index de3d1847..47f6db59 100644 --- a/web/src/lib/org-context.tsx +++ b/web/src/lib/org-context.tsx @@ -31,7 +31,10 @@ interface MeData { export function OrgProvider({ children, me, -}: { children: React.ReactNode; me: MeData | undefined }) { +}: { + children: React.ReactNode; + me: MeData | undefined; +}) { const [effectiveOrgId, setEffectiveOrgId] = useState(null); const isAdmin = me?.role === 'superadmin'; const initialized = useRef(false); diff --git a/web/src/lib/trigger-agent-mapping.ts b/web/src/lib/trigger-agent-mapping.ts index d2526d2f..0c7fbe1a 100644 --- a/web/src/lib/trigger-agent-mapping.ts +++ b/web/src/lib/trigger-agent-mapping.ts @@ -3,14 +3,14 @@ * Uses definition-based triggers from the API via agentTriggerConfigs.getProjectTriggersView. */ -// Re-export shared types for convenience -export { TRIGGER_CATEGORY_LABELS as CATEGORY_LABELS } from '../../../src/api/routers/_shared/triggerTypes.js'; export type { + ProjectTriggersView, ResolvedTrigger, TriggerParameterDef, TriggerParameterValue, - ProjectTriggersView, } from '../../../src/api/routers/_shared/triggerTypes.js'; +// Re-export shared types for convenience +export { TRIGGER_CATEGORY_LABELS as CATEGORY_LABELS } from '../../../src/api/routers/_shared/triggerTypes.js'; // ============================================================================ // Types diff --git a/web/src/lib/use-chart-colors.ts b/web/src/lib/use-chart-colors.ts new file mode 100644 index 00000000..5788c3a0 --- /dev/null +++ b/web/src/lib/use-chart-colors.ts @@ -0,0 +1,24 @@ +/** + * Theme-aware chart color hook. + * + * Recharts requires actual hex values (not CSS variables), so this hook + * selects the correct palette based on the current theme and returns a + * theme-aware getAgentColor function that triggers re-renders on theme change. + */ + +import { useTheme } from 'next-themes'; +import { CHART_PALETTE_DARK, CHART_PALETTE_LIGHT, getAgentColor } from './chart-colors.js'; + +/** + * Returns a theme-aware getAgentColor function. + * Re-renders automatically when the theme changes. + * + * @example + * const getColor = useChartColors(); + * // In recharts: fill={getColor(agentType)} + */ +export function useChartColors(): (agentType: string) => string { + const { resolvedTheme } = useTheme(); + const palette = resolvedTheme === 'dark' ? CHART_PALETTE_DARK : CHART_PALETTE_LIGHT; + return (agentType: string) => getAgentColor(agentType, palette); +} diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index d3570ad6..be6ca882 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -1,13 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; +import { createRootRoute, Outlet, redirect, useRouterState } from '@tanstack/react-router'; +import { Menu } from 'lucide-react'; +import { useState } from 'react'; import { Header } from '@/components/layout/header.js'; import { MobileSidebar } from '@/components/layout/mobile-sidebar.js'; import { Sidebar } from '@/components/layout/sidebar.js'; import { OrgProvider } from '@/lib/org-context.js'; import { queryClient } from '@/lib/query-client.js'; import { trpc } from '@/lib/trpc.js'; -import { useQuery } from '@tanstack/react-query'; -import { Outlet, createRootRoute, redirect, useRouterState } from '@tanstack/react-router'; -import { Menu } from 'lucide-react'; -import { useState } from 'react'; function RootLayout() { const routerState = useRouterState(); diff --git a/web/src/routes/global/definitions.tsx b/web/src/routes/global/definitions.tsx index 4ceb8b25..c0f48198 100644 --- a/web/src/routes/global/definitions.tsx +++ b/web/src/routes/global/definitions.tsx @@ -1,6 +1,10 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { createRoute } from '@tanstack/react-router'; +import { ArrowLeft, Pencil, Trash2 } from 'lucide-react'; +import { useState } from 'react'; import { AgentDefinitionEditor } from '@/components/settings/agent-definition-editor.js'; -import { AgentDefinitionsTable } from '@/components/settings/agent-definition-table.js'; import type { DefinitionRow } from '@/components/settings/agent-definition-table.js'; +import { AgentDefinitionsTable } from '@/components/settings/agent-definition-table.js'; import { PromptEditor } from '@/components/settings/prompt-editor.js'; import { Badge } from '@/components/ui/badge.js'; import { @@ -12,10 +16,6 @@ import { TableRow, } from '@/components/ui/table.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { createRoute } from '@tanstack/react-router'; -import { ArrowLeft, Pencil, Trash2 } from 'lucide-react'; -import { useState } from 'react'; import { rootRoute } from '../__root.js'; type Tab = 'definitions' | 'partials'; diff --git a/web/src/routes/global/organizations.tsx b/web/src/routes/global/organizations.tsx index 9b426280..0467d03f 100644 --- a/web/src/routes/global/organizations.tsx +++ b/web/src/routes/global/organizations.tsx @@ -1,10 +1,10 @@ -import { OrganizationFormDialog } from '@/components/global/organization-form-dialog.js'; -import { OrganizationsTable } from '@/components/global/organizations-table.js'; -import { trpc } from '@/lib/trpc.js'; import { useQuery } from '@tanstack/react-query'; import { createRoute } from '@tanstack/react-router'; import { Plus } from 'lucide-react'; import { useState } from 'react'; +import { OrganizationFormDialog } from '@/components/global/organization-form-dialog.js'; +import { OrganizationsTable } from '@/components/global/organizations-table.js'; +import { trpc } from '@/lib/trpc.js'; import { rootRoute } from '../__root.js'; interface Organization { diff --git a/web/src/routes/global/runs.tsx b/web/src/routes/global/runs.tsx index 0c182844..b23f9510 100644 --- a/web/src/routes/global/runs.tsx +++ b/web/src/routes/global/runs.tsx @@ -1,9 +1,9 @@ -import { RunFilters } from '@/components/runs/run-filters.js'; -import { RunsTable } from '@/components/runs/runs-table.js'; -import { trpc } from '@/lib/trpc.js'; import { useQuery } from '@tanstack/react-query'; import { createRoute, useNavigate, useSearch } from '@tanstack/react-router'; import { z } from 'zod'; +import { RunFilters } from '@/components/runs/run-filters.js'; +import { RunsTable } from '@/components/runs/runs-table.js'; +import { trpc } from '@/lib/trpc.js'; import { rootRoute } from '../__root.js'; const searchSchema = z.object({ diff --git a/web/src/routes/global/webhook-logs.tsx b/web/src/routes/global/webhook-logs.tsx index 6ac5437f..8df5ee81 100644 --- a/web/src/routes/global/webhook-logs.tsx +++ b/web/src/routes/global/webhook-logs.tsx @@ -1,10 +1,10 @@ -import { WebhookLogDetailDialog } from '@/components/webhooklogs/webhooklog-detail-dialog.js'; -import { WebhookLogsTable } from '@/components/webhooklogs/webhooklogs-table.js'; -import { trpc } from '@/lib/trpc.js'; import { useQuery } from '@tanstack/react-query'; import { createRoute, useNavigate, useSearch } from '@tanstack/react-router'; import { useState } from 'react'; import { z } from 'zod'; +import { WebhookLogDetailDialog } from '@/components/webhooklogs/webhooklog-detail-dialog.js'; +import { WebhookLogsTable } from '@/components/webhooklogs/webhooklogs-table.js'; +import { trpc } from '@/lib/trpc.js'; import { rootRoute } from '../__root.js'; const searchSchema = z.object({ @@ -56,7 +56,7 @@ function WebhookLogsPage() {