From 7210e25e3366caaba8fc9ff8a1927e8a30ee4257 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Tue, 24 Mar 2026 18:44:08 +0000 Subject: [PATCH 1/2] feat(projects): expose snapshot config across API, CLI, and dashboard --- src/api/routers/projects.ts | 4 + src/cli/dashboard/projects/create.ts | 11 ++ src/cli/dashboard/projects/update.ts | 11 ++ src/db/repositories/projectsRepository.ts | 6 + tests/unit/api/routers/projects.test.ts | 83 +++++++++++ .../cli/dashboard/projects/projects.test.ts | 137 ++++++++++++++++++ .../projects/project-general-form.tsx | 70 ++++++++- 7 files changed, 321 insertions(+), 1 deletion(-) diff --git a/src/api/routers/projects.ts b/src/api/routers/projects.ts index 6a2f28f3..013812ac 100644 --- a/src/api/routers/projects.ts +++ b/src/api/routers/projects.ts @@ -116,6 +116,8 @@ export const projectsRouter = router({ progressIntervalMinutes: z.string().nullish(), runLinksEnabled: z.boolean().optional(), maxInFlightItems: z.number().int().positive().nullish(), + snapshotEnabled: z.boolean().nullish(), + snapshotTtlMs: z.number().int().positive().nullish(), }), ) .mutation(async ({ ctx, input }) => { @@ -144,6 +146,8 @@ export const projectsRouter = router({ progressIntervalMinutes: z.string().nullish(), runLinksEnabled: z.boolean().optional(), maxInFlightItems: z.number().int().positive().nullish(), + snapshotEnabled: z.boolean().nullish(), + snapshotTtlMs: z.number().int().positive().nullish(), }), ) .mutation(async ({ ctx, input }) => { diff --git a/src/cli/dashboard/projects/create.ts b/src/cli/dashboard/projects/create.ts index cae27e36..639072e8 100644 --- a/src/cli/dashboard/projects/create.ts +++ b/src/cli/dashboard/projects/create.ts @@ -21,6 +21,13 @@ export default class ProjectsCreate extends DashboardCommand { 'max-in-flight-items': Flags.integer({ description: 'Max in-flight items (pipeline throughput)', }), + 'snapshot-enabled': Flags.boolean({ + description: 'Enable container snapshots for this project', + allowNo: true, + }), + 'snapshot-ttl': Flags.integer({ + description: 'Container snapshot TTL (ms)', + }), }; async run(): Promise { @@ -42,6 +49,10 @@ export default class ProjectsCreate extends DashboardCommand { progressModel: flags['progress-model'], progressIntervalMinutes: flags['progress-interval'], maxInFlightItems: flags['max-in-flight-items'], + ...(flags['snapshot-enabled'] !== undefined + ? { snapshotEnabled: flags['snapshot-enabled'] } + : {}), + ...(flags['snapshot-ttl'] !== undefined ? { snapshotTtlMs: flags['snapshot-ttl'] } : {}), }), ); diff --git a/src/cli/dashboard/projects/update.ts b/src/cli/dashboard/projects/update.ts index cf78ea7c..b8e75d35 100644 --- a/src/cli/dashboard/projects/update.ts +++ b/src/cli/dashboard/projects/update.ts @@ -28,6 +28,13 @@ export default class ProjectsUpdate extends DashboardCommand { 'max-in-flight-items': Flags.integer({ description: 'Max in-flight items (pipeline throughput)', }), + 'snapshot-enabled': Flags.boolean({ + description: 'Enable container snapshots for this project', + allowNo: true, + }), + 'snapshot-ttl': Flags.integer({ + description: 'Container snapshot TTL (ms)', + }), }; async run(): Promise { @@ -54,6 +61,10 @@ export default class ProjectsUpdate extends DashboardCommand { ...(flags['max-in-flight-items'] !== undefined ? { maxInFlightItems: flags['max-in-flight-items'] } : {}), + ...(flags['snapshot-enabled'] !== undefined + ? { snapshotEnabled: flags['snapshot-enabled'] } + : {}), + ...(flags['snapshot-ttl'] !== undefined ? { snapshotTtlMs: flags['snapshot-ttl'] } : {}), }), ); diff --git a/src/db/repositories/projectsRepository.ts b/src/db/repositories/projectsRepository.ts index 2c005085..8fb43d5e 100644 --- a/src/db/repositories/projectsRepository.ts +++ b/src/db/repositories/projectsRepository.ts @@ -44,6 +44,8 @@ export async function createProject( progressIntervalMinutes?: string | null; runLinksEnabled?: boolean; maxInFlightItems?: number | null; + snapshotEnabled?: boolean | null; + snapshotTtlMs?: number | null; }, ) { const db = getDb(); @@ -66,6 +68,8 @@ export async function createProject( progressIntervalMinutes: rest.progressIntervalMinutes, runLinksEnabled: rest.runLinksEnabled ?? false, maxInFlightItems: rest.maxInFlightItems, + snapshotEnabled: rest.snapshotEnabled, + snapshotTtlMs: rest.snapshotTtlMs, ...(engineSettings !== undefined ? { agentEngineSettings: normalizeEngineSettings(engineSettings) } : {}), @@ -92,6 +96,8 @@ export async function updateProject( progressIntervalMinutes?: string | null; runLinksEnabled?: boolean; maxInFlightItems?: number | null; + snapshotEnabled?: boolean | null; + snapshotTtlMs?: number | null; }, ) { const db = getDb(); diff --git a/tests/unit/api/routers/projects.test.ts b/tests/unit/api/routers/projects.test.ts index fde2b880..cfd1b1a3 100644 --- a/tests/unit/api/routers/projects.test.ts +++ b/tests/unit/api/routers/projects.test.ts @@ -253,6 +253,49 @@ describe('projectsRouter', () => { }), ).rejects.toThrow('Unsupported engine settings'); }); + + it('passes snapshotEnabled and snapshotTtlMs through on create', async () => { + const created = { + id: 'snap-project', + orgId: 'org-1', + name: 'Snap Project', + snapshotEnabled: true, + snapshotTtlMs: 3600000, + }; + mockCreateProject.mockResolvedValue(created); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + const result = await caller.create({ + id: 'snap-project', + name: 'Snap Project', + repo: 'owner/repo', + snapshotEnabled: true, + snapshotTtlMs: 3600000, + }); + + expect(mockCreateProject).toHaveBeenCalledWith( + 'org-1', + expect.objectContaining({ snapshotEnabled: true, snapshotTtlMs: 3600000 }), + ); + expect(result).toEqual(created); + }); + + it('accepts null snapshot fields on create (clears overrides)', async () => { + mockCreateProject.mockResolvedValue({ id: 'p', orgId: 'org-1', name: 'P' }); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await caller.create({ + id: 'p', + name: 'P', + snapshotEnabled: null, + snapshotTtlMs: null, + }); + + expect(mockCreateProject).toHaveBeenCalledWith( + 'org-1', + expect.objectContaining({ snapshotEnabled: null, snapshotTtlMs: null }), + ); + }); }); describe('update', () => { @@ -307,6 +350,46 @@ describe('projectsRouter', () => { ).rejects.toThrow('Unsupported engine settings'); expect(mockUpdateProject).not.toHaveBeenCalled(); }); + + it('passes snapshotEnabled and snapshotTtlMs through on update', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockUpdateProject.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await caller.update({ id: 'p1', snapshotEnabled: true, snapshotTtlMs: 7200000 }); + + expect(mockUpdateProject).toHaveBeenCalledWith( + 'p1', + 'org-1', + expect.objectContaining({ snapshotEnabled: true, snapshotTtlMs: 7200000 }), + ); + }); + + it('accepts null snapshot fields on update (clears overrides)', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockUpdateProject.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await caller.update({ id: 'p1', snapshotEnabled: null, snapshotTtlMs: null }); + + expect(mockUpdateProject).toHaveBeenCalledWith( + 'p1', + 'org-1', + expect.objectContaining({ snapshotEnabled: null, snapshotTtlMs: null }), + ); + }); + + it('does not include snapshot fields when absent from update input', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockUpdateProject.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await caller.update({ id: 'p1', name: 'New Name' }); + + const callArg = (mockUpdateProject as ReturnType).mock.calls[0][2]; + expect(callArg).not.toHaveProperty('snapshotEnabled'); + expect(callArg).not.toHaveProperty('snapshotTtlMs'); + }); }); describe('delete', () => { diff --git a/tests/unit/cli/dashboard/projects/projects.test.ts b/tests/unit/cli/dashboard/projects/projects.test.ts index 6b6013ce..85715756 100644 --- a/tests/unit/cli/dashboard/projects/projects.test.ts +++ b/tests/unit/cli/dashboard/projects/projects.test.ts @@ -394,6 +394,143 @@ describe('ProjectsUpdate (update)', () => { const callArg = (client.projects.update.mutate as ReturnType).mock.calls[0][0]; expect(callArg).not.toHaveProperty('runLinksEnabled'); }); + + it('passes --snapshot-enabled to update mutate as true', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new ProjectsUpdate(['my-project', '--snapshot-enabled'], oclifConfig as never); + await cmd.run(); + + expect(client.projects.update.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'my-project', + snapshotEnabled: true, + }), + ); + }); + + it('passes --no-snapshot-enabled to update mutate as false', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new ProjectsUpdate(['my-project', '--no-snapshot-enabled'], oclifConfig as never); + await cmd.run(); + + expect(client.projects.update.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'my-project', + snapshotEnabled: false, + }), + ); + }); + + it('passes --snapshot-ttl to update mutate', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new ProjectsUpdate( + ['my-project', '--snapshot-ttl', '3600000'], + oclifConfig as never, + ); + await cmd.run(); + + expect(client.projects.update.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'my-project', + snapshotTtlMs: 3600000, + }), + ); + }); + + it('does not include snapshotEnabled when flag is absent', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new ProjectsUpdate( + ['my-project', '--model', 'claude-sonnet-4-5-20250929'], + oclifConfig as never, + ); + await cmd.run(); + + const callArg = (client.projects.update.mutate as ReturnType).mock.calls[0][0]; + expect(callArg).not.toHaveProperty('snapshotEnabled'); + }); +}); + +// --------------------------------------------------------------------------- +// projects create — snapshot flags +// --------------------------------------------------------------------------- +describe('ProjectsCreate (create) — snapshot flags', () => { + beforeEach(() => { + mockLoadConfig.mockReturnValue(baseConfig); + }); + + it('passes --snapshot-enabled to create mutate', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new ProjectsCreate( + [ + '--id', + 'snap-project', + '--name', + 'Snap Project', + '--repo', + 'owner/repo', + '--snapshot-enabled', + ], + oclifConfig as never, + ); + await cmd.run(); + + expect(client.projects.create.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + snapshotEnabled: true, + }), + ); + }); + + it('passes --snapshot-ttl to create mutate', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new ProjectsCreate( + [ + '--id', + 'snap-project', + '--name', + 'Snap Project', + '--repo', + 'owner/repo', + '--snapshot-ttl', + '7200000', + ], + oclifConfig as never, + ); + await cmd.run(); + + expect(client.projects.create.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + snapshotTtlMs: 7200000, + }), + ); + }); + + it('does not include snapshotEnabled when flag is absent on create', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new ProjectsCreate( + ['--id', 'new-project', '--name', 'New Project', '--repo', 'owner/repo'], + oclifConfig as never, + ); + await cmd.run(); + + const callArg = (client.projects.create.mutate as ReturnType).mock.calls[0][0]; + expect(callArg).not.toHaveProperty('snapshotEnabled'); + expect(callArg).not.toHaveProperty('snapshotTtlMs'); + }); }); // --------------------------------------------------------------------------- diff --git a/web/src/components/projects/project-general-form.tsx b/web/src/components/projects/project-general-form.tsx index 7cb3b704..e4f5e2b1 100644 --- a/web/src/components/projects/project-general-form.tsx +++ b/web/src/components/projects/project-general-form.tsx @@ -40,6 +40,8 @@ interface Project { engineSettings: Record> | null; runLinksEnabled?: boolean | null; maxInFlightItems?: number | null; + snapshotEnabled?: boolean | null; + snapshotTtlMs?: number | null; } function numericFieldDefault(value: number | null | undefined): string { @@ -93,6 +95,8 @@ export function ProjectGeneralForm({ project }: { project: Project }) { numericFieldDefault(project.maxInFlightItems), ); const [runLinksEnabled, setRunLinksEnabled] = useState(project.runLinksEnabled ?? false); + const [snapshotEnabled, setSnapshotEnabled] = useState(project.snapshotEnabled ?? false); + const [snapshotTtlMs, setSnapshotTtlMs] = useState(numericFieldDefault(project.snapshotTtlMs)); // Track dirty state to enable/disable Save button const isDirty = useMemo(() => { @@ -102,7 +106,9 @@ export function ProjectGeneralForm({ project }: { project: Project }) { progressModel !== (project.progressModel ?? '') || workItemBudgetUsd !== (project.workItemBudgetUsd ?? '') || maxInFlightItems !== numericFieldDefault(project.maxInFlightItems) || - runLinksEnabled !== (project.runLinksEnabled ?? false) + runLinksEnabled !== (project.runLinksEnabled ?? false) || + snapshotEnabled !== (project.snapshotEnabled ?? false) || + snapshotTtlMs !== numericFieldDefault(project.snapshotTtlMs) ); }, [ name, @@ -111,6 +117,8 @@ export function ProjectGeneralForm({ project }: { project: Project }) { workItemBudgetUsd, maxInFlightItems, runLinksEnabled, + snapshotEnabled, + snapshotTtlMs, project, ]); @@ -121,6 +129,8 @@ export function ProjectGeneralForm({ project }: { project: Project }) { setWorkItemBudgetUsd(project.workItemBudgetUsd ?? ''); setMaxInFlightItems(numericFieldDefault(project.maxInFlightItems)); setRunLinksEnabled(project.runLinksEnabled ?? false); + setSnapshotEnabled(project.snapshotEnabled ?? false); + setSnapshotTtlMs(numericFieldDefault(project.snapshotTtlMs)); } function handleSubmit(e: React.FormEvent) { @@ -133,6 +143,8 @@ export function ProjectGeneralForm({ project }: { project: Project }) { workItemBudgetUsd: workItemBudgetUsd || null, maxInFlightItems: maxInFlightItems ? Number.parseInt(maxInFlightItems, 10) : null, runLinksEnabled, + snapshotEnabled: snapshotEnabled || null, + snapshotTtlMs: snapshotTtlMs ? Number.parseInt(snapshotTtlMs, 10) : null, }, { onSuccess: () => { @@ -301,6 +313,62 @@ export function ProjectGeneralForm({ project }: { project: Project }) { + {/* Container Snapshots */} + + +
+ Container Snapshots + + + + + + Enable container snapshots to speed up subsequent agent runs by reusing a saved + container state. + + +
+
+ +
+ setSnapshotEnabled(e.target.checked)} + className="h-4 w-4 rounded border-border" + /> +
+ +

+ Reuse a saved container state to speed up agent runs. +

+
+
+ {snapshotEnabled && ( +
+ + setSnapshotTtlMs(e.target.value)} + placeholder="e.g. 3600000 (1 hour)" + /> +

+ How long a snapshot remains valid (milliseconds). Leave blank to use the project + default. +

+
+ )} +
+
+ {/* Save / Reset */}