* feat: add Schedules screen to manage scheduled agents Adds a Schedules section in the sidebar header (below Sessions) for viewing and managing new-agent schedules: recurring jobs that spawn an agent on a cron or interval cadence. - Borderless table showing provider icon, model, cadence, next run, and status, with row actions to edit, pause/resume, run now, and delete. - Create/edit sheet that reuses the agent provider/model/mode/working- directory pickers, plus an interval/cron cadence editor with presets, validation, and a UTC preview. - Responsive: hover-revealed row actions on desktop, an always-visible action menu on mobile. - Data layer over the existing schedule RPCs (list/create/update/pause/ resume/delete/run-once) with optimistic pause, resume, and delete. Scope is limited to new-agent schedules for now. * Make the schedule form controls visually consistent Merge the provider and model pickers into the single nested selector the draft screen uses, render it as a full-width field, mute the placeholder, proportion the cadence segmented controls, unify the cron preset chips, and route the sheet scroll through the themed scrollbar. * Address schedules review feedback * Translate schedule hook client fallback errors * Make schedules global and project scoped * Stabilize project settings transport E2E * Reset schedule model on cross-host project change * Reject malformed cron step expressions * Tighten schedules project query freshness * refactor(schedules): clean up review feedback * test(schedules): extract fake host setup * refactor(schedules): share cron expression parsing Use one structural cron parser for app previews and daemon cadence validation so the accepted grammar cannot drift between surfaces. Remove the unused host-scoped schedules redirect while keeping the global schedules route.
157 lines
6.1 KiB
TypeScript
157 lines
6.1 KiB
TypeScript
import { expect, test, type Page } from "./fixtures";
|
|
import { gotoAppShell } from "./helpers/app";
|
|
import {
|
|
addFakeScheduleHostAndReload,
|
|
buildFakeScheduleHostWorkspace,
|
|
installFakeScheduleHost,
|
|
} from "./helpers/schedule-fake-host";
|
|
import { getServerId } from "./helpers/server-id";
|
|
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
|
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
|
import { buildSchedulesRoute } from "../src/utils/host-routes";
|
|
|
|
interface ScheduleListItem {
|
|
id: string;
|
|
name: string | null;
|
|
target: { type: string; config?: { cwd?: string } };
|
|
}
|
|
|
|
interface ScheduleSeedClient {
|
|
scheduleList(): Promise<{ schedules: ScheduleListItem[]; error: string | null }>;
|
|
scheduleDelete(input: { id: string }): Promise<{ error: string | null }>;
|
|
}
|
|
|
|
async function selectModelByLabel(page: Page, label: string): Promise<void> {
|
|
await page.getByRole("button", { name: /select model/i }).click();
|
|
const popup = page.getByTestId("combobox-desktop-container");
|
|
await expect(popup).toBeVisible({ timeout: 30_000 });
|
|
await popup.getByText(label, { exact: true }).click();
|
|
await expect(popup).toHaveCount(0, { timeout: 30_000 });
|
|
}
|
|
|
|
async function deleteScheduleByName(workspace: SeededWorkspace, name: string): Promise<void> {
|
|
const client = workspace.client as unknown as ScheduleSeedClient;
|
|
const list = await client.scheduleList();
|
|
const schedule = list.schedules.find((candidate) => candidate.name === name);
|
|
if (schedule) {
|
|
await client.scheduleDelete({ id: schedule.id }).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
async function expectScheduleCreatedForProject(input: {
|
|
workspace: SeededWorkspace;
|
|
name: string;
|
|
}): Promise<void> {
|
|
const client = input.workspace.client as unknown as ScheduleSeedClient;
|
|
const list = await client.scheduleList();
|
|
const schedule = list.schedules.find((candidate) => candidate.name === input.name);
|
|
expect(schedule).toEqual(
|
|
expect.objectContaining({
|
|
name: input.name,
|
|
target: expect.objectContaining({
|
|
type: "new-agent",
|
|
config: expect.objectContaining({
|
|
cwd: input.workspace.repoPath,
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
}
|
|
|
|
test.describe("Schedules project target", () => {
|
|
const cleanupTasks: Array<() => Promise<void>> = [];
|
|
|
|
test.afterEach(async () => {
|
|
for (const cleanup of cleanupTasks.toReversed()) {
|
|
await cleanup();
|
|
}
|
|
cleanupTasks.length = 0;
|
|
});
|
|
|
|
test("creates a schedule from a project picker instead of a raw CWD selector", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-target-" });
|
|
cleanupTasks.push(() => workspace.cleanup());
|
|
const scheduleName = `Project schedule ${Date.now()}`;
|
|
cleanupTasks.push(() => deleteScheduleByName(workspace, scheduleName));
|
|
|
|
await gotoAppShell(page);
|
|
await waitForSidebarHydration(page);
|
|
|
|
await page.getByRole("button", { name: "Schedules" }).click();
|
|
await expect(page).toHaveURL(/\/schedules$/);
|
|
await expect(page).not.toHaveURL(/\/h\//);
|
|
await expect(page.getByTestId(`schedules-section-${getServerId()}`)).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: "New schedule" }).click();
|
|
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
|
await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0);
|
|
|
|
await page.getByRole("button", { name: /select project/i }).click();
|
|
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
|
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
|
|
workspace.projectDisplayName,
|
|
);
|
|
|
|
await page.getByLabel("Schedule name").fill(scheduleName);
|
|
await page.getByLabel("Prompt").fill("Summarize the project status.");
|
|
await page.getByRole("button", { name: "Cron" }).click();
|
|
await page.getByRole("button", { name: "Create schedule" }).click();
|
|
|
|
await expect(page.getByTestId("schedule-form-sheet")).toHaveCount(0, { timeout: 30_000 });
|
|
await expectScheduleCreatedForProject({ workspace, name: scheduleName });
|
|
});
|
|
|
|
test("clears the selected model when the chosen project moves to another host", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-host-model-" });
|
|
cleanupTasks.push(() => workspace.cleanup());
|
|
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
|
|
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
|
|
|
|
await installFakeScheduleHost({
|
|
page,
|
|
port: fakePort,
|
|
serverId: fakeHost.serverId,
|
|
workspace: fakeHost.workspace,
|
|
});
|
|
|
|
await gotoAppShell(page);
|
|
await waitForSidebarHydration(page);
|
|
await page.goto(buildSchedulesRoute());
|
|
await addFakeScheduleHostAndReload({
|
|
page,
|
|
serverId: fakeHost.serverId,
|
|
label: "Fake host",
|
|
port: fakePort,
|
|
});
|
|
await expect(page.getByTestId(`schedules-section-${fakeHost.serverId}`)).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
await page.getByRole("button", { name: "New schedule" }).click();
|
|
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
|
|
|
await page.getByRole("button", { name: /select project/i }).click();
|
|
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
|
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
|
|
workspace.projectDisplayName,
|
|
);
|
|
|
|
await selectModelByLabel(page, "Ten second stream");
|
|
await expect(page.getByRole("button", { name: /ten second stream/i })).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: /select project/i }).click();
|
|
await page.getByTestId(`schedule-project-option-${fakeHost.projectId}`).click();
|
|
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
|
|
fakeHost.projectDisplayName,
|
|
);
|
|
await expect(page.getByRole("button", { name: /select model/i })).toBeVisible();
|
|
|
|
await page.getByLabel("Schedule name").fill(`Cross host model ${Date.now()}`);
|
|
await page.getByLabel("Prompt").fill("Run on the fake host project.");
|
|
await expect(page.getByRole("button", { name: "Create schedule" })).toBeDisabled();
|
|
});
|
|
});
|