From 2ed7682f16dd860cbed3567592d3742b6767814e Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Tue, 24 Mar 2026 17:48:44 +0000 Subject: [PATCH] feat(config): add per-project container snapshot policy --- src/config/schema.ts | 2 + .../migrations/0046_add_snapshot_policy.sql | 7 +++ src/db/migrations/meta/_journal.json | 7 +++ src/db/repositories/configMapper.ts | 46 ++++++++++------- src/db/schema/projects.ts | 3 ++ src/router/config.ts | 12 +++++ .../unit/db/repositories/configMapper.test.ts | 24 +++++++++ .../db/repositories/configRepository.test.ts | 49 +++++++++++++++++++ tests/unit/router/config.test.ts | 16 ++++++ 9 files changed, 149 insertions(+), 17 deletions(-) create mode 100644 src/db/migrations/0046_add_snapshot_policy.sql diff --git a/src/config/schema.ts b/src/config/schema.ts index bada8df7..b4da6f47 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -86,6 +86,8 @@ export const ProjectConfigSchema = z.object({ squintDbUrl: z.string().url().optional(), runLinksEnabled: z.boolean().default(false), maxInFlightItems: z.number().int().positive().optional(), + snapshotEnabled: z.boolean().optional(), + snapshotTtlMs: z.number().int().positive().optional(), }); export const CascadeConfigSchema = z.object({ diff --git a/src/db/migrations/0046_add_snapshot_policy.sql b/src/db/migrations/0046_add_snapshot_policy.sql new file mode 100644 index 00000000..0f5321e2 --- /dev/null +++ b/src/db/migrations/0046_add_snapshot_policy.sql @@ -0,0 +1,7 @@ +-- Add per-project snapshot policy columns to projects table. +-- NULL means fall back to router-level defaults. +-- snapshot_enabled: when NULL, router default (false) applies. +-- snapshot_ttl_ms: when NULL, router default applies. + +ALTER TABLE projects ADD COLUMN snapshot_enabled BOOLEAN DEFAULT NULL; +ALTER TABLE projects ADD COLUMN snapshot_ttl_ms INTEGER DEFAULT NULL; diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 93f39fd7..867cbab9 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -323,6 +323,13 @@ "when": 1780000000000, "tag": "0045_agent_config_prompts", "breakpoints": false + }, + { + "idx": 46, + "version": "7", + "when": 1781000000000, + "tag": "0046_add_snapshot_policy", + "breakpoints": false } ] } diff --git a/src/db/repositories/configMapper.ts b/src/db/repositories/configMapper.ts index 55eb1cff..e481bf86 100644 --- a/src/db/repositories/configMapper.ts +++ b/src/db/repositories/configMapper.ts @@ -88,6 +88,8 @@ export interface ProjectConfigRaw { agentEngineSettings?: Record; runLinksEnabled?: boolean; maxInFlightItems?: number; + snapshotEnabled?: boolean; + snapshotTtlMs?: number; trello?: { boardId: string; lists: Record; @@ -130,6 +132,8 @@ type ProjectRow = { agentEngineSettings: EngineSettings | null; runLinksEnabled: boolean; maxInFlightItems: number | null; + snapshotEnabled: boolean | null; + snapshotTtlMs: number | null; }; export function buildAgentMaps(configs: AgentConfigRow[]): { @@ -190,6 +194,30 @@ function buildAgentEngineConfig( }; } +function buildBaseProjectFields(row: ProjectRow, pmType: 'trello' | 'jira'): ProjectConfigRaw { + return { + id: row.id, + orgId: row.orgId, + name: row.name, + repo: row.repo ?? undefined, + baseBranch: row.baseBranch ?? 'main', + branchPrefix: row.branchPrefix ?? 'feature/', + pm: { type: pmType }, + model: row.model ?? undefined, + maxIterations: row.maxIterations ?? undefined, + watchdogTimeoutMs: row.watchdogTimeoutMs ?? undefined, + progressModel: row.progressModel ?? undefined, + progressIntervalMinutes: numericOrUndefined(row.progressIntervalMinutes), + workItemBudgetUsd: numericOrUndefined(row.workItemBudgetUsd), + engineSettings: row.agentEngineSettings ?? undefined, + squintDbUrl: row.squintDbUrl ?? undefined, + runLinksEnabled: row.runLinksEnabled ?? false, + maxInFlightItems: row.maxInFlightItems ?? undefined, + snapshotEnabled: row.snapshotEnabled ?? undefined, + snapshotTtlMs: row.snapshotTtlMs ?? undefined, + }; +} + // --------------------------------------------------------------------------- // Public mapping functions // --------------------------------------------------------------------------- @@ -226,27 +254,11 @@ export function mapProjectRow({ const pmType = jiraConfig ? 'jira' : 'trello'; const project: ProjectConfigRaw = { - id: row.id, - orgId: row.orgId, - name: row.name, - repo: row.repo ?? undefined, - baseBranch: row.baseBranch ?? 'main', - branchPrefix: row.branchPrefix ?? 'feature/', - pm: { type: pmType }, - model: row.model ?? undefined, + ...buildBaseProjectFields(row, pmType), agentModels: orUndefined(models), - maxIterations: row.maxIterations ?? undefined, - watchdogTimeoutMs: row.watchdogTimeoutMs ?? undefined, - progressModel: row.progressModel ?? undefined, - progressIntervalMinutes: numericOrUndefined(row.progressIntervalMinutes), - workItemBudgetUsd: numericOrUndefined(row.workItemBudgetUsd), - engineSettings: row.agentEngineSettings ?? undefined, agentEngineSettings: orUndefined(agentEngineSettingsMap) as | Record | undefined, - squintDbUrl: row.squintDbUrl ?? undefined, - runLinksEnabled: row.runLinksEnabled ?? false, - maxInFlightItems: row.maxInFlightItems ?? undefined, }; if (trelloConfig) { diff --git a/src/db/schema/projects.ts b/src/db/schema/projects.ts index 8b627af2..532d128f 100644 --- a/src/db/schema/projects.ts +++ b/src/db/schema/projects.ts @@ -27,6 +27,9 @@ export const projects = pgTable( runLinksEnabled: boolean('run_links_enabled').default(false).notNull(), maxInFlightItems: integer('max_in_flight_items'), + snapshotEnabled: boolean('snapshot_enabled'), + snapshotTtlMs: integer('snapshot_ttl_ms'), + createdAt: timestamp('created_at').defaultNow(), updatedAt: timestamp('updated_at') .defaultNow() diff --git a/src/router/config.ts b/src/router/config.ts index e4069712..83029e50 100644 --- a/src/router/config.ts +++ b/src/router/config.ts @@ -38,6 +38,12 @@ export interface RouterConfig { // Used for Trello HMAC which includes the full callback URL in the signature. // Falls back to deriving from request Host header + path at runtime if not set. webhookCallbackBaseUrl: string | undefined; + + // Snapshot defaults (project-level values override these) + snapshotEnabled: boolean; + snapshotDefaultTtlMs: number; + snapshotMaxCount: number; + snapshotMaxSizeBytes: number; } // --------------------------------------------------------------------------- @@ -120,4 +126,10 @@ export const routerConfig: RouterConfig = { dockerNetwork: process.env.DOCKER_NETWORK || 'services_default', emailScheduleIntervalMs: Number(process.env.EMAIL_SCHEDULE_INTERVAL_MS) || 5 * 60 * 1000, webhookCallbackBaseUrl: process.env.WEBHOOK_CALLBACK_BASE_URL, + + // Snapshot defaults — project-level values override these when set + snapshotEnabled: process.env.SNAPSHOT_ENABLED === 'true', + snapshotDefaultTtlMs: Number(process.env.SNAPSHOT_DEFAULT_TTL_MS) || 24 * 60 * 60 * 1000, // 24 hours + snapshotMaxCount: Number(process.env.SNAPSHOT_MAX_COUNT) || 5, + snapshotMaxSizeBytes: Number(process.env.SNAPSHOT_MAX_SIZE_BYTES) || 10 * 1024 * 1024 * 1024, // 10 GB }; diff --git a/tests/unit/db/repositories/configMapper.test.ts b/tests/unit/db/repositories/configMapper.test.ts index b28cc83c..42e0bbc2 100644 --- a/tests/unit/db/repositories/configMapper.test.ts +++ b/tests/unit/db/repositories/configMapper.test.ts @@ -31,6 +31,9 @@ const baseProjectRow = { agentEngine: null, agentEngineSettings: null, runLinksEnabled: false, + maxInFlightItems: null, + snapshotEnabled: null, + snapshotTtlMs: null, }; const trelloConfig = { @@ -346,4 +349,25 @@ describe('mapProjectRow', () => { const result = mapProjectRow(makeInput({ projectAgentConfigs: agentConfigs })); expect(Object.hasOwn(result, 'prompts')).toBe(false); }); + + it('returns undefined snapshotEnabled and snapshotTtlMs when both are null', () => { + const result = mapProjectRow(makeInput()); + expect(result.snapshotEnabled).toBeUndefined(); + expect(result.snapshotTtlMs).toBeUndefined(); + }); + + it('maps snapshotEnabled true when set on project row', () => { + const result = mapProjectRow(makeInput({ row: { ...baseProjectRow, snapshotEnabled: true } })); + expect(result.snapshotEnabled).toBe(true); + }); + + it('maps snapshotEnabled false when explicitly set on project row', () => { + const result = mapProjectRow(makeInput({ row: { ...baseProjectRow, snapshotEnabled: false } })); + expect(result.snapshotEnabled).toBe(false); + }); + + it('maps snapshotTtlMs when set on project row', () => { + const result = mapProjectRow(makeInput({ row: { ...baseProjectRow, snapshotTtlMs: 3600000 } })); + expect(result.snapshotTtlMs).toBe(3600000); + }); }); diff --git a/tests/unit/db/repositories/configRepository.test.ts b/tests/unit/db/repositories/configRepository.test.ts index 311f1ac1..16231957 100644 --- a/tests/unit/db/repositories/configRepository.test.ts +++ b/tests/unit/db/repositories/configRepository.test.ts @@ -458,4 +458,53 @@ describe('configRepository', () => { expect(result).toBeUndefined(); }); }); + + describe('snapshot config mapping', () => { + it('maps snapshotEnabled and snapshotTtlMs from project row', async () => { + const projectWithSnapshot = { + ...projectRow, + snapshotEnabled: true, + snapshotTtlMs: 3600000, + }; + const mockDb = createSequentialMockDb([[projectWithSnapshot], [], [trelloIntegration]]); + mockGetDb.mockReturnValue(mockDb as never); + + const config = await loadConfigFromDb(); + + const proj = config.projects[0]; + expect(proj.snapshotEnabled).toBe(true); + expect(proj.snapshotTtlMs).toBe(3600000); + }); + + it('leaves snapshotEnabled and snapshotTtlMs undefined when null in DB', async () => { + const mockDb = createSequentialMockDb([[projectRow], [], [trelloIntegration]]); + mockGetDb.mockReturnValue(mockDb as never); + + const config = await loadConfigFromDb(); + + const proj = config.projects[0]; + expect(proj.snapshotEnabled).toBeUndefined(); + expect(proj.snapshotTtlMs).toBeUndefined(); + }); + + it('maps snapshotEnabled false when explicitly disabled on project', async () => { + const projectWithSnapshotDisabled = { + ...projectRow, + snapshotEnabled: false, + snapshotTtlMs: null, + }; + const mockDb = createSequentialMockDb([ + [projectWithSnapshotDisabled], + [], + [trelloIntegration], + ]); + mockGetDb.mockReturnValue(mockDb as never); + + const config = await loadConfigFromDb(); + + const proj = config.projects[0]; + expect(proj.snapshotEnabled).toBe(false); + expect(proj.snapshotTtlMs).toBeUndefined(); + }); + }); }); diff --git a/tests/unit/router/config.test.ts b/tests/unit/router/config.test.ts index 3d0040b7..6911d975 100644 --- a/tests/unit/router/config.test.ts +++ b/tests/unit/router/config.test.ts @@ -49,6 +49,22 @@ describe('routerConfig', () => { it('has default emailScheduleIntervalMs of 5 minutes', () => { expect(routerConfig.emailScheduleIntervalMs).toBe(5 * 60 * 1000); }); + + it('defaults snapshotEnabled to false', () => { + expect(routerConfig.snapshotEnabled).toBe(false); + }); + + it('defaults snapshotDefaultTtlMs to 24 hours', () => { + expect(routerConfig.snapshotDefaultTtlMs).toBe(24 * 60 * 60 * 1000); + }); + + it('defaults snapshotMaxCount to 5', () => { + expect(routerConfig.snapshotMaxCount).toBe(5); + }); + + it('defaults snapshotMaxSizeBytes to 10 GB', () => { + expect(routerConfig.snapshotMaxSizeBytes).toBe(10 * 1024 * 1024 * 1024); + }); }); describe('loadProjectConfig', () => {