diff --git a/changelog.d/354-schedule-edit-pause.added.md b/changelog.d/354-schedule-edit-pause.added.md new file mode 100644 index 00000000..a1db6515 --- /dev/null +++ b/changelog.d/354-schedule-edit-pause.added.md @@ -0,0 +1 @@ +Scheduled tasks can be edited and paused: in Settings → Scheduled tasks and a Bot's card, **Edit** opens the task in its form, filled in, and **Pause** / **Resume** stop and restart a task without deleting it. The docs now say what happens to a run missed while the app was closed: a recurring task runs once, for its latest missed time, never once per missed time ([#354](https://github.com/ThinkFlowLab/FlyBot/issues/354)). diff --git a/core/client.js b/core/client.js index b61811de..b6e68e01 100644 --- a/core/client.js +++ b/core/client.js @@ -1021,6 +1021,7 @@ var ZH = { "On the schedule {expression}": "按计划 {expression}", "Next: {time}": "下次 {time}", Finished: "已结束", + Resume: "继续", "in {name}": "在 {name}", "Click again to delete": "再点一次删除", "Delete task": "删除任务", @@ -1059,6 +1060,12 @@ var ZH = { "Repeat every 1 minute to 7 days": "重复间隔要在 1 分钟到 7 天之间", "Pick when it runs": "选择什么时候运行", "No such task": "没有这个任务", + "This task has finished": "这个任务已经结束", + "The task changed meanwhile. Try again": "任务刚刚有变化,请再试一次", + "The task could not be saved": "任务没能保存", + "This task cannot be edited here": "这个任务不能在这里编辑", + "This task cannot be paused": "这个任务不能暂停", + "Its time has passed. Edit the task to pick a new one": "时间已经过了,请编辑任务选一个新时间", "Working folder": "工作文件夹", "Working folder: {folder}": "工作文件夹:{folder}", Folder: "文件夹", @@ -9070,8 +9077,8 @@ function timingText(task) { return ""; } } -var nextText = (task) => task.status === "active" ? t("Next: {time}", { time: dayAndTime(Date.parse(task.scheduledAt)) }) : t("Finished"); -function TaskRow({ task, actions, onView, showBot, roster }) { +var nextText = (task) => task.status === "paused" ? t("Paused") : task.status === "active" ? t("Next: {time}", { time: dayAndTime(Date.parse(task.scheduledAt)) }) : t("Finished"); +function TaskRow({ task, actions, onView, onEdit, showBot, roster }) { const [confirming, setConfirming] = (0, import_react18.useState)(false); const [busy, setBusy] = (0, import_react18.useState)(false); (0, import_react18.useEffect)(() => { @@ -9087,10 +9094,19 @@ function TaskRow({ task, actions, onView, showBot, roster }) { setBusy(true); void actions.scheduleDelete(task.sessionId, task.id).then(onView, () => setBusy(false)); }; + const toggle = () => { + setBusy(true); + const step = task.status === "paused" ? actions.scheduleResume : actions.schedulePause; + void step(task.sessionId, task.id).then((next) => { + setBusy(false); + onView(next); + }, () => setBusy(false)); + }; const room2 = task.roomId ? roster.roomsById[task.roomId] : void 0; + const stoppable = task.status === "active" || task.status === "paused"; return (0, import_react18.createElement)( "div", - { className: "bt-set-row bt-task", "data-done": task.status === "active" ? void 0 : "", "data-busy": busy || void 0 }, + { className: "bt-set-row bt-task", "data-done": stoppable ? void 0 : "", "data-paused": task.status === "paused" || void 0, "data-busy": busy || void 0 }, showBot ? (0, import_react18.createElement)(BotAvatar, { bot: roster.byId[task.botId], size: 28, badge: false, live: false }) : null, (0, import_react18.createElement)( "span", @@ -9100,6 +9116,8 @@ function TaskRow({ task, actions, onView, showBot, roster }) { task.prompt && task.prompt !== task.title ? (0, import_react18.createElement)("span", { className: "bt-task-prompt" }, task.prompt) : null ), (0, import_react18.createElement)("span", { className: "bt-task-next" }, nextText(task)), + onEdit ? (0, import_react18.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Edit"), title: t("Edit"), onClick: () => onEdit(task) }, (0, import_react18.createElement)(PencilIcon)) : null, + stoppable ? (0, import_react18.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, onClick: toggle }, task.status === "paused" ? t("Resume") : t("Pause")) : null, (0, import_react18.createElement)("button", { type: "button", className: "bt-mini bt-task-remove", @@ -9119,22 +9137,44 @@ function inAnHour() { } var KINDS = [["at", "Once"], ["daily", "Every day"], ["weekly", "Every week"], ["every", "Repeat"]]; var UNITS = [["minutes", 1, "minutes"], ["hours", 60, "hours"], ["days", 1440, "days"]]; -function TaskForm({ roster, actions, start, onDone, onCancel }) { - const [target, setTarget] = (0, import_react18.useState)(start ?? roster.bots[0]?.id ?? ""); - const [title, setTitle] = (0, import_react18.useState)(""); - const [prompt, setPrompt] = (0, import_react18.useState)(""); - const [kind, setKind] = (0, import_react18.useState)("daily"); - const [at, setAt] = (0, import_react18.useState)(inAnHour); - const [time, setTime] = (0, import_react18.useState)("09:00"); - const [weekdays, setWeekdays] = (0, import_react18.useState)([1, 2, 3, 4, 5]); - const [count, setCount] = (0, import_react18.useState)(1); - const [unit, setUnit] = (0, import_react18.useState)("hours"); +function localInput(ms) { + const date = new Date(ms); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} +function formStart(task) { + const seconds = task?.kind === "every" ? Number(task.everySeconds) : 3600; + const [unit, size] = seconds % 86400 === 0 ? ["days", 86400] : seconds % 3600 === 0 ? ["hours", 3600] : ["minutes", 60]; + const once = task?.kind === "at" || task?.kind === "after"; + return { + title: task?.title ?? "", + prompt: task?.prompt ?? "", + kind: once ? "at" : task?.kind ?? "daily", + at: once && !Number.isNaN(Date.parse(task.scheduledAt)) ? localInput(Date.parse(task.scheduledAt)) : inAnHour(), + time: task?.time ? clock2(task.time) : "09:00", + weekdays: task?.kind === "weekly" && Array.isArray(task.weekdays) ? task.weekdays : [1, 2, 3, 4, 5], + count: Math.max(1, Math.round(seconds / size)), + unit, + timeZone: task?.timeZone || localZone() + }; +} +function TaskForm({ roster, actions, start, task, onDone, onCancel }) { + const first = formStart(task); + const [target, setTarget] = (0, import_react18.useState)(task ? task.botId ?? task.roomId : start ?? roster.bots[0]?.id ?? ""); + const [title, setTitle] = (0, import_react18.useState)(first.title); + const [prompt, setPrompt] = (0, import_react18.useState)(first.prompt); + const [kind, setKind] = (0, import_react18.useState)(first.kind); + const [at, setAt] = (0, import_react18.useState)(first.at); + const [time, setTime] = (0, import_react18.useState)(first.time); + const [weekdays, setWeekdays] = (0, import_react18.useState)(first.weekdays); + const [count, setCount] = (0, import_react18.useState)(first.count); + const [unit, setUnit] = (0, import_react18.useState)(first.unit); const [busy, setBusy] = (0, import_react18.useState)(false); const [error, setError] = (0, import_react18.useState)(""); const timing = () => { + if (kind === "cron") return { kind: "keep" }; if (kind === "at") return { kind, at: new Date(at).toISOString() }; if (kind === "every") return { kind, minutes: Math.round(Number(count) * UNITS.find(([id]) => id === unit)[1]) }; - return { kind, time, timeZone: localZone(), ...kind === "weekly" ? { weekdays } : {} }; + return { kind, time, timeZone: first.timeZone, ...kind === "weekly" ? { weekdays } : {} }; }; const submit = (event) => { event.preventDefault(); @@ -9149,7 +9189,8 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { setBusy(false); return; } - void actions.scheduleCreate({ target, title, prompt, when: when3 }).then(onDone, (failure) => { + const saved = task ? actions.scheduleUpdate({ sessionId: task.sessionId, id: task.id, target, title, prompt, when: when3 }) : actions.scheduleCreate({ target, title, prompt, when: when3 }); + void saved.then(onDone, (failure) => { setError(hostText(failure?.message ?? String(failure))); setBusy(false); }); @@ -9189,6 +9230,7 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { (0, import_react18.createElement)( "div", { key: kind, className: "bt-task-when bt-veil" }, + kind === "cron" ? (0, import_react18.createElement)("span", { className: "bt-task-every" }, timingText(task)) : null, kind === "at" ? (0, import_react18.createElement)("input", { className: "bt-input", type: "datetime-local", value: at, "aria-label": t("Date and time"), onChange: (event) => setAt(event.target.value) }) : null, kind === "weekly" ? (0, import_react18.createElement)("div", { className: "bt-task-days", role: "group", "aria-label": t("Days") }, [1, 2, 3, 4, 5, 6, 7].map((day) => (0, import_react18.createElement)("button", { key: day, @@ -9216,7 +9258,11 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { "div", { className: "bt-set-row bt-task-foot" }, (0, import_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), - (0, import_react18.createElement)("button", { type: "submit", className: "bt-send bt-soft-sm", disabled: busy || !target || title.trim() === "" || prompt.trim() === "" }, busy ? t("Creating…") : t("Create task")) + (0, import_react18.createElement)( + "button", + { type: "submit", className: "bt-send bt-soft-sm", disabled: busy || !target || title.trim() === "" || prompt.trim() === "" }, + task ? busy ? t("Saving…") : t("Save changes") : busy ? t("Creating…") : t("Create task") + ) ) ); } @@ -9237,47 +9283,76 @@ function AutomationOff({ actions }) { function SchedulesPage({ roster, actions, start }) { const view = useSchedules(actions); const [adding, setAdding] = (0, import_react18.useState)(Boolean(start)); + const [editing, setEditing] = (0, import_react18.useState)(null); if (view === null) return (0, import_react18.createElement)("div", { className: "bt-set-hint" }, t("Loading…")); if (!view.available) return (0, import_react18.createElement)(AutomationOff, { actions }); const shown = (next) => { publish(next); setAdding(false); + setEditing(null); + }; + const edit = (task) => { + setAdding(false); + setEditing(task); }; const byChat = [ ...roster.bots.map((bot) => [bot.id, (0, import_react18.createElement)(BotAvatar, { bot, size: 18, badge: false, live: false }), bot.name, view.tasks.filter((task) => task.botId === bot.id)]), ...roster.rooms.map((room2) => [room2.id, (0, import_react18.createElement)(RoomAvatar, { room: room2, roster, size: 18 }), room2.name, view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id)]) ].filter(([, , , tasks]) => tasks.length > 0); return [ - adding ? (0, import_react18.createElement)(TaskForm, { key: "form", roster, actions, start, onDone: shown, onCancel: () => setAdding(false) }) : (0, import_react18.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => setAdding(true) }, (0, import_react18.createElement)(PlusIcon), t("New task"))), + adding ? (0, import_react18.createElement)(TaskForm, { key: "form", roster, actions, start, onDone: shown, onCancel: () => setAdding(false) }) : (0, import_react18.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => { + setEditing(null); + setAdding(true); + } }, (0, import_react18.createElement)(PlusIcon), t("New task"))), byChat.length === 0 && !adding ? (0, import_react18.createElement)("div", { key: "none", className: "bt-set-card" }, (0, import_react18.createElement)("div", { className: "bt-set-row" }, (0, import_react18.createElement)("span", { className: "bt-set-hint" }, t("No scheduled tasks yet.")))) : byChat.map(([id, avatar, name, tasks]) => (0, import_react18.createElement)( "section", { key: id, className: "bt-set-section" }, (0, import_react18.createElement)("h3", { className: "bt-task-head" }, avatar, name), - (0, import_react18.createElement)("div", { className: "bt-set-card" }, tasks.map((task) => (0, import_react18.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish }))) + (0, import_react18.createElement)(TaskList, { tasks, editing, roster, actions, onEdit: edit, onDone: shown, onCancel: () => setEditing(null) }) )) ]; } +function TaskList({ tasks, editing, roster, actions, onEdit, onDone, onCancel, extra = [] }) { + const open = editing ? tasks.find((task) => task.id === editing.id && task.sessionId === editing.sessionId) : void 0; + const rows = tasks.filter((task) => task !== open).map((task) => (0, import_react18.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish, onEdit })); + return (0, import_react18.createElement)( + import_react18.Fragment, + null, + open ? (0, import_react18.createElement)(TaskForm, { key: `edit-${open.id}`, roster, actions, task: open, onDone, onCancel }) : null, + rows.length + extra.length > 0 ? (0, import_react18.createElement)("div", { className: "bt-set-card" }, ...rows, ...extra) : null + ); +} function BotSchedules({ bot, roster, actions }) { const view = useSchedules(actions); + const [editing, setEditing] = (0, import_react18.useState)(null); if (view === null) return null; const tasks = view.available ? view.tasks.filter((task) => task.botId === bot.id) : []; return (0, import_react18.createElement)( "section", { className: "bt-set-section" }, (0, import_react18.createElement)("h3", null, t("Scheduled tasks")), - (0, import_react18.createElement)( + !view.available ? (0, import_react18.createElement)( "div", { className: "bt-set-card" }, - !view.available ? (0, import_react18.createElement)( + (0, import_react18.createElement)( "div", { className: "bt-set-row bt-bs-row" }, (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page."))), (0, import_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins")) - ) : [ - ...tasks.map((task) => (0, import_react18.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish })), - (0, import_react18.createElement)("button", { key: "new", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => actions.openSettings("schedules", bot.id) }, (0, import_react18.createElement)(PlusIcon), t("New task")) - ] - ) + ) + ) : (0, import_react18.createElement)(TaskList, { + tasks, + editing, + roster, + actions, + onEdit: setEditing, + onDone: (next) => { + publish(next); + setEditing(null); + }, + onCancel: () => setEditing(null), + extra: [(0, import_react18.createElement)("button", { key: "new", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => actions.openSettings("schedules", bot.id) }, (0, import_react18.createElement)(PlusIcon), t("New task"))] + }) ); } function ScheduleSummary({ bot, actions }) { @@ -9305,6 +9380,10 @@ var SCHEDULE_CSS = ` .bt-task-next{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums;white-space:nowrap} .bt-task-remove{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} .bt-task-remove svg{width:14px;height:14px} +.bt-task-tool{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} +.bt-task-tool svg{width:14px;height:14px} +.bt-task-pause{height:26px;color:var(--bt-ink-2)} +.bt-task[data-paused] .bt-task-title,.bt-task[data-paused] .bt-task-prompt{color:var(--bt-ink-3)} .bt-task-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135} .bt-task-head{display:flex;align-items:center;gap:6px} .bt-task-form{animation:bt-q-in .4s cubic-bezier(.22,1,.36,1),bt-veil-in .2s ease-out} @@ -16704,6 +16783,9 @@ function apply(ctx) { setBotCapability: (botId, name, attached) => after(call("bot-capability", { botId, name, attached })), schedules: () => call("schedules"), scheduleCreate: (task) => call("schedule-create", task), + scheduleUpdate: (task) => call("schedule-update", task), + schedulePause: (sessionId, id) => call("schedule-pause", { sessionId, id }), + scheduleResume: (sessionId, id) => call("schedule-resume", { sessionId, id }), scheduleDelete: (sessionId, id) => call("schedule-delete", { sessionId, id }), exchange: (a, b) => call("exchange", { a, b }).catch(() => []), roomProgress: (roomId) => call("room-progress", { roomId }).catch(() => []), diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index e635d3b7..5697bbf4 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -971,6 +971,7 @@ const ZH = { 'On the schedule {expression}': '按计划 {expression}', 'Next: {time}': '下次 {time}', Finished: '已结束', + Resume: '继续', 'in {name}': '在 {name}', 'Click again to delete': '再点一次删除', 'Delete task': '删除任务', @@ -1009,6 +1010,12 @@ const ZH = { 'Repeat every 1 minute to 7 days': '重复间隔要在 1 分钟到 7 天之间', 'Pick when it runs': '选择什么时候运行', 'No such task': '没有这个任务', + 'This task has finished': '这个任务已经结束', + 'The task changed meanwhile. Try again': '任务刚刚有变化,请再试一次', + 'The task could not be saved': '任务没能保存', + 'This task cannot be edited here': '这个任务不能在这里编辑', + 'This task cannot be paused': '这个任务不能暂停', + 'Its time has passed. Edit the task to pick a new one': '时间已经过了,请编辑任务选一个新时间', 'Working folder': '工作文件夹', 'Working folder: {folder}': '工作文件夹:{folder}', Folder: '文件夹', diff --git a/core/src/client/index.js b/core/src/client/index.js index 5e975981..849a1e85 100644 --- a/core/src/client/index.js +++ b/core/src/client/index.js @@ -597,6 +597,9 @@ export function apply(ctx) { setBotCapability: (botId, name, attached) => after(call('bot-capability', { botId, name, attached })), schedules: () => call('schedules'), scheduleCreate: task => call('schedule-create', task), + scheduleUpdate: task => call('schedule-update', task), + schedulePause: (sessionId, id) => call('schedule-pause', { sessionId, id }), + scheduleResume: (sessionId, id) => call('schedule-resume', { sessionId, id }), scheduleDelete: (sessionId, id) => call('schedule-delete', { sessionId, id }), exchange: (a, b) => call('exchange', { a, b }).catch(() => []), roomProgress: roomId => call('room-progress', { roomId }).catch(() => []), diff --git a/core/src/client/schedules.js b/core/src/client/schedules.js index a61fe254..a7e0cc44 100644 --- a/core/src/client/schedules.js +++ b/core/src/client/schedules.js @@ -1,9 +1,10 @@ // Scheduled tasks: Settings → Scheduled tasks, a Bot's card in its settings, and the // line in its details panel. DSH runs the tasks (see src/host/schedules.js); a task -// sends its prompt to the Bot's chat when it is due. +// sends its prompt to the Bot's chat when it is due. A row edits its task in the same +// form that creates one, and pauses or resumes it (#354). import { createElement as h, Fragment, useEffect, useState } from 'react' import { BotAvatar, RoomAvatar } from './mark.js' -import { ClockIcon, PlusIcon, TrashIcon } from './icons.js' +import { ClockIcon, PencilIcon, PlusIcon, TrashIcon } from './icons.js' import { Segmented } from './motion.js' import { dateLocale, hostText, t } from './i18n.js' @@ -69,9 +70,10 @@ export function timingText(task) { default: return '' } } -const nextText = task => (task.status === 'active' ? t('Next: {time}', { time: dayAndTime(Date.parse(task.scheduledAt)) }) : t('Finished')) +const nextText = task => (task.status === 'paused' ? t('Paused') + : task.status === 'active' ? t('Next: {time}', { time: dayAndTime(Date.parse(task.scheduledAt)) }) : t('Finished')) -function TaskRow({ task, actions, onView, showBot, roster }) { +function TaskRow({ task, actions, onView, onEdit, showBot, roster }) { const [confirming, setConfirming] = useState(false) const [busy, setBusy] = useState(false) useEffect(() => { @@ -84,14 +86,22 @@ function TaskRow({ task, actions, onView, showBot, roster }) { setBusy(true) void actions.scheduleDelete(task.sessionId, task.id).then(onView, () => setBusy(false)) } + const toggle = () => { + setBusy(true) + const step = task.status === 'paused' ? actions.scheduleResume : actions.schedulePause + void step(task.sessionId, task.id).then((next) => { setBusy(false); onView(next) }, () => setBusy(false)) + } const room = task.roomId ? roster.roomsById[task.roomId] : undefined - return h('div', { className: 'bt-set-row bt-task', 'data-done': task.status === 'active' ? undefined : '', 'data-busy': busy || undefined }, + const stoppable = task.status === 'active' || task.status === 'paused' + return h('div', { className: 'bt-set-row bt-task', 'data-done': stoppable ? undefined : '', 'data-paused': task.status === 'paused' || undefined, 'data-busy': busy || undefined }, showBot ? h(BotAvatar, { bot: roster.byId[task.botId], size: 28, badge: false, live: false }) : null, h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-task-title' }, task.title), h('span', { className: 'bt-set-hint' }, [timingText(task), room ? t('in {name}', { name: room.name }) : ''].filter(Boolean).join(' · ')), task.prompt && task.prompt !== task.title ? h('span', { className: 'bt-task-prompt' }, task.prompt) : null), h('span', { className: 'bt-task-next' }, nextText(task)), + onEdit ? h('button', { type: 'button', className: 'bt-mini bt-task-tool', disabled: busy, 'aria-label': t('Edit'), title: t('Edit'), onClick: () => onEdit(task) }, h(PencilIcon)) : null, + stoppable ? h('button', { type: 'button', className: 'bt-mini bt-task-pause', disabled: busy, onClick: toggle }, task.status === 'paused' ? t('Resume') : t('Pause')) : null, h('button', { type: 'button', className: 'bt-mini bt-task-remove', 'data-confirm': confirming || undefined, disabled: busy, 'aria-label': confirming ? t('Click again to delete') : t('Delete task'), title: confirming ? undefined : t('Delete task'), onClick: remove, @@ -107,23 +117,50 @@ function inAnHour() { const KINDS = [['at', 'Once'], ['daily', 'Every day'], ['weekly', 'Every week'], ['every', 'Repeat']] const UNITS = [['minutes', 1, 'minutes'], ['hours', 60, 'hours'], ['days', 1440, 'days']] -function TaskForm({ roster, actions, start, onDone, onCancel }) { +// A stored instant as the value of a datetime-local input, in this browser's zone. +function localInput(ms) { + const date = new Date(ms) + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}` +} +// What the form starts with: a new task's defaults, or the edited task's own values. +// A cron rule has no control here, so it is kept unless another kind is picked. +export function formStart(task) { + const seconds = task?.kind === 'every' ? Number(task.everySeconds) : 3600 + const [unit, size] = seconds % 86400 === 0 ? ['days', 86400] : seconds % 3600 === 0 ? ['hours', 3600] : ['minutes', 60] + const once = task?.kind === 'at' || task?.kind === 'after' + return { + title: task?.title ?? '', + prompt: task?.prompt ?? '', + kind: once ? 'at' : task?.kind ?? 'daily', + at: once && !Number.isNaN(Date.parse(task.scheduledAt)) ? localInput(Date.parse(task.scheduledAt)) : inAnHour(), + time: task?.time ? clock(task.time) : '09:00', + weekdays: task?.kind === 'weekly' && Array.isArray(task.weekdays) ? task.weekdays : [1, 2, 3, 4, 5], + count: Math.max(1, Math.round(seconds / size)), + unit, + timeZone: task?.timeZone || localZone(), + } +} + +function TaskForm({ roster, actions, start, task, onDone, onCancel }) { // A task goes to a Bot's chat or to a group chat, where it reads as your message. - const [target, setTarget] = useState(start ?? roster.bots[0]?.id ?? '') - const [title, setTitle] = useState('') - const [prompt, setPrompt] = useState('') - const [kind, setKind] = useState('daily') - const [at, setAt] = useState(inAnHour) - const [time, setTime] = useState('09:00') - const [weekdays, setWeekdays] = useState([1, 2, 3, 4, 5]) - const [count, setCount] = useState(1) - const [unit, setUnit] = useState('hours') + const first = formStart(task) + const [target, setTarget] = useState(task ? (task.botId ?? task.roomId) : start ?? roster.bots[0]?.id ?? '') + const [title, setTitle] = useState(first.title) + const [prompt, setPrompt] = useState(first.prompt) + const [kind, setKind] = useState(first.kind) + const [at, setAt] = useState(first.at) + const [time, setTime] = useState(first.time) + const [weekdays, setWeekdays] = useState(first.weekdays) + const [count, setCount] = useState(first.count) + const [unit, setUnit] = useState(first.unit) const [busy, setBusy] = useState(false) const [error, setError] = useState('') const timing = () => { + if (kind === 'cron') return { kind: 'keep' } if (kind === 'at') return { kind, at: new Date(at).toISOString() } if (kind === 'every') return { kind, minutes: Math.round(Number(count) * UNITS.find(([id]) => id === unit)[1]) } - return { kind, time, timeZone: localZone(), ...(kind === 'weekly' ? { weekdays } : {}) } + // An edited task keeps its own time zone; a new one takes this browser's. + return { kind, time, timeZone: first.timeZone, ...(kind === 'weekly' ? { weekdays } : {}) } } const submit = (event) => { event.preventDefault() @@ -132,8 +169,10 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { setError('') let when try { when = timing() } catch { setError(t('Pick a date and time')); setBusy(false); return } - void actions.scheduleCreate({ target, title, prompt, when }) - .then(onDone, (failure) => { setError(hostText(failure?.message ?? String(failure))); setBusy(false) }) + const saved = task + ? actions.scheduleUpdate({ sessionId: task.sessionId, id: task.id, target, title, prompt, when }) + : actions.scheduleCreate({ target, title, prompt, when }) + void saved.then(onDone, (failure) => { setError(hostText(failure?.message ?? String(failure))); setBusy(false) }) } const toggleDay = day => setWeekdays(days => (days.includes(day) ? days.filter(item => item !== day) : [...days, day].sort())) return h('form', { className: 'bt-set-card bt-task-form', onSubmit: submit }, @@ -152,6 +191,7 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, t('When'))), h(Segmented, { label: t('When'), value: kind }, KINDS.map(([id, label]) => h('button', { key: id, type: 'button', 'aria-pressed': kind === id, onClick: () => setKind(id) }, t(label)))), h('div', { key: kind, className: 'bt-task-when bt-veil' }, + kind === 'cron' ? h('span', { className: 'bt-task-every' }, timingText(task)) : null, kind === 'at' ? h('input', { className: 'bt-input', type: 'datetime-local', value: at, 'aria-label': t('Date and time'), onChange: event => setAt(event.target.value) }) : null, kind === 'weekly' ? h('div', { className: 'bt-task-days', role: 'group', 'aria-label': t('Days') }, [1, 2, 3, 4, 5, 6, 7].map(day => h('button', { key: day, type: 'button', className: 'bt-task-day', 'aria-pressed': weekdays.includes(day), onClick: () => toggleDay(day), @@ -165,7 +205,8 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { error ? h('div', { className: 'bt-set-row' }, h('span', { className: 'bt-danger bt-set-hint' }, error)) : null, h('div', { className: 'bt-set-row bt-task-foot' }, h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: onCancel }, t('Cancel')), - h('button', { type: 'submit', className: 'bt-send bt-soft-sm', disabled: busy || !target || title.trim() === '' || prompt.trim() === '' }, busy ? t('Creating…') : t('Create task')))) + h('button', { type: 'submit', className: 'bt-send bt-soft-sm', disabled: busy || !target || title.trim() === '' || prompt.trim() === '' }, + task ? (busy ? t('Saving…') : t('Save changes')) : busy ? t('Creating…') : t('Create task')))) } // The switch for Automation tasks is DSH's Plugins panel, not its settings dialog. The @@ -187,9 +228,11 @@ function AutomationOff({ actions }) { export function SchedulesPage({ roster, actions, start }) { const view = useSchedules(actions) const [adding, setAdding] = useState(Boolean(start)) + const [editing, setEditing] = useState(null) if (view === null) return h('div', { className: 'bt-set-hint' }, t('Loading…')) if (!view.available) return h(AutomationOff, { actions }) - const shown = next => { publish(next); setAdding(false) } + const shown = next => { publish(next); setAdding(false); setEditing(null) } + const edit = (task) => { setAdding(false); setEditing(task) } const byChat = [ ...roster.bots.map(bot => [bot.id, h(BotAvatar, { bot, size: 18, badge: false, live: false }), bot.name, view.tasks.filter(task => task.botId === bot.id)]), ...roster.rooms.map(room => [room.id, h(RoomAvatar, { room, roster, size: 18 }), room.name, view.tasks.filter(task => task.botId === undefined && task.roomId === room.id)]), @@ -197,31 +240,43 @@ export function SchedulesPage({ roster, actions, start }) { return [ adding ? h(TaskForm, { key: 'form', roster, actions, start, onDone: shown, onCancel: () => setAdding(false) }) - : h('div', { key: 'add', className: 'bt-actions' }, h('button', { type: 'button', className: 'bt-soft bt-soft-sm bt-with-icon', onClick: () => setAdding(true) }, h(PlusIcon), t('New task'))), + : h('div', { key: 'add', className: 'bt-actions' }, h('button', { type: 'button', className: 'bt-soft bt-soft-sm bt-with-icon', onClick: () => { setEditing(null); setAdding(true) } }, h(PlusIcon), t('New task'))), byChat.length === 0 && !adding ? h('div', { key: 'none', className: 'bt-set-card' }, h('div', { className: 'bt-set-row' }, h('span', { className: 'bt-set-hint' }, t('No scheduled tasks yet.')))) : byChat.map(([id, avatar, name, tasks]) => h('section', { key: id, className: 'bt-set-section' }, h('h3', { className: 'bt-task-head' }, avatar, name), - h('div', { className: 'bt-set-card' }, tasks.map(task => h(TaskRow, { key: task.id, task, actions, roster, onView: publish }))))), + h(TaskList, { tasks, editing, roster, actions, onEdit: edit, onDone: shown, onCancel: () => setEditing(null) }))), ] } +// One chat's rows; the task being edited opens the form in its place. +function TaskList({ tasks, editing, roster, actions, onEdit, onDone, onCancel, extra = [] }) { + const open = editing ? tasks.find(task => task.id === editing.id && task.sessionId === editing.sessionId) : undefined + const rows = tasks.filter(task => task !== open).map(task => h(TaskRow, { key: task.id, task, actions, roster, onView: publish, onEdit })) + return h(Fragment, null, + open ? h(TaskForm, { key: `edit-${open.id}`, roster, actions, task: open, onDone, onCancel }) : null, + rows.length + extra.length > 0 ? h('div', { className: 'bt-set-card' }, ...rows, ...extra) : null) +} + // A Bot's tasks in its settings form, with the way to add one. export function BotSchedules({ bot, roster, actions }) { const view = useSchedules(actions) + const [editing, setEditing] = useState(null) if (view === null) return null const tasks = view.available ? view.tasks.filter(task => task.botId === bot.id) : [] return h('section', { className: 'bt-set-section' }, h('h3', null, t('Scheduled tasks')), - h('div', { className: 'bt-set-card' }, - !view.available - ? h('div', { className: 'bt-set-row bt-bs-row' }, + !view.available + ? h('div', { className: 'bt-set-card' }, + h('div', { className: 'bt-set-row bt-bs-row' }, h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-set-hint' }, t('Switch on Automation tasks on the Plugins page.'))), - h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => openPlugins(actions) }, t('Open Plugins'))) - : [ - ...tasks.map(task => h(TaskRow, { key: task.id, task, actions, roster, onView: publish })), - h('button', { key: 'new', type: 'button', className: 'bt-set-row bt-bs-row bt-gs-add', onClick: () => actions.openSettings('schedules', bot.id) }, h(PlusIcon), t('New task')), - ])) + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => openPlugins(actions) }, t('Open Plugins')))) + : h(TaskList, { + tasks, editing, roster, actions, onEdit: setEditing, + onDone: (next) => { publish(next); setEditing(null) }, + onCancel: () => setEditing(null), + extra: [h('button', { key: 'new', type: 'button', className: 'bt-set-row bt-bs-row bt-gs-add', onClick: () => actions.openSettings('schedules', bot.id) }, h(PlusIcon), t('New task'))], + })) } // The details panel's line: the Bot's next tasks, or how to add one. @@ -245,6 +300,10 @@ export const SCHEDULE_CSS = ` .bt-task-next{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums;white-space:nowrap} .bt-task-remove{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} .bt-task-remove svg{width:14px;height:14px} +.bt-task-tool{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} +.bt-task-tool svg{width:14px;height:14px} +.bt-task-pause{height:26px;color:var(--bt-ink-2)} +.bt-task[data-paused] .bt-task-title,.bt-task[data-paused] .bt-task-prompt{color:var(--bt-ink-3)} .bt-task-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135} .bt-task-head{display:flex;align-items:center;gap:6px} .bt-task-form{animation:bt-q-in .4s cubic-bezier(.22,1,.36,1),bt-veil-in .2s ease-out} diff --git a/core/src/host/authz.js b/core/src/host/authz.js index 0784b528..dd302635 100644 --- a/core/src/host/authz.js +++ b/core/src/host/authz.js @@ -38,7 +38,7 @@ const SHARED_BLOCKED = new Set([ const BOT_TARGETED = new Set([ 'update-bot', 'delete-bot', 'duplicate-bot', 'set-flags', 'bot-capability', 'memory-view', 'memory-ask', 'memory-forget', - 'schedules', 'schedule-create', 'schedule-delete', + 'schedules', 'schedule-create', 'schedule-update', 'schedule-pause', 'schedule-resume', 'schedule-delete', 'agent-session', 'agent-adopt', 'agent-archive', 'agent-rename', 'remove-agent', 'stop-member-work', 'set-main', 'test-model', ]) @@ -225,15 +225,16 @@ export function install(rt) { const requireSession = id => (sessionOwnedBy(actor, id) ? ALLOW : deny('bot/forbidden', 'You do not own that session')) if (BOT_TARGETED.has(ep)) { - if (ep === 'schedules' || ep === 'schedule-create' || ep === 'schedule-delete') { + if (ep === 'schedules' || ep.startsWith('schedule-')) { // The create endpoint keys off `body.target` (Bot or room id); `sessionId` is // the list/delete key. Checking only sessionId would allow a foreign target - // paired with an owned session (#106). - if (ep === 'schedule-create') { + // paired with an owned session (#106). An edit needs both: the task's Session + // and the target it moves to (#354). + if (ep === 'schedule-create' || ep === 'schedule-update') { const target = body?.target if (typeof target === 'string' && target !== '') { - if (botOwnedBy(actor, target) || roomOwnedBy(actor, target) || roomMemberOf(actor, target)) return ALLOW - return deny('bot/forbidden', 'You do not own that Bot or group chat') + if (!(botOwnedBy(actor, target) || roomOwnedBy(actor, target) || roomMemberOf(actor, target))) return deny('bot/forbidden', 'You do not own that Bot or group chat') + if (ep === 'schedule-create') return ALLOW } } if (ep === 'schedule-delete' && typeof body?.id === 'string' && !sessionOwnedBy(actor, body.id) && !sessionOwnedBy(actor, body.sessionId)) return deny('bot/forbidden', 'You do not own that schedule') diff --git a/core/src/host/channel.js b/core/src/host/channel.js index 725921c1..690789b2 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -135,7 +135,7 @@ export function install(rt) { // A chat reference chip names a Session whose snapshot rides to every recipient of // the message, so the author must be able to read what they share (#53, #106). // Actor-authored text that becomes a session user message: send, room-post, - // answer-question, schedule-create. Bot-authored room posts strip chips in groups.js. + // answer-question, schedule-create, schedule-update. Bot-authored room posts strip chips in groups.js. // Menu chips encode the session id as base64url(JSON); raw ids still appear in tests. const decodeSessionChip = (payload) => { try { return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) } catch { return undefined } @@ -345,10 +345,13 @@ export function install(rt) { case 'bot-connectors': return ok(await rt.botConnectorEndpoint(body)) case 'schedules': + case 'schedule-pause': + case 'schedule-resume': case 'schedule-delete': await rt.load() return ok(await rt.scheduleEndpoint(endpoint, body)) - case 'schedule-create': { + case 'schedule-create': + case 'schedule-update': { await rt.load() const leaked = unshareableReference(actor, [body.title, body.prompt].filter(part => typeof part === 'string').join('\n')) if (leaked !== undefined) return fail('You do not own that chat', 'bot/forbidden') diff --git a/core/src/host/schedules.js b/core/src/host/schedules.js index b5929ffd..a9481049 100644 --- a/core/src/host/schedules.js +++ b/core/src/host/schedules.js @@ -5,6 +5,15 @@ // them. A group chat's task lives on the group's own Session, so the reminder reaches // the group like a message from the user (see reminderText). DSH 0.2.0 mounts the service only with its Automation tasks bundle on, so // the service is optional and every endpoint reports whether it is there. +// +// Edit and Pause (#354). Edit keeps a task where it is: on the same Session it goes +// through DSH's own compare-and-update (the id and delivery records stay); to another +// Bot or group chat, or for a finished one-shot, the new task is created before the old +// one goes, so a failure keeps the old task. DSH has no pause, so Pause keeps the task's +// record in FlyBot's state (`pausedSchedules`) before it removes it from DSH, and Resume +// creates it again on the Bot's current part. A missed run is DSH's: after the app was +// closed a recurring task runs once, for its latest missed time, never once per missed +// time (docs/subsystems/core-host.md). const MAX_TITLE = 120 const TIME = /^([01]\d|2[0-3]):[0-5]\d$/ @@ -39,6 +48,28 @@ function requestOf(record) { return null } +// A create request's timing as the record fields a paused task keeps (the inverse of +// requestOf), so a paused task reads like any other in the list. +function timingOf(selector) { + if (selector.at !== undefined) return { kind: 'at', scheduledAt: selector.at } + if (selector.every_seconds !== undefined) return { kind: 'every', everySeconds: selector.every_seconds } + if (selector.daily !== undefined) return { kind: 'daily', time: selector.daily.time, timeZone: selector.daily.time_zone } + if (selector.weekly !== undefined) return { kind: 'weekly', time: selector.weekly.time, timeZone: selector.weekly.time_zone, weekdays: selector.weekly.weekdays } + if (selector.cron !== undefined) return { kind: 'cron', expression: selector.cron.expression, timeZone: selector.cron.time_zone } + return {} +} +const TIMING_FIELDS = ['at', 'every_seconds', 'daily', 'weekly', 'cron'] +const timingPart = request => Object.fromEntries(TIMING_FIELDS.filter(key => request[key] !== undefined).map(key => [key, request[key]])) +// DSH's update takes a timing change as one discriminated value. +function changeOf(selector) { + if (selector.at !== undefined) return { kind: 'at', at: selector.at } + if (selector.every_seconds !== undefined) return { kind: 'every', every_seconds: selector.every_seconds } + if (selector.daily !== undefined) return { kind: 'daily', daily: selector.daily } + if (selector.weekly !== undefined) return { kind: 'weekly', weekly: selector.weekly } + return { kind: 'cron', cron: selector.cron } +} +const UPDATE_MISSES = { schedule_not_found: 'No such task', schedule_ended: 'This task has finished', schedule_conflict: 'The task changed meanwhile. Try again' } + // The settings form's timing as a create request selector. function selectorOf(when) { const zone = typeof when?.timeZone === 'string' && when.timeZone !== '' ? when.timeZone : 'UTC' @@ -89,6 +120,35 @@ export function install(rt) { return rt.groupOwners.get(sessionId) ?? rt.pastOwners.get(sessionId) } const known = owner => owner !== undefined && (owner.botId === undefined ? state.rooms[owner.roomId] !== undefined : state.bots[owner.botId] !== undefined) + // Paused tasks, by their DSH id: { id, sessionId, record, pausedAt }. DSH ids are + // unique across the host, so a paused task keeps the id it had. + const paused = () => { + if (state.pausedSchedules === null || typeof state.pausedSchedules !== 'object' || Array.isArray(state.pausedSchedules)) state.pausedSchedules = {} + return state.pausedSchedules + } + const pausedEntry = (sessionId, id) => { + const entry = Object.hasOwn(paused(), String(id ?? '')) ? paused()[id] : undefined + return entry !== undefined && entry.sessionId === sessionId ? entry : undefined + } + // The Session a Bot's or group chat's new task goes to: the Bot's current part. + const sessionFor = owner => (owner.botId !== undefined ? rt.chatOf(owner.botId) : owner.roomId) + const ensureService = () => { if (service === null) throw invalid('Automation tasks are off in DSH') } + + // The form's name, instruction and target, checked as creation checks them. + function contentOf(body) { + const bot = state.bots[body.target] + const room = state.rooms[body.target] + if (bot === undefined && room === undefined) throw invalid('No such Bot or group chat') + const title = String(body.title ?? '').trim() + const prompt = String(body.prompt ?? '').trim() + if (title === '') throw invalid('Give the task a name') + if (title.length > MAX_TITLE) throw invalid(`Keep the name under ${MAX_TITLE} characters`) + if (prompt === '') throw invalid('Say what the Bot should do') + return { sessionId: bot ? rt.chatOf(bot.id) : room.id, title, prompt } + } + + // One stored task as DSH has it, active or finished, or undefined. + const findRecord = async (sessionId, id) => (await service.catalog()).find(entry => entry.sessionId === sessionId && entry.id === id) async function schedulesView() { if (service === null) return { available: false, tasks: [] } @@ -98,27 +158,112 @@ export function install(rt) { if (!known(owner)) continue tasks.push({ ...entry, ...owner }) } + for (const entry of Object.values(paused())) { + const owner = ownerOf(entry.sessionId) + if (!known(owner)) continue + tasks.push({ ...entry.record, id: entry.id, sessionId: entry.sessionId, status: 'paused', pausedAt: entry.pausedAt, ...owner }) + } return { available: true, tasks } } + // Edit: a paused task stays paused with its new content; an active one is updated in + // place on the same Session, otherwise created anew before the old one is deleted. + // `when: { kind: 'keep' }` keeps the timing (a cron rule the form cannot show). + async function updateSchedule(body) { + ensureService() + const sessionId = String(body.sessionId ?? '') + const id = String(body.id ?? '') + if (ownerOf(sessionId) === undefined) throw invalid('No such task') + const content = contentOf(body) + const keep = body.when?.kind === 'keep' + const held = pausedEntry(sessionId, id) + if (held !== undefined) { + const timing = keep ? timingPart(requestOf(held.record) ?? {}) : selectorOf(body.when) + paused()[id] = { ...held, sessionId: content.sessionId, record: { title: content.title, prompt: content.prompt, ...timingOf(timing) } } + await rt.save() + return schedulesView() + } + // DSH's update compares the exact record `list` gives (active tasks only). + const active = (await service.list({ sessionId })).find(entry => entry.id === id) + const record = active ?? await findRecord(sessionId, id) + if (record === undefined) throw invalid('No such task') + const stored = requestOf(record) + if (stored === null) throw invalid('This task cannot be edited here') + const timing = keep ? timingPart(stored) : selectorOf(body.when) + if (active !== undefined && content.sessionId === sessionId && typeof service.update === 'function') { + const same = JSON.stringify(timingPart(stored)) === JSON.stringify(timing) + const result = await service.update({ sessionId, id, expected: active, title: content.title, prompt: content.prompt, ...(same ? {} : { change: changeOf(timing) }) }) + if (result?.record === undefined) throw invalid(UPDATE_MISSES[result?.code] ?? result?.message ?? 'The task could not be saved') + return schedulesView() + } + if (timing.at !== undefined && Date.parse(timing.at) <= Date.now()) throw invalid('Pick a time in the future') + await service.create(content.sessionId, { title: content.title, prompt: content.prompt, ...timing }) + await service.delete({ sessionId, id }) + return schedulesView() + } + + // Pause: the record is saved in FlyBot's state before DSH lets the task go, so a + // failed delete leaves it running and never loses it. + async function pauseSchedule(body) { + ensureService() + const sessionId = String(body.sessionId ?? '') + const id = String(body.id ?? '') + if (ownerOf(sessionId) === undefined) throw invalid('No such task') + if (pausedEntry(sessionId, id) !== undefined) return schedulesView() + const record = await findRecord(sessionId, id) + if (record === undefined) throw invalid('No such task') + if (record.status !== 'active') throw invalid('This task has finished') + const request = requestOf(record) + if (request === null) throw invalid('This task cannot be paused') + paused()[id] = { id, sessionId, record: { title: record.title, prompt: record.prompt, ...timingOf(timingPart(request)) }, pausedAt: Date.now() } + await rt.save() + try { + const result = await service.delete({ sessionId, id }) + if (result?.deleted === false) throw invalid('No such task') + } catch (error) { + delete paused()[id] + await rt.save() + throw error + } + return schedulesView() + } + + // Resume: DSH gets the task again, on the Bot's current part, before FlyBot forgets + // the paused copy. A one-time task whose time has passed waits for an edit. + async function resumeSchedule(body) { + ensureService() + const sessionId = String(body.sessionId ?? '') + const held = pausedEntry(sessionId, String(body.id ?? '')) + const owner = ownerOf(sessionId) + if (held === undefined || !known(owner)) throw invalid('No such task') + const request = requestOf(held.record) + if (request === null) throw invalid('No such task') + if (request.at !== undefined && Date.parse(request.at) <= Date.now()) throw invalid('Its time has passed. Edit the task to pick a new one') + await service.create(sessionFor(owner), request) + delete paused()[held.id] + await rt.save() + return schedulesView() + } + const endpoints = { schedules: () => schedulesView(), async 'schedule-create'(body) { - if (service === null) throw invalid('Automation tasks are off in DSH') - const bot = state.bots[body.target] - const room = state.rooms[body.target] - if (bot === undefined && room === undefined) throw invalid('No such Bot or group chat') - const title = String(body.title ?? '').trim() - const prompt = String(body.prompt ?? '').trim() - if (title === '') throw invalid('Give the task a name') - if (title.length > MAX_TITLE) throw invalid(`Keep the name under ${MAX_TITLE} characters`) - if (prompt === '') throw invalid('Say what the Bot should do') - await service.create(bot ? rt.chatOf(bot.id) : room.id, { title, prompt, ...selectorOf(body.when) }) + ensureService() + const { sessionId, title, prompt } = contentOf(body) + await service.create(sessionId, { title, prompt, ...selectorOf(body.when) }) return schedulesView() }, + 'schedule-update': body => updateSchedule(body), + 'schedule-pause': body => pauseSchedule(body), + 'schedule-resume': body => resumeSchedule(body), async 'schedule-delete'(body) { - if (service === null) throw invalid('Automation tasks are off in DSH') + ensureService() if (ownerOf(body.sessionId) === undefined) throw invalid('No such task') + if (pausedEntry(body.sessionId, body.id) !== undefined) { + delete paused()[body.id] + await rt.save() + return schedulesView() + } await service.delete({ sessionId: body.sessionId, id: body.id }) return schedulesView() }, diff --git a/core/test/authz.test.js b/core/test/authz.test.js index aca83a45..a9c3f0b7 100644 --- a/core/test/authz.test.js +++ b/core/test/authz.test.js @@ -215,6 +215,24 @@ test('schedule-create cannot hide a foreign target behind an owned sessionId', a assert.equal(rt.authorize(actorA, 'schedule-create', { target: 'a1' }).ok, true) }) +test('editing, pausing and resuming a task need its Session, and an edit its new target too (#354)', async (t) => { + const { rt } = runtime(t, TEAM) + await rt.load() + const sessionA = await redeemOwner(rt) + await redeemOwner(rt) + rt.state.bots.a1 = { id: 'a1', sessionId: 'chat-a1', name: 'A', instructions: '', createdAt: 2, ownerId: 'owner-1' } + rt.state.bots.b1 = { id: 'b1', sessionId: 'chat-b1', name: 'B', instructions: '', createdAt: 3, ownerId: 'owner-2' } + await rt.save() // indexes the Bots' Sessions + const actorA = await rt.authenticate(sessionA.sessionToken) + assert.equal(rt.authorize(actorA, 'schedule-update', { sessionId: 'chat-a1', id: 's1', target: 'a1' }).ok, true) + assert.equal(rt.authorize(actorA, 'schedule-update', { sessionId: 'chat-a1', id: 's1', target: 'b1' }).code, 'bot/forbidden') + assert.equal(rt.authorize(actorA, 'schedule-update', { sessionId: 'chat-b1', id: 's1', target: 'a1' }).code, 'bot/forbidden') + for (const ep of ['schedule-pause', 'schedule-resume']) { + assert.equal(rt.authorize(actorA, ep, { sessionId: 'chat-a1', id: 's1' }).ok, true) + assert.equal(rt.authorize(actorA, ep, { sessionId: 'chat-b1', id: 's1' }).code, 'bot/forbidden') + } +}) + test('task endpoints are an explicit allow-list, not a task-* prefix', async (t) => { const { rt } = runtime(t, TEAM) await rt.load() diff --git a/core/test/schedules-client.test.js b/core/test/schedules-client.test.js index b50f0c66..8632f588 100644 --- a/core/test/schedules-client.test.js +++ b/core/test/schedules-client.test.js @@ -9,7 +9,7 @@ import { build } from 'esbuild' // where the switch is; the Harness settings dialog has none. const { outputFiles } = await build({ stdin: { - contents: "export { SchedulesPage, BotSchedules, openPlugins } from './src/client/schedules.js'", + contents: "export { SchedulesPage, BotSchedules, openPlugins, formStart } from './src/client/schedules.js'", resolveDir: fileURLToPath(new URL('..', import.meta.url)), }, bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', @@ -17,7 +17,9 @@ const { outputFiles } = await build({ }) const h = (type, props, ...children) => ({ type, props: { ...props, children } }) const OFF = { available: false, tasks: [] } -const react = { createElement: h, Fragment: Symbol('F'), useEffect() {}, useLayoutEffect() {}, useState: v => [v === null ? OFF : v, () => {}], useMemo: f => f(), useRef: current => ({ current }), useCallback: f => f, useSyncExternalStore: (_s, get) => get() } +// States handed out in order before the defaults, to render a page with tasks or a row being edited. +const seeded = [] +const react = { createElement: h, Fragment: Symbol('F'), useEffect() {}, useLayoutEffect() {}, useState: v => [seeded.length > 0 ? seeded.shift() : v === null ? OFF : v, () => {}], useMemo: f => f(), useRef: current => ({ current }), useCallback: f => f, useSyncExternalStore: (_s, get) => get() } const module = { exports: {} } runInNewContext(outputFiles[0].text, { module, exports: module.exports, document: { hidden: false }, navigator: { language: 'en' }, localStorage: { getItem: () => null, setItem() {} }, @@ -29,7 +31,7 @@ runInNewContext(outputFiles[0].text, { throw new Error(`Unexpected platform module ${name}`) }, }) -const { SchedulesPage, BotSchedules, openPlugins } = module.exports +const { SchedulesPage, BotSchedules, openPlugins, formStart } = module.exports // Every node and string under a tree; an array (what a page returns) is walked too. const all = (node, out = []) => { if (Array.isArray(node)) { for (const item of node) all(item, out); return out } @@ -80,3 +82,56 @@ test('the hint reads the same in English and Chinese, and says Plugins page, not assert.doesNotMatch(source, /Automation tasks on the Plugins page in Harness settings/) assert.doesNotMatch(readFileSync(new URL('../src/client/schedules.js', import.meta.url), 'utf8'), /openHarnessSettings|Open Harness settings/) }) + +// Function components expanded, so their rows and buttons can be read. +const render = (node) => { + if (Array.isArray(node)) return node.map(render) + if (!node || typeof node !== 'object') return node + if (typeof node.type === 'function') return render(node.type(node.props)) + return { ...node, props: { ...node.props, children: render(node.props?.children) } } +} +const ACTIVE = { id: 's1', sessionId: 'chat-b1', botId: 'b1', title: 'Briefing', prompt: 'Sum up', kind: 'daily', time: '09:00:00', timeZone: 'Asia/Shanghai', scheduledAt: '2030-01-01T01:00:00.000Z', status: 'active' } +const PAUSED = { id: 's2', sessionId: 'chat-b1', botId: 'b1', title: 'Weekly', prompt: 'Plan', kind: 'every', everySeconds: 7200, status: 'paused' } +const ROSTER = { bots: [{ id: 'b1', name: 'B' }], rooms: [], byId: {}, roomsById: {} } + +test("a task row offers Edit and Pause; a paused one reads Paused and offers Resume (#354)", async () => { + const calls = [] + const actions = { ...spy(), schedulePause: async (...args) => { calls.push(['pause', ...args]); return OFF }, scheduleResume: async (...args) => { calls.push(['resume', ...args]); return OFF } } + seeded.push({ available: true, tasks: [ACTIVE, PAUSED] }, null) + const card = render(BotSchedules({ bot: { id: 'b1' }, roster: ROSTER, actions })) + const lines = text(card) + assert.ok(lines.includes('Paused')) + const buttons = buttonsOf(card) + assert.equal(buttons.filter(button => button.props['aria-label'] === 'Edit').length, 2) + const pause = buttons.find(button => text(button).includes('Pause')) + const resume = buttons.find(button => text(button).includes('Resume')) + pause.props.onClick() + resume.props.onClick() + await new Promise(resolve => setImmediate(resolve)) + assert.deepEqual(JSON.parse(JSON.stringify(calls)), [['pause', 'chat-b1', 's1'], ['resume', 'chat-b1', 's2']]) +}) + +test('Edit opens the task form filled in, and saving updates that task (#354)', async () => { + const sent = [] + const actions = { ...spy(), scheduleUpdate: async (task) => { sent.push(task); return OFF } } + seeded.push({ available: true, tasks: [ACTIVE, PAUSED] }, ACTIVE) + const card = render(BotSchedules({ bot: { id: 'b1' }, roster: ROSTER, actions })) + const form = all(card).find(node => node.type === 'form') + assert.ok(form, 'the edited row is a form') + assert.ok(all(form).some(node => node.type === 'input' && node.props.value === 'Briefing')) + assert.ok(all(form).some(node => node.type === 'textarea' && node.props.value === 'Sum up')) + assert.ok(text(form).includes('Save changes')) + assert.equal(all(card).filter(node => node.type === 'form').length, 1) + form.props.onSubmit({ preventDefault() {} }) + await new Promise(resolve => setImmediate(resolve)) + assert.deepEqual(JSON.parse(JSON.stringify(sent)), [{ sessionId: 'chat-b1', id: 's1', target: 'b1', title: 'Briefing', prompt: 'Sum up', when: { kind: 'daily', time: '09:00', timeZone: 'Asia/Shanghai' } }]) +}) + +test('the form starts from the edited task: repeat interval, one time, cron kept', () => { + assert.deepEqual([formStart(PAUSED).kind, formStart(PAUSED).count, formStart(PAUSED).unit], ['every', 2, 'hours']) + assert.deepEqual([formStart({ kind: 'every', everySeconds: 2 * 86400 }).count, formStart({ kind: 'every', everySeconds: 2 * 86400 }).unit], [2, 'days']) + const once = formStart({ kind: 'at', scheduledAt: new Date(2030, 0, 2, 7, 5).toISOString() }) + assert.deepEqual([once.kind, once.at], ['at', '2030-01-02T07:05']) + assert.equal(formStart({ kind: 'cron', expression: '0 9 * * 1', timeZone: 'UTC' }).kind, 'cron') + assert.deepEqual([formStart(undefined).kind, formStart(undefined).title, formStart(undefined).time], ['daily', '', '09:00']) +}) diff --git a/core/test/schedules.test.js b/core/test/schedules.test.js index f019f7a1..9b285c77 100644 --- a/core/test/schedules.test.js +++ b/core/test/schedules.test.js @@ -1,5 +1,5 @@ import assert from 'node:assert/strict' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' @@ -21,22 +21,48 @@ const TEAM = { questions: {}, } +// The record fields DSH stores for each create selector (see dsh-schedule's README). +const KINDS = { + at: at => ({ kind: 'at', scheduledAt: at }), + every_seconds: seconds => ({ kind: 'every', everySeconds: seconds, scheduledAt: '2030-01-01T01:00:00.000Z' }), + daily: ({ time, time_zone }) => ({ kind: 'daily', time, timeZone: time_zone, scheduledAt: '2030-01-01T01:00:00.000Z' }), + weekly: ({ time, time_zone, weekdays }) => ({ kind: 'weekly', time, timeZone: time_zone, weekdays, scheduledAt: '2030-01-01T01:00:00.000Z' }), + cron: ({ expression, time_zone }) => ({ kind: 'cron', expression, timeZone: time_zone, scheduledAt: '2030-01-01T01:00:00.000Z' }), +} +const CHANGE = { at: 'at', every: 'every_seconds', daily: 'daily', weekly: 'weekly', cron: 'cron' } + // An in-memory stand-in for DSH's schedule service. function scheduleService() { const tasks = new Map() + const updates = [] let next = 1 return { tasks, + updates, + failCreate: false, + failDelete: false, async create(sessionId, request) { + if (this.failCreate) throw new Error('create failed') const id = `schedule-${next++}` const kind = Object.keys(request).find(key => !['title', 'prompt'].includes(key)) - const record = { id, kind, title: request.title, prompt: request.prompt, timing: request[kind], scheduledAt: '2030-01-01T01:00:00.000Z' } - tasks.set(id, { sessionId, record }) + const record = { id, title: request.title, prompt: request.prompt, timing: request[kind], ...KINDS[kind](request[kind]) } + tasks.set(id, { sessionId, record, status: 'active' }) return record }, - async list({ sessionId }) { return [...tasks.values()].filter(task => task.sessionId === sessionId).map(task => task.record) }, - async catalog() { return [...tasks.values()].map(task => ({ ...task.record, sessionId: task.sessionId, status: 'active' })) }, + async list({ sessionId }) { return [...tasks.values()].filter(task => task.sessionId === sessionId && task.status === 'active').map(task => task.record) }, + async catalog() { return [...tasks.values()].map(task => ({ ...task.record, sessionId: task.sessionId, status: task.status })) }, + async update(request) { + updates.push(request) + const task = tasks.get(request.id) + if (task?.sessionId !== request.sessionId) return { id: request.id, updated: false, code: 'schedule_not_found' } + if (task.status !== 'active') return { id: request.id, updated: false, code: 'schedule_ended' } + assert.deepEqual(request.expected, task.record, 'update compares the exact listed record') + const timing = request.change === undefined ? {} : { timing: request.change[CHANGE[request.change.kind]], ...KINDS[CHANGE[request.change.kind]](request.change[CHANGE[request.change.kind]]) } + task.record = { ...task.record, title: request.title ?? task.record.title, prompt: request.prompt ?? task.record.prompt, ...timing } + return { id: request.id, updated: true, record: task.record } + }, async delete({ sessionId, id }) { + if (this.failDelete) throw new Error('delete failed') if (tasks.get(id)?.sessionId !== sessionId) return { id, deleted: false, code: 'schedule_not_found' } tasks.delete(id) return { id, deleted: true } @@ -121,3 +147,146 @@ test('a due reminder reaches a group as its prompts', () => { assert.equal(reminderText(batch), '[Scheduled task] One\n[Scheduled task] Two') assert.equal(reminderText('plain text'), 'plain text') }) + +const DAILY = { kind: 'daily', time: '09:00', timeZone: 'Asia/Shanghai' } + +test('editing a task on the same chat updates it in place and keeps its id', async (t) => { + const schedule = scheduleService() + const { ctx, home, call } = setup({ schedule }) + t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) + await call('schedule-create', { target: 'm1', title: 'Briefing', prompt: 'Sum up', when: DAILY }) + const [id] = schedule.tasks.keys() + + // Same timing: only the name and instruction change, so DSH keeps the next run. + const renamed = await call('schedule-update', { sessionId: 'm1', id, target: 'm1', title: ' Morning ', prompt: 'Sum up the night', when: DAILY }) + assert.equal(renamed.ok, true, JSON.stringify(renamed)) + assert.equal(schedule.updates.at(-1).change, undefined) + assert.deepEqual(renamed.value.tasks.map(task => [task.id, task.title, task.prompt]), [[id, 'Morning', 'Sum up the night']]) + + const moved = await call('schedule-update', { sessionId: 'm1', id, target: 'm1', title: 'Morning', prompt: 'Sum up the night', when: { kind: 'weekly', time: '08:30', timeZone: 'UTC', weekdays: [1, 3] } }) + assert.equal(moved.ok, true, JSON.stringify(moved)) + assert.deepEqual(schedule.updates.at(-1).change, { kind: 'weekly', weekly: { time: '08:30:00', time_zone: 'UTC', weekdays: [1, 3] } }) + assert.deepEqual(moved.value.tasks.map(task => [task.id, task.kind, task.weekdays]), [[id, 'weekly', [1, 3]]]) + + // Keep: a cron rule the form cannot show keeps its timing. + await schedule.create('m1', { title: 'Cron', prompt: 'x', cron: { expression: '*/15 9-17 * * 1-5', time_zone: 'UTC' } }) + const cronId = [...schedule.tasks.keys()].at(-1) + const kept = await call('schedule-update', { sessionId: 'm1', id: cronId, target: 'm1', title: 'Cron 2', prompt: 'y', when: { kind: 'keep' } }) + assert.equal(kept.ok, true, JSON.stringify(kept)) + assert.equal(schedule.updates.at(-1).change, undefined) + assert.equal(schedule.tasks.get(cronId).record.title, 'Cron 2') + + for (const [body, message] of [ + [{ sessionId: 'm1', id, target: 'm1', title: '', prompt: 'y', when: DAILY }, /name/], + [{ sessionId: 'm1', id, target: 'm1', title: 'x', prompt: 'y', when: { kind: 'daily', time: '9am' } }, /time of day/], + [{ sessionId: 'm1', id: 'schedule-99', target: 'm1', title: 'x', prompt: 'y', when: DAILY }, /No such task/], + [{ sessionId: 'outside', id, target: 'm1', title: 'x', prompt: 'y', when: DAILY }, /No such task/], + [{ sessionId: 'm1', id, target: 'nobody', title: 'x', prompt: 'y', when: DAILY }, /No such Bot or group chat/], + ]) assert.match((await call('schedule-update', body)).error.message, message) + assert.equal(schedule.tasks.get(id).record.title, 'Morning') +}) + +test('editing a task onto another chat creates the new one before deleting the old', async (t) => { + const schedule = scheduleService() + const { ctx, home, call } = setup({ schedule }) + t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) + await call('schedule-create', { target: 'm1', title: 'Briefing', prompt: 'Sum up', when: DAILY }) + const [id] = schedule.tasks.keys() + + schedule.failCreate = true + const failed = await call('schedule-update', { sessionId: 'm1', id, target: 'g1', title: 'Briefing', prompt: 'Sum up', when: DAILY }) + assert.equal(failed.ok, false) + assert.deepEqual([...schedule.tasks.values()].map(task => [task.sessionId, task.record.id]), [['m1', id]], 'a failed create keeps the old task') + + schedule.failCreate = false + const moved = await call('schedule-update', { sessionId: 'm1', id, target: 'g1', title: 'Standup', prompt: 'Where are we?', when: DAILY }) + assert.equal(moved.ok, true, JSON.stringify(moved)) + assert.deepEqual(moved.value.tasks.map(task => [task.title, task.roomId, task.botId]), [['Standup', 'g1', undefined]]) + assert.equal(schedule.tasks.has(id), false) + + // A finished one-shot cannot be updated in DSH, so its edit is a new task too. + const [groupId] = schedule.tasks.keys() + schedule.tasks.get(groupId).status = 'inactive' + const again = await call('schedule-update', { sessionId: 'g1', id: groupId, target: 'g1', title: 'Standup', prompt: 'Again', when: DAILY }) + assert.equal(again.ok, true, JSON.stringify(again)) + assert.deepEqual(again.value.tasks.map(task => [task.prompt, task.status]), [['Again', 'active']]) +}) + +test('pause keeps the task in FlyBot and removes it from DSH; resume brings it back', async (t) => { + const schedule = scheduleService() + const { ctx, home, call } = setup({ schedule }) + t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) + await call('schedule-create', { target: 'w1', title: 'Briefing', prompt: 'Sum up', when: { kind: 'weekly', time: '09:00', timeZone: 'UTC', weekdays: [1, 5] } }) + const [id] = schedule.tasks.keys() + + // A failed delete leaves the task running and nothing paused. + schedule.failDelete = true + assert.equal((await call('schedule-pause', { sessionId: 'w2', id })).ok, false) + assert.equal(schedule.tasks.size, 1) + assert.deepEqual((await call('schedules', {})).value.tasks.map(task => task.status), ['active']) + schedule.failDelete = false + + const paused = await call('schedule-pause', { sessionId: 'w2', id }) + assert.equal(paused.ok, true, JSON.stringify(paused)) + assert.equal(schedule.tasks.size, 0, 'DSH no longer runs it') + assert.deepEqual(paused.value.tasks.map(task => [task.id, task.status, task.botId, task.kind, task.time, task.weekdays]), [[id, 'paused', 'w1', 'weekly', '09:00:00', [1, 5]]]) + const saved = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) + assert.equal(saved.pausedSchedules[id].record.title, 'Briefing', 'the paused task survives a restart') + // Pausing twice is harmless; a finished or unknown task cannot be paused. + assert.equal((await call('schedule-pause', { sessionId: 'w2', id })).ok, true) + assert.match((await call('schedule-pause', { sessionId: 'w2', id: 'nope' })).error.message, /No such task/) + + // Editing a paused task keeps it paused. + const edited = await call('schedule-update', { sessionId: 'w2', id, target: 'w1', title: 'Weekly', prompt: 'Plan', when: { kind: 'every', minutes: 90 } }) + assert.equal(edited.ok, true, JSON.stringify(edited)) + assert.equal(schedule.tasks.size, 0) + assert.deepEqual(edited.value.tasks.map(task => [task.title, task.status, task.kind, task.everySeconds]), [['Weekly', 'paused', 'every', 5400]]) + + // Resume creates it again on the Bot's current part. + schedule.failCreate = true + assert.equal((await call('schedule-resume', { sessionId: 'w2', id })).ok, false) + assert.equal((await call('schedules', {})).value.tasks[0].status, 'paused', 'a failed resume keeps it paused') + schedule.failCreate = false + const resumed = await call('schedule-resume', { sessionId: 'w2', id }) + assert.equal(resumed.ok, true, JSON.stringify(resumed)) + assert.deepEqual([...schedule.tasks.values()].map(task => [task.sessionId, task.record.title, task.record.everySeconds]), [['w2', 'Weekly', 5400]]) + assert.deepEqual(resumed.value.tasks.map(task => task.status), ['active']) + assert.deepEqual(JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')).pausedSchedules, {}) + assert.match((await call('schedule-resume', { sessionId: 'w2', id })).error.message, /No such task/) +}) + +test('a paused task can be deleted, and a passed one-time task waits for an edit', async (t) => { + const schedule = scheduleService() + const { ctx, home, call } = setup({ schedule }) + t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) + const soon = new Date(Date.now() + 60_000).toISOString() + await call('schedule-create', { target: 'm1', title: 'Once', prompt: 'Ping', when: { kind: 'at', at: soon } }) + const [id] = schedule.tasks.keys() + assert.equal((await call('schedule-pause', { sessionId: 'm1', id })).ok, true) + t.mock.method(Date, 'now', () => Date.parse(soon) + 1000) + assert.match((await call('schedule-resume', { sessionId: 'm1', id })).error.message, /time has passed/) + t.mock.restoreAll() + const removed = await call('schedule-delete', { sessionId: 'm1', id }) + assert.deepEqual(removed.value.tasks, []) + assert.equal(schedule.tasks.size, 0) +}) + +// What DSH does with runs missed while the app was closed (docs/subsystems/core-host.md): +// a recurring task runs once, for its latest missed time, and then waits for its next +// time; it never runs once per missed time. A one-time task is one record, so it runs +// once when the app starts again. +test('a recurring task missed while the app was closed runs once, for its latest time', async () => { + const dsh = await import('@deepseek-ai/dsh-schedule') + const created = Date.parse('2030-01-01T00:00:00.000Z') + const daily = dsh.createDailyScheduleRecord(dsh.ScheduleId('d1'), 'Sum up', { time: '09:00:00', time_zone: 'UTC' }, created, 'Briefing') + assert.equal(daily.scheduledAt, '2030-01-01T09:00:00.000Z') + // Closed for three days: opened on Jan 4 at 12:00. + const reopened = Date.parse('2030-01-04T12:00:00.000Z') + const due = dsh.resolveRecurringOccurrence(daily, reopened) + assert.equal(due.occurrenceAt, '2030-01-04T09:00:00.000Z', 'only the latest missed time is delivered') + assert.equal(due.nextScheduledAt, '2030-01-05T09:00:00.000Z', 'the next run is in the future, so no second catch-up') + const every = dsh.createEveryScheduleRecord(dsh.ScheduleId('e1'), 'Check', 3600, created, 'Hourly') + const hourly = dsh.resolveRecurringOccurrence(every, created + 50 * 3600_000 + 60_000) + assert.ok(Date.parse(hourly.nextScheduledAt) > created + 50 * 3600_000 + 60_000) + assert.ok(Date.parse(hourly.occurrenceAt) > created + 49 * 3600_000, 'fifty missed hours give one run') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 63f44841..fd2782b5 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -29,7 +29,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | `core/src/client/face-at-filter.js` | A face room's `@` menu without DSH's Sessions rows (#305): `planFaceMenu` (rows to hide, where the highlight goes, when to dismiss), `faceMenuCss`, `watchFaceMenu`. Tests: `core/test/face-at-filter.test.js`. | | `core/test/new-chat-menu.test.js` | **+** menu rows (#283): an action row's description is its own line under the title, an inline Bot hint is capped, so hovering never hides a title. | | `core/test/new-chat.test.js` | Picker filters and the **+** short action menu (`data-menu`; New chat, New Bot, New group chat, plugin actions, then Add coding agent; #385 marks, verbs and hints). `ungrantedLocalFix`, `copiedToClipboard` (no clipboard or a refused write is a failure) and the refusal card (invite and leave-out actions, the copy-by-hand link field, no grant written by the client). | -| `core/src/client/schedules.js` | Settings → Scheduled tasks, a Bot's card and the details line. With Automation tasks off, the hint reads "Switch on Automation tasks on the Plugins page." and the button **Open Plugins** closes the Settings dialog and opens DSH's Plugins panel (`actions.selectPanel('plugins')`); the Harness settings dialog has no such switch (#378). Tests: `core/test/schedules-client.test.js`. | +| `core/src/client/schedules.js` | Settings → Scheduled tasks, a Bot's card and the details line. Each row has **Edit** (pencil; the row turns into the same form that creates a task, filled in, with **Save changes**; an edited task keeps its time zone, a cron rule it cannot show is kept) and **Pause** / **Resume**; a paused row reads **Paused** (#354). With Automation tasks off, the hint reads "Switch on Automation tasks on the Plugins page." and the button **Open Plugins** closes the Settings dialog and opens DSH's Plugins panel (`actions.selectPanel('plugins')`); the Harness settings dialog has no such switch (#378). Tests: `core/test/schedules-client.test.js`. | ## Invariants diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 6869578f..4c77b413 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -42,6 +42,7 @@ The server side of the patched `ds-bot` core (`core/`): the part of [ds-bot-core | `core/src/host/room-membership.js` | Typed room participants (human / local bot / granted remote), authorship, membership-window history (#50); rooms with friends' Bots or other owners take granted local Bots only and evict a Bot that stops qualifying (#90). Face-to-face rooms (#83) reuse this model (`origin: 'face-to-face'`, room-only remotes without a person-grant, `upsertFaceRoom` / `syncFaceParticipants` / `applyFaceRoomLine`); a face-room local Bot is `viaCode` (brought by number), `evictUnshared` skips face rooms (membership is the server's), and a Bot that returns to the member list drops the empty local `left` line (#236). Refusing an ungranted local Bot next to a friend's Bot throws `ungrantedRefusal`: a message that names the Bot, the friend and the two ways out, plus `error.details` `{ reason: 'ungranted-local', bots, remotes }`; `channel.js` `fail` forwards a plain-object `details` (empty otherwise) and `client/api.js` puts it on the thrown error, where `client/new-chat.js` draws a card (**Copy invite link for {Bot}** via `__flybot__.social.createInvite`, **Leave {Bot} out**) read through `ungrantedLocalFix` in `shared/remote-label.js`; the same file's `copiedToClipboard` reports whether the invite link really reached the clipboard (none, or a refused write, is `false`), and the card then shows the link to copy by hand instead of saying it was copied. | | `core/src/host/face-mentions.js` | Who "@" calls in a face-to-face room (#287): every alias of a participant (`faceAliases`, kept in `shared/remote-label.js` so the bubble chips the same names, #305; bare name, the picker label `'s Chief` / ` · 's Bot`, ` · `, handle), local and friend's Bots told apart. `faceMentions().handles` is the handle of every Bot a line names (the explicit mention list a sender attaches, #293) and `mentionedByHandle` resolves a received list to my Bots (`known` counts handles the room has). | | `core/src/host/reach.js` | Per-Bot grants and the owner-policy `reach()` check that hooks.js runs outside the plugin chain (#90); `rt.setGrants`, `rt.hasGrant`, `rt.hasAnyGrant`. A local Bot refused toward a friend's Bot is told that it is not shared with that friend and where to share it (Sharing, #273). | +| `core/src/host/schedules.js` | Scheduled tasks through DSH's schedule service: list, create, edit, pause/resume and delete for Settings → Scheduled tasks, and moving a Bot's tasks to its fresh part (#354, see **Scheduled tasks: edit, pause, missed runs**). Tests: `core/test/schedules.test.js`. | | `core/src/host/automation-default.js` | Automation tasks on by default (#378): once per profile, `offerBundle` asks DSH's plugin manager to switch on `@deepseek-ai/dsh-experimental-schedule-bundle` and writes the offer to `dsh-defaults.json` next to the team. Tests: `core/test/automation-default.test.js`. | | `core/src/shared/remote-label.js` | Owner-badge labels for a friend's Bot and “Shared room from …” titles. `isListedRemoteChat` / `listedChatBots` keep gone, hidden, and home-level (`@something.home`) remotes out of the sidebar, the **发给** picker, mentions, and `list_bots` friends lines; history stays on disk. | @@ -193,6 +194,12 @@ DSH 0.2 offers the schedule service only through its optional bundle `@deepseek- - **Fail closed.** An unreadable record, a record that cannot be written, a DSH without the bundle or without a plugin manager, a bundle with a load error, and a window that only shows the team (`rt.readOnly()`) change nothing. A refusal by the manager withdraws the note so the next start tries again. - **Opt out.** `automationTasks: false` in the `bot` config. +### Scheduled tasks: edit, pause, missed runs (#354) + +- **Edit** (`schedule-update`). On the same Session an active task goes through DSH's compare-and-update with the exact record `list` returned, so its id, next run and delivery records stay; the timing is sent only when it changed, so renaming a task does not move its next run. Moved to another Bot or group chat, or a finished one-time task, the new task is created first and the old one deleted after, the same order as a part move (`moveSchedules`), so a failure keeps the old task. `when: { kind: 'keep' }` keeps the timing (a cron rule the form cannot show). The edit needs the task's Session **and** its new target (`authz.js`), and its text gets the same chip check as `schedule-create`. +- **Pause / Resume** (`schedule-pause`, `schedule-resume`). DSH has no pause (its README: "Pause ... not supported"). Pause writes the task's record to `state.pausedSchedules[]` (`{ id, sessionId, record, pausedAt }`) and saves the team before DSH deletes the task; a failed delete takes the entry back out, so the task keeps running. Resume creates the task again on the Bot's current part (or the group's Session) before the entry goes, so a failed create leaves it paused. A paused task lists with `status: 'paused'` and its id, can be edited (it stays paused) or deleted, and a one-time task whose time passed while paused is refused until it is edited. A recurring task's next run is computed afresh on Resume. +- **Missed runs** (DSH's behavior, checked against the pinned `@deepseek-ai/dsh-schedule`). Tasks are stored by the host and survive a restart, but run only while the app is running. After the app was closed, a recurring task (every N minutes, daily, weekly, cron) runs **once**, for its latest missed time, and then waits for its next future time: it never runs once per missed time. A one-time task whose time passed while the app was closed runs once when the app starts again. A paused task runs no missed time at all. DSH notes one exception it does not rule out: a crash or shutdown between delivering a reminder and saving that it did can deliver that one reminder again. Test: `a recurring task missed while the app was closed runs once, for its latest time` in `core/test/schedules.test.js`. + ### Discarded promise `.catch` (#213) A discarded `.catch(() => {})` in the host sources either starts with `void` (fire and forget) or carries a one-line reason comment (an awaited wait); a `.catch(() => undefined)` whose value is used stays. `check-empty-catch` enforces it (see `docs/code-standards.md`). diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 82d0fd10..a6b36ea9 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -664,6 +664,14 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Scheduled tasks': '定时任务', 'Every day': '每天', 'Every {count} days': '每 {count} 天', +@@ -785,6 +785,7 @@ const ZH = { + 'On the schedule {expression}': '按计划 {expression}', + 'Next: {time}': '下次 {time}', + Finished: '已结束', ++ Resume: '继续', + 'in {name}': '在 {name}', + 'Click again to delete': '再点一次删除', + 'Delete task': '删除任务', @@ -806,8 +806,8 @@ const ZH = { Unit: '单位', 'Create task': '创建任务', @@ -675,6 +683,19 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'New task': '新建任务', 'No scheduled tasks yet.': '还没有定时任务。', 'No scheduled tasks yet. Add one': '还没有定时任务,去添加', +@@ -823,6 +823,12 @@ const ZH = { + 'Repeat every 1 minute to 7 days': '重复间隔要在 1 分钟到 7 天之间', + 'Pick when it runs': '选择什么时候运行', + 'No such task': '没有这个任务', ++ 'This task has finished': '这个任务已经结束', ++ 'The task changed meanwhile. Try again': '任务刚刚有变化,请再试一次', ++ 'The task could not be saved': '任务没能保存', ++ 'This task cannot be edited here': '这个任务不能在这里编辑', ++ 'This task cannot be paused': '这个任务不能暂停', ++ 'Its time has passed. Edit the task to pick a new one': '时间已经过了,请编辑任务选一个新时间', + 'Working folder': '工作文件夹', + 'Working folder: {folder}': '工作文件夹:{folder}', + Folder: '文件夹', @@ -851,10 +851,13 @@ const HOST_ERRORS = [ [/^A group chat holds at most (\d+) Bots$/, '一个群聊最多 $1 个 Bot'], [/^Reply mode must be one of (.+)$/, '回复模式只能是 $1'], @@ -895,25 +916,156 @@ diff --git a/src/client/model-menu.js b/src/client/model-menu.js diff --git a/src/client/schedules.js b/src/client/schedules.js --- a/src/client/schedules.js +++ b/src/client/schedules.js -@@ -4,7 +4,6 @@ +@@ -1,10 +1,10 @@ + // Scheduled tasks: Settings → Scheduled tasks, a Bot's card in its settings, and the + // line in its details panel. DSH runs the tasks (see src/host/schedules.js); a task +-// sends its prompt to the Bot's chat when it is due. ++// sends its prompt to the Bot's chat when it is due. A row edits its task in the same ++// form that creates one, and pauses or resumes it (#354). import { createElement as h, Fragment, useEffect, useState } from 'react' import { BotAvatar, RoomAvatar } from './mark.js' - import { ClockIcon, PlusIcon, TrashIcon } from './icons.js' +-import { ClockIcon, PlusIcon, TrashIcon } from './icons.js' -import { openHarnessSettings } from './sidebar.js' ++import { ClockIcon, PencilIcon, PlusIcon, TrashIcon } from './icons.js' import { Segmented } from './motion.js' import { dateLocale, hostText, t } from './i18n.js' -@@ -169,20 +169,27 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { - h('button', { type: 'submit', className: 'bt-send bt-soft-sm', disabled: busy || !target || title.trim() === '' || prompt.trim() === '' }, busy ? t('Creating…') : t('Create task')))) +@@ -70,9 +70,10 @@ export function timingText(task) { + default: return '' + } } +-const nextText = task => (task.status === 'active' ? t('Next: {time}', { time: dayAndTime(Date.parse(task.scheduledAt)) }) : t('Finished')) ++const nextText = task => (task.status === 'paused' ? t('Paused') ++ : task.status === 'active' ? t('Next: {time}', { time: dayAndTime(Date.parse(task.scheduledAt)) }) : t('Finished')) +-function TaskRow({ task, actions, onView, showBot, roster }) { ++function TaskRow({ task, actions, onView, onEdit, showBot, roster }) { + const [confirming, setConfirming] = useState(false) + const [busy, setBusy] = useState(false) + useEffect(() => { +@@ -85,14 +85,22 @@ function TaskRow({ task, actions, onView, showBot, roster }) { + setBusy(true) + void actions.scheduleDelete(task.sessionId, task.id).then(onView, () => setBusy(false)) + } ++ const toggle = () => { ++ setBusy(true) ++ const step = task.status === 'paused' ? actions.scheduleResume : actions.schedulePause ++ void step(task.sessionId, task.id).then((next) => { setBusy(false); onView(next) }, () => setBusy(false)) ++ } + const room = task.roomId ? roster.roomsById[task.roomId] : undefined +- return h('div', { className: 'bt-set-row bt-task', 'data-done': task.status === 'active' ? undefined : '', 'data-busy': busy || undefined }, ++ const stoppable = task.status === 'active' || task.status === 'paused' ++ return h('div', { className: 'bt-set-row bt-task', 'data-done': stoppable ? undefined : '', 'data-paused': task.status === 'paused' || undefined, 'data-busy': busy || undefined }, + showBot ? h(BotAvatar, { bot: roster.byId[task.botId], size: 28, badge: false, live: false }) : null, + h('span', { className: 'bt-set-copy' }, + h('span', { className: 'bt-task-title' }, task.title), + h('span', { className: 'bt-set-hint' }, [timingText(task), room ? t('in {name}', { name: room.name }) : ''].filter(Boolean).join(' · ')), + task.prompt && task.prompt !== task.title ? h('span', { className: 'bt-task-prompt' }, task.prompt) : null), + h('span', { className: 'bt-task-next' }, nextText(task)), ++ onEdit ? h('button', { type: 'button', className: 'bt-mini bt-task-tool', disabled: busy, 'aria-label': t('Edit'), title: t('Edit'), onClick: () => onEdit(task) }, h(PencilIcon)) : null, ++ stoppable ? h('button', { type: 'button', className: 'bt-mini bt-task-pause', disabled: busy, onClick: toggle }, task.status === 'paused' ? t('Resume') : t('Pause')) : null, + h('button', { + type: 'button', className: 'bt-mini bt-task-remove', 'data-confirm': confirming || undefined, disabled: busy, + 'aria-label': confirming ? t('Click again to delete') : t('Delete task'), title: confirming ? undefined : t('Delete task'), onClick: remove, +@@ -108,23 +108,50 @@ function inAnHour() { + const KINDS = [['at', 'Once'], ['daily', 'Every day'], ['weekly', 'Every week'], ['every', 'Repeat']] + const UNITS = [['minutes', 1, 'minutes'], ['hours', 60, 'hours'], ['days', 1440, 'days']] + +-function TaskForm({ roster, actions, start, onDone, onCancel }) { ++// A stored instant as the value of a datetime-local input, in this browser's zone. ++function localInput(ms) { ++ const date = new Date(ms) ++ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}` ++} ++// What the form starts with: a new task's defaults, or the edited task's own values. ++// A cron rule has no control here, so it is kept unless another kind is picked. ++export function formStart(task) { ++ const seconds = task?.kind === 'every' ? Number(task.everySeconds) : 3600 ++ const [unit, size] = seconds % 86400 === 0 ? ['days', 86400] : seconds % 3600 === 0 ? ['hours', 3600] : ['minutes', 60] ++ const once = task?.kind === 'at' || task?.kind === 'after' ++ return { ++ title: task?.title ?? '', ++ prompt: task?.prompt ?? '', ++ kind: once ? 'at' : task?.kind ?? 'daily', ++ at: once && !Number.isNaN(Date.parse(task.scheduledAt)) ? localInput(Date.parse(task.scheduledAt)) : inAnHour(), ++ time: task?.time ? clock(task.time) : '09:00', ++ weekdays: task?.kind === 'weekly' && Array.isArray(task.weekdays) ? task.weekdays : [1, 2, 3, 4, 5], ++ count: Math.max(1, Math.round(seconds / size)), ++ unit, ++ timeZone: task?.timeZone || localZone(), ++ } ++} ++ ++function TaskForm({ roster, actions, start, task, onDone, onCancel }) { + // A task goes to a Bot's chat or to a group chat, where it reads as your message. +- const [target, setTarget] = useState(start ?? roster.bots[0]?.id ?? '') +- const [title, setTitle] = useState('') +- const [prompt, setPrompt] = useState('') +- const [kind, setKind] = useState('daily') +- const [at, setAt] = useState(inAnHour) +- const [time, setTime] = useState('09:00') +- const [weekdays, setWeekdays] = useState([1, 2, 3, 4, 5]) +- const [count, setCount] = useState(1) +- const [unit, setUnit] = useState('hours') ++ const first = formStart(task) ++ const [target, setTarget] = useState(task ? (task.botId ?? task.roomId) : start ?? roster.bots[0]?.id ?? '') ++ const [title, setTitle] = useState(first.title) ++ const [prompt, setPrompt] = useState(first.prompt) ++ const [kind, setKind] = useState(first.kind) ++ const [at, setAt] = useState(first.at) ++ const [time, setTime] = useState(first.time) ++ const [weekdays, setWeekdays] = useState(first.weekdays) ++ const [count, setCount] = useState(first.count) ++ const [unit, setUnit] = useState(first.unit) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const timing = () => { ++ if (kind === 'cron') return { kind: 'keep' } + if (kind === 'at') return { kind, at: new Date(at).toISOString() } + if (kind === 'every') return { kind, minutes: Math.round(Number(count) * UNITS.find(([id]) => id === unit)[1]) } +- return { kind, time, timeZone: localZone(), ...(kind === 'weekly' ? { weekdays } : {}) } ++ // An edited task keeps its own time zone; a new one takes this browser's. ++ return { kind, time, timeZone: first.timeZone, ...(kind === 'weekly' ? { weekdays } : {}) } + } + const submit = (event) => { + event.preventDefault() +@@ -133,8 +133,10 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { + setError('') + let when + try { when = timing() } catch { setError(t('Pick a date and time')); setBusy(false); return } +- void actions.scheduleCreate({ target, title, prompt, when }) +- .then(onDone, (failure) => { setError(hostText(failure?.message ?? String(failure))); setBusy(false) }) ++ const saved = task ++ ? actions.scheduleUpdate({ sessionId: task.sessionId, id: task.id, target, title, prompt, when }) ++ : actions.scheduleCreate({ target, title, prompt, when }) ++ void saved.then(onDone, (failure) => { setError(hostText(failure?.message ?? String(failure))); setBusy(false) }) + } + const toggleDay = day => setWeekdays(days => (days.includes(day) ? days.filter(item => item !== day) : [...days, day].sort())) + return h('form', { className: 'bt-set-card bt-task-form', onSubmit: submit }, +@@ -153,6 +153,7 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { + h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, t('When'))), + h(Segmented, { label: t('When'), value: kind }, KINDS.map(([id, label]) => h('button', { key: id, type: 'button', 'aria-pressed': kind === id, onClick: () => setKind(id) }, t(label)))), + h('div', { key: kind, className: 'bt-task-when bt-veil' }, ++ kind === 'cron' ? h('span', { className: 'bt-task-every' }, timingText(task)) : null, + kind === 'at' ? h('input', { className: 'bt-input', type: 'datetime-local', value: at, 'aria-label': t('Date and time'), onChange: event => setAt(event.target.value) }) : null, + kind === 'weekly' ? h('div', { className: 'bt-task-days', role: 'group', 'aria-label': t('Days') }, [1, 2, 3, 4, 5, 6, 7].map(day => h('button', { + key: day, type: 'button', className: 'bt-task-day', 'aria-pressed': weekdays.includes(day), onClick: () => toggleDay(day), +@@ -166,24 +166,34 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { + error ? h('div', { className: 'bt-set-row' }, h('span', { className: 'bt-danger bt-set-hint' }, error)) : null, + h('div', { className: 'bt-set-row bt-task-foot' }, + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: onCancel }, t('Cancel')), +- h('button', { type: 'submit', className: 'bt-send bt-soft-sm', disabled: busy || !target || title.trim() === '' || prompt.trim() === '' }, busy ? t('Creating…') : t('Create task')))) ++ h('button', { type: 'submit', className: 'bt-send bt-soft-sm', disabled: busy || !target || title.trim() === '' || prompt.trim() === '' }, ++ task ? (busy ? t('Saving…') : t('Save changes')) : busy ? t('Creating…') : t('Create task')))) ++} ++ +// The switch for Automation tasks is DSH's Plugins panel, not its settings dialog. The +// Settings dialog closes first so the panel is not left behind it. +export function openPlugins(actions) { + actions.closeSettings() + actions.selectPanel('plugins') -+} -+ + } + // Settings has no schedule service to show: Automation tasks are off in DSH. -function AutomationOff() { +function AutomationOff({ actions }) { @@ -929,23 +1081,71 @@ diff --git a/src/client/schedules.js b/src/client/schedules.js export function SchedulesPage({ roster, actions, start }) { const view = useSchedules(actions) const [adding, setAdding] = useState(Boolean(start)) ++ const [editing, setEditing] = useState(null) if (view === null) return h('div', { className: 'bt-set-hint' }, t('Loading…')) - if (!view.available) return h(AutomationOff) +- const shown = next => { publish(next); setAdding(false) } + if (!view.available) return h(AutomationOff, { actions }) - const shown = next => { publish(next); setAdding(false) } ++ const shown = next => { publish(next); setAdding(false); setEditing(null) } ++ const edit = (task) => { setAdding(false); setEditing(task) } const byChat = [ ...roster.bots.map(bot => [bot.id, h(BotAvatar, { bot, size: 18, badge: false, live: false }), bot.name, view.tasks.filter(task => task.botId === bot.id)]), -@@ -210,8 +210,8 @@ export function BotSchedules({ bot, roster, actions }) { - h('div', { className: 'bt-set-card' }, - !view.available - ? h('div', { className: 'bt-set-row bt-bs-row' }, + ...roster.rooms.map(room => [room.id, h(RoomAvatar, { room, roster, size: 18 }), room.name, view.tasks.filter(task => task.botId === undefined && task.roomId === room.id)]), +@@ -191,31 +191,43 @@ export function SchedulesPage({ roster, actions, start }) { + return [ + adding + ? h(TaskForm, { key: 'form', roster, actions, start, onDone: shown, onCancel: () => setAdding(false) }) +- : h('div', { key: 'add', className: 'bt-actions' }, h('button', { type: 'button', className: 'bt-soft bt-soft-sm bt-with-icon', onClick: () => setAdding(true) }, h(PlusIcon), t('New task'))), ++ : h('div', { key: 'add', className: 'bt-actions' }, h('button', { type: 'button', className: 'bt-soft bt-soft-sm bt-with-icon', onClick: () => { setEditing(null); setAdding(true) } }, h(PlusIcon), t('New task'))), + byChat.length === 0 && !adding + ? h('div', { key: 'none', className: 'bt-set-card' }, h('div', { className: 'bt-set-row' }, h('span', { className: 'bt-set-hint' }, t('No scheduled tasks yet.')))) + : byChat.map(([id, avatar, name, tasks]) => h('section', { key: id, className: 'bt-set-section' }, + h('h3', { className: 'bt-task-head' }, avatar, name), +- h('div', { className: 'bt-set-card' }, tasks.map(task => h(TaskRow, { key: task.id, task, actions, roster, onView: publish }))))), ++ h(TaskList, { tasks, editing, roster, actions, onEdit: edit, onDone: shown, onCancel: () => setEditing(null) }))), + ] + } + ++// One chat's rows; the task being edited opens the form in its place. ++function TaskList({ tasks, editing, roster, actions, onEdit, onDone, onCancel, extra = [] }) { ++ const open = editing ? tasks.find(task => task.id === editing.id && task.sessionId === editing.sessionId) : undefined ++ const rows = tasks.filter(task => task !== open).map(task => h(TaskRow, { key: task.id, task, actions, roster, onView: publish, onEdit })) ++ return h(Fragment, null, ++ open ? h(TaskForm, { key: `edit-${open.id}`, roster, actions, task: open, onDone, onCancel }) : null, ++ rows.length + extra.length > 0 ? h('div', { className: 'bt-set-card' }, ...rows, ...extra) : null) ++} ++ + // A Bot's tasks in its settings form, with the way to add one. + export function BotSchedules({ bot, roster, actions }) { + const view = useSchedules(actions) ++ const [editing, setEditing] = useState(null) + if (view === null) return null + const tasks = view.available ? view.tasks.filter(task => task.botId === bot.id) : [] + return h('section', { className: 'bt-set-section' }, + h('h3', null, t('Scheduled tasks')), +- h('div', { className: 'bt-set-card' }, +- !view.available +- ? h('div', { className: 'bt-set-row bt-bs-row' }, - h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-set-hint' }, t('Switch on Automation tasks on the Plugins page in Harness settings.'))), - h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: openHarnessSettings }, t('Open Harness settings'))) +- : [ +- ...tasks.map(task => h(TaskRow, { key: task.id, task, actions, roster, onView: publish })), +- h('button', { key: 'new', type: 'button', className: 'bt-set-row bt-bs-row bt-gs-add', onClick: () => actions.openSettings('schedules', bot.id) }, h(PlusIcon), t('New task')), +- ])) ++ !view.available ++ ? h('div', { className: 'bt-set-card' }, ++ h('div', { className: 'bt-set-row bt-bs-row' }, + h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-set-hint' }, t('Switch on Automation tasks on the Plugins page.'))), -+ h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => openPlugins(actions) }, t('Open Plugins'))) - : [ - ...tasks.map(task => h(TaskRow, { key: task.id, task, actions, roster, onView: publish })), - h('button', { key: 'new', type: 'button', className: 'bt-set-row bt-bs-row bt-gs-add', onClick: () => actions.openSettings('schedules', bot.id) }, h(PlusIcon), t('New task')), ++ h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => openPlugins(actions) }, t('Open Plugins')))) ++ : h(TaskList, { ++ tasks, editing, roster, actions, onEdit: setEditing, ++ onDone: (next) => { publish(next); setEditing(null) }, ++ onCancel: () => setEditing(null), ++ extra: [h('button', { key: 'new', type: 'button', className: 'bt-set-row bt-bs-row bt-gs-add', onClick: () => actions.openSettings('schedules', bot.id) }, h(PlusIcon), t('New task'))], ++ })) + } + + // The details panel's line: the Bot's next tasks, or how to add one. @@ -225,7 +225,7 @@ export function ScheduleSummary({ bot, actions }) { return h('div', null, h('div', { className: 'bt-section-title' }, t('Scheduled tasks')), @@ -955,6 +1155,17 @@ diff --git a/src/client/schedules.js b/src/client/schedules.js : h('div', { className: 'bt-task-list' }, tasks.slice(0, 3).map(task => h('button', { key: task.id, type: 'button', className: 'bt-task-line', onClick: () => actions.openSettings('schedules') }, h(ClockIcon), h('span', { className: 'bt-task-line-title' }, task.title), h('span', { className: 'bt-task-line-when' }, timingText(task)))))) } +@@ -239,6 +239,10 @@ export const SCHEDULE_CSS = ` + .bt-task-next{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums;white-space:nowrap} + .bt-task-remove{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} + .bt-task-remove svg{width:14px;height:14px} ++.bt-task-tool{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} ++.bt-task-tool svg{width:14px;height:14px} ++.bt-task-pause{height:26px;color:var(--bt-ink-2)} ++.bt-task[data-paused] .bt-task-title,.bt-task[data-paused] .bt-task-prompt{color:var(--bt-ink-3)} + .bt-task-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135} + .bt-task-head{display:flex;align-items:center;gap:6px} + .bt-task-form{animation:bt-q-in .4s cubic-bezier(.22,1,.36,1),bt-veil-in .2s ease-out} diff --git a/src/client/text.js b/src/client/text.js --- a/src/client/text.js +++ b/src/client/text.js @@ -2111,6 +2322,214 @@ diff --git a/src/host/resume.js b/src/host/resume.js } // A hard stop keeps the inbox, which the Session runs once it wakes; only what DSH +diff --git a/src/host/schedules.js b/src/host/schedules.js +--- a/src/host/schedules.js ++++ b/src/host/schedules.js +@@ -5,6 +5,15 @@ + // them. A group chat's task lives on the group's own Session, so the reminder reaches + // the group like a message from the user (see reminderText). DSH 0.2.0 mounts the service only with its Automation tasks bundle on, so + // the service is optional and every endpoint reports whether it is there. ++// ++// Edit and Pause (#354). Edit keeps a task where it is: on the same Session it goes ++// through DSH's own compare-and-update (the id and delivery records stay); to another ++// Bot or group chat, or for a finished one-shot, the new task is created before the old ++// one goes, so a failure keeps the old task. DSH has no pause, so Pause keeps the task's ++// record in FlyBot's state (`pausedSchedules`) before it removes it from DSH, and Resume ++// creates it again on the Bot's current part. A missed run is DSH's: after the app was ++// closed a recurring task runs once, for its latest missed time, never once per missed ++// time (docs/subsystems/core-host.md). + + const MAX_TITLE = 120 + const TIME = /^([01]\d|2[0-3]):[0-5]\d$/ +@@ -39,6 +39,28 @@ function requestOf(record) { + return null + } + ++// A create request's timing as the record fields a paused task keeps (the inverse of ++// requestOf), so a paused task reads like any other in the list. ++function timingOf(selector) { ++ if (selector.at !== undefined) return { kind: 'at', scheduledAt: selector.at } ++ if (selector.every_seconds !== undefined) return { kind: 'every', everySeconds: selector.every_seconds } ++ if (selector.daily !== undefined) return { kind: 'daily', time: selector.daily.time, timeZone: selector.daily.time_zone } ++ if (selector.weekly !== undefined) return { kind: 'weekly', time: selector.weekly.time, timeZone: selector.weekly.time_zone, weekdays: selector.weekly.weekdays } ++ if (selector.cron !== undefined) return { kind: 'cron', expression: selector.cron.expression, timeZone: selector.cron.time_zone } ++ return {} ++} ++const TIMING_FIELDS = ['at', 'every_seconds', 'daily', 'weekly', 'cron'] ++const timingPart = request => Object.fromEntries(TIMING_FIELDS.filter(key => request[key] !== undefined).map(key => [key, request[key]])) ++// DSH's update takes a timing change as one discriminated value. ++function changeOf(selector) { ++ if (selector.at !== undefined) return { kind: 'at', at: selector.at } ++ if (selector.every_seconds !== undefined) return { kind: 'every', every_seconds: selector.every_seconds } ++ if (selector.daily !== undefined) return { kind: 'daily', daily: selector.daily } ++ if (selector.weekly !== undefined) return { kind: 'weekly', weekly: selector.weekly } ++ return { kind: 'cron', cron: selector.cron } ++} ++const UPDATE_MISSES = { schedule_not_found: 'No such task', schedule_ended: 'This task has finished', schedule_conflict: 'The task changed meanwhile. Try again' } ++ + // The settings form's timing as a create request selector. + function selectorOf(when) { + const zone = typeof when?.timeZone === 'string' && when.timeZone !== '' ? when.timeZone : 'UTC' +@@ -89,6 +89,35 @@ export function install(rt) { + return rt.groupOwners.get(sessionId) ?? rt.pastOwners.get(sessionId) + } + const known = owner => owner !== undefined && (owner.botId === undefined ? state.rooms[owner.roomId] !== undefined : state.bots[owner.botId] !== undefined) ++ // Paused tasks, by their DSH id: { id, sessionId, record, pausedAt }. DSH ids are ++ // unique across the host, so a paused task keeps the id it had. ++ const paused = () => { ++ if (state.pausedSchedules === null || typeof state.pausedSchedules !== 'object' || Array.isArray(state.pausedSchedules)) state.pausedSchedules = {} ++ return state.pausedSchedules ++ } ++ const pausedEntry = (sessionId, id) => { ++ const entry = Object.hasOwn(paused(), String(id ?? '')) ? paused()[id] : undefined ++ return entry !== undefined && entry.sessionId === sessionId ? entry : undefined ++ } ++ // The Session a Bot's or group chat's new task goes to: the Bot's current part. ++ const sessionFor = owner => (owner.botId !== undefined ? rt.chatOf(owner.botId) : owner.roomId) ++ const ensureService = () => { if (service === null) throw invalid('Automation tasks are off in DSH') } ++ ++ // The form's name, instruction and target, checked as creation checks them. ++ function contentOf(body) { ++ const bot = state.bots[body.target] ++ const room = state.rooms[body.target] ++ if (bot === undefined && room === undefined) throw invalid('No such Bot or group chat') ++ const title = String(body.title ?? '').trim() ++ const prompt = String(body.prompt ?? '').trim() ++ if (title === '') throw invalid('Give the task a name') ++ if (title.length > MAX_TITLE) throw invalid(`Keep the name under ${MAX_TITLE} characters`) ++ if (prompt === '') throw invalid('Say what the Bot should do') ++ return { sessionId: bot ? rt.chatOf(bot.id) : room.id, title, prompt } ++ } ++ ++ // One stored task as DSH has it, active or finished, or undefined. ++ const findRecord = async (sessionId, id) => (await service.catalog()).find(entry => entry.sessionId === sessionId && entry.id === id) + + async function schedulesView() { + if (service === null) return { available: false, tasks: [] } +@@ -98,27 +98,112 @@ export function install(rt) { + if (!known(owner)) continue + tasks.push({ ...entry, ...owner }) + } ++ for (const entry of Object.values(paused())) { ++ const owner = ownerOf(entry.sessionId) ++ if (!known(owner)) continue ++ tasks.push({ ...entry.record, id: entry.id, sessionId: entry.sessionId, status: 'paused', pausedAt: entry.pausedAt, ...owner }) ++ } + return { available: true, tasks } + } + ++ // Edit: a paused task stays paused with its new content; an active one is updated in ++ // place on the same Session, otherwise created anew before the old one is deleted. ++ // `when: { kind: 'keep' }` keeps the timing (a cron rule the form cannot show). ++ async function updateSchedule(body) { ++ ensureService() ++ const sessionId = String(body.sessionId ?? '') ++ const id = String(body.id ?? '') ++ if (ownerOf(sessionId) === undefined) throw invalid('No such task') ++ const content = contentOf(body) ++ const keep = body.when?.kind === 'keep' ++ const held = pausedEntry(sessionId, id) ++ if (held !== undefined) { ++ const timing = keep ? timingPart(requestOf(held.record) ?? {}) : selectorOf(body.when) ++ paused()[id] = { ...held, sessionId: content.sessionId, record: { title: content.title, prompt: content.prompt, ...timingOf(timing) } } ++ await rt.save() ++ return schedulesView() ++ } ++ // DSH's update compares the exact record `list` gives (active tasks only). ++ const active = (await service.list({ sessionId })).find(entry => entry.id === id) ++ const record = active ?? await findRecord(sessionId, id) ++ if (record === undefined) throw invalid('No such task') ++ const stored = requestOf(record) ++ if (stored === null) throw invalid('This task cannot be edited here') ++ const timing = keep ? timingPart(stored) : selectorOf(body.when) ++ if (active !== undefined && content.sessionId === sessionId && typeof service.update === 'function') { ++ const same = JSON.stringify(timingPart(stored)) === JSON.stringify(timing) ++ const result = await service.update({ sessionId, id, expected: active, title: content.title, prompt: content.prompt, ...(same ? {} : { change: changeOf(timing) }) }) ++ if (result?.record === undefined) throw invalid(UPDATE_MISSES[result?.code] ?? result?.message ?? 'The task could not be saved') ++ return schedulesView() ++ } ++ if (timing.at !== undefined && Date.parse(timing.at) <= Date.now()) throw invalid('Pick a time in the future') ++ await service.create(content.sessionId, { title: content.title, prompt: content.prompt, ...timing }) ++ await service.delete({ sessionId, id }) ++ return schedulesView() ++ } ++ ++ // Pause: the record is saved in FlyBot's state before DSH lets the task go, so a ++ // failed delete leaves it running and never loses it. ++ async function pauseSchedule(body) { ++ ensureService() ++ const sessionId = String(body.sessionId ?? '') ++ const id = String(body.id ?? '') ++ if (ownerOf(sessionId) === undefined) throw invalid('No such task') ++ if (pausedEntry(sessionId, id) !== undefined) return schedulesView() ++ const record = await findRecord(sessionId, id) ++ if (record === undefined) throw invalid('No such task') ++ if (record.status !== 'active') throw invalid('This task has finished') ++ const request = requestOf(record) ++ if (request === null) throw invalid('This task cannot be paused') ++ paused()[id] = { id, sessionId, record: { title: record.title, prompt: record.prompt, ...timingOf(timingPart(request)) }, pausedAt: Date.now() } ++ await rt.save() ++ try { ++ const result = await service.delete({ sessionId, id }) ++ if (result?.deleted === false) throw invalid('No such task') ++ } catch (error) { ++ delete paused()[id] ++ await rt.save() ++ throw error ++ } ++ return schedulesView() ++ } ++ ++ // Resume: DSH gets the task again, on the Bot's current part, before FlyBot forgets ++ // the paused copy. A one-time task whose time has passed waits for an edit. ++ async function resumeSchedule(body) { ++ ensureService() ++ const sessionId = String(body.sessionId ?? '') ++ const held = pausedEntry(sessionId, String(body.id ?? '')) ++ const owner = ownerOf(sessionId) ++ if (held === undefined || !known(owner)) throw invalid('No such task') ++ const request = requestOf(held.record) ++ if (request === null) throw invalid('No such task') ++ if (request.at !== undefined && Date.parse(request.at) <= Date.now()) throw invalid('Its time has passed. Edit the task to pick a new one') ++ await service.create(sessionFor(owner), request) ++ delete paused()[held.id] ++ await rt.save() ++ return schedulesView() ++ } ++ + const endpoints = { + schedules: () => schedulesView(), + async 'schedule-create'(body) { +- if (service === null) throw invalid('Automation tasks are off in DSH') +- const bot = state.bots[body.target] +- const room = state.rooms[body.target] +- if (bot === undefined && room === undefined) throw invalid('No such Bot or group chat') +- const title = String(body.title ?? '').trim() +- const prompt = String(body.prompt ?? '').trim() +- if (title === '') throw invalid('Give the task a name') +- if (title.length > MAX_TITLE) throw invalid(`Keep the name under ${MAX_TITLE} characters`) +- if (prompt === '') throw invalid('Say what the Bot should do') +- await service.create(bot ? rt.chatOf(bot.id) : room.id, { title, prompt, ...selectorOf(body.when) }) ++ ensureService() ++ const { sessionId, title, prompt } = contentOf(body) ++ await service.create(sessionId, { title, prompt, ...selectorOf(body.when) }) + return schedulesView() + }, ++ 'schedule-update': body => updateSchedule(body), ++ 'schedule-pause': body => pauseSchedule(body), ++ 'schedule-resume': body => resumeSchedule(body), + async 'schedule-delete'(body) { +- if (service === null) throw invalid('Automation tasks are off in DSH') ++ ensureService() + if (ownerOf(body.sessionId) === undefined) throw invalid('No such task') ++ if (pausedEntry(body.sessionId, body.id) !== undefined) { ++ delete paused()[body.id] ++ await rt.save() ++ return schedulesView() ++ } + await service.delete({ sessionId: body.sessionId, id: body.id }) + return schedulesView() + }, diff --git a/src/host/time-context.js b/src/host/time-context.js --- a/src/host/time-context.js +++ b/src/host/time-context.js @@ -3456,6 +3875,216 @@ diff --git a/test/models.test.js b/test/models.test.js + await assert.rejects(rt.testModel('missing'), error => error.code === 'bot/not-found') + assert.equal(calls.length, 2) +}) +diff --git a/test/schedules.test.js b/test/schedules.test.js +--- a/test/schedules.test.js ++++ b/test/schedules.test.js +@@ -1,5 +1,5 @@ + import assert from 'node:assert/strict' +-import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' ++import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' + import { tmpdir } from 'node:os' + import { join } from 'node:path' + import { test } from 'node:test' +@@ -21,22 +21,48 @@ const TEAM = { + questions: {}, + } + ++// The record fields DSH stores for each create selector (see dsh-schedule's README). ++const KINDS = { ++ at: at => ({ kind: 'at', scheduledAt: at }), ++ every_seconds: seconds => ({ kind: 'every', everySeconds: seconds, scheduledAt: '2030-01-01T01:00:00.000Z' }), ++ daily: ({ time, time_zone }) => ({ kind: 'daily', time, timeZone: time_zone, scheduledAt: '2030-01-01T01:00:00.000Z' }), ++ weekly: ({ time, time_zone, weekdays }) => ({ kind: 'weekly', time, timeZone: time_zone, weekdays, scheduledAt: '2030-01-01T01:00:00.000Z' }), ++ cron: ({ expression, time_zone }) => ({ kind: 'cron', expression, timeZone: time_zone, scheduledAt: '2030-01-01T01:00:00.000Z' }), ++} ++const CHANGE = { at: 'at', every: 'every_seconds', daily: 'daily', weekly: 'weekly', cron: 'cron' } ++ + // An in-memory stand-in for DSH's schedule service. + function scheduleService() { + const tasks = new Map() ++ const updates = [] + let next = 1 + return { + tasks, ++ updates, ++ failCreate: false, ++ failDelete: false, + async create(sessionId, request) { ++ if (this.failCreate) throw new Error('create failed') + const id = `schedule-${next++}` + const kind = Object.keys(request).find(key => !['title', 'prompt'].includes(key)) +- const record = { id, kind, title: request.title, prompt: request.prompt, timing: request[kind], scheduledAt: '2030-01-01T01:00:00.000Z' } +- tasks.set(id, { sessionId, record }) ++ const record = { id, title: request.title, prompt: request.prompt, timing: request[kind], ...KINDS[kind](request[kind]) } ++ tasks.set(id, { sessionId, record, status: 'active' }) + return record + }, +- async list({ sessionId }) { return [...tasks.values()].filter(task => task.sessionId === sessionId).map(task => task.record) }, +- async catalog() { return [...tasks.values()].map(task => ({ ...task.record, sessionId: task.sessionId, status: 'active' })) }, ++ async list({ sessionId }) { return [...tasks.values()].filter(task => task.sessionId === sessionId && task.status === 'active').map(task => task.record) }, ++ async catalog() { return [...tasks.values()].map(task => ({ ...task.record, sessionId: task.sessionId, status: task.status })) }, ++ async update(request) { ++ updates.push(request) ++ const task = tasks.get(request.id) ++ if (task?.sessionId !== request.sessionId) return { id: request.id, updated: false, code: 'schedule_not_found' } ++ if (task.status !== 'active') return { id: request.id, updated: false, code: 'schedule_ended' } ++ assert.deepEqual(request.expected, task.record, 'update compares the exact listed record') ++ const timing = request.change === undefined ? {} : { timing: request.change[CHANGE[request.change.kind]], ...KINDS[CHANGE[request.change.kind]](request.change[CHANGE[request.change.kind]]) } ++ task.record = { ...task.record, title: request.title ?? task.record.title, prompt: request.prompt ?? task.record.prompt, ...timing } ++ return { id: request.id, updated: true, record: task.record } ++ }, + async delete({ sessionId, id }) { ++ if (this.failDelete) throw new Error('delete failed') + if (tasks.get(id)?.sessionId !== sessionId) return { id, deleted: false, code: 'schedule_not_found' } + tasks.delete(id) + return { id, deleted: true } +@@ -121,3 +121,146 @@ test('a due reminder reaches a group as its prompts', () => { + assert.equal(reminderText(batch), '[Scheduled task] One\n[Scheduled task] Two') + assert.equal(reminderText('plain text'), 'plain text') + }) ++ ++const DAILY = { kind: 'daily', time: '09:00', timeZone: 'Asia/Shanghai' } ++ ++test('editing a task on the same chat updates it in place and keeps its id', async (t) => { ++ const schedule = scheduleService() ++ const { ctx, home, call } = setup({ schedule }) ++ t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) ++ await call('schedule-create', { target: 'm1', title: 'Briefing', prompt: 'Sum up', when: DAILY }) ++ const [id] = schedule.tasks.keys() ++ ++ // Same timing: only the name and instruction change, so DSH keeps the next run. ++ const renamed = await call('schedule-update', { sessionId: 'm1', id, target: 'm1', title: ' Morning ', prompt: 'Sum up the night', when: DAILY }) ++ assert.equal(renamed.ok, true, JSON.stringify(renamed)) ++ assert.equal(schedule.updates.at(-1).change, undefined) ++ assert.deepEqual(renamed.value.tasks.map(task => [task.id, task.title, task.prompt]), [[id, 'Morning', 'Sum up the night']]) ++ ++ const moved = await call('schedule-update', { sessionId: 'm1', id, target: 'm1', title: 'Morning', prompt: 'Sum up the night', when: { kind: 'weekly', time: '08:30', timeZone: 'UTC', weekdays: [1, 3] } }) ++ assert.equal(moved.ok, true, JSON.stringify(moved)) ++ assert.deepEqual(schedule.updates.at(-1).change, { kind: 'weekly', weekly: { time: '08:30:00', time_zone: 'UTC', weekdays: [1, 3] } }) ++ assert.deepEqual(moved.value.tasks.map(task => [task.id, task.kind, task.weekdays]), [[id, 'weekly', [1, 3]]]) ++ ++ // Keep: a cron rule the form cannot show keeps its timing. ++ await schedule.create('m1', { title: 'Cron', prompt: 'x', cron: { expression: '*/15 9-17 * * 1-5', time_zone: 'UTC' } }) ++ const cronId = [...schedule.tasks.keys()].at(-1) ++ const kept = await call('schedule-update', { sessionId: 'm1', id: cronId, target: 'm1', title: 'Cron 2', prompt: 'y', when: { kind: 'keep' } }) ++ assert.equal(kept.ok, true, JSON.stringify(kept)) ++ assert.equal(schedule.updates.at(-1).change, undefined) ++ assert.equal(schedule.tasks.get(cronId).record.title, 'Cron 2') ++ ++ for (const [body, message] of [ ++ [{ sessionId: 'm1', id, target: 'm1', title: '', prompt: 'y', when: DAILY }, /name/], ++ [{ sessionId: 'm1', id, target: 'm1', title: 'x', prompt: 'y', when: { kind: 'daily', time: '9am' } }, /time of day/], ++ [{ sessionId: 'm1', id: 'schedule-99', target: 'm1', title: 'x', prompt: 'y', when: DAILY }, /No such task/], ++ [{ sessionId: 'outside', id, target: 'm1', title: 'x', prompt: 'y', when: DAILY }, /No such task/], ++ [{ sessionId: 'm1', id, target: 'nobody', title: 'x', prompt: 'y', when: DAILY }, /No such Bot or group chat/], ++ ]) assert.match((await call('schedule-update', body)).error.message, message) ++ assert.equal(schedule.tasks.get(id).record.title, 'Morning') ++}) ++ ++test('editing a task onto another chat creates the new one before deleting the old', async (t) => { ++ const schedule = scheduleService() ++ const { ctx, home, call } = setup({ schedule }) ++ t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) ++ await call('schedule-create', { target: 'm1', title: 'Briefing', prompt: 'Sum up', when: DAILY }) ++ const [id] = schedule.tasks.keys() ++ ++ schedule.failCreate = true ++ const failed = await call('schedule-update', { sessionId: 'm1', id, target: 'g1', title: 'Briefing', prompt: 'Sum up', when: DAILY }) ++ assert.equal(failed.ok, false) ++ assert.deepEqual([...schedule.tasks.values()].map(task => [task.sessionId, task.record.id]), [['m1', id]], 'a failed create keeps the old task') ++ ++ schedule.failCreate = false ++ const moved = await call('schedule-update', { sessionId: 'm1', id, target: 'g1', title: 'Standup', prompt: 'Where are we?', when: DAILY }) ++ assert.equal(moved.ok, true, JSON.stringify(moved)) ++ assert.deepEqual(moved.value.tasks.map(task => [task.title, task.roomId, task.botId]), [['Standup', 'g1', undefined]]) ++ assert.equal(schedule.tasks.has(id), false) ++ ++ // A finished one-shot cannot be updated in DSH, so its edit is a new task too. ++ const [groupId] = schedule.tasks.keys() ++ schedule.tasks.get(groupId).status = 'inactive' ++ const again = await call('schedule-update', { sessionId: 'g1', id: groupId, target: 'g1', title: 'Standup', prompt: 'Again', when: DAILY }) ++ assert.equal(again.ok, true, JSON.stringify(again)) ++ assert.deepEqual(again.value.tasks.map(task => [task.prompt, task.status]), [['Again', 'active']]) ++}) ++ ++test('pause keeps the task in FlyBot and removes it from DSH; resume brings it back', async (t) => { ++ const schedule = scheduleService() ++ const { ctx, home, call } = setup({ schedule }) ++ t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) ++ await call('schedule-create', { target: 'w1', title: 'Briefing', prompt: 'Sum up', when: { kind: 'weekly', time: '09:00', timeZone: 'UTC', weekdays: [1, 5] } }) ++ const [id] = schedule.tasks.keys() ++ ++ // A failed delete leaves the task running and nothing paused. ++ schedule.failDelete = true ++ assert.equal((await call('schedule-pause', { sessionId: 'w2', id })).ok, false) ++ assert.equal(schedule.tasks.size, 1) ++ assert.deepEqual((await call('schedules', {})).value.tasks.map(task => task.status), ['active']) ++ schedule.failDelete = false ++ ++ const paused = await call('schedule-pause', { sessionId: 'w2', id }) ++ assert.equal(paused.ok, true, JSON.stringify(paused)) ++ assert.equal(schedule.tasks.size, 0, 'DSH no longer runs it') ++ assert.deepEqual(paused.value.tasks.map(task => [task.id, task.status, task.botId, task.kind, task.time, task.weekdays]), [[id, 'paused', 'w1', 'weekly', '09:00:00', [1, 5]]]) ++ const saved = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) ++ assert.equal(saved.pausedSchedules[id].record.title, 'Briefing', 'the paused task survives a restart') ++ // Pausing twice is harmless; a finished or unknown task cannot be paused. ++ assert.equal((await call('schedule-pause', { sessionId: 'w2', id })).ok, true) ++ assert.match((await call('schedule-pause', { sessionId: 'w2', id: 'nope' })).error.message, /No such task/) ++ ++ // Editing a paused task keeps it paused. ++ const edited = await call('schedule-update', { sessionId: 'w2', id, target: 'w1', title: 'Weekly', prompt: 'Plan', when: { kind: 'every', minutes: 90 } }) ++ assert.equal(edited.ok, true, JSON.stringify(edited)) ++ assert.equal(schedule.tasks.size, 0) ++ assert.deepEqual(edited.value.tasks.map(task => [task.title, task.status, task.kind, task.everySeconds]), [['Weekly', 'paused', 'every', 5400]]) ++ ++ // Resume creates it again on the Bot's current part. ++ schedule.failCreate = true ++ assert.equal((await call('schedule-resume', { sessionId: 'w2', id })).ok, false) ++ assert.equal((await call('schedules', {})).value.tasks[0].status, 'paused', 'a failed resume keeps it paused') ++ schedule.failCreate = false ++ const resumed = await call('schedule-resume', { sessionId: 'w2', id }) ++ assert.equal(resumed.ok, true, JSON.stringify(resumed)) ++ assert.deepEqual([...schedule.tasks.values()].map(task => [task.sessionId, task.record.title, task.record.everySeconds]), [['w2', 'Weekly', 5400]]) ++ assert.deepEqual(resumed.value.tasks.map(task => task.status), ['active']) ++ assert.deepEqual(JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')).pausedSchedules, {}) ++ assert.match((await call('schedule-resume', { sessionId: 'w2', id })).error.message, /No such task/) ++}) ++ ++test('a paused task can be deleted, and a passed one-time task waits for an edit', async (t) => { ++ const schedule = scheduleService() ++ const { ctx, home, call } = setup({ schedule }) ++ t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) ++ const soon = new Date(Date.now() + 60_000).toISOString() ++ await call('schedule-create', { target: 'm1', title: 'Once', prompt: 'Ping', when: { kind: 'at', at: soon } }) ++ const [id] = schedule.tasks.keys() ++ assert.equal((await call('schedule-pause', { sessionId: 'm1', id })).ok, true) ++ t.mock.method(Date, 'now', () => Date.parse(soon) + 1000) ++ assert.match((await call('schedule-resume', { sessionId: 'm1', id })).error.message, /time has passed/) ++ t.mock.restoreAll() ++ const removed = await call('schedule-delete', { sessionId: 'm1', id }) ++ assert.deepEqual(removed.value.tasks, []) ++ assert.equal(schedule.tasks.size, 0) ++}) ++ ++// What DSH does with runs missed while the app was closed (docs/subsystems/core-host.md): ++// a recurring task runs once, for its latest missed time, and then waits for its next ++// time; it never runs once per missed time. A one-time task is one record, so it runs ++// once when the app starts again. ++test('a recurring task missed while the app was closed runs once, for its latest time', async () => { ++ const dsh = await import('@deepseek-ai/dsh-schedule') ++ const created = Date.parse('2030-01-01T00:00:00.000Z') ++ const daily = dsh.createDailyScheduleRecord(dsh.ScheduleId('d1'), 'Sum up', { time: '09:00:00', time_zone: 'UTC' }, created, 'Briefing') ++ assert.equal(daily.scheduledAt, '2030-01-01T09:00:00.000Z') ++ // Closed for three days: opened on Jan 4 at 12:00. ++ const reopened = Date.parse('2030-01-04T12:00:00.000Z') ++ const due = dsh.resolveRecurringOccurrence(daily, reopened) ++ assert.equal(due.occurrenceAt, '2030-01-04T09:00:00.000Z', 'only the latest missed time is delivered') ++ assert.equal(due.nextScheduledAt, '2030-01-05T09:00:00.000Z', 'the next run is in the future, so no second catch-up') ++ const every = dsh.createEveryScheduleRecord(dsh.ScheduleId('e1'), 'Check', 3600, created, 'Hourly') ++ const hourly = dsh.resolveRecurringOccurrence(every, created + 50 * 3600_000 + 60_000) ++ assert.ok(Date.parse(hourly.nextScheduledAt) > created + 50 * 3600_000 + 60_000) ++ assert.ok(Date.parse(hourly.occurrenceAt) > created + 49 * 3600_000, 'fifty missed hours give one run') ++}) diff --git a/test/tool-cells.test.js b/test/tool-cells.test.js --- a/test/tool-cells.test.js +++ b/test/tool-cells.test.js diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 404370a6..c4d27dfe 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -1196,7 +1196,7 @@ diff --git a/src/client/index.js b/src/client/index.js endVisit: (id) => { leaveVisits([id]) ui.set((state) => { -@@ -539,12 +539,14 @@ export function apply(ctx) { +@@ -539,15 +539,20 @@ export function apply(ctx) { secretCancel: requestId => after(call('secret-cancel', { requestId })), secretUpdate: (name, scope) => after(call('secret-update', { name, scope })), secretDelete: name => after(call('secret-delete', { name })), @@ -1212,6 +1212,12 @@ diff --git a/src/client/index.js b/src/client/index.js setBotCapability: (botId, name, attached) => after(call('bot-capability', { botId, name, attached })), schedules: () => call('schedules'), scheduleCreate: task => call('schedule-create', task), ++ scheduleUpdate: task => call('schedule-update', task), ++ schedulePause: (sessionId, id) => call('schedule-pause', { sessionId, id }), ++ scheduleResume: (sessionId, id) => call('schedule-resume', { sessionId, id }), + scheduleDelete: (sessionId, id) => call('schedule-delete', { sessionId, id }), + exchange: (a, b) => call('exchange', { a, b }).catch(() => []), + roomProgress: roomId => call('room-progress', { roomId }).catch(() => []), @@ -566,6 +566,8 @@ export function apply(ctx) { noteClientError(where, error) ui.set(state => ({ ...state, toast: { text: error?.message ?? String(error), token: Date.now() } })) diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 1db79517..4e6a5870 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -362,7 +362,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js + // A chat reference chip names a Session whose snapshot rides to every recipient of + // the message, so the author must be able to read what they share (#53, #106). + // Actor-authored text that becomes a session user message: send, room-post, -+ // answer-question, schedule-create. Bot-authored room posts strip chips in groups.js. ++ // answer-question, schedule-create, schedule-update. Bot-authored room posts strip chips in groups.js. + // Menu chips encode the session id as base64url(JSON); raw ids still appear in tests. + const decodeSessionChip = (payload) => { + try { return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) } catch { return undefined } @@ -478,7 +478,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js case 'connector-disconnect': case 'connector-retry': await rt.load() -@@ -241,11 +241,18 @@ export function install(rt) { +@@ -241,11 +241,21 @@ export function install(rt) { case 'bot-capability': await rt.load() return ok(await rt.capabilityEndpoint(endpoint, body)) @@ -486,10 +486,13 @@ diff --git a/src/host/channel.js b/src/host/channel.js + return ok(await rt.botConnectorEndpoint(body)) case 'schedules': - case 'schedule-create': ++ case 'schedule-pause': ++ case 'schedule-resume': case 'schedule-delete': await rt.load() return ok(await rt.scheduleEndpoint(endpoint, body)) -+ case 'schedule-create': { ++ case 'schedule-create': ++ case 'schedule-update': { + await rt.load() + const leaked = unshareableReference(actor, [body.title, body.prompt].filter(part => typeof part === 'string').join('\n')) + if (leaked !== undefined) return fail('You do not own that chat', 'bot/forbidden') diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 9eb02c5a..73f9a711 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -421,6 +421,14 @@ diff --git a/client.js b/client.js "Scheduled tasks": "定时任务", "Every day": "每天", "Every {count} days": "每 {count} 天", +@@ -835,6 +835,7 @@ var ZH = { + "On the schedule {expression}": "按计划 {expression}", + "Next: {time}": "下次 {time}", + Finished: "已结束", ++ Resume: "继续", + "in {name}": "在 {name}", + "Click again to delete": "再点一次删除", + "Delete task": "删除任务", @@ -856,8 +856,8 @@ var ZH = { Unit: "单位", "Create task": "创建任务", @@ -432,6 +440,19 @@ diff --git a/client.js b/client.js "New task": "新建任务", "No scheduled tasks yet.": "还没有定时任务。", "No scheduled tasks yet. Add one": "还没有定时任务,去添加", +@@ -873,6 +873,12 @@ var ZH = { + "Repeat every 1 minute to 7 days": "重复间隔要在 1 分钟到 7 天之间", + "Pick when it runs": "选择什么时候运行", + "No such task": "没有这个任务", ++ "This task has finished": "这个任务已经结束", ++ "The task changed meanwhile. Try again": "任务刚刚有变化,请再试一次", ++ "The task could not be saved": "任务没能保存", ++ "This task cannot be edited here": "这个任务不能在这里编辑", ++ "This task cannot be paused": "这个任务不能暂停", ++ "Its time has passed. Edit the task to pick a new one": "时间已经过了,请编辑任务选一个新时间", + "Working folder": "工作文件夹", + "Working folder: {folder}": "工作文件夹:{folder}", + Folder: "文件夹", @@ -898,10 +898,13 @@ var HOST_ERRORS = [ [/^A group chat holds at most (\d+) Bots$/, "一个群聊最多 $1 个 Bot"], [/^Reply mode must be one of (.+)$/, "回复模式只能是 $1"], @@ -957,6 +978,7 @@ diff --git a/client.js b/client.js - value = resolved; - for (const listener of [...listeners2]) listener(); - } +- }; +// src/client/mark.js +var import_react = require("react"); +var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; @@ -993,7 +1015,21 @@ diff --git a/client.js b/client.js + document.documentElement.removeEventListener("pointerleave", onLeave); + }; + }, [enabled]); -+} + } +-var EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], agents: [], exchangeTurns: {}, questions: {}, secrets: [], capabilities: [], capabilityStatuses: {}, secretRequests: {}, models: [], defaultModel: null, modelsKey: null, prefs: {}, byId: {}, roomsById: {}, agentsById: {}, agentOf: {}, ready: false }; +-var isMainOf = (roster, id) => id !== void 0 && (roster.mainBotIds ?? []).includes(id); +-function indexRoster(value) { +- const byId = {}; +- for (const bot of value.bots) { +- for (const id of [...bot.parts ?? [], bot.sessionId ?? bot.id, bot.id]) byId[id] = bot; +- } +- const roomsById = {}; +- for (const room2 of value.rooms) roomsById[room2.id] = room2; +- const agentsById = {}; +- const agentOf = {}; +- for (const agent of value.agents ?? []) { +- agentsById[agent.id] = agent; +- for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; +var FIDGETS = ["hop", "sway", "stretch", "look"]; +function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { + const look = given ?? (bot?.remote ? lookAvoidingLocals(bot) : lookOf(bot)); @@ -1019,30 +1055,35 @@ diff --git a/client.js b/client.js + onAnimationEnd: poked ? (event) => { + if (/^bt-poke/.test(event.animationName)) setPoked(false); + } : void 0 - }; ++ }; + if (look.image) return (0, import_react.createElement)("span", common, (0, import_react.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); + if (look.emoji) { + return (0, import_react.createElement)("span", common, (0, import_react.createElement)("span", { + className: "bt-mark-emoji", + style: { fontSize: Math.max(12, Math.round(size * 0.62)), lineHeight: 1 } + }, look.emoji)); -+ } + } +- return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; + const box = shapeOf(look.shape).size; + return (0, import_react.createElement)("span", common, (0, import_react.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); } --var EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], agents: [], exchangeTurns: {}, questions: {}, secrets: [], capabilities: [], capabilityStatuses: {}, secretRequests: {}, models: [], defaultModel: null, modelsKey: null, prefs: {}, byId: {}, roomsById: {}, agentsById: {}, agentOf: {}, ready: false }; --var isMainOf = (roster, id) => id !== void 0 && (roster.mainBotIds ?? []).includes(id); --function indexRoster(value) { -- const byId = {}; -- for (const bot of value.bots) { -- for (const id of [...bot.parts ?? [], bot.sessionId ?? bot.id, bot.id]) byId[id] = bot; +-function isAgentSession(roster, cache, sessionId) { +- if (sessionId === void 0 || sessionId === null || sessionId === "") return false; +- if (roster.ready) return roster.agentOf[sessionId] !== void 0; +- return cache.has(sessionId); +function StarIcon({ size = 8 }) { + return (0, import_react.createElement)( + "svg", + { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, + (0, import_react.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) + ); -+} + } +-function countCards(state, sessionId, callIds, delta) { +- const own = { ...state[sessionId] }; +- for (const callId of callIds) { +- const count = (own[callId] ?? 0) + delta; +- if (count > 0) own[callId] = count; +- else delete own[callId]; +var personInitial = (name) => (Array.from(String(name ?? "").trim())[0] ?? "").toUpperCase(); +var personColor = (name) => defaultColor(`person:${String(name ?? "").trim().toLowerCase()}`); +function PersonMark({ size = 22, name }) { @@ -1055,13 +1096,7 @@ diff --git a/client.js b/client.js + "aria-hidden": true + }, initial); } -- const roomsById = {}; -- for (const room2 of value.rooms) roomsById[room2.id] = room2; -- const agentsById = {}; -- const agentOf = {}; -- for (const agent of value.agents ?? []) { -- agentsById[agent.id] = agent; -- for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; +- return { ...state, [sessionId]: own }; + return (0, import_react.createElement)( + "span", + { className: "bt-person-mark", style: { width: size, height: size }, "aria-hidden": true }, @@ -1072,10 +1107,93 @@ diff --git a/client.js b/client.js + (0, import_react.createElement)("path", { d: "M3.5 21c0-4.7 3.8-7.6 8.5-7.6s8.5 2.9 8.5 7.6z" }) + ) + ); -+} + } +- +-// src/client/themes.js +-var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; +-var THEMES = { +- deepseek: { +- label: "DeepSeek", +- light: { bg: "#fcfcfc", sidebar: "#f7f7f7", bubble: "#eeeeee", card: "#ffffff", text: "#141414", user: "#070707", userText: "#fcfcfc", accent: "#4d6bfe" }, +- dark: { bg: "#141414", sidebar: "#1b1b1b", bubble: "#262626", card: "#1f1f1f", text: "#f2f2f2", user: "#f2f2f2", userText: "#141414", accent: "#5b7bff" } +- }, +- mono: { +- label: "Mono", +- light: { bg: "#ffffff", sidebar: "#f9f9f9", bubble: "#f0f0f0", card: "#ffffff", text: "#0d0d0d", user: "#0d0d0d", userText: "#ffffff", accent: "#0d0d0d" }, +- dark: { bg: "#0f0f0f", sidebar: "#171717", bubble: "#242424", card: "#1c1c1c", text: "#f4f4f4", user: "#f4f4f4", userText: "#0f0f0f", accent: "#f4f4f4" } +- }, +- paper: { +- label: "Paper", +- light: { bg: "#faf9f5", sidebar: "#f3f0e8", bubble: "#ebe7dc", card: "#ffffff", text: "#1f1e1b", user: "#2b2a26", userText: "#faf9f5", accent: "#d97757" }, +- dark: { bg: "#1f1e1b", sidebar: "#262521", bubble: "#34322d", card: "#2a2925", text: "#f3f0e8", user: "#f3f0e8", userText: "#1f1e1b", accent: "#e08a6d" } +- }, +- moonlight: { +- label: "Moonlight", +- light: { bg: "#f7f9fc", sidebar: "#eef2f8", bubble: "#e5ebf5", card: "#ffffff", text: "#121826", user: "#1e3a8a", userText: "#ffffff", accent: "#1e86ff" }, +- dark: { bg: "#0b1020", sidebar: "#10172a", bubble: "#1a2340", card: "#141c33", text: "#e8edf7", user: "#3b6fd8", userText: "#ffffff", accent: "#4f9dff" } +- } +-}; +-var rgba = (hex, alpha) => `rgba(${[1, 3, 5].map((index) => parseInt(hex.slice(index, index + 2), 16)).join(",")},${alpha})`; +-function themeVars(mode, dark, accent = mode.accent) { +- return { +- "--bt-ink": mode.text, +- "--bt-ink-2": rgba(mode.text, 0.6), +- "--bt-ink-3": rgba(mode.text, 0.36), +- "--bt-bubble": mode.bubble, +- "--bt-sidebar": mode.sidebar, +- "--bt-main": mode.bg, +- "--bt-card": mode.card, +- "--bt-line": rgba(mode.text, 0.08), +- "--bt-line-2": rgba(mode.text, 0.15), +- "--bt-hover": dark ? "rgba(160,160,160,.12)" : "rgba(119,119,119,.09)", +- "--bt-active": dark ? "rgba(160,160,160,.2)" : "rgba(119,119,119,.17)", +- "--bt-accent": accent, +- "--bt-accent-ink": luminance(accent) > 0.45 ? "#141414" : "#ffffff", +- "--bt-user": mode.user, +- "--bt-user-ink": mode.userText, +- "--bt-tag-bg": rgba(mode.text, dark ? 0.09 : 0.06), +- "--bt-tag-ink": rgba(mode.text, 0.58) +- }; +function AdminStar() { + return (0, import_react.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react.createElement)(StarIcon, { size: 7 })); -+} + } +-var declarations = (vars) => Object.entries(vars).map(([name, value]) => `${name}:${value}`).join(";"); +-var themeCss = (theme, accent) => `html body{${declarations(themeVars(theme.light, false, accent ?? theme.light.accent))}} +-html body[data-ds-dark-theme]{${declarations(themeVars(theme.dark, true, accent ?? theme.dark.accent))}} +-${theme.css ?? ""}`; +-var shellTokens = ({ light, dark }) => ({ +- "--dsw-alias-bg-base": { light: light.bg, dark: dark.bg }, +- "--dsw-specific-sidebar-fill": { light: light.sidebar, dark: dark.sidebar }, +- "--dsw-specific-input-major": { light: light.bg, dark: dark.sidebar }, +- "--dsw-alias-label-primary": { light: light.text, dark: dark.text }, +- "--dsw-alias-label-secondary": { light: rgba(light.text, 0.74), dark: rgba(dark.text, 0.74) }, +- "--dsw-alias-label-tertiary": { light: rgba(light.text, 0.6), dark: rgba(dark.text, 0.6) } +-}); +-var customThemes = {}; +-var registry = createSource(0); +-var isPoint = (point) => Array.isArray(point) && point.length === 2 && point.every(Number.isFinite); +-function registerShape(id, shape) { +- if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Shape ids are lowercase words"); +- if (typeof shape?.body !== "function" || typeof shape.body("#000") !== "string") throw new Error("A shape needs body(paint) returning SVG markup"); +- const at = shape.eyes?.at; +- if (!Array.isArray(at) || at.length !== 2 || !at.every(isPoint)) throw new Error("A shape needs eyes.at: two [x, y] points"); +- const size = Number.isFinite(shape.size) && shape.size > 0 ? shape.size : 100; +- const pick2 = (key, fallback) => Number.isFinite(shape.eyes[key]) ? shape.eyes[key] : fallback; +- const eyes = at.map(([x, y]) => [x, y, pick2("w", size * 0.1), pick2("h", size * 0.23), pick2("tilt", -27)]); +- const previous = SHAPES[id]; +- SHAPES[id] = { +- label: String(shape.label ?? id), +- size, +- body: shape.body, +- eyes, +- ...typeof shape.wrap === "string" ? { wrap: shape.wrap } : {} +- }; +- const refresh = () => { +- markupCache.clear(); +- maskCache.delete(id); +- registry.set((value) => value + 1); +- }; +- refresh(); +function MainStar() { + return (0, import_react.createElement)( + "svg", @@ -1121,15 +1239,10 @@ diff --git a/client.js b/client.js + const size = frame * 5 / 9; + const rest = frame - size; + return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; - } -- return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; ++ } + const half = frame / 2; + return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; - } --function isAgentSession(roster, cache, sessionId) { -- if (sessionId === void 0 || sessionId === null || sessionId === "") return false; -- if (roster.ready) return roster.agentOf[sessionId] !== void 0; -- return cache.has(sessionId); ++} +function roomAvatarPeople(room2, roster) { + const members = (room2?.members ?? []).map((id) => roster?.byId?.[id]).filter(Boolean); + const seen = new Set(members.map((bot) => bot.id)); @@ -1162,13 +1275,7 @@ diff --git a/client.js b/client.js + } + if (members.length === 1 && remotes.length === 0) return [members[0], null]; + return [...members, ...remotes]; - } --function countCards(state, sessionId, callIds, delta) { -- const own = { ...state[sessionId] }; -- for (const callId of callIds) { -- const count = (own[callId] ?? 0) + delta; -- if (count > 0) own[callId] = count; -- else delete own[callId]; ++} +function RoomAvatar({ room: room2, roster, size = 36 }) { + const people = roomAvatarPeople(room2, roster); + const seat = (bot, seatSize, key) => bot ? (0, import_react.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); @@ -1180,8 +1287,7 @@ diff --git a/client.js b/client.js + people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), + people.length > 3 ? (0, import_react.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null + ); - } -- return { ...state, [sessionId]: own }; ++ } + const seats = clusterSeats(Math.min(people.length, 4), size); + const overflow = people.length > 4 ? people.length - 3 : 0; + const gap = size / 14; @@ -1200,97 +1306,15 @@ diff --git a/client.js b/client.js + return (0, import_react.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); + }) + ); - } - --// src/client/themes.js --var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; --var THEMES = { -- deepseek: { -- label: "DeepSeek", -- light: { bg: "#fcfcfc", sidebar: "#f7f7f7", bubble: "#eeeeee", card: "#ffffff", text: "#141414", user: "#070707", userText: "#fcfcfc", accent: "#4d6bfe" }, -- dark: { bg: "#141414", sidebar: "#1b1b1b", bubble: "#262626", card: "#1f1f1f", text: "#f2f2f2", user: "#f2f2f2", userText: "#141414", accent: "#5b7bff" } -- }, -- mono: { -- label: "Mono", -- light: { bg: "#ffffff", sidebar: "#f9f9f9", bubble: "#f0f0f0", card: "#ffffff", text: "#0d0d0d", user: "#0d0d0d", userText: "#ffffff", accent: "#0d0d0d" }, -- dark: { bg: "#0f0f0f", sidebar: "#171717", bubble: "#242424", card: "#1c1c1c", text: "#f4f4f4", user: "#f4f4f4", userText: "#0f0f0f", accent: "#f4f4f4" } -- }, -- paper: { -- label: "Paper", -- light: { bg: "#faf9f5", sidebar: "#f3f0e8", bubble: "#ebe7dc", card: "#ffffff", text: "#1f1e1b", user: "#2b2a26", userText: "#faf9f5", accent: "#d97757" }, -- dark: { bg: "#1f1e1b", sidebar: "#262521", bubble: "#34322d", card: "#2a2925", text: "#f3f0e8", user: "#f3f0e8", userText: "#1f1e1b", accent: "#e08a6d" } -- }, -- moonlight: { -- label: "Moonlight", -- light: { bg: "#f7f9fc", sidebar: "#eef2f8", bubble: "#e5ebf5", card: "#ffffff", text: "#121826", user: "#1e3a8a", userText: "#ffffff", accent: "#1e86ff" }, -- dark: { bg: "#0b1020", sidebar: "#10172a", bubble: "#1a2340", card: "#141c33", text: "#e8edf7", user: "#3b6fd8", userText: "#ffffff", accent: "#4f9dff" } -- } ++} ++ +// src/client/approval-claims.js +var claims = /* @__PURE__ */ new Map(); +var claimListeners = /* @__PURE__ */ new Set(); +var openChats = /* @__PURE__ */ new Map(); +var changed = () => { + for (const fn of [...claimListeners]) fn(); - }; --var rgba = (hex, alpha) => `rgba(${[1, 3, 5].map((index) => parseInt(hex.slice(index, index + 2), 16)).join(",")},${alpha})`; --function themeVars(mode, dark, accent = mode.accent) { -- return { -- "--bt-ink": mode.text, -- "--bt-ink-2": rgba(mode.text, 0.6), -- "--bt-ink-3": rgba(mode.text, 0.36), -- "--bt-bubble": mode.bubble, -- "--bt-sidebar": mode.sidebar, -- "--bt-main": mode.bg, -- "--bt-card": mode.card, -- "--bt-line": rgba(mode.text, 0.08), -- "--bt-line-2": rgba(mode.text, 0.15), -- "--bt-hover": dark ? "rgba(160,160,160,.12)" : "rgba(119,119,119,.09)", -- "--bt-active": dark ? "rgba(160,160,160,.2)" : "rgba(119,119,119,.17)", -- "--bt-accent": accent, -- "--bt-accent-ink": luminance(accent) > 0.45 ? "#141414" : "#ffffff", -- "--bt-user": mode.user, -- "--bt-user-ink": mode.userText, -- "--bt-tag-bg": rgba(mode.text, dark ? 0.09 : 0.06), -- "--bt-tag-ink": rgba(mode.text, 0.58) -- }; --} --var declarations = (vars) => Object.entries(vars).map(([name, value]) => `${name}:${value}`).join(";"); --var themeCss = (theme, accent) => `html body{${declarations(themeVars(theme.light, false, accent ?? theme.light.accent))}} --html body[data-ds-dark-theme]{${declarations(themeVars(theme.dark, true, accent ?? theme.dark.accent))}} --${theme.css ?? ""}`; --var shellTokens = ({ light, dark }) => ({ -- "--dsw-alias-bg-base": { light: light.bg, dark: dark.bg }, -- "--dsw-specific-sidebar-fill": { light: light.sidebar, dark: dark.sidebar }, -- "--dsw-specific-input-major": { light: light.bg, dark: dark.sidebar }, -- "--dsw-alias-label-primary": { light: light.text, dark: dark.text }, -- "--dsw-alias-label-secondary": { light: rgba(light.text, 0.74), dark: rgba(dark.text, 0.74) }, -- "--dsw-alias-label-tertiary": { light: rgba(light.text, 0.6), dark: rgba(dark.text, 0.6) } --}); --var customThemes = {}; --var registry = createSource(0); --var isPoint = (point) => Array.isArray(point) && point.length === 2 && point.every(Number.isFinite); --function registerShape(id, shape) { -- if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Shape ids are lowercase words"); -- if (typeof shape?.body !== "function" || typeof shape.body("#000") !== "string") throw new Error("A shape needs body(paint) returning SVG markup"); -- const at = shape.eyes?.at; -- if (!Array.isArray(at) || at.length !== 2 || !at.every(isPoint)) throw new Error("A shape needs eyes.at: two [x, y] points"); -- const size = Number.isFinite(shape.size) && shape.size > 0 ? shape.size : 100; -- const pick2 = (key, fallback) => Number.isFinite(shape.eyes[key]) ? shape.eyes[key] : fallback; -- const eyes = at.map(([x, y]) => [x, y, pick2("w", size * 0.1), pick2("h", size * 0.23), pick2("tilt", -27)]); -- const previous = SHAPES[id]; -- SHAPES[id] = { -- label: String(shape.label ?? id), -- size, -- body: shape.body, -- eyes, -- ...typeof shape.wrap === "string" ? { wrap: shape.wrap } : {} -- }; -- const refresh = () => { -- markupCache.clear(); -- maskCache.delete(id); -- registry.set((value) => value + 1); -- }; -- refresh(); ++}; +function chatOpened(sessionId, delta) { + const count = (openChats.get(sessionId) ?? 0) + delta; + if (count > 0) openChats.set(sessionId, count); @@ -1477,7 +1501,7 @@ diff --git a/client.js b/client.js + const inOpenChat = (approval) => { + const chat = chatOf(approval, roster); + return Boolean(chat) && (claimed.chats?.has(chat) === true || claimed.chats?.has(roster?.byId?.[chat]?.sessionId) === true); -+ }; + }; + const away = open.filter((approval) => policies.has(approval.by) && approval.status !== "failed" && !inOpenChat(approval)); + const awayKey = away.map((approval) => approval.id).join(","); + (0, import_react2.useEffect)(() => { @@ -1501,7 +1525,7 @@ diff --git a/client.js b/client.js + setBusy(""); + await refresh(); + } - }; ++ }; + const byChat = /* @__PURE__ */ new Map(); + for (const approval of pointers) { + const key = chatOf(approval, roster) ?? approval.id; @@ -1796,15 +1820,7 @@ diff --git a/client.js b/client.js + else delete own[callId]; + } + return { ...state, [sessionId]: own }; - } --function previewOf(list, id, hiddenTurns = []) { -- const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; -- if (!Array.isArray(outline)) return ""; -- for (let index = outline.length - 1; index >= 0; index -= 1) { -- const entry = outline[index]; -- if (hiddenTurns.includes(entry.turn)) continue; -- const text = entry.response || entry.prompt || ""; -- if (text !== "") return stripHeader(text).replace(/\s+/g, " ").trim(); ++} + +// src/client/themes.js +var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; @@ -1828,8 +1844,7 @@ diff --git a/client.js b/client.js + label: "Moonlight", + light: { bg: "#f7f9fc", sidebar: "#eef2f8", bubble: "#e5ebf5", card: "#ffffff", text: "#121826", user: "#1e3a8a", userText: "#ffffff", accent: "#1e86ff" }, + dark: { bg: "#0b1020", sidebar: "#10172a", bubble: "#1a2340", card: "#141c33", text: "#e8edf7", user: "#3b6fd8", userText: "#ffffff", accent: "#4f9dff" } - } -- return ""; ++ } +}; +var rgba = (hex, alpha) => `rgba(${[1, 3, 5].map((index) => parseInt(hex.slice(index, index + 2), 16)).join(",")},${alpha})`; +function themeVars(mode, dark, accent = mode.accent) { @@ -1853,17 +1868,16 @@ diff --git a/client.js b/client.js + "--bt-tag-ink": rgba(mode.text, 0.58) + }; } --var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); --function usageOf(list, ids) { -- let input = 0; -- let cacheRead = 0; -- let output = 0; -- for (const id of ids) { -- const usage = list.projectionsBySession?.[id]?.values?.tokenUsage; -- if (!usage) continue; -- input += (usage.uncachedInputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0); -- cacheRead += usage.cacheReadTokens ?? 0; -- output += usage.outputTokens ?? 0; +-function previewOf(list, id, hiddenTurns = []) { +- const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; +- if (!Array.isArray(outline)) return ""; +- for (let index = outline.length - 1; index >= 0; index -= 1) { +- const entry = outline[index]; +- if (hiddenTurns.includes(entry.turn)) continue; +- const text = entry.response || entry.prompt || ""; +- if (text !== "") return stripHeader(text).replace(/\s+/g, " ").trim(); +- } +- return ""; +var declarations = (vars) => Object.entries(vars).map(([name, value]) => `${name}:${value}`).join(";"); +var themeCss = (theme, accent) => `html body{${declarations(themeVars(theme.light, false, accent ?? theme.light.accent))}} +html body[data-ds-dark-theme]{${declarations(themeVars(theme.dark, true, accent ?? theme.dark.accent))}} @@ -1906,7 +1920,18 @@ diff --git a/client.js b/client.js + else delete SHAPES[id]; + refresh(); + }; -+} + } +-var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); +-function usageOf(list, ids) { +- let input = 0; +- let cacheRead = 0; +- let output = 0; +- for (const id of ids) { +- const usage = list.projectionsBySession?.[id]?.values?.tokenUsage; +- if (!usage) continue; +- input += (usage.uncachedInputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0); +- cacheRead += usage.cacheReadTokens ?? 0; +- output += usage.outputTokens ?? 0; +function registerTheme(id, theme) { + if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Theme ids are lowercase words"); + for (const mode of ["light", "dark"]) { @@ -2358,20 +2383,6 @@ diff --git a/client.js b/client.js - return () => clearTimeout(timer); - }, [state]); - return settled; --} --function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { -- const settled = useSettled(state); -- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); -- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, -- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), -- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, -- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, -- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, -- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null -- ); +// src/shared/face-title.js +var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +function isGenericFaceTitle(name) { @@ -2403,13 +2414,19 @@ diff --git a/client.js b/client.js + rows.sort((a, b) => a.sort.localeCompare(b.sort)); + return rows.slice(0, 3).map((row) => row.owner); } --function clusterSeats(count, frame) { -- if (count === 1) return [[0, 0, frame]]; -- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; -- if (count === 3) { -- const size = frame * 5 / 9; -- const rest = frame - size; -- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; +-function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { +- const settled = useSettled(state); +- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); +- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, +- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), +- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, +- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, +- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, +- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null +- ); +function uniqueOwners(owners) { + const seen = /* @__PURE__ */ new Set(); + const out = []; @@ -2419,9 +2436,7 @@ diff --git a/client.js b/client.js + if (!owner || seen.has(key)) continue; + seen.add(key); + out.push(owner); - } -- const half = frame / 2; -- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; ++ } + out.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + return out.slice(0, 3); +} @@ -2446,18 +2461,13 @@ diff --git a/client.js b/client.js + bits.push(join.length === 6 ? join : when3); + return bits.join(" · "); } --function RoomAvatar({ room: room2, roster, size = 36 }) { -- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); -- const people = members.length === 1 ? [members[0], null] : members; -- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); -- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); -- if (size <= 20 && people.length > 1) { -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, -- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), -- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null -- ); +-function clusterSeats(count, frame) { +- if (count === 1) return [[0, 0, frame]]; +- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; +- if (count === 3) { +- const size = frame * 5 / 9; +- const rest = frame - size; +- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; + +// src/client/brand.js +var FLYBOT_WING = "M150 238 C 96 200, 40 196, 22 214 C 8 230, 40 262, 150 300 Z"; @@ -2484,11 +2494,25 @@ diff --git a/client.js b/client.js + handle: bot.remote.handle, + isChief: bot.remote.isChief + }); -+ } + } +- const half = frame / 2; +- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; + if (room2 && room2.origin === "face-to-face" && (room2.face?.status === "closed" || room2.face?.status === "left") && isAutoFaceTitle(room2.name)) return "Face-to-face · closed"; + if (room2) return room2.displayName || (room2.sharedFrom ? sharedRoomTitle(room2.sharedFrom.inviterName) : room2.name) || ""; + return bot?.name || ""; -+} + } +-function RoomAvatar({ room: room2, roster, size = 36 }) { +- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); +- const people = members.length === 1 ? [members[0], null] : members; +- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); +- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); +- if (size <= 20 && people.length > 1) { +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, +- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), +- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null +- ); +function installTabTitle(sessions, roster, doc = globalThis.document) { + if (!doc || !sessions || !roster) return () => { + }; @@ -6892,29 +6916,44 @@ diff --git a/client.js b/client.js }); load(); return () => { -@@ -6898,9 +6898,9 @@ function timingText(task) { +@@ -6896,11 +6896,11 @@ function timingText(task) { + return ""; + } } - var nextText = (task) => task.status === "active" ? t("Next: {time}", { time: dayAndTime(Date.parse(task.scheduledAt)) }) : t("Finished"); - function TaskRow({ task, actions, onView, showBot, roster }) { +-var nextText = (task) => task.status === "active" ? t("Next: {time}", { time: dayAndTime(Date.parse(task.scheduledAt)) }) : t("Finished"); +-function TaskRow({ task, actions, onView, showBot, roster }) { - const [confirming, setConfirming] = (0, import_react15.useState)(false); - const [busy, setBusy] = (0, import_react15.useState)(false); - (0, import_react15.useEffect)(() => { ++var nextText = (task) => task.status === "paused" ? t("Paused") : task.status === "active" ? t("Next: {time}", { time: dayAndTime(Date.parse(task.scheduledAt)) }) : t("Finished"); ++function TaskRow({ task, actions, onView, onEdit, showBot, roster }) { + const [confirming, setConfirming] = (0, import_react18.useState)(false); + const [busy, setBusy] = (0, import_react18.useState)(false); + (0, import_react18.useEffect)(() => { if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 3e3); return () => clearTimeout(timer); -@@ -6914,19 +6914,19 @@ function TaskRow({ task, actions, onView, showBot, roster }) { +@@ -6913,20 +6913,31 @@ function TaskRow({ task, actions, onView, showBot, roster }) { + setBusy(true); void actions.scheduleDelete(task.sessionId, task.id).then(onView, () => setBusy(false)); }; ++ const toggle = () => { ++ setBusy(true); ++ const step = task.status === "paused" ? actions.scheduleResume : actions.schedulePause; ++ void step(task.sessionId, task.id).then((next) => { ++ setBusy(false); ++ onView(next); ++ }, () => setBusy(false)); ++ }; const room2 = task.roomId ? roster.roomsById[task.roomId] : void 0; - return (0, import_react15.createElement)( ++ const stoppable = task.status === "active" || task.status === "paused"; + return (0, import_react18.createElement)( "div", - { className: "bt-set-row bt-task", "data-done": task.status === "active" ? void 0 : "", "data-busy": busy || void 0 }, +- { className: "bt-set-row bt-task", "data-done": task.status === "active" ? void 0 : "", "data-busy": busy || void 0 }, - showBot ? (0, import_react15.createElement)(BotAvatar, { bot: roster.byId[task.botId], size: 28, badge: false, live: false }) : null, - (0, import_react15.createElement)( ++ { className: "bt-set-row bt-task", "data-done": stoppable ? void 0 : "", "data-paused": task.status === "paused" || void 0, "data-busy": busy || void 0 }, + showBot ? (0, import_react18.createElement)(BotAvatar, { bot: roster.byId[task.botId], size: 28, badge: false, live: false }) : null, + (0, import_react18.createElement)( "span", @@ -6929,6 +6968,8 @@ diff --git a/client.js b/client.js - (0, import_react15.createElement)("span", { className: "bt-task-next" }, nextText(task)), - (0, import_react15.createElement)("button", { + (0, import_react18.createElement)("span", { className: "bt-task-next" }, nextText(task)), ++ onEdit ? (0, import_react18.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Edit"), title: t("Edit"), onClick: () => onEdit(task) }, (0, import_react18.createElement)(PencilIcon)) : null, ++ stoppable ? (0, import_react18.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, onClick: toggle }, task.status === "paused" ? t("Resume") : t("Pause")) : null, + (0, import_react18.createElement)("button", { type: "button", className: "bt-mini bt-task-remove", @@ -6942,10 +6983,11 @@ diff --git a/client.js b/client.js ); } var pad = (number) => String(number).padStart(2, "0"); -@@ -6946,17 +6946,17 @@ function inAnHour() { +@@ -6945,22 +6945,44 @@ function inAnHour() { + } var KINDS = [["at", "Once"], ["daily", "Every day"], ["weekly", "Every week"], ["every", "Repeat"]]; var UNITS = [["minutes", 1, "minutes"], ["hours", 60, "hours"], ["days", 1440, "days"]]; - function TaskForm({ roster, actions, start, onDone, onCancel }) { +-function TaskForm({ roster, actions, start, onDone, onCancel }) { - const [target, setTarget] = (0, import_react15.useState)(start ?? roster.bots[0]?.id ?? ""); - const [title, setTitle] = (0, import_react15.useState)(""); - const [prompt, setPrompt] = (0, import_react15.useState)(""); @@ -6957,21 +6999,57 @@ diff --git a/client.js b/client.js - const [unit, setUnit] = (0, import_react15.useState)("hours"); - const [busy, setBusy] = (0, import_react15.useState)(false); - const [error, setError] = (0, import_react15.useState)(""); -+ const [target, setTarget] = (0, import_react18.useState)(start ?? roster.bots[0]?.id ?? ""); -+ const [title, setTitle] = (0, import_react18.useState)(""); -+ const [prompt, setPrompt] = (0, import_react18.useState)(""); -+ const [kind, setKind] = (0, import_react18.useState)("daily"); -+ const [at, setAt] = (0, import_react18.useState)(inAnHour); -+ const [time, setTime] = (0, import_react18.useState)("09:00"); -+ const [weekdays, setWeekdays] = (0, import_react18.useState)([1, 2, 3, 4, 5]); -+ const [count, setCount] = (0, import_react18.useState)(1); -+ const [unit, setUnit] = (0, import_react18.useState)("hours"); ++function localInput(ms) { ++ const date = new Date(ms); ++ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; ++} ++function formStart(task) { ++ const seconds = task?.kind === "every" ? Number(task.everySeconds) : 3600; ++ const [unit, size] = seconds % 86400 === 0 ? ["days", 86400] : seconds % 3600 === 0 ? ["hours", 3600] : ["minutes", 60]; ++ const once = task?.kind === "at" || task?.kind === "after"; ++ return { ++ title: task?.title ?? "", ++ prompt: task?.prompt ?? "", ++ kind: once ? "at" : task?.kind ?? "daily", ++ at: once && !Number.isNaN(Date.parse(task.scheduledAt)) ? localInput(Date.parse(task.scheduledAt)) : inAnHour(), ++ time: task?.time ? clock2(task.time) : "09:00", ++ weekdays: task?.kind === "weekly" && Array.isArray(task.weekdays) ? task.weekdays : [1, 2, 3, 4, 5], ++ count: Math.max(1, Math.round(seconds / size)), ++ unit, ++ timeZone: task?.timeZone || localZone() ++ }; ++} ++function TaskForm({ roster, actions, start, task, onDone, onCancel }) { ++ const first = formStart(task); ++ const [target, setTarget] = (0, import_react18.useState)(task ? task.botId ?? task.roomId : start ?? roster.bots[0]?.id ?? ""); ++ const [title, setTitle] = (0, import_react18.useState)(first.title); ++ const [prompt, setPrompt] = (0, import_react18.useState)(first.prompt); ++ const [kind, setKind] = (0, import_react18.useState)(first.kind); ++ const [at, setAt] = (0, import_react18.useState)(first.at); ++ const [time, setTime] = (0, import_react18.useState)(first.time); ++ const [weekdays, setWeekdays] = (0, import_react18.useState)(first.weekdays); ++ const [count, setCount] = (0, import_react18.useState)(first.count); ++ const [unit, setUnit] = (0, import_react18.useState)(first.unit); + const [busy, setBusy] = (0, import_react18.useState)(false); + const [error, setError] = (0, import_react18.useState)(""); const timing = () => { ++ if (kind === "cron") return { kind: "keep" }; if (kind === "at") return { kind, at: new Date(at).toISOString() }; if (kind === "every") return { kind, minutes: Math.round(Number(count) * UNITS.find(([id]) => id === unit)[1]) }; -@@ -6981,101 +6981,105 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { +- return { kind, time, timeZone: localZone(), ...kind === "weekly" ? { weekdays } : {} }; ++ return { kind, time, timeZone: first.timeZone, ...kind === "weekly" ? { weekdays } : {} }; + }; + const submit = (event) => { + event.preventDefault(); +@@ -6975,146 +6975,185 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { + setBusy(false); + return; + } +- void actions.scheduleCreate({ target, title, prompt, when: when3 }).then(onDone, (failure) => { ++ const saved = task ? actions.scheduleUpdate({ sessionId: task.sessionId, id: task.id, target, title, prompt, when: when3 }) : actions.scheduleCreate({ target, title, prompt, when: when3 }); ++ void saved.then(onDone, (failure) => { + setError(hostText(failure?.message ?? String(failure))); + setBusy(false); }); }; const toggleDay = (day) => setWeekdays((days) => days.includes(day) ? days.filter((item) => item !== day) : [...days, day].sort()); @@ -7027,6 +7105,7 @@ diff --git a/client.js b/client.js { key: kind, className: "bt-task-when bt-veil" }, - kind === "at" ? (0, import_react15.createElement)("input", { className: "bt-input", type: "datetime-local", value: at, "aria-label": t("Date and time"), onChange: (event) => setAt(event.target.value) }) : null, - kind === "weekly" ? (0, import_react15.createElement)("div", { className: "bt-task-days", role: "group", "aria-label": t("Days") }, [1, 2, 3, 4, 5, 6, 7].map((day) => (0, import_react15.createElement)("button", { ++ kind === "cron" ? (0, import_react18.createElement)("span", { className: "bt-task-every" }, timingText(task)) : null, + kind === "at" ? (0, import_react18.createElement)("input", { className: "bt-input", type: "datetime-local", value: at, "aria-label": t("Date and time"), onChange: (event) => setAt(event.target.value) }) : null, + kind === "weekly" ? (0, import_react18.createElement)("div", { className: "bt-task-days", role: "group", "aria-label": t("Days") }, [1, 2, 3, 4, 5, 6, 7].map((day) => (0, import_react18.createElement)("button", { key: day, @@ -7065,7 +7144,11 @@ diff --git a/client.js b/client.js - (0, import_react15.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), - (0, import_react15.createElement)("button", { type: "submit", className: "bt-send bt-soft-sm", disabled: busy || !target || title.trim() === "" || prompt.trim() === "" }, busy ? t("Creating…") : t("Create task")) + (0, import_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), -+ (0, import_react18.createElement)("button", { type: "submit", className: "bt-send bt-soft-sm", disabled: busy || !target || title.trim() === "" || prompt.trim() === "" }, busy ? t("Creating…") : t("Create task")) ++ (0, import_react18.createElement)( ++ "button", ++ { type: "submit", className: "bt-send bt-soft-sm", disabled: busy || !target || title.trim() === "" || prompt.trim() === "" }, ++ task ? busy ? t("Saving…") : t("Save changes") : busy ? t("Creating…") : t("Create task") ++ ) ) ); } @@ -7095,11 +7178,17 @@ diff --git a/client.js b/client.js - if (view === null) return (0, import_react15.createElement)("div", { className: "bt-set-hint" }, t("Loading…")); - if (!view.available) return (0, import_react15.createElement)(AutomationOff); + const [adding, setAdding] = (0, import_react18.useState)(Boolean(start)); ++ const [editing, setEditing] = (0, import_react18.useState)(null); + if (view === null) return (0, import_react18.createElement)("div", { className: "bt-set-hint" }, t("Loading…")); + if (!view.available) return (0, import_react18.createElement)(AutomationOff, { actions }); const shown = (next) => { publish(next); setAdding(false); ++ setEditing(null); ++ }; ++ const edit = (task) => { ++ setAdding(false); ++ setEditing(task); }; const byChat = [ - ...roster.bots.map((bot) => [bot.id, (0, import_react15.createElement)(BotAvatar, { bot, size: 18, badge: false, live: false }), bot.name, view.tasks.filter((task) => task.botId === bot.id)]), @@ -7110,19 +7199,33 @@ diff --git a/client.js b/client.js return [ - adding ? (0, import_react15.createElement)(TaskForm, { key: "form", roster, actions, start, onDone: shown, onCancel: () => setAdding(false) }) : (0, import_react15.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react15.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => setAdding(true) }, (0, import_react15.createElement)(PlusIcon), t("New task"))), - byChat.length === 0 && !adding ? (0, import_react15.createElement)("div", { key: "none", className: "bt-set-card" }, (0, import_react15.createElement)("div", { className: "bt-set-row" }, (0, import_react15.createElement)("span", { className: "bt-set-hint" }, t("No scheduled tasks yet.")))) : byChat.map(([id, avatar, name, tasks]) => (0, import_react15.createElement)( -+ adding ? (0, import_react18.createElement)(TaskForm, { key: "form", roster, actions, start, onDone: shown, onCancel: () => setAdding(false) }) : (0, import_react18.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => setAdding(true) }, (0, import_react18.createElement)(PlusIcon), t("New task"))), ++ adding ? (0, import_react18.createElement)(TaskForm, { key: "form", roster, actions, start, onDone: shown, onCancel: () => setAdding(false) }) : (0, import_react18.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => { ++ setEditing(null); ++ setAdding(true); ++ } }, (0, import_react18.createElement)(PlusIcon), t("New task"))), + byChat.length === 0 && !adding ? (0, import_react18.createElement)("div", { key: "none", className: "bt-set-card" }, (0, import_react18.createElement)("div", { className: "bt-set-row" }, (0, import_react18.createElement)("span", { className: "bt-set-hint" }, t("No scheduled tasks yet.")))) : byChat.map(([id, avatar, name, tasks]) => (0, import_react18.createElement)( "section", { key: id, className: "bt-set-section" }, - (0, import_react15.createElement)("h3", { className: "bt-task-head" }, avatar, name), - (0, import_react15.createElement)("div", { className: "bt-set-card" }, tasks.map((task) => (0, import_react15.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish }))) + (0, import_react18.createElement)("h3", { className: "bt-task-head" }, avatar, name), -+ (0, import_react18.createElement)("div", { className: "bt-set-card" }, tasks.map((task) => (0, import_react18.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish }))) ++ (0, import_react18.createElement)(TaskList, { tasks, editing, roster, actions, onEdit: edit, onDone: shown, onCancel: () => setEditing(null) }) )) ]; } -@@ -7083,21 +7083,21 @@ function BotSchedules({ bot, roster, actions }) { ++function TaskList({ tasks, editing, roster, actions, onEdit, onDone, onCancel, extra = [] }) { ++ const open = editing ? tasks.find((task) => task.id === editing.id && task.sessionId === editing.sessionId) : void 0; ++ const rows = tasks.filter((task) => task !== open).map((task) => (0, import_react18.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish, onEdit })); ++ return (0, import_react18.createElement)( ++ import_react18.Fragment, ++ null, ++ open ? (0, import_react18.createElement)(TaskForm, { key: `edit-${open.id}`, roster, actions, task: open, onDone, onCancel }) : null, ++ rows.length + extra.length > 0 ? (0, import_react18.createElement)("div", { className: "bt-set-card" }, ...rows, ...extra) : null ++ ); ++} + function BotSchedules({ bot, roster, actions }) { const view = useSchedules(actions); ++ const [editing, setEditing] = (0, import_react18.useState)(null); if (view === null) return null; const tasks = view.available ? view.tasks.filter((task) => task.botId === bot.id) : []; - return (0, import_react15.createElement)( @@ -7132,26 +7235,38 @@ diff --git a/client.js b/client.js - (0, import_react15.createElement)("h3", null, t("Scheduled tasks")), - (0, import_react15.createElement)( + (0, import_react18.createElement)("h3", null, t("Scheduled tasks")), -+ (0, import_react18.createElement)( ++ !view.available ? (0, import_react18.createElement)( "div", { className: "bt-set-card" }, - !view.available ? (0, import_react15.createElement)( -+ !view.available ? (0, import_react18.createElement)( ++ (0, import_react18.createElement)( "div", { className: "bt-set-row bt-bs-row" }, - (0, import_react15.createElement)("span", { className: "bt-set-copy" }, (0, import_react15.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page in Harness settings."))), - (0, import_react15.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: openHarnessSettings }, t("Open Harness settings")) -+ (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page."))), -+ (0, import_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins")) - ) : [ +- ) : [ - ...tasks.map((task) => (0, import_react15.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish })), - (0, import_react15.createElement)("button", { key: "new", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => actions.openSettings("schedules", bot.id) }, (0, import_react15.createElement)(PlusIcon), t("New task")) -+ ...tasks.map((task) => (0, import_react18.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish })), -+ (0, import_react18.createElement)("button", { key: "new", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => actions.openSettings("schedules", bot.id) }, (0, import_react18.createElement)(PlusIcon), t("New task")) - ] - ) +- ] +- ) ++ (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page."))), ++ (0, import_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins")) ++ ) ++ ) : (0, import_react18.createElement)(TaskList, { ++ tasks, ++ editing, ++ roster, ++ actions, ++ onEdit: setEditing, ++ onDone: (next) => { ++ publish(next); ++ setEditing(null); ++ }, ++ onCancel: () => setEditing(null), ++ extra: [(0, import_react18.createElement)("button", { key: "new", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => actions.openSettings("schedules", bot.id) }, (0, import_react18.createElement)(PlusIcon), t("New task"))] ++ }) ); -@@ -7105,16 +7105,16 @@ function BotSchedules({ bot, roster, actions }) { + } function ScheduleSummary({ bot, actions }) { const view = useSchedules(actions); const tasks = view?.available ? view.tasks.filter((task) => task.botId === bot.id && task.status === "active") : []; @@ -7174,6 +7289,17 @@ diff --git a/client.js b/client.js ))) ); } +@@ -7127,6 +7127,10 @@ var SCHEDULE_CSS = ` + .bt-task-next{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums;white-space:nowrap} + .bt-task-remove{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} + .bt-task-remove svg{width:14px;height:14px} ++.bt-task-tool{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} ++.bt-task-tool svg{width:14px;height:14px} ++.bt-task-pause{height:26px;color:var(--bt-ink-2)} ++.bt-task[data-paused] .bt-task-title,.bt-task[data-paused] .bt-task-prompt{color:var(--bt-ink-3)} + .bt-task-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135} + .bt-task-head{display:flex;align-items:center;gap:6px} + .bt-task-form{animation:bt-q-in .4s cubic-bezier(.22,1,.36,1),bt-veil-in .2s ease-out} @@ -7158,7 +7158,7 @@ var SCHEDULE_CSS = ` `; @@ -12672,7 +12798,7 @@ diff --git a/client.js b/client.js endVisit: (id) => { leaveVisits([id]); ui2.set((state) => { -@@ -13819,12 +13819,14 @@ function apply(ctx) { +@@ -13819,15 +13819,20 @@ function apply(ctx) { secretCancel: (requestId) => after(call("secret-cancel", { requestId })), secretUpdate: (name, scope) => after(call("secret-update", { name, scope })), secretDelete: (name) => after(call("secret-delete", { name })), @@ -12688,6 +12814,12 @@ diff --git a/client.js b/client.js setBotCapability: (botId, name, attached) => after(call("bot-capability", { botId, name, attached })), schedules: () => call("schedules"), scheduleCreate: (task) => call("schedule-create", task), ++ scheduleUpdate: (task) => call("schedule-update", task), ++ schedulePause: (sessionId, id) => call("schedule-pause", { sessionId, id }), ++ scheduleResume: (sessionId, id) => call("schedule-resume", { sessionId, id }), + scheduleDelete: (sessionId, id) => call("schedule-delete", { sessionId, id }), + exchange: (a, b) => call("exchange", { a, b }).catch(() => []), + roomProgress: (roomId) => call("room-progress", { roomId }).catch(() => []), @@ -13846,6 +13846,8 @@ function apply(ctx) { noteClientError(where, error); ui2.set((state) => ({ ...state, toast: { text: error?.message ?? String(error), token: Date.now() } })); diff --git a/patches/ds-bot/files/src/host/authz.js b/patches/ds-bot/files/src/host/authz.js index 0784b528..dd302635 100644 --- a/patches/ds-bot/files/src/host/authz.js +++ b/patches/ds-bot/files/src/host/authz.js @@ -38,7 +38,7 @@ const SHARED_BLOCKED = new Set([ const BOT_TARGETED = new Set([ 'update-bot', 'delete-bot', 'duplicate-bot', 'set-flags', 'bot-capability', 'memory-view', 'memory-ask', 'memory-forget', - 'schedules', 'schedule-create', 'schedule-delete', + 'schedules', 'schedule-create', 'schedule-update', 'schedule-pause', 'schedule-resume', 'schedule-delete', 'agent-session', 'agent-adopt', 'agent-archive', 'agent-rename', 'remove-agent', 'stop-member-work', 'set-main', 'test-model', ]) @@ -225,15 +225,16 @@ export function install(rt) { const requireSession = id => (sessionOwnedBy(actor, id) ? ALLOW : deny('bot/forbidden', 'You do not own that session')) if (BOT_TARGETED.has(ep)) { - if (ep === 'schedules' || ep === 'schedule-create' || ep === 'schedule-delete') { + if (ep === 'schedules' || ep.startsWith('schedule-')) { // The create endpoint keys off `body.target` (Bot or room id); `sessionId` is // the list/delete key. Checking only sessionId would allow a foreign target - // paired with an owned session (#106). - if (ep === 'schedule-create') { + // paired with an owned session (#106). An edit needs both: the task's Session + // and the target it moves to (#354). + if (ep === 'schedule-create' || ep === 'schedule-update') { const target = body?.target if (typeof target === 'string' && target !== '') { - if (botOwnedBy(actor, target) || roomOwnedBy(actor, target) || roomMemberOf(actor, target)) return ALLOW - return deny('bot/forbidden', 'You do not own that Bot or group chat') + if (!(botOwnedBy(actor, target) || roomOwnedBy(actor, target) || roomMemberOf(actor, target))) return deny('bot/forbidden', 'You do not own that Bot or group chat') + if (ep === 'schedule-create') return ALLOW } } if (ep === 'schedule-delete' && typeof body?.id === 'string' && !sessionOwnedBy(actor, body.id) && !sessionOwnedBy(actor, body.sessionId)) return deny('bot/forbidden', 'You do not own that schedule') diff --git a/patches/ds-bot/files/test/authz.test.js b/patches/ds-bot/files/test/authz.test.js index aca83a45..a9c3f0b7 100644 --- a/patches/ds-bot/files/test/authz.test.js +++ b/patches/ds-bot/files/test/authz.test.js @@ -215,6 +215,24 @@ test('schedule-create cannot hide a foreign target behind an owned sessionId', a assert.equal(rt.authorize(actorA, 'schedule-create', { target: 'a1' }).ok, true) }) +test('editing, pausing and resuming a task need its Session, and an edit its new target too (#354)', async (t) => { + const { rt } = runtime(t, TEAM) + await rt.load() + const sessionA = await redeemOwner(rt) + await redeemOwner(rt) + rt.state.bots.a1 = { id: 'a1', sessionId: 'chat-a1', name: 'A', instructions: '', createdAt: 2, ownerId: 'owner-1' } + rt.state.bots.b1 = { id: 'b1', sessionId: 'chat-b1', name: 'B', instructions: '', createdAt: 3, ownerId: 'owner-2' } + await rt.save() // indexes the Bots' Sessions + const actorA = await rt.authenticate(sessionA.sessionToken) + assert.equal(rt.authorize(actorA, 'schedule-update', { sessionId: 'chat-a1', id: 's1', target: 'a1' }).ok, true) + assert.equal(rt.authorize(actorA, 'schedule-update', { sessionId: 'chat-a1', id: 's1', target: 'b1' }).code, 'bot/forbidden') + assert.equal(rt.authorize(actorA, 'schedule-update', { sessionId: 'chat-b1', id: 's1', target: 'a1' }).code, 'bot/forbidden') + for (const ep of ['schedule-pause', 'schedule-resume']) { + assert.equal(rt.authorize(actorA, ep, { sessionId: 'chat-a1', id: 's1' }).ok, true) + assert.equal(rt.authorize(actorA, ep, { sessionId: 'chat-b1', id: 's1' }).code, 'bot/forbidden') + } +}) + test('task endpoints are an explicit allow-list, not a task-* prefix', async (t) => { const { rt } = runtime(t, TEAM) await rt.load() diff --git a/patches/ds-bot/files/test/schedules-client.test.js b/patches/ds-bot/files/test/schedules-client.test.js index b50f0c66..8632f588 100644 --- a/patches/ds-bot/files/test/schedules-client.test.js +++ b/patches/ds-bot/files/test/schedules-client.test.js @@ -9,7 +9,7 @@ import { build } from 'esbuild' // where the switch is; the Harness settings dialog has none. const { outputFiles } = await build({ stdin: { - contents: "export { SchedulesPage, BotSchedules, openPlugins } from './src/client/schedules.js'", + contents: "export { SchedulesPage, BotSchedules, openPlugins, formStart } from './src/client/schedules.js'", resolveDir: fileURLToPath(new URL('..', import.meta.url)), }, bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', @@ -17,7 +17,9 @@ const { outputFiles } = await build({ }) const h = (type, props, ...children) => ({ type, props: { ...props, children } }) const OFF = { available: false, tasks: [] } -const react = { createElement: h, Fragment: Symbol('F'), useEffect() {}, useLayoutEffect() {}, useState: v => [v === null ? OFF : v, () => {}], useMemo: f => f(), useRef: current => ({ current }), useCallback: f => f, useSyncExternalStore: (_s, get) => get() } +// States handed out in order before the defaults, to render a page with tasks or a row being edited. +const seeded = [] +const react = { createElement: h, Fragment: Symbol('F'), useEffect() {}, useLayoutEffect() {}, useState: v => [seeded.length > 0 ? seeded.shift() : v === null ? OFF : v, () => {}], useMemo: f => f(), useRef: current => ({ current }), useCallback: f => f, useSyncExternalStore: (_s, get) => get() } const module = { exports: {} } runInNewContext(outputFiles[0].text, { module, exports: module.exports, document: { hidden: false }, navigator: { language: 'en' }, localStorage: { getItem: () => null, setItem() {} }, @@ -29,7 +31,7 @@ runInNewContext(outputFiles[0].text, { throw new Error(`Unexpected platform module ${name}`) }, }) -const { SchedulesPage, BotSchedules, openPlugins } = module.exports +const { SchedulesPage, BotSchedules, openPlugins, formStart } = module.exports // Every node and string under a tree; an array (what a page returns) is walked too. const all = (node, out = []) => { if (Array.isArray(node)) { for (const item of node) all(item, out); return out } @@ -80,3 +82,56 @@ test('the hint reads the same in English and Chinese, and says Plugins page, not assert.doesNotMatch(source, /Automation tasks on the Plugins page in Harness settings/) assert.doesNotMatch(readFileSync(new URL('../src/client/schedules.js', import.meta.url), 'utf8'), /openHarnessSettings|Open Harness settings/) }) + +// Function components expanded, so their rows and buttons can be read. +const render = (node) => { + if (Array.isArray(node)) return node.map(render) + if (!node || typeof node !== 'object') return node + if (typeof node.type === 'function') return render(node.type(node.props)) + return { ...node, props: { ...node.props, children: render(node.props?.children) } } +} +const ACTIVE = { id: 's1', sessionId: 'chat-b1', botId: 'b1', title: 'Briefing', prompt: 'Sum up', kind: 'daily', time: '09:00:00', timeZone: 'Asia/Shanghai', scheduledAt: '2030-01-01T01:00:00.000Z', status: 'active' } +const PAUSED = { id: 's2', sessionId: 'chat-b1', botId: 'b1', title: 'Weekly', prompt: 'Plan', kind: 'every', everySeconds: 7200, status: 'paused' } +const ROSTER = { bots: [{ id: 'b1', name: 'B' }], rooms: [], byId: {}, roomsById: {} } + +test("a task row offers Edit and Pause; a paused one reads Paused and offers Resume (#354)", async () => { + const calls = [] + const actions = { ...spy(), schedulePause: async (...args) => { calls.push(['pause', ...args]); return OFF }, scheduleResume: async (...args) => { calls.push(['resume', ...args]); return OFF } } + seeded.push({ available: true, tasks: [ACTIVE, PAUSED] }, null) + const card = render(BotSchedules({ bot: { id: 'b1' }, roster: ROSTER, actions })) + const lines = text(card) + assert.ok(lines.includes('Paused')) + const buttons = buttonsOf(card) + assert.equal(buttons.filter(button => button.props['aria-label'] === 'Edit').length, 2) + const pause = buttons.find(button => text(button).includes('Pause')) + const resume = buttons.find(button => text(button).includes('Resume')) + pause.props.onClick() + resume.props.onClick() + await new Promise(resolve => setImmediate(resolve)) + assert.deepEqual(JSON.parse(JSON.stringify(calls)), [['pause', 'chat-b1', 's1'], ['resume', 'chat-b1', 's2']]) +}) + +test('Edit opens the task form filled in, and saving updates that task (#354)', async () => { + const sent = [] + const actions = { ...spy(), scheduleUpdate: async (task) => { sent.push(task); return OFF } } + seeded.push({ available: true, tasks: [ACTIVE, PAUSED] }, ACTIVE) + const card = render(BotSchedules({ bot: { id: 'b1' }, roster: ROSTER, actions })) + const form = all(card).find(node => node.type === 'form') + assert.ok(form, 'the edited row is a form') + assert.ok(all(form).some(node => node.type === 'input' && node.props.value === 'Briefing')) + assert.ok(all(form).some(node => node.type === 'textarea' && node.props.value === 'Sum up')) + assert.ok(text(form).includes('Save changes')) + assert.equal(all(card).filter(node => node.type === 'form').length, 1) + form.props.onSubmit({ preventDefault() {} }) + await new Promise(resolve => setImmediate(resolve)) + assert.deepEqual(JSON.parse(JSON.stringify(sent)), [{ sessionId: 'chat-b1', id: 's1', target: 'b1', title: 'Briefing', prompt: 'Sum up', when: { kind: 'daily', time: '09:00', timeZone: 'Asia/Shanghai' } }]) +}) + +test('the form starts from the edited task: repeat interval, one time, cron kept', () => { + assert.deepEqual([formStart(PAUSED).kind, formStart(PAUSED).count, formStart(PAUSED).unit], ['every', 2, 'hours']) + assert.deepEqual([formStart({ kind: 'every', everySeconds: 2 * 86400 }).count, formStart({ kind: 'every', everySeconds: 2 * 86400 }).unit], [2, 'days']) + const once = formStart({ kind: 'at', scheduledAt: new Date(2030, 0, 2, 7, 5).toISOString() }) + assert.deepEqual([once.kind, once.at], ['at', '2030-01-02T07:05']) + assert.equal(formStart({ kind: 'cron', expression: '0 9 * * 1', timeZone: 'UTC' }).kind, 'cron') + assert.deepEqual([formStart(undefined).kind, formStart(undefined).title, formStart(undefined).time], ['daily', '', '09:00']) +}) diff --git a/patches/ds-bot/topics.json b/patches/ds-bot/topics.json index 55bc9ab9..0ec2d3b1 100644 --- a/patches/ds-bot/topics.json +++ b/patches/ds-bot/topics.json @@ -31,7 +31,9 @@ "test/connectors.test.js", "test/work-dir.test.js", "src/client/schedules.js", - "README.md" + "README.md", + "src/host/schedules.js", + "test/schedules.test.js" ], "0002-flybot-api-host": [ "index.js",