feat(web-shell): add a Scheduled Tasks management page (#6348)

* 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.

* chore(web-shell): address review feedback on scheduled tasks

- cron_list: surface name/enabled so the agent can tell a disabled durable task from an active one (a disabled task no longer looks identical to an active one).
- core: export only the tasks-file functions the daemon route actually uses (drop unused addCronTask / getCronFilePath / CRON_TASKS_DISPLAY_PATH from the public barrel).
- CronScheduler: warn when a durable reload fails and the prior view is kept, since a just-disabled or -deleted task can keep firing until the next successful reload.
- Extract the schedule helpers (buildCron / describeCron / parseHhmm / describeLastRun) into a pure module and add unit tests for them.
- Add route tests for PATCH cron/prompt/recurring, empty-patch rejection, and POST field-length / boolean-type validation.

* chore(web-shell): address second review round on scheduled tasks

- Log CRUD errors server-side (writeStderrLine) in each route catch block, matching the other daemon routes.
- Share one id generator (generateCronTaskId in cronTasksFile) between the scheduler and the daemon route instead of duplicating it.
- describeCron: recognize cron day-of-week 7 as an alternate notation for Sunday.
- Reset the builder time to :00 when switching to the hourly frequency (its time picker is hidden, so it no longer silently carries the daily minute).
- Tests: cron_list name/disabled output; route Feb-30 impossible-cron and corrupt-file 500 read-failure; describeCron dow=7.

* chore(web-shell): address third review round on scheduled tasks

- Run now: report sendPrompt rejections via the toast/error path instead of dropping the promise.
- Block chat interaction while the full-pane Scheduled Tasks view is open, so the covered composer can't receive keystrokes/Escape.
- Guard reload() with a request-sequence id so a slow load can't overwrite a newer list after a mutation.
- Re-enabling a task that had genuinely fired resumes from now instead of catching up work paused while it was disabled.
- Restrict "every N minutes" to divisors of 60 (a non-divisor */N fires more often than the label claims).
- Show a Repeats / Runs once label on each card so tool-created one-shots aren't mistaken for repeating schedules.
- Return generic 500 client messages (no internal file path); the detail is logged server-side.
- Tests: SDK scheduled-task methods (method/URL/id-encoding/headers/errors); route re-enable behavior both ways.

* chore(web-shell): address fourth review round (minor suggestions)

- Route error logs interpolate the actual task id instead of the literal ":id".
- cron_list returnDisplay includes the task name (matching llmContent) so terminal /cron list shows UI-assigned names.
- Truncate the delete-confirm label so an unnamed task's long prompt doesn't blow up the confirm() dialog.
- Cap the create-form prompt textarea at MAX_PROMPT_LENGTH and drop the dead typeof-window guard.
- Test generateCronTaskId (format + near-uniqueness).

* chore(web-shell): address fifth review round on scheduled tasks

- Re-enable now resumes any recurring task from now (stamp on every false→true), not only ones that had already fired — a task disabled before its first run no longer catch-up-fires the slot it was paused through.
- describeCron applies the same divisor-of-60 check as buildCron, so a hand-edited/persisted */45 falls back to the raw expression instead of a misleading "every 45 minutes".
- Strengthen the corrupt-file route test to assert the generic client message and no leaked file path.
- Tests: recurring-disabled-before-first-run and one-shot re-enable; describeCron non-divisor fallback.

* test(cli): cover legacy scheduled-task normalization on GET

Seed a pre-fields task (no name/enabled) directly to disk and assert the GET response normalizes it to name:null / enabled:true, guarding backward compatibility with existing scheduled_tasks.json files.

* fix(core): cap durable cron loads against a durable-only budget

The daemon route accepts up to MAX_JOBS durable tasks on disk, but the scheduler previously capped durable loads against its combined job map (session-only + durable). A session holding session-only cron jobs could push the map to MAX_JOBS and make loadFileTasks silently skip durable tasks the route had already accepted — a create that returned 201 would then never fire.

Cap durable installs against a durable-only count instead, and share one MAX_JOBS constant between the scheduler and the daemon route, so a successful create is always loadable. Adds a scheduler test that 40 session-only jobs no longer crowd out 20 durable loads.
This commit is contained in:
Shaojin Wen 2026-07-06 11:47:17 +08:00 committed by GitHub
parent fa6e0f942c
commit 9a63c03224
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 2748 additions and 24 deletions

View file

@ -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<string, unknown> {
return req.body && typeof req.body === 'object'
? (req.body as Record<string, unknown>)
: {};
}
interface Harness {
app: express.Application;
scratch: string;
workspace: string;
}
async function makeHarness(): Promise<Harness> {
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<void> {
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<string, unknown>) =>
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<string, unknown>) => {
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)
});
});

View file

@ -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<string, unknown>;
}
/** 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<DurableCronTask> = {};
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 };
}

View file

@ -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,

View file

@ -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';

View file

@ -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<DurableCronTask> & Pick<DurableCronTask, 'id'>,
): 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;

View file

@ -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

View file

@ -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);
});
});
});

View file

@ -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')
);
}

View file

@ -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);

View file

@ -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');

View file

@ -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;
}

View file

@ -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<SerializedMcpStatusMessage | null>(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}
/>
</div>
)}
<div className={styles.chatPane}>
<div
className={
mainView === 'scheduledTasks'
? `${styles.chatPane} ${styles.chatPaneShowingPage}`
: styles.chatPane
}
>
{sidebarOptions.enabled && (
<button
type="button"
@ -4119,6 +4147,64 @@ export function App({
</svg>
</button>
)}
{mainView === 'scheduledTasks' && (
<div className={styles.fullPage}>
<div className={styles.fullPageHeader}>
<button
type="button"
className={styles.fullPageBack}
onClick={() => setMainView('chat')}
aria-label={t('common.back')}
title={t('common.back')}
>
<svg
viewBox="0 0 24 24"
width="18"
height="18"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
<div className={styles.fullPageTitle}>
{t('scheduledTasks.title')}
</div>
</div>
<div className={styles.fullPageBody}>
<ScheduledTasksDialog
onRunPrompt={(taskPrompt) => {
// 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}
/>
</div>
</div>
)}
<WebShellCustomizationProvider value={customization}>
<CompactModeContext.Provider value={compactMode}>
<TodoContextsProvider

View file

@ -0,0 +1,344 @@
.root {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.intro {
font-size: 13px;
line-height: 1.5;
color: var(--muted-foreground);
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.count {
font-size: 13px;
font-weight: 600;
color: var(--foreground);
}
.toolbarActions {
display: flex;
align-items: center;
gap: 8px;
}
.primaryButton,
.secondaryButton {
appearance: none;
border-radius: 8px;
padding: 6px 14px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid var(--border);
transition:
background 0.15s ease,
opacity 0.15s ease;
}
.primaryButton {
background: var(--primary);
color: var(--background);
border-color: var(--primary);
}
.secondaryButton {
background: transparent;
color: var(--foreground);
}
.primaryButton:disabled,
.secondaryButton:disabled {
opacity: 0.55;
cursor: default;
}
.secondaryButton:hover:not(:disabled) {
background: var(--muted);
}
/* ── Create form ─────────────────────────────────────────────── */
/* Create form now lives inside a DialogShell modal, which supplies the panel
chrome (border/background/padding); this class only handles field layout. */
.formFields {
display: flex;
flex-direction: column;
gap: 14px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.fieldGrow {
flex: 1 1 220px;
}
.fieldLabel {
font-size: 12px;
font-weight: 600;
color: var(--muted-foreground);
}
.required {
color: var(--error-color);
margin-left: 2px;
}
.input,
.textarea,
.select {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 10px;
font-size: 13px;
background: var(--background);
color: var(--foreground);
font-family: inherit;
}
.textarea {
resize: vertical;
min-height: 72px;
line-height: 1.5;
}
.input:focus,
.textarea:focus,
.select:focus {
outline: 2px solid var(--primary);
outline-offset: -1px;
}
.scheduleRow {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-end;
}
.scheduleRow .field {
flex: 0 0 auto;
min-width: 140px;
}
.preview {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
font-size: 13px;
}
.previewLabel {
font-weight: 600;
color: var(--foreground);
}
.previewCron {
font-family: var(--font-mono, monospace);
font-size: 12px;
color: var(--muted-foreground);
background: var(--background);
border: 1px solid var(--border);
border-radius: 6px;
padding: 2px 6px;
}
.previewInvalid {
color: var(--warning-color);
font-size: 13px;
}
.formError,
.loadError {
color: var(--error-color);
font-size: 13px;
line-height: 1.5;
}
.formActions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* ── List ────────────────────────────────────────────────────── */
.empty {
padding: 32px 16px;
text-align: center;
color: var(--muted-foreground);
font-size: 13px;
}
.list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--background);
min-width: 0;
}
.cardDisabled {
opacity: 0.6;
}
.cardHeader {
display: flex;
align-items: center;
gap: 10px;
}
.cardTitle {
flex: 1 1 auto;
min-width: 0;
font-size: 14px;
font-weight: 600;
color: var(--foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cardMenu {
display: flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.iconAction {
appearance: none;
background: transparent;
border: none;
color: var(--muted-foreground);
cursor: pointer;
border-radius: 6px;
width: 26px;
height: 26px;
font-size: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.iconAction:hover:not(:disabled) {
background: var(--muted);
color: var(--foreground);
}
.iconAction:disabled {
opacity: 0.5;
cursor: default;
}
/* Toggle switch */
.toggle {
appearance: none;
flex: 0 0 auto;
position: relative;
width: 36px;
height: 20px;
border-radius: 999px;
border: none;
background: var(--border);
cursor: pointer;
padding: 0;
transition: background 0.15s ease;
}
.toggle:disabled {
cursor: default;
opacity: 0.6;
}
.toggleOn {
background: var(--primary);
}
.toggleKnob {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
transition: transform 0.15s ease;
}
.toggleOn .toggleKnob {
transform: translateX(16px);
}
.cardPrompt {
font-size: 12px;
line-height: 1.5;
color: var(--muted-foreground);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.cardFooter {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
margin-top: auto;
}
.schedulePill {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 500;
color: var(--foreground);
background: var(--muted);
border-radius: 999px;
padding: 3px 10px;
}
.clockIcon {
font-size: 12px;
color: var(--muted-foreground);
}
.recurringTag {
font-size: 11px;
color: var(--muted-foreground);
}
.lastFired {
font-size: 11px;
color: var(--muted-foreground);
margin-left: auto;
}

View file

@ -0,0 +1,499 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import {
useWorkspaceActions,
type DaemonScheduledTask,
} from '@qwen-code/webui/daemon-react-sdk';
import { useI18n } from '../../i18n';
import { DialogShell } from './DialogShell';
import {
buildCron,
describeCron,
describeLastRun,
type BuilderState,
type Frequency,
} from './scheduledTasksSchedule';
import styles from './ScheduledTasksDialog.module.css';
interface ScheduledTasksDialogProps {
/** Manual "run now": inject the task's prompt into the current session and
* (in the App wiring) close this dialog so the run is visible in the chat. */
onRunPrompt: (prompt: string) => 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<DaemonScheduledTask[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
// Create form
const [showForm, setShowForm] = useState(false);
const [name, setName] = useState('');
const [prompt, setPrompt] = useState('');
const [builder, setBuilder] = useState<BuilderState>(DEFAULT_BUILDER);
const [submitting, setSubmitting] = useState(false);
const [formError, setFormError] = useState<string | null>(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 (
<div className={styles.root}>
<div className={styles.intro}>{t('scheduledTasks.subtitle')}</div>
<div className={styles.toolbar}>
<div className={styles.count}>
{tasks === null
? t('scheduledTasks.loading')
: t('scheduledTasks.count', { count: tasks.length })}
</div>
<div className={styles.toolbarActions}>
<button
type="button"
className={styles.secondaryButton}
onClick={() => void reload()}
>
{t('scheduledTasks.refresh')}
</button>
<button
type="button"
className={styles.secondaryButton}
onClick={onCreateViaChat}
>
{t('scheduledTasks.createViaChat')}
</button>
<button
type="button"
className={styles.primaryButton}
onClick={() => setShowForm(true)}
>
{t('scheduledTasks.new')}
</button>
</div>
</div>
{showForm && (
<DialogShell
title={t('scheduledTasks.new')}
size="md"
onClose={resetForm}
>
<div className={styles.formFields}>
<label className={styles.field}>
<span className={styles.fieldLabel}>
{t('scheduledTasks.name')}
</span>
<input
className={styles.input}
type="text"
value={name}
maxLength={200}
placeholder={t('scheduledTasks.namePlaceholder')}
onChange={(e) => setName(e.target.value)}
/>
</label>
<label className={styles.field}>
<span className={styles.fieldLabel}>
{t('scheduledTasks.prompt')}
<span className={styles.required}>*</span>
</span>
<textarea
className={styles.textarea}
value={prompt}
rows={4}
maxLength={100_000}
placeholder={t('scheduledTasks.promptPlaceholder')}
onChange={(e) => setPrompt(e.target.value)}
/>
</label>
<div className={styles.scheduleRow}>
<label className={styles.field}>
<span className={styles.fieldLabel}>
{t('scheduledTasks.frequency')}
</span>
<select
className={styles.select}
value={builder.frequency}
onChange={(e) => {
const frequency = e.target.value as Frequency;
setBuilder((b) => ({
...b,
frequency,
// The time picker is hidden for hourly, so reset the
// minute to :00 instead of silently carrying over the
// minute picked for a daily/weekly schedule.
...(frequency === 'hourly' ? { time: '00:00' } : {}),
}));
}}
>
{FREQUENCIES.map((f) => (
<option key={f} value={f}>
{t(`scheduledTasks.freq.${f}`)}
</option>
))}
</select>
</label>
{(builder.frequency === 'daily' ||
builder.frequency === 'weekdays' ||
builder.frequency === 'weekly') && (
<label className={styles.field}>
<span className={styles.fieldLabel}>
{t('scheduledTasks.time')}
</span>
<input
className={styles.input}
type="time"
value={builder.time}
onChange={(e) =>
setBuilder((b) => ({ ...b, time: e.target.value }))
}
/>
</label>
)}
{builder.frequency === 'weekly' && (
<label className={styles.field}>
<span className={styles.fieldLabel}>
{t('scheduledTasks.weekday')}
</span>
<select
className={styles.select}
value={builder.weekday}
onChange={(e) =>
setBuilder((b) => ({
...b,
weekday: Number(e.target.value),
}))
}
>
{t('scheduledTasks.weekdayNames')
.split(',')
.map((label, idx) => (
<option key={idx} value={idx}>
{label}
</option>
))}
</select>
</label>
)}
{builder.frequency === 'minutes' && (
<label className={styles.field}>
<span className={styles.fieldLabel}>
{t('scheduledTasks.interval')}
</span>
<select
className={styles.select}
value={builder.minuteInterval}
onChange={(e) =>
setBuilder((b) => ({
...b,
minuteInterval: Number(e.target.value),
}))
}
>
{MINUTE_INTERVALS.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</label>
)}
{builder.frequency === 'custom' && (
<label className={`${styles.field} ${styles.fieldGrow}`}>
<span className={styles.fieldLabel}>
{t('scheduledTasks.cron')}
</span>
<input
className={styles.input}
type="text"
value={builder.customCron}
spellCheck={false}
placeholder="0 9 * * 1-5"
onChange={(e) =>
setBuilder((b) => ({ ...b, customCron: e.target.value }))
}
/>
</label>
)}
</div>
<div className={styles.preview}>
{previewLabel ? (
<>
<span className={styles.previewLabel}>{previewLabel}</span>
<code className={styles.previewCron}>{previewCron}</code>
</>
) : (
<span className={styles.previewInvalid}>
{t('scheduledTasks.error.invalidSchedule')}
</span>
)}
</div>
{formError && <div className={styles.formError}>{formError}</div>}
<div className={styles.formActions}>
<button
type="button"
className={styles.secondaryButton}
onClick={resetForm}
disabled={submitting}
>
{t('scheduledTasks.cancel')}
</button>
<button
type="button"
className={styles.primaryButton}
onClick={() => void handleCreate()}
disabled={submitting}
>
{submitting
? t('scheduledTasks.creating')
: t('scheduledTasks.create')}
</button>
</div>
</div>
</DialogShell>
)}
{loadError && <div className={styles.loadError}>{loadError}</div>}
{tasks !== null && tasks.length === 0 && !loadError && (
<div className={styles.empty}>{t('scheduledTasks.empty')}</div>
)}
<div className={styles.list}>
{(tasks ?? []).map((task) => {
const title = task.name || task.prompt;
const busy = busyId === task.id;
return (
<div
key={task.id}
className={`${styles.card} ${task.enabled ? '' : styles.cardDisabled}`}
>
<div className={styles.cardHeader}>
<button
type="button"
role="switch"
aria-checked={task.enabled}
aria-label={
task.enabled
? t('scheduledTasks.disable')
: t('scheduledTasks.enable')
}
className={`${styles.toggle} ${task.enabled ? styles.toggleOn : ''}`}
onClick={() => void handleToggle(task)}
disabled={busy}
>
<span className={styles.toggleKnob} />
</button>
<div className={styles.cardTitle} title={title}>
{title}
</div>
<div className={styles.cardMenu}>
<button
type="button"
className={styles.iconAction}
onClick={() => onRunPrompt(task.prompt)}
title={t('scheduledTasks.runNow')}
aria-label={t('scheduledTasks.runNow')}
>
</button>
<button
type="button"
className={styles.iconAction}
onClick={() => void handleDelete(task)}
disabled={busy}
title={t('scheduledTasks.delete')}
aria-label={t('scheduledTasks.delete')}
>
</button>
</div>
</div>
{task.name && (
<div className={styles.cardPrompt} title={task.prompt}>
{task.prompt}
</div>
)}
<div className={styles.cardFooter}>
<span className={styles.schedulePill}>
<span className={styles.clockIcon} aria-hidden="true">
</span>
{describeCron(task.cron, t)}
</span>
<span className={styles.recurringTag}>
{t(
task.recurring
? 'scheduledTasks.repeats'
: 'scheduledTasks.runsOnce',
)}
</span>
<span className={styles.lastFired}>
{describeLastRun(task, t)}
</span>
</div>
</div>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,149 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
buildCron,
describeCron,
describeLastRun,
parseHhmm,
type BuilderState,
type TranslateFn,
} from './scheduledTasksSchedule';
// Fake t: echoes the key with its interpolation vars so assertions can check
// both which message was chosen and the values passed in. weekdayNames returns
// a real comma-joined list so describeCron's weekly branch can index it.
const t: TranslateFn = (key, vars) => {
if (key === 'scheduledTasks.weekdayNames')
return 'Sun,Mon,Tue,Wed,Thu,Fri,Sat';
if (!vars) return key;
const parts = Object.entries(vars)
.map(([k, v]) => `${k}=${v}`)
.join(',');
return `${key}(${parts})`;
};
const builder = (over: Partial<BuilderState>): BuilderState => ({
frequency: 'daily',
time: '09:00',
weekday: 1,
minuteInterval: 30,
customCron: '0 9 * * *',
...over,
});
describe('parseHhmm', () => {
it('parses valid HH:MM', () => {
expect(parseHhmm('09:30')).toEqual({ hh: 9, mm: 30 });
expect(parseHhmm('23:59')).toEqual({ hh: 23, mm: 59 });
expect(parseHhmm('0:05')).toEqual({ hh: 0, mm: 5 });
});
it('rejects out-of-range and malformed values', () => {
expect(parseHhmm('24:00')).toBeNull();
expect(parseHhmm('12:60')).toBeNull();
expect(parseHhmm('9:5')).toBeNull(); // minutes must be two digits
expect(parseHhmm('abc')).toBeNull();
expect(parseHhmm('')).toBeNull();
});
});
describe('buildCron', () => {
it('builds each frequency shape', () => {
expect(buildCron(builder({ frequency: 'daily', time: '09:30' }))).toBe(
'30 9 * * *',
);
expect(buildCron(builder({ frequency: 'weekdays', time: '12:30' }))).toBe(
'30 12 * * 1-5',
);
expect(
buildCron(builder({ frequency: 'weekly', time: '10:00', weekday: 1 })),
).toBe('0 10 * * 1');
expect(buildCron(builder({ frequency: 'hourly', time: '00:07' }))).toBe(
'7 * * * *',
);
expect(
buildCron(builder({ frequency: 'minutes', minuteInterval: 15 })),
).toBe('*/15 * * * *');
expect(
buildCron(builder({ frequency: 'custom', customCron: '0 9 * * 1-5' })),
).toBe('0 9 * * 1-5');
});
it('returns null for invalid inputs', () => {
expect(
buildCron(builder({ frequency: 'custom', customCron: ' ' })),
).toBeNull();
expect(
buildCron(builder({ frequency: 'minutes', minuteInterval: 0 })),
).toBeNull();
expect(
buildCron(builder({ frequency: 'minutes', minuteInterval: 60 })),
).toBeNull();
// Non-divisors of 60 are rejected — */45 would not mean "every 45 minutes".
expect(
buildCron(builder({ frequency: 'minutes', minuteInterval: 45 })),
).toBeNull();
expect(
buildCron(builder({ frequency: 'daily', time: '25:00' })),
).toBeNull();
});
});
describe('describeCron', () => {
it('labels the builder-emitted shapes', () => {
expect(describeCron('0 9 * * *', t)).toBe(
'scheduledTasks.human.daily(time=09:00)',
);
expect(describeCron('30 12 * * 1-5', t)).toBe(
'scheduledTasks.human.weekdays(time=12:30)',
);
expect(describeCron('0 9 * * 1', t)).toBe(
'scheduledTasks.human.weekly(day=Mon,time=09:00)',
);
expect(describeCron('0 9 * * 0', t)).toBe(
'scheduledTasks.human.weekly(day=Sun,time=09:00)',
);
// Cron allows 7 as an alternate notation for Sunday.
expect(describeCron('0 9 * * 7', t)).toBe(
'scheduledTasks.human.weekly(day=Sun,time=09:00)',
);
expect(describeCron('15 * * * *', t)).toBe(
'scheduledTasks.human.hourly(min=15)',
);
expect(describeCron('*/30 * * * *', t)).toBe(
'scheduledTasks.human.everyMinutes(n=30)',
);
});
it('falls back to the raw expression for anything else', () => {
expect(describeCron('0 0 1 1 *', t)).toBe('0 0 1 1 *'); // day-of-month pinned
expect(describeCron('0 9 * * 1-3', t)).toBe('0 9 * * 1-3'); // weekday range
expect(describeCron('not a cron', t)).toBe('not a cron'); // not 5 fields
// Non-divisor */N: fires irregularly, so it is not labeled "every N min".
expect(describeCron('*/45 * * * *', t)).toBe('*/45 * * * *');
expect(describeCron('*/7 * * * *', t)).toBe('*/7 * * * *');
});
});
describe('describeLastRun', () => {
const created = 1_700_000_000_000; // arbitrary fixed ms
const createdMinute = created - (created % 60_000);
it('reads as "never" for a fresh (creation-minute) or null stamp', () => {
expect(describeLastRun({ createdAt: created, lastFiredAt: null }, t)).toBe(
'scheduledTasks.never',
);
expect(
describeLastRun({ createdAt: created, lastFiredAt: createdMinute }, t),
).toBe('scheduledTasks.never');
});
it('reads as a real run once lastFiredAt advances past the creation minute', () => {
const label = describeLastRun(
{ createdAt: created, lastFiredAt: createdMinute + 60_000 },
t,
);
expect(label.startsWith('scheduledTasks.lastFired(')).toBe(true);
});
});

View file

@ -0,0 +1,178 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Pure cron-schedule helpers for the Scheduled Tasks page building a cron
* expression from the form's builder inputs, and turning a cron expression /
* last-fired timestamp into a localized label. Kept free of React and of the
* SDK task type so they can be unit-tested in isolation.
*/
export type Frequency =
| 'daily'
| 'weekdays'
| 'weekly'
| 'hourly'
| 'minutes'
| 'custom';
export interface BuilderState {
frequency: Frequency;
time: string; // "HH:MM"
weekday: number; // 0..6, Sun..Sat
minuteInterval: number;
customCron: string;
}
/** Minimal `t()` shape — a key plus optional interpolation vars. */
export type TranslateFn = (
key: string,
vars?: Record<string, string | number>,
) => string;
export 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). */
export 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);
// Only divisors of 60: `*/N` with a non-divisor (e.g. */45) is anchored to
// the hour, so it fires at :00 and :45 then :00 again — far more often than
// "every 45 minutes" claims. Reject non-divisors so the label stays honest.
if (!Number.isFinite(n) || n < 1 || n > 30 || 60 % n !== 0) 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. */
export function describeCron(cron: string, t: TranslateFn): 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 * * * * — only honest divisors of 60 (matching buildCron) get the
// "every N minutes" label; a non-divisor like */45 is anchored to the hour
// and fires irregularly, so fall back to the raw expression for a
// hand-edited / persisted one rather than mislabel it.
if (
/^\*\/\d+$/.test(min!) &&
hour === '*' &&
dom === '*' &&
mon === '*' &&
dow === '*'
) {
const n = Number(min!.slice(2));
if (Number.isInteger(n) && n >= 1 && n <= 30 && 60 % n === 0) {
return t('scheduledTasks.human.everyMinutes', { n });
}
return cron;
}
// 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. Cron allows 0 and 7 for Sunday.
if (
isNum(min!) &&
isNum(hour!) &&
dom === '*' &&
mon === '*' &&
isNum(dow!) &&
Number(dow) >= 0 &&
Number(dow) <= 7
) {
const names = t('scheduledTasks.weekdayNames').split(',');
const dayIndex = Number(dow) === 7 ? 0 : Number(dow);
const name = names[dayIndex] ?? dow!;
return t('scheduledTasks.human.weekly', {
day: name,
time: `${pad(hour!)}:${pad(min!)}`,
});
}
return cron;
}
/** "Last run: " label, or "never run" for a task that has not genuinely
* fired. 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, so anything at or before the creation minute reads as "never". */
export function describeLastRun(
task: { createdAt: number; lastFiredAt: number | null },
t: TranslateFn,
): string {
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 });
}

View file

@ -111,6 +111,7 @@ function renderSidebar(
overrides: Partial<{
onOpenSettings: () => void;
onOpenDaemonStatus: () => void;
onOpenScheduledTasks: () => void;
onLoadSession: (sessionId: string) => Promise<void> | void;
onError: (error: unknown, message: string) => void;
sessionListReloadToken: number;
@ -128,6 +129,7 @@ function renderSidebar(
onCollapsedChange={noop}
onOpenSettings={noop}
onOpenDaemonStatus={noop}
onOpenScheduledTasks={noop}
onNewSession={() => false}
onLoadSession={noop}
onError={noop}

View file

@ -86,6 +86,7 @@ interface WebShellSidebarProps {
onCollapsedChange: (collapsed: boolean) => void;
onOpenSettings: () => void;
onOpenDaemonStatus: () => void;
onOpenScheduledTasks: () => void;
onNewSession: () => Promise<boolean> | boolean;
onLoadSession: (sessionId: string) => Promise<void> | void;
onError: (error: unknown, fallback: string) => void;
@ -233,6 +234,15 @@ function IconPulse() {
);
}
function IconSchedule() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</svg>
);
}
function IconRename() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
@ -422,6 +432,7 @@ export function WebShellSidebar({
onCollapsedChange,
onOpenSettings,
onOpenDaemonStatus,
onOpenScheduledTasks,
onNewSession,
onLoadSession,
onError,
@ -2451,6 +2462,15 @@ export function WebShellSidebar({
{versionLabel}
</span>
)}
<button
className={styles.collapseButton}
type="button"
title={t('sidebar.scheduledTasks')}
aria-label={t('sidebar.scheduledTasks')}
onClick={onOpenScheduledTasks}
>
<IconSchedule />
</button>
<button
className={styles.collapseButton}
type="button"

View file

@ -108,6 +108,7 @@ export function getLocalCommands(t: Translate): CommandInfo[] {
argumentHint: '<session-id>',
},
{ name: 'settings', description: t('local.settings') },
{ name: 'schedule', description: t('local.schedule') },
{
name: 'extensions',
description: t('local.extensions'),

View file

@ -555,6 +555,58 @@ const EN: Messages = {
'quickActions.shellMode': 'Shell mode',
'quickActions.exitShellMode': 'Exit Shell',
'quickActions.setGoal': 'Set goal',
// Scheduled tasks page
'scheduledTasks.title': 'Scheduled Tasks',
'scheduledTasks.subtitle':
'Run tasks automatically on a schedule, or trigger them manually anytime.',
'scheduledTasks.loading': 'Loading…',
'scheduledTasks.count': (v) =>
`${v?.count ?? 0} task${Number(v?.count ?? 0) === 1 ? '' : 's'}`,
'scheduledTasks.refresh': 'Refresh',
'scheduledTasks.new': 'New scheduled task',
'scheduledTasks.createViaChat': 'Create via chat',
'scheduledTasks.chatStarter':
'Help me set up a recurring scheduled task (keep it long-term). What I want: ',
'scheduledTasks.name': 'Name',
'scheduledTasks.namePlaceholder': 'Optional — defaults to the prompt',
'scheduledTasks.prompt': 'Prompt',
'scheduledTasks.promptPlaceholder': 'What should this task do?',
'scheduledTasks.frequency': 'Frequency',
'scheduledTasks.freq.daily': 'Daily',
'scheduledTasks.freq.weekdays': 'Weekdays',
'scheduledTasks.freq.weekly': 'Weekly',
'scheduledTasks.freq.hourly': 'Hourly',
'scheduledTasks.freq.minutes': 'Every N minutes',
'scheduledTasks.freq.custom': 'Custom (cron)',
'scheduledTasks.time': 'Time',
'scheduledTasks.weekday': 'Weekday',
'scheduledTasks.interval': 'Minutes',
'scheduledTasks.cron': 'Cron expression',
'scheduledTasks.weekdayNames': 'Sun,Mon,Tue,Wed,Thu,Fri,Sat',
'scheduledTasks.human.daily': (v) => `Daily at ${v?.time ?? ''}`,
'scheduledTasks.human.weekdays': (v) => `Weekdays at ${v?.time ?? ''}`,
'scheduledTasks.human.weekly': (v) =>
`Every ${v?.day ?? ''} at ${v?.time ?? ''}`,
'scheduledTasks.human.hourly': (v) => `Hourly at :${v?.min ?? '00'}`,
'scheduledTasks.human.everyMinutes': (v) => `Every ${v?.n ?? ''} minutes`,
'scheduledTasks.never': 'Never run',
'scheduledTasks.repeats': 'Repeats',
'scheduledTasks.runsOnce': 'Runs once',
'scheduledTasks.lastFired': (v) => `Last run: ${v?.when ?? ''}`,
'scheduledTasks.runNow': 'Run now',
'scheduledTasks.enable': 'Enable',
'scheduledTasks.disable': 'Disable',
'scheduledTasks.delete': 'Delete',
'scheduledTasks.deleteConfirm': (v) =>
`Delete scheduled task "${v?.name ?? ''}"?`,
'scheduledTasks.empty': 'No scheduled tasks yet. Create one to get started.',
'scheduledTasks.create': 'Create',
'scheduledTasks.creating': 'Creating…',
'scheduledTasks.cancel': 'Cancel',
'scheduledTasks.error.invalidSchedule': 'Invalid schedule',
'scheduledTasks.error.emptyPrompt': 'Prompt is required',
'scheduledTasks.error.toggleFailed': 'Failed to update task',
'scheduledTasks.error.deleteFailed': 'Failed to delete task',
'sidebar.label': 'Workspace sidebar',
'sidebar.toggleMenu': 'Toggle menu',
'sidebar.newChat': 'New chat',
@ -562,6 +614,7 @@ const EN: Messages = {
'sidebar.projectFallback': 'Project',
'sidebar.settings': 'Settings',
'sidebar.daemonStatus': 'Daemon Status',
'sidebar.scheduledTasks': 'Scheduled Tasks',
'sidebar.collapse': 'Collapse',
'sidebar.expand': 'Expand',
'sidebar.collapseProject': 'Collapse project',
@ -871,6 +924,7 @@ const EN: Messages = {
'local.status': 'Show version info',
'local.theme': 'Change theme',
'local.settings': 'View and edit settings',
'local.schedule': 'Manage scheduled tasks',
'local.extensions':
'Manage extensions. Usage: /extensions manage|install <source>',
'extensions.label': 'extension',
@ -2010,6 +2064,54 @@ const ZH: Messages = {
'quickActions.shellMode': 'Shell模式',
'quickActions.exitShellMode': '退出Shell',
'quickActions.setGoal': '设置目标',
// 定时任务页面
'scheduledTasks.title': '定时任务',
'scheduledTasks.subtitle': '按计划自动执行任务,也可随时手动触发。',
'scheduledTasks.loading': '加载中…',
'scheduledTasks.count': (v) => `${v?.count ?? 0} 个任务`,
'scheduledTasks.refresh': '刷新',
'scheduledTasks.new': '新建定时任务',
'scheduledTasks.createViaChat': '通过聊天创建',
'scheduledTasks.chatStarter': '帮我创建一个长期保留的定时任务,我想:',
'scheduledTasks.name': '名称',
'scheduledTasks.namePlaceholder': '可选 —— 默认取提示词',
'scheduledTasks.prompt': '提示词',
'scheduledTasks.promptPlaceholder': '这个任务要做什么?',
'scheduledTasks.frequency': '频率',
'scheduledTasks.freq.daily': '每天',
'scheduledTasks.freq.weekdays': '工作日',
'scheduledTasks.freq.weekly': '每周',
'scheduledTasks.freq.hourly': '每小时',
'scheduledTasks.freq.minutes': '每 N 分钟',
'scheduledTasks.freq.custom': '自定义cron',
'scheduledTasks.time': '时间',
'scheduledTasks.weekday': '星期',
'scheduledTasks.interval': '分钟',
'scheduledTasks.cron': 'Cron 表达式',
'scheduledTasks.weekdayNames': '周日,周一,周二,周三,周四,周五,周六',
'scheduledTasks.human.daily': (v) => `每天 ${v?.time ?? ''}`,
'scheduledTasks.human.weekdays': (v) => `工作日 ${v?.time ?? ''}`,
'scheduledTasks.human.weekly': (v) => `${v?.day ?? ''} ${v?.time ?? ''}`,
'scheduledTasks.human.hourly': (v) => `每小时 第 ${v?.min ?? '00'}`,
'scheduledTasks.human.everyMinutes': (v) => `${v?.n ?? ''} 分钟`,
'scheduledTasks.never': '尚未运行',
'scheduledTasks.repeats': '重复',
'scheduledTasks.runsOnce': '仅一次',
'scheduledTasks.lastFired': (v) => `上次运行:${v?.when ?? ''}`,
'scheduledTasks.runNow': '立即运行',
'scheduledTasks.enable': '启用',
'scheduledTasks.disable': '停用',
'scheduledTasks.delete': '删除',
'scheduledTasks.deleteConfirm': (v) =>
`确定删除定时任务「${v?.name ?? ''}」?`,
'scheduledTasks.empty': '还没有定时任务,创建一个开始吧。',
'scheduledTasks.create': '创建',
'scheduledTasks.creating': '创建中…',
'scheduledTasks.cancel': '取消',
'scheduledTasks.error.invalidSchedule': '计划无效',
'scheduledTasks.error.emptyPrompt': '提示词不能为空',
'scheduledTasks.error.toggleFailed': '更新任务失败',
'scheduledTasks.error.deleteFailed': '删除任务失败',
'sidebar.label': '工作区侧边栏',
'sidebar.toggleMenu': '切换菜单',
'sidebar.newChat': '新对话',
@ -2017,6 +2119,7 @@ const ZH: Messages = {
'sidebar.projectFallback': '项目',
'sidebar.settings': '设置',
'sidebar.daemonStatus': 'Daemon 状态',
'sidebar.scheduledTasks': '定时任务',
'sidebar.collapse': '收起',
'sidebar.expand': '展开',
'sidebar.collapseProject': '收起项目',
@ -2311,6 +2414,7 @@ const ZH: Messages = {
'local.status': '查看版本信息',
'local.theme': '切换主题',
'local.settings': '查看和编辑设置',
'local.schedule': '管理定时任务',
'local.extensions': '管理扩展。用法: /extensions manage|install <source>',
'extensions.label': '扩展',
'extensions.action.failed': (v) =>

View file

@ -78,6 +78,11 @@ export default defineConfig(({ command }) => ({
'/stat': daemonProxy,
'/list': daemonProxy,
'/glob': daemonProxy,
// Scheduled-tasks CRUD (the Scheduled Tasks dialog). Prefix-matches
// `/scheduled-tasks` and `/scheduled-tasks/:id`. Like the routes above,
// without it the SPA fallback returns index.html in dev and the dialog
// fails JSON parsing / reports an HTTP error on open.
'/scheduled-tasks': daemonProxy,
// Voice dictation is a WebSocket (`/voice/stream`); `ws: true` makes the
// dev proxy forward the HTTP upgrade to the daemon. Scope it to the exact
// path — a bare `/voice` prefix would shadow the client's own

View file

@ -272,6 +272,12 @@ export type {
DaemonGlobOptions,
/** Glob match result containing matched file paths. */
DaemonGlobResult,
/** A durable scheduled task (cron) as returned by the daemon. */
DaemonScheduledTask,
/** Request body for creating a scheduled task. */
DaemonCreateScheduledTaskRequest,
/** Partial-update body for a scheduled task. */
DaemonUpdateScheduledTaskRequest,
/** Memory file scope: `'workspace' | 'global'`. */
DaemonContextFileScope,
} from './daemon/index.js';

View file

@ -85,6 +85,9 @@ export type {
DaemonFileStat,
DaemonGlobOptions,
DaemonGlobResult,
DaemonScheduledTask,
DaemonCreateScheduledTaskRequest,
DaemonUpdateScheduledTaskRequest,
DaemonResourceOptions,
DaemonStatusReportOptions,
DaemonWorkspaceActions,

View file

@ -9,6 +9,7 @@ import { withActionTimeout } from '../timing.js';
import type {
DaemonDirectoryListing,
DaemonFileStat,
DaemonScheduledTask,
DaemonWorkspaceActions,
} from './types.js';
@ -454,6 +455,85 @@ export function createDaemonWorkspaceActions({
return (await res.json()) as DaemonDirectoryListing;
},
// Scheduled tasks (durable cron). Raw fetch like glob/stat/list — the
// /scheduled-tasks routes are REST-only and not yet on the DaemonClient
// transport, so this path only reaches the daemon over plain HTTP (the
// web-shell's own origin), which is exactly where the page runs.
async listScheduledTasks() {
requireClient(getClient, 'List scheduled tasks failed');
const url = createDaemonRequestUrl(baseUrl, '/scheduled-tasks');
const res = await withActionTimeout(
fetch(serializeDaemonRequestUrl(url, baseUrl), {
headers: createDaemonHeaders(token),
}),
'List scheduled tasks timed out',
);
if (!res.ok) {
throw new Error(await readDaemonError(res, 'GET /scheduled-tasks'));
}
const data = (await res.json()) as { tasks?: DaemonScheduledTask[] };
return Array.isArray(data.tasks) ? data.tasks : [];
},
async createScheduledTask(req) {
requireClient(getClient, 'Create scheduled task failed');
const url = createDaemonRequestUrl(baseUrl, '/scheduled-tasks');
const res = await withActionTimeout(
fetch(serializeDaemonRequestUrl(url, baseUrl), {
method: 'POST',
headers: createDaemonJsonHeaders(token),
body: JSON.stringify(req),
}),
'Create scheduled task timed out',
);
if (!res.ok) {
throw new Error(await readDaemonError(res, 'POST /scheduled-tasks'));
}
return (await res.json()) as DaemonScheduledTask;
},
async updateScheduledTask(id, patch) {
requireClient(getClient, 'Update scheduled task failed');
const url = createDaemonRequestUrl(
baseUrl,
`/scheduled-tasks/${encodeURIComponent(id)}`,
);
const res = await withActionTimeout(
fetch(serializeDaemonRequestUrl(url, baseUrl), {
method: 'PATCH',
headers: createDaemonJsonHeaders(token),
body: JSON.stringify(patch),
}),
'Update scheduled task timed out',
);
if (!res.ok) {
throw new Error(
await readDaemonError(res, `PATCH /scheduled-tasks/${id}`),
);
}
return (await res.json()) as DaemonScheduledTask;
},
async deleteScheduledTask(id) {
requireClient(getClient, 'Delete scheduled task failed');
const url = createDaemonRequestUrl(
baseUrl,
`/scheduled-tasks/${encodeURIComponent(id)}`,
);
const res = await withActionTimeout(
fetch(serializeDaemonRequestUrl(url, baseUrl), {
method: 'DELETE',
headers: createDaemonHeaders(token),
}),
'Delete scheduled task timed out',
);
if (!res.ok) {
throw new Error(
await readDaemonError(res, `DELETE /scheduled-tasks/${id}`),
);
}
},
async loadEnv() {
const client = requireClient(getClient, 'Load env failed');
return withActionTimeout(client.workspaceEnv(), 'Load env timed out');
@ -629,6 +709,16 @@ function createDaemonHeaders(token: string | undefined): HeadersInit {
return headers;
}
// Same as createDaemonHeaders but with a JSON content-type, for the
// POST/PATCH scheduled-task writes that carry a body.
function createDaemonJsonHeaders(token: string | undefined): HeadersInit {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (token) headers['Authorization'] = `Bearer ${token}`;
return headers;
}
function createDaemonRequestUrl(baseUrl: string, path: string): URL {
const normalizedBaseUrl = stripTrailingSlashes(baseUrl);
const fallbackBase =

View file

@ -16,6 +16,9 @@ export type {
DaemonFileStat,
DaemonGlobOptions,
DaemonGlobResult,
DaemonScheduledTask,
DaemonCreateScheduledTaskRequest,
DaemonUpdateScheduledTaskRequest,
DaemonResourceOptions,
DaemonWorkspaceActions,
DaemonWorkspaceContextValue,

View file

@ -0,0 +1,108 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createDaemonWorkspaceActions } from './actions.js';
// The scheduled-task methods use raw global fetch (like glob/stat/list), so we
// stub fetch and assert the HTTP method, path (with id encoding), JSON body,
// auth header, and error handling.
function makeActions(token?: string) {
return createDaemonWorkspaceActions({
// These methods never touch the client, only requireClient's non-null check.
getClient: () => ({}) as never,
getWorkspaceCwd: () => '/ws',
baseUrl: '',
token,
});
}
const ok = (body: unknown) => ({
ok: true,
status: 200,
json: async () => body,
});
const fail = (status: number, body: unknown) => ({
ok: false,
status,
json: async () => body,
});
describe('scheduled-tasks workspace actions', () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
const initOf = (call: unknown[]) => call[1] as RequestInit;
const headersOf = (init: RequestInit) =>
(init.headers ?? {}) as Record<string, string>;
it('lists tasks with a GET and returns the tasks array', async () => {
fetchMock.mockResolvedValue(
ok({ v: 1, tasks: [{ id: 'a' }, { id: 'b' }] }),
);
const tasks = await makeActions('tok').listScheduledTasks();
expect(tasks).toEqual([{ id: 'a' }, { id: 'b' }]);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/scheduled-tasks');
expect(initOf(fetchMock.mock.calls[0]).method ?? 'GET').toBe('GET');
expect(headersOf(init)['Authorization']).toBe('Bearer tok');
});
it('returns [] when the response omits tasks', async () => {
fetchMock.mockResolvedValue(ok({ v: 1 }));
expect(await makeActions().listScheduledTasks()).toEqual([]);
});
it('creates a task with a POST + JSON body', async () => {
fetchMock.mockResolvedValue(ok({ id: 'x' }));
const res = await makeActions().createScheduledTask({
cron: '0 9 * * *',
prompt: 'p',
});
expect(res).toEqual({ id: 'x' });
const init = initOf(fetchMock.mock.calls[0]);
expect(fetchMock.mock.calls[0][0]).toBe('/scheduled-tasks');
expect(init.method).toBe('POST');
expect(headersOf(init)['Content-Type']).toBe('application/json');
expect(JSON.parse(init.body as string)).toMatchObject({
cron: '0 9 * * *',
prompt: 'p',
});
});
it('updates with a PATCH and URL-encodes the id', async () => {
fetchMock.mockResolvedValue(ok({ id: 'a/b' }));
await makeActions().updateScheduledTask('a/b', { enabled: false });
const init = initOf(fetchMock.mock.calls[0]);
expect(fetchMock.mock.calls[0][0]).toBe('/scheduled-tasks/a%2Fb');
expect(init.method).toBe('PATCH');
expect(JSON.parse(init.body as string)).toEqual({ enabled: false });
});
it('deletes with a DELETE', async () => {
fetchMock.mockResolvedValue(ok({ deleted: true, id: 'id1' }));
await makeActions().deleteScheduledTask('id1');
const init = initOf(fetchMock.mock.calls[0]);
expect(fetchMock.mock.calls[0][0]).toBe('/scheduled-tasks/id1');
expect(init.method).toBe('DELETE');
});
it('throws with the server error message on a non-ok response', async () => {
fetchMock.mockResolvedValue(
fail(400, { error: 'bad cron', code: 'invalid_cron' }),
);
await expect(
makeActions().createScheduledTask({ cron: 'x', prompt: 'p' }),
).rejects.toThrow(/bad cron/);
});
});

View file

@ -152,6 +152,43 @@ export interface DaemonGlobResult {
matches: string[];
}
// ── Scheduled Tasks (durable cron, server-only) ─────────────────────
/** A durable scheduled task as returned by the daemon. `name`/`enabled` are
* normalized (never undefined): `name: null` = unnamed, `enabled` defaults to
* true for tasks created before the field existed. */
export interface DaemonScheduledTask {
id: string;
name: string | null;
cron: string;
prompt: string;
recurring: boolean;
enabled: boolean;
createdAt: number;
lastFiredAt: number | null;
}
export interface DaemonCreateScheduledTaskRequest {
cron: string;
prompt: string;
/** Omit or null for an unnamed task. */
name?: string | null;
/** Defaults to true (fire on every match until deleted/expired). */
recurring?: boolean;
/** Defaults to true. */
enabled?: boolean;
}
/** Partial update. `name: null` (or '') clears the name. Omitted fields are
* left unchanged. */
export interface DaemonUpdateScheduledTaskRequest {
cron?: string;
prompt?: string;
name?: string | null;
recurring?: boolean;
enabled?: boolean;
}
export interface DaemonWorkspaceActions {
// Sessions
listSessions(
@ -259,6 +296,17 @@ export interface DaemonWorkspaceActions {
stat(filePath: string): Promise<DaemonFileStat>;
listDirectory(dirPath: string): Promise<DaemonDirectoryListing>;
// Scheduled tasks (durable cron)
listScheduledTasks(): Promise<DaemonScheduledTask[]>;
createScheduledTask(
req: DaemonCreateScheduledTaskRequest,
): Promise<DaemonScheduledTask>;
updateScheduledTask(
id: string,
patch: DaemonUpdateScheduledTaskRequest,
): Promise<DaemonScheduledTask>;
deleteScheduledTask(id: string): Promise<void>;
// Providers / env (read-only diagnostics)
loadProviders(): Promise<DaemonWorkspaceProvidersStatus>;
loadEnv(): Promise<DaemonWorkspaceEnvStatus>;