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/2507-run-now.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A scheduled task can be run once from its own row even when its last run went fine: **Run now** joins **Retry**, a run in flight or a paused task offers neither, and running a task now leaves its next DSH run where it was ([#2507](https://github.com/ThinkFlowLab/FlyBot/issues/2507)).
5 changes: 4 additions & 1 deletion core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1321,6 +1321,7 @@ var ZH = {
"Runs with {names}": "运行时带上 {names}",
"Runs with no connectors": "运行时不带任何连接器",
"Run the task once now": "现在运行一次这个任务",
"Run now": "现在运行",
"DSH could not deliver it": "DSH 没能送达",
"Resume the task first": "请先继续这个任务",
"Copy to…": "复制到…",
Expand Down Expand Up @@ -3951,7 +3952,9 @@ function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster, locke
(0, import_react5.createElement)("span", { className: "bt-task-next" }, nextText(task)),
// A locked window draws none of the writes the host would refuse (#2232). Hiding, not
// disabling: a disabled button still says this window can do it.
!locked && failed && task.status !== "paused" && actions.scheduleRetry ? (0, import_react5.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, title: t("Run the task once now"), onClick: retry }, t("Retry")) : null,
// A failed run still reads Retry; any task the host will run on demand reads Run now, and a
// paused task or one whose run is in flight offers neither (#2507).
!locked && actions.scheduleRetry && task.status !== "paused" && task.lastRun?.result !== "running" ? (0, import_react5.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, title: t("Run the task once now"), onClick: retry }, t(failed ? "Retry" : "Run now")) : null,
!locked && onEdit ? (0, import_react5.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Edit"), title: t("Edit"), onClick: () => onEdit(task) }, (0, import_react5.createElement)(PencilIcon)) : null,
!locked && onCopy ? (0, import_react5.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Copy to another Bot or group chat"), title: t("Copy to…"), onClick: () => onCopy(task) }, (0, import_react5.createElement)(DuplicateIcon)) : null,
!locked && stoppable ? (0, import_react5.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, onClick: toggle }, task.status === "paused" ? t("Resume") : t("Pause")) : null,
Expand Down
1 change: 1 addition & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -1275,6 +1275,7 @@ const ZH = {
'Runs with {names}': '运行时带上 {names}',
'Runs with no connectors': '运行时不带任何连接器',
'Run the task once now': '现在运行一次这个任务',
'Run now': '现在运行',
'DSH could not deliver it': 'DSH 没能送达',
'Resume the task first': '请先继续这个任务',
'Copy to…': '复制到…',
Expand Down
13 changes: 9 additions & 4 deletions core/src/client/schedules.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
// 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. A row edits its task in the same
// form that creates one, and pauses or resumes it (#354). Each row says how its last run
// went, and a failed run has Retry, which runs the task once now (#473). A row also copies
// its task to another Bot or group chat (#1287): the picker is the create form's target
// picker, and the copy keeps the source's next run because the host writes it as a row.
// went; a failed run has Retry and any other runnable task has Run now, both of which run the
// task once now (#473, #2507). A row also copies its task to another Bot or group chat (#1287):
// the picker is the create form's target picker, and the copy keeps the source's next run
// because the host writes it as a row.
import { createElement as h, Fragment, useEffect, useState } from 'react'
import { BotAvatar, RoomAvatar } from './mark.js'
import { ClockIcon, DuplicateIcon, PencilIcon, PlusIcon, TrashIcon } from './icons.js'
Expand Down Expand Up @@ -190,7 +191,11 @@ function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster, locke
h('span', { className: 'bt-task-next' }, nextText(task)),
// A locked window draws none of the writes the host would refuse (#2232). Hiding, not
// disabling: a disabled button still says this window can do it.
!locked && failed && task.status !== 'paused' && actions.scheduleRetry ? h('button', { type: 'button', className: 'bt-mini bt-task-pause', disabled: busy, title: t('Run the task once now'), onClick: retry }, t('Retry')) : null,
// A failed run still reads Retry; any task the host will run on demand reads Run now, and a
// paused task or one whose run is in flight offers neither (#2507).
!locked && actions.scheduleRetry && task.status !== 'paused' && task.lastRun?.result !== 'running'
? h('button', { type: 'button', className: 'bt-mini bt-task-pause', disabled: busy, title: t('Run the task once now'), onClick: retry }, t(failed ? 'Retry' : 'Run now'))
: null,
!locked && 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,
!locked && onCopy ? h('button', { type: 'button', className: 'bt-mini bt-task-tool', disabled: busy, 'aria-label': t('Copy to another Bot or group chat'), title: t('Copy to…'), onClick: () => onCopy(task) }, h(DuplicateIcon)) : null,
!locked && stoppable ? h('button', { type: 'button', className: 'bt-mini bt-task-pause', disabled: busy, onClick: toggle }, task.status === 'paused' ? t('Resume') : t('Pause')) : null,
Expand Down
46 changes: 44 additions & 2 deletions core/test/schedules-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -653,7 +653,7 @@ test('#2112: the Bot details line says the list is narrowed, not that none exist
// refusal lines carried no role. The lock is read the way the rest of the client reads it.
const LOCK = 'Another FlyBot window has this home open; this window is read-only.'
const LOCKED_ROSTER = { ...ROSTER, readOnly: LOCK }
const WRITE_LABELS = ['Turn on', 'Turn off', 'Save window', 'New task', 'Edit', 'Copy to another Bot or group chat', 'Pause', 'Resume', 'Delete task', 'Retry']
const WRITE_LABELS = ['Turn on', 'Turn off', 'Save window', 'New task', 'Edit', 'Copy to another Bot or group chat', 'Pause', 'Resume', 'Delete task', 'Retry', 'Run now']
const labelOf = node => text(node).join(' ').trim() || String(node.props?.['aria-label'] ?? '')
const writeControls = tree => all(tree).filter(node => node.type === 'button' && WRITE_LABELS.includes(labelOf(node)))
const fieldControls = tree => all(tree).filter(node => ['form', 'input', 'select', 'textarea'].includes(node.type))
Expand Down Expand Up @@ -687,7 +687,7 @@ test('#2232: a read-only window draws no scheduled-task write control', () => {
seeded.push(view)
const writable = render(SchedulesPage({ roster: ROSTER, actions: scheduleActions(), start: null }))
const drawn = writeControls(writable).map(labelOf).sort()
assert.deepEqual(drawn, ['Copy to another Bot or group chat', 'Delete task', 'Edit', 'New task', 'Pause', 'Save window', 'Turn off'], `a writable window keeps every write, saw ${drawn.join(',')}`)
assert.deepEqual(drawn, ['Copy to another Bot or group chat', 'Delete task', 'Edit', 'New task', 'Pause', 'Run now', 'Save window', 'Turn off'], `a writable window keeps every write, saw ${drawn.join(',')}`)
assert.equal(fieldControls(writable).filter(node => node.type === 'input' && node.props.type === 'time').length, 2, 'the quiet-hours window keeps its two inputs')
})

Expand Down Expand Up @@ -912,3 +912,45 @@ test('#2354 c: a connector still connecting is not named', () => {
assert.equal(reachOf(lines), 'Runs with GitHub')
assert.ok(!lines.join('|').includes('Composio'), 'a connecting connector has no tools yet')
})

// P-590 (#2507): a healthy task had no way to run now — Retry was drawn only for a failed run,
// while the host's `retrySchedule` already accepts any task and refuses only a paused one. The row
// now says Run now for a healthy task or one that never ran, keeps Retry for a failed run, and
// draws neither while a run is in flight or the task is paused. DSH's own times are untouched.
const RUN_LABELS = ['Run now', 'Retry']
const runControls = tree => all(tree).filter(node => node.type === 'button' && RUN_LABELS.includes(text(node).join('').trim()))

test('P-590: a healthy or never-run task offers Run now, and one press sends one schedule-retry', async () => {
const calls = []
const actions = { ...spy(), scheduleRetry: async (...args) => { calls.push(['retry', ...args]); return OFF } }
const at = new Date(2030, 0, 2, 9, 0).getTime()
const ran = { ...ACTIVE, lastRun: { at, result: 'delivered' } }
const never = { ...ACTIVE, id: 's4' }
const failed = { ...ACTIVE, id: 's5', lastRun: { at, result: 'failed', reason: 'SERVER 503: upstream busy' } }
const inFlight = { ...ACTIVE, id: 's6', lastRun: { at, result: 'running' } }
const view = { available: true, tasks: [ran, never, failed, inFlight, PAUSED] }
seeded.push(view, null)
const card = render(BotSchedules({ bot: { id: 'b1' }, roster: ROSTER, actions }))
const seen = runControls(card).map(button => text(button).join('').trim())
assert.deepEqual(seen, ['Run now', 'Run now', 'Retry'], `a healthy or never-run task offers Run now, a failed one Retry, an in-flight or paused one neither, saw ${seen.join(',')}`)
const nextBefore = text(card).filter(line => /^Next: /.test(line))
runControls(card)[0].props.onClick()
await new Promise(resolve => setImmediate(resolve))
assert.deepEqual(JSON.parse(JSON.stringify(calls)), [['retry', 'chat-b1', 's1']], 'one press sends exactly one schedule-retry for that task')
seeded.push(view, null)
const again = render(BotSchedules({ bot: { id: 'b1' }, roster: ROSTER, actions }))
assert.deepEqual(text(again).filter(line => /^Next: /.test(line)), nextBefore, "the task's own next run does not move")
})

test('P-590: a read-only window draws no Run now either', () => {
const at = new Date(2030, 0, 2, 9, 0).getTime()
const view = { available: true, tasks: [{ ...ACTIVE, lastRun: { at, result: 'delivered' } }, { ...ACTIVE, id: 's4' }] }
seeded.push(view)
const locked = render(SchedulesPage({ roster: LOCKED_ROSTER, actions: scheduleActions(), start: null }))
assert.deepEqual(runControls(locked).map(button => text(button).join('').trim()), [], 'a read-only window offers neither Run now nor Retry')
assert.deepEqual(writeControls(locked).map(labelOf), [], 'and still no other scheduled-task write')
// The control: the same tasks in a writable window draw the new control on both rows.
seeded.push(view)
const writable = render(SchedulesPage({ roster: ROSTER, actions: scheduleActions(), start: null }))
assert.deepEqual(runControls(writable).map(button => text(button).join('').trim()), ['Run now', 'Run now'], 'a writable window draws Run now on both')
})
Loading
Loading