-
Notifications
You must be signed in to change notification settings - Fork 2.9k
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 2 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
Some comments aren't visible on the classic Files Changed page.
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,243 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2025 Qwen Team | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import express from 'express'; | ||
| import type { Request } from 'express'; | ||
| import { promises as fsp } from 'node:fs'; | ||
| import * as os from 'node:os'; | ||
| import * as path from 'node:path'; | ||
| import { afterEach, beforeEach, describe, expect, it } from 'vitest'; | ||
| import request from 'supertest'; | ||
| import { Storage } from '@qwen-code/qwen-code-core'; | ||
| import { registerScheduledTasksRoutes } from './scheduled-tasks.js'; | ||
|
|
||
| function safeBody(req: Request): Record<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 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'); | ||
| }); | ||
| }); | ||
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.