From dfb6c9bd6fec046f8ef4d4d0ee9bc1fff14399a5 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Mon, 6 Jul 2026 00:44:16 +0800 Subject: [PATCH 1/8] feat(web-shell): add scheduled tasks management page Add a "Scheduled tasks" page to the Web Shell for managing durable cron tasks against the current workspace. - Sidebar entry opens a full-pane page (replaces the chat area, not a modal) listing tasks with enable/disable toggle, delete, run-now, and human-readable schedules. - "New scheduled task" opens a modal with a schedule builder (daily / weekdays / weekly / hourly / every-N-minutes / custom cron) and a live preview. - "Create via chat" returns to the chat and primes the composer so the agent creates the task through its cron_create tool. - Daemon CRUD routes (GET/POST/PATCH/DELETE /scheduled-tasks) read/write the existing per-project scheduled_tasks.json; task firing stays with the session-side scheduler. - Extend DurableCronTask with optional name/enabled (backward compatible); the scheduler skips tasks with enabled:false. - Add /scheduled-tasks to the vite dev-server proxy allowlist so the page works under npm run dev:daemon. --- .../src/serve/routes/scheduled-tasks.test.ts | 163 +++++ .../cli/src/serve/routes/scheduled-tasks.ts | 397 +++++++++++ packages/cli/src/serve/server.ts | 11 + packages/core/src/index.ts | 8 + .../core/src/services/cronScheduler.test.ts | 70 ++ packages/core/src/services/cronScheduler.ts | 12 +- .../core/src/services/cronTasksFile.test.ts | 32 + packages/core/src/services/cronTasksFile.ts | 21 +- packages/web-shell/client/App.module.css | 55 ++ packages/web-shell/client/App.tsx | 87 ++- .../dialogs/ScheduledTasksDialog.module.css | 338 ++++++++++ .../dialogs/ScheduledTasksDialog.tsx | 618 ++++++++++++++++++ .../sidebar/WebShellSidebar.test.tsx | 2 + .../components/sidebar/WebShellSidebar.tsx | 20 + .../client/constants/localCommands.ts | 1 + packages/web-shell/client/i18n.tsx | 100 +++ packages/web-shell/vite.config.ts | 5 + packages/webui/src/daemon-react-sdk.ts | 6 + packages/webui/src/daemon/index.ts | 3 + .../webui/src/daemon/workspace/actions.ts | 90 +++ packages/webui/src/daemon/workspace/index.ts | 3 + packages/webui/src/daemon/workspace/types.ts | 48 ++ 22 files changed, 2085 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/serve/routes/scheduled-tasks.test.ts create mode 100644 packages/cli/src/serve/routes/scheduled-tasks.ts create mode 100644 packages/web-shell/client/components/dialogs/ScheduledTasksDialog.module.css create mode 100644 packages/web-shell/client/components/dialogs/ScheduledTasksDialog.tsx 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..371a3f2c4b8 --- /dev/null +++ b/packages/cli/src/serve/routes/scheduled-tasks.test.ts @@ -0,0 +1,163 @@ +/** + * @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 } 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 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'); + }); +}); 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..55882140617 --- /dev/null +++ b/packages/cli/src/serve/routes/scheduled-tasks.ts @@ -0,0 +1,397 @@ +/** + * @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, + parseCron, + nextFireTime, + type DurableCronTask, +} from '@qwen-code/qwen-code-core'; + +// Mirrors the scheduler's own MAX_JOBS (cronScheduler.ts): durable tasks share +// that in-memory job map and loadFileTasks silently caps installs at this +// number, so a file with more entries would persist tasks that never load. +// Rejecting past the cap here turns that silent drop into a clean 409. +const MAX_SCHEDULED_TASKS = 50; +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, + }; +} + +// 8-char base36, matching cronScheduler's generateId scheme so route-created +// and tool-created durable tasks are indistinguishable on disk (and share the +// short-id space CronDelete accepts). Math.random is fine here — ids only need +// to be unique within a <50-entry file, not unpredictable. +function generateTaskId(): string { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + let id = ''; + for (let i = 0; i < 8; i++) { + id += chars[Math.floor(Math.random() * chars.length)]; + } + return id; +} + +// 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 []. + res.status(500).json({ + error: err instanceof Error ? err.message : 'Failed to read tasks', + 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: generateTaskId(), + 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) { + res.status(500).json({ + error: err instanceof Error ? err.message : 'Failed to create 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; + updated = next; + return tasks.map((t, i) => (i === idx ? next : t)); + }); + } catch (err) { + res.status(500).json({ + error: err instanceof Error ? err.message : 'Failed to update 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) { + res.status(500).json({ + error: err instanceof Error ? err.message : 'Failed to delete 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..9f7657d23a8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -208,6 +208,14 @@ export { export * from './services/chatRecordingService.js'; export * from './services/cronScheduler.js'; export type { DurableCronTask } from './services/cronTasksFile.js'; +export { + readCronTasks, + addCronTask, + updateCronTasks, + removeCronTasks, + getCronFilePath, + CRON_TASKS_DISPLAY_PATH, +} 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..3f754164073 100644 --- a/packages/core/src/services/cronScheduler.test.ts +++ b/packages/core/src/services/cronScheduler.test.ts @@ -937,6 +937,76 @@ 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 }, + ); + }); + }); + describe('durable ownership', () => { let tmpDir: string; diff --git a/packages/core/src/services/cronScheduler.ts b/packages/core/src/services/cronScheduler.ts index 48724afa477..4fec087278c 100644 --- a/packages/core/src/services/cronScheduler.ts +++ b/packages/core/src/services/cronScheduler.ts @@ -643,7 +643,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[] = []; diff --git a/packages/core/src/services/cronTasksFile.test.ts b/packages/core/src/services/cronTasksFile.test.ts index 7f585f4dd39..445eba95ea7 100644 --- a/packages/core/src/services/cronTasksFile.test.ts +++ b/packages/core/src/services/cronTasksFile.test.ts @@ -134,6 +134,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', () => { diff --git a/packages/core/src/services/cronTasksFile.ts b/packages/core/src/services/cronTasksFile.ts index 64b1f22bf7c..54a9168751f 100644 --- a/packages/core/src/services/cronTasksFile.ts +++ b/packages/core/src/services/cronTasksFile.ts @@ -23,6 +23,19 @@ 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; } const TASKS_FILENAME = 'scheduled_tasks.json'; @@ -259,6 +272,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/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..c53a19df29d 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); @@ -2939,6 +2944,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 +4096,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'); + void sendPrompt(taskPrompt); + }} + 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; +} + +type Frequency = + | 'daily' + | 'weekdays' + | 'weekly' + | 'hourly' + | 'minutes' + | 'custom'; + +const FREQUENCIES: Frequency[] = [ + 'daily', + 'weekdays', + 'weekly', + 'hourly', + 'minutes', + 'custom', +]; + +interface BuilderState { + frequency: Frequency; + time: string; // "HH:MM" + weekday: number; // 0..6, Sun..Sat + minuteInterval: number; + customCron: string; +} + +const DEFAULT_BUILDER: BuilderState = { + frequency: 'daily', + time: '09:00', + weekday: 1, + minuteInterval: 30, + customCron: '0 9 * * *', +}; + +function parseHhmm(time: string): { hh: number; mm: number } | null { + const m = /^(\d{1,2}):(\d{2})$/.exec(time.trim()); + if (!m) return null; + const hh = Number(m[1]); + const mm = Number(m[2]); + if (hh < 0 || hh > 23 || mm < 0 || mm > 59) return null; + return { hh, mm }; +} + +/** Build a 5-field cron from the builder inputs. Returns null when the inputs + * for the chosen frequency are invalid (the caller surfaces a form error). */ +function buildCron(state: BuilderState): string | null { + if (state.frequency === 'custom') { + const cron = state.customCron.trim(); + return cron.length > 0 ? cron : null; + } + if (state.frequency === 'minutes') { + const n = Math.floor(state.minuteInterval); + if (!Number.isFinite(n) || n < 1 || n > 59) return null; + return `*/${n} * * * *`; + } + const t = parseHhmm(state.time); + if (!t) return null; + switch (state.frequency) { + case 'daily': + return `${t.mm} ${t.hh} * * *`; + case 'weekdays': + return `${t.mm} ${t.hh} * * 1-5`; + case 'weekly': + return `${t.mm} ${t.hh} * * ${state.weekday}`; + case 'hourly': + return `${t.mm} * * * *`; + default: + return null; + } +} + +/** Human-readable schedule label, localized. Covers the shapes the builder + * emits (the common cases in the reference design); anything else — including + * ranges/lists a power user hand-writes — falls back to the raw expression so + * the label is never wrong, only sometimes terse. */ +function describeCron( + cron: string, + t: (key: string, vars?: Record) => string, +): string { + const parts = cron.trim().split(/\s+/); + if (parts.length !== 5) return cron; + const [min, hour, dom, mon, dow] = parts; + const isNum = (s: string) => /^\d+$/.test(s); + const pad = (n: string) => n.padStart(2, '0'); + + // */N * * * * + if ( + /^\*\/\d+$/.test(min!) && + hour === '*' && + dom === '*' && + mon === '*' && + dow === '*' + ) { + return t('scheduledTasks.human.everyMinutes', { n: min!.slice(2) }); + } + // M * * * * → hourly at minute M + if ( + isNum(min!) && + hour === '*' && + dom === '*' && + mon === '*' && + dow === '*' + ) { + return t('scheduledTasks.human.hourly', { min: pad(min!) }); + } + // M H * * * → daily + if ( + isNum(min!) && + isNum(hour!) && + dom === '*' && + mon === '*' && + dow === '*' + ) { + return t('scheduledTasks.human.daily', { + time: `${pad(hour!)}:${pad(min!)}`, + }); + } + // M H * * 1-5 → weekdays + if ( + isNum(min!) && + isNum(hour!) && + dom === '*' && + mon === '*' && + dow === '1-5' + ) { + return t('scheduledTasks.human.weekdays', { + time: `${pad(hour!)}:${pad(min!)}`, + }); + } + // M H * * D → weekly on a single weekday + if ( + isNum(min!) && + isNum(hour!) && + dom === '*' && + mon === '*' && + isNum(dow!) && + Number(dow) >= 0 && + Number(dow) <= 6 + ) { + const names = t('scheduledTasks.weekdayNames').split(','); + const name = names[Number(dow)] ?? dow!; + return t('scheduledTasks.human.weekly', { + day: name, + time: `${pad(hour!)}:${pad(min!)}`, + }); + } + return cron; +} + +function describeLastRun( + task: DaemonScheduledTask, + t: (key: string, vars?: Record) => string, +): string { + // A fresh task is stamped with `lastFiredAt = floor(createdAt)` so the + // scheduler can't fire it during its creation minute — that stamp is NOT a + // real run. Only a genuine fire advances lastFiredAt past the creation + // minute, so anything at or before it reads as "never run". + const createdMinute = task.createdAt - (task.createdAt % 60_000); + if (task.lastFiredAt === null || task.lastFiredAt <= createdMinute) { + return t('scheduledTasks.never'); + } + let when: string; + try { + when = new Date(task.lastFiredAt).toLocaleString(); + } catch { + return t('scheduledTasks.never'); + } + return t('scheduledTasks.lastFired', { when }); +} + +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); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const reload = useCallback(async () => { + try { + const list = await actions.listScheduledTasks(); + if (!mountedRef.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) 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) => { + const label = task.name || task.prompt; + if ( + typeof window !== 'undefined' && + !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 && ( + +
+ + +