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
4 changes: 4 additions & 0 deletions src/api/routers/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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 }) => {
Expand Down
11 changes: 11 additions & 0 deletions src/cli/dashboard/projects/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand All @@ -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'] } : {}),
}),
);

Expand Down
11 changes: 11 additions & 0 deletions src/cli/dashboard/projects/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand All @@ -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'] } : {}),
}),
);

Expand Down
6 changes: 6 additions & 0 deletions src/db/repositories/projectsRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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) }
: {}),
Expand All @@ -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();
Expand Down
83 changes: 83 additions & 0 deletions tests/unit/api/routers/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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<typeof vi.fn>).mock.calls[0][2];
expect(callArg).not.toHaveProperty('snapshotEnabled');
expect(callArg).not.toHaveProperty('snapshotTtlMs');
});
});

describe('delete', () => {
Expand Down
137 changes: 137 additions & 0 deletions tests/unit/cli/dashboard/projects/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,143 @@ describe('ProjectsUpdate (update)', () => {
const callArg = (client.projects.update.mutate as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mock.calls[0][0];
expect(callArg).not.toHaveProperty('snapshotEnabled');
expect(callArg).not.toHaveProperty('snapshotTtlMs');
});
});

// ---------------------------------------------------------------------------
Expand Down
Loading
Loading