From 07ef2069be09b0765f42c548cfe692513be32301 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Mon, 16 Feb 2026 11:24:31 +0100 Subject: [PATCH] feat: config schema cleanup, credentials overhaul, and tmux refactor (#282) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop dead project_secrets table and secretsRepository (migrated to credentials system) - Restructure cascade_defaults PK: drop id column, make org_id the primary key - Move agentConfigs to its own schema file (src/db/schema/agentConfigs.ts) - Rename backend → agent_backend in agent_configs table - Rename agent_backend_default → agent_backend in projects table - Standardize index naming (idx_projects_repo → uq_projects_repo) - Extract 15 Trello columns into project_integrations table with JSONB config - Deduplicate configRepository find functions into shared findProjectFromDb helper - Add organizations and org-scoped credentials with per-project overrides - Add per-agent credential overrides (agent_type scoping) - Refactor tmux gadget into modular structure (TmuxControlClient, TmuxGadget, etc.) - Update GitHub triggers to use project-scoped credentials via provider - Add configRepository test suite (20 tests) and expand credentials/tmux coverage - Update CLAUDE.md documentation to reflect new schema Co-authored-by: Claude Opus 4.6 --- .env.example | 3 - CLAUDE.md | 39 +- drizzle.config.ts | 3 +- src/agents/shared/githubAgent.ts | 17 +- src/config/index.ts | 3 +- src/config/projects.ts | 4 - src/config/provider.ts | 14 + .../0004_agent_credential_overrides.sql | 84 ++ .../migrations/0005_config_schema_cleanup.sql | 119 ++ src/db/repositories/configRepository.ts | 198 ++- src/db/repositories/credentialsRepository.ts | 115 +- src/db/repositories/secretsRepository.ts | 48 - src/db/schema/agentConfigs.ts | 24 + src/db/schema/credentials.ts | 21 +- src/db/schema/defaults.ts | 5 +- src/db/schema/index.ts | 5 +- src/db/schema/{secrets.ts => integrations.ts} | 12 +- src/db/schema/projects.ts | 61 +- src/gadgets/tmux.ts | 1138 +---------------- src/gadgets/tmux/TmuxControlClient.ts | 534 ++++++++ src/gadgets/tmux/TmuxGadget.ts | 451 +++++++ src/gadgets/tmux/constants.ts | 17 + src/gadgets/tmux/errors.ts | 20 + src/gadgets/tmux/gitValidation.ts | 35 + src/gadgets/tmux/index.ts | 5 + src/gadgets/tmux/sessionNotices.ts | 30 + src/gadgets/tmux/utils.ts | 49 + src/github/client.ts | 2 +- src/triggers/github/check-suite-success.ts | 8 +- src/triggers/github/pr-comment-mention.ts | 15 +- src/triggers/github/webhook-handler.ts | 18 +- src/triggers/trello/webhook-handler.ts | 13 +- src/utils/cascadeEnv.ts | 1 - tests/unit/config/projects.test.ts | 1 + .../db/repositories/configRepository.test.ts | 503 ++++++++ .../credentialsRepository.test.ts | 87 +- tests/unit/gadgets/tmux.test.ts | 197 ++- tests/unit/github/client.test.ts | 10 +- .../unit/triggers/check-suite-success.test.ts | 20 +- tools/debug-run.ts | 2 +- tools/manage-secrets.ts | 51 +- tools/resolve-config.ts | 132 +- tools/seed-config-from-json.ts | 64 +- 43 files changed, 2612 insertions(+), 1566 deletions(-) create mode 100644 src/db/migrations/0004_agent_credential_overrides.sql create mode 100644 src/db/migrations/0005_config_schema_cleanup.sql delete mode 100644 src/db/repositories/secretsRepository.ts create mode 100644 src/db/schema/agentConfigs.ts rename src/db/schema/{secrets.ts => integrations.ts} (50%) create mode 100644 src/gadgets/tmux/TmuxControlClient.ts create mode 100644 src/gadgets/tmux/TmuxGadget.ts create mode 100644 src/gadgets/tmux/constants.ts create mode 100644 src/gadgets/tmux/errors.ts create mode 100644 src/gadgets/tmux/gitValidation.ts create mode 100644 src/gadgets/tmux/index.ts create mode 100644 src/gadgets/tmux/sessionNotices.ts create mode 100644 src/gadgets/tmux/utils.ts create mode 100644 tests/unit/db/repositories/configRepository.test.ts diff --git a/.env.example b/.env.example index 10ee9be9..67b2eaec 100644 --- a/.env.example +++ b/.env.example @@ -5,9 +5,6 @@ TRELLO_TOKEN= # GitHub token for cloning repos and creating PRs GITHUB_TOKEN= -# GitHub reviewer PAT (separate account for submitting PR reviews) -GITHUB_REVIEWER_TOKEN= - # LLM API keys OPENROUTER_API_KEY= diff --git a/CLAUDE.md b/CLAUDE.md index ca564342..f4d7c280 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,7 +71,7 @@ Optional (infrastructure): - `DATABASE_SSL` - Set to `false` to disable SSL for local PostgreSQL (default: enabled) - `CLAUDE_CODE_OAUTH_TOKEN` - For Claude Code backend (subscription auth) -**Project credentials** (`GITHUB_TOKEN`, `TRELLO_API_KEY`, `TRELLO_TOKEN`, LLM API keys) are stored per-project in the database `project_secrets` table. There is no env var fallback — the database is the sole source of truth for project-scoped secrets. +**Project credentials** (`GITHUB_TOKEN`, `TRELLO_API_KEY`, `TRELLO_TOKEN`, LLM API keys) are stored in the `credentials` table (org-scoped) with optional per-project overrides via `project_credential_overrides`. There is no env var fallback — the database is the sole source of truth for project-scoped secrets. ## Database Configuration @@ -79,10 +79,13 @@ CASCADE stores all project configuration in PostgreSQL (Supabase). The `config/p ### Schema -- `cascade_defaults` - Global defaults (model, iterations, timeouts, budget) -- `projects` - Per-project config (repo, Trello board/lists/labels, budget, backend) -- `agent_configs` - Per-agent-type overrides (model, iterations, backend, prompt), scoped to global or per-project -- `project_secrets` - Per-project credentials (Trello, GitHub, LLM API keys) +- `organizations` - Organization definitions (multi-tenant support) +- `cascade_defaults` - Global defaults per org (model, iterations, timeouts, budget) +- `projects` - Per-project config (repo, base branch, budget, backend) +- `project_integrations` - Integration configs per project (Trello boards/lists/labels as JSONB) +- `agent_configs` - Per-agent-type overrides (model, iterations, backend, prompt), scoped globally, per-org, or per-project +- `credentials` - Org-scoped credentials (API keys, tokens) +- `project_credential_overrides` - Per-project credential overrides (optional, falls back to org defaults) ### Database Scripts @@ -96,14 +99,32 @@ npm run db:seed # Seed DB from config/projects.json ### Per-Project Secrets -Store per-project credentials in `project_secrets` table. Falls back to global env vars when not set. +Credentials are stored in the `credentials` table (org-scoped) with optional per-project overrides via `project_credential_overrides`. ```bash -npx tsx tools/manage-secrets.ts set TRELLO_API_KEY -npx tsx tools/manage-secrets.ts list -npx tsx tools/manage-secrets.ts delete +npx tsx tools/manage-secrets.ts create [--name "..."] [--default] +npx tsx tools/manage-secrets.ts list +npx tsx tools/manage-secrets.ts set-override +npx tsx tools/manage-secrets.ts remove-override +npx tsx tools/manage-secrets.ts resolve ``` +### Per-Agent Credential Overrides + +Override any credential for a specific agent type. For example, to make the `review` agent use a separate GitHub identity: + +```bash +# Create a credential for the reviewer bot +npx tsx tools/manage-secrets.ts create GITHUB_TOKEN --name "Reviewer Bot" + +# Set agent-scoped overrides (review-related agents use the reviewer token) +npx tsx tools/manage-secrets.ts set-override GITHUB_TOKEN --agent-type review +npx tsx tools/manage-secrets.ts set-override GITHUB_TOKEN --agent-type respond-to-review +npx tsx tools/manage-secrets.ts set-override GITHUB_TOKEN --agent-type respond-to-pr-comment +``` + +Resolution order: agent+project override → project override → org default → null. + ## Claude Code Backend CASCADE supports using Claude Code SDK as an alternative agent backend. Configure per-project: diff --git a/drizzle.config.ts b/drizzle.config.ts index f8084ffc..a3ebf870 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -6,8 +6,9 @@ export default defineConfig({ './src/db/schema/credentials.ts', './src/db/schema/defaults.ts', './src/db/schema/projects.ts', + './src/db/schema/agentConfigs.ts', + './src/db/schema/integrations.ts', './src/db/schema/runs.ts', - './src/db/schema/secrets.ts', ], out: './src/db/migrations', dialect: 'postgresql', diff --git a/src/agents/shared/githubAgent.ts b/src/agents/shared/githubAgent.ts index 1c7825d3..ca08af5d 100644 --- a/src/agents/shared/githubAgent.ts +++ b/src/agents/shared/githubAgent.ts @@ -2,7 +2,7 @@ import type { ModelSpec } from 'llmist'; import { createProgressMonitor } from '../../backends/progress.js'; import { CUSTOM_MODELS } from '../../config/customModels.js'; -import { getProjectReviewerToken } from '../../config/projects.js'; +import { getAgentCredential } from '../../config/provider.js'; import { recordInitialComment } from '../../gadgets/sessionState.js'; import { githubClient, withGitHubToken } from '../../github/client.js'; import type { AgentInput, AgentResult, CascadeConfig, ProjectConfig } from '../../types/index.js'; @@ -219,12 +219,15 @@ export async function executeGitHubAgent< }, }); - // Scope reviewer token for all GitHub agents — if GITHUB_REVIEWER_TOKEN is set, - // all PR interactions (comments, reviews) use the reviewer identity instead of - // the main GITHUB_TOKEN. Individual agents can add further wrapping via wrapExecution. - const reviewerToken = await getProjectReviewerToken(input.project); - const scopedLifecycle = reviewerToken - ? () => withGitHubToken(reviewerToken, runLifecycle) + // If this agent type has a dedicated GITHUB_TOKEN override, use it for all + // PR interactions (comments, reviews). Individual agents can add further wrapping via wrapExecution. + const agentGitHubToken = await getAgentCredential( + input.project.id, + definition.agentType, + 'GITHUB_TOKEN', + ); + const scopedLifecycle = agentGitHubToken + ? () => withGitHubToken(agentGitHubToken, runLifecycle) : runLifecycle; if (definition.wrapExecution) { diff --git a/src/config/index.ts b/src/config/index.ts index cac39351..7aa08a00 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,10 +1,11 @@ export { loadEnvConfig, loadEnvConfigSafe, type EnvConfig } from './env.js'; -export { getProjectGitHubToken, getProjectReviewerToken } from './projects.js'; +export { getProjectGitHubToken } from './projects.js'; export { loadConfig, findProjectByBoardId, findProjectByRepo, findProjectById, + getAgentCredential, getProjectSecret, getProjectSecretOrNull, getProjectSecrets, diff --git a/src/config/projects.ts b/src/config/projects.ts index 47d1bfe6..3f6d4888 100644 --- a/src/config/projects.ts +++ b/src/config/projects.ts @@ -7,7 +7,3 @@ export async function getProjectGitHubToken(project: ProjectConfig): Promise { - return getProjectSecretOrNull(project.id, 'GITHUB_REVIEWER_TOKEN'); -} diff --git a/src/config/provider.ts b/src/config/provider.ts index 9fa9d7c8..2d4a48e8 100644 --- a/src/config/provider.ts +++ b/src/config/provider.ts @@ -5,6 +5,7 @@ import { loadConfigFromDb, } from '../db/repositories/configRepository.js'; import { + resolveAgentCredential, resolveAllCredentials, resolveCredential, } from '../db/repositories/credentialsRepository.js'; @@ -92,6 +93,19 @@ export async function getProjectSecrets(projectId: string): Promise { + const orgId = await getOrgIdForProject(projectId); + return resolveAgentCredential(projectId, orgId, agentType, key); +} + export function invalidateConfigCache(): void { configCache.invalidate(); } diff --git a/src/db/migrations/0004_agent_credential_overrides.sql b/src/db/migrations/0004_agent_credential_overrides.sql new file mode 100644 index 00000000..d0fbe113 --- /dev/null +++ b/src/db/migrations/0004_agent_credential_overrides.sql @@ -0,0 +1,84 @@ +-- Agent Credential Overrides Migration +-- Adds optional agent_type column to project_credential_overrides, +-- enabling per-agent credential overrides (e.g. review agent uses a different GITHUB_TOKEN). +-- Migrates existing GITHUB_REVIEWER_TOKEN credentials to agent-scoped GITHUB_TOKEN overrides. + +BEGIN; + +-- 1. Add agent_type column (nullable = project-wide override when NULL) +ALTER TABLE "project_credential_overrides" + ADD COLUMN IF NOT EXISTS "agent_type" text; + +-- 2. Drop old unique index (project_id, env_var_key) — replaced by two partial indexes +DROP INDEX IF EXISTS "uq_project_credential_overrides_project_env_var_key"; + +-- 3. Create partial unique indexes +-- Project-wide overrides: one per (project_id, env_var_key) when agent_type is NULL +CREATE UNIQUE INDEX IF NOT EXISTS "uq_pco_project_env_var_key" + ON "project_credential_overrides" ("project_id", "env_var_key") + WHERE "agent_type" IS NULL; + +-- Agent-scoped overrides: one per (project_id, env_var_key, agent_type) +CREATE UNIQUE INDEX IF NOT EXISTS "uq_pco_project_env_var_key_agent_type" + ON "project_credential_overrides" ("project_id", "env_var_key", "agent_type") + WHERE "agent_type" IS NOT NULL; + +-- 4. Migrate GITHUB_REVIEWER_TOKEN → agent-scoped GITHUB_TOKEN overrides +-- For each project that has a GITHUB_REVIEWER_TOKEN (via project override OR org default), +-- create agent-scoped GITHUB_TOKEN overrides for review-related agent types. +DO $$ +DECLARE + r RECORD; + agent TEXT; + reviewer_agents TEXT[] := ARRAY['review', 'respond-to-review', 'respond-to-pr-comment', 'respond-to-ci']; +BEGIN + -- Case 1: Projects with explicit GITHUB_REVIEWER_TOKEN project overrides + FOR r IN + SELECT pco."project_id", c."id" AS credential_id + FROM "project_credential_overrides" pco + INNER JOIN "credentials" c ON c."id" = pco."credential_id" + WHERE pco."env_var_key" = 'GITHUB_REVIEWER_TOKEN' + AND pco."agent_type" IS NULL + LOOP + FOREACH agent IN ARRAY reviewer_agents + LOOP + INSERT INTO "project_credential_overrides" + ("project_id", "env_var_key", "credential_id", "agent_type") + VALUES (r.project_id, 'GITHUB_TOKEN', r.credential_id, agent) + ON CONFLICT DO NOTHING; + END LOOP; + END LOOP; + + -- Case 2: Projects using org-default GITHUB_REVIEWER_TOKEN (no project override) + -- Find org defaults for GITHUB_REVIEWER_TOKEN and create agent-scoped overrides + -- for all projects in that org that don't already have a project-level override. + FOR r IN + SELECT p."id" AS project_id, c."id" AS credential_id + FROM "projects" p + INNER JOIN "credentials" c + ON c."org_id" = p."org_id" + AND c."env_var_key" = 'GITHUB_REVIEWER_TOKEN' + AND c."is_default" = true + WHERE NOT EXISTS ( + SELECT 1 FROM "project_credential_overrides" pco + WHERE pco."project_id" = p."id" + AND pco."env_var_key" = 'GITHUB_REVIEWER_TOKEN' + AND pco."agent_type" IS NULL + ) + LOOP + FOREACH agent IN ARRAY reviewer_agents + LOOP + INSERT INTO "project_credential_overrides" + ("project_id", "env_var_key", "credential_id", "agent_type") + VALUES (r.project_id, 'GITHUB_TOKEN', r.credential_id, agent) + ON CONFLICT DO NOTHING; + END LOOP; + END LOOP; + + -- 5. Remove old GITHUB_REVIEWER_TOKEN project override rows + DELETE FROM "project_credential_overrides" + WHERE "env_var_key" = 'GITHUB_REVIEWER_TOKEN' + AND "agent_type" IS NULL; +END $$; + +COMMIT; diff --git a/src/db/migrations/0005_config_schema_cleanup.sql b/src/db/migrations/0005_config_schema_cleanup.sql new file mode 100644 index 00000000..c37c2c91 --- /dev/null +++ b/src/db/migrations/0005_config_schema_cleanup.sql @@ -0,0 +1,119 @@ +-- Migration 0005: Configuration Schema Cleanup +-- +-- 1. Drop dead project_secrets table +-- 2. Restructure cascade_defaults PK (drop id, promote org_id) +-- 3. Rename backend → agent_backend in agent_configs +-- 4. Rename agent_backend_default → agent_backend in projects +-- 5. Standardize index naming (idx_projects_repo → uq_projects_repo) +-- 6. Extract Trello config into project_integrations table +-- 7. Drop Trello columns from projects + +-- ============================================================ +-- 1. Drop dead project_secrets table +-- ============================================================ +DROP TABLE IF EXISTS "project_secrets"; + +-- ============================================================ +-- 2. Restructure cascade_defaults: drop id, make org_id the PK +-- ============================================================ +-- org_id is already NOT NULL UNIQUE, so promote it to PK +ALTER TABLE "cascade_defaults" DROP CONSTRAINT IF EXISTS "cascade_defaults_pkey"; +ALTER TABLE "cascade_defaults" DROP COLUMN IF EXISTS "id"; +ALTER TABLE "cascade_defaults" ADD PRIMARY KEY ("org_id"); + +-- Add created_at (every other table has it) +ALTER TABLE "cascade_defaults" ADD COLUMN IF NOT EXISTS "created_at" timestamp DEFAULT now(); + +-- ============================================================ +-- 3. Rename backend → agent_backend in agent_configs +-- ============================================================ +ALTER TABLE "agent_configs" RENAME COLUMN "backend" TO "agent_backend"; + +-- ============================================================ +-- 4. Rename agent_backend_default → agent_backend in projects +-- ============================================================ +ALTER TABLE "projects" RENAME COLUMN "agent_backend_default" TO "agent_backend"; + +-- ============================================================ +-- 5. Standardize index naming +-- ============================================================ +ALTER INDEX IF EXISTS "idx_projects_repo" RENAME TO "uq_projects_repo"; + +-- ============================================================ +-- 6. Create project_integrations table +-- ============================================================ +CREATE TABLE IF NOT EXISTS "project_integrations" ( + "id" serial PRIMARY KEY, + "project_id" text NOT NULL REFERENCES "projects"("id") ON DELETE CASCADE, + "type" text NOT NULL, + "config" jsonb NOT NULL, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS "uq_project_integrations_project_type" + ON "project_integrations" ("project_id", "type"); + +-- ============================================================ +-- 7. Migrate Trello data from flat columns to JSONB +-- ============================================================ +INSERT INTO "project_integrations" ("project_id", "type", "config") +SELECT + "id", + 'trello', + jsonb_strip_nulls(jsonb_build_object( + 'boardId', "trello_board_id", + 'lists', jsonb_strip_nulls(jsonb_build_object( + 'briefing', "trello_list_briefing", + 'stories', "trello_list_stories", + 'planning', "trello_list_planning", + 'todo', "trello_list_todo", + 'inProgress', "trello_list_in_progress", + 'inReview', "trello_list_in_review", + 'done', "trello_list_done", + 'merged', "trello_list_merged", + 'debug', "trello_list_debug" + )), + 'labels', jsonb_strip_nulls(jsonb_build_object( + 'readyToProcess', "trello_label_ready_to_process", + 'processing', "trello_label_processing", + 'processed', "trello_label_processed", + 'error', "trello_label_error" + )), + 'customFields', CASE + WHEN "trello_custom_field_cost" IS NOT NULL + THEN jsonb_build_object('cost', "trello_custom_field_cost") + ELSE NULL + END + )) +FROM "projects" +WHERE "trello_board_id" IS NOT NULL; + +-- ============================================================ +-- 8. Drop Trello columns from projects +-- ============================================================ +ALTER TABLE "projects" + DROP COLUMN IF EXISTS "trello_board_id", + DROP COLUMN IF EXISTS "trello_list_briefing", + DROP COLUMN IF EXISTS "trello_list_stories", + DROP COLUMN IF EXISTS "trello_list_planning", + DROP COLUMN IF EXISTS "trello_list_todo", + DROP COLUMN IF EXISTS "trello_list_in_progress", + DROP COLUMN IF EXISTS "trello_list_in_review", + DROP COLUMN IF EXISTS "trello_list_done", + DROP COLUMN IF EXISTS "trello_list_merged", + DROP COLUMN IF EXISTS "trello_list_debug", + DROP COLUMN IF EXISTS "trello_label_ready_to_process", + DROP COLUMN IF EXISTS "trello_label_processing", + DROP COLUMN IF EXISTS "trello_label_processed", + DROP COLUMN IF EXISTS "trello_label_error", + DROP COLUMN IF EXISTS "trello_custom_field_cost"; + +DROP INDEX IF EXISTS "idx_projects_trello_board_id"; + +-- ============================================================ +-- 9. Expression index for Trello board lookup via integrations +-- ============================================================ +CREATE INDEX IF NOT EXISTS "idx_pi_trello_board" + ON "project_integrations" ((config->>'boardId')) + WHERE "type" = 'trello'; diff --git a/src/db/repositories/configRepository.ts b/src/db/repositories/configRepository.ts index 4946a046..3b55680e 100644 --- a/src/db/repositories/configRepository.ts +++ b/src/db/repositories/configRepository.ts @@ -1,8 +1,15 @@ -import { and, eq, isNull } from 'drizzle-orm'; +import { type SQL, and, eq, isNull, sql } from 'drizzle-orm'; import { validateConfig } from '../../config/schema.js'; import type { CascadeConfig, ProjectConfig } from '../../types/index.js'; import { getDb } from '../client.js'; -import { agentConfigs, cascadeDefaults, projects } from '../schema/index.js'; +import { agentConfigs, cascadeDefaults, projectIntegrations, projects } from '../schema/index.js'; + +interface TrelloIntegrationConfig { + boardId: string; + lists: Record; + labels: Record; + customFields?: { cost?: string }; +} interface DefaultsRow { model: string | null; @@ -22,7 +29,7 @@ interface AgentConfigRow { agentType: string; model: string | null; maxIterations: number | null; - backend: string | null; + agentBackend: string | null; prompt: string | null; } @@ -35,7 +42,7 @@ function buildAgentMaps(configs: AgentConfigRow[]) { if (ac.model) models[ac.agentType] = ac.model; if (ac.maxIterations != null) iterations[ac.agentType] = ac.maxIterations; if (ac.prompt) prompts[ac.agentType] = ac.prompt; - if (ac.backend) backends[ac.agentType] = ac.backend; + if (ac.agentBackend) backends[ac.agentType] = ac.agentBackend; } return { models, iterations, prompts, backends }; } @@ -44,15 +51,6 @@ function orUndefined>(obj: T): T | undefined { return Object.keys(obj).length > 0 ? obj : undefined; } -/** Filter null/undefined values from a key-value mapping, returning only string entries. */ -function compactRecord(entries: Record): Record { - const result: Record = {}; - for (const [key, value] of Object.entries(entries)) { - if (value != null) result[key] = value; - } - return result; -} - function mapDefaultsRow(row: DefaultsRow | undefined, globalAgentConfigs: AgentConfigRow[]) { const { models, iterations } = buildAgentMaps(globalAgentConfigs); @@ -78,28 +76,10 @@ type ProjectRow = typeof projects.$inferSelect; function mapProjectRow( row: ProjectRow, projectAgentConfigs: AgentConfigRow[], + trelloConfig?: TrelloIntegrationConfig, ): Record { const { models, prompts, backends } = buildAgentMaps(projectAgentConfigs); - const lists = compactRecord({ - briefing: row.trelloListBriefing, - stories: row.trelloListStories, - planning: row.trelloListPlanning, - todo: row.trelloListTodo, - inProgress: row.trelloListInProgress, - inReview: row.trelloListInReview, - done: row.trelloListDone, - merged: row.trelloListMerged, - debug: row.trelloListDebug, - }); - - const labels = compactRecord({ - readyToProcess: row.trelloLabelReadyToProcess, - processing: row.trelloLabelProcessing, - processed: row.trelloLabelProcessed, - error: row.trelloLabelError, - }); - const project: Record = { id: row.id, orgId: row.orgId, @@ -107,21 +87,23 @@ function mapProjectRow( repo: row.repo, baseBranch: row.baseBranch ?? 'main', branchPrefix: row.branchPrefix ?? 'feature/', - trello: { - boardId: row.trelloBoardId, - lists, - labels, - customFields: row.trelloCustomFieldCost ? { cost: row.trelloCustomFieldCost } : undefined, - }, + trello: trelloConfig + ? { + boardId: trelloConfig.boardId, + lists: trelloConfig.lists, + labels: trelloConfig.labels, + customFields: trelloConfig.customFields, + } + : { boardId: '', lists: {}, labels: {} }, prompts: orUndefined(prompts), model: row.model ?? undefined, agentModels: orUndefined(models), cardBudgetUsd: row.cardBudgetUsd ? Number(row.cardBudgetUsd) : undefined, }; - if (row.agentBackendDefault) { + if (row.agentBackend) { project.agentBackend = { - default: row.agentBackendDefault, + default: row.agentBackend, overrides: backends, subscriptionCostZero: row.subscriptionCostZero ?? false, }; @@ -138,10 +120,24 @@ async function loadAgentConfigs(): Promise { export async function loadConfigFromDb(): Promise { const db = getDb(); - // Load first defaults row (for the primary/default org) - const [defaultsRow] = await db.select().from(cascadeDefaults).limit(1); - const projectRows = await db.select().from(projects); - const allAgentConfigs = await loadAgentConfigs(); + const [defaultsRow, projectRows, allAgentConfigs, integrationRows] = await Promise.all([ + db + .select() + .from(cascadeDefaults) + .limit(1) + .then((r) => r[0]), + db.select().from(projects), + loadAgentConfigs(), + db.select().from(projectIntegrations), + ]); + + // Index integrations by project ID + const integrationsByProject = new Map(); + for (const row of integrationRows) { + const existing = integrationsByProject.get(row.projectId) ?? []; + existing.push(row); + integrationsByProject.set(row.projectId, existing); + } // Split agent configs: global (project_id IS NULL, org_id IS NULL) and per-project // Also collect org-level configs (org_id set, project_id IS NULL) as fallback globals @@ -170,93 +166,67 @@ export async function loadConfigFromDb(): Promise { const rawConfig = { defaults: mapDefaultsRow(defaultsRow, mergedGlobalConfigs), - projects: projectRows.map((row) => - mapProjectRow(row, projectAgentConfigsMap.get(row.id) ?? []), - ), + projects: projectRows.map((row) => { + const integrations = integrationsByProject.get(row.id) ?? []; + const trelloConfig = integrations.find((i) => i.type === 'trello')?.config as + | TrelloIntegrationConfig + | undefined; + return mapProjectRow(row, projectAgentConfigsMap.get(row.id) ?? [], trelloConfig); + }), }; return validateConfig(rawConfig); } -export async function findProjectByBoardIdFromDb( - boardId: string, -): Promise { +async function findProjectFromDb(whereClause: SQL): Promise { const db = getDb(); - const [row] = await db.select().from(projects).where(eq(projects.trelloBoardId, boardId)); + const [row] = await db.select().from(projects).where(whereClause); if (!row) return undefined; - const projectAcs = await db.select().from(agentConfigs).where(eq(agentConfigs.projectId, row.id)); - const orgAcs = await db - .select() - .from(agentConfigs) - .where(and(eq(agentConfigs.orgId, row.orgId), isNull(agentConfigs.projectId))); - const globalAcs = await db - .select() - .from(agentConfigs) - .where(and(isNull(agentConfigs.projectId), isNull(agentConfigs.orgId))); + const [projectAcs, orgAcs, globalAcs, defaultsRow, integrations] = await Promise.all([ + db.select().from(agentConfigs).where(eq(agentConfigs.projectId, row.id)), + db + .select() + .from(agentConfigs) + .where(and(eq(agentConfigs.orgId, row.orgId), isNull(agentConfigs.projectId))), + db + .select() + .from(agentConfigs) + .where(and(isNull(agentConfigs.projectId), isNull(agentConfigs.orgId))), + db + .select() + .from(cascadeDefaults) + .where(eq(cascadeDefaults.orgId, row.orgId)) + .then((r) => r[0]), + db.select().from(projectIntegrations).where(eq(projectIntegrations.projectId, row.id)), + ]); + + const trelloConfig = integrations.find((i) => i.type === 'trello')?.config as + | TrelloIntegrationConfig + | undefined; - const [defaultsRow] = await db - .select() - .from(cascadeDefaults) - .where(eq(cascadeDefaults.orgId, row.orgId)); const rawConfig = { defaults: mapDefaultsRow(defaultsRow, [...globalAcs, ...orgAcs]), - projects: [mapProjectRow(row, projectAcs)], + projects: [mapProjectRow(row, projectAcs, trelloConfig)], }; const validated = validateConfig(rawConfig); return validated.projects[0]; } -export async function findProjectByRepoFromDb(repo: string): Promise { - const db = getDb(); - const [row] = await db.select().from(projects).where(eq(projects.repo, repo)); - if (!row) return undefined; - - const projectAcs = await db.select().from(agentConfigs).where(eq(agentConfigs.projectId, row.id)); - const orgAcs = await db - .select() - .from(agentConfigs) - .where(and(eq(agentConfigs.orgId, row.orgId), isNull(agentConfigs.projectId))); - const globalAcs = await db - .select() - .from(agentConfigs) - .where(and(isNull(agentConfigs.projectId), isNull(agentConfigs.orgId))); - - const [defaultsRow] = await db - .select() - .from(cascadeDefaults) - .where(eq(cascadeDefaults.orgId, row.orgId)); - const rawConfig = { - defaults: mapDefaultsRow(defaultsRow, [...globalAcs, ...orgAcs]), - projects: [mapProjectRow(row, projectAcs)], - }; - const validated = validateConfig(rawConfig); - return validated.projects[0]; +export function findProjectByBoardIdFromDb(boardId: string): Promise { + return findProjectFromDb( + sql`${projects.id} IN ( + SELECT ${projectIntegrations.projectId} FROM ${projectIntegrations} + WHERE ${projectIntegrations.type} = 'trello' + AND ${projectIntegrations.config}->>'boardId' = ${boardId} + )`, + ); } -export async function findProjectByIdFromDb(id: string): Promise { - const db = getDb(); - const [row] = await db.select().from(projects).where(eq(projects.id, id)); - if (!row) return undefined; - - const projectAcs = await db.select().from(agentConfigs).where(eq(agentConfigs.projectId, row.id)); - const orgAcs = await db - .select() - .from(agentConfigs) - .where(and(eq(agentConfigs.orgId, row.orgId), isNull(agentConfigs.projectId))); - const globalAcs = await db - .select() - .from(agentConfigs) - .where(and(isNull(agentConfigs.projectId), isNull(agentConfigs.orgId))); +export function findProjectByRepoFromDb(repo: string): Promise { + return findProjectFromDb(eq(projects.repo, repo)); +} - const [defaultsRow] = await db - .select() - .from(cascadeDefaults) - .where(eq(cascadeDefaults.orgId, row.orgId)); - const rawConfig = { - defaults: mapDefaultsRow(defaultsRow, [...globalAcs, ...orgAcs]), - projects: [mapProjectRow(row, projectAcs)], - }; - const validated = validateConfig(rawConfig); - return validated.projects[0]; +export function findProjectByIdFromDb(id: string): Promise { + return findProjectFromDb(eq(projects.id, id)); } diff --git a/src/db/repositories/credentialsRepository.ts b/src/db/repositories/credentialsRepository.ts index 322b0ede..bd5cee2b 100644 --- a/src/db/repositories/credentialsRepository.ts +++ b/src/db/repositories/credentialsRepository.ts @@ -1,11 +1,11 @@ -import { and, eq } from 'drizzle-orm'; +import { and, eq, isNull } from 'drizzle-orm'; import { getDb } from '../client.js'; import { credentials, projectCredentialOverrides } from '../schema/index.js'; /** * Resolve a single credential for a project. * Resolution order: - * 1. Project-level override (project_credential_overrides → credentials.value) + * 1. Project-level override (project_credential_overrides WHERE agent_type IS NULL) * 2. Org-level default (credentials WHERE org_id AND env_var_key AND is_default) * 3. null */ @@ -16,7 +16,7 @@ export async function resolveCredential( ): Promise { const db = getDb(); - // 1. Check project override + // 1. Check project override (project-wide, not agent-scoped) const [override] = await db .select({ value: credentials.value }) .from(projectCredentialOverrides) @@ -25,6 +25,7 @@ export async function resolveCredential( and( eq(projectCredentialOverrides.projectId, projectId), eq(projectCredentialOverrides.envVarKey, envVarKey), + isNull(projectCredentialOverrides.agentType), ), ); if (override) return override.value; @@ -45,6 +46,38 @@ export async function resolveCredential( return null; } +/** + * Resolve a credential for a specific agent type and project. + * Resolution order: + * 1. Agent+project override (WHERE project_id AND env_var_key AND agent_type) + * 2. Falls through to resolveCredential() (project override → org default → null) + */ +export async function resolveAgentCredential( + projectId: string, + orgId: string, + agentType: string, + envVarKey: string, +): Promise { + const db = getDb(); + + // 1. Check agent-scoped override + const [agentOverride] = await db + .select({ value: credentials.value }) + .from(projectCredentialOverrides) + .innerJoin(credentials, eq(projectCredentialOverrides.credentialId, credentials.id)) + .where( + and( + eq(projectCredentialOverrides.projectId, projectId), + eq(projectCredentialOverrides.envVarKey, envVarKey), + eq(projectCredentialOverrides.agentType, agentType), + ), + ); + if (agentOverride) return agentOverride.value; + + // 2. Fall through to project override → org default + return resolveCredential(projectId, orgId, envVarKey); +} + /** * Resolve all credentials for a project as a key-value map. * Merges org defaults with project overrides (overrides win). @@ -66,7 +99,7 @@ export async function resolveAllCredentials( result[row.envVarKey] = row.value; } - // Load project overrides (overwrite org defaults) + // Load project-wide overrides (overwrite org defaults) — excludes agent-scoped overrides const overrides = await db .select({ envVarKey: projectCredentialOverrides.envVarKey, @@ -74,7 +107,12 @@ export async function resolveAllCredentials( }) .from(projectCredentialOverrides) .innerJoin(credentials, eq(projectCredentialOverrides.credentialId, credentials.id)) - .where(eq(projectCredentialOverrides.projectId, projectId)); + .where( + and( + eq(projectCredentialOverrides.projectId, projectId), + isNull(projectCredentialOverrides.agentType), + ), + ); for (const row of overrides) { result[row.envVarKey] = row.value; @@ -139,7 +177,7 @@ export async function listOrgCredentials( return db.select().from(credentials).where(eq(credentials.orgId, orgId)); } -// --- Override management --- +// --- Override management (project-wide) --- export async function setProjectCredentialOverride( projectId: string, @@ -147,13 +185,21 @@ export async function setProjectCredentialOverride( credentialId: number, ): Promise { const db = getDb(); + // Upsert: use raw SQL conflict target for partial index (agent_type IS NULL) + // Drizzle's onConflictDoUpdate doesn't support WHERE on conflict target, + // so we delete-then-insert to match the partial unique index. + await db + .delete(projectCredentialOverrides) + .where( + and( + eq(projectCredentialOverrides.projectId, projectId), + eq(projectCredentialOverrides.envVarKey, envVarKey), + isNull(projectCredentialOverrides.agentType), + ), + ); await db .insert(projectCredentialOverrides) - .values({ projectId, envVarKey, credentialId }) - .onConflictDoUpdate({ - target: [projectCredentialOverrides.projectId, projectCredentialOverrides.envVarKey], - set: { credentialId, updatedAt: new Date() }, - }); + .values({ projectId, envVarKey, credentialId, agentType: null }); } export async function removeProjectCredentialOverride( @@ -167,22 +213,67 @@ export async function removeProjectCredentialOverride( and( eq(projectCredentialOverrides.projectId, projectId), eq(projectCredentialOverrides.envVarKey, envVarKey), + isNull(projectCredentialOverrides.agentType), ), ); } export async function listProjectOverrides( projectId: string, -): Promise<{ envVarKey: string; credentialId: number; credentialName: string }[]> { +): Promise< + { envVarKey: string; credentialId: number; credentialName: string; agentType: string | null }[] +> { const db = getDb(); const rows = await db .select({ envVarKey: projectCredentialOverrides.envVarKey, credentialId: projectCredentialOverrides.credentialId, credentialName: credentials.name, + agentType: projectCredentialOverrides.agentType, }) .from(projectCredentialOverrides) .innerJoin(credentials, eq(projectCredentialOverrides.credentialId, credentials.id)) .where(eq(projectCredentialOverrides.projectId, projectId)); return rows; } + +// --- Override management (agent-scoped) --- + +export async function setAgentCredentialOverride( + projectId: string, + envVarKey: string, + agentType: string, + credentialId: number, +): Promise { + const db = getDb(); + // Delete-then-insert to match partial unique index (agent_type IS NOT NULL) + await db + .delete(projectCredentialOverrides) + .where( + and( + eq(projectCredentialOverrides.projectId, projectId), + eq(projectCredentialOverrides.envVarKey, envVarKey), + eq(projectCredentialOverrides.agentType, agentType), + ), + ); + await db + .insert(projectCredentialOverrides) + .values({ projectId, envVarKey, credentialId, agentType }); +} + +export async function removeAgentCredentialOverride( + projectId: string, + envVarKey: string, + agentType: string, +): Promise { + const db = getDb(); + await db + .delete(projectCredentialOverrides) + .where( + and( + eq(projectCredentialOverrides.projectId, projectId), + eq(projectCredentialOverrides.envVarKey, envVarKey), + eq(projectCredentialOverrides.agentType, agentType), + ), + ); +} diff --git a/src/db/repositories/secretsRepository.ts b/src/db/repositories/secretsRepository.ts deleted file mode 100644 index 8c005991..00000000 --- a/src/db/repositories/secretsRepository.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { and, eq } from 'drizzle-orm'; -import { getDb } from '../client.js'; -import { projectSecrets } from '../schema/index.js'; - -export async function getProjectSecret(projectId: string, key: string): Promise { - const db = getDb(); - const [row] = await db - .select({ value: projectSecrets.value }) - .from(projectSecrets) - .where(and(eq(projectSecrets.projectId, projectId), eq(projectSecrets.key, key))); - return row?.value ?? null; -} - -export async function getProjectSecrets(projectId: string): Promise> { - const db = getDb(); - const rows = await db - .select({ key: projectSecrets.key, value: projectSecrets.value }) - .from(projectSecrets) - .where(eq(projectSecrets.projectId, projectId)); - - const result: Record = {}; - for (const row of rows) { - result[row.key] = row.value; - } - return result; -} - -export async function setProjectSecret( - projectId: string, - key: string, - value: string, -): Promise { - const db = getDb(); - await db - .insert(projectSecrets) - .values({ projectId, key, value }) - .onConflictDoUpdate({ - target: [projectSecrets.projectId, projectSecrets.key], - set: { value, updatedAt: new Date() }, - }); -} - -export async function deleteProjectSecret(projectId: string, key: string): Promise { - const db = getDb(); - await db - .delete(projectSecrets) - .where(and(eq(projectSecrets.projectId, projectId), eq(projectSecrets.key, key))); -} diff --git a/src/db/schema/agentConfigs.ts b/src/db/schema/agentConfigs.ts new file mode 100644 index 00000000..4bcbf9ea --- /dev/null +++ b/src/db/schema/agentConfigs.ts @@ -0,0 +1,24 @@ +import { integer, pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core'; +import { organizations } from './organizations.js'; +import { projects } from './projects.js'; + +export const agentConfigs = pgTable( + 'agent_configs', + { + id: serial('id').primaryKey(), + orgId: text('org_id').references(() => organizations.id, { onDelete: 'cascade' }), + projectId: text('project_id').references(() => projects.id, { onDelete: 'cascade' }), + agentType: text('agent_type').notNull(), + model: text('model'), + maxIterations: integer('max_iterations'), + agentBackend: text('agent_backend'), + prompt: text('prompt'), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at') + .defaultNow() + .$onUpdate(() => new Date()), + }, + // Unique constraints are enforced by partial indexes in the DB: + // - uq_agent_configs_global: UNIQUE(agent_type) WHERE project_id IS NULL + // - uq_agent_configs_with_project: UNIQUE(project_id, agent_type) WHERE project_id IS NOT NULL +); diff --git a/src/db/schema/credentials.ts b/src/db/schema/credentials.ts index 59cd0881..d57f1bd2 100644 --- a/src/db/schema/credentials.ts +++ b/src/db/schema/credentials.ts @@ -1,13 +1,4 @@ -import { - boolean, - index, - integer, - pgTable, - serial, - text, - timestamp, - uniqueIndex, -} from 'drizzle-orm/pg-core'; +import { boolean, index, integer, pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core'; import { organizations } from './organizations.js'; import { projects } from './projects.js'; @@ -47,15 +38,15 @@ export const projectCredentialOverrides = pgTable( credentialId: integer('credential_id') .notNull() .references(() => credentials.id, { onDelete: 'cascade' }), + agentType: text('agent_type'), createdAt: timestamp('created_at').defaultNow(), updatedAt: timestamp('updated_at') .defaultNow() .$onUpdate(() => new Date()), }, - (table) => [ - uniqueIndex('uq_project_credential_overrides_project_env_var_key').on( - table.projectId, - table.envVarKey, - ), + () => [ + // Partial unique indexes enforced via migration SQL: + // - (project_id, env_var_key) WHERE agent_type IS NULL — project-wide overrides + // - (project_id, env_var_key, agent_type) WHERE agent_type IS NOT NULL — agent-scoped ], ); diff --git a/src/db/schema/defaults.ts b/src/db/schema/defaults.ts index 86a504bc..0b0ad0d4 100644 --- a/src/db/schema/defaults.ts +++ b/src/db/schema/defaults.ts @@ -2,10 +2,8 @@ import { integer, numeric, pgTable, text, timestamp } from 'drizzle-orm/pg-core' import { organizations } from './organizations.js'; export const cascadeDefaults = pgTable('cascade_defaults', { - id: text('id').primaryKey(), orgId: text('org_id') - .notNull() - .unique() + .primaryKey() .references(() => organizations.id, { onDelete: 'cascade' }), model: text('model'), maxIterations: integer('max_iterations'), @@ -16,6 +14,7 @@ export const cascadeDefaults = pgTable('cascade_defaults', { agentBackend: text('agent_backend'), progressModel: text('progress_model'), progressIntervalMinutes: numeric('progress_interval_minutes', { precision: 5, scale: 1 }), + createdAt: timestamp('created_at').defaultNow(), updatedAt: timestamp('updated_at') .defaultNow() .$onUpdate(() => new Date()), diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index 565a4655..c4dc28ea 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -1,6 +1,7 @@ export { credentials, projectCredentialOverrides } from './credentials.js'; export { cascadeDefaults } from './defaults.js'; export { organizations } from './organizations.js'; -export { agentConfigs, projects } from './projects.js'; +export { agentConfigs } from './agentConfigs.js'; +export { projectIntegrations } from './integrations.js'; +export { projects } from './projects.js'; export { agentRunLlmCalls, agentRunLogs, agentRuns, debugAnalyses } from './runs.js'; -export { projectSecrets } from './secrets.js'; diff --git a/src/db/schema/secrets.ts b/src/db/schema/integrations.ts similarity index 50% rename from src/db/schema/secrets.ts rename to src/db/schema/integrations.ts index 74d28a0d..14e4f801 100644 --- a/src/db/schema/secrets.ts +++ b/src/db/schema/integrations.ts @@ -1,19 +1,19 @@ -import { pgTable, serial, text, timestamp, unique } from 'drizzle-orm/pg-core'; +import { jsonb, pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; import { projects } from './projects.js'; -export const projectSecrets = pgTable( - 'project_secrets', +export const projectIntegrations = pgTable( + 'project_integrations', { id: serial('id').primaryKey(), projectId: text('project_id') .notNull() .references(() => projects.id, { onDelete: 'cascade' }), - key: text('key').notNull(), - value: text('value').notNull(), + type: text('type').notNull(), + config: jsonb('config').notNull(), createdAt: timestamp('created_at').defaultNow(), updatedAt: timestamp('updated_at') .defaultNow() .$onUpdate(() => new Date()), }, - (table) => [unique('uq_project_secrets_project_key').on(table.projectId, table.key)], + (table) => [uniqueIndex('uq_project_integrations_project_type').on(table.projectId, table.type)], ); diff --git a/src/db/schema/projects.ts b/src/db/schema/projects.ts index ae616fc6..18b70c08 100644 --- a/src/db/schema/projects.ts +++ b/src/db/schema/projects.ts @@ -1,14 +1,4 @@ -import { - boolean, - index, - integer, - numeric, - pgTable, - serial, - text, - timestamp, - uniqueIndex, -} from 'drizzle-orm/pg-core'; +import { boolean, numeric, pgTable, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; import { organizations } from './organizations.js'; export const projects = pgTable( @@ -22,31 +12,10 @@ export const projects = pgTable( repo: text('repo').notNull().unique(), baseBranch: text('base_branch').default('main'), branchPrefix: text('branch_prefix').default('feature/'), - trelloBoardId: text('trello_board_id').notNull(), - - // Trello lists (individual columns replacing JSONB) - trelloListBriefing: text('trello_list_briefing'), - trelloListStories: text('trello_list_stories'), - trelloListPlanning: text('trello_list_planning'), - trelloListTodo: text('trello_list_todo'), - trelloListInProgress: text('trello_list_in_progress'), - trelloListInReview: text('trello_list_in_review'), - trelloListDone: text('trello_list_done'), - trelloListMerged: text('trello_list_merged'), - trelloListDebug: text('trello_list_debug'), - - // Trello labels (individual columns replacing JSONB) - trelloLabelReadyToProcess: text('trello_label_ready_to_process'), - trelloLabelProcessing: text('trello_label_processing'), - trelloLabelProcessed: text('trello_label_processed'), - trelloLabelError: text('trello_label_error'), - - // Trello custom fields (individual column replacing JSONB) - trelloCustomFieldCost: text('trello_custom_field_cost'), model: text('model'), cardBudgetUsd: numeric('card_budget_usd', { precision: 10, scale: 2 }), - agentBackendDefault: text('agent_backend_default'), + agentBackend: text('agent_backend'), subscriptionCostZero: boolean('subscription_cost_zero').default(false), createdAt: timestamp('created_at').defaultNow(), @@ -54,29 +23,5 @@ export const projects = pgTable( .defaultNow() .$onUpdate(() => new Date()), }, - (table) => [ - index('idx_projects_trello_board_id').on(table.trelloBoardId), - uniqueIndex('idx_projects_repo').on(table.repo), - ], -); - -export const agentConfigs = pgTable( - 'agent_configs', - { - id: serial('id').primaryKey(), - orgId: text('org_id').references(() => organizations.id, { onDelete: 'cascade' }), - projectId: text('project_id').references(() => projects.id, { onDelete: 'cascade' }), - agentType: text('agent_type').notNull(), - model: text('model'), - maxIterations: integer('max_iterations'), - backend: text('backend'), - prompt: text('prompt'), - createdAt: timestamp('created_at').defaultNow(), - updatedAt: timestamp('updated_at') - .defaultNow() - .$onUpdate(() => new Date()), - }, - // Unique constraints are enforced by partial indexes in the DB: - // - uq_agent_configs_global: UNIQUE(agent_type) WHERE project_id IS NULL - // - uq_agent_configs_with_project: UNIQUE(project_id, agent_type) WHERE project_id IS NOT NULL + (table) => [uniqueIndex('uq_projects_repo').on(table.repo)], ); diff --git a/src/gadgets/tmux.ts b/src/gadgets/tmux.ts index 8e3dd9f6..891eb44d 100644 --- a/src/gadgets/tmux.ts +++ b/src/gadgets/tmux.ts @@ -1,1131 +1,7 @@ -/** - * Tmux gadget - execute commands in tmux sessions. - * - * Uses tmux control mode for reliable output streaming and exit detection. - * All commands run as windows within a single control session. - */ -import { type ChildProcess, execSync, spawn } from 'node:child_process'; -import { resolve } from 'node:path'; -import * as readline from 'node:readline'; -import { Gadget, z } from 'llmist'; - -/** - * Error thrown when a command exits with non-zero exit code. - * Contains session name, exit code, and output preview for debugging. - */ -class CommandFailedError extends Error { - constructor( - public readonly session: string, - public readonly exitCode: number, - public readonly output: string, - ) { - const preview = output.length > 1000 ? output.slice(-1000) : output; - super( - `Command exited with code ${exitCode}\n\n` + - `Session: ${session}\n` + - `Exit code: ${exitCode}\n\n` + - `Output:\n${preview || '(no output)'}`, - ); - this.name = 'CommandFailedError'; - } -} - -const DEFAULT_TIMEOUT_MS = 300000; // 5 min for the gadget itself -const DEFAULT_WAIT_MS = 120000; // 120s to wait for initial output -const MAX_WAIT_MS = 120000; // Never wait longer than 120s -const POLL_INTERVAL_MS = 100; // Faster polling since we have streamed output -const MAX_OUTPUT_BUFFER = 10 * 1024 * 1024; // 10MB max output per pane - -const CONTROL_SESSION = '_cascade_control'; -const EXIT_MARKER_PREFIX = '___CASCADE_EXIT_'; -const EXIT_MARKER_SUFFIX = '___'; - -/** - * Session name schema - accepts any string, sanitized at runtime. - * Invalid characters (like /) are automatically replaced with dashes. - */ -const sessionNameSchema = z.string().min(1).max(64); - -/** - * Sanitize session name by replacing invalid characters with dashes. - */ -function sanitizeSessionName(name: string): string { - return name.replace(/[^a-zA-Z0-9_-]/g, '-'); -} - -/** - * Unescape tmux control mode output (octal escapes like \012 -> \n) - */ -function unescapeOutput(s: string): string { - return s.replace(/\\(\d{3})/g, (_, oct) => String.fromCharCode(Number.parseInt(oct, 8))); -} - -// ANSI escape code patterns (using hex to avoid lint errors about control chars) -const ESC = '\u001b'; -const BEL = '\u0007'; -const ANSI_PATTERN = new RegExp(`${ESC}\\[[0-9;]*[a-zA-Z]`, 'g'); -const OSC_PATTERN = new RegExp(`${ESC}\\][^${BEL}]*${BEL}`, 'g'); -const DCS_PATTERN = new RegExp(`${ESC}[PX^_][^${ESC}]*${ESC}\\\\`, 'g'); - -/** - * Strip ANSI escape codes from output - */ -function stripAnsi(s: string): string { - return s - .replace(ANSI_PATTERN, '') - .replace(OSC_PATTERN, '') - .replace(DCS_PATTERN, '') - .replace(/\r/g, ''); -} - -/** - * Sleep helper - */ -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** - * Validates that git commands don't contain dangerous flags that bypass safety checks. - * @throws Error if a dangerous flag is detected - */ -function validateGitCommand(command: string): void { - // Normalize command for checking (handle multiline, extra spaces) - const normalized = command.toLowerCase(); - - // Check for git commit/push with --no-verify or -n flag - // Match patterns like: git commit --no-verify, git push --no-verify - // Also match: git commit -n, git commit -anm (contains -n) - // Uses .*? (non-greedy) to match any characters between git commit/push and the flag - // The -n pattern matches any flag containing 'n' (e.g., -n, -an, -anm, -nam) - const gitNoVerifyPattern = - /\bgit\s+(commit|push)\b.*?(\s--no-verify\b|\s-[a-z]*n[a-z]*(?=\s|"|'|$))/; - - if (gitNoVerifyPattern.test(normalized)) { - throw new Error( - 'Git commands with --no-verify or -n flag are not allowed. ' + - 'Pre-commit and pre-push hooks must run to ensure code quality.', - ); - } - - // Block broad staging commands that capture unintended files (build artifacts, generated files) - // Note: normalized is already lowercased, so -A becomes -a - const broadStagingPattern = - /\bgit\s+add\s+(-a\b|--all\b|\.\s*($|&&|\||;|"|')|\.\/\s*($|&&|\||;|"|'))/; - if (broadStagingPattern.test(normalized)) { - throw new Error( - 'Broad git staging (git add -A / git add . / git add --all) is not allowed. ' + - 'Stage specific files instead: git add ...\n' + - 'This prevents accidentally committing build artifacts and generated files.', - ); - } -} - -// ============================================================================ -// Session Completion Notices -// ============================================================================ - -/** - * Completed session notice - stored for injection into agent conversation. - */ -interface CompletedSessionNotice { - exitCode: number; - tailOutput: string; // Last 100 lines -} - -/** - * Pending notices for sessions that completed. - * Consumed by agent loop to inject as user messages. - */ -const pendingNotices = new Map(); - -/** - * Get and clear pending session completion notices. - * Called by agent loop to inject into conversation via injectUserMessage(). - */ -export function consumePendingSessionNotices(): Map { - const notices = new Map(pendingNotices); - pendingNotices.clear(); - return notices; -} - -/** - * TmuxControlClient - manages a persistent control mode connection to tmux. - * Uses event-driven protocol for real-time output and reliable exit detection. - */ -class TmuxControlClient { - private proc: ChildProcess | null = null; - private rl: readline.Interface | null = null; - private connected = false; - - // Connection lock - prevents race conditions when multiple parallel calls try to connect - private connectPromise: Promise | null = null; - - // Command queue - allows parallel gadget calls to queue and wait - private commandQueue: Array<{ - cmd: string; - resolve: (v: string) => void; - reject: (e: Error) => void; - }> = []; - private processingQueue = false; - - // Current command being processed - private pendingCommand: { resolve: (v: string) => void; reject: (e: Error) => void } | null = - null; - private currentBlock: { lines: string[] } | null = null; - - // Pane output buffers (keyed by pane ID like "%1") - private paneOutputs = new Map(); - - // Window name to pane ID mapping - private windowToPaneId = new Map(); - - // Message output (from display-message) - private lastMessage = ''; - - /** - * Connect to tmux in control mode with retry support. - * Creates a control session if needed, then attaches with -C flag. - * Uses a connection lock to prevent race conditions from parallel calls. - */ - async connect(): Promise { - // Already connected and process is running - if (this.connected && this.proc && this.proc.exitCode === null) return; - - // If another connection attempt is in progress, wait for it - if (this.connectPromise) { - await this.connectPromise; - // After waiting, check if we're now connected - if (this.connected && this.proc && this.proc.exitCode === null) return; - } - - // Start a new connection attempt with retry logic - this.connectPromise = this.connectWithRetry(); - - try { - await this.connectPromise; - } finally { - this.connectPromise = null; - } - } - - /** - * Internal connection logic with retry support. - */ - private async connectWithRetry(): Promise { - let lastError: Error | null = null; - for (let attempt = 0; attempt < 2; attempt++) { - try { - await this.connectOnce(); - return; - } catch (err) { - lastError = err instanceof Error ? err : new Error(String(err)); - if (attempt < 1) { - await sleep(500); // Wait before retry - } - } - } - throw lastError ?? new Error('Failed to connect to tmux after retries'); - } - - /** - * Single connection attempt to tmux control mode. - */ - private async connectOnce(): Promise { - // Reset state - this.connected = false; - this.proc = null; - this.rl = null; - - // Kill any existing control session to start fresh - try { - execSync(`tmux kill-session -t ${CONTROL_SESSION} 2>/dev/null`); - } catch { - // Ignore - session may not exist - } - - // Create the control session with explicit CWD to avoid stale directory issues - // When CASCADE chdirs to temp directories that are later deleted, the tmux - // session would inherit an invalid CWD causing "getcwd: cannot access parent directories" errors - try { - execSync(`tmux new-session -d -s ${CONTROL_SESSION} -x 200 -y 50 -c /tmp`); - } catch (err) { - throw new Error(`Failed to create control session: ${err}`); - } - - // Attach in control mode with explicit CWD - this.proc = spawn('tmux', ['-C', 'attach-session', '-t', CONTROL_SESSION], { - stdio: ['pipe', 'pipe', 'pipe'], - cwd: '/tmp', - }); - - if (!this.proc.stdout) { - throw new Error('Failed to get stdout from tmux process'); - } - this.rl = readline.createInterface({ input: this.proc.stdout }); - - // Set up line parsing - this.rl.on('line', (line) => this.parseLine(line)); - - this.proc.on('close', () => { - this.connected = false; - this.proc = null; - this.rl = null; - }); - - this.proc.on('error', (err) => { - console.error('Tmux control mode error:', err); - this.connected = false; - }); - - // Wait for initial connection - await sleep(300); - - // Verify the process is still running and stdin is available - if (!this.proc || this.proc.exitCode !== null || !this.proc.stdin) { - this.connected = false; - throw new Error( - 'Failed to connect to tmux: process exited immediately. Check if tmux is installed and working.', - ); - } - - this.connected = true; - } - - private static readonly IGNORED_NOTIFICATION_PREFIXES = [ - '%session', - '%window', - '%pane', - '%client', - '%exit', - '%unlinked', - '%subscription', - '%layout', - '%paste', - ]; - - private isIgnoredNotification(line: string): boolean { - return TmuxControlClient.IGNORED_NOTIFICATION_PREFIXES.some((prefix) => - line.startsWith(prefix), - ); - } - - private handleOutputLine(line: string): void { - const match = line.match(/^%output (%\d+) (.*)$/); - if (!match) return; - - const [, paneId, escaped] = match; - const unescaped = unescapeOutput(escaped); - - if (!this.paneOutputs.has(paneId)) { - this.paneOutputs.set(paneId, []); - } - - const buffer = this.paneOutputs.get(paneId); - if (buffer) { - buffer.push(unescaped); - const totalSize = buffer.reduce((sum, s) => sum + s.length, 0); - if (totalSize > MAX_OUTPUT_BUFFER) { - buffer.shift(); - } - } - } - - /** - * Parse a line from tmux control mode protocol - */ - private parseLine(line: string): void { - if (line.startsWith('%begin ')) { - this.currentBlock = { lines: [] }; - } else if (line.startsWith('%end ') || line.startsWith('%error ')) { - if (this.currentBlock && this.pendingCommand) { - this.pendingCommand.resolve(this.currentBlock.lines.join('\n')); - this.pendingCommand = null; - } - this.currentBlock = null; - } else if (line.startsWith('%output ')) { - this.handleOutputLine(line); - } else if (line.startsWith('%message ')) { - this.lastMessage = line.slice(9); - } else if (this.isIgnoredNotification(line)) { - return; - } else if (this.currentBlock) { - this.currentBlock.lines.push(line); - } - } - - /** - * Send a command to tmux and wait for response. - * Commands are queued and executed sequentially to handle parallel gadget calls. - */ - async sendCommand(cmd: string): Promise { - // Reconnect if needed - if (!this.connected || !this.proc || this.proc.exitCode !== null) { - this.connected = false; - this.proc = null; - this.windowToPaneId.clear(); - this.paneOutputs.clear(); - await this.connect(); - } - - // Queue the command and process - return new Promise((resolve, reject) => { - this.commandQueue.push({ cmd, resolve, reject }); - this.processQueue(); - }); - } - - /** - * Process queued commands sequentially. - * Only one command can be processed at a time due to tmux control mode protocol. - */ - private processQueue(): void { - if (this.processingQueue || this.commandQueue.length === 0) return; - this.processingQueue = true; - - const processNext = () => { - if (this.commandQueue.length === 0) { - this.processingQueue = false; - return; - } - - const item = this.commandQueue.shift(); - if (!item) return; - const { cmd, resolve, reject } = item; - this.executeCommandInternal(cmd) - .then(resolve) - .catch(reject) - .finally(() => { - // Process next command after a small delay to let tmux settle - setTimeout(processNext, 10); - }); - }; - - processNext(); - } - - /** - * Internal command execution - actually sends to tmux and waits for response - */ - private executeCommandInternal(cmd: string): Promise { - return new Promise((resolve, reject) => { - if (this.pendingCommand) { - // This shouldn't happen with queue, but just in case - reject(new Error('Already have a pending command')); - return; - } - - if (!this.proc?.stdin) { - reject( - new Error( - 'Not connected to tmux - process stdin unavailable. Check if tmux is installed and working.', - ), - ); - return; - } - - this.lastMessage = ''; - - // Set up timeout - MUST be cleared when command completes - const timeoutId = setTimeout(() => { - if (this.pendingCommand) { - this.pendingCommand = null; - reject(new Error('Command timed out')); - } - }, 30000); - - // Wrap resolve/reject to clear timeout - this.pendingCommand = { - resolve: (result: string) => { - clearTimeout(timeoutId); - resolve(result); - }, - reject: (err: Error) => { - clearTimeout(timeoutId); - reject(err); - }, - }; - - this.proc.stdin.write(`${cmd}\n`); - }); - } - - /** - * Get the last message from display-message - */ - getLastMessage(): string { - return this.lastMessage; - } - - /** - * Create a new window with a command. - * Wraps command in a shell that emits an exit marker for reliable exit detection. - */ - async createWindow(windowName: string, command: string, cwd?: string): Promise { - // Base64 encode the entire command to pass through tmux safely - // Use single quotes for bash -c and eval to properly handle quotes in decoded command - const base64 = Buffer.from(command).toString('base64'); - const shellCmd = `bash -c 'eval "$(echo ${base64} | base64 -d)"; echo "${EXIT_MARKER_PREFIX}$?${EXIT_MARKER_SUFFIX}"'`; - - // Resolve relative cwd against process.cwd() (repo root), since control session uses /tmp - const effectiveCwd = resolveWorkingDirectory(cwd); - const cwdArg = `-c "${effectiveCwd.replace(/"/g, '\\"')}" `; - const result = await this.sendCommand( - `new-window -t ${CONTROL_SESSION} -n ${windowName} ${cwdArg}-PF "#{pane_id}" ${shellCmd}`, - ); - - const paneId = result.trim(); - if (paneId.startsWith('%')) { - this.windowToPaneId.set(windowName, paneId); - this.paneOutputs.set(paneId, []); - - // Set remain-on-exit for this pane so output can be captured after command exits - await this.sendCommand(`set-option -p -t ${paneId} remain-on-exit on`); - } - - return paneId; - } - - /** - * Check if command has exited by looking for exit marker in output. - * This is the most reliable method as it doesn't depend on tmux's pane_dead. - */ - checkExitMarker(windowName: string): { exited: boolean; exitCode: number } { - const paneId = this.windowToPaneId.get(windowName); - if (!paneId) return { exited: false, exitCode: 0 }; - - const buffer = this.paneOutputs.get(paneId); - if (!buffer) return { exited: false, exitCode: 0 }; - - // Search for exit marker in output - const output = buffer.join(''); - const markerMatch = output.match( - new RegExp(`${EXIT_MARKER_PREFIX}(\\d+)${EXIT_MARKER_SUFFIX}`), - ); - if (markerMatch) { - return { exited: true, exitCode: Number.parseInt(markerMatch[1], 10) }; - } - - return { exited: false, exitCode: 0 }; - } - - /** - * Check if a pane is dead (command exited) - * First checks exit marker (most reliable), then capture-pane, then pane_dead variable. - */ - async isPaneDead(paneId: string): Promise<{ dead: boolean; exitCode: number }> { - // Method 1: Check for "Pane is dead" text in capture-pane - // This appears when remain-on-exit is on - const captured = await this.sendCommand(`capture-pane -t ${paneId} -p -S -10`); - const deadMatch = captured.match(/Pane is dead \(status (\d+)\)/); - if (deadMatch) { - return { dead: true, exitCode: Number.parseInt(deadMatch[1], 10) }; - } - - // Method 2: Check pane_dead variable - const result = await this.sendCommand( - `list-panes -t ${paneId} -F "#{pane_dead}\t#{pane_dead_status}"`, - ); - const [dead, status] = result.trim().split('\t'); - - return { - dead: dead === '1', - exitCode: status ? Number.parseInt(status, 10) : 0, - }; - } - - /** - * Get session status - checks if command has exited and returns exit code. - * Combines checkExitMarker and isPaneDead for comprehensive detection. - */ - async getSessionStatus( - windowName: string, - ): Promise<{ status: 'running' | 'exited' | 'not_found'; exitCode?: number }> { - // Check if window exists first - if (!(await this.windowExists(windowName))) { - return { status: 'not_found' }; - } - - // Method 1: Check for exit marker in streamed output (most reliable) - const markerResult = this.checkExitMarker(windowName); - if (markerResult.exited) { - return { status: 'exited', exitCode: markerResult.exitCode }; - } - - // Method 2: Check if pane is dead via tmux - const paneId = this.windowToPaneId.get(windowName); - if (paneId) { - const { dead, exitCode } = await this.isPaneDead(paneId); - if (dead) { - return { status: 'exited', exitCode }; - } - } - - return { status: 'running' }; - } - - /** - * Get buffered output for a window - */ - getOutput(windowName: string): string { - const paneId = this.windowToPaneId.get(windowName); - if (!paneId) return ''; - - const buffer = this.paneOutputs.get(paneId); - if (!buffer) return ''; - - // Join and strip ANSI escape codes - return stripAnsi(buffer.join('')); - } - - /** - * Capture pane output via tmux command (backup method) - */ - async capturePaneOutput(windowName: string, lines = 200): Promise { - const paneId = this.windowToPaneId.get(windowName); - const target = paneId || `${CONTROL_SESSION}:${windowName}`; - - const result = await this.sendCommand(`capture-pane -t ${target} -p -S -${lines}`); - return result; - } - - /** - * Kill a window - */ - async killWindow(windowName: string): Promise { - const paneId = this.windowToPaneId.get(windowName); - if (paneId) { - this.paneOutputs.delete(paneId); - this.windowToPaneId.delete(windowName); - } - await this.sendCommand(`kill-window -t ${CONTROL_SESSION}:${windowName}`); - } - - /** - * Check if a window exists - */ - async windowExists(windowName: string): Promise { - // Check in-memory map first - if (this.windowToPaneId.has(windowName)) { - return true; - } - // Query tmux for window list - try { - const result = await this.sendCommand( - `list-windows -t ${CONTROL_SESSION} -F "#{window_name}"`, - ); - const windows = result.split('\n').map((w) => w.trim()); - return windows.includes(windowName); - } catch { - return false; - } - } - - /** - * Send keys to a window - */ - async sendKeys(windowName: string, keys: string, enter: boolean): Promise { - const enterArg = enter ? ' Enter' : ''; - await this.sendCommand( - `send-keys -t ${CONTROL_SESSION}:${windowName} "${keys.replace(/"/g, '\\"')}"${enterArg}`, - ); - } - - /** - * List all windows - */ - async listWindows(): Promise { - const result = await this.sendCommand( - `list-windows -t ${CONTROL_SESSION} -F "#{window_name}: #{pane_current_command} (#{?pane_dead,exited,running})"`, - ); - return result.split('\n').filter((line) => line.trim() && !line.includes('(exited)')); - } - - /** - * Check if connected - */ - isConnected(): boolean { - return this.connected; - } - - /** - * Disconnect from control mode - */ - disconnect(): void { - if (this.proc) { - this.proc.stdin?.write('\n'); // Empty line to detach - this.proc.kill(); - this.proc = null; - } - this.connected = false; - } -} - -// Singleton control client -let controlClient: TmuxControlClient | null = null; - -async function getControlClient(): Promise { - if (!controlClient) { - controlClient = new TmuxControlClient(); - } - if (!controlClient.isConnected()) { - await controlClient.connect(); - } - return controlClient; -} - -class TmuxGadget extends Gadget({ - name: 'Tmux', - description: `Execute commands in tmux sessions. - -**Use this for ALL commands** (npm, tests, builds, git, gh, etc.) - -**COMMAND FORMAT:** Pass command as a shell string. -- command="npm test" -- command="npm run build && npm test" -- command="rg 'pattern' src/ | head -20" - -Commands are interpreted by bash, so pipes, &&, ||, redirects, and globs all work. - -**QUOTING:** When using gh, git, or commands with special characters: -- Use single quotes around values with parentheses: --title 'feat(scope): message' -- Example: command="gh pr create --title 'feat(auth): add login' --body 'Description'" - -**WORKING DIRECTORY:** Set \`cwd\` parameter to run commands in subdirectories. -- Tmux(action="start", session="test", command="npm test", cwd="packages/core") - -**ACTIONS:** -- \`start\`: Run a command in a new session. Waits up to 120s for initial output. - - If command exits with code 0: returns full output - - **If command exits with non-zero code: THROWS AN ERROR** with output - - If still running after 120s: returns partial output + "still running" message - - **Process keeps running in background** - use capture to check progress -- \`capture\`: Get recent output from a running session -- \`send\`: Send keys/commands to a session (use "C-c" for Ctrl+C) -- \`list\`: List all active sessions -- \`kill\`: Terminate a session -- \`exists\`: Check if a session is running - -**TYPICAL WORKFLOW:** -1. Start: Tmux(action="start", session="test-run", command="npm test") -2. If still running, check progress: Tmux(action="capture", session="test-run") -3. When done: Tmux(action="kill", session="test-run") - -**SESSION NAMING:** Use descriptive names like "npm-install", "test-run", "lint-check", "build"`, - timeoutMs: DEFAULT_TIMEOUT_MS, - maxConcurrent: 1, // Sequential execution to prevent race conditions - schema: z.discriminatedUnion('action', [ - z.object({ - action: z.literal('start'), - comment: z.string().min(1).describe('Brief rationale for this gadget call'), - session: sessionNameSchema.describe("Unique session name (e.g., 'test-run', 'npm-install')"), - command: z - .string() - .min(1) - .describe("Shell command to execute (e.g., 'npm test', 'npm run build && npm test')"), - cwd: z.string().optional().describe('Working directory for the command (default: repo root)'), - wait: z.coerce - .number() - .default(DEFAULT_WAIT_MS) - .describe('Max time to wait for initial output in ms (default: 120000, max: 120000)'), - }), - z.object({ - action: z.literal('send'), - comment: z.string().min(1).describe('Brief rationale for this gadget call'), - session: sessionNameSchema.describe('Target session name'), - keys: z.string().describe("Keys or command to send (e.g., 'npm test' or 'C-c' for Ctrl+C)"), - enter: z.coerce - .boolean() - .default(true) - .describe('Press Enter after sending keys (default: true)'), - }), - z.object({ - action: z.literal('capture'), - comment: z.string().min(1).describe('Brief rationale for this gadget call'), - session: sessionNameSchema.describe('Session to capture output from'), - lines: z.coerce - .number() - .int() - .min(1) - .max(1000) - .default(25) - .describe('Number of lines to capture (default: 25, max: 1000)'), - }), - z.object({ - action: z.literal('list'), - comment: z.string().min(1).describe('Brief rationale for this gadget call'), - }), - z.object({ - action: z.literal('kill'), - comment: z.string().min(1).describe('Brief rationale for this gadget call'), - session: sessionNameSchema.describe('Session to terminate'), - }), - z.object({ - action: z.literal('exists'), - comment: z.string().min(1).describe('Brief rationale for this gadget call'), - session: sessionNameSchema.describe('Session name to check'), - }), - ]), - examples: [ - { - params: { - action: 'start', - comment: 'Running unit tests to verify changes', - session: 'test-run', - command: 'npm test', - wait: 120000, - }, - output: - 'session=test-run status=exited exit_code=0\n\n> project@1.0.0 test\n> vitest run\n\n✓ 15 tests passed', - comment: 'Run tests - command completed within 120s wait period', - }, - { - params: { - action: 'start', - comment: 'Exploring vehicle service module dependencies', - session: 'squint-modules', - command: 'squint modules show backend.services.vehicles --json', - wait: 15000, - }, - output: - 'session=squint-modules status=exited exit_code=0\n\n{"path":"backend.services.vehicles","description":"Vehicle business logic","files":["src/services/vehicles.service.ts"],"dependencies":["backend.data.models","shared-types.entities.vehicles"],"dependents":["backend.api.vehicles"]}', - comment: - "Use squint to see a module's files, dependencies, and dependents before reading code", - }, - { - params: { - action: 'start', - comment: 'Tracing vehicle search data flow', - session: 'squint-features', - command: 'squint features show vehicle-inventory --json', - wait: 15000, - }, - output: - 'session=squint-features status=exited exit_code=0\n\n{"slug":"vehicle-inventory","description":"Vehicle CRUD and search","flows":["VehicleController.getAll -> VehicleService.getAll -> VehicleModel.findAll"],"modules":["backend.api.vehicles","backend.services.vehicles","backend.data.models"]}', - comment: 'Use squint to trace how data flows through the system before exploring code', - }, - { - params: { - action: 'start', - comment: 'Running lint and tests in sequence', - session: 'pipeline', - command: 'npm run lint && npm test', - wait: 120000, - }, - output: 'session=pipeline status=exited exit_code=0\n\n✓ Lint passed\n✓ 15 tests passed', - comment: 'Chain commands with &&', - }, - { - params: { - action: 'start', - comment: 'Testing frontend package separately', - session: 'frontend-test', - command: 'pnpm test', - cwd: 'packages/frontend', - wait: 120000, - }, - output: 'session=frontend-test status=exited exit_code=0\n\n✓ 42 tests passed', - comment: 'Run in subdirectory using cwd instead of cd prefix', - }, - { - params: { - action: 'start', - comment: 'Starting E2E test suite', - session: 'e2e-tests', - command: 'npm run test:e2e', - wait: 120000, - }, - output: - "session=e2e-tests status=running\n\nStarting backend...\n✓ Backend healthy\n\n(Process still running in session 'e2e-tests'. Use capture to check progress, kill when done.)", - comment: 'Long-running E2E tests still running after 120s - use capture to monitor', - }, - { - params: { - action: 'capture', - comment: 'Checking install progress', - session: 'npm-install', - lines: 25, - }, - output: 'session=npm-install status=running lines=25\n\nadded 874 packages in 45s', - comment: 'Check output from running session - status=running means command still executing', - }, - { - params: { - action: 'capture', - comment: 'Checking if tests completed', - session: 'test-run', - lines: 50, - }, - output: - 'session=test-run status=exited exit_code=0 lines=50\n\n✓ 15 tests passed\n✓ All tests completed', - comment: 'Capture shows command finished - status=exited with exit code', - }, - { - params: { - action: 'send', - comment: 'Stopping dev server', - session: 'dev-server', - keys: 'C-c', - enter: false, - }, - output: "session=dev-server status=sent\n\nSent keys to session 'dev-server': C-c", - comment: 'Send Ctrl+C to stop a process', - }, - { - params: { action: 'kill', comment: 'Cleaning up completed session', session: 'npm-install' }, - output: "session=npm-install status=killed\n\nSession 'npm-install' terminated", - comment: 'Terminate a session', - }, - { - params: { action: 'list', comment: 'Checking for running sessions' }, - output: 'sessions=2\n\ntest-run: npm (running)\nnpm-install: npm (running)', - comment: 'List all active tmux sessions', - }, - { - params: { - action: 'start', - comment: 'Creating PR for OAuth feature', - session: 'create-pr', - command: - "gh pr create --title 'feat(auth): add OAuth login' --body 'Implements OAuth flow'", - wait: 30000, - }, - output: - 'session=create-pr status=exited exit_code=0\n\nCreating pull request for feature/oauth...\nhttps://github.com/org/repo/pull/123', - comment: - 'Create PR - note single quotes around title with parentheses to prevent shell errors', - }, - ], -}) { - override async execute(params: this['params']): Promise { - // Sanitize session name if present (replaces / and other invalid chars with -) - if ('session' in params && typeof params.session === 'string') { - (params as { session: string }).session = sanitizeSessionName(params.session); - } - - switch (params.action) { - case 'start': - return this.handleStart(params); - case 'send': - return this.handleSend(params); - case 'capture': - return this.handleCapture(params); - case 'list': - return this.handleList(); - case 'kill': - return this.handleKill(params); - case 'exists': - return this.handleExists(params); - default: - return 'status=error\n\nUnknown action'; - } - } - - private async handleStart(params: { - session: string; - command: string; - cwd?: string; - wait?: number; - }): Promise { - // Validate git commands don't bypass hooks - validateGitCommand(params.command); - - const client = await getControlClient(); - - // Check if window already exists - if (await client.windowExists(params.session)) { - return `session=${params.session} status=error\n\nSession '${params.session}' already exists. Use action="kill" first or choose a different name.`; - } - - // Create window with command - const paneId = await client.createWindow(params.session, params.command, params.cwd); - if (!paneId.startsWith('%')) { - return `session=${params.session} status=error\n\nFailed to create session: ${paneId}`; - } - - // Wait for output/exit - return this.waitForOutput(client, params.session, paneId, params.wait); - } - - private cleanupOutput(output: string, removeDeadPane = false): string { - let cleaned = output - .replace(new RegExp(`${EXIT_MARKER_PREFIX}\\d+${EXIT_MARKER_SUFFIX}\\s*`), '') - .replace(/\n{3,}/g, '\n\n') - .trim(); - - if (removeDeadPane) { - cleaned = cleaned.replace(/\nPane is dead \([^)]+\)\s*$/, ''); - } - - return cleaned; - } - - private async getSessionOutput(client: TmuxControlClient, session: string): Promise { - let output = client.getOutput(session); - if (!output.trim()) { - output = await client.capturePaneOutput(session); - } - return output; - } - - private async waitForOutput( - client: TmuxControlClient, - session: string, - paneId: string, - requestedWait?: number, - ): Promise { - const waitMs = Math.min(requestedWait ?? DEFAULT_WAIT_MS, MAX_WAIT_MS); - let elapsed = 0; - - while (elapsed < waitMs) { - await sleep(POLL_INTERVAL_MS); - elapsed += POLL_INTERVAL_MS; - - // Primary method: Check for exit marker in streamed output - const markerResult = client.checkExitMarker(session); - if (markerResult.exited) { - const output = this.cleanupOutput(await this.getSessionOutput(client, session), true); - - // Store notice for injection into agent conversation - const lines = output.split('\n'); - const tailOutput = lines.slice(-100).join('\n'); - pendingNotices.set(session, { - exitCode: markerResult.exitCode, - tailOutput, - }); - - await client.killWindow(session); - - if (markerResult.exitCode !== 0) { - throw new CommandFailedError(session, markerResult.exitCode, output); - } - - return `session=${session} status=exited exit_code=${markerResult.exitCode}\n\n${output || '(no output)'}`; - } - - // Fallback: Check if pane is dead via tmux - const { dead, exitCode } = await client.isPaneDead(paneId); - if (dead) { - const output = this.cleanupOutput(await this.getSessionOutput(client, session), true); - - // Store notice for injection into agent conversation - const lines = output.split('\n'); - const tailOutput = lines.slice(-100).join('\n'); - pendingNotices.set(session, { - exitCode, - tailOutput, - }); - - await client.killWindow(session); - - if (exitCode !== 0) { - throw new CommandFailedError(session, exitCode, output); - } - - return `session=${session} status=exited exit_code=${exitCode}\n\n${output || '(no output)'}`; - } - } - - // Still running - get partial output - const output = this.cleanupOutput(await this.getSessionOutput(client, session)); - return `session=${session} status=running\n\n${output || '(no output yet)'}\n\n(Process still running in session '${session}'. Use capture to check progress, kill when done.)`; - } - - private async handleSend(params: { - session: string; - keys: string; - enter?: boolean; - }): Promise { - // Validate git commands don't bypass hooks (only when sending a command with Enter) - if (params.enter !== false) { - validateGitCommand(params.keys); - } - - const client = await getControlClient(); - - if (!(await client.windowExists(params.session))) { - return `session=${params.session} status=error\n\nSession '${params.session}' does not exist`; - } - - await client.sendKeys(params.session, params.keys, params.enter ?? true); - - const enterNote = params.enter ? ' [Enter]' : ''; - return `session=${params.session} status=sent\n\nSent keys to session '${params.session}': ${params.keys}${enterNote}`; - } - - private async handleCapture(params: { session: string; lines?: number }): Promise { - const client = await getControlClient(); - const lines = params.lines ?? 25; - - // Check session status (existence + exit detection) - const sessionStatus = await client.getSessionStatus(params.session); - - if (sessionStatus.status === 'not_found') { - return `session=${params.session} status=error\n\nSession '${params.session}' does not exist`; - } - - // Get output (try streamed output first, then capture-pane) - let output = client.getOutput(params.session); - if (!output.trim()) { - output = await client.capturePaneOutput(params.session, lines); - } - - // Take last N lines and clean up - const outputLines = output.split('\n'); - let captured = outputLines.slice(-lines).join('\n').trim(); - - // Clean exit marker from output if present - captured = captured - .replace(new RegExp(`${EXIT_MARKER_PREFIX}\\d+${EXIT_MARKER_SUFFIX}\\s*`), '') - .replace(/\nPane is dead \([^)]+\)\s*$/, '') - .trim(); - - // Report status with exit code if exited - if (sessionStatus.status === 'exited') { - return `session=${params.session} status=exited exit_code=${sessionStatus.exitCode} lines=${lines}\n\n${captured || '(no output)'}`; - } - - return `session=${params.session} status=running lines=${lines}\n\n${captured || '(no output yet)'}`; - } - - private async handleList(): Promise { - const client = await getControlClient(); - - try { - const windows = await client.listWindows(); - // Filter out the initial shell window - const filtered = windows.filter((w) => !w.startsWith('0:')); - return `sessions=${filtered.length}\n\n${filtered.join('\n') || 'No active sessions'}`; - } catch { - return 'sessions=0\n\nNo active tmux sessions'; - } - } - - private async handleKill(params: { session: string }): Promise { - const client = await getControlClient(); - - if (!(await client.windowExists(params.session))) { - return `session=${params.session} status=error\n\nSession '${params.session}' does not exist`; - } - - await client.killWindow(params.session); - return `session=${params.session} status=killed\n\nSession '${params.session}' terminated`; - } - - private async handleExists(params: { session: string }): Promise { - const client = await getControlClient(); - const exists = await client.windowExists(params.session); - return `session=${params.session} exists=${exists}\n\nSession '${params.session}' ${exists ? 'exists and is running' : 'does not exist'}`; - } -} - -/** - * Resolve working directory for tmux commands. - * Relative paths are resolved against process.cwd() (the repo root), - * since the tmux control session runs in /tmp. - */ -function resolveWorkingDirectory(cwd?: string): string { - return cwd ? resolve(cwd) : process.cwd(); -} - -export { TmuxGadget as Tmux, resolveWorkingDirectory, validateGitCommand }; +export { + Tmux, + consumePendingSessionNotices, + validateGitCommand, + resolveWorkingDirectory, +} from './tmux/index.js'; +export type { CompletedSessionNotice } from './tmux/index.js'; diff --git a/src/gadgets/tmux/TmuxControlClient.ts b/src/gadgets/tmux/TmuxControlClient.ts new file mode 100644 index 00000000..002d56ba --- /dev/null +++ b/src/gadgets/tmux/TmuxControlClient.ts @@ -0,0 +1,534 @@ +import { type ChildProcess, execSync, spawn } from 'node:child_process'; +import * as readline from 'node:readline'; +import { + CONTROL_SESSION, + EXIT_MARKER_PREFIX, + EXIT_MARKER_SUFFIX, + MAX_OUTPUT_BUFFER, +} from './constants.js'; +import { resolveWorkingDirectory, sleep, stripAnsi, unescapeOutput } from './utils.js'; + +/** + * TmuxControlClient - manages a persistent control mode connection to tmux. + * Uses event-driven protocol for real-time output and reliable exit detection. + */ +export class TmuxControlClient { + private proc: ChildProcess | null = null; + private rl: readline.Interface | null = null; + private connected = false; + + // Connection lock - prevents race conditions when multiple parallel calls try to connect + private connectPromise: Promise | null = null; + + // Command queue - allows parallel gadget calls to queue and wait + private commandQueue: Array<{ + cmd: string; + resolve: (v: string) => void; + reject: (e: Error) => void; + }> = []; + private processingQueue = false; + + // Current command being processed + private pendingCommand: { resolve: (v: string) => void; reject: (e: Error) => void } | null = + null; + private currentBlock: { lines: string[] } | null = null; + + // Pane output buffers (keyed by pane ID like "%1") + private paneOutputs = new Map(); + + // Window name to pane ID mapping + private windowToPaneId = new Map(); + + // Message output (from display-message) + private lastMessage = ''; + + /** + * Connect to tmux in control mode with retry support. + * Creates a control session if needed, then attaches with -C flag. + * Uses a connection lock to prevent race conditions from parallel calls. + */ + async connect(): Promise { + // Already connected and process is running + if (this.connected && this.proc && this.proc.exitCode === null) return; + + // If another connection attempt is in progress, wait for it + if (this.connectPromise) { + await this.connectPromise; + // After waiting, check if we're now connected + if (this.connected && this.proc && this.proc.exitCode === null) return; + } + + // Start a new connection attempt with retry logic + this.connectPromise = this.connectWithRetry(); + + try { + await this.connectPromise; + } finally { + this.connectPromise = null; + } + } + + /** + * Internal connection logic with retry support. + */ + private async connectWithRetry(): Promise { + let lastError: Error | null = null; + for (let attempt = 0; attempt < 2; attempt++) { + try { + await this.connectOnce(); + return; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + if (attempt < 1) { + await sleep(500); // Wait before retry + } + } + } + throw lastError ?? new Error('Failed to connect to tmux after retries'); + } + + /** + * Single connection attempt to tmux control mode. + */ + private async connectOnce(): Promise { + // Reset state + this.connected = false; + this.proc = null; + this.rl = null; + + // Kill any existing control session to start fresh + try { + execSync(`tmux kill-session -t ${CONTROL_SESSION} 2>/dev/null`); + } catch { + // Ignore - session may not exist + } + + // Create the control session with explicit CWD to avoid stale directory issues + // When CASCADE chdirs to temp directories that are later deleted, the tmux + // session would inherit an invalid CWD causing "getcwd: cannot access parent directories" errors + try { + execSync(`tmux new-session -d -s ${CONTROL_SESSION} -x 200 -y 50 -c /tmp`); + } catch (err) { + throw new Error(`Failed to create control session: ${err}`); + } + + // Attach in control mode with explicit CWD + this.proc = spawn('tmux', ['-C', 'attach-session', '-t', CONTROL_SESSION], { + stdio: ['pipe', 'pipe', 'pipe'], + cwd: '/tmp', + }); + + if (!this.proc.stdout) { + throw new Error('Failed to get stdout from tmux process'); + } + this.rl = readline.createInterface({ input: this.proc.stdout }); + + // Set up line parsing + this.rl.on('line', (line) => this.parseLine(line)); + + this.proc.on('close', () => { + this.connected = false; + this.proc = null; + this.rl = null; + }); + + this.proc.on('error', (err) => { + console.error('Tmux control mode error:', err); + this.connected = false; + }); + + // Wait for initial connection + await sleep(300); + + // Verify the process is still running and stdin is available + if (!this.proc || this.proc.exitCode !== null || !this.proc.stdin) { + this.connected = false; + throw new Error( + 'Failed to connect to tmux: process exited immediately. Check if tmux is installed and working.', + ); + } + + this.connected = true; + } + + private static readonly IGNORED_NOTIFICATION_PREFIXES = [ + '%session', + '%window', + '%pane', + '%client', + '%exit', + '%unlinked', + '%subscription', + '%layout', + '%paste', + ]; + + private isIgnoredNotification(line: string): boolean { + return TmuxControlClient.IGNORED_NOTIFICATION_PREFIXES.some((prefix) => + line.startsWith(prefix), + ); + } + + private handleOutputLine(line: string): void { + const match = line.match(/^%output (%\d+) (.*)$/); + if (!match) return; + + const [, paneId, escaped] = match; + const unescaped = unescapeOutput(escaped); + + if (!this.paneOutputs.has(paneId)) { + this.paneOutputs.set(paneId, []); + } + + const buffer = this.paneOutputs.get(paneId); + if (buffer) { + buffer.push(unescaped); + const totalSize = buffer.reduce((sum, s) => sum + s.length, 0); + if (totalSize > MAX_OUTPUT_BUFFER) { + buffer.shift(); + } + } + } + + /** + * Parse a line from tmux control mode protocol + */ + private parseLine(line: string): void { + if (line.startsWith('%begin ')) { + this.currentBlock = { lines: [] }; + } else if (line.startsWith('%end ') || line.startsWith('%error ')) { + if (this.currentBlock && this.pendingCommand) { + this.pendingCommand.resolve(this.currentBlock.lines.join('\n')); + this.pendingCommand = null; + } + this.currentBlock = null; + } else if (line.startsWith('%output ')) { + this.handleOutputLine(line); + } else if (line.startsWith('%message ')) { + this.lastMessage = line.slice(9); + } else if (this.isIgnoredNotification(line)) { + return; + } else if (this.currentBlock) { + this.currentBlock.lines.push(line); + } + } + + /** + * Send a command to tmux and wait for response. + * Commands are queued and executed sequentially to handle parallel gadget calls. + */ + async sendCommand(cmd: string): Promise { + // Reconnect if needed + if (!this.connected || !this.proc || this.proc.exitCode !== null) { + this.connected = false; + this.proc = null; + this.windowToPaneId.clear(); + this.paneOutputs.clear(); + await this.connect(); + } + + // Queue the command and process + return new Promise((resolve, reject) => { + this.commandQueue.push({ cmd, resolve, reject }); + this.processQueue(); + }); + } + + /** + * Process queued commands sequentially. + * Only one command can be processed at a time due to tmux control mode protocol. + */ + private processQueue(): void { + if (this.processingQueue || this.commandQueue.length === 0) return; + this.processingQueue = true; + + const processNext = () => { + if (this.commandQueue.length === 0) { + this.processingQueue = false; + return; + } + + const item = this.commandQueue.shift(); + if (!item) return; + const { cmd, resolve, reject } = item; + this.executeCommandInternal(cmd) + .then(resolve) + .catch(reject) + .finally(() => { + // Process next command after a small delay to let tmux settle + setTimeout(processNext, 10); + }); + }; + + processNext(); + } + + /** + * Internal command execution - actually sends to tmux and waits for response + */ + private executeCommandInternal(cmd: string): Promise { + return new Promise((resolve, reject) => { + if (this.pendingCommand) { + // This shouldn't happen with queue, but just in case + reject(new Error('Already have a pending command')); + return; + } + + if (!this.proc?.stdin) { + reject( + new Error( + 'Not connected to tmux - process stdin unavailable. Check if tmux is installed and working.', + ), + ); + return; + } + + this.lastMessage = ''; + + // Set up timeout - MUST be cleared when command completes + const timeoutId = setTimeout(() => { + if (this.pendingCommand) { + this.pendingCommand = null; + reject(new Error('Command timed out')); + } + }, 30000); + + // Wrap resolve/reject to clear timeout + this.pendingCommand = { + resolve: (result: string) => { + clearTimeout(timeoutId); + resolve(result); + }, + reject: (err: Error) => { + clearTimeout(timeoutId); + reject(err); + }, + }; + + this.proc.stdin.write(`${cmd}\n`); + }); + } + + /** + * Get the last message from display-message + */ + getLastMessage(): string { + return this.lastMessage; + } + + /** + * Create a new window with a command. + * Wraps command in a shell that emits an exit marker for reliable exit detection. + */ + async createWindow(windowName: string, command: string, cwd?: string): Promise { + // Base64 encode the entire command to pass through tmux safely + // Use single quotes for bash -c and eval to properly handle quotes in decoded command + const base64 = Buffer.from(command).toString('base64'); + const shellCmd = `bash -c 'eval "$(echo ${base64} | base64 -d)"; echo "${EXIT_MARKER_PREFIX}$?${EXIT_MARKER_SUFFIX}"'`; + + // Resolve relative cwd against process.cwd() (repo root), since control session uses /tmp + const effectiveCwd = resolveWorkingDirectory(cwd); + const cwdArg = `-c "${effectiveCwd.replace(/"/g, '\\"')}" `; + const result = await this.sendCommand( + `new-window -t ${CONTROL_SESSION} -n ${windowName} ${cwdArg}-PF "#{pane_id}" ${shellCmd}`, + ); + + const paneId = result.trim(); + if (paneId.startsWith('%')) { + this.windowToPaneId.set(windowName, paneId); + this.paneOutputs.set(paneId, []); + + // Set remain-on-exit for this pane so output can be captured after command exits + await this.sendCommand(`set-option -p -t ${paneId} remain-on-exit on`); + } + + return paneId; + } + + /** + * Check if command has exited by looking for exit marker in output. + * This is the most reliable method as it doesn't depend on tmux's pane_dead. + */ + checkExitMarker(windowName: string): { exited: boolean; exitCode: number } { + const paneId = this.windowToPaneId.get(windowName); + if (!paneId) return { exited: false, exitCode: 0 }; + + const buffer = this.paneOutputs.get(paneId); + if (!buffer) return { exited: false, exitCode: 0 }; + + // Search for exit marker in output + const output = buffer.join(''); + const markerMatch = output.match( + new RegExp(`${EXIT_MARKER_PREFIX}(\\d+)${EXIT_MARKER_SUFFIX}`), + ); + if (markerMatch) { + return { exited: true, exitCode: Number.parseInt(markerMatch[1], 10) }; + } + + return { exited: false, exitCode: 0 }; + } + + /** + * Check if a pane is dead (command exited) + * First checks exit marker (most reliable), then capture-pane, then pane_dead variable. + */ + async isPaneDead(paneId: string): Promise<{ dead: boolean; exitCode: number }> { + // Method 1: Check for "Pane is dead" text in capture-pane + // This appears when remain-on-exit is on + const captured = await this.sendCommand(`capture-pane -t ${paneId} -p -S -10`); + const deadMatch = captured.match(/Pane is dead \(status (\d+)\)/); + if (deadMatch) { + return { dead: true, exitCode: Number.parseInt(deadMatch[1], 10) }; + } + + // Method 2: Check pane_dead variable + const result = await this.sendCommand( + `list-panes -t ${paneId} -F "#{pane_dead}\t#{pane_dead_status}"`, + ); + const [dead, status] = result.trim().split('\t'); + + return { + dead: dead === '1', + exitCode: status ? Number.parseInt(status, 10) : 0, + }; + } + + /** + * Get session status - checks if command has exited and returns exit code. + * Combines checkExitMarker and isPaneDead for comprehensive detection. + */ + async getSessionStatus( + windowName: string, + ): Promise<{ status: 'running' | 'exited' | 'not_found'; exitCode?: number }> { + // Check if window exists first + if (!(await this.windowExists(windowName))) { + return { status: 'not_found' }; + } + + // Method 1: Check for exit marker in streamed output (most reliable) + const markerResult = this.checkExitMarker(windowName); + if (markerResult.exited) { + return { status: 'exited', exitCode: markerResult.exitCode }; + } + + // Method 2: Check if pane is dead via tmux + const paneId = this.windowToPaneId.get(windowName); + if (paneId) { + const { dead, exitCode } = await this.isPaneDead(paneId); + if (dead) { + return { status: 'exited', exitCode }; + } + } + + return { status: 'running' }; + } + + /** + * Get buffered output for a window + */ + getOutput(windowName: string): string { + const paneId = this.windowToPaneId.get(windowName); + if (!paneId) return ''; + + const buffer = this.paneOutputs.get(paneId); + if (!buffer) return ''; + + // Join and strip ANSI escape codes + return stripAnsi(buffer.join('')); + } + + /** + * Capture pane output via tmux command (backup method) + */ + async capturePaneOutput(windowName: string, lines = 200): Promise { + const paneId = this.windowToPaneId.get(windowName); + const target = paneId || `${CONTROL_SESSION}:${windowName}`; + + const result = await this.sendCommand(`capture-pane -t ${target} -p -S -${lines}`); + return result; + } + + /** + * Kill a window + */ + async killWindow(windowName: string): Promise { + const paneId = this.windowToPaneId.get(windowName); + if (paneId) { + this.paneOutputs.delete(paneId); + this.windowToPaneId.delete(windowName); + } + await this.sendCommand(`kill-window -t ${CONTROL_SESSION}:${windowName}`); + } + + /** + * Check if a window exists + */ + async windowExists(windowName: string): Promise { + // Check in-memory map first + if (this.windowToPaneId.has(windowName)) { + return true; + } + // Query tmux for window list + try { + const result = await this.sendCommand( + `list-windows -t ${CONTROL_SESSION} -F "#{window_name}"`, + ); + const windows = result.split('\n').map((w) => w.trim()); + return windows.includes(windowName); + } catch { + return false; + } + } + + /** + * Send keys to a window + */ + async sendKeys(windowName: string, keys: string, enter: boolean): Promise { + const enterArg = enter ? ' Enter' : ''; + await this.sendCommand( + `send-keys -t ${CONTROL_SESSION}:${windowName} "${keys.replace(/"/g, '\\"')}"${enterArg}`, + ); + } + + /** + * List all windows + */ + async listWindows(): Promise { + const result = await this.sendCommand( + `list-windows -t ${CONTROL_SESSION} -F "#{window_name}: #{pane_current_command} (#{?pane_dead,exited,running})"`, + ); + return result.split('\n').filter((line) => line.trim() && !line.includes('(exited)')); + } + + /** + * Check if connected + */ + isConnected(): boolean { + return this.connected; + } + + /** + * Disconnect from control mode + */ + disconnect(): void { + if (this.proc) { + this.proc.stdin?.write('\n'); // Empty line to detach + this.proc.kill(); + this.proc = null; + } + this.connected = false; + } +} + +// Singleton control client +let controlClient: TmuxControlClient | null = null; + +export async function getControlClient(): Promise { + if (!controlClient) { + controlClient = new TmuxControlClient(); + } + if (!controlClient.isConnected()) { + await controlClient.connect(); + } + return controlClient; +} diff --git a/src/gadgets/tmux/TmuxGadget.ts b/src/gadgets/tmux/TmuxGadget.ts new file mode 100644 index 00000000..d1d2dc02 --- /dev/null +++ b/src/gadgets/tmux/TmuxGadget.ts @@ -0,0 +1,451 @@ +/** + * Tmux gadget - execute commands in tmux sessions. + * + * Uses tmux control mode for reliable output streaming and exit detection. + * All commands run as windows within a single control session. + */ +import { Gadget, z } from 'llmist'; +import { type TmuxControlClient, getControlClient } from './TmuxControlClient.js'; +import { + DEFAULT_TIMEOUT_MS, + DEFAULT_WAIT_MS, + EXIT_MARKER_PREFIX, + EXIT_MARKER_SUFFIX, + MAX_WAIT_MS, + POLL_INTERVAL_MS, + sessionNameSchema, +} from './constants.js'; +import { CommandFailedError } from './errors.js'; +import { validateGitCommand } from './gitValidation.js'; +import { addPendingNotice } from './sessionNotices.js'; +import { sanitizeSessionName, sleep } from './utils.js'; + +export class TmuxGadget extends Gadget({ + name: 'Tmux', + description: `Execute commands in tmux sessions. + +**Use this for ALL commands** (npm, tests, builds, git, gh, etc.) + +**COMMAND FORMAT:** Pass command as a shell string. +- command="npm test" +- command="npm run build && npm test" +- command="rg 'pattern' src/ | head -20" + +Commands are interpreted by bash, so pipes, &&, ||, redirects, and globs all work. + +**QUOTING:** When using gh, git, or commands with special characters: +- Use single quotes around values with parentheses: --title 'feat(scope): message' +- Example: command="gh pr create --title 'feat(auth): add login' --body 'Description'" + +**WORKING DIRECTORY:** Set \`cwd\` parameter to run commands in subdirectories. +- Tmux(action="start", session="test", command="npm test", cwd="packages/core") + +**ACTIONS:** +- \`start\`: Run a command in a new session. Waits up to 120s for initial output. + - If command exits with code 0: returns full output + - **If command exits with non-zero code: THROWS AN ERROR** with output + - If still running after 120s: returns partial output + "still running" message + - **Process keeps running in background** - use capture to check progress +- \`capture\`: Get recent output from a running session +- \`send\`: Send keys/commands to a session (use "C-c" for Ctrl+C) +- \`list\`: List all active sessions +- \`kill\`: Terminate a session +- \`exists\`: Check if a session is running + +**TYPICAL WORKFLOW:** +1. Start: Tmux(action="start", session="test-run", command="npm test") +2. If still running, check progress: Tmux(action="capture", session="test-run") +3. When done: Tmux(action="kill", session="test-run") + +**SESSION NAMING:** Use descriptive names like "npm-install", "test-run", "lint-check", "build"`, + timeoutMs: DEFAULT_TIMEOUT_MS, + maxConcurrent: 1, // Sequential execution to prevent race conditions + schema: z.discriminatedUnion('action', [ + z.object({ + action: z.literal('start'), + comment: z.string().min(1).describe('Brief rationale for this gadget call'), + session: sessionNameSchema.describe("Unique session name (e.g., 'test-run', 'npm-install')"), + command: z + .string() + .min(1) + .describe("Shell command to execute (e.g., 'npm test', 'npm run build && npm test')"), + cwd: z.string().optional().describe('Working directory for the command (default: repo root)'), + wait: z.coerce + .number() + .default(DEFAULT_WAIT_MS) + .describe('Max time to wait for initial output in ms (default: 120000, max: 120000)'), + }), + z.object({ + action: z.literal('send'), + comment: z.string().min(1).describe('Brief rationale for this gadget call'), + session: sessionNameSchema.describe('Target session name'), + keys: z.string().describe("Keys or command to send (e.g., 'npm test' or 'C-c' for Ctrl+C)"), + enter: z.coerce + .boolean() + .default(true) + .describe('Press Enter after sending keys (default: true)'), + }), + z.object({ + action: z.literal('capture'), + comment: z.string().min(1).describe('Brief rationale for this gadget call'), + session: sessionNameSchema.describe('Session to capture output from'), + lines: z.coerce + .number() + .int() + .min(1) + .max(1000) + .default(25) + .describe('Number of lines to capture (default: 25, max: 1000)'), + }), + z.object({ + action: z.literal('list'), + comment: z.string().min(1).describe('Brief rationale for this gadget call'), + }), + z.object({ + action: z.literal('kill'), + comment: z.string().min(1).describe('Brief rationale for this gadget call'), + session: sessionNameSchema.describe('Session to terminate'), + }), + z.object({ + action: z.literal('exists'), + comment: z.string().min(1).describe('Brief rationale for this gadget call'), + session: sessionNameSchema.describe('Session name to check'), + }), + ]), + examples: [ + { + params: { + action: 'start', + comment: 'Running unit tests to verify changes', + session: 'test-run', + command: 'npm test', + wait: 120000, + }, + output: + 'session=test-run status=exited exit_code=0\n\n> project@1.0.0 test\n> vitest run\n\n✓ 15 tests passed', + comment: 'Run tests - command completed within 120s wait period', + }, + { + params: { + action: 'start', + comment: 'Exploring vehicle service module dependencies', + session: 'squint-modules', + command: 'squint modules show backend.services.vehicles --json', + wait: 15000, + }, + output: + 'session=squint-modules status=exited exit_code=0\n\n{"path":"backend.services.vehicles","description":"Vehicle business logic","files":["src/services/vehicles.service.ts"],"dependencies":["backend.data.models","shared-types.entities.vehicles"],"dependents":["backend.api.vehicles"]}', + comment: + "Use squint to see a module's files, dependencies, and dependents before reading code", + }, + { + params: { + action: 'start', + comment: 'Tracing vehicle search data flow', + session: 'squint-features', + command: 'squint features show vehicle-inventory --json', + wait: 15000, + }, + output: + 'session=squint-features status=exited exit_code=0\n\n{"slug":"vehicle-inventory","description":"Vehicle CRUD and search","flows":["VehicleController.getAll -> VehicleService.getAll -> VehicleModel.findAll"],"modules":["backend.api.vehicles","backend.services.vehicles","backend.data.models"]}', + comment: 'Use squint to trace how data flows through the system before exploring code', + }, + { + params: { + action: 'start', + comment: 'Running lint and tests in sequence', + session: 'pipeline', + command: 'npm run lint && npm test', + wait: 120000, + }, + output: 'session=pipeline status=exited exit_code=0\n\n✓ Lint passed\n✓ 15 tests passed', + comment: 'Chain commands with &&', + }, + { + params: { + action: 'start', + comment: 'Testing frontend package separately', + session: 'frontend-test', + command: 'pnpm test', + cwd: 'packages/frontend', + wait: 120000, + }, + output: 'session=frontend-test status=exited exit_code=0\n\n✓ 42 tests passed', + comment: 'Run in subdirectory using cwd instead of cd prefix', + }, + { + params: { + action: 'start', + comment: 'Starting E2E test suite', + session: 'e2e-tests', + command: 'npm run test:e2e', + wait: 120000, + }, + output: + "session=e2e-tests status=running\n\nStarting backend...\n✓ Backend healthy\n\n(Process still running in session 'e2e-tests'. Use capture to check progress, kill when done.)", + comment: 'Long-running E2E tests still running after 120s - use capture to monitor', + }, + { + params: { + action: 'capture', + comment: 'Checking install progress', + session: 'npm-install', + lines: 25, + }, + output: 'session=npm-install status=running lines=25\n\nadded 874 packages in 45s', + comment: 'Check output from running session - status=running means command still executing', + }, + { + params: { + action: 'capture', + comment: 'Checking if tests completed', + session: 'test-run', + lines: 50, + }, + output: + 'session=test-run status=exited exit_code=0 lines=50\n\n✓ 15 tests passed\n✓ All tests completed', + comment: 'Capture shows command finished - status=exited with exit code', + }, + { + params: { + action: 'send', + comment: 'Stopping dev server', + session: 'dev-server', + keys: 'C-c', + enter: false, + }, + output: "session=dev-server status=sent\n\nSent keys to session 'dev-server': C-c", + comment: 'Send Ctrl+C to stop a process', + }, + { + params: { action: 'kill', comment: 'Cleaning up completed session', session: 'npm-install' }, + output: "session=npm-install status=killed\n\nSession 'npm-install' terminated", + comment: 'Terminate a session', + }, + { + params: { action: 'list', comment: 'Checking for running sessions' }, + output: 'sessions=2\n\ntest-run: npm (running)\nnpm-install: npm (running)', + comment: 'List all active tmux sessions', + }, + { + params: { + action: 'start', + comment: 'Creating PR for OAuth feature', + session: 'create-pr', + command: + "gh pr create --title 'feat(auth): add OAuth login' --body 'Implements OAuth flow'", + wait: 30000, + }, + output: + 'session=create-pr status=exited exit_code=0\n\nCreating pull request for feature/oauth...\nhttps://github.com/org/repo/pull/123', + comment: + 'Create PR - note single quotes around title with parentheses to prevent shell errors', + }, + ], +}) { + override async execute(params: this['params']): Promise { + // Sanitize session name if present (replaces / and other invalid chars with -) + if ('session' in params && typeof params.session === 'string') { + (params as { session: string }).session = sanitizeSessionName(params.session); + } + + switch (params.action) { + case 'start': + return this.handleStart(params); + case 'send': + return this.handleSend(params); + case 'capture': + return this.handleCapture(params); + case 'list': + return this.handleList(); + case 'kill': + return this.handleKill(params); + case 'exists': + return this.handleExists(params); + default: + return 'status=error\n\nUnknown action'; + } + } + + private async handleStart(params: { + session: string; + command: string; + cwd?: string; + wait?: number; + }): Promise { + // Validate git commands don't bypass hooks + validateGitCommand(params.command); + + const client = await getControlClient(); + + // Check if window already exists + if (await client.windowExists(params.session)) { + return `session=${params.session} status=error\n\nSession '${params.session}' already exists. Use action="kill" first or choose a different name.`; + } + + // Create window with command + const paneId = await client.createWindow(params.session, params.command, params.cwd); + if (!paneId.startsWith('%')) { + return `session=${params.session} status=error\n\nFailed to create session: ${paneId}`; + } + + // Wait for output/exit + return this.waitForOutput(client, params.session, paneId, params.wait); + } + + private cleanupOutput(output: string, removeDeadPane = false): string { + let cleaned = output + .replace(new RegExp(`${EXIT_MARKER_PREFIX}\\d+${EXIT_MARKER_SUFFIX}\\s*`), '') + .replace(/\n{3,}/g, '\n\n') + .trim(); + + if (removeDeadPane) { + cleaned = cleaned.replace(/\nPane is dead \([^)]+\)\s*$/, ''); + } + + return cleaned; + } + + private async getSessionOutput(client: TmuxControlClient, session: string): Promise { + let output = client.getOutput(session); + if (!output.trim()) { + output = await client.capturePaneOutput(session); + } + return output; + } + + private async handleCommandExit( + client: TmuxControlClient, + session: string, + exitCode: number, + ): Promise { + const output = this.cleanupOutput(await this.getSessionOutput(client, session), true); + const lines = output.split('\n'); + addPendingNotice(session, { exitCode, tailOutput: lines.slice(-100).join('\n') }); + await client.killWindow(session); + + if (exitCode !== 0) { + throw new CommandFailedError(session, exitCode, output); + } + + return `session=${session} status=exited exit_code=${exitCode}\n\n${output || '(no output)'}`; + } + + private async waitForOutput( + client: TmuxControlClient, + session: string, + paneId: string, + requestedWait?: number, + ): Promise { + const waitMs = Math.min(requestedWait ?? DEFAULT_WAIT_MS, MAX_WAIT_MS); + let elapsed = 0; + + while (elapsed < waitMs) { + await sleep(POLL_INTERVAL_MS); + elapsed += POLL_INTERVAL_MS; + + // Primary method: Check for exit marker in streamed output + const markerResult = client.checkExitMarker(session); + if (markerResult.exited) { + return this.handleCommandExit(client, session, markerResult.exitCode); + } + + // Fallback: Check if pane is dead via tmux + const { dead, exitCode } = await client.isPaneDead(paneId); + if (dead) { + return this.handleCommandExit(client, session, exitCode); + } + } + + // Still running - get partial output + const output = this.cleanupOutput(await this.getSessionOutput(client, session)); + return `session=${session} status=running\n\n${output || '(no output yet)'}\n\n(Process still running in session '${session}'. Use capture to check progress, kill when done.)`; + } + + private async handleSend(params: { + session: string; + keys: string; + enter?: boolean; + }): Promise { + // Validate git commands don't bypass hooks (only when sending a command with Enter) + if (params.enter !== false) { + validateGitCommand(params.keys); + } + + const client = await getControlClient(); + + if (!(await client.windowExists(params.session))) { + return `session=${params.session} status=error\n\nSession '${params.session}' does not exist`; + } + + await client.sendKeys(params.session, params.keys, params.enter ?? true); + + const enterNote = params.enter ? ' [Enter]' : ''; + return `session=${params.session} status=sent\n\nSent keys to session '${params.session}': ${params.keys}${enterNote}`; + } + + private async handleCapture(params: { session: string; lines?: number }): Promise { + const client = await getControlClient(); + const lines = params.lines ?? 25; + + // Check session status (existence + exit detection) + const sessionStatus = await client.getSessionStatus(params.session); + + if (sessionStatus.status === 'not_found') { + return `session=${params.session} status=error\n\nSession '${params.session}' does not exist`; + } + + // Get output (try streamed output first, then capture-pane) + let output = client.getOutput(params.session); + if (!output.trim()) { + output = await client.capturePaneOutput(params.session, lines); + } + + // Take last N lines and clean up + const outputLines = output.split('\n'); + let captured = outputLines.slice(-lines).join('\n').trim(); + + // Clean exit marker from output if present + captured = captured + .replace(new RegExp(`${EXIT_MARKER_PREFIX}\\d+${EXIT_MARKER_SUFFIX}\\s*`), '') + .replace(/\nPane is dead \([^)]+\)\s*$/, '') + .trim(); + + // Report status with exit code if exited + if (sessionStatus.status === 'exited') { + return `session=${params.session} status=exited exit_code=${sessionStatus.exitCode} lines=${lines}\n\n${captured || '(no output)'}`; + } + + return `session=${params.session} status=running lines=${lines}\n\n${captured || '(no output yet)'}`; + } + + private async handleList(): Promise { + const client = await getControlClient(); + + try { + const windows = await client.listWindows(); + // Filter out the initial shell window + const filtered = windows.filter((w) => !w.startsWith('0:')); + return `sessions=${filtered.length}\n\n${filtered.join('\n') || 'No active sessions'}`; + } catch { + return 'sessions=0\n\nNo active tmux sessions'; + } + } + + private async handleKill(params: { session: string }): Promise { + const client = await getControlClient(); + + if (!(await client.windowExists(params.session))) { + return `session=${params.session} status=error\n\nSession '${params.session}' does not exist`; + } + + await client.killWindow(params.session); + return `session=${params.session} status=killed\n\nSession '${params.session}' terminated`; + } + + private async handleExists(params: { session: string }): Promise { + const client = await getControlClient(); + const exists = await client.windowExists(params.session); + return `session=${params.session} exists=${exists}\n\nSession '${params.session}' ${exists ? 'exists and is running' : 'does not exist'}`; + } +} diff --git a/src/gadgets/tmux/constants.ts b/src/gadgets/tmux/constants.ts new file mode 100644 index 00000000..a6ce65bc --- /dev/null +++ b/src/gadgets/tmux/constants.ts @@ -0,0 +1,17 @@ +import { z } from 'llmist'; + +export const DEFAULT_TIMEOUT_MS = 300000; // 5 min for the gadget itself +export const DEFAULT_WAIT_MS = 120000; // 120s to wait for initial output +export const MAX_WAIT_MS = 120000; // Never wait longer than 120s +export const POLL_INTERVAL_MS = 100; // Faster polling since we have streamed output +export const MAX_OUTPUT_BUFFER = 10 * 1024 * 1024; // 10MB max output per pane + +export const CONTROL_SESSION = '_cascade_control'; +export const EXIT_MARKER_PREFIX = '___CASCADE_EXIT_'; +export const EXIT_MARKER_SUFFIX = '___'; + +/** + * Session name schema - accepts any string, sanitized at runtime. + * Invalid characters (like /) are automatically replaced with dashes. + */ +export const sessionNameSchema = z.string().min(1).max(64); diff --git a/src/gadgets/tmux/errors.ts b/src/gadgets/tmux/errors.ts new file mode 100644 index 00000000..b92af36e --- /dev/null +++ b/src/gadgets/tmux/errors.ts @@ -0,0 +1,20 @@ +/** + * Error thrown when a command exits with non-zero exit code. + * Contains session name, exit code, and output preview for debugging. + */ +export class CommandFailedError extends Error { + constructor( + public readonly session: string, + public readonly exitCode: number, + public readonly output: string, + ) { + const preview = output.length > 1000 ? output.slice(-1000) : output; + super( + `Command exited with code ${exitCode}\n\n` + + `Session: ${session}\n` + + `Exit code: ${exitCode}\n\n` + + `Output:\n${preview || '(no output)'}`, + ); + this.name = 'CommandFailedError'; + } +} diff --git a/src/gadgets/tmux/gitValidation.ts b/src/gadgets/tmux/gitValidation.ts new file mode 100644 index 00000000..e868d078 --- /dev/null +++ b/src/gadgets/tmux/gitValidation.ts @@ -0,0 +1,35 @@ +/** + * Validates that git commands don't contain dangerous flags that bypass safety checks. + * @throws Error if a dangerous flag is detected + */ +export function validateGitCommand(command: string): void { + // Normalize command for checking (handle multiline, extra spaces) + const normalized = command.toLowerCase(); + + // Check for git commit/push with --no-verify or -n flag + // Match patterns like: git commit --no-verify, git push --no-verify + // Also match: git commit -n, git commit -anm (contains -n) + // Uses .*? (non-greedy) to match any characters between git commit/push and the flag + // The -n pattern matches any flag containing 'n' (e.g., -n, -an, -anm, -nam) + const gitNoVerifyPattern = + /\bgit\s+(commit|push)\b.*?(\s--no-verify\b|\s-[a-z]*n[a-z]*(?=\s|"|'|$))/; + + if (gitNoVerifyPattern.test(normalized)) { + throw new Error( + 'Git commands with --no-verify or -n flag are not allowed. ' + + 'Pre-commit and pre-push hooks must run to ensure code quality.', + ); + } + + // Block broad staging commands that capture unintended files (build artifacts, generated files) + // Note: normalized is already lowercased, so -A becomes -a + const broadStagingPattern = + /\bgit\s+add\s+(-a\b|--all\b|\.\s*($|&&|\||;|"|')|\.\/\s*($|&&|\||;|"|'))/; + if (broadStagingPattern.test(normalized)) { + throw new Error( + 'Broad git staging (git add -A / git add . / git add --all) is not allowed. ' + + 'Stage specific files instead: git add ...\n' + + 'This prevents accidentally committing build artifacts and generated files.', + ); + } +} diff --git a/src/gadgets/tmux/index.ts b/src/gadgets/tmux/index.ts new file mode 100644 index 00000000..79f29402 --- /dev/null +++ b/src/gadgets/tmux/index.ts @@ -0,0 +1,5 @@ +export { TmuxGadget as Tmux } from './TmuxGadget.js'; +export { consumePendingSessionNotices } from './sessionNotices.js'; +export type { CompletedSessionNotice } from './sessionNotices.js'; +export { validateGitCommand } from './gitValidation.js'; +export { resolveWorkingDirectory } from './utils.js'; diff --git a/src/gadgets/tmux/sessionNotices.ts b/src/gadgets/tmux/sessionNotices.ts new file mode 100644 index 00000000..a6413c02 --- /dev/null +++ b/src/gadgets/tmux/sessionNotices.ts @@ -0,0 +1,30 @@ +/** + * Completed session notice - stored for injection into agent conversation. + */ +export interface CompletedSessionNotice { + exitCode: number; + tailOutput: string; // Last 100 lines +} + +/** + * Pending notices for sessions that completed. + * Consumed by agent loop to inject as user messages. + */ +const pendingNotices = new Map(); + +/** + * Add a pending notice for a completed session. + */ +export function addPendingNotice(session: string, notice: CompletedSessionNotice): void { + pendingNotices.set(session, notice); +} + +/** + * Get and clear pending session completion notices. + * Called by agent loop to inject into conversation via injectUserMessage(). + */ +export function consumePendingSessionNotices(): Map { + const notices = new Map(pendingNotices); + pendingNotices.clear(); + return notices; +} diff --git a/src/gadgets/tmux/utils.ts b/src/gadgets/tmux/utils.ts new file mode 100644 index 00000000..838c4e9d --- /dev/null +++ b/src/gadgets/tmux/utils.ts @@ -0,0 +1,49 @@ +import { resolve } from 'node:path'; + +/** + * Sanitize session name by replacing invalid characters with dashes. + */ +export function sanitizeSessionName(name: string): string { + return name.replace(/[^a-zA-Z0-9_-]/g, '-'); +} + +/** + * Unescape tmux control mode output (octal escapes like \012 -> \n) + */ +export function unescapeOutput(s: string): string { + return s.replace(/\\(\d{3})/g, (_, oct) => String.fromCharCode(Number.parseInt(oct, 8))); +} + +// ANSI escape code patterns (using hex to avoid lint errors about control chars) +const ESC = '\u001b'; +const BEL = '\u0007'; +const ANSI_PATTERN = new RegExp(`${ESC}\\[[0-9;]*[a-zA-Z]`, 'g'); +const OSC_PATTERN = new RegExp(`${ESC}\\][^${BEL}]*${BEL}`, 'g'); +const DCS_PATTERN = new RegExp(`${ESC}[PX^_][^${ESC}]*${ESC}\\\\`, 'g'); + +/** + * Strip ANSI escape codes from output + */ +export function stripAnsi(s: string): string { + return s + .replace(ANSI_PATTERN, '') + .replace(OSC_PATTERN, '') + .replace(DCS_PATTERN, '') + .replace(/\r/g, ''); +} + +/** + * Sleep helper + */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Resolve working directory for tmux commands. + * Relative paths are resolved against process.cwd() (the repo root), + * since the tmux control session runs in /tmp. + */ +export function resolveWorkingDirectory(cwd?: string): string { + return cwd ? resolve(cwd) : process.cwd(); +} diff --git a/src/github/client.ts b/src/github/client.ts index 690e1de2..d3fe0b56 100644 --- a/src/github/client.ts +++ b/src/github/client.ts @@ -388,7 +388,7 @@ export async function getAuthenticatedUser(): Promise { return data.login; } -export async function getReviewerUser(token: string | null): Promise { +export async function getGitHubUserForToken(token: string | null): Promise { if (!token) return null; try { diff --git a/src/triggers/github/check-suite-success.ts b/src/triggers/github/check-suite-success.ts index 9122a3a1..0e840ad2 100644 --- a/src/triggers/github/check-suite-success.ts +++ b/src/triggers/github/check-suite-success.ts @@ -1,5 +1,5 @@ -import { getProjectReviewerToken } from '../../config/projects.js'; -import { getAuthenticatedUser, getReviewerUser, githubClient } from '../../github/client.js'; +import { getAgentCredential } from '../../config/provider.js'; +import { getAuthenticatedUser, getGitHubUserForToken, githubClient } from '../../github/client.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; import { type GitHubCheckSuitePayload, isGitHubCheckSuitePayload } from './types.js'; @@ -68,11 +68,11 @@ export class CheckSuiteSuccessTrigger implements TriggerHandler { const cardId = extractTrelloCardId(prDetails.body); // Skip if our latest review already covers the current HEAD SHA - const reviewerToken = await getProjectReviewerToken(ctx.project); + const agentGitHubToken = await getAgentCredential(ctx.project.id, 'review', 'GITHUB_TOKEN'); const [reviews, botUser, reviewerUser] = await Promise.all([ githubClient.getPRReviews(owner, repo, prNumber), getAuthenticatedUser(), - getReviewerUser(reviewerToken), + getGitHubUserForToken(agentGitHubToken), ]); // Only consider actual reviews (approved/changes_requested), not COMMENTED diff --git a/src/triggers/github/pr-comment-mention.ts b/src/triggers/github/pr-comment-mention.ts index 62ed9ede..b09feb28 100644 --- a/src/triggers/github/pr-comment-mention.ts +++ b/src/triggers/github/pr-comment-mention.ts @@ -1,6 +1,5 @@ -import { getProjectReviewerToken } from '../../config/projects.js'; -import { getReviewerUser } from '../../github/client.js'; -import { githubClient } from '../../github/client.js'; +import { getAgentCredential } from '../../config/provider.js'; +import { getGitHubUserForToken, githubClient } from '../../github/client.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; import { isGitHubIssueCommentPayload, isGitHubPRReviewCommentPayload } from './types.js'; @@ -34,9 +33,13 @@ export class PRCommentMentionTrigger implements TriggerHandler { } async handle(ctx: TriggerContext): Promise { - // Resolve reviewer username — if no reviewer token configured, fall through - const reviewerToken = await getProjectReviewerToken(ctx.project); - const reviewerUser = await getReviewerUser(reviewerToken); + // Resolve reviewer username — if no agent-scoped GITHUB_TOKEN configured, fall through + const agentGitHubToken = await getAgentCredential( + ctx.project.id, + 'respond-to-pr-comment', + 'GITHUB_TOKEN', + ); + const reviewerUser = await getGitHubUserForToken(agentGitHubToken); if (!reviewerUser) { return null; } diff --git a/src/triggers/github/webhook-handler.ts b/src/triggers/github/webhook-handler.ts index 40323255..2e1d4e95 100644 --- a/src/triggers/github/webhook-handler.ts +++ b/src/triggers/github/webhook-handler.ts @@ -1,6 +1,10 @@ import { runAgent } from '../../agents/registry.js'; -import { getProjectReviewerToken } from '../../config/projects.js'; -import { findProjectByRepo, getProjectSecret, loadConfig } from '../../config/provider.js'; +import { + findProjectByRepo, + getAgentCredential, + getProjectSecret, + loadConfig, +} from '../../config/provider.js'; import { getSessionState } from '../../gadgets/sessionState.js'; import { githubClient, withGitHubToken } from '../../github/client.js'; import { trelloClient, withTrelloCredentials } from '../../trello/client.js'; @@ -217,10 +221,12 @@ async function runGitHubAgentJob( githubToken: string, registry: TriggerRegistry, ): Promise { - // Use reviewer token for PR comments when available, so acknowledgments - // and error messages appear from the reviewer identity (not the repo owner). - const reviewerToken = await getProjectReviewerToken(project); - const prCommentToken = reviewerToken || githubToken; + // Use agent-scoped GITHUB_TOKEN when available, so acknowledgments + // and error messages appear from the agent's identity (not the repo owner). + const agentGitHubToken = result.agentType + ? await getAgentCredential(project.id, result.agentType, 'GITHUB_TOKEN') + : null; + const prCommentToken = agentGitHubToken || githubToken; await withGitHubToken(prCommentToken, () => postAcknowledgmentComment(result)); cancelFreshMachineTimer(); diff --git a/src/triggers/trello/webhook-handler.ts b/src/triggers/trello/webhook-handler.ts index cacf225b..351a08ab 100644 --- a/src/triggers/trello/webhook-handler.ts +++ b/src/triggers/trello/webhook-handler.ts @@ -1,5 +1,10 @@ import { runAgent } from '../../agents/registry.js'; -import { findProjectByBoardId, getProjectSecret, loadConfig } from '../../config/provider.js'; +import { + findProjectByBoardId, + getAgentCredential, + getProjectSecret, + loadConfig, +} from '../../config/provider.js'; import { withGitHubToken } from '../../github/client.js'; import { trelloClient, withTrelloCredentials } from '../../trello/client.js'; import type { @@ -109,12 +114,16 @@ async function executeAgent( const trelloToken = await getProjectSecret(project.id, 'TRELLO_TOKEN'); const githubToken = await getProjectSecret(project.id, 'GITHUB_TOKEN'); + // Check for agent-scoped credential overrides + const agentGitHubToken = await getAgentCredential(project.id, result.agentType, 'GITHUB_TOKEN'); + const effectiveGithubToken = agentGitHubToken || githubToken; + // Inject LLM API keys into process.env for llmist backend const restoreLlmEnv = await injectLlmApiKeys(project.id); try { await withTrelloCredentials({ apiKey: trelloApiKey, token: trelloToken }, () => - withGitHubToken(githubToken, () => executeAgentWithCreds(result, project, config)), + withGitHubToken(effectiveGithubToken, () => executeAgentWithCreds(result, project, config)), ); } finally { restoreLlmEnv(); diff --git a/src/utils/cascadeEnv.ts b/src/utils/cascadeEnv.ts index b3cd1702..97db0cb1 100644 --- a/src/utils/cascadeEnv.ts +++ b/src/utils/cascadeEnv.ts @@ -5,7 +5,6 @@ const PROTECTED_ENV_KEYS = new Set([ 'TRELLO_API_KEY', 'TRELLO_TOKEN', 'GITHUB_TOKEN', - 'GITHUB_REVIEWER_TOKEN', 'OPENROUTER_API_KEY', 'CASCADE_WORKSPACE_DIR', 'CASCADE_LOCAL_MODE', diff --git a/tests/unit/config/projects.test.ts b/tests/unit/config/projects.test.ts index 7a3ad873..d0b02aed 100644 --- a/tests/unit/config/projects.test.ts +++ b/tests/unit/config/projects.test.ts @@ -10,6 +10,7 @@ vi.mock('../../../src/db/repositories/configRepository.js', () => ({ vi.mock('../../../src/db/repositories/credentialsRepository.js', () => ({ resolveCredential: vi.fn(), + resolveAgentCredential: vi.fn(), resolveAllCredentials: vi.fn(), })); diff --git a/tests/unit/db/repositories/configRepository.test.ts b/tests/unit/db/repositories/configRepository.test.ts new file mode 100644 index 00000000..ea61cd54 --- /dev/null +++ b/tests/unit/db/repositories/configRepository.test.ts @@ -0,0 +1,503 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../src/db/client.js', () => ({ + getDb: vi.fn(), +})); + +import { getDb } from '../../../../src/db/client.js'; +import { + findProjectByBoardIdFromDb, + findProjectByIdFromDb, + findProjectByRepoFromDb, + loadConfigFromDb, +} from '../../../../src/db/repositories/configRepository.js'; + +// --------------------------------------------------------------------------- +// Test data +// --------------------------------------------------------------------------- + +const defaultsRow = { + orgId: 'default', + model: 'test-model', + maxIterations: 50, + freshMachineTimeoutMs: 300000, + watchdogTimeoutMs: 1800000, + postJobGracePeriodMs: 5000, + cardBudgetUsd: '5.00', + agentBackend: 'llmist', + progressModel: 'progress-model', + progressIntervalMinutes: '5', + createdAt: new Date(), + updatedAt: new Date(), +}; + +const projectRow = { + id: 'proj1', + orgId: 'default', + name: 'Project One', + repo: 'owner/repo1', + baseBranch: 'main', + branchPrefix: 'feature/', + model: null, + cardBudgetUsd: null, + agentBackend: null, + subscriptionCostZero: false, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const projectRowWithBackend = { + ...projectRow, + id: 'proj2', + name: 'Project Two', + repo: 'owner/repo2', + agentBackend: 'claude-code', + subscriptionCostZero: true, +}; + +const trelloIntegration = { + id: 1, + projectId: 'proj1', + type: 'trello' as const, + config: { + boardId: 'board123', + lists: { todo: 'list-todo', done: 'list-done' }, + labels: { processing: 'label-proc' }, + customFields: { cost: 'cf-cost' }, + }, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const globalAgentConfig = { + id: 1, + orgId: null, + projectId: null, + agentType: 'review', + model: 'global-review-model', + maxIterations: 30, + agentBackend: null, + prompt: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const projectAgentConfig = { + id: 2, + orgId: null, + projectId: 'proj1', + agentType: 'implementation', + model: 'impl-model', + maxIterations: null, + agentBackend: 'claude-code', + prompt: 'Write clean code', + createdAt: new Date(), + updatedAt: new Date(), +}; + +const orgAgentConfig = { + id: 3, + orgId: 'default', + projectId: null, + agentType: 'briefing', + model: 'org-briefing-model', + maxIterations: 20, + agentBackend: null, + prompt: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +// --------------------------------------------------------------------------- +// Mock DB helper +// +// Uses sequential result returning: each from() call returns the next result +// in the queue. This works because the select().from() calls are set up in a +// deterministic order within each function. +// +// loadConfigFromDb order (Promise.all): cascadeDefaults, projects, agentConfigs, integrations +// findProjectFromDb order: projects (initial), then Promise.all: agentConfigs x3, defaults, integrations +// --------------------------------------------------------------------------- + +type QueryResult = Record[]; + +function createSequentialMockDb(results: QueryResult[]) { + let callIndex = 0; + + const makeTerminal = () => { + const idx = callIndex++; + const data = results[idx] ?? []; + // biome-ignore lint/suspicious/noThenProperty: mock must be thenable for Promise.all + const limitResult = { then: (fn: (r: QueryResult) => unknown) => Promise.resolve(fn(data)) }; + return { + where: vi.fn().mockImplementation(() => Promise.resolve(data)), + limit: vi.fn().mockReturnValue(limitResult), + // biome-ignore lint/suspicious/noThenProperty: mock must be thenable for Promise.all + then: (fn: (r: QueryResult) => unknown) => Promise.resolve(fn(data)), + }; + }; + + const db = { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockImplementation(() => makeTerminal()), + }), + }; + + return db; +} + +describe('configRepository', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('loadConfigFromDb', () => { + it('loads config with Trello integration from project_integrations', async () => { + // loadConfigFromDb Promise.all order: defaults, projects, agentConfigs, integrations + const mockDb = createSequentialMockDb([ + [defaultsRow], // cascadeDefaults + [projectRow], // projects + [], // agentConfigs (loadAgentConfigs) + [trelloIntegration], // projectIntegrations + ]); + vi.mocked(getDb).mockReturnValue(mockDb as never); + + const config = await loadConfigFromDb(); + + expect(config.projects).toHaveLength(1); + const proj = config.projects[0]; + expect(proj.id).toBe('proj1'); + expect(proj.trello.boardId).toBe('board123'); + expect(proj.trello.lists).toEqual({ todo: 'list-todo', done: 'list-done' }); + expect(proj.trello.labels).toEqual({ processing: 'label-proc' }); + expect(proj.trello.customFields).toEqual({ cost: 'cf-cost' }); + }); + + it('maps defaults correctly from DB row', async () => { + const mockDb = createSequentialMockDb([[defaultsRow], [projectRow], [], [trelloIntegration]]); + vi.mocked(getDb).mockReturnValue(mockDb as never); + + const config = await loadConfigFromDb(); + + expect(config.defaults.model).toBe('test-model'); + expect(config.defaults.maxIterations).toBe(50); + expect(config.defaults.agentBackend).toBe('llmist'); + expect(config.defaults.cardBudgetUsd).toBe(5); + expect(config.defaults.progressModel).toBe('progress-model'); + expect(config.defaults.progressIntervalMinutes).toBe(5); + }); + + it('maps agentBackend from project row when set', async () => { + const mockDb = createSequentialMockDb([ + [defaultsRow], + [projectRowWithBackend], + [], + [{ ...trelloIntegration, projectId: 'proj2' }], + ]); + vi.mocked(getDb).mockReturnValue(mockDb as never); + + const config = await loadConfigFromDb(); + const proj = config.projects[0]; + + expect(proj.agentBackend).toBeDefined(); + expect(proj.agentBackend?.default).toBe('claude-code'); + expect(proj.agentBackend?.subscriptionCostZero).toBe(true); + }); + + it('builds agent backend overrides from agentBackend column in agent_configs', async () => { + const mockDb = createSequentialMockDb([ + [defaultsRow], + [projectRowWithBackend], + [{ ...projectAgentConfig, projectId: 'proj2' }], + [{ ...trelloIntegration, projectId: 'proj2' }], + ]); + vi.mocked(getDb).mockReturnValue(mockDb as never); + + const config = await loadConfigFromDb(); + const proj = config.projects[0]; + + expect(proj.agentBackend?.overrides).toEqual({ + implementation: 'claude-code', + }); + expect(proj.agentModels).toEqual({ implementation: 'impl-model' }); + }); + + it('merges global and org-level agent configs into defaults', async () => { + const mockDb = createSequentialMockDb([ + [defaultsRow], + [projectRow], + [globalAgentConfig, orgAgentConfig], + [trelloIntegration], + ]); + vi.mocked(getDb).mockReturnValue(mockDb as never); + + const config = await loadConfigFromDb(); + + expect(config.defaults.agentModels).toEqual({ + review: 'global-review-model', + briefing: 'org-briefing-model', + }); + expect(config.defaults.agentIterations).toEqual({ + review: 30, + briefing: 20, + }); + }); + + it('handles multiple projects with separate integrations', async () => { + const proj2Integration = { + ...trelloIntegration, + id: 2, + projectId: 'proj2', + config: { + boardId: 'board456', + lists: { todo: 'list-todo-2' }, + labels: { error: 'label-error' }, + }, + }; + + const mockDb = createSequentialMockDb([ + [defaultsRow], + [projectRow, projectRowWithBackend], + [], + [trelloIntegration, proj2Integration], + ]); + vi.mocked(getDb).mockReturnValue(mockDb as never); + + const config = await loadConfigFromDb(); + + expect(config.projects).toHaveLength(2); + expect(config.projects[0].trello.boardId).toBe('board123'); + expect(config.projects[1].trello.boardId).toBe('board456'); + }); + + it('queries 4 tables via Promise.all', async () => { + const mockDb = createSequentialMockDb([[defaultsRow], [projectRow], [], [trelloIntegration]]); + vi.mocked(getDb).mockReturnValue(mockDb as never); + + await loadConfigFromDb(); + + // 4 select() calls: defaults, projects, agentConfigs, integrations + expect(mockDb.select).toHaveBeenCalledTimes(4); + }); + + it('omits agentBackend from project when not set', async () => { + const mockDb = createSequentialMockDb([ + [defaultsRow], + [projectRow], // agentBackend is null + [], + [trelloIntegration], + ]); + vi.mocked(getDb).mockReturnValue(mockDb as never); + + const config = await loadConfigFromDb(); + + expect(config.projects[0].agentBackend).toBeUndefined(); + }); + }); + + describe('findProjectByIdFromDb', () => { + it('returns project with Trello integration from integrations table', async () => { + // findProjectFromDb order: projects (initial), then Promise.all: + // projectAcs, orgAcs, globalAcs, defaults, integrations + const mockDb = createSequentialMockDb([ + [projectRow], // project lookup + [projectAgentConfig], // project agent configs + [], // org agent configs + [globalAgentConfig], // global agent configs + [defaultsRow], // defaults + [trelloIntegration], // integrations + ]); + vi.mocked(getDb).mockReturnValue(mockDb as never); + + const result = await findProjectByIdFromDb('proj1'); + + expect(result).toBeDefined(); + expect(result?.id).toBe('proj1'); + expect(result?.trello.boardId).toBe('board123'); + expect(result?.trello.lists).toEqual({ todo: 'list-todo', done: 'list-done' }); + expect(result?.trello.labels).toEqual({ processing: 'label-proc' }); + }); + + it('returns undefined when project not found', async () => { + const mockDb = createSequentialMockDb([ + [], // no project found + ]); + vi.mocked(getDb).mockReturnValue(mockDb as never); + + const result = await findProjectByIdFromDb('nonexistent'); + + expect(result).toBeUndefined(); + }); + + it('maps agent configs with agentBackend column (renamed from backend)', async () => { + const mockDb = createSequentialMockDb([ + [projectRowWithBackend], + [projectAgentConfig], // has agentBackend: 'claude-code' + [], + [], + [defaultsRow], + [{ ...trelloIntegration, projectId: 'proj2' }], + ]); + vi.mocked(getDb).mockReturnValue(mockDb as never); + + const result = await findProjectByIdFromDb('proj2'); + + expect(result).toBeDefined(); + expect(result?.agentBackend?.default).toBe('claude-code'); + expect(result?.agentBackend?.overrides).toEqual({ + implementation: 'claude-code', + }); + }); + + it('includes prompts from agent configs', async () => { + const mockDb = createSequentialMockDb([ + [projectRow], + [projectAgentConfig], // has prompt: 'Write clean code' + [], + [], + [defaultsRow], + [trelloIntegration], + ]); + vi.mocked(getDb).mockReturnValue(mockDb as never); + + const result = await findProjectByIdFromDb('proj1'); + + expect(result).toBeDefined(); + expect(result?.prompts).toEqual({ implementation: 'Write clean code' }); + }); + + it('runs 5 sub-queries in parallel after initial project lookup', async () => { + const mockDb = createSequentialMockDb([ + [projectRow], + [], + [], + [], + [defaultsRow], + [trelloIntegration], + ]); + vi.mocked(getDb).mockReturnValue(mockDb as never); + + await findProjectByIdFromDb('proj1'); + + // 1 initial project lookup + 5 parallel sub-queries = 6 select() calls + expect(mockDb.select).toHaveBeenCalledTimes(6); + }); + + it('converts cardBudgetUsd from string to number', async () => { + const projWithBudget = { ...projectRow, cardBudgetUsd: '10.50' }; + + const mockDb = createSequentialMockDb([ + [projWithBudget], + [], + [], + [], + [defaultsRow], + [trelloIntegration], + ]); + vi.mocked(getDb).mockReturnValue(mockDb as never); + + const result = await findProjectByIdFromDb('proj1'); + + expect(result?.cardBudgetUsd).toBe(10.5); + }); + + it('handles Trello integration with customFields', async () => { + const mockDb = createSequentialMockDb([ + [projectRow], + [], + [], + [], + [defaultsRow], + [trelloIntegration], // has customFields: { cost: 'cf-cost' } + ]); + vi.mocked(getDb).mockReturnValue(mockDb as never); + + const result = await findProjectByIdFromDb('proj1'); + + expect(result?.trello.customFields).toEqual({ cost: 'cf-cost' }); + }); + + it('handles Trello integration without customFields', async () => { + const noCustomFields = { + ...trelloIntegration, + config: { + boardId: 'board123', + lists: { todo: 'list-todo' }, + labels: { processing: 'label-proc' }, + }, + }; + + const mockDb = createSequentialMockDb([ + [projectRow], + [], + [], + [], + [defaultsRow], + [noCustomFields], + ]); + vi.mocked(getDb).mockReturnValue(mockDb as never); + + const result = await findProjectByIdFromDb('proj1'); + + expect(result?.trello.customFields).toBeUndefined(); + }); + }); + + describe('findProjectByRepoFromDb', () => { + it('returns project found by repo', async () => { + const mockDb = createSequentialMockDb([ + [projectRow], + [], + [], + [], + [defaultsRow], + [trelloIntegration], + ]); + vi.mocked(getDb).mockReturnValue(mockDb as never); + + const result = await findProjectByRepoFromDb('owner/repo1'); + + expect(result).toBeDefined(); + expect(result?.id).toBe('proj1'); + expect(result?.repo).toBe('owner/repo1'); + }); + + it('returns undefined for unknown repo', async () => { + const mockDb = createSequentialMockDb([[]]); + vi.mocked(getDb).mockReturnValue(mockDb as never); + + const result = await findProjectByRepoFromDb('owner/unknown'); + + expect(result).toBeUndefined(); + }); + }); + + describe('findProjectByBoardIdFromDb', () => { + it('returns project found via integrations board ID subquery', async () => { + const mockDb = createSequentialMockDb([ + [projectRow], // subquery finds project + [], + [], + [], + [defaultsRow], + [trelloIntegration], + ]); + vi.mocked(getDb).mockReturnValue(mockDb as never); + + const result = await findProjectByBoardIdFromDb('board123'); + + expect(result).toBeDefined(); + expect(result?.id).toBe('proj1'); + expect(result?.trello.boardId).toBe('board123'); + }); + + it('returns undefined when no project has matching board ID', async () => { + const mockDb = createSequentialMockDb([[]]); + vi.mocked(getDb).mockReturnValue(mockDb as never); + + const result = await findProjectByBoardIdFromDb('nonexistent-board'); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/tests/unit/db/repositories/credentialsRepository.test.ts b/tests/unit/db/repositories/credentialsRepository.test.ts index 4557cafb..e7af5d7a 100644 --- a/tests/unit/db/repositories/credentialsRepository.test.ts +++ b/tests/unit/db/repositories/credentialsRepository.test.ts @@ -11,9 +11,12 @@ import { deleteCredential, listOrgCredentials, listProjectOverrides, + removeAgentCredentialOverride, removeProjectCredentialOverride, + resolveAgentCredential, resolveAllCredentials, resolveCredential, + setAgentCredentialOverride, setProjectCredentialOverride, updateCredential, } from '../../../../src/db/repositories/credentialsRepository.js'; @@ -136,6 +139,83 @@ describe('credentialsRepository', () => { }); }); + describe('resolveAgentCredential', () => { + it('returns agent-scoped override when found', async () => { + // First query (agent override) returns a result + mockDb.chain.where.mockResolvedValueOnce([{ value: 'agent-override-secret' }]); + + const result = await resolveAgentCredential('proj1', 'org1', 'review', 'GITHUB_TOKEN'); + expect(result).toBe('agent-override-secret'); + + // Should only call select once (found agent override, short-circuits) + expect(mockDb.db.select).toHaveBeenCalledTimes(1); + }); + + it('falls through to project override when no agent override', async () => { + // First query (agent override) returns empty + mockDb.chain.where.mockResolvedValueOnce([]); + // Second query (project override via resolveCredential) returns a result + mockDb.chain.where.mockResolvedValueOnce([{ value: 'project-override-secret' }]); + + const result = await resolveAgentCredential('proj1', 'org1', 'review', 'GITHUB_TOKEN'); + expect(result).toBe('project-override-secret'); + + // Two selects: agent override check + project override check + expect(mockDb.db.select).toHaveBeenCalledTimes(2); + }); + + it('falls through to org default when no agent or project override', async () => { + // First query (agent override) returns empty + mockDb.chain.where.mockResolvedValueOnce([]); + // Second query (project override) returns empty + mockDb.chain.where.mockResolvedValueOnce([]); + // Third query (org default) returns a result + mockDb.chain.where.mockResolvedValueOnce([{ value: 'org-default-secret' }]); + + const result = await resolveAgentCredential('proj1', 'org1', 'review', 'GITHUB_TOKEN'); + expect(result).toBe('org-default-secret'); + + expect(mockDb.db.select).toHaveBeenCalledTimes(3); + }); + + it('returns null when no override at any level', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + mockDb.chain.where.mockResolvedValueOnce([]); + mockDb.chain.where.mockResolvedValueOnce([]); + + const result = await resolveAgentCredential('proj1', 'org1', 'review', 'GITHUB_TOKEN'); + expect(result).toBeNull(); + }); + }); + + describe('setAgentCredentialOverride', () => { + it('deletes then inserts agent-scoped override', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); // delete + mockDb.chain.returning.mockResolvedValueOnce([]); // insert (no returning needed) + + await setAgentCredentialOverride('proj1', 'GITHUB_TOKEN', 'review', 42); + + expect(mockDb.db.delete).toHaveBeenCalledTimes(1); + expect(mockDb.db.insert).toHaveBeenCalledTimes(1); + expect(mockDb.chain.values).toHaveBeenCalledWith({ + projectId: 'proj1', + envVarKey: 'GITHUB_TOKEN', + credentialId: 42, + agentType: 'review', + }); + }); + }); + + describe('removeAgentCredentialOverride', () => { + it('deletes agent-scoped override', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await removeAgentCredentialOverride('proj1', 'GITHUB_TOKEN', 'review'); + + expect(mockDb.db.delete).toHaveBeenCalledTimes(1); + }); + }); + describe('createCredential', () => { it('inserts credential and returns id', async () => { mockDb.chain.returning.mockResolvedValueOnce([{ id: 42 }]); @@ -247,18 +327,19 @@ describe('credentialsRepository', () => { }); describe('setProjectCredentialOverride', () => { - it('upserts override', async () => { - mockDb.chain.onConflictDoUpdate.mockResolvedValueOnce(undefined); + it('deletes then inserts project-wide override', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); // delete await setProjectCredentialOverride('proj1', 'GITHUB_TOKEN', 42); + expect(mockDb.db.delete).toHaveBeenCalledTimes(1); expect(mockDb.db.insert).toHaveBeenCalledTimes(1); expect(mockDb.chain.values).toHaveBeenCalledWith({ projectId: 'proj1', envVarKey: 'GITHUB_TOKEN', credentialId: 42, + agentType: null, }); - expect(mockDb.chain.onConflictDoUpdate).toHaveBeenCalled(); }); }); diff --git a/tests/unit/gadgets/tmux.test.ts b/tests/unit/gadgets/tmux.test.ts index fd0bab41..98e038e8 100644 --- a/tests/unit/gadgets/tmux.test.ts +++ b/tests/unit/gadgets/tmux.test.ts @@ -1,6 +1,19 @@ import { resolve } from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { Tmux, resolveWorkingDirectory, validateGitCommand } from '../../../src/gadgets/tmux.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + Tmux, + consumePendingSessionNotices, + resolveWorkingDirectory, + validateGitCommand, +} from '../../../src/gadgets/tmux.js'; +import { CommandFailedError } from '../../../src/gadgets/tmux/errors.js'; +import { addPendingNotice } from '../../../src/gadgets/tmux/sessionNotices.js'; +import { + sanitizeSessionName, + sleep, + stripAnsi, + unescapeOutput, +} from '../../../src/gadgets/tmux/utils.js'; describe('Tmux Gadget', () => { describe('validateGitCommand', () => { @@ -224,4 +237,184 @@ describe('Tmux Gadget', () => { expect(gadget.description).toContain('tmux'); }); }); + + describe('sanitizeSessionName', () => { + it('passes through valid names unchanged', () => { + expect(sanitizeSessionName('test-run')).toBe('test-run'); + expect(sanitizeSessionName('my_session')).toBe('my_session'); + expect(sanitizeSessionName('Run123')).toBe('Run123'); + }); + + it('replaces slashes with dashes', () => { + expect(sanitizeSessionName('feat/my-branch')).toBe('feat-my-branch'); + }); + + it('replaces spaces and special characters', () => { + expect(sanitizeSessionName('my session!')).toBe('my-session-'); + expect(sanitizeSessionName('a@b#c$d')).toBe('a-b-c-d'); + }); + + it('replaces dots with dashes', () => { + expect(sanitizeSessionName('file.test')).toBe('file-test'); + }); + }); + + describe('unescapeOutput', () => { + it('converts octal newline escape to actual newline', () => { + expect(unescapeOutput('hello\\012world')).toBe('hello\nworld'); + }); + + it('converts octal tab escape', () => { + expect(unescapeOutput('col1\\011col2')).toBe('col1\tcol2'); + }); + + it('leaves strings without escapes unchanged', () => { + expect(unescapeOutput('plain text')).toBe('plain text'); + }); + + it('handles multiple octal escapes', () => { + expect(unescapeOutput('a\\012b\\012c')).toBe('a\nb\nc'); + }); + }); + + describe('stripAnsi', () => { + it('strips CSI sequences (colors, bold, etc.)', () => { + expect(stripAnsi('\u001b[31mred\u001b[0m')).toBe('red'); + expect(stripAnsi('\u001b[1;32mbold green\u001b[0m')).toBe('bold green'); + }); + + it('strips OSC sequences (title sets, hyperlinks)', () => { + expect(stripAnsi('\u001b]0;window title\u0007rest')).toBe('rest'); + }); + + it('strips DCS sequences', () => { + expect(stripAnsi('\u001bPsome data\u001b\\rest')).toBe('rest'); + }); + + it('removes carriage returns', () => { + expect(stripAnsi('line1\r\nline2')).toBe('line1\nline2'); + }); + + it('leaves plain text unchanged', () => { + expect(stripAnsi('no escape codes here')).toBe('no escape codes here'); + }); + + it('handles mixed ANSI and plain text', () => { + expect(stripAnsi('before \u001b[32mgreen\u001b[0m after')).toBe('before green after'); + }); + }); + + describe('sleep', () => { + it('resolves after the specified delay', async () => { + vi.useFakeTimers(); + const promise = sleep(100); + vi.advanceTimersByTime(100); + await expect(promise).resolves.toBeUndefined(); + vi.useRealTimers(); + }); + }); + + describe('CommandFailedError', () => { + it('stores session, exitCode, and output', () => { + const err = new CommandFailedError('test-session', 1, 'some output'); + expect(err.session).toBe('test-session'); + expect(err.exitCode).toBe(1); + expect(err.output).toBe('some output'); + }); + + it('has correct name', () => { + const err = new CommandFailedError('s', 1, ''); + expect(err.name).toBe('CommandFailedError'); + }); + + it('includes session and exit code in message', () => { + const err = new CommandFailedError('my-cmd', 127, 'command not found'); + expect(err.message).toContain('Session: my-cmd'); + expect(err.message).toContain('Exit code: 127'); + expect(err.message).toContain('command not found'); + }); + + it('truncates long output to last 1000 chars', () => { + const longOutput = 'x'.repeat(2000); + const err = new CommandFailedError('s', 1, longOutput); + // Preview should be the last 1000 chars + expect(err.message).toContain('x'.repeat(1000)); + expect(err.message).not.toContain('x'.repeat(1001)); + }); + + it('shows "(no output)" for empty output', () => { + const err = new CommandFailedError('s', 1, ''); + expect(err.message).toContain('(no output)'); + }); + + it('is an instance of Error', () => { + const err = new CommandFailedError('s', 1, 'out'); + expect(err).toBeInstanceOf(Error); + }); + }); + + describe('sessionNotices', () => { + afterEach(() => { + // Drain any leftover notices from previous tests + consumePendingSessionNotices(); + }); + + it('addPendingNotice + consumePendingSessionNotices round-trip', () => { + addPendingNotice('sess-1', { exitCode: 0, tailOutput: 'all good' }); + + const notices = consumePendingSessionNotices(); + expect(notices.size).toBe(1); + expect(notices.get('sess-1')).toEqual({ exitCode: 0, tailOutput: 'all good' }); + }); + + it('consume clears the pending notices', () => { + addPendingNotice('sess-2', { exitCode: 1, tailOutput: 'fail' }); + + consumePendingSessionNotices(); // first consume + const second = consumePendingSessionNotices(); // should be empty + expect(second.size).toBe(0); + }); + + it('handles multiple notices', () => { + addPendingNotice('a', { exitCode: 0, tailOutput: 'output-a' }); + addPendingNotice('b', { exitCode: 1, tailOutput: 'output-b' }); + + const notices = consumePendingSessionNotices(); + expect(notices.size).toBe(2); + expect(notices.get('a')?.exitCode).toBe(0); + expect(notices.get('b')?.exitCode).toBe(1); + }); + + it('later notice for same session overwrites earlier one', () => { + addPendingNotice('dup', { exitCode: 0, tailOutput: 'first' }); + addPendingNotice('dup', { exitCode: 1, tailOutput: 'second' }); + + const notices = consumePendingSessionNotices(); + expect(notices.size).toBe(1); + expect(notices.get('dup')).toEqual({ exitCode: 1, tailOutput: 'second' }); + }); + + it('consumePendingSessionNotices returns empty map when nothing pending', () => { + const notices = consumePendingSessionNotices(); + expect(notices.size).toBe(0); + }); + }); + + describe('backward-compat shim re-exports', () => { + it('Tmux is importable from the shim path', () => { + expect(Tmux).toBeDefined(); + }); + + it('consumePendingSessionNotices is importable from the shim path', () => { + expect(typeof consumePendingSessionNotices).toBe('function'); + }); + + it('validateGitCommand is importable from the shim path', () => { + expect(typeof validateGitCommand).toBe('function'); + }); + + it('resolveWorkingDirectory is importable from the shim path', () => { + expect(typeof resolveWorkingDirectory).toBe('function'); + }); + }); }); diff --git a/tests/unit/github/client.test.ts b/tests/unit/github/client.test.ts index b0a1f875..fe893223 100644 --- a/tests/unit/github/client.test.ts +++ b/tests/unit/github/client.test.ts @@ -51,7 +51,7 @@ vi.mock('../../../src/utils/logging.js', () => ({ import { getAuthenticatedUser, - getReviewerUser, + getGitHubUserForToken, githubClient, withGitHubToken, } from '../../../src/github/client.js'; @@ -659,9 +659,9 @@ describe('githubClient', () => { }); }); - describe('getReviewerUser', () => { + describe('getGitHubUserForToken', () => { it('returns null when token is null', async () => { - const result = await getReviewerUser(null); + const result = await getGitHubUserForToken(null); expect(result).toBeNull(); }); @@ -670,7 +670,7 @@ describe('githubClient', () => { data: { login: 'cascade-reviewer' }, }); - const result = await getReviewerUser('reviewer-pat'); + const result = await getGitHubUserForToken('reviewer-pat'); expect(result).toBe('cascade-reviewer'); expect(Octokit).toHaveBeenCalledWith({ auth: 'reviewer-pat' }); }); @@ -678,7 +678,7 @@ describe('githubClient', () => { it('returns null on auth failure', async () => { mockUsers.getAuthenticated.mockRejectedValue(new Error('Bad credentials')); - const result = await getReviewerUser('bad-token'); + const result = await getGitHubUserForToken('bad-token'); expect(result).toBeNull(); }); }); diff --git a/tests/unit/triggers/check-suite-success.test.ts b/tests/unit/triggers/check-suite-success.test.ts index 6bf24485..14d88c53 100644 --- a/tests/unit/triggers/check-suite-success.test.ts +++ b/tests/unit/triggers/check-suite-success.test.ts @@ -9,10 +9,18 @@ vi.mock('../../../src/github/client.js', () => ({ getCheckSuiteStatus: vi.fn(), }, getAuthenticatedUser: vi.fn(), - getReviewerUser: vi.fn(), + getGitHubUserForToken: vi.fn(), })); -import { getAuthenticatedUser, getReviewerUser, githubClient } from '../../../src/github/client.js'; +vi.mock('../../../src/config/provider.js', () => ({ + getAgentCredential: vi.fn().mockResolvedValue(null), +})); + +import { + getAuthenticatedUser, + getGitHubUserForToken, + githubClient, +} from '../../../src/github/client.js'; describe('CheckSuiteSuccessTrigger', () => { const trigger = new CheckSuiteSuccessTrigger(); @@ -50,7 +58,7 @@ describe('CheckSuiteSuccessTrigger', () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(getReviewerUser).mockResolvedValue(null); + vi.mocked(getGitHubUserForToken).mockResolvedValue(null); }); describe('matches', () => { @@ -316,7 +324,7 @@ describe('CheckSuiteSuccessTrigger', () => { }, ]); vi.mocked(getAuthenticatedUser).mockResolvedValue('cascade-bot'); - vi.mocked(getReviewerUser).mockResolvedValue('cascade-reviewer'); + vi.mocked(getGitHubUserForToken).mockResolvedValue('cascade-reviewer'); const ctx: TriggerContext = { project: mockProject, @@ -327,7 +335,7 @@ describe('CheckSuiteSuccessTrigger', () => { const result = await trigger.handle(ctx); expect(result).toBeNull(); - expect(getReviewerUser).toHaveBeenCalled(); + expect(getGitHubUserForToken).toHaveBeenCalled(); expect(githubClient.getCheckSuiteStatus).not.toHaveBeenCalled(); }); @@ -453,7 +461,7 @@ describe('CheckSuiteSuccessTrigger', () => { }, ]); vi.mocked(getAuthenticatedUser).mockResolvedValue('cascade-bot'); - vi.mocked(getReviewerUser).mockResolvedValue('cascade-reviewer'); + vi.mocked(getGitHubUserForToken).mockResolvedValue('cascade-reviewer'); vi.mocked(githubClient.getCheckSuiteStatus).mockResolvedValue({ allPassing: true, totalCount: 1, diff --git a/tools/debug-run.ts b/tools/debug-run.ts index ffdeb5b2..3f87a0a2 100644 --- a/tools/debug-run.ts +++ b/tools/debug-run.ts @@ -8,13 +8,13 @@ * Requires DATABASE_URL to be set. */ +import { getProjectSecrets } from '../src/config/provider.js'; import { closeDb } from '../src/db/client.js'; import { findProjectByIdFromDb, loadConfigFromDb, } from '../src/db/repositories/configRepository.js'; import { getRunById } from '../src/db/repositories/runsRepository.js'; -import { getProjectSecrets } from '../src/db/repositories/secretsRepository.js'; import { withTrelloCredentials } from '../src/trello/client.js'; import { triggerDebugAnalysis } from '../src/triggers/shared/debug-runner.js'; diff --git a/tools/manage-secrets.ts b/tools/manage-secrets.ts index 037213fb..2bbdd61b 100644 --- a/tools/manage-secrets.ts +++ b/tools/manage-secrets.ts @@ -6,8 +6,8 @@ * npx tsx tools/manage-secrets.ts create [--name "..."] [--description "..."] [--default] * npx tsx tools/manage-secrets.ts list * npx tsx tools/manage-secrets.ts delete - * npx tsx tools/manage-secrets.ts set-override - * npx tsx tools/manage-secrets.ts remove-override + * npx tsx tools/manage-secrets.ts set-override [--agent-type ] + * npx tsx tools/manage-secrets.ts remove-override [--agent-type ] * npx tsx tools/manage-secrets.ts resolve * * Requires DATABASE_URL to be set. @@ -20,8 +20,10 @@ import { deleteCredential, listOrgCredentials, listProjectOverrides, + removeAgentCredentialOverride, removeProjectCredentialOverride, resolveAllCredentials, + setAgentCredentialOverride, setProjectCredentialOverride, } from '../src/db/repositories/credentialsRepository.js'; @@ -33,9 +35,11 @@ function printUsage(): void { console.log(' npx tsx tools/manage-secrets.ts list '); console.log(' npx tsx tools/manage-secrets.ts delete '); console.log( - ' npx tsx tools/manage-secrets.ts set-override ', + ' npx tsx tools/manage-secrets.ts set-override [--agent-type ]', + ); + console.log( + ' npx tsx tools/manage-secrets.ts remove-override [--agent-type ]', ); - console.log(' npx tsx tools/manage-secrets.ts remove-override '); console.log(' npx tsx tools/manage-secrets.ts resolve '); } @@ -141,8 +145,16 @@ async function main() { console.error('Error: credential-id must be a number'); process.exit(1); } - await setProjectCredentialOverride(projectId, envVarKey, credId); - console.log(`Set override: project ${projectId} → ${envVarKey} → credential #${credId}`); + const agentType = parseFlag(args, '--agent-type'); + if (agentType) { + await setAgentCredentialOverride(projectId, envVarKey, agentType, credId); + console.log( + `Set agent override: project ${projectId} → ${envVarKey} → credential #${credId} (agent: ${agentType})`, + ); + } else { + await setProjectCredentialOverride(projectId, envVarKey, credId); + console.log(`Set override: project ${projectId} → ${envVarKey} → credential #${credId}`); + } break; } @@ -153,8 +165,16 @@ async function main() { printUsage(); process.exit(1); } - await removeProjectCredentialOverride(projectId, envVarKey); - console.log(`Removed override: project ${projectId} → ${envVarKey}`); + const agentType = parseFlag(args, '--agent-type'); + if (agentType) { + await removeAgentCredentialOverride(projectId, envVarKey, agentType); + console.log( + `Removed agent override: project ${projectId} → ${envVarKey} (agent: ${agentType})`, + ); + } else { + await removeProjectCredentialOverride(projectId, envVarKey); + console.log(`Removed override: project ${projectId} → ${envVarKey}`); + } break; } @@ -172,16 +192,25 @@ async function main() { } const resolved = await resolveAllCredentials(projectId, project.orgId); const overrides = await listProjectOverrides(projectId); - const overrideKeys = new Set(overrides.map((o) => o.envVarKey)); + const projectOverrideKeys = new Set( + overrides.filter((o) => !o.agentType).map((o) => o.envVarKey), + ); + const agentOverrides = overrides.filter((o) => o.agentType); - if (Object.keys(resolved).length === 0) { + if (Object.keys(resolved).length === 0 && agentOverrides.length === 0) { console.log(`No credentials resolved for project ${projectId}`); } else { console.log(`Resolved credentials for project ${projectId} (org: ${project.orgId}):`); for (const [key, value] of Object.entries(resolved)) { - const source = overrideKeys.has(key) ? 'override' : 'org-default'; + const source = projectOverrideKeys.has(key) ? 'override' : 'org-default'; console.log(` ${key}: ${maskValue(value)} [${source}]`); } + if (agentOverrides.length > 0) { + console.log(' Agent-scoped overrides:'); + for (const o of agentOverrides) { + console.log(` ${o.envVarKey} → ${o.credentialName} (agent: ${o.agentType})`); + } + } } break; } diff --git a/tools/resolve-config.ts b/tools/resolve-config.ts index 03bb3914..f637e289 100644 --- a/tools/resolve-config.ts +++ b/tools/resolve-config.ts @@ -23,17 +23,29 @@ import { listProjectOverrides, resolveAllCredentials, } from '../src/db/repositories/credentialsRepository.js'; -import { agentConfigs, cascadeDefaults, projects } from '../src/db/schema/index.js'; +import { + agentConfigs, + cascadeDefaults, + projectIntegrations, + projects, +} from '../src/db/schema/index.js'; function maskValue(value: string): string { if (value.length <= 8) return '****'; return `${value.slice(0, 4)}...${value.slice(-4)}`; } +interface TrelloIntegrationConfig { + boardId: string; + lists: Record; + labels: Record; + customFields?: Record; +} + interface AgentConfigInfo { model: string | null; maxIterations: number | null; - backend: string | null; + agentBackend: string | null; prompt: string | null; } @@ -54,12 +66,7 @@ interface EffectiveConfig { org: AgentConfigInfo | null; project: AgentConfigInfo | null; }; - trello: { - boardId: string; - lists: Record; - labels: Record; - customFields: Record; - }; + trello: TrelloIntegrationConfig | null; credentials: Record; credentialOverrides: { envVarKey: string; credentialId: number; credentialName: string }[]; } @@ -69,45 +76,11 @@ function toInfo(ac: typeof agentConfigs.$inferSelect | null | undefined): AgentC return { model: ac.model, maxIterations: ac.maxIterations, - backend: ac.backend, + agentBackend: ac.agentBackend, prompt: ac.prompt, }; } -function compactRecord(entries: Record): Record { - const result: Record = {}; - for (const [key, value] of Object.entries(entries)) { - if (value) result[key] = value; - } - return result; -} - -function buildTrelloConfig(projectRow: typeof projects.$inferSelect) { - const lists = compactRecord({ - briefing: projectRow.trelloListBriefing, - stories: projectRow.trelloListStories, - planning: projectRow.trelloListPlanning, - todo: projectRow.trelloListTodo, - inProgress: projectRow.trelloListInProgress, - inReview: projectRow.trelloListInReview, - done: projectRow.trelloListDone, - merged: projectRow.trelloListMerged, - debug: projectRow.trelloListDebug, - }); - - const labels = compactRecord({ - readyToProcess: projectRow.trelloLabelReadyToProcess, - processing: projectRow.trelloLabelProcessing, - processed: projectRow.trelloLabelProcessed, - error: projectRow.trelloLabelError, - }); - - const customFields: Record = {}; - if (projectRow.trelloCustomFieldCost) customFields.cost = projectRow.trelloCustomFieldCost; - - return { boardId: projectRow.trelloBoardId, lists, labels, customFields }; -} - function resolveBackend( projectAc: AgentConfigInfo | null, orgAc: AgentConfigInfo | null, @@ -116,9 +89,9 @@ function resolveBackend( orgBackend: string | null, ): string { return ( - projectAc?.backend ?? - orgAc?.backend ?? - globalAc?.backend ?? + projectAc?.agentBackend ?? + orgAc?.agentBackend ?? + globalAc?.agentBackend ?? projectBackendDefault ?? orgBackend ?? 'llmist' @@ -136,25 +109,37 @@ async function resolveEffectiveConfig( const orgId = projectRow.orgId; - const [defaultsRow] = await db - .select() - .from(cascadeDefaults) - .where(eq(cascadeDefaults.orgId, orgId)); - - const globalAcs = await db - .select() - .from(agentConfigs) - .where(and(isNull(agentConfigs.projectId), isNull(agentConfigs.orgId))); - - const orgAcs = await db - .select() - .from(agentConfigs) - .where(and(eq(agentConfigs.orgId, orgId), isNull(agentConfigs.projectId))); + const [ + defaultsRow, + globalAcs, + orgAcs, + projectAcs, + integrations, + credentials, + credentialOverrides, + ] = await Promise.all([ + db + .select() + .from(cascadeDefaults) + .where(eq(cascadeDefaults.orgId, orgId)) + .then((r) => r[0]), + db + .select() + .from(agentConfigs) + .where(and(isNull(agentConfigs.projectId), isNull(agentConfigs.orgId))), + db + .select() + .from(agentConfigs) + .where(and(eq(agentConfigs.orgId, orgId), isNull(agentConfigs.projectId))), + db.select().from(agentConfigs).where(eq(agentConfigs.projectId, projectId)), + db.select().from(projectIntegrations).where(eq(projectIntegrations.projectId, projectId)), + resolveAllCredentials(projectId, orgId), + listProjectOverrides(projectId), + ]); - const projectAcs = await db - .select() - .from(agentConfigs) - .where(eq(agentConfigs.projectId, projectId)); + const trelloConfig = integrations.find((i) => i.type === 'trello')?.config as + | TrelloIntegrationConfig + | undefined; const findByType = (acs: (typeof agentConfigs.$inferSelect)[]) => agentType ? acs.find((ac) => ac.agentType === agentType) : null; @@ -163,9 +148,6 @@ async function resolveEffectiveConfig( const orgAc = toInfo(findByType(orgAcs)); const projectAc = toInfo(findByType(projectAcs)); - const credentials = await resolveAllCredentials(projectId, orgId); - const credentialOverrides = await listProjectOverrides(projectId); - return { projectId, orgId, @@ -189,7 +171,7 @@ async function resolveEffectiveConfig( projectAc, orgAc, globalAc, - projectRow.agentBackendDefault, + projectRow.agentBackend, defaultsRow?.agentBackend ?? null, ), effectivePrompt: projectAc?.prompt ?? orgAc?.prompt ?? globalAc?.prompt ?? null, @@ -207,13 +189,13 @@ async function resolveEffectiveConfig( projectOverrides: { model: projectRow.model, cardBudgetUsd: projectRow.cardBudgetUsd, - agentBackendDefault: projectRow.agentBackendDefault, + agentBackend: projectRow.agentBackend, subscriptionCostZero: projectRow.subscriptionCostZero, baseBranch: projectRow.baseBranch, branchPrefix: projectRow.branchPrefix, }, agentConfigLayers: { global: globalAc, org: orgAc, project: projectAc }, - trello: buildTrelloConfig(projectRow), + trello: trelloConfig ?? null, credentials, credentialOverrides, }; @@ -241,20 +223,24 @@ function printAgentLayer(name: string, data: AgentConfigInfo | null): void { console.log(` ${name}:`); if (data.model) console.log(` model: ${data.model}`); if (data.maxIterations != null) console.log(` maxIterations: ${data.maxIterations}`); - if (data.backend) console.log(` backend: ${data.backend}`); + if (data.agentBackend) console.log(` agentBackend: ${data.agentBackend}`); if (data.prompt) { const truncated = data.prompt.length > 80 ? `${data.prompt.slice(0, 80)}...` : data.prompt; console.log(` prompt: ${truncated}`); } } -function printTrello(trello: EffectiveConfig['trello']): void { +function printTrello(trello: TrelloIntegrationConfig | null): void { console.log('\n--- Trello ---'); + if (!trello) { + console.log(' (no Trello integration configured)'); + return; + } console.log(` Board ID: ${trello.boardId}`); for (const [section, data] of Object.entries({ Lists: trello.lists, Labels: trello.labels, - 'Custom Fields': trello.customFields, + 'Custom Fields': trello.customFields ?? {}, })) { const entries = Object.entries(data); if (entries.length === 0 && section !== 'Custom Fields') { diff --git a/tools/seed-config-from-json.ts b/tools/seed-config-from-json.ts index 4d8c2810..fd85c096 100644 --- a/tools/seed-config-from-json.ts +++ b/tools/seed-config-from-json.ts @@ -15,7 +15,12 @@ import { sql } from 'drizzle-orm'; import type { z } from 'zod'; import { type CascadeConfigSchema, validateConfig } from '../src/config/schema.js'; import { closeDb, getDb } from '../src/db/client.js'; -import { agentConfigs, cascadeDefaults, projects } from '../src/db/schema/index.js'; +import { + agentConfigs, + cascadeDefaults, + projectIntegrations, + projects, +} from '../src/db/schema/index.js'; type CascadeConfig = z.infer; type ProjectConfig = CascadeConfig['projects'][number]; @@ -30,27 +35,6 @@ for (let i = 0; i < args.length; i++) { } } -function buildTrelloColumns(p: ProjectConfig) { - const l = p.trello.lists; - const lb = p.trello.labels; - return { - trelloListBriefing: l.briefing ?? null, - trelloListStories: l.stories ?? null, - trelloListPlanning: l.planning ?? null, - trelloListTodo: l.todo ?? null, - trelloListInProgress: l.inProgress ?? null, - trelloListInReview: l.inReview ?? null, - trelloListDone: l.done ?? null, - trelloListMerged: l.merged ?? null, - trelloListDebug: l.debug ?? null, - trelloLabelReadyToProcess: lb.readyToProcess ?? null, - trelloLabelProcessing: lb.processing ?? null, - trelloLabelProcessed: lb.processed ?? null, - trelloLabelError: lb.error ?? null, - trelloCustomFieldCost: p.trello.customFields?.cost ?? null, - }; -} - function buildProjectValues(p: ProjectConfig) { return { id: p.id, @@ -58,11 +42,9 @@ function buildProjectValues(p: ProjectConfig) { repo: p.repo, baseBranch: p.baseBranch, branchPrefix: p.branchPrefix, - trelloBoardId: p.trello.boardId, - ...buildTrelloColumns(p), model: p.model ?? null, cardBudgetUsd: p.cardBudgetUsd ? String(p.cardBudgetUsd) : null, - agentBackendDefault: p.agentBackend?.default ?? null, + agentBackend: p.agentBackend?.default ?? null, subscriptionCostZero: p.agentBackend?.subscriptionCostZero ?? false, }; } @@ -71,6 +53,7 @@ async function seedDefaults(d: CascadeConfig['defaults']) { console.log('Inserting defaults...'); const db = getDb(); const values = { + orgId: 'default', model: d.model, maxIterations: d.maxIterations, freshMachineTimeoutMs: d.freshMachineTimeoutMs, @@ -83,9 +66,9 @@ async function seedDefaults(d: CascadeConfig['defaults']) { }; await db .insert(cascadeDefaults) - .values({ id: 'singleton', ...values }) + .values(values) .onConflictDoUpdate({ - target: cascadeDefaults.id, + target: cascadeDefaults.orgId, set: { ...values, updatedAt: new Date() }, }); console.log(' Defaults upserted.'); @@ -130,6 +113,24 @@ async function seedProject(p: ProjectConfig) { console.log(` Project ${p.id} upserted.`); } +async function seedProjectIntegrations(p: ProjectConfig) { + const db = getDb(); + const config = { + boardId: p.trello.boardId, + lists: p.trello.lists, + labels: p.trello.labels, + customFields: p.trello.customFields, + }; + await db + .insert(projectIntegrations) + .values({ projectId: p.id, type: 'trello', config }) + .onConflictDoUpdate({ + target: [projectIntegrations.projectId, projectIntegrations.type], + set: { config: sql`EXCLUDED.config`, updatedAt: new Date() }, + }); + console.log(' Trello integration upserted.'); +} + async function seedProjectAgentConfigs(p: ProjectConfig) { const db = getDb(); const agentTypes = new Set([ @@ -140,17 +141,17 @@ async function seedProjectAgentConfigs(p: ProjectConfig) { for (const agentType of agentTypes) { console.log(` Inserting project agent config: ${agentType}...`); const model = p.agentModels?.[agentType] ?? null; - const backend = p.agentBackend?.overrides?.[agentType] ?? null; + const agentBackend = p.agentBackend?.overrides?.[agentType] ?? null; const prompt = p.prompts?.[agentType] ?? null; // Use raw SQL because the partial unique index (WHERE project_id IS NOT NULL) // can't be expressed via Drizzle's onConflictDoUpdate target await db.execute(sql` - INSERT INTO agent_configs (project_id, agent_type, model, backend, prompt) - VALUES (${p.id}, ${agentType}, ${model}, ${backend}, ${prompt}) + INSERT INTO agent_configs (project_id, agent_type, model, agent_backend, prompt) + VALUES (${p.id}, ${agentType}, ${model}, ${agentBackend}, ${prompt}) ON CONFLICT (project_id, agent_type) WHERE project_id IS NOT NULL DO UPDATE SET model = COALESCE(EXCLUDED.model, agent_configs.model), - backend = COALESCE(EXCLUDED.backend, agent_configs.backend), + agent_backend = COALESCE(EXCLUDED.agent_backend, agent_configs.agent_backend), prompt = COALESCE(EXCLUDED.prompt, agent_configs.prompt), updated_at = NOW() `); @@ -170,6 +171,7 @@ async function main() { for (const p of config.projects) { await seedProject(p); + await seedProjectIntegrations(p); await seedProjectAgentConfigs(p); }