From fa2b84697b7ec0d5288d7ff1112a2200949634c0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Jul 2026 23:08:22 -0400 Subject: [PATCH] feat(dev): add cron fixtures to the Control UI mock server (#114248) --- scripts/control-ui-mock-cron.ts | 261 ++++++++++++++++++++++++++++++++ scripts/control-ui-mock-dev.ts | 3 + 2 files changed, 264 insertions(+) create mode 100644 scripts/control-ui-mock-cron.ts diff --git a/scripts/control-ui-mock-cron.ts b/scripts/control-ui-mock-cron.ts new file mode 100644 index 000000000000..c1dd07dc926d --- /dev/null +++ b/scripts/control-ui-mock-cron.ts @@ -0,0 +1,261 @@ +import type { + CronJob, + CronJobsListResult, + CronRunLogEntry, + CronRunsResult, + CronStatus, +} from "../ui/src/api/types.ts"; + +function listResult( + jobs: CronJob[], + options: { total?: number; limit?: number; offset?: number } = {}, +): CronJobsListResult { + const total = options.total ?? jobs.length; + const limit = options.limit ?? 50; + const offset = options.offset ?? 0; + const nextOffset = offset + jobs.length; + const hasMore = nextOffset < total; + return { + jobs, + total, + offset, + limit, + hasMore, + nextOffset: hasMore ? nextOffset : null, + }; +} + +function runsResult(entries: CronRunLogEntry[]): CronRunsResult { + return { + entries, + total: entries.length, + offset: 0, + limit: 50, + hasMore: false, + nextOffset: null, + }; +} + +function singleJobListCases(jobs: CronJob[], match: Record) { + return jobs.map((job, offset) => ({ + match: { ...match, offset }, + response: listResult([job], { total: jobs.length, limit: 1, offset }), + })); +} + +export function buildCronMocks(baseTime: number) { + const minute = 60_000; + const hour = 60 * minute; + const day = 24 * hour; + const failedJob: CronJob = { + id: "mock-cron-calendar-sync", + agentId: "main", + name: "Sync team calendar", + description: "Refresh the shared calendar cache for the morning briefing.", + enabled: true, + createdAtMs: baseTime - 30 * day, + updatedAtMs: baseTime - 4 * minute, + schedule: { kind: "cron", expr: "0 */6 * * *", tz: "America/New_York" }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { + kind: "agentTurn", + message: "Sync the team calendar and summarize schedule conflicts.", + }, + delivery: { mode: "announce", channel: "telegram", to: "@operations" }, + state: { + nextRunAtMs: baseTime + 5 * hour, + lastRunAtMs: baseTime - 5 * minute, + lastRunStatus: "error", + lastError: + "OAuth refresh failed: invalid_grant. The provider rejected the stored refresh token because it was revoked or expired. Reconnect Google Calendar before the next scheduled sync.", + lastDurationMs: 8_420, + consecutiveErrors: 2, + lastDeliveryStatus: "not-requested", + }, + }; + const overdueJob: CronJob = { + id: "mock-cron-inbox-triage", + agentId: "main", + name: "Triage support inbox", + description: "Classify new support mail and prepare the daily response queue.", + enabled: true, + createdAtMs: baseTime - 18 * day, + updatedAtMs: baseTime - 35 * minute, + schedule: { kind: "every", everyMs: 15 * minute, anchorMs: baseTime - 18 * day }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "Triage the support inbox." }, + delivery: { mode: "none" }, + state: { + nextRunAtMs: baseTime - 20 * minute, + lastRunAtMs: baseTime - 35 * minute, + lastRunStatus: "ok", + lastDurationMs: 24_180, + lastDeliveryStatus: "not-requested", + }, + }; + const healthyJob: CronJob = { + id: "mock-cron-release-digest", + agentId: "main", + name: "Publish release digest", + description: "Summarize merged changes for the engineering channel.", + enabled: true, + createdAtMs: baseTime - 9 * day, + updatedAtMs: baseTime - 30 * minute, + schedule: { kind: "cron", expr: "30 9 * * 1-5", tz: "America/New_York" }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "Draft and publish the release digest." }, + delivery: { mode: "announce", channel: "slack", to: "#engineering" }, + state: { + nextRunAtMs: baseTime + 30 * minute, + lastRunAtMs: baseTime - 30 * minute, + lastRunStatus: "ok", + lastDurationMs: 51_230, + lastDelivered: true, + lastDeliveryStatus: "delivered", + }, + }; + const jobs = [overdueJob, healthyJob, failedJob]; + const failedRun: CronRunLogEntry = { + ts: baseTime - 5 * minute, + runAtMs: baseTime - 5 * minute, + jobId: failedJob.id, + jobName: failedJob.name, + action: "finished", + status: "error", + durationMs: failedJob.state?.lastDurationMs, + error: failedJob.state?.lastError, + deliveryStatus: "not-requested", + model: "gpt-5.6-sol", + provider: "openai", + }; + const runs: CronRunLogEntry[] = [ + failedRun, + { + ts: baseTime - 30 * minute, + runAtMs: baseTime - 30 * minute, + jobId: healthyJob.id, + jobName: healthyJob.name, + action: "finished", + status: "ok", + durationMs: healthyJob.state?.lastDurationMs, + summary: "Published a digest covering 14 merged changes.", + delivered: true, + deliveryStatus: "delivered", + model: "claude-sonnet-4-6", + provider: "anthropic", + }, + { + ts: baseTime - 35 * minute, + runAtMs: baseTime - 35 * minute, + jobId: overdueJob.id, + jobName: overdueJob.name, + action: "finished", + status: "ok", + durationMs: overdueJob.state?.lastDurationMs, + summary: "Classified 23 messages and prepared 6 replies.", + deliveryStatus: "not-requested", + model: "gpt-5.6-sol", + provider: "openai", + }, + ]; + const queuedRuns: Array<{ runId: string; entry: CronRunLogEntry }> = jobs.map((job, index) => ({ + runId: `mock-cron-manual-${job.id}`, + entry: { + ts: baseTime + index, + runAtMs: baseTime + index, + jobId: job.id, + jobName: job.name, + action: "finished", + status: "ok", + durationMs: 42_000 + index * 2_500, + summary: `Completed an on-demand run for ${job.name}.`, + deliveryStatus: "not-requested", + model: "gpt-5.6-sol", + provider: "openai", + }, + })); + const status: CronStatus = { + enabled: true, + jobs: jobs.length, + nextWakeAtMs: overdueJob.state?.nextRunAtMs, + }; + const runByJobId = new Map(runs.map((entry) => [entry.jobId, entry])); + const sortedJobLists = [ + { match: { sortBy: "nextRunAtMs", sortDir: "asc" }, jobs }, + { + match: { sortBy: "nextRunAtMs", sortDir: "desc" }, + jobs: [failedJob, healthyJob, overdueJob], + }, + { match: { sortBy: "updatedAtMs", sortDir: "asc" }, jobs }, + { + match: { sortBy: "updatedAtMs", sortDir: "desc" }, + jobs: [failedJob, healthyJob, overdueJob], + }, + { match: { sortBy: "name", sortDir: "asc" }, jobs: [healthyJob, failedJob, overdueJob] }, + { match: { sortBy: "name", sortDir: "desc" }, jobs: [overdueJob, failedJob, healthyJob] }, + ]; + + return { + "cron.status": status, + "cron.list": { + // Cases mirror the concrete queries today's Cron UI issues. Unknown combinations fall back + // to the full fixture list; dynamic evaluation is intentionally out of scope because the + // scenario is JSON-serialized into the page rather than installed as a live responder. + cases: [ + { + match: { enabled: "enabled", lastRunStatus: "error" }, + response: listResult([failedJob], { limit: 1 }), + }, + { match: { enabled: "disabled" }, response: listResult([]) }, + ...singleJobListCases(jobs, { + enabled: "enabled", + sortBy: "nextRunAtMs", + sortDir: "asc", + limit: 1, + }), + ...singleJobListCases(jobs, { includeDisabled: true, limit: 1 }), + ...sortedJobLists.map((entry) => ({ + match: entry.match, + response: listResult(entry.jobs), + })), + { response: listResult(jobs) }, + ], + }, + "cron.runs": { + cases: [ + ...queuedRuns.map((run) => ({ + match: { runId: run.runId }, + response: runsResult([run.entry]), + })), + ...jobs.flatMap((job) => { + const jobRun = runByJobId.get(job.id); + return [ + { + match: { scope: "job", id: job.id, statuses: ["error"] }, + response: runsResult(jobRun?.status === "error" ? [jobRun] : []), + }, + { + match: { scope: "job", id: job.id }, + response: runsResult(jobRun ? [jobRun] : []), + }, + ]; + }), + { match: { statuses: ["error"] }, response: runsResult([failedRun]) }, + { response: runsResult(runs) }, + ], + }, + // Writes acknowledge the UI action but intentionally keep the fixture snapshot immutable. + "cron.add": { id: "mock-cron-created" }, + "cron.update": { ok: true }, + "cron.remove": { ok: true }, + "cron.run": { + cases: queuedRuns.map((run) => ({ + match: { id: run.entry.jobId }, + response: { ok: true, enqueued: true, runId: run.runId }, + })), + }, + }; +} diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index ab10ab3f365b..17d2e42614dd 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -19,6 +19,7 @@ import { } from "../ui/vite.config.ts"; import { buildBackgroundTasksMock } from "./control-ui-mock-background-tasks.ts"; import { buildChannelsStatusMock, buildChannelWizardMocks } from "./control-ui-mock-channels.ts"; +import { buildCronMocks } from "./control-ui-mock-cron.ts"; import { buildPluginCatalogMock } from "./control-ui-mock-plugins.ts"; import { buildSkillWorkshopMocks } from "./control-ui-mock-skill-workshop.js"; @@ -1259,6 +1260,7 @@ async function createChatPickerScenario( const profileUsage = buildProfileUsageMocks(Date.now()); const modelProviders = buildModelProviderMocks(Date.now()); const skillWorkshop = buildSkillWorkshopMocks(Date.now()); + const cronMocks = buildCronMocks(Date.now()); const channelWizard = buildChannelWizardMocks(); const configMocks = buildConfigMocks({ swarmEnabled: fixture === "swarm" }); return { @@ -1288,6 +1290,7 @@ async function createChatPickerScenario( ], methodResponses: { ...buildBackgroundTasksMock(baseTime), + ...cronMocks, "users.self": { profile: selfProfile }, // Custom session group catalog so the sidebar's category zone (and its // drag-reordering against built-in sections) is exercised in the mock.