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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 <id> --max-iterations 30
cascade agents delete <id> --yes

Expand Down
9 changes: 2 additions & 7 deletions src/agents/shared/modelResolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
76 changes: 13 additions & 63 deletions src/api/routers/agentConfigs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,52 +7,30 @@ 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();
return getEngineCatalog();
}),

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(),
Expand All @@ -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,
Expand All @@ -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, {
Expand All @@ -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);
}),
Expand Down
7 changes: 5 additions & 2 deletions src/cli/dashboard/agents/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@ 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,
'agent-type': Flags.string({
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' }),
Expand Down
12 changes: 6 additions & 6 deletions src/cli/dashboard/agents/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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);
Expand All @@ -25,7 +25,7 @@ export default class AgentsList extends DashboardCommand {
this.outputTable(configs as unknown as Record<string, unknown>[], [
{ 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' },
Expand Down
18 changes: 18 additions & 0 deletions src/db/migrations/0036_project_only_agent_configs.sql
Original file line number Diff line number Diff line change
@@ -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);
7 changes: 7 additions & 0 deletions src/db/migrations/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
Loading
Loading