diff --git a/CLAUDE.md b/CLAUDE.md index 37391c5f..b01534df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,7 +108,7 @@ CASCADE stores all project configuration in PostgreSQL (Supabase). The `config/p - `projects` - Per-project config (repo, base branch, budget, backend) - `project_integrations` - Integration configs per project with `category` (pm/scm/email), `provider` (trello/jira/github/imap/gmail), `config` JSONB, and `triggers` JSONB. One PM + one SCM per project (enforced by unique constraint) - `integration_credentials` - Links integration roles to org-scoped credential rows (e.g., `api_key` → credential #5). Roles are provider-specific: trello has `api_key`/`token`, jira has `email`/`api_token`, github has `implementer_token`/`reviewer_token` -- `agent_configs` - Per-agent-type overrides (model, iterations, backend, prompt), scoped globally, per-org, or per-project +- `agent_configs` - Per-agent-type overrides (model, iterations, engine, max_concurrency), project-scoped only (`project_id NOT NULL`) - `credentials` - Org-scoped credentials (API keys, tokens) - `users` - Dashboard users (email, bcrypt password hash, org-scoped) - `sessions` - Session tokens for cookie-based auth (30-day expiry) @@ -453,8 +453,8 @@ cascade org show cascade org update --name "My Org" # Agent Configs -cascade agents list [--project-id ID] -cascade agents create --agent-type implementation --model claude-sonnet-4-5-20250929 [--project-id ID] +cascade agents list --project-id ID +cascade agents create --agent-type implementation --model claude-sonnet-4-5-20250929 --project-id ID cascade agents update --max-iterations 30 cascade agents delete --yes diff --git a/src/agents/shared/modelResolution.ts b/src/agents/shared/modelResolution.ts index 9a27d539..0062022e 100644 --- a/src/agents/shared/modelResolution.ts +++ b/src/agents/shared/modelResolution.ts @@ -59,14 +59,9 @@ export async function resolveModelConfig(options: ResolveModelConfigOptions): Pr } const model = - modelOverride || - project.agentModels?.[configKey] || - project.model || - config.defaults.agentModels?.[configKey] || - config.defaults.model; + modelOverride || project.agentModels?.[configKey] || project.model || config.defaults.model; - const maxIterations = - config.defaults.agentIterations?.[configKey] || config.defaults.maxIterations; + const maxIterations = config.defaults.maxIterations; // Resolve task prompt override from definition → undefined (use .eta default) let taskPrompt: string | undefined; diff --git a/src/api/routers/agentConfigs.ts b/src/api/routers/agentConfigs.ts index 06590d78..63c79416 100644 --- a/src/api/routers/agentConfigs.ts +++ b/src/api/routers/agentConfigs.ts @@ -7,24 +7,12 @@ import { createAgentConfig, deleteAgentConfig, listAgentConfigs, - listGlobalAgentConfigs, updateAgentConfig, } from '../../db/repositories/settingsRepository.js'; import { agentConfigs } from '../../db/schema/index.js'; -import type { TRPCContext } from '../trpc.js'; -import { protectedProcedure, publicProcedure, router, superAdminProcedure } from '../trpc.js'; +import { protectedProcedure, publicProcedure, router } from '../trpc.js'; import { verifyProjectOrgAccess } from './_shared/projectAccess.js'; -/** Throws FORBIDDEN when a global config (no org, no project) is modified by a non-superadmin. */ -function assertCanModifyConfig( - config: { orgId: string | null; projectId: string | null }, - ctx: { user: TRPCContext['user'] & object }, -) { - if (!config.orgId && !config.projectId && ctx.user.role !== 'superadmin') { - throw new TRPCError({ code: 'FORBIDDEN', message: 'Superadmin access required' }); - } -} - export const agentConfigsRouter = router({ engines: publicProcedure.query(() => { registerBuiltInEngines(); @@ -32,27 +20,17 @@ export const agentConfigsRouter = router({ }), list: protectedProcedure - .input(z.object({ projectId: z.string().optional() }).optional()) + .input(z.object({ projectId: z.string() })) .query(async ({ ctx, input }) => { - if (input?.projectId) { - // Verify project belongs to org - await verifyProjectOrgAccess(input.projectId, ctx.effectiveOrgId); - return listAgentConfigs({ projectId: input.projectId, orgId: ctx.effectiveOrgId }); - } - return listAgentConfigs({ orgId: ctx.effectiveOrgId }); + // Verify project belongs to org + await verifyProjectOrgAccess(input.projectId, ctx.effectiveOrgId); + return listAgentConfigs({ projectId: input.projectId }); }), - listGlobal: superAdminProcedure.query(async () => { - return listGlobalAgentConfigs(); - }), - - // Allows superadmins to create global configs (no org, no project). - // For other users, orgId defaults to effectiveOrgId. create: protectedProcedure .input( z.object({ - orgId: z.string().nullish(), - projectId: z.string().nullish(), + projectId: z.string(), agentType: z.string().min(1), model: z.string().nullish(), maxIterations: z.number().int().positive().nullish(), @@ -61,26 +39,11 @@ export const agentConfigsRouter = router({ }), ) .mutation(async ({ ctx, input }) => { - const finalOrgId = - input.orgId === undefined ? (input.projectId ? null : ctx.effectiveOrgId) : input.orgId; - const finalProjectId = input.projectId ?? null; - - // If projectId given, verify ownership - if (finalProjectId) { - await verifyProjectOrgAccess(finalProjectId, ctx.effectiveOrgId); - } - - // Global config (no orgId, no projectId) requires superadmin - if (!finalOrgId && !finalProjectId && ctx.user.role !== 'superadmin') { - throw new TRPCError({ - code: 'FORBIDDEN', - message: 'Superadmin access required for global config', - }); - } + // Verify project ownership + await verifyProjectOrgAccess(input.projectId, ctx.effectiveOrgId); return createAgentConfig({ - orgId: finalOrgId, - projectId: finalProjectId, + projectId: input.projectId, agentType: input.agentType, model: input.model, maxIterations: input.maxIterations, @@ -104,21 +67,14 @@ export const agentConfigsRouter = router({ // Verify ownership const db = getDb(); const [config] = await db - .select({ orgId: agentConfigs.orgId, projectId: agentConfigs.projectId }) + .select({ projectId: agentConfigs.projectId }) .from(agentConfigs) .where(eq(agentConfigs.id, input.id)); if (!config) { throw new TRPCError({ code: 'NOT_FOUND' }); } - assertCanModifyConfig(config, ctx); - // Check org-scoped configs belong to user's org - if (config.orgId && config.orgId !== ctx.effectiveOrgId) { - throw new TRPCError({ code: 'NOT_FOUND' }); - } // Check project-scoped configs belong to user's org - if (config.projectId) { - await verifyProjectOrgAccess(config.projectId, ctx.effectiveOrgId); - } + await verifyProjectOrgAccess(config.projectId, ctx.effectiveOrgId); const { id, ...updates } = input; await updateAgentConfig(id, { @@ -132,19 +88,13 @@ export const agentConfigsRouter = router({ .mutation(async ({ ctx, input }) => { const db = getDb(); const [config] = await db - .select({ orgId: agentConfigs.orgId, projectId: agentConfigs.projectId }) + .select({ projectId: agentConfigs.projectId }) .from(agentConfigs) .where(eq(agentConfigs.id, input.id)); if (!config) { throw new TRPCError({ code: 'NOT_FOUND' }); } - assertCanModifyConfig(config, ctx); - if (config.orgId && config.orgId !== ctx.effectiveOrgId) { - throw new TRPCError({ code: 'NOT_FOUND' }); - } - if (config.projectId) { - await verifyProjectOrgAccess(config.projectId, ctx.effectiveOrgId); - } + await verifyProjectOrgAccess(config.projectId, ctx.effectiveOrgId); await deleteAgentConfig(input.id); }), diff --git a/src/cli/dashboard/agents/create.ts b/src/cli/dashboard/agents/create.ts index 8ecb74f5..6b3fe01b 100644 --- a/src/cli/dashboard/agents/create.ts +++ b/src/cli/dashboard/agents/create.ts @@ -2,7 +2,7 @@ import { Flags } from '@oclif/core'; import { DashboardCommand } from '../_shared/base.js'; export default class AgentsCreate extends DashboardCommand { - static override description = 'Create an agent configuration.'; + static override description = 'Create an agent configuration for a project.'; static override flags = { ...DashboardCommand.baseFlags, @@ -10,7 +10,10 @@ export default class AgentsCreate extends DashboardCommand { description: 'Agent type (e.g. implementation, review)', required: true, }), - 'project-id': Flags.string({ description: 'Scope to specific project' }), + 'project-id': Flags.string({ + description: 'Project ID to scope the config to', + required: true, + }), model: Flags.string({ description: 'Model override' }), 'max-iterations': Flags.integer({ description: 'Max iterations override' }), engine: Flags.string({ description: 'Agent engine override' }), diff --git a/src/cli/dashboard/agents/list.ts b/src/cli/dashboard/agents/list.ts index 43c49670..71422313 100644 --- a/src/cli/dashboard/agents/list.ts +++ b/src/cli/dashboard/agents/list.ts @@ -2,20 +2,20 @@ import { Flags } from '@oclif/core'; import { DashboardCommand } from '../_shared/base.js'; export default class AgentsList extends DashboardCommand { - static override description = 'List agent configurations.'; + static override description = 'List agent configurations for a project.'; static override flags = { ...DashboardCommand.baseFlags, - 'project-id': Flags.string({ description: 'Filter by project ID' }), + 'project-id': Flags.string({ description: 'Project ID to list configs for', required: true }), }; async run(): Promise { const { flags } = await this.parse(AgentsList); try { - const configs = await this.client.agentConfigs.list.query( - flags['project-id'] ? { projectId: flags['project-id'] } : undefined, - ); + const configs = await this.client.agentConfigs.list.query({ + projectId: flags['project-id'], + }); if (flags.json) { this.outputJson(configs); @@ -25,7 +25,7 @@ export default class AgentsList extends DashboardCommand { this.outputTable(configs as unknown as Record[], [ { key: 'id', header: 'ID' }, { key: 'agentType', header: 'Agent Type' }, - { key: 'projectId', header: 'Project', format: (v) => String(v ?? '(org)') }, + { key: 'projectId', header: 'Project' }, { key: 'model', header: 'Model' }, { key: 'maxIterations', header: 'Max Iter' }, { key: 'agentEngine', header: 'Engine' }, diff --git a/src/db/migrations/0036_project_only_agent_configs.sql b/src/db/migrations/0036_project_only_agent_configs.sql new file mode 100644 index 00000000..e9f76ed7 --- /dev/null +++ b/src/db/migrations/0036_project_only_agent_configs.sql @@ -0,0 +1,18 @@ +-- Migration 0036: Remove global and org-level agent configurations +-- Only project-scoped rows (project_id IS NOT NULL) will remain. + +-- Step 1: Delete non-project rows +DELETE FROM agent_configs WHERE project_id IS NULL; + +-- Step 2: Drop the old partial indexes +DROP INDEX IF EXISTS uq_agent_configs_global; +DROP INDEX IF EXISTS uq_agent_configs_with_project; + +-- Step 3: Drop org_id column +ALTER TABLE agent_configs DROP COLUMN IF EXISTS org_id; + +-- Step 4: Make project_id NOT NULL +ALTER TABLE agent_configs ALTER COLUMN project_id SET NOT NULL; + +-- Step 5: Add simple unique constraint on (project_id, agent_type) +ALTER TABLE agent_configs ADD CONSTRAINT uq_agent_configs_project UNIQUE (project_id, agent_type); diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index d36cbaf9..17aab923 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -253,6 +253,13 @@ "when": 1770000000000, "tag": "0035_add_job_id_to_runs", "breakpoints": false + }, + { + "idx": 36, + "version": "7", + "when": 1771000000000, + "tag": "0036_project_only_agent_configs", + "breakpoints": false } ] } diff --git a/src/db/repositories/agentConfigsRepository.ts b/src/db/repositories/agentConfigsRepository.ts index 3f5cc3bb..b6a30002 100644 --- a/src/db/repositories/agentConfigsRepository.ts +++ b/src/db/repositories/agentConfigsRepository.ts @@ -1,66 +1,18 @@ -import { and, eq, isNull, or } from 'drizzle-orm'; +import { and, eq } from 'drizzle-orm'; import { getDb } from '../client.js'; -import { agentConfigs, projects } from '../schema/index.js'; +import { agentConfigs } from '../schema/index.js'; // ============================================================================ // Agent Configs // ============================================================================ -export async function listAgentConfigs(filter?: { orgId?: string; projectId?: string }) { +export async function listAgentConfigs(filter: { projectId: string }) { const db = getDb(); - const conditions = []; - - let orgId = filter?.orgId; - if (filter?.projectId && !orgId) { - const [project] = await db - .select({ orgId: projects.orgId }) - .from(projects) - .where(eq(projects.id, filter.projectId)) - .limit(1); - if (project?.orgId) { - orgId = project.orgId; - } - } - - if (filter?.projectId) { - // Return project-scoped + org-scoped fallback + global fallback - conditions.push( - or( - eq(agentConfigs.projectId, filter.projectId), - and( - isNull(agentConfigs.projectId), - orgId - ? or(eq(agentConfigs.orgId, orgId), isNull(agentConfigs.orgId)) - : isNull(agentConfigs.orgId), - ), - ), - ); - } else if (filter?.orgId) { - // Return global (no orgId, no projectId) + org-scoped (orgId set, no projectId) - conditions.push(or(eq(agentConfigs.orgId, filter.orgId), isNull(agentConfigs.orgId))); - conditions.push(isNull(agentConfigs.projectId)); - } - - if (conditions.length > 0) { - return db - .select() - .from(agentConfigs) - .where(and(...conditions)); - } - return db.select().from(agentConfigs); -} - -export async function listGlobalAgentConfigs() { - const db = getDb(); - return db - .select() - .from(agentConfigs) - .where(and(isNull(agentConfigs.orgId), isNull(agentConfigs.projectId))); + return db.select().from(agentConfigs).where(eq(agentConfigs.projectId, filter.projectId)); } export async function createAgentConfig(data: { - orgId?: string | null; - projectId?: string | null; + projectId: string; agentType: string; model?: string | null; maxIterations?: number | null; @@ -71,8 +23,7 @@ export async function createAgentConfig(data: { const [row] = await db .insert(agentConfigs) .values({ - orgId: data.orgId ?? null, - projectId: data.projectId ?? null, + projectId: data.projectId, agentType: data.agentType, model: data.model, maxIterations: data.maxIterations, @@ -107,8 +58,7 @@ export async function deleteAgentConfig(id: number) { /** * Resolve max_concurrency for a (projectId, agentType) pair. - * Checks project-scoped config first, then org-scoped config. - * Returns null if no config with max_concurrency is found (= no limit). + * Returns null if no project-scoped config with max_concurrency is found (= no limit). * * Results are cached for 5 seconds to avoid repeated DB queries on * sequential webhook batches. @@ -128,63 +78,13 @@ export async function getMaxConcurrency( const db = getDb(); - // 1. Project-scoped config const [projectConfig] = await db .select({ maxConcurrency: agentConfigs.maxConcurrency }) .from(agentConfigs) .where(and(eq(agentConfigs.projectId, projectId), eq(agentConfigs.agentType, agentType))) .limit(1); - if (projectConfig?.maxConcurrency != null) { - maxConcurrencyCache.set(cacheKey, { - value: projectConfig.maxConcurrency, - expiresAt: Date.now() + MAX_CONCURRENCY_TTL_MS, - }); - return projectConfig.maxConcurrency; - } - - // 2. Org-scoped config — need orgId from project - const [project] = await db - .select({ orgId: projects.orgId }) - .from(projects) - .where(eq(projects.id, projectId)) - .limit(1); - - if (project?.orgId) { - const [orgConfig] = await db - .select({ maxConcurrency: agentConfigs.maxConcurrency }) - .from(agentConfigs) - .where( - and( - eq(agentConfigs.orgId, project.orgId), - isNull(agentConfigs.projectId), - eq(agentConfigs.agentType, agentType), - ), - ) - .limit(1); - - if (orgConfig?.maxConcurrency != null) { - maxConcurrencyCache.set(cacheKey, { - value: orgConfig.maxConcurrency, - expiresAt: Date.now() + MAX_CONCURRENCY_TTL_MS, - }); - return orgConfig.maxConcurrency; - } - } - - // 3. Global-scoped fallback - const [globalConfig] = await db - .select({ maxConcurrency: agentConfigs.maxConcurrency }) - .from(agentConfigs) - .where( - and( - isNull(agentConfigs.orgId), - isNull(agentConfigs.projectId), - eq(agentConfigs.agentType, agentType), - ), - ) - .limit(1); - const result = globalConfig?.maxConcurrency ?? null; + const result = projectConfig?.maxConcurrency ?? null; maxConcurrencyCache.set(cacheKey, { value: result, expiresAt: Date.now() + MAX_CONCURRENCY_TTL_MS, diff --git a/src/db/repositories/configMapper.ts b/src/db/repositories/configMapper.ts index 9ecaa323..7684c881 100644 --- a/src/db/repositories/configMapper.ts +++ b/src/db/repositories/configMapper.ts @@ -47,8 +47,7 @@ export interface DefaultsRow { } export interface AgentConfigRow { - orgId: string | null; - projectId: string | null; + projectId: string; agentType: string; model: string | null; maxIterations: number | null; @@ -184,17 +183,10 @@ function buildAgentEngineConfig( // Public mapping functions // --------------------------------------------------------------------------- -export function mapDefaultsRow( - row: DefaultsRow | undefined, - globalAgentConfigs: AgentConfigRow[], -): Record { - const { models, iterations } = buildAgentMaps(globalAgentConfigs); - +export function mapDefaultsRow(row: DefaultsRow | undefined): Record { return { model: row?.model ?? undefined, - agentModels: orUndefined(models), maxIterations: row?.maxIterations ?? undefined, - agentIterations: orUndefined(iterations), watchdogTimeoutMs: row?.watchdogTimeoutMs ?? undefined, workItemBudgetUsd: row?.workItemBudgetUsd ? Number(row.workItemBudgetUsd) : undefined, agentEngine: row?.agentEngine ?? undefined, diff --git a/src/db/repositories/configRepository.ts b/src/db/repositories/configRepository.ts index a270cb59..0b4ff0af 100644 --- a/src/db/repositories/configRepository.ts +++ b/src/db/repositories/configRepository.ts @@ -1,4 +1,4 @@ -import { type SQL, and, eq, isNull, sql } from 'drizzle-orm'; +import { type SQL, eq, sql } from 'drizzle-orm'; import { mergeEngineSettings } from '../../config/engineSettings.js'; import { validateConfig } from '../../config/schema.js'; import type { CascadeConfig, ProjectConfig } from '../../types/index.js'; @@ -19,7 +19,6 @@ import { interface BuildRawConfigOpts { defaultsRow: DefaultsRow | undefined; - globalAgentConfigs: AgentConfigRow[]; projectRows: Array; /** All integration rows for all projects in projectRows */ integrationRows: IntegrationRow[]; @@ -29,7 +28,6 @@ interface BuildRawConfigOpts { function buildRawConfig({ defaultsRow, - globalAgentConfigs, projectRows, integrationRows, projectAgentConfigsMap, @@ -43,7 +41,7 @@ function buildRawConfig({ } return { - defaults: mapDefaultsRow(defaultsRow, globalAgentConfigs), + defaults: mapDefaultsRow(defaultsRow), projects: projectRows.map((row) => { const integrations = integrationsByProject.get(row.id) ?? []; const { trelloConfig, jiraConfig, githubConfig } = extractIntegrationConfigs(integrations); @@ -87,34 +85,16 @@ export async function loadConfigFromDb(): Promise { db.select().from(projectIntegrations), ]); - // 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 - const globalAgentConfigs = allAgentConfigs.filter( - (ac) => ac.projectId === null && ac.orgId === null, - ); - const orgAgentConfigsMap = new Map(); + // All agent configs are project-scoped (project_id IS NOT NULL) after migration 0036 const projectAgentConfigsMap = new Map(); for (const ac of allAgentConfigs) { - if (ac.projectId !== null) { - const existing = projectAgentConfigsMap.get(ac.projectId) ?? []; - existing.push(ac); - projectAgentConfigsMap.set(ac.projectId, existing); - } else if (ac.orgId !== null) { - const existing = orgAgentConfigsMap.get(ac.orgId) ?? []; - existing.push(ac); - orgAgentConfigsMap.set(ac.orgId, existing); - } + const existing = projectAgentConfigsMap.get(ac.projectId) ?? []; + existing.push(ac); + projectAgentConfigsMap.set(ac.projectId, existing); } - // Merge global + org-level agent configs for defaults - const mergedGlobalConfigs = [ - ...globalAgentConfigs, - ...(defaultsRow ? (orgAgentConfigsMap.get(defaultsRow.orgId) ?? []) : []), - ]; - const rawConfig = buildRawConfig({ defaultsRow, - globalAgentConfigs: mergedGlobalConfigs, projectRows, integrationRows: integrationRows as IntegrationRow[], projectAgentConfigsMap, @@ -130,16 +110,8 @@ async function findProjectConfigFromDb( const [row] = await db.select().from(projects).where(whereClause); if (!row) return undefined; - const [projectAcs, orgAcs, globalAcs, defaultsRow, integrations] = await Promise.all([ + const [projectAcs, 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) @@ -152,7 +124,6 @@ async function findProjectConfigFromDb( const rawConfig = buildRawConfig({ defaultsRow, - globalAgentConfigs: [...globalAcs, ...orgAcs], projectRows: [row], integrationRows: integrations as IntegrationRow[], projectAgentConfigsMap, diff --git a/src/db/schema/agentConfigs.ts b/src/db/schema/agentConfigs.ts index 978c32b3..80343c0d 100644 --- a/src/db/schema/agentConfigs.ts +++ b/src/db/schema/agentConfigs.ts @@ -1,13 +1,14 @@ -import { integer, pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core'; -import { organizations } from './organizations.js'; +import { integer, pgTable, serial, text, timestamp, unique } from 'drizzle-orm/pg-core'; 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' }), + // Only project-scoped rows exist; org-level and global rows were removed in migration 0036. + projectId: text('project_id') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), agentType: text('agent_type').notNull(), model: text('model'), maxIterations: integer('max_iterations'), @@ -18,7 +19,5 @@ export const agentConfigs = pgTable( .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 + (t) => [unique('uq_agent_configs_project').on(t.projectId, t.agentType)], ); diff --git a/tests/integration/db/configRepository.test.ts b/tests/integration/db/configRepository.test.ts index 112c496a..734c1341 100644 --- a/tests/integration/db/configRepository.test.ts +++ b/tests/integration/db/configRepository.test.ts @@ -53,52 +53,8 @@ describe('configRepository (integration)', () => { expect(project.trello?.boardId).toBe('board-123'); }); - it('handles multiple projects', async () => { - await seedProject({ id: 'project-2', name: 'Project 2', repo: 'owner/repo2' }); - const config = await loadConfigFromDb(); - expect(config.projects).toHaveLength(2); - expect(config.projects.map((p) => p.id).sort()).toEqual(['project-2', 'test-project']); - }); - - it('applies global agent config model overrides to defaults.agentModels', async () => { - await seedDefaults(); - await seedAgentConfig({ - orgId: null, - projectId: null, - agentType: 'implementation', - model: 'global-impl-model', - }); - const config = await loadConfigFromDb(); - expect(config.defaults.agentModels.implementation).toBe('global-impl-model'); - }); - - it('applies global agent config iteration overrides to defaults.agentIterations', async () => { - await seedDefaults(); - await seedAgentConfig({ - orgId: null, - projectId: null, - agentType: 'implementation', - maxIterations: 25, - }); - const config = await loadConfigFromDb(); - expect(config.defaults.agentIterations.implementation).toBe(25); - }); - - it('applies org-level agent config overrides to defaults.agentModels', async () => { - await seedDefaults(); - await seedAgentConfig({ - orgId: 'test-org', - projectId: null, - agentType: 'review', - model: 'org-review-model', - }); - const config = await loadConfigFromDb(); - expect(config.defaults.agentModels.review).toBe('org-review-model'); - }); - it('applies project-level agent config overrides to project.agentModels', async () => { await seedAgentConfig({ - orgId: null, projectId: 'test-project', agentType: 'implementation', model: 'project-impl-model', @@ -107,6 +63,13 @@ describe('configRepository (integration)', () => { const project = config.projects[0]; expect(project.agentModels?.implementation).toBe('project-impl-model'); }); + + it('handles multiple projects', async () => { + await seedProject({ id: 'project-2', name: 'Project 2', repo: 'owner/repo2' }); + const config = await loadConfigFromDb(); + expect(config.projects).toHaveLength(2); + expect(config.projects.map((p) => p.id).sort()).toEqual(['project-2', 'test-project']); + }); }); // ========================================================================= @@ -217,34 +180,6 @@ describe('configRepository (integration)', () => { }); }); - // ========================================================================= - // Agent config inheritance: global → org → project - // ========================================================================= - - describe('agent config inheritance', () => { - it('project agentModels overrides global agentModels for the same agent', async () => { - await seedDefaults(); - await seedAgentConfig({ - orgId: null, - projectId: null, - agentType: 'implementation', - model: 'global-model', - }); - await seedAgentConfig({ - orgId: null, - projectId: 'test-project', - agentType: 'implementation', - model: 'project-model', - }); - const config = await loadConfigFromDb(); - const project = config.projects[0]; - // Project-level agentModels should take precedence - expect(project.agentModels?.implementation).toBe('project-model'); - // Global-level should be in defaults - expect(config.defaults.agentModels.implementation).toBe('global-model'); - }); - }); - // ========================================================================= // Multi-project config loading // ========================================================================= diff --git a/tests/integration/db/repositories-edge-cases.test.ts b/tests/integration/db/repositories-edge-cases.test.ts index 269aea1d..cee6356e 100644 --- a/tests/integration/db/repositories-edge-cases.test.ts +++ b/tests/integration/db/repositories-edge-cases.test.ts @@ -45,34 +45,14 @@ describe('Database Repository Edge Cases (integration)', () => { }); // ========================================================================= - // Agent Config 4-Level Resolution Cascade + // Agent Config Project-Level Resolution // ========================================================================= - describe('agent config resolution cascade', () => { - it('resolves global → org → project config hierarchy', async () => { + describe('agent config project-level resolution', () => { + it('applies project-level agent config model override', async () => { await seedDefaults(); - // Global (no org, no project) await seedAgentConfig({ - orgId: null, - projectId: null, - agentType: 'implementation', - model: 'global-model', - maxIterations: 10, - }); - - // Org-level (org set, no project) - await seedAgentConfig({ - orgId: 'test-org', - projectId: null, - agentType: 'implementation', - model: 'org-model', - maxIterations: 20, - }); - - // Project-level (project set) - await seedAgentConfig({ - orgId: null, projectId: 'test-project', agentType: 'implementation', model: 'project-model', @@ -80,34 +60,14 @@ describe('Database Repository Edge Cases (integration)', () => { }); const config = await loadConfigFromDb(); - - // Global defaults should reflect global agent config - expect(config.defaults.agentModels.implementation).toBe('org-model'); - - // Project-level config should override const project = config.projects[0]; expect(project.agentModels?.implementation).toBe('project-model'); }); - it('handles multiple agent types with independent overrides', async () => { + it('handles multiple agent types with independent project overrides', async () => { await seedDefaults(); await seedAgentConfig({ - orgId: null, - projectId: null, - agentType: 'implementation', - model: 'global-impl-model', - }); - await seedAgentConfig({ - orgId: null, - projectId: null, - agentType: 'review', - model: 'global-review-model', - }); - - // Project overrides only implementation - await seedAgentConfig({ - orgId: null, projectId: 'test-project', agentType: 'implementation', model: 'project-impl-model', @@ -119,8 +79,6 @@ describe('Database Repository Edge Cases (integration)', () => { expect(project.agentModels?.implementation).toBe('project-impl-model'); // review not overridden at project level expect(project.agentModels?.review).toBeUndefined(); - // Global still has review model - expect(config.defaults.agentModels.review).toBe('global-review-model'); }); }); diff --git a/tests/integration/db/settingsRepository.test.ts b/tests/integration/db/settingsRepository.test.ts index e66bbcd2..64d85a79 100644 --- a/tests/integration/db/settingsRepository.test.ts +++ b/tests/integration/db/settingsRepository.test.ts @@ -309,110 +309,81 @@ describe('settingsRepository (integration)', () => { }); // ========================================================================= - // Agent Configs + // Agent Configs (project-scoped only after migration 0036) // ========================================================================= describe('listAgentConfigs', () => { - it('lists all agent configs when no filter given', async () => { + it('lists agent configs for a project', async () => { await createAgentConfig({ - orgId: null, - projectId: null, + projectId: 'test-project', agentType: 'implementation', - model: 'global-model', + model: 'impl-model', }); await createAgentConfig({ - orgId: 'test-org', - projectId: null, - agentType: 'review', - model: 'org-model', - }); - await createAgentConfig({ - orgId: null, projectId: 'test-project', - agentType: 'planning', - model: 'proj-model', + agentType: 'review', + model: 'review-model', }); - const configs = await listAgentConfigs(); - expect(configs.length).toBeGreaterThanOrEqual(3); + const configs = await listAgentConfigs({ projectId: 'test-project' }); + expect(configs).toHaveLength(2); + expect(configs.every((c) => c.projectId === 'test-project')).toBe(true); }); - it('filters by projectId', async () => { - await createAgentConfig({ - orgId: null, - projectId: 'test-project', - agentType: 'implementation', - model: 'proj-model', - }); - await createAgentConfig({ - orgId: null, - projectId: null, - agentType: 'review', - model: 'global-model', - }); - + it('returns empty list for project with no configs', async () => { const configs = await listAgentConfigs({ projectId: 'test-project' }); - expect(configs.some((c) => c.projectId === 'test-project')).toBe(true); - expect(configs.some((c) => c.projectId === null)).toBe(true); + expect(configs).toHaveLength(0); }); - it('filters by orgId (returns global + org-level configs with null projectId)', async () => { + it('only returns configs for the specified project', async () => { + await seedProject({ id: 'project-2', name: 'Project 2', repo: 'owner/repo2' }); await createAgentConfig({ - orgId: null, - projectId: null, + projectId: 'test-project', agentType: 'implementation', - model: 'global-model', - }); - await createAgentConfig({ - orgId: 'test-org', - projectId: null, - agentType: 'review', - model: 'org-model', + model: 'proj1-model', }); await createAgentConfig({ - orgId: null, - projectId: 'test-project', - agentType: 'planning', - model: 'proj-model', + projectId: 'project-2', + agentType: 'implementation', + model: 'proj2-model', }); - const configs = await listAgentConfigs({ orgId: 'test-org' }); - // Should return configs where projectId is null (global + org-level) - expect(configs.every((c) => c.projectId === null)).toBe(true); + const configs = await listAgentConfigs({ projectId: 'test-project' }); + expect(configs).toHaveLength(1); + expect(configs[0].model).toBe('proj1-model'); }); }); describe('createAgentConfig', () => { - it('creates a global agent config', async () => { + it('creates a project-scoped agent config', async () => { const { id } = await createAgentConfig({ - orgId: null, - projectId: null, + projectId: 'test-project', agentType: 'implementation', model: 'claude-opus-4-5', maxIterations: 30, }); expect(id).toBeGreaterThan(0); + + const configs = await listAgentConfigs({ projectId: 'test-project' }); + expect(configs.find((c) => c.id === id)?.model).toBe('claude-opus-4-5'); }); - it('creates a project-scoped agent config', async () => { + it('creates a config with engine and max concurrency', async () => { const { id } = await createAgentConfig({ - orgId: null, projectId: 'test-project', agentType: 'review', model: 'claude-sonnet', + agentEngine: 'claude-code', + maxConcurrency: 3, }); expect(id).toBeGreaterThan(0); - - const configs = await listAgentConfigs({ projectId: 'test-project' }); - expect(configs.find((c) => c.id === id)?.model).toBe('claude-sonnet'); }); }); describe('updateAgentConfig', () => { it('updates an agent config', async () => { const { id } = await createAgentConfig({ - orgId: null, - projectId: null, + projectId: 'test-project', agentType: 'implementation', model: 'old-model', maxIterations: 10, @@ -420,7 +391,7 @@ describe('settingsRepository (integration)', () => { await updateAgentConfig(id, { model: 'new-model', maxIterations: 20 }); - const configs = await listAgentConfigs(); + const configs = await listAgentConfigs({ projectId: 'test-project' }); const config = configs.find((c) => c.id === id); expect(config?.model).toBe('new-model'); expect(config?.maxIterations).toBe(20); @@ -430,15 +401,14 @@ describe('settingsRepository (integration)', () => { describe('deleteAgentConfig', () => { it('deletes an agent config', async () => { const { id } = await createAgentConfig({ - orgId: null, - projectId: null, + projectId: 'test-project', agentType: 'implementation', model: 'to-delete', }); await deleteAgentConfig(id); - const configs = await listAgentConfigs(); + const configs = await listAgentConfigs({ projectId: 'test-project' }); expect(configs.find((c) => c.id === id)).toBeUndefined(); }); }); diff --git a/tests/integration/helpers/seed.ts b/tests/integration/helpers/seed.ts index 80d6d173..cad00d7c 100644 --- a/tests/integration/helpers/seed.ts +++ b/tests/integration/helpers/seed.ts @@ -149,12 +149,11 @@ export async function seedDefaults( } /** - * Seeds an agent config row. + * Seeds a project-scoped agent config row. */ export async function seedAgentConfig( overrides: { - orgId?: string | null; - projectId?: string | null; + projectId?: string; agentType?: string; model?: string | null; maxIterations?: number | null; @@ -165,8 +164,7 @@ export async function seedAgentConfig( const [row] = await db .insert(agentConfigs) .values({ - orgId: overrides.orgId ?? null, - projectId: overrides.projectId ?? null, + projectId: overrides.projectId ?? 'test-project', agentType: overrides.agentType ?? 'implementation', model: overrides.model ?? null, maxIterations: overrides.maxIterations ?? null, diff --git a/tests/unit/agents/shared/modelResolution.test.ts b/tests/unit/agents/shared/modelResolution.test.ts index e4db5268..3466adc8 100644 --- a/tests/unit/agents/shared/modelResolution.test.ts +++ b/tests/unit/agents/shared/modelResolution.test.ts @@ -248,14 +248,13 @@ describe('resolveModelConfig', () => { expect(result.model).toBe('agent-specific-model'); }); - it('uses configKey for model lookup when provided', async () => { - const config = makeConfig({ - agentModels: { review: 'review-model' }, - }); + it('uses configKey for model lookup when provided (project-level)', async () => { + const config = makeConfig(); + const project = makeProject({ agentModels: { review: 'review-model' } }); const result = await resolveModelConfig({ agentType: 'respond-to-review', - project: makeProject(), + project, config, repoDir: '/tmp/test', configKey: 'review', @@ -392,9 +391,8 @@ describe('resolveModelConfig', () => { expect(result.maxIterations).toBe(42); }); - it('uses agent-specific iterations', async () => { + it('falls back to defaults.maxIterations when no agent-specific config', async () => { const config = makeConfig({ - agentIterations: { splitting: 10 }, maxIterations: 50, }); @@ -405,7 +403,7 @@ describe('resolveModelConfig', () => { repoDir: '/tmp/test', }); - expect(result.maxIterations).toBe(10); + expect(result.maxIterations).toBe(50); }); }); }); diff --git a/tests/unit/api/access-control.test.ts b/tests/unit/api/access-control.test.ts index 90366f8a..5ffef7c5 100644 --- a/tests/unit/api/access-control.test.ts +++ b/tests/unit/api/access-control.test.ts @@ -76,7 +76,7 @@ vi.mock('../../../src/db/client.js', () => ({ vi.mock('../../../src/db/schema/index.js', () => ({ credentials: { id: 'id', orgId: 'org_id', value: 'value' }, projects: { id: 'id', orgId: 'org_id' }, - agentConfigs: { id: 'id', orgId: 'org_id', projectId: 'project_id' }, + agentConfigs: { id: 'id', projectId: 'project_id' }, organizations: { id: 'id', name: 'name' }, cascadeDefaults: { orgId: 'org_id' }, })); diff --git a/tests/unit/api/routers/agentConfigs.test.ts b/tests/unit/api/routers/agentConfigs.test.ts index 94f8090a..4591b02a 100644 --- a/tests/unit/api/routers/agentConfigs.test.ts +++ b/tests/unit/api/routers/agentConfigs.test.ts @@ -1,7 +1,7 @@ import { TRPCError } from '@trpc/server'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { TRPCContext } from '../../../../src/api/trpc.js'; -import { createMockSuperAdmin, createMockUser } from '../../../helpers/factories.js'; +import { createMockUser } from '../../../helpers/factories.js'; const { mockListAgentConfigs, @@ -43,7 +43,7 @@ vi.mock('../../../../src/db/client.js', () => ({ })); vi.mock('../../../../src/db/schema/index.js', () => ({ - agentConfigs: { id: 'id', orgId: 'org_id', projectId: 'project_id' }, + agentConfigs: { id: 'id', projectId: 'project_id' }, projects: { id: 'id', orgId: 'org_id' }, })); @@ -89,17 +89,6 @@ describe('agentConfigsRouter', () => { }); describe('list', () => { - it('lists org-scoped configs when no projectId', async () => { - const configs = [{ id: 1, agentType: 'implementation', model: 'claude-sonnet-4-5-20250929' }]; - mockListAgentConfigs.mockResolvedValue(configs); - const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); - - const result = await caller.list(); - - expect(mockListAgentConfigs).toHaveBeenCalledWith({ orgId: 'org-1' }); - expect(result).toEqual(configs); - }); - it('lists project-scoped configs when projectId provided', async () => { mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); const configs = [{ id: 2, agentType: 'review', projectId: 'proj-1' }]; @@ -108,10 +97,16 @@ describe('agentConfigsRouter', () => { const result = await caller.list({ projectId: 'proj-1' }); - expect(mockListAgentConfigs).toHaveBeenCalledWith({ projectId: 'proj-1', orgId: 'org-1' }); + expect(mockListAgentConfigs).toHaveBeenCalledWith({ projectId: 'proj-1' }); expect(result).toEqual(configs); }); + it('requires projectId', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + // @ts-expect-error: testing missing required param + await expect(caller.list()).rejects.toThrow(); + }); + it('throws NOT_FOUND when project does not belong to org', async () => { mockDbWhere.mockResolvedValue([{ orgId: 'different-org' }]); const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); @@ -132,37 +127,19 @@ describe('agentConfigsRouter', () => { it('throws UNAUTHORIZED when not authenticated', async () => { const caller = createCaller({ user: null, effectiveOrgId: null }); - await expect(caller.list()).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + await expect(caller.list({ projectId: 'proj-1' })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); }); }); describe('create', () => { - it('creates org-scoped config', async () => { - mockCreateAgentConfig.mockResolvedValue({ id: 10 }); - const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); - - const result = await caller.create({ - agentType: 'implementation', - model: 'claude-sonnet-4-5-20250929', - maxIterations: 25, - }); - - expect(mockCreateAgentConfig).toHaveBeenCalledWith({ - orgId: 'org-1', - projectId: null, - agentType: 'implementation', - model: 'claude-sonnet-4-5-20250929', - maxIterations: 25, - }); - expect(result).toEqual({ id: 10 }); - }); - it('creates project-scoped config after verifying ownership', async () => { mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); mockCreateAgentConfig.mockResolvedValue({ id: 11 }); const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); - await caller.create({ + const result = await caller.create({ projectId: 'proj-1', agentType: 'review', agentEngine: 'claude-code', @@ -175,6 +152,7 @@ describe('agentConfigsRouter', () => { agentEngine: 'claude-code', }), ); + expect(result).toEqual({ id: 11 }); }); it('throws NOT_FOUND when project does not belong to org', async () => { @@ -186,69 +164,25 @@ describe('agentConfigsRouter', () => { ).rejects.toMatchObject({ code: 'NOT_FOUND' }); }); - it('allows superadmin to create global config with explicit orgId: null', async () => { - mockCreateAgentConfig.mockResolvedValue({ id: 12 }); - const superAdmin = createMockSuperAdmin(); - const caller = createCaller({ user: superAdmin, effectiveOrgId: superAdmin.orgId }); - - const result = await caller.create({ - orgId: null, - agentType: 'implementation', - model: 'claude-sonnet-4-5-20250929', - }); - - expect(mockCreateAgentConfig).toHaveBeenCalledWith( - expect.objectContaining({ - orgId: null, - projectId: null, - agentType: 'implementation', - }), - ); - expect(result).toEqual({ id: 12 }); - }); - - it('throws FORBIDDEN when non-superadmin tries to create global config', async () => { - const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); - - await expect( - caller.create({ orgId: null, agentType: 'implementation' }), - ).rejects.toMatchObject({ - code: 'FORBIDDEN', - message: 'Superadmin access required for global config', - }); - }); - it('rejects empty agentType', async () => { const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); - await expect(caller.create({ agentType: '' })).rejects.toThrow(); + await expect(caller.create({ projectId: 'proj-1', agentType: '' })).rejects.toThrow(); }); it('throws UNAUTHORIZED when not authenticated', async () => { const caller = createCaller({ user: null, effectiveOrgId: null }); - await expect(caller.create({ agentType: 'test' })).rejects.toMatchObject({ - code: 'UNAUTHORIZED', - }); + await expect(caller.create({ projectId: 'proj-1', agentType: 'test' })).rejects.toMatchObject( + { + code: 'UNAUTHORIZED', + }, + ); }); }); describe('update', () => { - it('updates org-scoped config', async () => { - // First call: find config - mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1', projectId: null }]); - mockUpdateAgentConfig.mockResolvedValue(undefined); - const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); - - await caller.update({ id: 10, model: 'new-model', maxIterations: 30 }); - - expect(mockUpdateAgentConfig).toHaveBeenCalledWith(10, { - model: 'new-model', - maxIterations: 30, - }); - }); - it('updates project-scoped config after verifying project ownership', async () => { // First call: find config - mockDbWhere.mockResolvedValueOnce([{ orgId: null, projectId: 'proj-1' }]); + mockDbWhere.mockResolvedValueOnce([{ projectId: 'proj-1' }]); // Second call: verify project mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1' }]); mockUpdateAgentConfig.mockResolvedValue(undefined); @@ -270,57 +204,17 @@ describe('agentConfigsRouter', () => { }); }); - it('throws NOT_FOUND when org-scoped config belongs to different org', async () => { - mockDbWhere.mockResolvedValue([{ orgId: 'different-org', projectId: null }]); - const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); - - await expect(caller.update({ id: 10, model: 'x' })).rejects.toMatchObject({ - code: 'NOT_FOUND', - }); - }); - it('throws UNAUTHORIZED when not authenticated', async () => { const caller = createCaller({ user: null, effectiveOrgId: null }); await expect(caller.update({ id: 10, model: 'x' })).rejects.toMatchObject({ code: 'UNAUTHORIZED', }); }); - - it('throws FORBIDDEN when non-superadmin updates a global config', async () => { - mockDbWhere.mockResolvedValueOnce([{ orgId: null, projectId: null }]); - const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); - - await expect(caller.update({ id: 10, model: 'x' })).rejects.toMatchObject({ - code: 'FORBIDDEN', - message: 'Superadmin access required', - }); - }); - - it('allows superadmin to update a global config', async () => { - mockDbWhere.mockResolvedValueOnce([{ orgId: null, projectId: null }]); - mockUpdateAgentConfig.mockResolvedValue(undefined); - const superAdmin = createMockSuperAdmin(); - const caller = createCaller({ user: superAdmin, effectiveOrgId: superAdmin.orgId }); - - await caller.update({ id: 10, model: 'global-model' }); - - expect(mockUpdateAgentConfig).toHaveBeenCalledWith(10, { model: 'global-model' }); - }); }); describe('delete', () => { - it('deletes org-scoped config', async () => { - mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1', projectId: null }]); - mockDeleteAgentConfig.mockResolvedValue(undefined); - const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); - - await caller.delete({ id: 10 }); - - expect(mockDeleteAgentConfig).toHaveBeenCalledWith(10); - }); - it('deletes project-scoped config after verifying project ownership', async () => { - mockDbWhere.mockResolvedValueOnce([{ orgId: null, projectId: 'proj-1' }]); + mockDbWhere.mockResolvedValueOnce([{ projectId: 'proj-1' }]); mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1' }]); mockDeleteAgentConfig.mockResolvedValue(undefined); const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); @@ -339,62 +233,15 @@ describe('agentConfigsRouter', () => { }); }); - it('throws NOT_FOUND when org-scoped config belongs to different org', async () => { - mockDbWhere.mockResolvedValue([{ orgId: 'different-org', projectId: null }]); - const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); - - await expect(caller.delete({ id: 10 })).rejects.toMatchObject({ - code: 'NOT_FOUND', - }); - }); - it('throws UNAUTHORIZED when not authenticated', async () => { const caller = createCaller({ user: null, effectiveOrgId: null }); await expect(caller.delete({ id: 10 })).rejects.toMatchObject({ code: 'UNAUTHORIZED', }); }); - - it('throws FORBIDDEN when non-superadmin deletes a global config', async () => { - mockDbWhere.mockResolvedValueOnce([{ orgId: null, projectId: null }]); - const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); - - await expect(caller.delete({ id: 10 })).rejects.toMatchObject({ - code: 'FORBIDDEN', - message: 'Superadmin access required', - }); - }); - - it('allows superadmin to delete a global config', async () => { - mockDbWhere.mockResolvedValueOnce([{ orgId: null, projectId: null }]); - mockDeleteAgentConfig.mockResolvedValue(undefined); - const superAdmin = createMockSuperAdmin(); - const caller = createCaller({ user: superAdmin, effectiveOrgId: superAdmin.orgId }); - - await caller.delete({ id: 10 }); - - expect(mockDeleteAgentConfig).toHaveBeenCalledWith(10); - }); }); describe('create with maxConcurrency', () => { - it('passes maxConcurrency to repository when creating org-scoped config', async () => { - mockCreateAgentConfig.mockResolvedValue({ id: 20 }); - const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); - - await caller.create({ - agentType: 'implementation', - maxConcurrency: 3, - }); - - expect(mockCreateAgentConfig).toHaveBeenCalledWith( - expect.objectContaining({ - agentType: 'implementation', - maxConcurrency: 3, - }), - ); - }); - it('passes maxConcurrency to repository when creating project-scoped config', async () => { mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); mockCreateAgentConfig.mockResolvedValue({ id: 21 }); @@ -417,22 +264,9 @@ describe('agentConfigsRouter', () => { }); describe('update with maxConcurrency', () => { - it('passes maxConcurrency to repository when updating org-scoped config', async () => { - mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1', projectId: null }]); - mockUpdateAgentConfig.mockResolvedValue(undefined); - const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); - - await caller.update({ id: 10, maxConcurrency: 5 }); - - expect(mockUpdateAgentConfig).toHaveBeenCalledWith( - 10, - expect.objectContaining({ maxConcurrency: 5 }), - ); - }); - it('passes maxConcurrency to repository when updating project-scoped config', async () => { // First call: find config - mockDbWhere.mockResolvedValueOnce([{ orgId: null, projectId: 'proj-1' }]); + mockDbWhere.mockResolvedValueOnce([{ projectId: 'proj-1' }]); // Second call: verify project ownership mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1' }]); mockUpdateAgentConfig.mockResolvedValue(undefined); @@ -447,7 +281,8 @@ describe('agentConfigsRouter', () => { }); it('can set maxConcurrency alongside other fields', async () => { - mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1', projectId: null }]); + mockDbWhere.mockResolvedValueOnce([{ projectId: 'proj-1' }]); + mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1' }]); mockUpdateAgentConfig.mockResolvedValue(undefined); const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); diff --git a/tests/unit/db/repositories/agentConfigsRepository.test.ts b/tests/unit/db/repositories/agentConfigsRepository.test.ts index 09f04661..e02fcc1a 100644 --- a/tests/unit/db/repositories/agentConfigsRepository.test.ts +++ b/tests/unit/db/repositories/agentConfigsRepository.test.ts @@ -20,53 +20,15 @@ describe('agentConfigsRepository', () => { beforeEach(() => { mockDb = createMockDb({ withUpsert: true, withThenable: true, withLimit: true }); vi.mocked(getDb).mockReturnValue(mockDb.db as never); - // Reset cache for getMaxConcurrency by clearing the internal map if it were accessible - // Since it's private to the module, we rely on unique projectIds or clearing it somehow. - // For now we'll just use fresh IDs. }); describe('listAgentConfigs', () => { - it('returns all configs when no filter', async () => { - const configs = [{ id: 1, agentType: 'impl' }]; - // No where clause → thenable chain resolves - const fromMock = vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue(configs), - // biome-ignore lint/suspicious/noThenProperty: intentional thenable mock for Drizzle query chains - then: (resolve: (v: unknown) => unknown) => Promise.resolve(configs).then(resolve), - }); - mockDb.db.select.mockReturnValue({ from: fromMock }); - - const result = await listAgentConfigs(); - expect(result).toEqual(configs); - }); - - it('filters by projectId and fetches orgId if not provided', async () => { + it('filters by projectId', async () => { const configs = [{ id: 2, agentType: 'review', projectId: 'p1' }]; - // First call: fetch orgId from projects table - mockDb.chain.where.mockReturnValueOnce({ limit: mockDb.chain.limit }); - mockDb.chain.limit.mockResolvedValueOnce([{ orgId: 'org-1' }]); - // Second call: list configs with the fetched orgId mockDb.chain.where.mockResolvedValueOnce(configs); const result = await listAgentConfigs({ projectId: 'p1' }); expect(result).toEqual(configs); - expect(mockDb.db.select).toHaveBeenCalledTimes(2); - }); - - it('filters by projectId and includes fallbacks when orgId provided', async () => { - const configs = [{ id: 2, agentType: 'review', projectId: 'p1' }]; - mockDb.chain.where.mockResolvedValueOnce(configs); - - const result = await listAgentConfigs({ projectId: 'p1', orgId: 'org-1' }); - expect(result).toEqual(configs); - }); - - it('filters to non-project configs when orgId provided', async () => { - const configs = [{ id: 3, agentType: 'impl', orgId: 'org-1' }]; - mockDb.chain.where.mockResolvedValueOnce(configs); - - const result = await listAgentConfigs({ orgId: 'org-1' }); - expect(result).toEqual(configs); }); }); @@ -75,7 +37,7 @@ describe('agentConfigsRepository', () => { mockDb.chain.returning.mockResolvedValueOnce([{ id: 42 }]); const result = await createAgentConfig({ - orgId: 'org-1', + projectId: 'proj-1', agentType: 'implementation', model: 'test-model', maxIterations: 20, @@ -84,8 +46,7 @@ describe('agentConfigsRepository', () => { expect(result).toEqual({ id: 42 }); expect(mockDb.chain.values).toHaveBeenCalledWith( expect.objectContaining({ - orgId: 'org-1', - projectId: null, + projectId: 'proj-1', agentType: 'implementation', model: 'test-model', maxIterations: 20, @@ -125,28 +86,19 @@ describe('agentConfigsRepository', () => { expect(result).toBe(5); }); - it('falls back to org-scoped limit if project-scoped is not set', async () => { - // First call (project-scoped): return empty + it('returns null when no project config found', async () => { + // Only one DB call now (no org/global fallback) mockDb.chain.limit.mockResolvedValueOnce([]); - // Second call (fetch orgId from project): return org-1 - mockDb.chain.limit.mockResolvedValueOnce([{ orgId: 'org-1' }]); - // Third call (org-scoped): return limit 3 - mockDb.chain.limit.mockResolvedValueOnce([{ maxConcurrency: 3 }]); - const result = await getMaxConcurrency('p-proj-2', 'implementation'); - expect(result).toBe(3); + const result = await getMaxConcurrency('p-proj-unique-1', 'implementation'); + expect(result).toBeNull(); }); - it('falls back to global-scoped limit if project is not found', async () => { - // First call (project-scoped): return empty - mockDb.chain.limit.mockResolvedValueOnce([]); - // Second call (fetch orgId from project): return empty (project not found) - mockDb.chain.limit.mockResolvedValueOnce([]); - // Third call (global-scoped): return limit 1 - mockDb.chain.limit.mockResolvedValueOnce([{ maxConcurrency: 1 }]); + it('returns null when project config has no maxConcurrency', async () => { + mockDb.chain.limit.mockResolvedValueOnce([{ maxConcurrency: null }]); - const result = await getMaxConcurrency('missing-project', 'implementation'); - expect(result).toBe(1); + const result = await getMaxConcurrency('p-proj-unique-2', 'review'); + expect(result).toBeNull(); }); }); }); diff --git a/tests/unit/db/repositories/configMapper.test.ts b/tests/unit/db/repositories/configMapper.test.ts index 79df0836..3f53c985 100644 --- a/tests/unit/db/repositories/configMapper.test.ts +++ b/tests/unit/db/repositories/configMapper.test.ts @@ -151,7 +151,7 @@ describe('mapDefaultsRow', () => { }; it('maps all fields from row', () => { - const result = mapDefaultsRow(defaultsRow, []); + const result = mapDefaultsRow(defaultsRow); expect(result.model).toBe('test-model'); expect(result.maxIterations).toBe(50); expect(result.watchdogTimeoutMs).toBe(1800000); @@ -163,36 +163,20 @@ describe('mapDefaultsRow', () => { }); it('converts workItemBudgetUsd string to number', () => { - const result = mapDefaultsRow({ ...defaultsRow, workItemBudgetUsd: '10.50' }, []); + const result = mapDefaultsRow({ ...defaultsRow, workItemBudgetUsd: '10.50' }); expect(result.workItemBudgetUsd).toBe(10.5); }); it('converts progressIntervalMinutes string to number', () => { - const result = mapDefaultsRow({ ...defaultsRow, progressIntervalMinutes: '15' }, []); + const result = mapDefaultsRow({ ...defaultsRow, progressIntervalMinutes: '15' }); expect(result.progressIntervalMinutes).toBe(15); }); it('handles undefined defaults row gracefully', () => { - const result = mapDefaultsRow(undefined, []); + const result = mapDefaultsRow(undefined); expect(result.model).toBeUndefined(); expect(result.workItemBudgetUsd).toBeUndefined(); }); - - it('builds agentModels and agentIterations from agent configs', () => { - const agentConfigs: AgentConfigRow[] = [ - { - orgId: null, - projectId: null, - agentType: 'review', - model: 'review-model', - maxIterations: 20, - agentEngine: null, - }, - ]; - const result = mapDefaultsRow(defaultsRow, agentConfigs); - expect(result.agentModels).toEqual({ review: 'review-model' }); - expect(result.agentIterations).toEqual({ review: 20 }); - }); }); // --------------------------------------------------------------------------- diff --git a/tests/unit/db/repositories/configRepository.test.ts b/tests/unit/db/repositories/configRepository.test.ts index 6eaf9d08..8529c0cb 100644 --- a/tests/unit/db/repositories/configRepository.test.ts +++ b/tests/unit/db/repositories/configRepository.test.ts @@ -83,21 +83,8 @@ const jiraIntegration = { updatedAt: new Date(), }; -const globalAgentConfig = { - id: 1, - orgId: null, - projectId: null, - agentType: 'review', - model: 'global-review-model', - maxIterations: 30, - agentEngine: null, - createdAt: new Date(), - updatedAt: new Date(), -}; - const projectAgentConfig = { id: 2, - orgId: null, projectId: 'proj1', agentType: 'implementation', model: 'impl-model', @@ -107,18 +94,6 @@ const projectAgentConfig = { updatedAt: new Date(), }; -const orgAgentConfig = { - id: 3, - orgId: 'default', - projectId: null, - agentType: 'splitting', - model: 'org-splitting-model', - maxIterations: 20, - agentEngine: null, - createdAt: new Date(), - updatedAt: new Date(), -}; - // --------------------------------------------------------------------------- // Mock DB helper // @@ -127,7 +102,7 @@ const orgAgentConfig = { // deterministic order within each function. // // loadConfigFromDb order (Promise.all): cascadeDefaults, projects, agentConfigs, integrations -// findProjectFromDb order: projects (initial), then Promise.all: agentConfigs x3, defaults, integrations +// findProjectFromDb order: projects (initial), then Promise.all: agentConfigs, defaults, integrations // --------------------------------------------------------------------------- type QueryResult = Record[]; @@ -231,6 +206,16 @@ describe('configRepository', () => { expect(config.defaults.progressIntervalMinutes).toBe(5); }); + it('defaults have empty agentModels and agentIterations (no global/org configs)', async () => { + const mockDb = createSequentialMockDb([[defaultsRow], [projectRow], [], [trelloIntegration]]); + vi.mocked(getDb).mockReturnValue(mockDb as never); + + const config = await loadConfigFromDb(); + + expect(config.defaults.agentModels).toEqual({}); + expect(config.defaults.agentIterations).toEqual({}); + }); + it('maps agentEngine from project row when set', async () => { const mockDb = createSequentialMockDb([ [defaultsRow], @@ -265,27 +250,6 @@ describe('configRepository', () => { 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', - splitting: 'org-splitting-model', - }); - expect(config.defaults.agentIterations).toEqual({ - review: 30, - splitting: 20, - }); - }); - it('handles multiple projects with separate integrations', async () => { const proj2Integration = { ...trelloIntegration, @@ -360,12 +324,10 @@ describe('configRepository', () => { describe('findProjectByIdFromDb', () => { it('returns project with Trello integration from integrations table', async () => { // findProjectFromDb order: projects (initial), then Promise.all: - // projectAcs, orgAcs, globalAcs, defaults, integrations + // projectAcs, defaults, integrations const mockDb = createSequentialMockDb([ [projectRow], // project lookup [projectAgentConfig], // project agent configs - [], // org agent configs - [globalAgentConfig], // global agent configs [defaultsRow], // defaults [trelloIntegration], // integrations ]); @@ -395,8 +357,6 @@ describe('configRepository', () => { const mockDb = createSequentialMockDb([ [projectRowWithBackend], [projectAgentConfig], // has agentEngine: 'claude-code' - [], - [], [defaultsRow], [{ ...trelloIntegration, projectId: 'proj2' }], ]); @@ -415,8 +375,6 @@ describe('configRepository', () => { const mockDb = createSequentialMockDb([ [projectRow], [projectAgentConfig], // has agentEngine: 'claude-code' - [], - [], [defaultsRow], [trelloIntegration], ]); @@ -430,21 +388,14 @@ describe('configRepository', () => { expect(result && Object.hasOwn(result, 'prompts')).toBe(false); }); - it('runs 5 sub-queries in parallel after initial project lookup', async () => { - const mockDb = createSequentialMockDb([ - [projectRow], - [], - [], - [], - [defaultsRow], - [trelloIntegration], - ]); + it('runs 3 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); + // 1 initial project lookup + 3 parallel sub-queries = 4 select() calls + expect(mockDb.select).toHaveBeenCalledTimes(4); }); it('maps workItemBudgetUsd from DB row (config-layer rename pending)', async () => { @@ -453,8 +404,6 @@ describe('configRepository', () => { const mockDb = createSequentialMockDb([ [projWithBudget], [], - [], - [], [defaultsRow], [trelloIntegration], ]); @@ -471,8 +420,6 @@ describe('configRepository', () => { const mockDb = createSequentialMockDb([ [projectRow], [], - [], - [], [defaultsRow], [trelloIntegration], // has customFields: { cost: 'cf-cost' } ]); @@ -493,14 +440,7 @@ describe('configRepository', () => { }, }; - const mockDb = createSequentialMockDb([ - [projectRow], - [], - [], - [], - [defaultsRow], - [noCustomFields], - ]); + const mockDb = createSequentialMockDb([[projectRow], [], [defaultsRow], [noCustomFields]]); vi.mocked(getDb).mockReturnValue(mockDb as never); const result = await findProjectByIdFromDb('proj1'); @@ -511,14 +451,7 @@ describe('configRepository', () => { describe('findProjectByRepoFromDb', () => { it('returns project found by repo', async () => { - const mockDb = createSequentialMockDb([ - [projectRow], - [], - [], - [], - [defaultsRow], - [trelloIntegration], - ]); + const mockDb = createSequentialMockDb([[projectRow], [], [defaultsRow], [trelloIntegration]]); vi.mocked(getDb).mockReturnValue(mockDb as never); const result = await findProjectByRepoFromDb('owner/repo1'); @@ -543,8 +476,6 @@ describe('configRepository', () => { const mockDb = createSequentialMockDb([ [projectRow], // subquery finds project [], - [], - [], [defaultsRow], [trelloIntegration], ]); diff --git a/tests/unit/db/repositories/settingsRepository.test.ts b/tests/unit/db/repositories/settingsRepository.test.ts index e30044f9..5fb8e2d4 100644 --- a/tests/unit/db/repositories/settingsRepository.test.ts +++ b/tests/unit/db/repositories/settingsRepository.test.ts @@ -277,49 +277,21 @@ describe('settingsRepository', () => { // ============================================================================ describe('listAgentConfigs', () => { - it('returns all configs when no filter', async () => { - const configs = [{ id: 1, agentType: 'impl' }]; - // No where clause → thenable chain resolves - const fromMock = vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue(configs), - // biome-ignore lint/suspicious/noThenProperty: intentional thenable mock for Drizzle query chains - then: (resolve: (v: unknown) => unknown) => Promise.resolve(configs).then(resolve), - }); - mockDb.db.select.mockReturnValue({ from: fromMock }); - - const result = await listAgentConfigs(); - expect(result).toEqual(configs); - }); - - it('filters by projectId and fetches orgId if not provided', async () => { + it('filters by projectId', async () => { const configs = [{ id: 2, agentType: 'review', projectId: 'p1' }]; - // First call (to where): return object with limit - mockDb.chain.where.mockReturnValueOnce({ limit: mockDb.chain.limit }); - // First call (to limit): return the project - mockDb.chain.limit.mockResolvedValueOnce([{ orgId: 'org-1' }]); - // Second call (to where): return the configs mockDb.chain.where.mockResolvedValueOnce(configs); const result = await listAgentConfigs({ projectId: 'p1' }); expect(result).toEqual(configs); - expect(mockDb.db.select).toHaveBeenCalledTimes(2); - }); - - it('filters to non-project configs when orgId provided', async () => { - const configs = [{ id: 3, agentType: 'impl', orgId: 'org-1' }]; - mockDb.chain.where.mockResolvedValueOnce(configs); - - const result = await listAgentConfigs({ orgId: 'org-1' }); - expect(result).toEqual(configs); }); }); describe('createAgentConfig', () => { - it('inserts config and returns id', async () => { + it('inserts project-scoped config and returns id', async () => { mockDb.chain.returning.mockResolvedValueOnce([{ id: 42 }]); const result = await createAgentConfig({ - orgId: 'org-1', + projectId: 'proj-1', agentType: 'implementation', model: 'test-model', maxIterations: 20, @@ -328,8 +300,7 @@ describe('settingsRepository', () => { expect(result).toEqual({ id: 42 }); expect(mockDb.chain.values).toHaveBeenCalledWith( expect.objectContaining({ - orgId: 'org-1', - projectId: null, + projectId: 'proj-1', agentType: 'implementation', model: 'test-model', maxIterations: 20, diff --git a/tools/resolve-config.ts b/tools/resolve-config.ts index 32bf681b..23f70ce1 100644 --- a/tools/resolve-config.ts +++ b/tools/resolve-config.ts @@ -1,14 +1,12 @@ #!/usr/bin/env tsx /** - * Resolve and display the full effective configuration for an agent in a project/org context. + * Resolve and display the full effective configuration for an agent in a project context. * * Merges all configuration layers: * 1. cascade_defaults (org-level global defaults) - * 2. Global agent_configs (org_id IS NULL, project_id IS NULL) - * 3. Org-level agent_configs (org_id set, project_id IS NULL) - * 4. Project-level agent_configs (project_id set) - * 5. Project row overrides (model, workItemBudgetUsd, agentEngine) - * 6. Resolved credentials (integration credentials + org defaults) + * 2. Project-level agent_configs (project_id set) + * 3. Project row overrides (model, workItemBudgetUsd, agentEngine) + * 4. Resolved credentials (integration credentials + org defaults) * * Usage: * npx tsx tools/resolve-config.ts @@ -17,7 +15,7 @@ * Requires DATABASE_URL to be set. */ -import { and, eq, isNull } from 'drizzle-orm'; +import { eq } from 'drizzle-orm'; import { type IntegrationProvider, PROVIDER_CREDENTIAL_ROLES, @@ -50,7 +48,6 @@ interface AgentConfigInfo { model: string | null; maxIterations: number | null; agentEngine: string | null; - prompt: string | null; } interface EffectiveConfig { @@ -62,14 +59,9 @@ interface EffectiveConfig { effectiveModel: string; effectiveMaxIterations: number; effectiveEngine: string; - effectivePrompt: string | null; orgDefaults: Record; projectOverrides: Record; - agentConfigLayers: { - global: AgentConfigInfo | null; - org: AgentConfigInfo | null; - project: AgentConfigInfo | null; - }; + projectAgentConfig: AgentConfigInfo | null; trello: TrelloIntegrationConfig | null; credentials: Record; integrationCredentials: { category: string; provider: string; role: string; value: string }[]; @@ -81,27 +73,9 @@ function toInfo(ac: typeof agentConfigs.$inferSelect | null | undefined): AgentC model: ac.model, maxIterations: ac.maxIterations, agentEngine: ac.agentEngine, - prompt: ac.prompt, }; } -function resolveEngine( - projectAc: AgentConfigInfo | null, - orgAc: AgentConfigInfo | null, - globalAc: AgentConfigInfo | null, - projectEngineDefault: string | null, - orgEngine: string | null, -): string { - return ( - projectAc?.agentEngine ?? - orgAc?.agentEngine ?? - globalAc?.agentEngine ?? - projectEngineDefault ?? - orgEngine ?? - 'llmist' - ); -} - function buildCredentialMap( integrationCreds: { provider: string; role: string; value: string }[], orgCreds: Record, @@ -129,26 +103,17 @@ async function resolveEffectiveConfig( const orgId = projectRow.orgId; - const [defaultsRow, globalAcs, orgAcs, projectAcs, integrations, integrationCreds, orgCreds] = - 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)), - resolveAllIntegrationCredentials(projectId), - resolveAllOrgCredentials(orgId), - ]); + const [defaultsRow, projectAcs, integrations, integrationCreds, orgCreds] = await Promise.all([ + db + .select() + .from(cascadeDefaults) + .where(eq(cascadeDefaults.orgId, orgId)) + .then((r) => r[0]), + db.select().from(agentConfigs).where(eq(agentConfigs.projectId, projectId)), + db.select().from(projectIntegrations).where(eq(projectIntegrations.projectId, projectId)), + resolveAllIntegrationCredentials(projectId), + resolveAllOrgCredentials(orgId), + ]); const credentials = buildCredentialMap(integrationCreds, orgCreds); @@ -159,8 +124,6 @@ async function resolveEffectiveConfig( const findByType = (acs: (typeof agentConfigs.$inferSelect)[]) => agentType ? acs.find((ac) => ac.agentType === agentType) : null; - const globalAc = toInfo(findByType(globalAcs)); - const orgAc = toInfo(findByType(orgAcs)); const projectAc = toInfo(findByType(projectAcs)); return { @@ -171,25 +134,12 @@ async function resolveEffectiveConfig( agentType, effectiveModel: projectAc?.model ?? - orgAc?.model ?? - globalAc?.model ?? projectRow.model ?? defaultsRow?.model ?? 'openrouter:google/gemini-3-flash-preview', - effectiveMaxIterations: - projectAc?.maxIterations ?? - orgAc?.maxIterations ?? - globalAc?.maxIterations ?? - defaultsRow?.maxIterations ?? - 50, - effectiveEngine: resolveEngine( - projectAc, - orgAc, - globalAc, - projectRow.agentEngine, - defaultsRow?.agentEngine ?? null, - ), - effectivePrompt: projectAc?.prompt ?? orgAc?.prompt ?? globalAc?.prompt ?? null, + effectiveMaxIterations: projectAc?.maxIterations ?? defaultsRow?.maxIterations ?? 50, + effectiveEngine: + projectAc?.agentEngine ?? projectRow.agentEngine ?? defaultsRow?.agentEngine ?? 'llmist', orgDefaults: { model: defaultsRow?.model ?? null, maxIterations: defaultsRow?.maxIterations ?? null, @@ -206,7 +156,7 @@ async function resolveEffectiveConfig( baseBranch: projectRow.baseBranch, branchPrefix: projectRow.branchPrefix, }, - agentConfigLayers: { global: globalAc, org: orgAc, project: projectAc }, + projectAgentConfig: projectAc, trello: trelloConfig ?? null, credentials, integrationCredentials: integrationCreds, @@ -236,10 +186,6 @@ function printAgentLayer(name: string, data: AgentConfigInfo | null): void { if (data.model) console.log(` model: ${data.model}`); if (data.maxIterations != null) console.log(` maxIterations: ${data.maxIterations}`); if (data.agentEngine) console.log(` agentEngine: ${data.agentEngine}`); - if (data.prompt) { - const truncated = data.prompt.length > 80 ? `${data.prompt.slice(0, 80)}...` : data.prompt; - console.log(` prompt: ${truncated}`); - } } function printTrello(trello: TrelloIntegrationConfig | null): void { @@ -315,13 +261,10 @@ function printConfig(config: EffectiveConfig): void { ['Model', config.effectiveModel], ['Max iterations', config.effectiveMaxIterations], ['Engine', config.effectiveEngine], - ['Prompt', config.effectivePrompt ?? '(none)'], ]); console.log('\n--- Resolution Chain ---'); - printAgentLayer('Project agent_config', config.agentConfigLayers.project); - printAgentLayer('Org agent_config', config.agentConfigLayers.org); - printAgentLayer('Global agent_config', config.agentConfigLayers.global); + printAgentLayer('Project agent_config', config.projectAgentConfig); } printKeyValueSection('Org Defaults (cascade_defaults)', config.orgDefaults); diff --git a/tools/seed-config-from-json.ts b/tools/seed-config-from-json.ts index cc5ee635..59d018cb 100644 --- a/tools/seed-config-from-json.ts +++ b/tools/seed-config-from-json.ts @@ -11,7 +11,6 @@ */ import { readFileSync } from 'node:fs'; -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'; @@ -82,30 +81,6 @@ async function seedDefaults(d: CascadeConfig['defaults']) { console.log(' Defaults upserted.'); } -async function seedGlobalAgentConfigs(d: CascadeConfig['defaults']) { - const db = getDb(); - const agentTypes = new Set([ - ...Object.keys(d.agentModels ?? {}), - ...Object.keys(d.agentIterations ?? {}), - ]); - for (const agentType of agentTypes) { - console.log(` Inserting global agent config: ${agentType}...`); - const model = d.agentModels?.[agentType] ?? null; - const maxIterations = d.agentIterations?.[agentType] ?? null; - // Use raw SQL because the partial unique index (WHERE project_id IS NULL) - // can't be expressed via Drizzle's onConflictDoUpdate target - await db.execute(sql` - INSERT INTO agent_configs (project_id, agent_type, model, max_iterations) - VALUES (NULL, ${agentType}, ${model}, ${maxIterations}) - ON CONFLICT (agent_type) WHERE project_id IS NULL - DO UPDATE SET - model = COALESCE(EXCLUDED.model, agent_configs.model), - max_iterations = COALESCE(EXCLUDED.max_iterations, agent_configs.max_iterations), - updated_at = NOW() - `); - } -} - async function seedProject(p: ProjectConfig) { console.log(`Inserting project: ${p.id} (${p.name})...`); const db = getDb(); @@ -177,8 +152,6 @@ async function main() { getDb(); // initialize connection await seedDefaults(config.defaults); - await seedGlobalAgentConfigs(config.defaults); - for (const p of config.projects) { await seedProject(p); await seedProjectIntegrations(p); diff --git a/tools/seed-prompts.ts b/tools/seed-prompts.ts index 93d4da70..f51f9579 100644 --- a/tools/seed-prompts.ts +++ b/tools/seed-prompts.ts @@ -1,10 +1,12 @@ #!/usr/bin/env tsx /** - * Seed database with prompt templates and partials from disk .eta files. + * Seed database with prompt partials from disk .eta files. * - * Reads all .eta templates from src/agents/prompts/templates/ and inserts them - * as global agent_configs rows (prompt column). Reads all partials from - * src/agents/prompts/templates/partials/ and inserts them as prompt_partials rows. + * Reads all partials from src/agents/prompts/templates/partials/ and inserts + * them as prompt_partials rows. + * + * Note: Agent prompt templates are now managed via Agent Definitions + * (agent_definitions table), not agent_configs. * * Uses upsert semantics — safe to re-run. * @@ -14,46 +16,9 @@ * Requires DATABASE_URL to be set. */ -import { and, eq, isNull } from 'drizzle-orm'; -import { - getAvailablePartialNames, - getRawPartial, - getRawTemplate, - getValidAgentTypes, -} from '../src/agents/prompts/index.js'; -import { closeDb, getDb } from '../src/db/client.js'; +import { getAvailablePartialNames, getRawPartial } from '../src/agents/prompts/index.js'; +import { closeDb } from '../src/db/client.js'; import { upsertPartial } from '../src/db/repositories/partialsRepository.js'; -import { agentConfigs } from '../src/db/schema/index.js'; - -async function seedTemplates() { - const db = getDb(); - const agentTypes = getValidAgentTypes(); - - console.log(`Seeding ${agentTypes.length} agent prompt templates...`); - - for (const agentType of agentTypes) { - const content = getRawTemplate(agentType); - - // Update-first approach: try updating existing non-project row, insert if none affected. - // The unique constraint uq_agent_configs_global is on (agent_type) WHERE project_id IS NULL, - // so we match any row with this agent_type and no project (regardless of org_id). - const updated = await db - .update(agentConfigs) - .set({ prompt: content, updatedAt: new Date() }) - .where(and(eq(agentConfigs.agentType, agentType), isNull(agentConfigs.projectId))) - .returning({ id: agentConfigs.id }); - - if (updated.length > 0) { - console.log(` Updated: ${agentType}`); - } else { - await db.insert(agentConfigs).values({ - agentType, - prompt: content, - }); - console.log(` Created: ${agentType}`); - } - } -} async function seedPartials() { const partialNames = getAvailablePartialNames(); @@ -69,7 +34,6 @@ async function seedPartials() { async function main() { try { - await seedTemplates(); await seedPartials(); console.log('\nDone.'); } catch (err) { diff --git a/web/src/components/layout/sidebar.tsx b/web/src/components/layout/sidebar.tsx index 7f13b8da..98007306 100644 --- a/web/src/components/layout/sidebar.tsx +++ b/web/src/components/layout/sidebar.tsx @@ -6,7 +6,6 @@ import { Link, useRouterState } from '@tanstack/react-router'; import { Activity, BookOpen, - Bot, Building, FolderGit2, KeyRound, @@ -25,7 +24,6 @@ const mainNav = [{ to: '/' as const, label: 'Runs', icon: Activity }]; const globalNav = [ { to: '/global/runs' as const, label: 'Global Runs', icon: Activity }, { to: '/global/webhook-logs' as const, label: 'Webhook Logs', icon: Zap }, - { to: '/global/agent-configs' as const, label: 'Global Agent Configs', icon: Bot }, { to: '/global/defaults' as const, label: 'Cascade Defaults', icon: SlidersHorizontal }, { to: '/global/definitions' as const, label: 'Agent Definitions', icon: BookOpen }, { to: '/global/organizations' as const, label: 'Organizations', icon: Building }, @@ -34,7 +32,6 @@ const globalNav = [ const settingsNav = [ { to: '/settings/general' as const, label: 'General', icon: Settings }, { to: '/settings/credentials' as const, label: 'Credentials', icon: KeyRound }, - { to: '/settings/agents' as const, label: 'Agent Configs', icon: Bot }, ]; function NavLink({ diff --git a/web/src/components/settings/agent-config-form-dialog.tsx b/web/src/components/settings/agent-config-form-dialog.tsx index 8682d193..23e25cb1 100644 --- a/web/src/components/settings/agent-config-form-dialog.tsx +++ b/web/src/components/settings/agent-config-form-dialog.tsx @@ -20,15 +20,9 @@ interface AgentConfigFormDialogProps { open: boolean; onOpenChange: (open: boolean) => void; config?: AgentConfig; - isGlobalScope?: boolean; } -export function AgentConfigFormDialog({ - open, - onOpenChange, - config, - isGlobalScope = false, -}: AgentConfigFormDialogProps) { +export function AgentConfigFormDialog({ open, onOpenChange, config }: AgentConfigFormDialogProps) { const queryClient = useQueryClient(); const isEdit = !!config && config.id !== 0; const enginesQuery = useQuery(trpc.agentConfigs.engines.queryOptions()); @@ -39,15 +33,14 @@ export function AgentConfigFormDialog({ const [agentEngine, setAgentEngine] = useState(config?.agentEngine ?? ''); const [maxConcurrency, setMaxConcurrency] = useState(config?.maxConcurrency?.toString() ?? ''); - const queryKey = isGlobalScope - ? trpc.agentConfigs.listGlobal.queryOptions().queryKey - : trpc.agentConfigs.list.queryOptions().queryKey; + const queryKey = trpc.agentConfigs.list.queryOptions({ + projectId: config?.projectId ?? '', + }).queryKey; const createMutation = useMutation({ mutationFn: () => trpcClient.agentConfigs.create.mutate({ - orgId: isGlobalScope ? null : config?.orgId, - projectId: config?.projectId, + projectId: config?.projectId as string, agentType, model: model || null, maxIterations: maxIterations ? Number(maxIterations) : null, diff --git a/web/src/components/settings/agent-configs-table.tsx b/web/src/components/settings/agent-configs-table.tsx index 18627d2c..fb4b4c85 100644 --- a/web/src/components/settings/agent-configs-table.tsx +++ b/web/src/components/settings/agent-configs-table.tsx @@ -8,7 +8,6 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog.js'; -import { Badge } from '@/components/ui/badge.js'; import { Table, TableBody, @@ -25,8 +24,7 @@ import { AgentConfigFormDialog } from './agent-config-form-dialog.js'; export interface AgentConfig { id: number; - orgId: string | null; - projectId: string | null; + projectId: string; agentType: string; model: string | null; maxIterations: number | null; @@ -34,10 +32,7 @@ export interface AgentConfig { maxConcurrency: number | null; } -export function AgentConfigsTable({ - configs, - isGlobalScope = false, -}: { configs: AgentConfig[]; isGlobalScope?: boolean }) { +export function AgentConfigsTable({ configs }: { configs: AgentConfig[] }) { const queryClient = useQueryClient(); const [editConfig, setEditConfig] = useState(null); const [deleteConfigId, setDeleteConfigId] = useState(null); @@ -45,13 +40,10 @@ export function AgentConfigsTable({ const deleteMutation = useMutation({ mutationFn: (id: number) => trpcClient.agentConfigs.delete.mutate({ id }), onSuccess: () => { - if (isGlobalScope) { - queryClient.invalidateQueries({ - queryKey: trpc.agentConfigs.listGlobal.queryOptions().queryKey, - }); - } else { - queryClient.invalidateQueries({ queryKey: trpc.agentConfigs.list.queryOptions().queryKey }); - } + queryClient.invalidateQueries({ + queryKey: trpc.agentConfigs.list.queryOptions({ projectId: configs[0]?.projectId ?? '' }) + .queryKey, + }); setDeleteConfigId(null); }, }); @@ -67,14 +59,13 @@ export function AgentConfigsTable({ Max Iterations Max Concurrency Engine - Scope {configs.length === 0 && ( - + No agent configs yet @@ -90,15 +81,6 @@ export function AgentConfigsTable({ {config.maxConcurrency ?? '-'} {config.agentEngine ?? '-'} - - {config.projectId ? ( - Project - ) : config.orgId ? ( - Org - ) : ( - Global - )} -
-
- - {configsQuery.isLoading && ( -
Loading configs...
- )} - - {configsQuery.isError && ( -
- Failed to load configs: {configsQuery.error.message} -
- )} - - {configsQuery.data && } - - - - ); -} - -export const globalAgentConfigsRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/global/agent-configs', - component: GlobalAgentConfigsPage, -}); diff --git a/web/src/routes/route-tree.ts b/web/src/routes/route-tree.ts index 1e4b2492..23dfc676 100644 --- a/web/src/routes/route-tree.ts +++ b/web/src/routes/route-tree.ts @@ -1,5 +1,4 @@ import { rootRoute } from './__root.js'; -import { globalAgentConfigsRoute } from './global/agent-configs.js'; import { globalDefaultsRoute } from './global/defaults.js'; import { globalDefinitionsRoute } from './global/definitions.js'; import { globalOrganizationsRoute } from './global/organizations.js'; @@ -11,7 +10,6 @@ import { projectDetailRoute } from './projects/$projectId.js'; import { projectsIndexRoute } from './projects/index.js'; import { prRunsRoute } from './prs/$projectId.$prNumber.js'; import { runDetailRoute } from './runs/$runId.js'; -import { settingsAgentsRoute } from './settings/agents.js'; import { settingsCredentialsRoute } from './settings/credentials.js'; import { settingsGeneralRoute } from './settings/general.js'; import { workItemRunsRoute } from './work-items/$projectId.$workItemId.js'; @@ -24,8 +22,6 @@ export const routeTree = rootRoute.addChildren([ projectDetailRoute, settingsGeneralRoute, settingsCredentialsRoute, - settingsAgentsRoute, - globalAgentConfigsRoute, globalDefaultsRoute, globalDefinitionsRoute, globalWebhookLogsRoute, diff --git a/web/src/routes/settings/agents.tsx b/web/src/routes/settings/agents.tsx deleted file mode 100644 index abfcbdcb..00000000 --- a/web/src/routes/settings/agents.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { AgentConfigFormDialog } from '@/components/settings/agent-config-form-dialog.js'; -import { AgentConfigsTable } from '@/components/settings/agent-configs-table.js'; -import { trpc } from '@/lib/trpc.js'; -import { useQuery } from '@tanstack/react-query'; -import { createRoute } from '@tanstack/react-router'; -import { useState } from 'react'; -import { rootRoute } from '../__root.js'; - -function AgentConfigsPage() { - const [createOpen, setCreateOpen] = useState(false); - const configsQuery = useQuery(trpc.agentConfigs.list.queryOptions()); - - return ( -
-
-
-

Agent Configs

-

- Global and organization-scoped agent configuration overrides. -

-
- -
- - {configsQuery.isLoading && ( -
Loading agent configs...
- )} - - {configsQuery.isError && ( -
- Failed to load agent configs: {configsQuery.error.message} -
- )} - - {configsQuery.data && } - - -
- ); -} - -export const settingsAgentsRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/settings/agents', - component: AgentConfigsPage, -});