-
Notifications
You must be signed in to change notification settings - Fork 2.6k
feat(web-shell): add a Scheduled Tasks management page #6348
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
dfb6c9b
feat(web-shell): add scheduled tasks management page
wenshao 76c4744
chore(web-shell): address review feedback on scheduled tasks
wenshao 18a04af
chore(web-shell): address second review round on scheduled tasks
wenshao caadb8d
chore(web-shell): address third review round on scheduled tasks
wenshao e0d3c53
chore(web-shell): address fourth review round (minor suggestions)
wenshao 6698b03
chore(web-shell): address fifth review round on scheduled tasks
wenshao eee54fb
test(cli): cover legacy scheduled-task normalization on GET
wenshao 62485aa
fix(core): cap durable cron loads against a durable-only budget
wenshao File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'); | ||
|
wenshao marked this conversation as resolved.
|
||
| // 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) | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.