diff --git a/changelog.d/2507-run-now.added.md b/changelog.d/2507-run-now.added.md new file mode 100644 index 000000000..f91c5f6d2 --- /dev/null +++ b/changelog.d/2507-run-now.added.md @@ -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)). diff --git a/core/client.js b/core/client.js index 9e0c82d3d..6030223e0 100644 --- a/core/client.js +++ b/core/client.js @@ -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…": "复制到…", @@ -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, diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index f2d3c6c4f..027688ce6 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -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…': '复制到…', diff --git a/core/src/client/schedules.js b/core/src/client/schedules.js index 6682042a1..4cbd7db6c 100644 --- a/core/src/client/schedules.js +++ b/core/src/client/schedules.js @@ -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' @@ -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, diff --git a/core/test/schedules-client.test.js b/core/test/schedules-client.test.js index dfe44c4bf..7bcf03bcc 100644 --- a/core/test/schedules-client.test.js +++ b/core/test/schedules-client.test.js @@ -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)) @@ -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') }) @@ -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') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 4c32878c7..5543768ac 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -48,7 +48,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | `core/src/client/panes.js` | `PANE_MIN_WIDTH_CSS` (#705): every pane the multi-pane row flexes gets `min-width:0`, the conversation column is matched by its DSH class fragment, and the panel's long values (About text, `4.1M tok · Cache hit …`) wrap with `overflow-wrap:anywhere`, so a value that cannot wrap never widens the panel past the space reserved for it. The same sheet clips horizontal overflow on the document, the conversation column and the app frame (`overflow-x:clip`, #1086), and `watchShellLevel` / `levelShell` put a leftover `scrollLeft` back, so an error toast or dialog cannot leave the window shifted. Tests: `core/test/shell-level.test.js`. | | `core/src/shared/approval-waiting.js` | The one rule for "this hold still waits for the owner's decision" (#734, #754): `isApprovalWaiting(status)` is true for `pending` and `pending_approval` and false for every settled spelling (`approved`, `auto`, `failed`, `rejected`, `stale`, missing). The host writes `pending`; Bot Social's own outbound hold arrives as `pending_approval`. Host and client both import this one file — the held-reply card, the approvals overlay and the chat bubble on this side, `hooks.js` (`decide()`, `heldOf()`, the cap, the in-flight filter, the cleanup) and `channel.js` (a row's wait badge) on the other — and the overlay asks the host for `[...WAITING_STATUSES, 'failed']`, so the two spellings cannot drift apart again on one side only. Tests: `core/test/approval-waiting.test.js`, `core/test/host-approval-status.test.js`. | | `core/src/shared/held-retry.js` | The one rule for "this failure can still be retried" (#968): `canRetry(record)` is false for `retry: false`, for `noRetry: true`, and for a record whose stored `error`/`code` names a terminal reason (`no such remote bot` / `is not shared with your Bots` / `is no longer shared with you` / `has not granted chat.public` / `bot_not_shared` / `bot_not_found`; a bare `capability_not_granted` is not one, #988), true otherwise. The host states the same answer in two spellings (`noRetry` on an approval, `retry: false` on a held reply), so both cards and Bot Social's own dock ask this one function instead of keeping a copy each; a hold written before the flag existed is read from its stored sentence at render time, with no state migration. Tests: `core/test/held-retry.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**; a new task opens with no target chosen — **Send to** reads "Pick a Bot or group chat" and **Create task** stays disabled until one is picked, so one click cannot create the task for the first Bot (#1334); an edited task keeps its time zone, a cron rule it cannot show is kept), **Copy** (`CopyRow`, #1287: the row turns into the create form's target picker, minus the chat the task already runs in, and **Copy** posts `schedule-create` with `copyFrom: { sessionId, id }` — the host writes the row, so the copy keeps the source's next run) and **Pause** / **Resume**; a paused row reads **Paused** (#354). A row with a recorded run adds one line under it: "Running since …", "Last run …", "Last run … · Stopped" or, in red, "Last run … · Failed: "; a failed run of a task that is not paused gets **Retry**, which runs the task once now (`actions.scheduleRetry`, #473). 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). The page opens with the team's **Quiet hours** card (#1166): **Off** with a **Turn on** button, or **On, 22:00–07:00** with the window's two time fields and **Save window** (the settings write refuses an invalid window, and the host's named refusal appears on the form's error line, as a `role="alert"` so it is announced and not only printed (#2232)). While quiet hours are on and an `every`/`cron` task exists, the view's `quietUnchecked` flag draws one line saying such a task can still run inside the window: that flag is the only place the rule is computed — the page does not scan the task list itself (#1199) — and it asks for the view again right after the settings write, so the line does not wait for the 30-second poll. Never a silent claim of silence, and no suppressed count. A repeat whose interval is not a whole number of minutes is stated as seconds (`Every 90 seconds`, #1651): the edit form opens holding that exact value in the **seconds** unit, and saving a task whose repeat was not touched sends the stored interval back unchanged instead of a rounded number of minutes. The details panel's line (`ScheduleSummary`) lists every task of that Bot, paused and finished ones included, because a Bot whose tasks are all paused has tasks while a Bot with none is the only empty state; each row answers when — `Next: