diff --git a/src/web-dashboard-server.ts b/src/web-dashboard-server.ts index 51f4c43..ad69677 100644 --- a/src/web-dashboard-server.ts +++ b/src/web-dashboard-server.ts @@ -2,9 +2,7 @@ import fs from 'fs'; import path from 'path'; import { execFileSync } from 'child_process'; -import { CronExpressionParser } from 'cron-parser'; - -import { TIMEZONE, WEB_DASHBOARD } from './config.js'; +import { WEB_DASHBOARD } from './config.js'; import { createTask, deleteTask, @@ -40,11 +38,7 @@ import type { RegisteredGroup, ScheduledTask, } from './types.js'; -import { isWatchCiTask } from './task-watch-status.js'; -import { - buildWebDashboardOverview, - sanitizeScheduledTask, -} from './web-dashboard-data.js'; +import { buildWebDashboardOverview } from './web-dashboard-data.js'; import { handleRoomTimelineRoute, startRoomsTimelineCacheRefresh, @@ -56,11 +50,11 @@ import { type ServiceRestartRecord, } from './web-dashboard-service-routes.js'; import { handleSettingsRoute } from './web-dashboard-settings-routes.js'; +import { handleScheduledTaskRoute } from './web-dashboard-task-routes.js'; const DEFAULT_STATUS_MAX_AGE_MS = 10 * 60 * 1000; const ROOM_MESSAGE_ID_CACHE_LIMIT = 500; const STACK_RESTART_UNIT_NAME = 'ejclaw-stack-restart.service'; -const WEB_TASK_PROMPT_MAX_LENGTH = 8000; const MANAGED_SERVICE_CALLER_FALLBACK_MESSAGE = 'Stack restart unit is not installed yet. Run setup service from an external shell before retrying from a managed EJClaw service.'; const UNIT_NOT_FOUND_PATTERN = @@ -247,7 +241,6 @@ function serveStaticFile(staticDir: string, pathname: string): Response { }); } -type TaskAction = 'pause' | 'resume' | 'cancel'; type InboxAction = 'run' | 'decline' | 'dismiss'; interface InboxActionRequest { @@ -256,26 +249,6 @@ interface InboxActionRequest { lastOccurredAt: string | null; } -function parseTaskPath(pathname: string): string | null { - const match = pathname.match(/^\/api\/tasks\/([^/]+)$/); - if (!match) return null; - try { - return decodeURIComponent(match[1]); - } catch { - return null; - } -} - -function parseTaskActionPath(pathname: string): string | null { - const match = pathname.match(/^\/api\/tasks\/([^/]+)\/actions$/); - if (!match) return null; - try { - return decodeURIComponent(match[1]); - } catch { - return null; - } -} - function parseInboxActionPath(pathname: string): string | null { const match = pathname.match(/^\/api\/inbox\/([^/]+)\/actions$/); if (!match) return null; @@ -311,24 +284,6 @@ function parsePairedInboxTarget( }; } -function isTaskAction(value: unknown): value is TaskAction { - return value === 'pause' || value === 'resume' || value === 'cancel'; -} - -function isScheduleType( - value: unknown, -): value is ScheduledTask['schedule_type'] { - return value === 'cron' || value === 'interval' || value === 'once'; -} - -function isContextMode(value: unknown): value is ScheduledTask['context_mode'] { - return value === 'group' || value === 'isolated'; -} - -function isAgentType(value: unknown): value is AgentType { - return value === 'claude-code' || value === 'codex'; -} - function isInboxAction(value: unknown): value is InboxAction { return value === 'run' || value === 'decline' || value === 'dismiss'; } @@ -345,15 +300,6 @@ function isPairedTaskStatus(value: unknown): value is PairedTask['status'] { ); } -async function readTaskAction(request: Request): Promise { - try { - const body = (await request.json()) as { action?: unknown }; - return isTaskAction(body.action) ? body.action : null; - } catch { - return null; - } -} - async function readInboxAction( request: Request, ): Promise { @@ -377,108 +323,6 @@ async function readInboxAction( } } -interface ScheduledTaskMutationBody { - roomJid?: unknown; - groupFolder?: unknown; - prompt?: unknown; - scheduleType?: unknown; - scheduleValue?: unknown; - contextMode?: unknown; - agentType?: unknown; - requestId?: unknown; -} - -async function readScheduledTaskMutationBody( - request: Request, -): Promise { - try { - return (await request.json()) as ScheduledTaskMutationBody; - } catch { - return null; - } -} - -function normalizeNonEmptyString(value: unknown): string | null { - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - return trimmed ? trimmed : null; -} - -function normalizeTaskPrompt(value: unknown): string | null { - const prompt = normalizeNonEmptyString(value); - if (!prompt) return null; - return prompt.length > WEB_TASK_PROMPT_MAX_LENGTH - ? prompt.slice(0, WEB_TASK_PROMPT_MAX_LENGTH) - : prompt; -} - -function computeInitialNextRun( - scheduleType: ScheduledTask['schedule_type'], - scheduleValue: string, - nowIso: string, -): { nextRun: string | null; error?: string } { - if (scheduleType === 'cron') { - try { - const interval = CronExpressionParser.parse(scheduleValue, { - tz: TIMEZONE, - }); - return { nextRun: interval.next().toISOString() }; - } catch { - return { nextRun: null, error: 'Invalid cron schedule' }; - } - } - - if (scheduleType === 'interval') { - const ms = Number.parseInt(scheduleValue, 10); - if (!Number.isFinite(ms) || ms <= 0) { - return { nextRun: null, error: 'Invalid interval schedule' }; - } - return { - nextRun: new Date(new Date(nowIso).getTime() + ms).toISOString(), - }; - } - - const date = new Date(scheduleValue); - if (Number.isNaN(date.getTime())) { - return { nextRun: null, error: 'Invalid timestamp schedule' }; - } - return { nextRun: date.toISOString() }; -} - -function resolveTaskRoom( - body: ScheduledTaskMutationBody, - roomBindings: Record, -): { chatJid: string; group: RegisteredGroup } | null { - const roomJid = normalizeNonEmptyString(body.roomJid); - const groupFolder = normalizeNonEmptyString(body.groupFolder); - if (roomJid && roomBindings[roomJid]) { - return { chatJid: roomJid, group: roomBindings[roomJid] }; - } - if (groupFolder) { - const match = Object.entries(roomBindings).find( - ([, group]) => group.folder === groupFolder, - ); - if (match) return { chatJid: match[0], group: match[1] }; - } - return null; -} - -function makeWebTaskId(): string { - return `web-task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -} - -function sanitizeScheduledTaskRequestId(value: unknown): string | null { - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - if (!trimmed) return null; - const safe = trimmed.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 120); - return safe || null; -} - -function makeWebTaskIdFromRequest(requestId: string | null): string { - return requestId ? `web-task-${requestId}` : makeWebTaskId(); -} - function sanitizeRoomMessageRequestId(value: unknown): string | null { if (typeof value !== 'string') return null; const trimmed = value.trim(); @@ -713,49 +557,22 @@ export function createWebDashboardHandler( return async (request: Request): Promise => { const url = new URL(request.url); - const actionTaskId = parseTaskActionPath(url.pathname); - const taskId = parseTaskPath(url.pathname); const actionInboxId = parseInboxActionPath(url.pathname); const messageRoomJid = parseRoomMessagePath(url.pathname); - if (actionTaskId) { - if (request.method !== 'POST') { - return jsonResponse({ error: 'Method not allowed' }, { status: 405 }); - } - - const action = await readTaskAction(request); - if (!action) { - return jsonResponse({ error: 'Invalid task action' }, { status: 400 }); - } - - const task = loadTaskById(actionTaskId); - if (!task) { - return jsonResponse({ error: 'Task not found' }, { status: 404 }); - } - - if (task.status === 'completed' && action !== 'cancel') { - return jsonResponse( - { error: 'Completed tasks cannot be changed' }, - { status: 409 }, - ); - } - - if (action === 'cancel') { - removeTask(actionTaskId); - return jsonResponse({ ok: true, id: actionTaskId, deleted: true }); - } - - mutateTask(actionTaskId, { - status: action === 'pause' ? 'paused' : 'active', - suspended_until: null, - }); - - const updatedTask = loadTaskById(actionTaskId); - return jsonResponse({ - ok: true, - task: updatedTask ? sanitizeScheduledTask(updatedTask) : null, - }); - } + const scheduledTaskRoute = await handleScheduledTaskRoute({ + url, + request, + jsonResponse, + createScheduledTask, + loadRoomBindings, + loadTaskById, + mutateTask, + nudgeScheduler, + removeTask, + now: opts.now, + }); + if (scheduledTaskRoute) return scheduledTaskRoute; if (actionInboxId) { if (request.method !== 'POST') { @@ -992,165 +809,6 @@ export function createWebDashboardHandler( }); if (settingsRoute) return settingsRoute; - if (url.pathname === '/api/tasks' && request.method === 'POST') { - if (!loadRoomBindings) { - return jsonResponse( - { error: 'Task creation is not configured' }, - { status: 503 }, - ); - } - - const body = await readScheduledTaskMutationBody(request); - const prompt = normalizeTaskPrompt(body?.prompt); - const scheduleType = - body && isScheduleType(body.scheduleType) ? body.scheduleType : null; - const scheduleValue = normalizeNonEmptyString(body?.scheduleValue); - if (!body || !prompt || !scheduleType || !scheduleValue) { - return jsonResponse( - { - error: - 'Task prompt, schedule type, and schedule value are required', - }, - { status: 400 }, - ); - } - - const room = resolveTaskRoom(body, loadRoomBindings()); - if (!room) { - return jsonResponse({ error: 'Room not found' }, { status: 404 }); - } - - const nowIso = opts.now?.() ?? new Date().toISOString(); - const next = computeInitialNextRun(scheduleType, scheduleValue, nowIso); - if (next.error) { - return jsonResponse({ error: next.error }, { status: 400 }); - } - - const contextMode = isContextMode(body.contextMode) - ? body.contextMode - : 'isolated'; - const agentType = isAgentType(body.agentType) - ? body.agentType - : (room.group.agentType ?? 'claude-code'); - const requestId = sanitizeScheduledTaskRequestId(body.requestId); - const id = makeWebTaskIdFromRequest(requestId); - const existing = requestId ? loadTaskById(id) : undefined; - if (existing) { - return jsonResponse({ - ok: true, - duplicate: true, - task: sanitizeScheduledTask(existing), - }); - } - - createScheduledTask({ - id, - group_folder: room.group.folder, - chat_jid: room.chatJid, - agent_type: agentType, - prompt, - schedule_type: scheduleType, - schedule_value: scheduleValue, - context_mode: contextMode, - next_run: next.nextRun, - status: 'active', - created_at: nowIso, - }); - - const created = loadTaskById(id); - if ( - next.nextRun && - new Date(next.nextRun).getTime() <= new Date(nowIso).getTime() - ) { - nudgeScheduler?.(); - } - return jsonResponse({ - ok: true, - task: created ? sanitizeScheduledTask(created) : null, - }); - } - - if (taskId && request.method === 'PATCH') { - const task = loadTaskById(taskId); - if (!task) { - return jsonResponse({ error: 'Task not found' }, { status: 404 }); - } - if (task.status === 'completed') { - return jsonResponse( - { error: 'Completed tasks cannot be edited' }, - { status: 409 }, - ); - } - if (isWatchCiTask(task)) { - return jsonResponse( - { error: 'CI watchers cannot be edited here' }, - { status: 409 }, - ); - } - - const body = await readScheduledTaskMutationBody(request); - if (!body) { - return jsonResponse({ error: 'Invalid task update' }, { status: 400 }); - } - if ( - body.roomJid !== undefined || - body.groupFolder !== undefined || - body.contextMode !== undefined || - body.agentType !== undefined - ) { - return jsonResponse( - { error: 'Task room, context, and agent cannot be edited here' }, - { status: 400 }, - ); - } - - const prompt = - body.prompt === undefined - ? task.prompt - : normalizeTaskPrompt(body.prompt); - const scheduleChanged = - body.scheduleType !== undefined || body.scheduleValue !== undefined; - const scheduleType = - body.scheduleType === undefined - ? task.schedule_type - : isScheduleType(body.scheduleType) - ? body.scheduleType - : null; - const scheduleValue = - body.scheduleValue === undefined - ? task.schedule_value - : normalizeNonEmptyString(body.scheduleValue); - if (!prompt || !scheduleType || !scheduleValue) { - return jsonResponse({ error: 'Invalid task update' }, { status: 400 }); - } - - const updates: Parameters[1] = { - prompt, - schedule_type: scheduleType, - schedule_value: scheduleValue, - }; - - if (scheduleChanged) { - const next = computeInitialNextRun( - scheduleType, - scheduleValue, - opts.now?.() ?? new Date().toISOString(), - ); - if (next.error) { - return jsonResponse({ error: next.error }, { status: 400 }); - } - updates.next_run = next.nextRun; - updates.suspended_until = null; - } - - mutateTask(taskId, updates); - const updatedTask = loadTaskById(taskId); - return jsonResponse({ - ok: true, - task: updatedTask ? sanitizeScheduledTask(updatedTask) : null, - }); - } - if (request.method !== 'GET' && request.method !== 'HEAD') { return jsonResponse({ error: 'Method not allowed' }, { status: 405 }); } diff --git a/src/web-dashboard-task-routes.test.ts b/src/web-dashboard-task-routes.test.ts new file mode 100644 index 0000000..cfc17c2 --- /dev/null +++ b/src/web-dashboard-task-routes.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from 'vitest'; + +import type { RegisteredGroup, ScheduledTask } from './types.js'; +import { + handleScheduledTaskRoute, + type ScheduledTaskRouteDependencies, +} from './web-dashboard-task-routes.js'; + +function jsonResponse(value: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(value), { + ...init, + headers: { + 'content-type': 'application/json; charset=utf-8', + ...(init?.headers as Record | undefined), + }, + }); +} + +function request( + pathname: string, + method: string, + body?: Record, +): Request { + return new Request(`http://localhost${pathname}`, { + method, + body: body === undefined ? undefined : JSON.stringify(body), + headers: + body === undefined ? undefined : { 'content-type': 'application/json' }, + }); +} + +function makeTask(overrides: Partial): ScheduledTask { + return { + id: 'task-1', + group_folder: 'ops-room', + chat_jid: 'dc:ops', + agent_type: null, + status_message_id: null, + status_started_at: null, + prompt: 'regular scheduled task', + schedule_type: 'once', + schedule_value: '2026-04-26T05:20:00.000Z', + context_mode: 'isolated', + next_run: '2026-04-26T05:20:00.000Z', + last_run: null, + last_result: null, + status: 'active', + suspended_until: null, + created_at: '2026-04-26T05:00:00.000Z', + ...overrides, + }; +} + +function makeDeps( + tasks: Map, + overrides: Partial = {}, +): ScheduledTaskRouteDependencies { + return { + createScheduledTask: (task) => { + tasks.set(task.id, { + ...task, + agent_type: task.agent_type ?? null, + ci_provider: null, + ci_metadata: null, + max_duration_ms: null, + status_message_id: null, + status_started_at: null, + last_run: null, + last_result: null, + suspended_until: null, + }); + }, + loadTaskById: (id) => tasks.get(id), + mutateTask: (id, updates) => { + const task = tasks.get(id); + if (task) tasks.set(id, { ...task, ...updates }); + }, + removeTask: (id) => { + tasks.delete(id); + }, + ...overrides, + }; +} + +async function route({ + body, + deps, + method, + now, + pathname, +}: { + body?: Record; + deps: ScheduledTaskRouteDependencies; + method: string; + now?: () => string; + pathname: string; +}): Promise { + return handleScheduledTaskRoute({ + url: new URL(`http://localhost${pathname}`), + request: request(pathname, method, body), + jsonResponse, + now, + ...deps, + }); +} + +describe('web dashboard scheduled task routes', () => { + it('creates scheduled tasks with room resolution and duplicate request ids', async () => { + const tasks = new Map(); + const rooms: Record = { + 'dc:ops': { + name: '#ops', + folder: 'ops-room', + added_at: '2026-04-26T05:00:00.000Z', + agentType: 'codex', + }, + }; + let nudges = 0; + const deps = makeDeps(tasks, { + loadRoomBindings: () => rooms, + nudgeScheduler: () => { + nudges += 1; + }, + }); + + const create = await route({ + pathname: '/api/tasks', + method: 'POST', + body: { + roomJid: 'dc:ops', + prompt: ' run deployment audit ', + requestId: 'task-create-1', + scheduleType: 'once', + scheduleValue: '2026-04-26T05:10:00.000Z', + contextMode: 'group', + }, + deps, + now: () => '2026-04-26T05:10:00.000Z', + }); + + expect(create?.status).toBe(200); + await expect(create?.json()).resolves.toMatchObject({ + ok: true, + task: { + id: 'web-task-task-create-1', + groupFolder: 'ops-room', + chatJid: 'dc:ops', + agentType: 'codex', + promptLength: 20, + scheduleType: 'once', + status: 'active', + }, + }); + expect(tasks.get('web-task-task-create-1')).toMatchObject({ + prompt: 'run deployment audit', + next_run: '2026-04-26T05:10:00.000Z', + }); + expect(nudges).toBe(1); + + const duplicate = await route({ + pathname: '/api/tasks', + method: 'POST', + body: { + roomJid: 'dc:ops', + prompt: 'run deployment audit', + requestId: 'task-create-1', + scheduleType: 'once', + scheduleValue: '2026-04-26T05:10:00.000Z', + }, + deps, + now: () => '2026-04-26T05:10:00.000Z', + }); + expect(duplicate?.status).toBe(200); + await expect(duplicate?.json()).resolves.toMatchObject({ + ok: true, + duplicate: true, + task: { id: 'web-task-task-create-1' }, + }); + expect(tasks).toHaveLength(1); + expect(nudges).toBe(1); + }); + + it('handles task actions, edits, invalid watchers, and fall-through', async () => { + const tasks = new Map([ + ['active-task', makeTask({ id: 'active-task' })], + [ + 'watch-task', + makeTask({ + id: 'watch-task', + prompt: '[BACKGROUND CI WATCH]\nwatch', + }), + ], + ]); + const deps = makeDeps(tasks); + + const pause = await route({ + pathname: '/api/tasks/active-task/actions', + method: 'POST', + body: { action: 'pause' }, + deps, + }); + expect(pause?.status).toBe(200); + await expect(pause?.json()).resolves.toMatchObject({ + ok: true, + task: { id: 'active-task', status: 'paused' }, + }); + expect(tasks.get('active-task')).toMatchObject({ + status: 'paused', + suspended_until: null, + }); + + const edit = await route({ + pathname: '/api/tasks/active-task', + method: 'PATCH', + body: { + prompt: 'watch every minute', + scheduleType: 'interval', + scheduleValue: '60000', + }, + deps, + now: () => '2026-04-26T05:10:00.000Z', + }); + expect(edit?.status).toBe(200); + expect(tasks.get('active-task')).toMatchObject({ + next_run: '2026-04-26T05:11:00.000Z', + prompt: 'watch every minute', + schedule_type: 'interval', + schedule_value: '60000', + suspended_until: null, + }); + + const watcherEdit = await route({ + pathname: '/api/tasks/watch-task', + method: 'PATCH', + body: { prompt: 'run again' }, + deps, + }); + expect(watcherEdit?.status).toBe(409); + + const wrongMethod = await route({ + pathname: '/api/tasks/active-task/actions', + method: 'GET', + deps, + }); + expect(wrongMethod?.status).toBe(405); + + const unmatched = await route({ + pathname: '/api/overview', + method: 'GET', + deps, + }); + expect(unmatched).toBeNull(); + }); +}); diff --git a/src/web-dashboard-task-routes.ts b/src/web-dashboard-task-routes.ts new file mode 100644 index 0000000..b4b43a8 --- /dev/null +++ b/src/web-dashboard-task-routes.ts @@ -0,0 +1,441 @@ +import { CronExpressionParser } from 'cron-parser'; + +import { TIMEZONE } from './config.js'; +import { isWatchCiTask } from './task-watch-status.js'; +import type { AgentType, RegisteredGroup, ScheduledTask } from './types.js'; +import { sanitizeScheduledTask } from './web-dashboard-data.js'; + +type JsonResponse = ( + value: unknown, + init?: ResponseInit, + request?: Request, +) => Response; + +type TaskAction = 'pause' | 'resume' | 'cancel'; + +interface ScheduledTaskMutationBody { + roomJid?: unknown; + groupFolder?: unknown; + prompt?: unknown; + scheduleType?: unknown; + scheduleValue?: unknown; + contextMode?: unknown; + agentType?: unknown; + requestId?: unknown; +} + +type ScheduledTaskCreateInput = Pick< + ScheduledTask, + | 'id' + | 'group_folder' + | 'chat_jid' + | 'prompt' + | 'schedule_type' + | 'schedule_value' + | 'context_mode' + | 'next_run' + | 'status' + | 'created_at' +> & { + agent_type?: AgentType | null; +}; + +type ScheduledTaskUpdateInput = Partial< + Pick< + ScheduledTask, + | 'prompt' + | 'schedule_type' + | 'schedule_value' + | 'next_run' + | 'status' + | 'suspended_until' + > +>; + +export interface ScheduledTaskRouteDependencies { + createScheduledTask: (task: ScheduledTaskCreateInput) => void; + loadRoomBindings?: () => Record; + loadTaskById: (id: string) => ScheduledTask | undefined; + mutateTask: (id: string, updates: ScheduledTaskUpdateInput) => void; + nudgeScheduler?: () => void; + removeTask: (id: string) => void; +} + +interface ScheduledTaskRouteContext extends ScheduledTaskRouteDependencies { + url: URL; + request: Request; + jsonResponse: JsonResponse; + now?: () => string; +} + +const WEB_TASK_PROMPT_MAX_LENGTH = 8000; + +function parseTaskPath(pathname: string): string | null { + const match = pathname.match(/^\/api\/tasks\/([^/]+)$/); + if (!match) return null; + try { + return decodeURIComponent(match[1]); + } catch { + return null; + } +} + +function parseTaskActionPath(pathname: string): string | null { + const match = pathname.match(/^\/api\/tasks\/([^/]+)\/actions$/); + if (!match) return null; + try { + return decodeURIComponent(match[1]); + } catch { + return null; + } +} + +function isTaskAction(value: unknown): value is TaskAction { + return value === 'pause' || value === 'resume' || value === 'cancel'; +} + +function isScheduleType( + value: unknown, +): value is ScheduledTask['schedule_type'] { + return value === 'cron' || value === 'interval' || value === 'once'; +} + +function isContextMode(value: unknown): value is ScheduledTask['context_mode'] { + return value === 'group' || value === 'isolated'; +} + +function isAgentType(value: unknown): value is AgentType { + return value === 'claude-code' || value === 'codex'; +} + +async function readTaskAction(request: Request): Promise { + try { + const body = (await request.json()) as { action?: unknown }; + return isTaskAction(body.action) ? body.action : null; + } catch { + return null; + } +} + +async function readScheduledTaskMutationBody( + request: Request, +): Promise { + try { + return (await request.json()) as ScheduledTaskMutationBody; + } catch { + return null; + } +} + +function normalizeNonEmptyString(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function normalizeTaskPrompt(value: unknown): string | null { + const prompt = normalizeNonEmptyString(value); + if (!prompt) return null; + return prompt.length > WEB_TASK_PROMPT_MAX_LENGTH + ? prompt.slice(0, WEB_TASK_PROMPT_MAX_LENGTH) + : prompt; +} + +function computeInitialNextRun( + scheduleType: ScheduledTask['schedule_type'], + scheduleValue: string, + nowIso: string, +): { nextRun: string | null; error?: string } { + if (scheduleType === 'cron') { + try { + const interval = CronExpressionParser.parse(scheduleValue, { + tz: TIMEZONE, + }); + return { nextRun: interval.next().toISOString() }; + } catch { + return { nextRun: null, error: 'Invalid cron schedule' }; + } + } + + if (scheduleType === 'interval') { + const ms = Number.parseInt(scheduleValue, 10); + if (!Number.isFinite(ms) || ms <= 0) { + return { nextRun: null, error: 'Invalid interval schedule' }; + } + return { + nextRun: new Date(new Date(nowIso).getTime() + ms).toISOString(), + }; + } + + const date = new Date(scheduleValue); + if (Number.isNaN(date.getTime())) { + return { nextRun: null, error: 'Invalid timestamp schedule' }; + } + return { nextRun: date.toISOString() }; +} + +function resolveTaskRoom( + body: ScheduledTaskMutationBody, + roomBindings: Record, +): { chatJid: string; group: RegisteredGroup } | null { + const roomJid = normalizeNonEmptyString(body.roomJid); + const groupFolder = normalizeNonEmptyString(body.groupFolder); + if (roomJid && roomBindings[roomJid]) { + return { chatJid: roomJid, group: roomBindings[roomJid] }; + } + if (groupFolder) { + const match = Object.entries(roomBindings).find( + ([, group]) => group.folder === groupFolder, + ); + if (match) return { chatJid: match[0], group: match[1] }; + } + return null; +} + +function makeWebTaskId(): string { + return `web-task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function sanitizeScheduledTaskRequestId(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + const safe = trimmed.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 120); + return safe || null; +} + +function makeWebTaskIdFromRequest(requestId: string | null): string { + return requestId ? `web-task-${requestId}` : makeWebTaskId(); +} + +async function handleTaskActionRoute( + context: ScheduledTaskRouteContext, + taskId: string, +): Promise { + const { request, jsonResponse, loadTaskById, mutateTask, removeTask } = + context; + if (request.method !== 'POST') { + return jsonResponse({ error: 'Method not allowed' }, { status: 405 }); + } + + const action = await readTaskAction(request); + if (!action) { + return jsonResponse({ error: 'Invalid task action' }, { status: 400 }); + } + + const task = loadTaskById(taskId); + if (!task) { + return jsonResponse({ error: 'Task not found' }, { status: 404 }); + } + + if (task.status === 'completed' && action !== 'cancel') { + return jsonResponse( + { error: 'Completed tasks cannot be changed' }, + { status: 409 }, + ); + } + + if (action === 'cancel') { + removeTask(taskId); + return jsonResponse({ ok: true, id: taskId, deleted: true }); + } + + mutateTask(taskId, { + status: action === 'pause' ? 'paused' : 'active', + suspended_until: null, + }); + + const updatedTask = loadTaskById(taskId); + return jsonResponse({ + ok: true, + task: updatedTask ? sanitizeScheduledTask(updatedTask) : null, + }); +} + +async function handleTaskCreateRoute( + context: ScheduledTaskRouteContext, +): Promise { + const { + createScheduledTask, + jsonResponse, + loadRoomBindings, + loadTaskById, + nudgeScheduler, + request, + now, + } = context; + if (!loadRoomBindings) { + return jsonResponse( + { error: 'Task creation is not configured' }, + { status: 503 }, + ); + } + + const body = await readScheduledTaskMutationBody(request); + const prompt = normalizeTaskPrompt(body?.prompt); + const scheduleType = + body && isScheduleType(body.scheduleType) ? body.scheduleType : null; + const scheduleValue = normalizeNonEmptyString(body?.scheduleValue); + if (!body || !prompt || !scheduleType || !scheduleValue) { + return jsonResponse( + { error: 'Task prompt, schedule type, and schedule value are required' }, + { status: 400 }, + ); + } + + const room = resolveTaskRoom(body, loadRoomBindings()); + if (!room) { + return jsonResponse({ error: 'Room not found' }, { status: 404 }); + } + + const nowIso = now?.() ?? new Date().toISOString(); + const next = computeInitialNextRun(scheduleType, scheduleValue, nowIso); + if (next.error) { + return jsonResponse({ error: next.error }, { status: 400 }); + } + + const contextMode = isContextMode(body.contextMode) + ? body.contextMode + : 'isolated'; + const agentType = isAgentType(body.agentType) + ? body.agentType + : (room.group.agentType ?? 'claude-code'); + const requestId = sanitizeScheduledTaskRequestId(body.requestId); + const id = makeWebTaskIdFromRequest(requestId); + const existing = requestId ? loadTaskById(id) : undefined; + if (existing) { + return jsonResponse({ + ok: true, + duplicate: true, + task: sanitizeScheduledTask(existing), + }); + } + + createScheduledTask({ + id, + group_folder: room.group.folder, + chat_jid: room.chatJid, + agent_type: agentType, + prompt, + schedule_type: scheduleType, + schedule_value: scheduleValue, + context_mode: contextMode, + next_run: next.nextRun, + status: 'active', + created_at: nowIso, + }); + + const created = loadTaskById(id); + if ( + next.nextRun && + new Date(next.nextRun).getTime() <= new Date(nowIso).getTime() + ) { + nudgeScheduler?.(); + } + return jsonResponse({ + ok: true, + task: created ? sanitizeScheduledTask(created) : null, + }); +} + +async function handleTaskUpdateRoute( + context: ScheduledTaskRouteContext, + taskId: string, +): Promise { + const { jsonResponse, loadTaskById, mutateTask, request, now } = context; + const task = loadTaskById(taskId); + if (!task) { + return jsonResponse({ error: 'Task not found' }, { status: 404 }); + } + if (task.status === 'completed') { + return jsonResponse( + { error: 'Completed tasks cannot be edited' }, + { status: 409 }, + ); + } + if (isWatchCiTask(task)) { + return jsonResponse( + { error: 'CI watchers cannot be edited here' }, + { status: 409 }, + ); + } + + const body = await readScheduledTaskMutationBody(request); + if (!body) { + return jsonResponse({ error: 'Invalid task update' }, { status: 400 }); + } + if ( + body.roomJid !== undefined || + body.groupFolder !== undefined || + body.contextMode !== undefined || + body.agentType !== undefined + ) { + return jsonResponse( + { error: 'Task room, context, and agent cannot be edited here' }, + { status: 400 }, + ); + } + + const prompt = + body.prompt === undefined ? task.prompt : normalizeTaskPrompt(body.prompt); + const scheduleChanged = + body.scheduleType !== undefined || body.scheduleValue !== undefined; + const scheduleType = + body.scheduleType === undefined + ? task.schedule_type + : isScheduleType(body.scheduleType) + ? body.scheduleType + : null; + const scheduleValue = + body.scheduleValue === undefined + ? task.schedule_value + : normalizeNonEmptyString(body.scheduleValue); + if (!prompt || !scheduleType || !scheduleValue) { + return jsonResponse({ error: 'Invalid task update' }, { status: 400 }); + } + + const updates: ScheduledTaskUpdateInput = { + prompt, + schedule_type: scheduleType, + schedule_value: scheduleValue, + }; + + if (scheduleChanged) { + const next = computeInitialNextRun( + scheduleType, + scheduleValue, + now?.() ?? new Date().toISOString(), + ); + if (next.error) { + return jsonResponse({ error: next.error }, { status: 400 }); + } + updates.next_run = next.nextRun; + updates.suspended_until = null; + } + + mutateTask(taskId, updates); + const updatedTask = loadTaskById(taskId); + return jsonResponse({ + ok: true, + task: updatedTask ? sanitizeScheduledTask(updatedTask) : null, + }); +} + +export async function handleScheduledTaskRoute( + context: ScheduledTaskRouteContext, +): Promise { + const { request, url } = context; + const actionTaskId = parseTaskActionPath(url.pathname); + if (actionTaskId) return handleTaskActionRoute(context, actionTaskId); + + if (url.pathname === '/api/tasks' && request.method === 'POST') { + return handleTaskCreateRoute(context); + } + + const taskId = parseTaskPath(url.pathname); + if (taskId && request.method === 'PATCH') { + return handleTaskUpdateRoute(context, taskId); + } + + return null; +}