mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-10 01:29:17 +00:00
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:
parent
fa6e0f942c
commit
9a63c03224
27 changed files with 2748 additions and 24 deletions
360
packages/cli/src/serve/routes/scheduled-tasks.test.ts
Normal file
360
packages/cli/src/serve/routes/scheduled-tasks.test.ts
Normal 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)
|
||||
});
|
||||
});
|
||||
414
packages/cli/src/serve/routes/scheduled-tasks.ts
Normal file
414
packages/cli/src/serve/routes/scheduled-tasks.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -85,6 +85,9 @@ export type {
|
|||
DaemonFileStat,
|
||||
DaemonGlobOptions,
|
||||
DaemonGlobResult,
|
||||
DaemonScheduledTask,
|
||||
DaemonCreateScheduledTaskRequest,
|
||||
DaemonUpdateScheduledTaskRequest,
|
||||
DaemonResourceOptions,
|
||||
DaemonStatusReportOptions,
|
||||
DaemonWorkspaceActions,
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ export type {
|
|||
DaemonFileStat,
|
||||
DaemonGlobOptions,
|
||||
DaemonGlobResult,
|
||||
DaemonScheduledTask,
|
||||
DaemonCreateScheduledTaskRequest,
|
||||
DaemonUpdateScheduledTaskRequest,
|
||||
DaemonResourceOptions,
|
||||
DaemonWorkspaceActions,
|
||||
DaemonWorkspaceContextValue,
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue