Skip to content

Commit 684ea89

Browse files
aaightCascade Bot
andauthored
feat(config): add per-project container snapshot policy (#1042)
Co-authored-by: Cascade Bot <bot@cascade.dev>
1 parent 4a1397a commit 684ea89

9 files changed

Lines changed: 149 additions & 17 deletions

File tree

src/config/schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ export const ProjectConfigSchema = z.object({
8686
squintDbUrl: z.string().url().optional(),
8787
runLinksEnabled: z.boolean().default(false),
8888
maxInFlightItems: z.number().int().positive().optional(),
89+
snapshotEnabled: z.boolean().optional(),
90+
snapshotTtlMs: z.number().int().positive().optional(),
8991
});
9092

9193
export const CascadeConfigSchema = z.object({
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- Add per-project snapshot policy columns to projects table.
2+
-- NULL means fall back to router-level defaults.
3+
-- snapshot_enabled: when NULL, router default (false) applies.
4+
-- snapshot_ttl_ms: when NULL, router default applies.
5+
6+
ALTER TABLE projects ADD COLUMN snapshot_enabled BOOLEAN DEFAULT NULL;
7+
ALTER TABLE projects ADD COLUMN snapshot_ttl_ms INTEGER DEFAULT NULL;

src/db/migrations/meta/_journal.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,13 @@
323323
"when": 1780000000000,
324324
"tag": "0045_agent_config_prompts",
325325
"breakpoints": false
326+
},
327+
{
328+
"idx": 46,
329+
"version": "7",
330+
"when": 1781000000000,
331+
"tag": "0046_add_snapshot_policy",
332+
"breakpoints": false
326333
}
327334
]
328335
}

src/db/repositories/configMapper.ts

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ export interface ProjectConfigRaw {
8888
agentEngineSettings?: Record<string, EngineSettings>;
8989
runLinksEnabled?: boolean;
9090
maxInFlightItems?: number;
91+
snapshotEnabled?: boolean;
92+
snapshotTtlMs?: number;
9193
trello?: {
9294
boardId: string;
9395
lists: Record<string, string>;
@@ -130,6 +132,8 @@ type ProjectRow = {
130132
agentEngineSettings: EngineSettings | null;
131133
runLinksEnabled: boolean;
132134
maxInFlightItems: number | null;
135+
snapshotEnabled: boolean | null;
136+
snapshotTtlMs: number | null;
133137
};
134138

135139
export function buildAgentMaps(configs: AgentConfigRow[]): {
@@ -190,6 +194,30 @@ function buildAgentEngineConfig(
190194
};
191195
}
192196

197+
function buildBaseProjectFields(row: ProjectRow, pmType: 'trello' | 'jira'): ProjectConfigRaw {
198+
return {
199+
id: row.id,
200+
orgId: row.orgId,
201+
name: row.name,
202+
repo: row.repo ?? undefined,
203+
baseBranch: row.baseBranch ?? 'main',
204+
branchPrefix: row.branchPrefix ?? 'feature/',
205+
pm: { type: pmType },
206+
model: row.model ?? undefined,
207+
maxIterations: row.maxIterations ?? undefined,
208+
watchdogTimeoutMs: row.watchdogTimeoutMs ?? undefined,
209+
progressModel: row.progressModel ?? undefined,
210+
progressIntervalMinutes: numericOrUndefined(row.progressIntervalMinutes),
211+
workItemBudgetUsd: numericOrUndefined(row.workItemBudgetUsd),
212+
engineSettings: row.agentEngineSettings ?? undefined,
213+
squintDbUrl: row.squintDbUrl ?? undefined,
214+
runLinksEnabled: row.runLinksEnabled ?? false,
215+
maxInFlightItems: row.maxInFlightItems ?? undefined,
216+
snapshotEnabled: row.snapshotEnabled ?? undefined,
217+
snapshotTtlMs: row.snapshotTtlMs ?? undefined,
218+
};
219+
}
220+
193221
// ---------------------------------------------------------------------------
194222
// Public mapping functions
195223
// ---------------------------------------------------------------------------
@@ -226,27 +254,11 @@ export function mapProjectRow({
226254
const pmType = jiraConfig ? 'jira' : 'trello';
227255

228256
const project: ProjectConfigRaw = {
229-
id: row.id,
230-
orgId: row.orgId,
231-
name: row.name,
232-
repo: row.repo ?? undefined,
233-
baseBranch: row.baseBranch ?? 'main',
234-
branchPrefix: row.branchPrefix ?? 'feature/',
235-
pm: { type: pmType },
236-
model: row.model ?? undefined,
257+
...buildBaseProjectFields(row, pmType),
237258
agentModels: orUndefined(models),
238-
maxIterations: row.maxIterations ?? undefined,
239-
watchdogTimeoutMs: row.watchdogTimeoutMs ?? undefined,
240-
progressModel: row.progressModel ?? undefined,
241-
progressIntervalMinutes: numericOrUndefined(row.progressIntervalMinutes),
242-
workItemBudgetUsd: numericOrUndefined(row.workItemBudgetUsd),
243-
engineSettings: row.agentEngineSettings ?? undefined,
244259
agentEngineSettings: orUndefined(agentEngineSettingsMap) as
245260
| Record<string, EngineSettings>
246261
| undefined,
247-
squintDbUrl: row.squintDbUrl ?? undefined,
248-
runLinksEnabled: row.runLinksEnabled ?? false,
249-
maxInFlightItems: row.maxInFlightItems ?? undefined,
250262
};
251263

252264
if (trelloConfig) {

src/db/schema/projects.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ export const projects = pgTable(
2727
runLinksEnabled: boolean('run_links_enabled').default(false).notNull(),
2828
maxInFlightItems: integer('max_in_flight_items'),
2929

30+
snapshotEnabled: boolean('snapshot_enabled'),
31+
snapshotTtlMs: integer('snapshot_ttl_ms'),
32+
3033
createdAt: timestamp('created_at').defaultNow(),
3134
updatedAt: timestamp('updated_at')
3235
.defaultNow()

src/router/config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ export interface RouterConfig {
3838
// Used for Trello HMAC which includes the full callback URL in the signature.
3939
// Falls back to deriving from request Host header + path at runtime if not set.
4040
webhookCallbackBaseUrl: string | undefined;
41+
42+
// Snapshot defaults (project-level values override these)
43+
snapshotEnabled: boolean;
44+
snapshotDefaultTtlMs: number;
45+
snapshotMaxCount: number;
46+
snapshotMaxSizeBytes: number;
4147
}
4248

4349
// ---------------------------------------------------------------------------
@@ -120,4 +126,10 @@ export const routerConfig: RouterConfig = {
120126
dockerNetwork: process.env.DOCKER_NETWORK || 'services_default',
121127
emailScheduleIntervalMs: Number(process.env.EMAIL_SCHEDULE_INTERVAL_MS) || 5 * 60 * 1000,
122128
webhookCallbackBaseUrl: process.env.WEBHOOK_CALLBACK_BASE_URL,
129+
130+
// Snapshot defaults — project-level values override these when set
131+
snapshotEnabled: process.env.SNAPSHOT_ENABLED === 'true',
132+
snapshotDefaultTtlMs: Number(process.env.SNAPSHOT_DEFAULT_TTL_MS) || 24 * 60 * 60 * 1000, // 24 hours
133+
snapshotMaxCount: Number(process.env.SNAPSHOT_MAX_COUNT) || 5,
134+
snapshotMaxSizeBytes: Number(process.env.SNAPSHOT_MAX_SIZE_BYTES) || 10 * 1024 * 1024 * 1024, // 10 GB
123135
};

tests/unit/db/repositories/configMapper.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ const baseProjectRow = {
3131
agentEngine: null,
3232
agentEngineSettings: null,
3333
runLinksEnabled: false,
34+
maxInFlightItems: null,
35+
snapshotEnabled: null,
36+
snapshotTtlMs: null,
3437
};
3538

3639
const trelloConfig = {
@@ -346,4 +349,25 @@ describe('mapProjectRow', () => {
346349
const result = mapProjectRow(makeInput({ projectAgentConfigs: agentConfigs }));
347350
expect(Object.hasOwn(result, 'prompts')).toBe(false);
348351
});
352+
353+
it('returns undefined snapshotEnabled and snapshotTtlMs when both are null', () => {
354+
const result = mapProjectRow(makeInput());
355+
expect(result.snapshotEnabled).toBeUndefined();
356+
expect(result.snapshotTtlMs).toBeUndefined();
357+
});
358+
359+
it('maps snapshotEnabled true when set on project row', () => {
360+
const result = mapProjectRow(makeInput({ row: { ...baseProjectRow, snapshotEnabled: true } }));
361+
expect(result.snapshotEnabled).toBe(true);
362+
});
363+
364+
it('maps snapshotEnabled false when explicitly set on project row', () => {
365+
const result = mapProjectRow(makeInput({ row: { ...baseProjectRow, snapshotEnabled: false } }));
366+
expect(result.snapshotEnabled).toBe(false);
367+
});
368+
369+
it('maps snapshotTtlMs when set on project row', () => {
370+
const result = mapProjectRow(makeInput({ row: { ...baseProjectRow, snapshotTtlMs: 3600000 } }));
371+
expect(result.snapshotTtlMs).toBe(3600000);
372+
});
349373
});

tests/unit/db/repositories/configRepository.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,4 +458,53 @@ describe('configRepository', () => {
458458
expect(result).toBeUndefined();
459459
});
460460
});
461+
462+
describe('snapshot config mapping', () => {
463+
it('maps snapshotEnabled and snapshotTtlMs from project row', async () => {
464+
const projectWithSnapshot = {
465+
...projectRow,
466+
snapshotEnabled: true,
467+
snapshotTtlMs: 3600000,
468+
};
469+
const mockDb = createSequentialMockDb([[projectWithSnapshot], [], [trelloIntegration]]);
470+
mockGetDb.mockReturnValue(mockDb as never);
471+
472+
const config = await loadConfigFromDb();
473+
474+
const proj = config.projects[0];
475+
expect(proj.snapshotEnabled).toBe(true);
476+
expect(proj.snapshotTtlMs).toBe(3600000);
477+
});
478+
479+
it('leaves snapshotEnabled and snapshotTtlMs undefined when null in DB', async () => {
480+
const mockDb = createSequentialMockDb([[projectRow], [], [trelloIntegration]]);
481+
mockGetDb.mockReturnValue(mockDb as never);
482+
483+
const config = await loadConfigFromDb();
484+
485+
const proj = config.projects[0];
486+
expect(proj.snapshotEnabled).toBeUndefined();
487+
expect(proj.snapshotTtlMs).toBeUndefined();
488+
});
489+
490+
it('maps snapshotEnabled false when explicitly disabled on project', async () => {
491+
const projectWithSnapshotDisabled = {
492+
...projectRow,
493+
snapshotEnabled: false,
494+
snapshotTtlMs: null,
495+
};
496+
const mockDb = createSequentialMockDb([
497+
[projectWithSnapshotDisabled],
498+
[],
499+
[trelloIntegration],
500+
]);
501+
mockGetDb.mockReturnValue(mockDb as never);
502+
503+
const config = await loadConfigFromDb();
504+
505+
const proj = config.projects[0];
506+
expect(proj.snapshotEnabled).toBe(false);
507+
expect(proj.snapshotTtlMs).toBeUndefined();
508+
});
509+
});
461510
});

tests/unit/router/config.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,22 @@ describe('routerConfig', () => {
4949
it('has default emailScheduleIntervalMs of 5 minutes', () => {
5050
expect(routerConfig.emailScheduleIntervalMs).toBe(5 * 60 * 1000);
5151
});
52+
53+
it('defaults snapshotEnabled to false', () => {
54+
expect(routerConfig.snapshotEnabled).toBe(false);
55+
});
56+
57+
it('defaults snapshotDefaultTtlMs to 24 hours', () => {
58+
expect(routerConfig.snapshotDefaultTtlMs).toBe(24 * 60 * 60 * 1000);
59+
});
60+
61+
it('defaults snapshotMaxCount to 5', () => {
62+
expect(routerConfig.snapshotMaxCount).toBe(5);
63+
});
64+
65+
it('defaults snapshotMaxSizeBytes to 10 GB', () => {
66+
expect(routerConfig.snapshotMaxSizeBytes).toBe(10 * 1024 * 1024 * 1024);
67+
});
5268
});
5369

5470
describe('loadProjectConfig', () => {

0 commit comments

Comments
 (0)