feat(dashboard): create and edit scheduled tasks

This commit is contained in:
Eyejoker
2026-04-27 03:05:04 +09:00
committed by GitHub
parent 16aeb0449d
commit 92e4e22e4d
7 changed files with 987 additions and 7 deletions

View File

@@ -1,15 +1,21 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { import {
type CreateScheduledTaskInput,
type DashboardInboxAction, type DashboardInboxAction,
type DashboardTaskContextMode,
type DashboardTaskScheduleType,
type DashboardTaskAction, type DashboardTaskAction,
type DashboardOverview, type DashboardOverview,
type DashboardTask, type DashboardTask,
type UpdateScheduledTaskInput,
type StatusSnapshot, type StatusSnapshot,
createScheduledTask,
fetchDashboardData, fetchDashboardData,
runInboxAction, runInboxAction,
runScheduledTaskAction, runScheduledTaskAction,
sendRoomMessage, sendRoomMessage,
updateScheduledTask,
} from './api'; } from './api';
import { import {
LOCALES, LOCALES,
@@ -37,11 +43,20 @@ type UsageLimitWindow = 'h5' | 'd7';
type DashboardView = 'usage' | 'inbox' | 'health' | 'rooms' | 'scheduled'; type DashboardView = 'usage' | 'inbox' | 'health' | 'rooms' | 'scheduled';
type TaskGroupKey = 'watchers' | 'scheduled' | 'paused' | 'completed'; type TaskGroupKey = 'watchers' | 'scheduled' | 'paused' | 'completed';
type TaskResultTone = 'ok' | 'fail' | 'none'; type TaskResultTone = 'ok' | 'fail' | 'none';
type TaskActionKey = `${string}:${DashboardTaskAction}`; type TaskActionKey =
| 'create'
| `${string}:edit`
| `${string}:${DashboardTaskAction}`;
type InboxActionKey = `${string}:${DashboardInboxAction}`; type InboxActionKey = `${string}:${DashboardInboxAction}`;
type InboxFilter = 'all' | InboxItem['kind']; type InboxFilter = 'all' | InboxItem['kind'];
type HealthLevel = 'ok' | 'stale' | 'down'; type HealthLevel = 'ok' | 'stale' | 'down';
interface RoomOption {
jid: string;
name: string;
folder: string;
}
const REFRESH_INTERVAL_MS = 15_000; const REFRESH_INTERVAL_MS = 15_000;
const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale.v2'; const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale.v2';
const DEFAULT_VIEW: DashboardView = 'inbox'; const DEFAULT_VIEW: DashboardView = 'inbox';
@@ -295,6 +310,85 @@ function taskActionsFor(task: DashboardTask): DashboardTaskAction[] {
return []; return [];
} }
function buildRoomOptions(snapshots: StatusSnapshot[]): RoomOption[] {
const rooms = new Map<string, RoomOption>();
for (const snapshot of snapshots) {
for (const entry of snapshot.entries) {
if (!rooms.has(entry.jid)) {
rooms.set(entry.jid, {
jid: entry.jid,
name: entry.name || entry.folder || entry.jid,
folder: entry.folder,
});
}
}
}
return [...rooms.values()].sort((a, b) =>
`${a.name} ${a.folder}`.localeCompare(`${b.name} ${b.folder}`),
);
}
function isTaskScheduleType(
value: FormDataEntryValue | null,
): value is DashboardTaskScheduleType {
return value === 'cron' || value === 'interval' || value === 'once';
}
function isTaskContextMode(
value: FormDataEntryValue | null,
): value is DashboardTaskContextMode {
return value === 'group' || value === 'isolated';
}
function readRequiredText(form: FormData, name: string): string | null {
const value = form.get(name);
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function readTaskForm(
form: FormData,
includeRoom: true,
): CreateScheduledTaskInput | null;
function readTaskForm(
form: FormData,
includeRoom: false,
): UpdateScheduledTaskInput | null;
function readTaskForm(
form: FormData,
includeRoom: boolean,
): CreateScheduledTaskInput | UpdateScheduledTaskInput | null {
const prompt = readRequiredText(form, 'prompt');
const scheduleValue = readRequiredText(form, 'scheduleValue');
const scheduleTypeValue = form.get('scheduleType');
if (!scheduleValue || !isTaskScheduleType(scheduleTypeValue)) {
return null;
}
const scheduleType = scheduleTypeValue;
if (!includeRoom) {
return prompt
? { prompt, scheduleType, scheduleValue }
: { scheduleType, scheduleValue };
}
if (!prompt) {
return null;
}
const roomJid = readRequiredText(form, 'roomJid');
const contextMode = form.get('contextMode');
if (!roomJid || !isTaskContextMode(contextMode)) return null;
return {
contextMode,
prompt,
roomJid,
scheduleType,
scheduleValue,
};
}
function inboxActionsFor(item: InboxItem): DashboardInboxAction[] { function inboxActionsFor(item: InboxItem): DashboardInboxAction[] {
if (item.source !== 'paired-task') return []; if (item.source !== 'paired-task') return [];
if ( if (
@@ -1262,14 +1356,20 @@ function UsagePanel({
function TaskPanel({ function TaskPanel({
tasks, tasks,
rooms,
locale, locale,
onTaskAction, onTaskAction,
onTaskCreate,
onTaskUpdate,
taskActionKey, taskActionKey,
t, t,
}: { }: {
tasks: DashboardTask[]; tasks: DashboardTask[];
rooms: RoomOption[];
locale: Locale; locale: Locale;
onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void; onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void;
onTaskCreate: (input: CreateScheduledTaskInput) => void;
onTaskUpdate: (task: DashboardTask, input: UpdateScheduledTaskInput) => void;
taskActionKey: TaskActionKey | null; taskActionKey: TaskActionKey | null;
t: Messages; t: Messages;
}) { }) {
@@ -1301,12 +1401,73 @@ function TaskPanel({
]; ];
}, [tasks]); }, [tasks]);
if (tasks.length === 0) {
return <EmptyState>{t.tasks.empty}</EmptyState>;
}
return ( return (
<div className="task-board" aria-label={t.tasks.cardsAria}> <div className="task-board" aria-label={t.tasks.cardsAria}>
<form
className="task-create-form"
onSubmit={(event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const input = readTaskForm(form, true);
if (!input) return;
onTaskCreate(input);
event.currentTarget.reset();
}}
>
<div className="task-create-head">
<span className="eyebrow">{t.tasks.createTitle}</span>
<strong>{t.tasks.createSubtitle}</strong>
</div>
<label>
<span>{t.tasks.room}</span>
<select name="roomJid" required>
<option value="">{t.tasks.selectRoom}</option>
{rooms.map((room) => (
<option key={room.jid} value={room.jid}>
{room.name} · {room.folder}
</option>
))}
</select>
</label>
<label className="task-form-wide">
<span>{t.tasks.prompt}</span>
<textarea
name="prompt"
placeholder={t.tasks.promptPlaceholder}
required
/>
</label>
<label>
<span>{t.tasks.scheduleType}</span>
<select name="scheduleType" required>
<option value="once">{t.tasks.scheduleTypes.once}</option>
<option value="interval">{t.tasks.scheduleTypes.interval}</option>
<option value="cron">{t.tasks.scheduleTypes.cron}</option>
</select>
</label>
<label>
<span>{t.tasks.scheduleValue}</span>
<input
name="scheduleValue"
placeholder={t.tasks.scheduleValueHint}
required
/>
</label>
<label>
<span>{t.tasks.context}</span>
<select name="contextMode" required>
<option value="isolated">{t.tasks.contextModes.isolated}</option>
<option value="group">{t.tasks.contextModes.group}</option>
</select>
</label>
<button disabled={taskActionKey === 'create'} type="submit">
{taskActionKey === 'create'
? t.tasks.actions.busy
: t.tasks.actions.create}
</button>
</form>
{tasks.length === 0 ? <EmptyState>{t.tasks.empty}</EmptyState> : null}
{taskGroups.map((group) => { {taskGroups.map((group) => {
const label = t.tasks.groups[group.key]; const label = t.tasks.groups[group.key];
const groupHead = ( const groupHead = (
@@ -1429,6 +1590,64 @@ function TaskPanel({
{t.units.chars} {t.units.chars}
</small> </small>
</details> </details>
{!task.isWatcher && task.status !== 'completed' ? (
<details className="task-edit">
<summary>{t.tasks.actions.edit}</summary>
<form
className="task-edit-form"
onSubmit={(event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const input = readTaskForm(form, false);
if (!input) return;
onTaskUpdate(task, input);
}}
>
<label className="task-form-wide">
<span>{t.tasks.prompt}</span>
<textarea
name="prompt"
placeholder={t.tasks.editPromptPlaceholder}
/>
</label>
<label>
<span>{t.tasks.scheduleType}</span>
<select
name="scheduleType"
defaultValue={task.scheduleType}
required
>
<option value="once">
{t.tasks.scheduleTypes.once}
</option>
<option value="interval">
{t.tasks.scheduleTypes.interval}
</option>
<option value="cron">
{t.tasks.scheduleTypes.cron}
</option>
</select>
</label>
<label>
<span>{t.tasks.scheduleValue}</span>
<input
name="scheduleValue"
defaultValue={task.scheduleValue}
required
/>
</label>
<button
disabled={taskActionKey === `${task.id}:edit`}
type="submit"
>
{taskActionKey === `${task.id}:edit`
? t.tasks.actions.busy
: t.tasks.actions.save}
</button>
</form>
</details>
) : null}
</article> </article>
); );
})} })}
@@ -1534,6 +1753,34 @@ function App() {
} }
} }
async function handleTaskCreate(input: CreateScheduledTaskInput) {
setTaskActionKey('create');
try {
await createScheduledTask(input);
await refresh(false);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setTaskActionKey(null);
}
}
async function handleTaskUpdate(
task: DashboardTask,
input: UpdateScheduledTaskInput,
) {
const actionKey: TaskActionKey = `${task.id}:edit`;
setTaskActionKey(actionKey);
try {
await updateScheduledTask(task.id, input);
await refresh(false);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setTaskActionKey(null);
}
}
async function handleInboxAction( async function handleInboxAction(
item: InboxItem, item: InboxItem,
action: DashboardInboxAction, action: DashboardInboxAction,
@@ -1605,6 +1852,8 @@ function App() {
return <LoadingSkeleton t={t} />; return <LoadingSkeleton t={t} />;
} }
const roomOptions = data ? buildRoomOptions(data.snapshots) : [];
return ( return (
<div className="shell"> <div className="shell">
<SideRail <SideRail
@@ -1715,6 +1964,11 @@ function App() {
onTaskAction={(task, action) => onTaskAction={(task, action) =>
void handleTaskAction(task, action) void handleTaskAction(task, action)
} }
onTaskCreate={(input) => void handleTaskCreate(input)}
onTaskUpdate={(task, input) =>
void handleTaskUpdate(task, input)
}
rooms={roomOptions}
taskActionKey={taskActionKey} taskActionKey={taskActionKey}
tasks={data.tasks} tasks={data.tasks}
t={t} t={t}

View File

@@ -102,6 +102,22 @@ export interface DashboardTask {
export type DashboardTaskAction = 'pause' | 'resume' | 'cancel'; export type DashboardTaskAction = 'pause' | 'resume' | 'cancel';
export type DashboardInboxAction = 'run'; export type DashboardInboxAction = 'run';
export type DashboardTaskScheduleType = 'cron' | 'interval' | 'once';
export type DashboardTaskContextMode = 'group' | 'isolated';
export interface CreateScheduledTaskInput {
roomJid: string;
prompt: string;
scheduleType: DashboardTaskScheduleType;
scheduleValue: string;
contextMode: DashboardTaskContextMode;
}
export interface UpdateScheduledTaskInput {
prompt?: string;
scheduleType: DashboardTaskScheduleType;
scheduleValue: string;
}
async function fetchJson<T>(path: string): Promise<T> { async function fetchJson<T>(path: string): Promise<T> {
const response = await fetch(path, { const response = await fetch(path, {
@@ -163,6 +179,37 @@ export async function runScheduledTaskAction(
}); });
} }
export async function createScheduledTask(
input: CreateScheduledTaskInput,
): Promise<{ ok: true; task: DashboardTask | null }> {
return postJson('/api/tasks', input);
}
export async function updateScheduledTask(
taskId: string,
input: UpdateScheduledTaskInput,
): Promise<{ ok: true; task: DashboardTask | null }> {
const response = await fetch(`/api/tasks/${encodeURIComponent(taskId)}`, {
method: 'PATCH',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify(input),
});
if (!response.ok) {
let message = `/api/tasks/${taskId} failed: ${response.status}`;
try {
const payload = (await response.json()) as { error?: string };
if (payload.error) message = payload.error;
} catch {
// Keep the status-based message when the server body is not JSON.
}
throw new Error(message);
}
return (await response.json()) as { ok: true; task: DashboardTask | null };
}
export async function runInboxAction( export async function runInboxAction(
inboxId: string, inboxId: string,
action: DashboardInboxAction, action: DashboardInboxAction,

View File

@@ -188,7 +188,28 @@ export interface Messages {
noResult: string; noResult: string;
noTime: string; noTime: string;
now: string; now: string;
createTitle: string;
createSubtitle: string;
room: string;
selectRoom: string;
scheduleType: string;
scheduleValue: string;
scheduleValueHint: string;
promptPlaceholder: string;
editPromptPlaceholder: string;
scheduleTypes: {
once: string;
interval: string;
cron: string;
};
contextModes: {
isolated: string;
group: string;
};
actions: { actions: {
create: string;
edit: string;
save: string;
pause: string; pause: string;
resume: string; resume: string;
cancel: string; cancel: string;
@@ -415,7 +436,28 @@ export const messages = {
noResult: '결과 없음', noResult: '결과 없음',
noTime: '-', noTime: '-',
now: '지금', now: '지금',
createTitle: 'Create task',
createSubtitle: 'Schedule work from web',
room: 'Room',
selectRoom: 'Select room',
scheduleType: 'Type',
scheduleValue: 'Value',
scheduleValueHint: 'ISO time, ms, or cron',
promptPlaceholder: 'What should the agent do?',
editPromptPlaceholder: 'Leave blank to keep prompt',
scheduleTypes: {
once: 'Once',
interval: 'Interval',
cron: 'Cron',
},
contextModes: {
isolated: 'Isolated',
group: 'Group',
},
actions: { actions: {
create: 'Create',
edit: 'Edit',
save: 'Save',
pause: 'Pause', pause: 'Pause',
resume: 'Resume', resume: 'Resume',
cancel: 'Cancel', cancel: 'Cancel',
@@ -626,7 +668,28 @@ export const messages = {
noResult: 'no result', noResult: 'no result',
noTime: '-', noTime: '-',
now: 'now', now: 'now',
createTitle: 'Create task',
createSubtitle: 'Schedule work from web',
room: 'Room',
selectRoom: 'Select room',
scheduleType: 'Type',
scheduleValue: 'Value',
scheduleValueHint: 'ISO time, ms, or cron',
promptPlaceholder: 'What should the agent do?',
editPromptPlaceholder: 'Leave blank to keep prompt',
scheduleTypes: {
once: 'Once',
interval: 'Interval',
cron: 'Cron',
},
contextModes: {
isolated: 'Isolated',
group: 'Group',
},
actions: { actions: {
create: 'Create',
edit: 'Edit',
save: 'Save',
pause: 'Pause', pause: 'Pause',
resume: 'Resume', resume: 'Resume',
cancel: 'Cancel', cancel: 'Cancel',
@@ -837,7 +900,28 @@ export const messages = {
noResult: '无结果', noResult: '无结果',
noTime: '-', noTime: '-',
now: '现在', now: '现在',
createTitle: 'Create task',
createSubtitle: 'Schedule work from web',
room: 'Room',
selectRoom: 'Select room',
scheduleType: 'Type',
scheduleValue: 'Value',
scheduleValueHint: 'ISO time, ms, or cron',
promptPlaceholder: 'What should the agent do?',
editPromptPlaceholder: 'Leave blank to keep prompt',
scheduleTypes: {
once: 'Once',
interval: 'Interval',
cron: 'Cron',
},
contextModes: {
isolated: 'Isolated',
group: 'Group',
},
actions: { actions: {
create: 'Create',
edit: 'Edit',
save: 'Save',
pause: 'Pause', pause: 'Pause',
resume: 'Resume', resume: 'Resume',
cancel: 'Cancel', cancel: 'Cancel',
@@ -1048,7 +1132,28 @@ export const messages = {
noResult: '結果なし', noResult: '結果なし',
noTime: '-', noTime: '-',
now: '今', now: '今',
createTitle: 'Create task',
createSubtitle: 'Schedule work from web',
room: 'Room',
selectRoom: 'Select room',
scheduleType: 'Type',
scheduleValue: 'Value',
scheduleValueHint: 'ISO time, ms, or cron',
promptPlaceholder: 'What should the agent do?',
editPromptPlaceholder: 'Leave blank to keep prompt',
scheduleTypes: {
once: 'Once',
interval: 'Interval',
cron: 'Cron',
},
contextModes: {
isolated: 'Isolated',
group: 'Group',
},
actions: { actions: {
create: 'Create',
edit: 'Edit',
save: 'Save',
pause: 'Pause', pause: 'Pause',
resume: 'Resume', resume: 'Resume',
cancel: 'Cancel', cancel: 'Cancel',

View File

@@ -1138,6 +1138,104 @@ progress::-moz-progress-bar {
gap: 12px; gap: 12px;
} }
.task-create-form,
.task-edit-form {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)) auto;
gap: 8px;
align-items: end;
padding: 12px;
border: 1px solid rgba(43, 55, 38, 0.11);
border-radius: 22px;
background: rgba(255, 250, 240, 0.72);
}
.task-edit-form {
grid-template-columns:
minmax(0, 1.4fr) minmax(110px, 0.6fr) minmax(0, 1fr)
auto;
margin-top: 8px;
padding: 9px;
border-radius: 16px;
}
.task-create-head {
align-self: center;
}
.task-create-head strong {
display: block;
margin-top: 2px;
font-size: 16px;
letter-spacing: -0.03em;
}
.task-create-form label,
.task-edit-form label {
display: grid;
min-width: 0;
gap: 4px;
color: var(--muted);
font-size: 11px;
font-weight: 850;
}
.task-create-form input,
.task-create-form select,
.task-create-form textarea,
.task-edit-form input,
.task-edit-form select,
.task-edit-form textarea {
width: 100%;
min-height: 38px;
border: 1px solid rgba(43, 55, 38, 0.14);
border-radius: 13px;
padding: 8px 10px;
color: var(--ink);
background: rgba(255, 250, 240, 0.86);
font: inherit;
font-size: 13px;
}
.task-create-form textarea,
.task-edit-form textarea {
min-height: 38px;
resize: vertical;
}
.task-create-form input:focus-visible,
.task-create-form select:focus-visible,
.task-create-form textarea:focus-visible,
.task-edit-form input:focus-visible,
.task-edit-form select:focus-visible,
.task-edit-form textarea:focus-visible {
outline: 3px solid rgba(191, 95, 44, 0.18);
}
.task-create-form button,
.task-edit-form button {
min-height: 38px;
border: 0;
border-radius: 999px;
padding: 0 14px;
color: #fffaf0;
background: var(--ink);
font: inherit;
font-size: 12px;
font-weight: 950;
cursor: pointer;
}
.task-create-form button:disabled,
.task-edit-form button:disabled {
cursor: progress;
opacity: 0.55;
}
.task-form-wide {
grid-column: span 2;
}
.task-group { .task-group {
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -1350,6 +1448,7 @@ progress::-moz-progress-bar {
color: var(--muted); color: var(--muted);
} }
.task-edit summary,
.task-prompt summary { .task-prompt summary {
min-height: 32px; min-height: 32px;
color: var(--accent-strong); color: var(--accent-strong);
@@ -1737,6 +1836,17 @@ progress::-moz-progress-bar {
padding: 10px; padding: 10px;
} }
.task-create-form,
.task-edit-form {
grid-template-columns: 1fr;
gap: 7px;
padding: 10px;
}
.task-form-wide {
grid-column: auto;
}
.task-list { .task-list {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 8px; gap: 8px;

View File

@@ -526,6 +526,7 @@ async function main(): Promise<void> {
getRoomBindings: runtimeState.getRoomBindings, getRoomBindings: runtimeState.getRoomBindings,
enqueueMessageCheck: (chatJid, groupFolder) => enqueueMessageCheck: (chatJid, groupFolder) =>
queue.enqueueMessageCheck(chatJid, resolveGroupIpcPath(groupFolder)), queue.enqueueMessageCheck(chatJid, resolveGroupIpcPath(groupFolder)),
nudgeScheduler: nudgeSchedulerLoop,
}); });
leaseRecoveryTimer = setInterval(() => { leaseRecoveryTimer = setInterval(() => {

View File

@@ -349,6 +349,198 @@ describe('web dashboard server handler', () => {
expect(wrongMethod.status).toBe(405); expect(wrongMethod.status).toBe(405);
}); });
it('creates and edits scheduled tasks through web endpoints', async () => {
const tasks = new Map<string, ScheduledTask>();
const rooms: Record<string, RegisteredGroup> = {
'dc:ops': {
name: '#ops',
folder: 'ops-room',
added_at: '2026-04-26T05:00:00.000Z',
agentType: 'codex',
},
};
const nudges: string[] = [];
const handler = createWebDashboardHandler({
readStatusSnapshots: () => [],
getTasks: () => [...tasks.values()],
getTaskById: (id) => tasks.get(id),
createTask: (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,
});
},
updateTask: (id, updates) => {
const task = tasks.get(id);
if (task) tasks.set(id, { ...task, ...updates });
},
getPairedTasks: () => [],
getRoomBindings: () => rooms,
nudgeScheduler: () => {
nudges.push('nudge');
},
now: () => '2026-04-26T05:10:00.000Z',
});
const create = await handler(
new Request('http://localhost/api/tasks', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
roomJid: 'dc:ops',
prompt: ' run hourly dashboard audit ',
scheduleType: 'once',
scheduleValue: '2026-04-26T05:20:00.000Z',
contextMode: 'group',
}),
}),
);
expect(create.status).toBe(200);
const created = (await create.json()) as {
task: { id: string; groupFolder: string; scheduleType: string };
};
expect(created.task.groupFolder).toBe('ops-room');
expect(created.task.scheduleType).toBe('once');
const createdTask = tasks.get(created.task.id);
expect(createdTask).toMatchObject({
agent_type: 'codex',
chat_jid: 'dc:ops',
context_mode: 'group',
group_folder: 'ops-room',
next_run: '2026-04-26T05:20:00.000Z',
prompt: 'run hourly dashboard audit',
schedule_type: 'once',
status: 'active',
});
const update = await handler(
new Request(`http://localhost/api/tasks/${created.task.id}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
prompt: 'watch dashboard every minute',
scheduleType: 'interval',
scheduleValue: '60000',
}),
}),
);
expect(update.status).toBe(200);
expect(tasks.get(created.task.id)).toMatchObject({
next_run: '2026-04-26T05:11:00.000Z',
prompt: 'watch dashboard every minute',
schedule_type: 'interval',
schedule_value: '60000',
suspended_until: null,
});
expect(nudges).toEqual([]);
});
it('rejects invalid scheduled task create and edit requests', async () => {
const completed = makeTask({
id: 'completed-task',
status: 'completed',
});
const watch = makeTask({
id: 'watch-task',
prompt: '[BACKGROUND CI WATCH]\nwatch',
});
const handler = createWebDashboardHandler({
readStatusSnapshots: () => [],
getTasks: () => [completed, watch],
getTaskById: (id) =>
id === completed.id ? completed : id === watch.id ? watch : undefined,
createTask: () => {
throw new Error('createTask should not be called');
},
updateTask: () => {
throw new Error('updateTask should not be called');
},
getPairedTasks: () => [],
getRoomBindings: () => ({
'dc:ops': {
name: '#ops',
folder: 'ops-room',
added_at: '2026-04-26T05:00:00.000Z',
},
}),
});
const missingBody = await handler(
new Request('http://localhost/api/tasks', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ prompt: 'run' }),
}),
);
expect(missingBody.status).toBe(400);
const missingRoom = await handler(
new Request('http://localhost/api/tasks', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
roomJid: 'dc:missing',
prompt: 'run',
scheduleType: 'once',
scheduleValue: '2026-04-26T05:20:00.000Z',
contextMode: 'isolated',
}),
}),
);
expect(missingRoom.status).toBe(404);
const invalidCron = await handler(
new Request('http://localhost/api/tasks', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
roomJid: 'dc:ops',
prompt: 'run',
scheduleType: 'cron',
scheduleValue: 'not cron',
contextMode: 'isolated',
}),
}),
);
expect(invalidCron.status).toBe(400);
const completedEdit = await handler(
new Request('http://localhost/api/tasks/completed-task', {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
prompt: 'run again',
scheduleType: 'once',
scheduleValue: '2026-04-26T05:20:00.000Z',
}),
}),
);
expect(completedEdit.status).toBe(409);
const watcherEdit = await handler(
new Request('http://localhost/api/tasks/watch-task', {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
prompt: 'run again',
scheduleType: 'interval',
scheduleValue: '60000',
}),
}),
);
expect(watcherEdit.status).toBe(409);
});
it('queues paired inbox actions through the paired follow-up scheduler', async () => { it('queues paired inbox actions through the paired follow-up scheduler', async () => {
const pairedTasks = new Map<string, PairedTask>([ const pairedTasks = new Map<string, PairedTask>([
[ [

View File

@@ -1,8 +1,11 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { WEB_DASHBOARD } from './config.js'; import { CronExpressionParser } from 'cron-parser';
import { TIMEZONE, WEB_DASHBOARD } from './config.js';
import { import {
createTask,
deleteTask, deleteTask,
getAllOpenPairedTasks, getAllOpenPairedTasks,
getAllTasks, getAllTasks,
@@ -21,11 +24,13 @@ import {
type StatusSnapshot, type StatusSnapshot,
} from './status-dashboard.js'; } from './status-dashboard.js';
import type { import type {
AgentType,
NewMessage, NewMessage,
PairedTask, PairedTask,
RegisteredGroup, RegisteredGroup,
ScheduledTask, ScheduledTask,
} from './types.js'; } from './types.js';
import { isWatchCiTask } from './task-watch-status.js';
import { import {
buildWebDashboardOverview, buildWebDashboardOverview,
sanitizeScheduledTask, sanitizeScheduledTask,
@@ -55,8 +60,31 @@ export interface WebDashboardHandlerOptions {
getTaskById?: (id: string) => ScheduledTask | undefined; getTaskById?: (id: string) => ScheduledTask | undefined;
updateTask?: ( updateTask?: (
id: string, id: string,
updates: Partial<Pick<ScheduledTask, 'status' | 'suspended_until'>>, updates: Partial<
Pick<
ScheduledTask,
| 'prompt'
| 'schedule_type'
| 'schedule_value'
| 'next_run'
| 'status'
| 'suspended_until'
>
>,
) => void; ) => void;
createTask?: (task: {
id: string;
group_folder: string;
chat_jid: string;
agent_type?: AgentType | null;
prompt: string;
schedule_type: ScheduledTask['schedule_type'];
schedule_value: string;
context_mode: ScheduledTask['context_mode'];
next_run: string | null;
status: ScheduledTask['status'];
created_at: string;
}) => void;
deleteTask?: (id: string) => void; deleteTask?: (id: string) => void;
getPairedTasks?: () => PairedTask[]; getPairedTasks?: () => PairedTask[];
getPairedTaskById?: (id: string) => PairedTask | undefined; getPairedTaskById?: (id: string) => PairedTask | undefined;
@@ -72,6 +100,7 @@ export interface WebDashboardHandlerOptions {
storeMessage?: (message: NewMessage) => void; storeMessage?: (message: NewMessage) => void;
hasMessage?: (chatJid: string, id: string) => boolean; hasMessage?: (chatJid: string, id: string) => boolean;
enqueueMessageCheck?: (chatJid: string, groupFolder: string) => void; enqueueMessageCheck?: (chatJid: string, groupFolder: string) => void;
nudgeScheduler?: () => void;
now?: () => string; now?: () => string;
} }
@@ -154,6 +183,16 @@ function serveStaticFile(staticDir: string, pathname: string): Response {
type TaskAction = 'pause' | 'resume' | 'cancel'; type TaskAction = 'pause' | 'resume' | 'cancel';
type InboxAction = 'run'; type InboxAction = 'run';
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 { function parseTaskActionPath(pathname: string): string | null {
const match = pathname.match(/^\/api\/tasks\/([^/]+)\/actions$/); const match = pathname.match(/^\/api\/tasks\/([^/]+)\/actions$/);
if (!match) return null; if (!match) return null;
@@ -203,6 +242,20 @@ function isTaskAction(value: unknown): value is TaskAction {
return value === 'pause' || value === 'resume' || value === 'cancel'; 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 { function isInboxAction(value: unknown): value is InboxAction {
return value === 'run'; return value === 'run';
} }
@@ -237,6 +290,87 @@ async function readInboxAction(request: Request): Promise<InboxAction | null> {
} }
} }
interface ScheduledTaskMutationBody {
roomJid?: unknown;
groupFolder?: unknown;
prompt?: unknown;
scheduleType?: unknown;
scheduleValue?: unknown;
contextMode?: unknown;
agentType?: unknown;
}
async function readScheduledTaskMutationBody(
request: Request,
): Promise<ScheduledTaskMutationBody | null> {
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 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<string, RegisteredGroup>,
): { 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 sanitizeRoomMessageRequestId(value: unknown): string | null { function sanitizeRoomMessageRequestId(value: unknown): string | null {
if (typeof value !== 'string') return null; if (typeof value !== 'string') return null;
const trimmed = value.trim(); const trimmed = value.trim();
@@ -299,6 +433,7 @@ export function createWebDashboardHandler(
const loadTasks = opts.getTasks ?? getAllTasks; const loadTasks = opts.getTasks ?? getAllTasks;
const loadTaskById = opts.getTaskById ?? getTaskById; const loadTaskById = opts.getTaskById ?? getTaskById;
const mutateTask = opts.updateTask ?? updateTask; const mutateTask = opts.updateTask ?? updateTask;
const createScheduledTask = opts.createTask ?? createTask;
const removeTask = opts.deleteTask ?? deleteTask; const removeTask = opts.deleteTask ?? deleteTask;
const loadPairedTasks = opts.getPairedTasks ?? getAllOpenPairedTasks; const loadPairedTasks = opts.getPairedTasks ?? getAllOpenPairedTasks;
const loadPairedTaskById = opts.getPairedTaskById ?? getPairedTaskById; const loadPairedTaskById = opts.getPairedTaskById ?? getPairedTaskById;
@@ -309,6 +444,7 @@ export function createWebDashboardHandler(
const writeMessage = opts.storeMessage ?? storeMessage; const writeMessage = opts.storeMessage ?? storeMessage;
const messageExists = opts.hasMessage ?? hasMessage; const messageExists = opts.hasMessage ?? hasMessage;
const enqueueMessageCheck = opts.enqueueMessageCheck; const enqueueMessageCheck = opts.enqueueMessageCheck;
const nudgeScheduler = opts.nudgeScheduler;
const statusMaxAgeMs = opts.statusMaxAgeMs ?? DEFAULT_STATUS_MAX_AGE_MS; const statusMaxAgeMs = opts.statusMaxAgeMs ?? DEFAULT_STATUS_MAX_AGE_MS;
const seenRoomMessageIds: string[] = []; const seenRoomMessageIds: string[] = [];
const seenRoomMessageIdSet = new Set<string>(); const seenRoomMessageIdSet = new Set<string>();
@@ -327,6 +463,7 @@ export function createWebDashboardHandler(
return async (request: Request): Promise<Response> => { return async (request: Request): Promise<Response> => {
const url = new URL(request.url); const url = new URL(request.url);
const actionTaskId = parseTaskActionPath(url.pathname); const actionTaskId = parseTaskActionPath(url.pathname);
const taskId = parseTaskPath(url.pathname);
const actionInboxId = parseInboxActionPath(url.pathname); const actionInboxId = parseInboxActionPath(url.pathname);
const messageRoomJid = parseRoomMessagePath(url.pathname); const messageRoomJid = parseRoomMessagePath(url.pathname);
@@ -495,6 +632,138 @@ export function createWebDashboardHandler(
return jsonResponse({ ok: true, id, queued: true }); return jsonResponse({ ok: true, id, queued: true });
} }
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 = normalizeNonEmptyString(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 id = makeWebTaskId();
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 });
}
const prompt =
body.prompt === undefined
? task.prompt
: normalizeNonEmptyString(body.prompt);
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 next = computeInitialNextRun(
scheduleType,
scheduleValue,
opts.now?.() ?? new Date().toISOString(),
);
if (next.error) {
return jsonResponse({ error: next.error }, { status: 400 });
}
mutateTask(taskId, {
prompt,
schedule_type: scheduleType,
schedule_value: scheduleValue,
next_run: next.nextRun,
suspended_until: null,
});
const updatedTask = loadTaskById(taskId);
return jsonResponse({
ok: true,
task: updatedTask ? sanitizeScheduledTask(updatedTask) : null,
});
}
if (request.method !== 'GET' && request.method !== 'HEAD') { if (request.method !== 'GET' && request.method !== 'HEAD') {
return jsonResponse({ error: 'Method not allowed' }, { status: 405 }); return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
} }
@@ -547,6 +816,7 @@ export function startWebDashboardServer(
staticDir?: string; staticDir?: string;
getRoomBindings?: () => Record<string, RegisteredGroup>; getRoomBindings?: () => Record<string, RegisteredGroup>;
enqueueMessageCheck?: (chatJid: string, groupFolder: string) => void; enqueueMessageCheck?: (chatJid: string, groupFolder: string) => void;
nudgeScheduler?: () => void;
} = {}, } = {},
): StartedWebDashboardServer | null { ): StartedWebDashboardServer | null {
const enabled = opts.enabled ?? WEB_DASHBOARD.enabled; const enabled = opts.enabled ?? WEB_DASHBOARD.enabled;
@@ -562,6 +832,7 @@ export function startWebDashboardServer(
staticDir, staticDir,
getRoomBindings: opts.getRoomBindings, getRoomBindings: opts.getRoomBindings,
enqueueMessageCheck: opts.enqueueMessageCheck, enqueueMessageCheck: opts.enqueueMessageCheck,
nudgeScheduler: opts.nudgeScheduler,
}), }),
}); });
const url = `http://${host}:${server.port}`; const url = `http://${host}:${server.port}`;