Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/354-schedule-edit-pause.added.md
Original file line number Diff line number Diff line change
@@ -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)).
132 changes: 107 additions & 25 deletions core/client.js

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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': '删除任务',
Expand Down Expand Up @@ -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: '文件夹',
Expand Down
3 changes: 3 additions & 0 deletions core/src/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => []),
Expand Down
119 changes: 89 additions & 30 deletions core/src/client/schedules.js
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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(() => {
Expand All @@ -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,
Expand All @@ -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()
Expand All @@ -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 },
Expand All @@ -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),
Expand All @@ -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
Expand All @@ -187,41 +228,55 @@ 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)]),
].filter(([, , , tasks]) => tasks.length > 0)
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.
Expand All @@ -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}
Expand Down
13 changes: 7 additions & 6 deletions core/src/host/authz.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
])
Expand Down Expand Up @@ -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')
Expand Down
Loading
Loading