diff --git a/packages/cli/src/serve/routes/scheduled-tasks.test.ts b/packages/cli/src/serve/routes/scheduled-tasks.test.ts new file mode 100644 index 00000000000..717c8c7a1c2 --- /dev/null +++ b/packages/cli/src/serve/routes/scheduled-tasks.test.ts @@ -0,0 +1,360 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import express from 'express'; +import type { Request } from 'express'; +import { promises as fsp } from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import { Storage, getCronFilePath } from '@qwen-code/qwen-code-core'; +import { registerScheduledTasksRoutes } from './scheduled-tasks.js'; + +function safeBody(req: Request): Record { + return req.body && typeof req.body === 'object' + ? (req.body as Record) + : {}; +} + +interface Harness { + app: express.Application; + scratch: string; + workspace: string; +} + +async function makeHarness(): Promise { + const scratch = await fsp.mkdtemp(path.join(os.tmpdir(), 'sched-route-')); + const workspace = path.join(scratch, 'workspace'); + await fsp.mkdir(workspace, { recursive: true }); + // The durable tasks file lands under the runtime base dir, not the real + // ~/.qwen — redirect it into the scratch dir for the duration of the test. + Storage.setRuntimeBaseDir(scratch); + + const app = express(); + app.use(express.json()); + registerScheduledTasksRoutes(app, { + boundWorkspace: workspace, + // Non-strict mutate is a passthrough (matches the loopback web-shell). + mutate: () => (_req, _res, next) => next(), + safeBody, + }); + return { app, scratch, workspace }; +} + +async function teardown(h: Harness): Promise { + Storage.setRuntimeBaseDir(null); + await fsp.rm(h.scratch, { recursive: true, force: true }); +} + +describe('scheduled-tasks routes', () => { + let h: Harness; + + beforeEach(async () => { + h = await makeHarness(); + }); + + afterEach(async () => { + await teardown(h); + }); + + const create = (body: Record) => + request(h.app).post('/scheduled-tasks').send(body); + + it('returns an empty list initially', async () => { + const res = await request(h.app).get('/scheduled-tasks'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ v: 1, tasks: [] }); + }); + + it('creates a task (normalized view) and lists it', async () => { + const res = await create({ + name: 'Digest', + cron: '30 12 * * 1-5', + prompt: 'summarize the day', + }); + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ + name: 'Digest', + cron: '30 12 * * 1-5', + prompt: 'summarize the day', + recurring: true, + enabled: true, + }); + expect(typeof res.body.id).toBe('string'); + + const list = await request(h.app).get('/scheduled-tasks'); + expect(list.body.tasks).toHaveLength(1); + expect(list.body.tasks[0].id).toBe(res.body.id); + }); + + it('rejects an unparseable cron', async () => { + const res = await create({ cron: 'not a cron', prompt: 'x' }); + expect(res.status).toBe(400); + expect(res.body.code).toBe('invalid_cron'); + }); + + it('rejects a syntactically-valid but impossible cron (Feb 30)', async () => { + // parseCron accepts "0 0 30 2 *" but nextFireTime rejects it — the route + // runs both, so a task that could never fire is refused. + const res = await create({ cron: '0 0 30 2 *', prompt: 'x' }); + expect(res.status).toBe(400); + expect(res.body.code).toBe('invalid_cron'); + }); + + it('returns 500 when the tasks file is corrupt', async () => { + // A file that exists but does not parse is corruption, not an empty + // schedule; the route surfaces it rather than hiding the user's tasks. + const file = getCronFilePath(h.workspace); + await fsp.mkdir(path.dirname(file), { recursive: true }); + await fsp.writeFile(file, 'NOT JSON {{{', 'utf8'); + const res = await request(h.app).get('/scheduled-tasks'); + expect(res.status).toBe(500); + expect(res.body.code).toBe('scheduled_tasks_read_failed'); + // The client message must stay generic — no leak of the internal file path. + expect(res.body.error).toBe( + 'Failed to read scheduled tasks (the tasks file may be corrupt)', + ); + expect(res.body.error).not.toContain(file); + }); + + it('rejects a whitespace-only prompt', async () => { + const res = await create({ cron: '0 9 * * *', prompt: ' ' }); + expect(res.status).toBe(400); + expect(res.body.code).toBe('invalid_prompt'); + }); + + it('toggles enabled via PATCH', async () => { + const created = await create({ cron: '0 9 * * *', prompt: 'x' }); + const id = created.body.id as string; + + const patch = await request(h.app) + .patch(`/scheduled-tasks/${id}`) + .send({ enabled: false }); + expect(patch.status).toBe(200); + expect(patch.body.enabled).toBe(false); + + const list = await request(h.app).get('/scheduled-tasks'); + expect(list.body.tasks[0].enabled).toBe(false); + }); + + it('clears the name when patched to an empty string', async () => { + const created = await create({ + name: 'Named', + cron: '0 9 * * *', + prompt: 'p', + }); + const id = created.body.id as string; + const patch = await request(h.app) + .patch(`/scheduled-tasks/${id}`) + .send({ name: '' }); + expect(patch.status).toBe(200); + expect(patch.body.name).toBeNull(); + }); + + it('404s when patching a missing task', async () => { + const res = await request(h.app) + .patch('/scheduled-tasks/missing1') + .send({ enabled: false }); + expect(res.status).toBe(404); + expect(res.body.code).toBe('task_not_found'); + }); + + it('deletes a task, then 404s on repeat', async () => { + const created = await create({ cron: '0 9 * * *', prompt: 'x' }); + const id = created.body.id as string; + + const del = await request(h.app).delete(`/scheduled-tasks/${id}`); + expect(del.status).toBe(200); + expect(del.body).toEqual({ deleted: true, id }); + + const again = await request(h.app).delete(`/scheduled-tasks/${id}`); + expect(again.status).toBe(404); + }); + + it('rejects a create past the max-tasks cap', async () => { + for (let i = 0; i < 50; i++) { + const r = await create({ cron: '0 9 * * *', prompt: `p${i}` }); + expect(r.status).toBe(201); + } + const over = await create({ cron: '0 9 * * *', prompt: 'overflow' }); + expect(over.status).toBe(409); + expect(over.body.code).toBe('max_tasks_reached'); + }); + + it('updates cron / prompt / recurring via PATCH', async () => { + const created = await create({ + cron: '0 9 * * *', + prompt: 'orig', + recurring: true, + }); + const id = created.body.id as string; + + const patch = await request(h.app) + .patch(`/scheduled-tasks/${id}`) + .send({ cron: '30 12 * * 1-5', prompt: 'updated', recurring: false }); + expect(patch.status).toBe(200); + expect(patch.body).toMatchObject({ + cron: '30 12 * * 1-5', + prompt: 'updated', + recurring: false, + }); + + const list = await request(h.app).get('/scheduled-tasks'); + expect(list.body.tasks[0]).toMatchObject({ + cron: '30 12 * * 1-5', + prompt: 'updated', + recurring: false, + }); + }); + + it('rejects an invalid cron via PATCH', async () => { + const created = await create({ cron: '0 9 * * *', prompt: 'x' }); + const id = created.body.id as string; + const patch = await request(h.app) + .patch(`/scheduled-tasks/${id}`) + .send({ cron: 'nope' }); + expect(patch.status).toBe(400); + expect(patch.body.code).toBe('invalid_cron'); + // The bad PATCH must not have mutated the stored cron. + const list = await request(h.app).get('/scheduled-tasks'); + expect(list.body.tasks[0].cron).toBe('0 9 * * *'); + }); + + it('rejects a PATCH with no updatable fields', async () => { + const created = await create({ cron: '0 9 * * *', prompt: 'x' }); + const id = created.body.id as string; + const patch = await request(h.app).patch(`/scheduled-tasks/${id}`).send({}); + expect(patch.status).toBe(400); + expect(patch.body.code).toBe('empty_patch'); + }); + + it('enforces POST field limits and boolean types', async () => { + const longPrompt = await create({ + cron: '0 9 * * *', + prompt: 'x'.repeat(100_001), + }); + expect(longPrompt.status).toBe(400); + expect(longPrompt.body.code).toBe('invalid_prompt'); + + const longName = await create({ + cron: '0 9 * * *', + prompt: 'x', + name: 'n'.repeat(201), + }); + expect(longName.status).toBe(400); + expect(longName.body.code).toBe('invalid_name'); + + const badRecurring = await create({ + cron: '0 9 * * *', + prompt: 'x', + recurring: 'yes', + }); + expect(badRecurring.status).toBe(400); + expect(badRecurring.body.code).toBe('invalid_recurring'); + + const badEnabled = await create({ + cron: '0 9 * * *', + prompt: 'x', + enabled: 1, + }); + expect(badEnabled.status).toBe(400); + expect(badEnabled.body.code).toBe('invalid_enabled'); + }); + + // Seeds the on-disk file directly so a task can carry a real prior fire. + const seedTask = async (task: Record) => { + const file = getCronFilePath(h.workspace); + await fsp.mkdir(path.dirname(file), { recursive: true }); + await fsp.writeFile(file, JSON.stringify([task]), 'utf8'); + }; + + it('normalizes a legacy task (no name/enabled) on GET', async () => { + // Pre-fields format, as tool-created tasks were written before this PR. + await seedTask({ + id: 'leg1', + cron: '0 9 * * *', + prompt: 'p', + recurring: true, + createdAt: 1_700_000_000_000, + lastFiredAt: null, + }); + const res = await request(h.app).get('/scheduled-tasks'); + expect(res.status).toBe(200); + // Backward compatibility: absent name → null, absent enabled → true. + expect(res.body.tasks[0]).toMatchObject({ + id: 'leg1', + name: null, + enabled: true, + }); + }); + + it('re-enabling a previously-fired task resumes from now (no catch-up)', async () => { + const createdAt = 1_700_000_000_000; + const firedAt = createdAt + 3 * 86_400_000; // a genuine past fire + await seedTask({ + id: 'r1', + cron: '0 9 * * *', + prompt: 'p', + recurring: true, + createdAt, + lastFiredAt: firedAt, + enabled: false, + }); + const now = Date.now(); + const patch = await request(h.app) + .patch('/scheduled-tasks/r1') + .send({ enabled: true }); + expect(patch.status).toBe(200); + expect(patch.body.enabled).toBe(true); + // lastFiredAt advanced to ~now so the scheduler won't catch up the fires + // it "missed" while paused. + expect(patch.body.lastFiredAt).toBeGreaterThan(firedAt); + expect(patch.body.lastFiredAt).toBeGreaterThanOrEqual(now - (now % 60_000)); + }); + + it('re-enabling a recurring task disabled before its first run also resumes from now', async () => { + // A task paused before ever firing must not catch-up its missed slot on + // re-enable — every recurring false→true transition is stamped to now. + const createdAt = 1_700_000_000_000; + const createdMinute = createdAt - (createdAt % 60_000); + await seedTask({ + id: 'n1', + cron: '0 9 * * *', + prompt: 'p', + recurring: true, + createdAt, + lastFiredAt: createdMinute, // never actually fired + enabled: false, + }); + const now = Date.now(); + const patch = await request(h.app) + .patch('/scheduled-tasks/n1') + .send({ enabled: true }); + expect(patch.status).toBe(200); + expect(patch.body.lastFiredAt).toBeGreaterThan(createdMinute); + expect(patch.body.lastFiredAt).toBeGreaterThanOrEqual(now - (now % 60_000)); + }); + + it('re-enabling a one-shot task leaves its anchor untouched', async () => { + const createdAt = 1_700_000_000_000; + const lastFiredAt = createdAt - (createdAt % 60_000); + await seedTask({ + id: 'o1', + cron: '0 9 1 1 *', + prompt: 'p', + recurring: false, + createdAt, + lastFiredAt, + enabled: false, + }); + const patch = await request(h.app) + .patch('/scheduled-tasks/o1') + .send({ enabled: true }); + expect(patch.status).toBe(200); + expect(patch.body.lastFiredAt).toBe(lastFiredAt); // unchanged (not recurring) + }); +}); diff --git a/packages/cli/src/serve/routes/scheduled-tasks.ts b/packages/cli/src/serve/routes/scheduled-tasks.ts new file mode 100644 index 00000000000..8c12b5fe28d --- /dev/null +++ b/packages/cli/src/serve/routes/scheduled-tasks.ts @@ -0,0 +1,414 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Scheduled-tasks CRUD over the durable cron file (`scheduled_tasks.json`). + * + * This is the daemon-side surface behind the Web Shell "Scheduled tasks" + * page. It only reads/writes the per-project durable-task file via core's + * `cronTasksFile` helpers (atomic writes, cross-process lock) — it does NOT + * run a scheduler of its own. Tasks created here fire the same way + * cron_create's durable tasks do: an agent session with durable cron enabled + * loads them from disk (watched, 300 ms debounce) and fires them at their + * cron time. Disabling a task (`enabled: false`) keeps it on disk but makes + * the scheduler skip it. + * + * Writes use the non-strict `mutate()` gate — creating a scheduled prompt is + * the same capability class as `POST /session/:id/prompt` (both enqueue a + * prompt that runs with tool access), and that route is non-strict too, so a + * loopback web-shell without a token can manage its own schedule. + */ + +import type { Application, Request, RequestHandler } from 'express'; +import { + readCronTasks, + updateCronTasks, + removeCronTasks, + generateCronTaskId, + parseCron, + nextFireTime, + MAX_JOBS, + type DurableCronTask, +} from '@qwen-code/qwen-code-core'; +import { writeStderrLine } from '../../utils/stdioHelpers.js'; + +// The per-file create cap, shared with the scheduler's MAX_JOBS. The scheduler +// caps DURABLE loads against a durable-only budget of MAX_JOBS (independent of +// session-only jobs), so a task accepted here is always loadable — no silent +// "created but never fires". Rejecting past the cap returns a clean 409. +const MAX_SCHEDULED_TASKS = MAX_JOBS; +const MAX_PROMPT_LENGTH = 100_000; +const MAX_NAME_LENGTH = 200; +const MAX_CRON_LENGTH = 200; + +interface RegisterScheduledTasksRoutesDeps { + boundWorkspace: string; + mutate: (opts?: { strict?: boolean }) => RequestHandler; + safeBody: (req: Request) => Record; +} + +/** On-the-wire task shape — normalizes the optional on-disk fields so the + * client never has to special-case `undefined` name/enabled. */ +interface ScheduledTaskView { + id: string; + name: string | null; + cron: string; + prompt: string; + recurring: boolean; + enabled: boolean; + createdAt: number; + lastFiredAt: number | null; +} + +function toView(task: DurableCronTask): ScheduledTaskView { + return { + id: task.id, + name: + typeof task.name === 'string' && task.name.length > 0 ? task.name : null, + cron: task.cron, + prompt: task.prompt, + recurring: task.recurring, + // Absent enabled defaults to enabled — tool-created tasks never write it. + enabled: task.enabled !== false, + createdAt: task.createdAt, + lastFiredAt: task.lastFiredAt, + }; +} + +// Same validation cron_create runs: parseCron rejects malformed syntax, +// nextFireTime rejects expressions that parse but never match a real date +// (e.g. "0 0 30 2 *") — which would otherwise persist a task that silently +// never fires. Returns an error message, or null when valid. +function validateCron(cron: string): string | null { + try { + parseCron(cron); + nextFireTime(cron, new Date()); + return null; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } +} + +export function registerScheduledTasksRoutes( + app: Application, + deps: RegisterScheduledTasksRoutesDeps, +): void { + const { boundWorkspace, mutate, safeBody } = deps; + + // ── List ────────────────────────────────────────────────────────── + app.get('/scheduled-tasks', async (_req, res) => { + try { + const tasks = await readCronTasks(boundWorkspace); + res.status(200).json({ v: 1, tasks: tasks.map(toView) }); + } catch (err) { + // A malformed/corrupt file throws (fix-or-delete contract) rather than + // reading as empty — surface it instead of hiding the user's tasks + // behind a silent []. + writeStderrLine( + `qwen serve: GET /scheduled-tasks failed: ${err instanceof Error ? err.message : String(err)}`, + ); + res.status(500).json({ + error: 'Failed to read scheduled tasks (the tasks file may be corrupt)', + code: 'scheduled_tasks_read_failed', + }); + } + }); + + // ── Create ──────────────────────────────────────────────────────── + app.post('/scheduled-tasks', mutate(), async (req, res) => { + const body = safeBody(req); + + const cron = typeof body['cron'] === 'string' ? body['cron'].trim() : ''; + if (cron.length === 0) { + res.status(400).json({ + error: '`cron` is required and must be a non-empty string', + code: 'invalid_cron', + }); + return; + } + if (cron.length > MAX_CRON_LENGTH) { + res.status(400).json({ + error: `\`cron\` exceeds ${MAX_CRON_LENGTH}-character limit`, + code: 'invalid_cron', + }); + return; + } + const cronError = validateCron(cron); + if (cronError) { + res.status(400).json({ error: cronError, code: 'invalid_cron' }); + return; + } + + const prompt = + typeof body['prompt'] === 'string' ? body['prompt'].trim() : ''; + if (prompt.length === 0) { + res.status(400).json({ + error: '`prompt` is required and must be a non-empty string', + code: 'invalid_prompt', + }); + return; + } + if (prompt.length > MAX_PROMPT_LENGTH) { + res.status(400).json({ + error: `\`prompt\` exceeds ${MAX_PROMPT_LENGTH}-character limit`, + code: 'invalid_prompt', + }); + return; + } + + const nameResult = parseNameField(body['name']); + if (nameResult.error) { + res.status(400).json({ error: nameResult.error, code: 'invalid_name' }); + return; + } + + if ( + body['recurring'] !== undefined && + typeof body['recurring'] !== 'boolean' + ) { + res.status(400).json({ + error: '`recurring` must be a boolean', + code: 'invalid_recurring', + }); + return; + } + if (body['enabled'] !== undefined && typeof body['enabled'] !== 'boolean') { + res.status(400).json({ + error: '`enabled` must be a boolean', + code: 'invalid_enabled', + }); + return; + } + const recurring = body['recurring'] !== false; + const enabled = body['enabled'] !== false; + + const now = Date.now(); + const task: DurableCronTask = { + id: generateCronTaskId(), + cron, + prompt, + recurring, + createdAt: now, + // Pin to the creation minute so the scheduler can't fire during the + // minute the task was created — same guard cronScheduler.create uses. + lastFiredAt: now - (now % 60_000), + enabled, + ...(nameResult.value !== undefined ? { name: nameResult.value } : {}), + }; + + let overCap = false; + try { + await updateCronTasks(boundWorkspace, (tasks) => { + // Cap check under the write lock so two concurrent creates can't both + // slip past a stale count. Returning the input unchanged is a no-op + // (no write), which the flag below turns into a 409. + if (tasks.length >= MAX_SCHEDULED_TASKS) { + overCap = true; + return tasks; + } + return [...tasks, task]; + }); + } catch (err) { + writeStderrLine( + `qwen serve: POST /scheduled-tasks failed: ${err instanceof Error ? err.message : String(err)}`, + ); + res.status(500).json({ + error: 'Failed to create scheduled task', + code: 'scheduled_tasks_write_failed', + }); + return; + } + if (overCap) { + res.status(409).json({ + error: `Maximum number of scheduled tasks (${MAX_SCHEDULED_TASKS}) reached`, + code: 'max_tasks_reached', + }); + return; + } + res.status(201).json(toView(task)); + }); + + // ── Update (name / enabled / cron / prompt / recurring) ──────────── + app.patch('/scheduled-tasks/:id', mutate(), async (req, res) => { + const id = typeof req.params['id'] === 'string' ? req.params['id'] : ''; + if (id.length === 0) { + res + .status(400) + .json({ error: 'Task id is required', code: 'invalid_id' }); + return; + } + const body = safeBody(req); + + // Pre-validate every provided field OUTSIDE the write lock — cron parsing + // and type checks don't need it, and validating inside the mutate callback + // would mean holding the lock to reject a bad request. + const patch: Partial = {}; + let clearName = false; + + if ('cron' in body) { + const cron = typeof body['cron'] === 'string' ? body['cron'].trim() : ''; + if (cron.length === 0 || cron.length > MAX_CRON_LENGTH) { + res.status(400).json({ + error: '`cron` must be a non-empty string within the length limit', + code: 'invalid_cron', + }); + return; + } + const cronError = validateCron(cron); + if (cronError) { + res.status(400).json({ error: cronError, code: 'invalid_cron' }); + return; + } + patch.cron = cron; + } + if ('prompt' in body) { + const prompt = + typeof body['prompt'] === 'string' ? body['prompt'].trim() : ''; + if (prompt.length === 0 || prompt.length > MAX_PROMPT_LENGTH) { + res.status(400).json({ + error: '`prompt` must be a non-empty string within the length limit', + code: 'invalid_prompt', + }); + return; + } + patch.prompt = prompt; + } + if ('name' in body) { + const nameResult = parseNameField(body['name']); + if (nameResult.error) { + res.status(400).json({ error: nameResult.error, code: 'invalid_name' }); + return; + } + if (nameResult.value === undefined) { + clearName = true; + } else { + patch.name = nameResult.value; + } + } + if ('recurring' in body) { + if (typeof body['recurring'] !== 'boolean') { + res.status(400).json({ + error: '`recurring` must be a boolean', + code: 'invalid_recurring', + }); + return; + } + patch.recurring = body['recurring']; + } + if ('enabled' in body) { + if (typeof body['enabled'] !== 'boolean') { + res.status(400).json({ + error: '`enabled` must be a boolean', + code: 'invalid_enabled', + }); + return; + } + patch.enabled = body['enabled']; + } + + if (Object.keys(patch).length === 0 && !clearName) { + res.status(400).json({ + error: 'No updatable fields provided', + code: 'empty_patch', + }); + return; + } + + let found = false; + let updated: DurableCronTask | undefined; + try { + await updateCronTasks(boundWorkspace, (tasks) => { + const idx = tasks.findIndex((t) => t.id === id); + if (idx === -1) return tasks; // not found → no write + found = true; + const current = tasks[idx]!; + const next: DurableCronTask = { ...current, ...patch }; + // `name: null/""` clears the field rather than storing an empty name, + // so toView reports it as unnamed and isValidTask never sees a "". + if (clearName) delete next.name; + // Re-enabling a recurring task resumes it from now instead of catching + // up the fires it "missed" while disabled — which would run prompts the + // user intentionally paused. Applied to every false→true transition, + // including a task disabled before it ever ran, so its paused slot is + // not treated as overdue on the next scheduler load. (The trade-off is + // a re-enabled never-run task reads "last run: now" rather than "never + // run" — a cosmetic edge, preferred over an unwanted real fire.) + if ( + current.enabled === false && + patch.enabled === true && + next.recurring + ) { + const now = Date.now(); + next.lastFiredAt = now - (now % 60_000); + } + updated = next; + return tasks.map((t, i) => (i === idx ? next : t)); + }); + } catch (err) { + writeStderrLine( + `qwen serve: PATCH /scheduled-tasks/${id} failed: ${err instanceof Error ? err.message : String(err)}`, + ); + res.status(500).json({ + error: 'Failed to update scheduled task', + code: 'scheduled_tasks_write_failed', + }); + return; + } + if (!found || !updated) { + res.status(404).json({ error: 'Task not found', code: 'task_not_found' }); + return; + } + res.status(200).json(toView(updated)); + }); + + // ── Delete ──────────────────────────────────────────────────────── + app.delete('/scheduled-tasks/:id', mutate(), async (req, res) => { + const id = typeof req.params['id'] === 'string' ? req.params['id'] : ''; + if (id.length === 0) { + res + .status(400) + .json({ error: 'Task id is required', code: 'invalid_id' }); + return; + } + let removed: number; + try { + removed = await removeCronTasks(boundWorkspace, [id]); + } catch (err) { + writeStderrLine( + `qwen serve: DELETE /scheduled-tasks/${id} failed: ${err instanceof Error ? err.message : String(err)}`, + ); + res.status(500).json({ + error: 'Failed to delete scheduled task', + code: 'scheduled_tasks_write_failed', + }); + return; + } + if (removed === 0) { + res.status(404).json({ error: 'Task not found', code: 'task_not_found' }); + return; + } + res.status(200).json({ deleted: true, id }); + }); +} + +/** + * Parses an optional `name` field. Accepts: + * - absent / null / empty-string → `{ value: undefined }` (unnamed / clear) + * - a non-empty string within the length cap → `{ value: trimmed }` + * - anything else → `{ error }` + */ +function parseNameField(raw: unknown): { value?: string; error?: string } { + if (raw === undefined || raw === null) return { value: undefined }; + if (typeof raw !== 'string') { + return { error: '`name` must be a string' }; + } + const trimmed = raw.trim(); + if (trimmed.length === 0) return { value: undefined }; + if (trimmed.length > MAX_NAME_LENGTH) { + return { error: `\`name\` exceeds ${MAX_NAME_LENGTH}-character limit` }; + } + return { value: trimmed }; +} diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index c05914d0cfb..06233443b6e 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -68,6 +68,7 @@ import { registerWorkspaceSetupGithubRoutes } from './routes/workspace-setup-git import { registerWorkspaceTrustRoutes } from './routes/workspace-trust.js'; import { registerPermissionRoutes } from './routes/permission.js'; import { registerSessionRoutes } from './routes/session.js'; +import { registerScheduledTasksRoutes } from './routes/scheduled-tasks.js'; import { registerWorkspaceDiagnosticStatusRoutes, registerWorkspaceStatusRoutes, @@ -785,6 +786,16 @@ export function createServeApp( parseAndValidateWorkspaceClientId(req, res, bridge), }); + // Durable scheduled-tasks CRUD (the Web Shell "Scheduled tasks" page). + // Reads/writes the per-project cron file only; firing stays with the + // session-side scheduler. Non-strict mutate: creating a scheduled prompt + // is the same capability class as POST /session/:id/prompt. + registerScheduledTasksRoutes(app, { + boundWorkspace, + mutate, + safeBody, + }); + registerPermissionRoutes(app, { bridge, mutate, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b34b6967e65..65f39181826 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -208,6 +208,13 @@ export { export * from './services/chatRecordingService.js'; export * from './services/cronScheduler.js'; export type { DurableCronTask } from './services/cronTasksFile.js'; +export { + readCronTasks, + updateCronTasks, + removeCronTasks, + getCronFilePath, + generateCronTaskId, +} from './services/cronTasksFile.js'; export * from './services/fileDiscoveryService.js'; export * from './services/fileHistoryService.js'; export * from './services/fileReadCache.js'; diff --git a/packages/core/src/services/cronScheduler.test.ts b/packages/core/src/services/cronScheduler.test.ts index 46167865592..573f456bedd 100644 --- a/packages/core/src/services/cronScheduler.test.ts +++ b/packages/core/src/services/cronScheduler.test.ts @@ -937,6 +937,95 @@ describe('CronScheduler', () => { }); }); + describe('durable enabled flag', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cron-enabled-test-')); + Storage.setRuntimeBaseDir(tmpDir); + scheduler = new CronScheduler(tmpDir); + }); + + afterEach(async () => { + await removeTmpDir(tmpDir); + }); + + const seed = ( + overrides: Partial & Pick, + ): DurableCronTask => ({ + cron: '0 9 * * *', + prompt: `prompt-${overrides.id}`, + recurring: true, + createdAt: Date.now(), + lastFiredAt: null, + ...overrides, + }); + + it('loads enabled and legacy tasks but skips enabled:false ones', async () => { + await writeCronTasks(tmpDir, [ + seed({ id: 'on', enabled: true }), + seed({ id: 'off', enabled: false }), + // No enabled field — a tool-created task must still fire. + seed({ id: 'legacy' }), + ]); + + await scheduler.enableDurable('session-1'); + + const ids = scheduler.list().map((j) => j.id); + expect(ids).toContain('on'); + expect(ids).toContain('legacy'); + expect(ids).not.toContain('off'); + }); + + it('never fires a disabled task even when its minute matches', async () => { + const onFire = vi.fn(); + await writeCronTasks(tmpDir, [ + seed({ id: 'off', cron: '30 10 * * *', enabled: false }), + ]); + await scheduler.enableDurable('session-1'); + scheduler.start(onFire); + + // A minute the disabled task's cron matches — a live job would fire. + scheduler.tick(new Date(2025, 0, 15, 10, 30, 59)); + expect(onFire).not.toHaveBeenCalled(); + }); + + it('drops a live job when the file flips it to disabled on reload', async () => { + await writeCronTasks(tmpDir, [seed({ id: 'x', enabled: true })]); + await scheduler.enableDurable('session-1'); + expect(scheduler.list().map((j) => j.id)).toContain('x'); + + // Rewrite the file with the task disabled; the file watcher reloads + // (debounced) and the reconcile must remove the now-disabled job. + await writeCronTasks(tmpDir, [seed({ id: 'x', enabled: false })]); + await vi.waitFor( + () => { + expect(scheduler.list().map((j) => j.id)).not.toContain('x'); + }, + { timeout: 5000 }, + ); + }); + + it('does not let session-only jobs crowd out durable loads', async () => { + // 40 session-only jobs + 20 durable on disk. A combined 50-cap would load + // only 10 durable; the durable-only cap loads all 20 so a route-accepted + // create is actually loadable. + for (let i = 0; i < 40; i++) { + scheduler.create('0 9 * * *', `session ${i}`, true); + } + const durable = Array.from({ length: 20 }, (_unused, i) => + seed({ id: `d${i}` }), + ); + await writeCronTasks(tmpDir, durable); + await scheduler.enableDurable('session-1'); + + const loadedIds = new Set(scheduler.list().map((j) => j.id)); + for (const d of durable) { + expect(loadedIds.has(d.id)).toBe(true); + } + }); + }); + describe('durable ownership', () => { let tmpDir: string; diff --git a/packages/core/src/services/cronScheduler.ts b/packages/core/src/services/cronScheduler.ts index 48724afa477..13cc93b4edb 100644 --- a/packages/core/src/services/cronScheduler.ts +++ b/packages/core/src/services/cronScheduler.ts @@ -16,6 +16,7 @@ import type { DurableCronTask } from './cronTasksFile.js'; import { addCronTask, CRON_TASKS_DISPLAY_PATH, + generateCronTaskId, getCronFilePath, readCronTasks, removeCronTasks, @@ -25,7 +26,10 @@ import { tryAcquireLock, releaseLock } from './cronTasksLock.js'; const debugLogger = createDebugLogger('CRON_SCHEDULER'); -const MAX_JOBS = 50; +/** Max jobs the scheduler keeps in its in-memory map. Also the durable-task + * load cap and the daemon route's per-file create cap — exported so all three + * share one source of truth. */ +export const MAX_JOBS = 50; export const DEFAULT_RECURRING_MAX_AGE_DAYS = 7; // Recurring jobs auto-expire this long after creation by default (claw-code // parity: covers "check my PRs every hour this week" while bounding how long @@ -160,14 +164,10 @@ function computeJitter( return 0; } -function generateId(): string { - const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; - let id = ''; - for (let i = 0; i < 8; i++) { - id += chars[Math.floor(Math.random() * chars.length)]; - } - return id; -} +// Single id scheme, shared with the daemon's scheduled-tasks route via +// cronTasksFile so route-created and tool-created durable tasks are +// indistinguishable on disk. +const generateId = generateCronTaskId; export function clampWakeupSeconds(delaySeconds: number): number { if (!Number.isFinite(delaySeconds)) return WAKEUP_DEFAULT_SECONDS; @@ -630,6 +630,19 @@ export class CronScheduler { // either as an empty schedule would wipe every loaded durable job // and clear pendingRemoval guards whose removals are still in // flight; keep the current view and let a later reload retry. + // + // Breadcrumb: keeping the prior view means an edit that hasn't loaded + // yet — a just-disabled or just-deleted durable task — keeps firing + // until a later reload succeeds. Rare (needs a read failure exactly + // between the write and this reload), but otherwise silent. + if (this.jobs.size > 0) { + // eslint-disable-next-line no-console -- operator-facing breadcrumb for a silent scheduler/disk divergence + console.warn( + 'CronScheduler: durable tasks reload failed; keeping the previous ' + + 'schedule (a just-disabled or -deleted task may keep firing until ' + + 'the next successful reload).', + ); + } return; } if (generation !== this.durableGeneration) { @@ -643,7 +656,17 @@ export class CronScheduler { // file) are skipped but left on disk: installing one would make the // tick's matches() throw from the interval, while dropping it from // the file would discard what the user wrote over a typo. - const tasks = read.filter(hasParseableCron); + // + // Disabled tasks (enabled === false) are skipped the same way — the + // management UI's off switch must stop firing without losing the + // task's config. Treating them as absent here is what makes the + // toggle effective: the reconcile below deletes any live job whose id + // is no longer in this filtered set, so toggling off removes the job, + // and toggling on reinstalls it on the next watcher reload. Absent + // `enabled` counts as enabled, so tool-created tasks keep firing. + const tasks = read.filter( + (t) => hasParseableCron(t) && t.enabled !== false, + ); const now = Date.now(); const missedOneShots: DurableCronTask[] = []; @@ -724,17 +747,22 @@ export class CronScheduler { for (const id of this.pendingRemoval) { if (!diskIds.has(id)) this.pendingRemoval.delete(id); } + // Cap durable installs against a DURABLE-ONLY budget, not the combined + // job map. Session-only jobs (cron_create with durable:false) must not + // crowd out durable tasks the daemon route already accepted onto disk — + // otherwise a create that returned 201 would silently never load/fire in a + // session that happens to hold session-only jobs. This matches the route's + // MAX_SCHEDULED_TASKS (also MAX_JOBS), so a successful create is loadable. + // A hand-edited/force-committed file with hundreds of durable entries is + // still bounded here (only brand-new ids count; updates don't grow it). + let durableJobCount = 0; + for (const j of this.jobs.values()) if (j.durable) durableJobCount++; for (const task of tasks) { if (this.pendingRemoval.has(task.id)) continue; const existing = this.jobs.get(task.id); - // Bound what a project-controlled file can install, mirroring the - // create()-time MAX_JOBS limit. Updating an already-loaded job is - // always allowed (it doesn't grow the map); only brand-new ids are - // capped, so a hand-edited or force-committed file with hundreds of - // entries can't balloon the map and the 1s tick loop. - if (!existing && this.jobs.size >= MAX_JOBS) { + if (!existing && durableJobCount >= MAX_JOBS) { debugLogger.warn( - `Durable task ${task.id} skipped — MAX_JOBS (${MAX_JOBS}) reached.`, + `Durable task ${task.id} skipped — durable cap (${MAX_JOBS}) reached.`, ); continue; } @@ -743,6 +771,7 @@ export class CronScheduler { job.lastFiredAt = Math.max(existing.lastFiredAt, job.lastFiredAt ?? 0); } this.jobs.set(task.id, job); + if (!existing) durableJobCount++; } // Stamp catch-up jobs with the current minute before delivery so the diff --git a/packages/core/src/services/cronTasksFile.test.ts b/packages/core/src/services/cronTasksFile.test.ts index 7f585f4dd39..1a1c06e8007 100644 --- a/packages/core/src/services/cronTasksFile.test.ts +++ b/packages/core/src/services/cronTasksFile.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { DurableCronTask } from './cronTasksFile.js'; import { addCronTask, + generateCronTaskId, getCronFilePath, readCronTasks, removeCronTasks, @@ -134,6 +135,38 @@ describe('cronTasksFile', () => { const result = await readCronTasks(tmpDir); expect(result).toEqual([task]); }); + + it('round-trips the optional name/enabled fields', async () => { + const task = makeTask({ name: 'Weekly digest', enabled: false }); + await writeCronTasks(tmpDir, [task]); + const result = await readCronTasks(tmpDir); + expect(result).toEqual([task]); + }); + + it('accepts legacy tasks with no name/enabled fields', async () => { + // A task written before the fields existed must still read back. + const legacy = makeTask(); + await seedTasksFile(tmpDir, JSON.stringify([legacy])); + const result = await readCronTasks(tmpDir); + expect(result[0]!.name).toBeUndefined(); + expect(result[0]!.enabled).toBeUndefined(); + }); + + it('rejects a task whose name is not a string', async () => { + await seedTasksFile( + tmpDir, + JSON.stringify([{ ...makeTask(), name: 123 }]), + ); + await expect(readCronTasks(tmpDir)).rejects.toThrow(/Invalid task entry/); + }); + + it('rejects a task whose enabled is not a boolean', async () => { + await seedTasksFile( + tmpDir, + JSON.stringify([{ ...makeTask(), enabled: 'yes' }]), + ); + await expect(readCronTasks(tmpDir)).rejects.toThrow(/Invalid task entry/); + }); }); describe('writeCronTasks', () => { @@ -336,4 +369,19 @@ describe('cronTasksFile', () => { ); }); }); + + describe('generateCronTaskId', () => { + it('returns an 8-character base36 id', () => { + expect(generateCronTaskId()).toMatch(/^[a-z0-9]{8}$/); + }); + + it('is very unlikely to collide across calls', () => { + const ids = new Set( + Array.from({ length: 200 }, () => generateCronTaskId()), + ); + // 36^8 space — 200 draws essentially never collide (P ~ 1e-8). Assert + // near-uniqueness with a tiny margin so this can't flake. + expect(ids.size).toBeGreaterThan(195); + }); + }); }); diff --git a/packages/core/src/services/cronTasksFile.ts b/packages/core/src/services/cronTasksFile.ts index 64b1f22bf7c..828023488ec 100644 --- a/packages/core/src/services/cronTasksFile.ts +++ b/packages/core/src/services/cronTasksFile.ts @@ -23,6 +23,35 @@ export interface DurableCronTask { recurring: boolean; createdAt: number; lastFiredAt: number | null; + /** + * Optional display name, shown in management UIs (the Web Shell + * scheduled-tasks page). Absent on tool-created tasks — consumers fall + * back to the prompt. Never used for scheduling. + */ + name?: string; + /** + * Whether the task is active. Absent or `true` = scheduled; `false` = + * kept on disk but skipped by the scheduler — a reversible "off" switch + * for the management UI. Absent defaults to enabled so tool-created + * tasks (which never write this field) keep firing. + */ + enabled?: boolean; +} + +/** + * Generates an 8-character base36 id for a durable task. Shared by the + * scheduler (`CronScheduler`) and the daemon's scheduled-tasks route so + * route-created and tool-created tasks use one id scheme — changing it here + * changes it everywhere. Math.random is fine: ids only need to be unique + * within a <50-entry file, not unpredictable. + */ +export function generateCronTaskId(): string { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + let id = ''; + for (let i = 0; i < 8; i++) { + id += chars[Math.floor(Math.random() * chars.length)]; + } + return id; } const TASKS_FILENAME = 'scheduled_tasks.json'; @@ -259,6 +288,12 @@ function isValidTask(value: unknown): value is DurableCronTask { typeof obj['prompt'] === 'string' && typeof obj['recurring'] === 'boolean' && isFiniteTimestamp(obj['createdAt']) && - (obj['lastFiredAt'] === null || isFiniteTimestamp(obj['lastFiredAt'])) + (obj['lastFiredAt'] === null || isFiniteTimestamp(obj['lastFiredAt'])) && + // Optional fields (added for the management UI): absent is valid and + // means "unnamed" / "enabled". Present-but-wrong-type routes through + // the same fix-or-delete contract as any other corrupt field rather + // than being silently coerced or dropped. + (obj['name'] === undefined || typeof obj['name'] === 'string') && + (obj['enabled'] === undefined || typeof obj['enabled'] === 'boolean') ); } diff --git a/packages/core/src/tools/cron-list.test.ts b/packages/core/src/tools/cron-list.test.ts index c2912573de1..c0b5cdd743b 100644 --- a/packages/core/src/tools/cron-list.test.ts +++ b/packages/core/src/tools/cron-list.test.ts @@ -119,6 +119,24 @@ describe('CronListTool', () => { expect(result.returnDisplay).toContain('[durable]'); }); + it('surfaces a durable task name and its disabled status', async () => { + await writeCronTasks(tmpDir, [ + makeDurableTask({ id: 'off01', name: 'Weekly digest', enabled: false }), + ]); + + const invocation = tool.build({}); + const result = await invocation.execute(new AbortController().signal); + expect(result.error).toBeUndefined(); + // The disabled marker + name let the agent tell a disabled task apart from + // an active one and reference it by name. + expect(result.llmContent).toContain( + 'off01 — 0 */2 * * * (recurring) [durable, disabled]: Weekly digest: check deploy', + ); + expect(result.returnDisplay).toContain( + '[durable, disabled]: Weekly digest', + ); + }); + it('merges file-backed durable jobs with session-only jobs', async () => { await writeCronTasks(tmpDir, [makeDurableTask()]); config._scheduler.create('*/10 * * * *', 'session task', true); diff --git a/packages/core/src/tools/cron-list.ts b/packages/core/src/tools/cron-list.ts index 13ffe6b163b..eda689e14cd 100644 --- a/packages/core/src/tools/cron-list.ts +++ b/packages/core/src/tools/cron-list.ts @@ -22,6 +22,12 @@ interface ListedJob { recurring: boolean; durable: boolean; fireAtMs?: number; + /** Optional display name (durable tasks created via the management UI). */ + name?: string; + /** Absent (session-only jobs) or true = active; false = kept on disk but + * skipped by the scheduler. Surfaced so the agent can tell a disabled task + * apart from an active one. */ + enabled?: boolean; } function truncatePrompt(prompt: string): string { @@ -77,6 +83,8 @@ class CronListInvocation extends BaseToolInvocation< prompt: task.prompt, recurring: task.recurring, durable: true, + ...(task.name ? { name: task.name } : {}), + enabled: task.enabled !== false, })), ...scheduler .list() @@ -99,19 +107,23 @@ class CronListInvocation extends BaseToolInvocation< const llmLines = jobs.map((job) => { const type = job.recurring ? 'recurring' : 'one-shot'; const durability = job.durable ? 'durable' : 'session-only'; + // A disabled durable task stays on disk but never fires — mark it so the + // agent doesn't assume it is active. + const status = job.enabled === false ? ', disabled' : ''; const schedule = job.cron === '@wakeup' ? displaySchedule(job.cron, job.fireAtMs) : job.cron; const prompt = job.cron === '@wakeup' ? truncatePrompt(job.prompt) : job.prompt; - return `${job.id} — ${schedule} (${type}) [${durability}]: ${prompt}`; + const label = job.name ? `${job.name}: ` : ''; + return `${job.id} — ${schedule} (${type}) [${durability}${status}]: ${label}${prompt}`; }); const llmContent = llmLines.join('\n'); const displayLines = jobs.map( (job) => - `${job.id} ${displaySchedule(job.cron, job.fireAtMs)} [${job.durable ? 'durable' : 'session-only'}]`, + `${job.id} ${displaySchedule(job.cron, job.fireAtMs)} [${job.durable ? 'durable' : 'session-only'}${job.enabled === false ? ', disabled' : ''}]${job.name ? `: ${job.name}` : ''}`, ); const returnDisplay = displayLines.join('\n'); diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css index 5372644fd61..2be0df1d06f 100644 --- a/packages/web-shell/client/App.module.css +++ b/packages/web-shell/client/App.module.css @@ -61,6 +61,61 @@ overflow: hidden; } +/* Positioning context so the scheduled-tasks page (position:absolute) covers + exactly the chat pane. Only applied while the page is shown, so normal chat + layout is untouched. */ +.chatPaneShowingPage { + position: relative; +} + +/* Full-pane view that replaces the chat (messages + composer stay mounted + underneath, so returning to chat preserves their state). */ +.fullPage { + position: absolute; + inset: 0; + z-index: 20; + background: var(--background); + display: flex; + flex-direction: column; + overflow: hidden; +} +.fullPageHeader { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 10px; + padding: 14px 20px; + border-bottom: 1px solid var(--border); +} +.fullPageBack { + appearance: none; + border: none; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; + width: 32px; + height: 32px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; +} +.fullPageBack:hover { + background: var(--muted); + color: var(--foreground); +} +.fullPageTitle { + font-size: 16px; + font-weight: 600; + color: var(--foreground); +} +.fullPageBody { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 20px 24px; +} + .mobileDrawer { display: contents; } diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index c8defa466c8..351c8aa77d2 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -65,6 +65,7 @@ import { MemoryMessage } from './components/messages/MemoryMessage'; import { AuthMessage } from './components/messages/AuthMessage'; import { ToolsDialog } from './components/dialogs/ToolsDialog'; import { DaemonStatusDialog } from './components/dialogs/DaemonStatusDialog'; +import { ScheduledTasksDialog } from './components/dialogs/ScheduledTasksDialog'; import { ExtensionsDialog } from './components/dialogs/ExtensionsDialog'; import { SettingsMessage } from './components/messages/SettingsMessage'; import { resolveShellOutputMaxLines } from './components/messages/ToolGroup'; @@ -1193,6 +1194,10 @@ export function App({ const [showThemeDialog, setShowThemeDialog] = useState(false); const [showToolsDialog, setShowToolsDialog] = useState(false); const [showDaemonStatusDialog, setShowDaemonStatusDialog] = useState(false); + // Main content view. The scheduled-tasks page replaces the chat pane inline + // (not a modal overlay), mirroring the reference design; creating or opening + // a chat returns to 'chat'. + const [mainView, setMainView] = useState<'chat' | 'scheduledTasks'>('chat'); const [showExtensionsDialog, setShowExtensionsDialog] = useState(false); const [mcpDialogMessage, setMcpDialogMessage] = useState(null); @@ -1486,7 +1491,10 @@ export function App({ showSettingsDialog || showMemoryDialog || showAuthDialog; - const interactionBlocked = dialogOpen; + // Block chat interaction (composer, chat keyboard shortcuts) both when a + // modal is open and while a full-pane view (e.g. Scheduled Tasks) covers the + // chat, so keystrokes/Escape can't reach the hidden composer underneath. + const interactionBlocked = dialogOpen || mainView !== 'chat'; const reportError = useCallback( (error: unknown, fallback: string) => { @@ -2939,6 +2947,10 @@ export function App({ setShowSettingsDialog(true); return true; } + if (cmd === 'schedule') { + setMainView('scheduledTasks'); + return true; + } if (cmd === 'context') { const contextArg = text.slice(match[0].length).trim().toLowerCase(); if ( @@ -4087,15 +4099,31 @@ export function App({ closeMobileDrawer(); setShowDaemonStatusDialog(true); }} - onNewSession={createNewSession} - onLoadSession={loadSidebarSession} + onOpenScheduledTasks={() => { + closeMobileDrawer(); + setMainView('scheduledTasks'); + }} + onNewSession={() => { + setMainView('chat'); + return createNewSession(); + }} + onLoadSession={(sessionId) => { + setMainView('chat'); + return loadSidebarSession(sessionId); + }} onError={reportError} mobileOpen={mobileDrawerOpen} sessionListReloadToken={sessionListReloadToken} /> )} -
+
{sidebarOptions.enabled && ( )} + {mainView === 'scheduledTasks' && ( +
+
+ +
+ {t('scheduledTasks.title')} +
+
+
+ { + // Manual trigger reuses the normal prompt path: return + // to the chat view and send the task's prompt into the + // current session so the run streams in the chat. + setMainView('chat'); + sendPrompt(taskPrompt).catch((error: unknown) => { + reportError(error, 'Failed to run scheduled task'); + }); + }} + onCreateViaChat={() => { + // Return to chat and prime the composer so the user can + // describe the task in natural language; the agent + // creates it via its cron_create tool. Deferred so the + // composer is mounted/visible before we focus it. + setMainView('chat'); + window.setTimeout(() => { + editorRef.current?.insertText( + t('scheduledTasks.chatStarter'), + { mode: 'replace' }, + ); + editorRef.current?.focus(); + }, 0); + }} + onError={reportError} + /> +
+
+ )} void; + /** Switch to the chat view with the composer primed to describe a task, so + * the agent can create it conversationally via its cron_create tool. */ + onCreateViaChat: () => void; + onError: (error: unknown, fallback: string) => void; +} + +const FREQUENCIES: Frequency[] = [ + 'daily', + 'weekdays', + 'weekly', + 'hourly', + 'minutes', + 'custom', +]; + +const DEFAULT_BUILDER: BuilderState = { + frequency: 'daily', + time: '09:00', + weekday: 1, + minuteInterval: 30, + customCron: '0 9 * * *', +}; + +// Divisors of 60 only: a non-divisor `*/N` is anchored to the hour and fires +// more often than "every N minutes" implies, so the picker offers only values +// that actually mean "every N minutes". +const MINUTE_INTERVALS = [1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30]; + +export function ScheduledTasksDialog({ + onRunPrompt, + onCreateViaChat, + onError, +}: ScheduledTasksDialogProps) { + const { t } = useI18n(); + const actions = useWorkspaceActions(); + + const [tasks, setTasks] = useState(null); + const [loadError, setLoadError] = useState(null); + const [busyId, setBusyId] = useState(null); + + // Create form + const [showForm, setShowForm] = useState(false); + const [name, setName] = useState(''); + const [prompt, setPrompt] = useState(''); + const [builder, setBuilder] = useState(DEFAULT_BUILDER); + const [submitting, setSubmitting] = useState(false); + const [formError, setFormError] = useState(null); + + // Guard against setState after unmount (loads are async). + const mountedRef = useRef(true); + // Monotonic reload id: a slow mount/Refresh load that resolves after a + // create/toggle/delete's reload must not overwrite the newer list with + // stale data. Only the latest reload is allowed to apply its result. + const reloadSeqRef = useRef(0); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const reload = useCallback(async () => { + const seq = ++reloadSeqRef.current; + try { + const list = await actions.listScheduledTasks(); + if (!mountedRef.current || seq !== reloadSeqRef.current) return; + // Newest first — matches the reference "sort by created, descending". + const sorted = [...list].sort((a, b) => b.createdAt - a.createdAt); + setTasks(sorted); + setLoadError(null); + } catch (err) { + if (!mountedRef.current || seq !== reloadSeqRef.current) return; + setLoadError(err instanceof Error ? err.message : String(err)); + setTasks((prev) => prev ?? []); + } + }, [actions]); + + useEffect(() => { + void reload(); + }, [reload]); + + const previewCron = buildCron(builder); + const previewLabel = previewCron ? describeCron(previewCron, t) : null; + + const resetForm = useCallback(() => { + setName(''); + setPrompt(''); + setBuilder(DEFAULT_BUILDER); + setFormError(null); + setShowForm(false); + }, []); + + const handleCreate = useCallback(async () => { + const cron = buildCron(builder); + if (!cron) { + setFormError(t('scheduledTasks.error.invalidSchedule')); + return; + } + if (prompt.trim().length === 0) { + setFormError(t('scheduledTasks.error.emptyPrompt')); + return; + } + setSubmitting(true); + setFormError(null); + try { + await actions.createScheduledTask({ + cron, + prompt: prompt.trim(), + name: name.trim() || null, + recurring: true, + enabled: true, + }); + if (!mountedRef.current) return; + resetForm(); + await reload(); + } catch (err) { + if (!mountedRef.current) return; + setFormError(err instanceof Error ? err.message : String(err)); + } finally { + if (mountedRef.current) setSubmitting(false); + } + }, [actions, builder, name, prompt, reload, resetForm, t]); + + const handleToggle = useCallback( + async (task: DaemonScheduledTask) => { + setBusyId(task.id); + try { + await actions.updateScheduledTask(task.id, { enabled: !task.enabled }); + await reload(); + } catch (err) { + onError(err, t('scheduledTasks.error.toggleFailed')); + } finally { + if (mountedRef.current) setBusyId(null); + } + }, + [actions, onError, reload, t], + ); + + const handleDelete = useCallback( + async (task: DaemonScheduledTask) => { + // Truncate: an unnamed task falls back to its prompt, which can be up to + // MAX_PROMPT_LENGTH — too long for a confirm() dialog. + const raw = task.name || task.prompt; + const label = raw.length > 60 ? `${raw.slice(0, 57)}…` : raw; + if (!window.confirm(t('scheduledTasks.deleteConfirm', { name: label }))) { + return; + } + setBusyId(task.id); + try { + await actions.deleteScheduledTask(task.id); + await reload(); + } catch (err) { + onError(err, t('scheduledTasks.error.deleteFailed')); + } finally { + if (mountedRef.current) setBusyId(null); + } + }, + [actions, onError, reload, t], + ); + + return ( +
+
{t('scheduledTasks.subtitle')}
+ +
+
+ {tasks === null + ? t('scheduledTasks.loading') + : t('scheduledTasks.count', { count: tasks.length })} +
+
+ + + +
+
+ + {showForm && ( + +
+ + +