From cdbe206b937007e738807d387ad4888a56c9967c Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Tue, 27 Oct 2026 10:57:38 +0800 Subject: [PATCH] fix(schedules): copy a scheduled task to another Bot or group chat (#1287) --- changelog.d/1287-copy-scheduled-task.added.md | 1 + core/client.js | 52 +++- core/src/client/i18n.js | 6 + core/src/client/schedules.js | 49 +++- core/src/host/schedules.js | 63 +++++ core/test/schedules-client.test.js | 31 ++- core/test/schedules.test.js | 94 +++++++ docs/subsystems/core-client.md | 2 +- docs/subsystems/core-host.md | 14 +- patches/ds-bot/0001-core-fixes.patch | 246 ++++++++++++++++-- patches/ds-bot/0008-client-bundle.patch | 138 ++++++---- .../files/test/schedules-client.test.js | 31 ++- 12 files changed, 641 insertions(+), 86 deletions(-) create mode 100644 changelog.d/1287-copy-scheduled-task.added.md diff --git a/changelog.d/1287-copy-scheduled-task.added.md b/changelog.d/1287-copy-scheduled-task.added.md new file mode 100644 index 000000000..fbde7a5c8 --- /dev/null +++ b/changelog.d/1287-copy-scheduled-task.added.md @@ -0,0 +1 @@ +A scheduled task can now be copied to another Bot or group chat: **Copy** on the task's row asks for the target and creates the same title, instruction and timing there, keeping the task's next run instead of re-anchoring it ([#1287](https://github.com/ThinkFlowLab/FlyBot/issues/1287)). diff --git a/core/client.js b/core/client.js index 2b42b4db3..6cf02c662 100644 --- a/core/client.js +++ b/core/client.js @@ -1225,6 +1225,12 @@ var ZH = { "Run the task once now": "现在运行一次这个任务", "DSH could not deliver it": "DSH 没能送达", "Resume the task first": "请先继续这个任务", + "Copy to…": "复制到…", + "Copy to": "复制到", + "Copy to another Bot or group chat": "复制到其他 Bot 或群聊", + "Copy “{name}” to": "把「{name}」复制到", + "Pick a Bot or group chat": "选择 Bot 或群聊", + "Copying…": "正在复制…", Finished: "已结束", Resume: "继续", "in {name}": "在 {name}", @@ -11951,7 +11957,7 @@ function lastRunText(run) { return t("Last run {time}", { time }); } } -function TaskRow({ task, actions, onView, onEdit, showBot, roster }) { +function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster }) { const [confirming, setConfirming] = (0, import_react24.useState)(false); const [busy, setBusy] = (0, import_react24.useState)(false); (0, import_react24.useEffect)(() => { @@ -12007,6 +12013,7 @@ function TaskRow({ task, actions, onView, onEdit, showBot, roster }) { (0, import_react24.createElement)("span", { className: "bt-task-next" }, nextText(task)), failed && task.status !== "paused" && actions.scheduleRetry ? (0, import_react24.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, title: t("Run the task once now"), onClick: retry }, t("Retry")) : null, onEdit ? (0, import_react24.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Edit"), title: t("Edit"), onClick: () => onEdit(task) }, (0, import_react24.createElement)(PencilIcon)) : null, + onCopy ? (0, import_react24.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_react24.createElement)(DuplicateIcon)) : null, stoppable ? (0, import_react24.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, onClick: toggle }, task.status === "paused" ? t("Resume") : t("Pause")) : null, (0, import_react24.createElement)("button", { type: "button", @@ -12270,14 +12277,53 @@ function SchedulesPage({ roster, actions, start }) { } 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_react24.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish, onEdit })); + const [copying, setCopying] = (0, import_react24.useState)(null); + const source = copying ? tasks.find((task) => task.id === copying.id && task.sessionId === copying.sessionId) : void 0; + const rows = tasks.filter((task) => task !== open && task !== source).map((task) => (0, import_react24.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish, onEdit, onCopy: actions.scheduleCreate ? setCopying : void 0 })); return (0, import_react24.createElement)( import_react24.Fragment, null, open ? (0, import_react24.createElement)(TaskForm, { key: `edit-${open.id}`, roster, actions, task: open, onDone, onCancel }) : null, + source ? (0, import_react24.createElement)(CopyRow, { key: `copy-${source.id}`, task: source, roster, actions, onView: (next) => { + setCopying(null); + onDone(next); + }, onCancel: () => setCopying(null) }) : null, rows.length + extra.length > 0 ? (0, import_react24.createElement)("div", { className: "bt-set-card" }, ...rows, ...extra) : null ); } +function CopyRow({ task, roster, actions, onView, onCancel }) { + const others = [ + ...roster.bots.filter((bot) => bot.id !== task.botId).map((bot) => [bot.id, bot.name]), + ...roster.rooms.filter((room2) => room2.id !== task.roomId).map((room2) => [room2.id, room2.name]) + ]; + const [target, setTarget] = (0, import_react24.useState)(others[0]?.[0] ?? ""); + const [busy, setBusy] = (0, import_react24.useState)(false); + const [error, setError] = (0, import_react24.useState)(""); + const submit = (event) => { + event.preventDefault(); + if (busy || target === "") return; + setBusy(true); + setError(""); + void actions.scheduleCreate({ target, copyFrom: { sessionId: task.sessionId, id: task.id } }).then(onView, (failure) => { + setBusy(false); + setError(hostText(failure?.message ?? String(failure))); + }); + }; + return (0, import_react24.createElement)( + "form", + { className: "bt-set-row bt-task-copy", onSubmit: submit }, + (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("Copy “{name}” to", { name: task.title }))), + (0, import_react24.createElement)( + "select", + { className: "bt-select bt-task-bot", value: target, "aria-label": t("Copy to"), onChange: (event) => setTarget(event.target.value) }, + (0, import_react24.createElement)("option", { value: "" }, t("Pick a Bot or group chat")), + others.map(([id, name]) => (0, import_react24.createElement)("option", { key: id, value: id }, name)) + ), + (0, import_react24.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm", disabled: busy || target === "" }, busy ? t("Copying…") : t("Copy")), + (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), + error ? (0, import_react24.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error) : null + ); +} function BotSchedules({ bot, roster, actions }) { const view = useSchedules(actions); const [editing, setEditing] = (0, import_react24.useState)(null); @@ -12329,6 +12375,8 @@ function ScheduleSummary({ bot, actions }) { } var SCHEDULE_CSS = ` .bt-task{gap:10px} +.bt-task-copy{flex-wrap:wrap;gap:8px} +.bt-task-copy .bt-task-bot{flex:1;min-width:140px;max-width:100%} .bt-task[data-done]{opacity:.6} .bt-task[data-busy]{opacity:.4} .bt-task-title{font-size:13px;line-height:18px;font-weight:500;overflow-wrap:anywhere} diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 58b4d4185..92054b7f4 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -1177,6 +1177,12 @@ const ZH = { 'Run the task once now': '现在运行一次这个任务', 'DSH could not deliver it': 'DSH 没能送达', 'Resume the task first': '请先继续这个任务', + 'Copy to…': '复制到…', + 'Copy to': '复制到', + 'Copy to another Bot or group chat': '复制到其他 Bot 或群聊', + 'Copy “{name}” to': '把「{name}」复制到', + 'Pick a Bot or group chat': '选择 Bot 或群聊', + 'Copying…': '正在复制…', Finished: '已结束', Resume: '继续', 'in {name}': '在 {name}', diff --git a/core/src/client/schedules.js b/core/src/client/schedules.js index 1225dd870..834b0c0d1 100644 --- a/core/src/client/schedules.js +++ b/core/src/client/schedules.js @@ -2,10 +2,12 @@ // 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). +// 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. import { createElement as h, Fragment, useEffect, useState } from 'react' import { BotAvatar, RoomAvatar } from './mark.js' -import { ClockIcon, PencilIcon, PlusIcon, TrashIcon } from './icons.js' +import { ClockIcon, DuplicateIcon, PencilIcon, PlusIcon, TrashIcon } from './icons.js' import { Segmented } from './motion.js' import { dateLocale, hostText, t } from './i18n.js' @@ -86,7 +88,7 @@ export function lastRunText(run) { } } -function TaskRow({ task, actions, onView, onEdit, showBot, roster }) { +function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster }) { const [confirming, setConfirming] = useState(false) const [busy, setBusy] = useState(false) useEffect(() => { @@ -125,6 +127,7 @@ function TaskRow({ task, actions, onView, onEdit, showBot, roster }) { h('span', { className: 'bt-task-next' }, nextText(task)), 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, 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, + 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, 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, @@ -328,15 +331,49 @@ export function SchedulesPage({ roster, actions, start }) { ] } -// One chat's rows; the task being edited opens the form in its place. +// One chat's rows; the task being edited opens the form in its place, and so does the one +// being copied, which opens only the target picker. 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 })) + const [copying, setCopying] = useState(null) + const source = copying ? tasks.find(task => task.id === copying.id && task.sessionId === copying.sessionId) : undefined + const rows = tasks.filter(task => task !== open && task !== source).map(task => h(TaskRow, { key: task.id, task, actions, roster, onView: publish, onEdit, onCopy: actions.scheduleCreate ? setCopying : undefined })) return h(Fragment, null, open ? h(TaskForm, { key: `edit-${open.id}`, roster, actions, task: open, onDone, onCancel }) : null, + source ? h(CopyRow, { key: `copy-${source.id}`, task: source, roster, actions, onView: (next) => { setCopying(null); onDone(next) }, onCancel: () => setCopying(null) }) : null, rows.length + extra.length > 0 ? h('div', { className: 'bt-set-card' }, ...rows, ...extra) : null) } +// Copy (#1287): sends the row's task to another Bot or group chat. The picker lists the +// chats the task does not already run in; the host refuses the same chat as well, because a +// stale view must not produce a silent duplicate. It posts the create endpoint with +// `copyFrom`, so there is no second endpoint and no new state. +export function CopyRow({ task, roster, actions, onView, onCancel }) { + const others = [ + ...roster.bots.filter(bot => bot.id !== task.botId).map(bot => [bot.id, bot.name]), + ...roster.rooms.filter(room => room.id !== task.roomId).map(room => [room.id, room.name]), + ] + const [target, setTarget] = useState(others[0]?.[0] ?? '') + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const submit = (event) => { + event.preventDefault() + if (busy || target === '') return + setBusy(true) + setError('') + void actions.scheduleCreate({ target, copyFrom: { sessionId: task.sessionId, id: task.id } }) + .then(onView, (failure) => { setBusy(false); setError(hostText(failure?.message ?? String(failure))) }) + } + return h('form', { className: 'bt-set-row bt-task-copy', onSubmit: submit }, + h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, t('Copy “{name}” to', { name: task.title }))), + h('select', { className: 'bt-select bt-task-bot', value: target, 'aria-label': t('Copy to'), onChange: event => setTarget(event.target.value) }, + h('option', { value: '' }, t('Pick a Bot or group chat')), + others.map(([id, name]) => h('option', { key: id, value: id }, name))), + h('button', { type: 'submit', className: 'bt-soft bt-soft-sm', disabled: busy || target === '' }, busy ? t('Copying…') : t('Copy')), + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: onCancel }, t('Cancel')), + error ? h('span', { className: 'bt-danger bt-set-hint', role: 'alert' }, error) : 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) @@ -372,6 +409,8 @@ export function ScheduleSummary({ bot, actions }) { export const SCHEDULE_CSS = ` .bt-task{gap:10px} +.bt-task-copy{flex-wrap:wrap;gap:8px} +.bt-task-copy .bt-task-bot{flex:1;min-width:140px;max-width:100%} .bt-task[data-done]{opacity:.6} .bt-task[data-busy]{opacity:.4} .bt-task-title{font-size:13px;line-height:18px;font-weight:500;overflow-wrap:anywhere} diff --git a/core/src/host/schedules.js b/core/src/host/schedules.js index ad6356f81..57c07e512 100644 --- a/core/src/host/schedules.js +++ b/core/src/host/schedules.js @@ -15,6 +15,10 @@ // closed a recurring task runs once, for its latest missed time, never once per missed // time (docs/subsystems/core-host.md). // +// Copy (#1287). A task can be copied to another Bot or group chat: the same title, prompt +// and timing on the target's current part, keeping the source's next run. That is a new row +// under a new id, never `create`, which re-anchors an `every` task (#1245). +// // Last run and Retry (#473). DSH's `lastDelivery` says a reminder reached the Session's // inbox, not how the Bot's turn went, so FlyBot keeps each task's last run in // `state.scheduleRuns[]`: running, ran, stopped, or failed with a reason. A run fails @@ -23,6 +27,7 @@ // task's prompt once now, framed as DSH frames a reminder; it never backfills missed times. // A failure also joins the ended turns the notifications and the Agent inbox read (#353). +import { randomUUID } from 'node:crypto' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { quietHoursOf, inQuietHours, clockIn, quietError } from '../shared/quiet-hours.js' import { defineTool } from './tool-schema.js' @@ -34,6 +39,8 @@ const MAX_TITLE = 120 const MISSED_MS = 120_000 const CHECK_MS = 60_000 const RUNS_KEPT = 200 +// A copy mints its own id; a collision retries this many times rather than overwrite a row. +const COPY_ID_TRIES = 5 const TIME = /^([01]\d|2[0-3]):[0-5]\d$/ const invalid = message => Object.assign(new Error(message), { code: 'bot/invalid' }) @@ -303,6 +310,61 @@ export function install(rt) { // 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) + // Copy (#1287): a second task with the source's title, prompt and timing on the target's + // current part, which keeps the source's next run. DSH's `create` would anchor an `every` + // task to now + interval (#1245), so an active source is written as a new row through the + // same private domain `moveSchedules` uses: an id in DSH's own shape, the source's record + // under it, and no delivery history. Nothing needs re-arming: the copy's instant is the + // source's, and the source's own row already keeps it in the driver's global minimum. A + // paused source has no next run to keep, so its copy goes through `create`, as Resume does. + async function copySchedule(body) { + const from = body.copyFrom ?? {} + const sourceSession = String(from.sessionId ?? '') + const sourceId = String(from.id ?? '') + const owner = ownerOf(sourceSession) + if (owner === undefined) throw invalid('No such task') + const held = pausedEntry(sourceSession, sourceId) + const source = held !== undefined ? held.record : await findRecord(sourceSession, sourceId) + if (source === undefined) throw invalid('No such task') + const targetBot = state.bots[body.target] + const targetRoom = state.rooms[body.target] + if (targetBot === undefined && targetRoom === undefined) throw invalid('No such Bot or group chat') + const same = owner.botId !== undefined ? targetBot?.id === owner.botId : targetRoom?.id === owner.roomId + if (same) throw invalid('Pick a different Bot or group chat to copy it to') + const request = requestOf(source) + if (request === null) throw invalid('This task cannot be copied') + if (request.at !== undefined && Date.parse(request.at) <= Date.now()) throw invalid('Pick a time in the future') + // Quiet hours guard a time the owner picks, as they do on create; a repeat or cron rule is + // let through because FlyBot cannot know its next fire (`quietUnchecked`, #1166). + refuseDuringQuietHours(timingPart(request), request.daily?.time_zone ?? request.weekly?.time_zone ?? request.cron?.time_zone) + const toSession = sessionFor(targetBot !== undefined ? { botId: targetBot.id } : { roomId: targetRoom.id }) + if (held !== undefined) await service.create(toSession, request) + else await copyRow(sourceSession, sourceId, toSession) + return schedulesView() + } + + // The row write itself. `put`, `get` and the `tasks` row shape are the same private API + // `moveSchedules` documents above; a renamed `put` or `get` throws here and copies nothing. + async function copyRow(fromSession, sourceId, toSession) { + await service.serialize(async () => { + const tasks = (await service.getDomain()).table('tasks') + const source = tasks.get(sourceId) + if (source === undefined || source.sessionId !== fromSession) throw invalid('No such task') + const id = copyId(tasks) + await tasks.put(id, { sessionId: toSession, record: { ...source.record, id }, status: 'active', deliveryHistory: { records: [], earlierRecordsUnavailable: false } }) + }) + } + + // DSH mints `schedule-` in `create`; a copy keeps that shape here and treats a + // collision as a retry, never as an overwrite of the row that already has the id. + const copyId = (tasks) => { + for (let tries = 0; tries < COPY_ID_TRIES; tries += 1) { + const id = `schedule-${randomUUID()}` + if (tasks.get(id) === undefined) return id + } + throw invalid('The task could not be copied. Try again') + } + async function schedulesView() { const quietHours = quietNow() if (service === null) return { available: false, tasks: [], quietHours } @@ -496,6 +558,7 @@ export function install(rt) { schedules: () => schedulesView(), async 'schedule-create'(body) { ensureService() + if (body.copyFrom !== undefined) return copySchedule(body) const { sessionId, title, prompt } = contentOf(body) const timing = selectorOf(body.when) refuseDuringQuietHours(timing, body.when?.timeZone) diff --git a/core/test/schedules-client.test.js b/core/test/schedules-client.test.js index 58547ed23..63d6af77b 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, formStart } from './src/client/schedules.js'", + contents: "export { SchedulesPage, BotSchedules, CopyRow, openPlugins, formStart } from './src/client/schedules.js'", resolveDir: fileURLToPath(new URL('..', import.meta.url)), }, bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', @@ -31,7 +31,7 @@ runInNewContext(outputFiles[0].text, { throw new Error(`Unexpected platform module ${name}`) }, }) -const { SchedulesPage, BotSchedules, openPlugins, formStart } = module.exports +const { SchedulesPage, BotSchedules, CopyRow, 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 } @@ -245,3 +245,30 @@ test('the quiet-hours copy has Chinese entries, including the refusal with the w assert.match(source, /Quiet hours are on until \(\\d\\d:\\d\\d\)/) assert.match(source, /安静时段到 \$1 结束/) }) + +// #1287: a row copies its task to another Bot or group chat. The picker offers only the +// other chats, and the copy is the one `schedule-create` call with `copyFrom` — no new +// endpoint, no new state. +const OTHERS = { bots: [{ id: 'b1', name: 'B' }, { id: 'b2', name: 'Writer' }], rooms: [{ id: 'g1', name: 'Launch' }], byId: {}, roomsById: {} } + +test('Copy asks for another chat and posts schedule-create with copyFrom (#1287)', async () => { + const sent = [] + const actions = { ...spy(), scheduleCreate: async (task) => { sent.push(task); return OFF } } + seeded.push('b2') + const form = render(h(CopyRow, { task: ACTIVE, roster: OTHERS, actions, onView() {}, onCancel() {} })) + const select = all(form).find(node => node.type === 'select') + assert.ok(select, 'the form has the target picker') + assert.deepEqual(all(select).filter(node => node.type === 'option').map(node => node.props.value), ['', 'b2', 'g1'], 'the row’s own chat is not offered') + form.props.onSubmit({ preventDefault() {} }) + await new Promise(resolve => setImmediate(resolve)) + assert.deepEqual(JSON.parse(JSON.stringify(sent)), [{ target: 'b2', copyFrom: { sessionId: 'chat-b1', id: 's1' } }]) +}) + +test('a task row offers Copy (#1287)', () => { + seeded.push({ available: true, tasks: [ACTIVE] }, null) + const card = render(BotSchedules({ bot: { id: 'b1' }, roster: OTHERS, actions: { ...spy(), scheduleCreate: async () => OFF } })) + const copy = buttonsOf(card).find(button => button.props['aria-label'] === 'Copy to another Bot or group chat') + assert.ok(copy, 'the row offers Copy') + assert.deepEqual(text(copy), [], 'an icon button, like Edit') +}) + diff --git a/core/test/schedules.test.js b/core/test/schedules.test.js index ccbae9bb6..71c58984e 100644 --- a/core/test/schedules.test.js +++ b/core/test/schedules.test.js @@ -313,6 +313,100 @@ test('#1245: a handoff leaves a disabled and an exhausted row on the earlier par assert.deepEqual([...schedule.tasks.values()].filter(task => task.sessionId === 'm1-fresh').map(task => task.record.id), [active.id], 'the fresh part gets neither inactive row') }) +// #1287: a task can be copied to another Bot or group chat. A copy is a second row with the +// source's title, prompt and timing, and it keeps the source's next run: going through DSH's +// create would anchor an `every` task to now + interval, which is #1245 again. +test('#1287: a copy lands on the target’s current part with the source’s next run, leaving the source alone', 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 }) }) + let clock = Date.parse('2030-01-01T12:00:00.000Z') + t.mock.method(Date, 'now', () => clock) + + await call('schedule-create', { target: 'm1', title: 'Watch', prompt: 'Check the board', when: { kind: 'every', minutes: 120 } }) + const [sourceId] = schedule.tasks.keys() + const source = schedule.tasks.get(sourceId) + const due = '2030-01-01T12:30:00.000Z' + const delivered = { scheduledAt: '2030-01-01T11:00:00.000Z', deliveredAt: '2030-01-01T11:00:02.000Z', messageId: 'msg-old' } + source.record = { ...source.record, scheduledAt: due } + source.deliveryHistory = { records: [delivered], earlierRecordsUnavailable: false } + source.lastDelivery = delivered + + clock += 10 * 60_000 + const copied = await call('schedule-create', { target: 'w1', copyFrom: { sessionId: 'm1', id: sourceId } }) + assert.equal(copied.ok, true, JSON.stringify(copied)) + const copies = [...schedule.tasks.entries()].filter(([id]) => id !== sourceId) + assert.equal(copies.length, 1, 'the copy is a second row, not a rewrite of the source') + const [copyId, copy] = copies[0] + assert.notEqual(copyId, sourceId, 'the copy gets its own id') + assert.match(copyId, /^schedule-/, 'the id keeps DSH’s own shape') + assert.equal(copy.sessionId, 'w2', 'it lands on the target Bot’s current part') + assert.deepEqual([copy.record.title, copy.record.prompt, copy.record.everySeconds], ['Watch', 'Check the board', 7200]) + assert.equal(copy.record.id, copyId, 'the record carries the new id') + assert.equal(copy.record.scheduledAt, due, 'the source’s next run is kept, not now + 7200') + assert.equal(copy.status, 'active') + assert.equal(copy.lastDelivery, undefined, 'a copy has never been delivered') + assert.deepEqual(copy.deliveryHistory, { records: [], earlierRecordsUnavailable: false }, 'a copy carries no delivery history') + + // The source keeps its row, its next run and its delivery record. + assert.equal(schedule.tasks.get(sourceId).sessionId, 'm1') + assert.equal(source.record.scheduledAt, due) + assert.equal(source.lastDelivery.messageId, 'msg-old') + + // FlyBot's run record is keyed on the DSH id, so the copy starts with none (#473). + const saved = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) + assert.equal(Object.hasOwn(saved.scheduleRuns ?? {}, copyId), false) + assert.deepEqual(copied.value.tasks.map(task => task.botId).sort(), ['m1', 'w1'], 'both rows are listed, each under its own Bot') + assert.equal(new Set(copied.value.tasks.map(task => task.id)).size, 2, 'the view lists two distinct tasks') +}) + +test('#1287: an unknown source, an unknown target, the same chat and a passed one-time task are refused', 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: 'Watch', prompt: 'Check', when: { kind: 'every', minutes: 30 } }) + const [id] = schedule.tasks.keys() + const from = { sessionId: 'm1', id } + + for (const [body, message] of [ + [{ target: 'w1', copyFrom: { sessionId: 'm1', id: 'schedule-404' } }, /No such task/], + [{ target: 'w1', copyFrom: { sessionId: 'outside', id } }, /No such task/], + [{ target: 'nobody', copyFrom: from }, /No such Bot or group chat/], + [{ target: 'm1', copyFrom: from }, /different Bot or group chat/], + ]) assert.match((await call('schedule-create', body)).error.message, message) + assert.equal(schedule.tasks.size, 1, 'a refused copy writes nothing') + + // A passed one-time task cannot run again, so its copy is refused as an edit is. + const once = await schedule.create('m1', { title: 'Once', prompt: 'Ping', at: '2030-01-01T00:00:00.000Z' }) + schedule.tasks.get(once.id).record = { ...schedule.tasks.get(once.id).record, scheduledAt: new Date(Date.now() - 1000).toISOString() } + assert.match((await call('schedule-create', { target: 'w1', copyFrom: { sessionId: 'm1', id: once.id } })).error.message, /future/) +}) + +test('#1287: a copy keeps a daily rule’s time and zone, and an earlier part’s task goes to the current one', 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: DAILY }) + const [id] = schedule.tasks.keys() + const anchor = schedule.tasks.get(id).record.scheduledAt + + const copied = await call('schedule-create', { target: 'm1', copyFrom: { sessionId: 'w2', id } }) + assert.equal(copied.ok, true, JSON.stringify(copied)) + const copy = [...schedule.tasks.values()].find(task => task.record.id !== id) + assert.deepEqual([copy.record.kind, copy.record.time, copy.record.timeZone], ['daily', '09:00:00', 'Asia/Shanghai']) + assert.equal(copy.record.scheduledAt, anchor, 'a daily copy keeps the instant it was already due') + assert.equal(copy.sessionId, 'm1') + + // A task still on the Bot's earlier part: the copy goes to the target's current part. + await schedule.create('w1', { title: 'Old part', prompt: 'x', every_seconds: 3600 }) + const oldId = [...schedule.tasks.keys()].at(-1) + const again = await call('schedule-create', { target: 'g1', copyFrom: { sessionId: 'w1', id: oldId } }) + assert.equal(again.ok, true, JSON.stringify(again)) + const groupCopy = [...schedule.tasks.values()].find(task => task.record.title === 'Old part' && task.record.id !== oldId) + assert.equal(groupCopy.sessionId, 'g1', 'a group copy lands on the group’s own Session') + assert.equal(groupCopy.record.scheduledAt, schedule.tasks.get(oldId).record.scheduledAt, 'and keeps the next run') +}) + 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 }) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index a38af94ff..6fed32fe6 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -35,7 +35,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**; an edited task keeps its time zone, a cron rule it cannot show is kept) 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). 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. 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), **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). 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. Tests: `core/test/schedules-client.test.js`. | | `core/src/client/inbox.js` | Agent inbox, step 1 (#349): the **Inbox** row at the top of the sidebar (`InboxRow`, count badge = cards in *Needs you*) and the inbox dialog (`InboxDialog`, `ui.inbox`, `actions.openInbox` / `closeInbox`) with the groups **Needs you**, **In progress** and **Done** (`inboxModel`); failed turns, a *Latest* line and a per-card `N tool calls` count (#471); ask_user cards (a group's and the Bot's own chat) answered in the inbox (#552, #1140); a *Needs you* title and reason wrap in full so the ask can be read on the card (#1177); a result card accepted (**Accept**) or sent back with a note (**Send back** → the box → **Send it back**), with its `result.links` evidence and the producing Bot's model, and a sent-back card reading *Sent back: …* (#401). Tests: `core/test/inbox.test.js`. | | `core/src/client/turn-failures.js` | Dead-turn lines (#1044): a turn that died with no text draws one line above the composer (`flybot.session.dock`, `data-flybot=turn-failures`), newest last, at most 3, naming the provider error from the host's `ended` entry and the streak when it is above 1. Registered by `index.js` (`flybot-turn-failures`, order 20); `failureLines(snapshot, place, t)` lives in `core/src/shared/turn-failures.js` so the test reads it without React. It never posts, retries or counts as a reply. Tests: `core/test/dead-turn.test.js`. | | `core/src/client/delivery-view.js` | The **Delivery** view of the task dialog (#1173, #1211): five columns (`raw`, `ready`, `working`, `review`, `done-today`) from one host snapshot, cards that link out, the verdict/CI badge, and the header numbers. A read is one of four states and each reads differently, never as a blank board: `loading` counts its own seconds (“首次加载中,已 N 秒”) and polls every 2 s, `fresh`/`stale` poll every 60 s and a stale board keeps its columns with “显示 03:12 的快照 · 刷新失败:…” beside the header, and `failed` names the endpoint, the advice and a **Retry** button whose countdown really counts: the screen redraws once a second while a retry is scheduled and not otherwise, and the rate-limit and timeout advice says `while this page is open it retries on its own`, because the host installs no timer (#1227). “还没有快照。” is reachable only from `idle`, a host that never tried. Since #1250 the header opens with whose repository this is — `acme/widget · from config` / `from remote` / `from the package (default)`, and `no repository — set delivery.repo` when the host has nothing — so a package default never passes for a fact; a `no-repo` failure adds `Set delivery.repo in this instance's settings…` and never points at the token. Since #1244 the as-of line prints one time, not two: while today is still running it is the task zone's clock (`clockOf`) and the hint beside it is the bare `today is partial` (zh `今天为部分日`), so a partial day never shows a local-clock and a zone rendering of the same instant side by side. The CI row's share divides by `counted` when the day has one and by `total` otherwise — the one denominator `dayValue` draws that day's point from — so the row and the line beside it cannot read one day two ways; a day with no runs at all still reads `—`. The header's **Diagnose** button prints the five `probeGitHub()` lines into a copyable block, so the user reads the same statuses `core/scripts/delivery-probe.mjs` prints. Since #1265 step 4 one board may hold several repositories: more than one is a `section.bt-del-repo-block[data-repo][data-state]` per repository with a sticky `h3.bt-del-repo-head` (name plus its own source) and its own columns, and exactly one renders no wrapper at all — today's single-repository DOM. A repository that cannot be read keeps its block and says why while the others still draw, and the body says `partial`, never an empty board. Which columns exist comes from the payload through `stagesOf()` (the snapshot's own `columns`; `DELIVERY_COLUMNS` only orders and labels them), so a definition with three columns draws three and the renderer has no fixed five. Read-only: it renders, it never writes. Tests: `core/test/delivery-view.test.js`, `core/test/kanban.test.js`. | diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 8b51c6826..3ab415417 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -43,7 +43,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). `rt.reach` also reads the room a send already belongs to when the caller names none (#948): `sharedRoom` applies only to the `roomOnly` identity — the Bot holds no grant at all and came into that **face-to-face** room by its number (`isCodeJoined`) — and looks for the one such room where the **exact** target is still seated; a Bot that holds a grant, or a room of this home, is judged as it always was and keeps asking for an explicit `roomId`, so a Bot that joined a room by number — a `roomOnly` identity with no person-grant — may line that seat, which is the same allowance `reach()` already applies to an explicit `roomId`; the room itself is named outbound by the transport, never assumed here. A `user-mention` with no named room is also judged under `mentionRoom` (#1126): one live local or inbound group where that Bot is a member and the exact target is still a remote seat, including a seat a group invite admitted (`admittedBy: 'ticket'`). A plain line, `message_bot`, a seat that has left, and a stranger outside every such room stay on the grant rule. Face-to-face stays on `sharedRoom`. Tests: `core/test/reach-policy.test.js`, `core/test/flybot.test.js`. | -| `core/src/host/schedules.js` | Scheduled tasks through DSH's schedule service: list, create, edit, pause/resume, retry and delete for Settings → Scheduled tasks, each task's last run, and moving a Bot's tasks to its fresh part, which rewrites the stored row in place so the task keeps its id, its delivery history and the instant it was already due (#354, #473, #1245, see **Scheduled tasks: edit, pause, missed runs** and **Scheduled tasks: last run and Retry**). Also the `bot_schedules` tool: the Bot-wide list DSH's own `schedule_list` cannot give, group chats' own Sessions included (#628, see **Scheduled tasks: the Bot-wide list**). Tests: `core/test/schedules.test.js`. Tests for team quiet hours, its refusals and the settings write: `core/test/quiet-hours.test.js`; the rule itself is in `core/src/shared/quiet-hours.js` (#1166). | +| `core/src/host/schedules.js` | Scheduled tasks through DSH's schedule service: list, create, edit, copy, pause/resume, retry and delete for Settings → Scheduled tasks, each task's last run, and moving a Bot's tasks to its fresh part, which rewrites the stored row in place so the task keeps its id, its delivery history and the instant it was already due (#354, #473, #1245, #1287, see **Scheduled tasks: edit, pause, missed runs**, **Scheduled tasks: copy** and **Scheduled tasks: last run and Retry**). Also the `bot_schedules` tool: the Bot-wide list DSH's own `schedule_list` cannot give, group chats' own Sessions included (#628, see **Scheduled tasks: the Bot-wide list**). Tests: `core/test/schedules.test.js`. Tests for team quiet hours, its refusals and the settings write: `core/test/quiet-hours.test.js`; the rule itself is in `core/src/shared/quiet-hours.js` (#1166). | | `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/host/delivery-repo.js` | Which repositories a delivery read talks to, and where each name came from (#1250, #1265). `resolveRepos({ setting, workspace, packageSlug })` answers first-wins as a list: the instance setting (`instanceRepoSettings` reads `delivery.repos` as a list and the older `delivery.repo` as a list of one, and a duplicate name written twice is one repository), then the team workspace's `origin` read straight out of `.git/config` (a `.git` file is followed through its `gitdir:`; a linked worktree then follows its `commondir` to the shared config, and no `git` process is spawned), then the package's `repository.url`. A name is normalised to `owner/name` from `owner/name`, a GitHub URL, `git@github.com:owner/name.git` or an `ssh://` URL; anything on another host is refused rather than mined, and a `.` or `..` segment is refused because it is a path step rather than a repository name (#1258). Only `origin` counts — a union of disagreeing remotes would be a guess. A level that answers yields its whole list, so a setting replaces the inferred answer instead of adding to it; `resolveRepo` is the first of that list for a caller that asks about one repository. `NO_REPO_MESSAGE` is the one wording the host, the view and `core/scripts/delivery-probe.mjs` use. Tests: `core/test/delivery-repo.test.js` (the knife for each level). | | `core/src/host/notify.js` | Out-of-app delivery, first half (#355): a transport-agnostic adapter with Slack's `chat.postMessage` shape, one key per reminder so a retried send cannot duplicate, exponential backoff that honours `Retry-After`, three attempts at most, a structural failure not retried, and zero transport calls while the channel is off. Ships `fakeSlack` for tests; nothing calls it until the real Slack transport lands (see **Out-of-app delivery, first half**). Tests: `core/test/notify.test.js`. | @@ -464,11 +464,21 @@ DSH's own `schedule_list` answers for the Session the call runs in, and its `[]` - **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`. +### Scheduled tasks: copy (#1287) + +A task can be copied to another Bot or group chat: the same title, prompt and timing, on the target's current part (a Bot's current chat, an earlier part's task included anyway), keeping the source's **next run**. `schedule-create` takes `copyFrom: { sessionId, id }` — no second endpoint — and the source is resolved through the same ownership check `schedule-update` uses, or from `state.pausedSchedules` when it is paused. + +- **A new row, not `create`.** An active source is read in DSH's storage domain and written back as a new row inside `service.serialize`: a fresh id in DSH's own shape (`schedule-`, retried on collision rather than overwriting), the source's record under it, `status: 'active'`, and an **empty** `deliveryHistory` with no `lastDelivery`. Copying through `create` would anchor an `every` task to `now + interval` — #1245 with a new name — and would carry the source's delivery record, which `checkMissed` reads as "this run was already delivered", so the copy would never fire. The private API is the one **Part move** already documents (`getDomain()`, `serialize()`, `table('tasks')`, `get`, `put`, the row shape), so re-check it on every DSH upgrade; this path sits outside the `moveSchedules` warn-and-continue loop, so a renamed `put` or `get` rejects the copy and writes nothing. +- **No re-arm.** The copy's due instant is the source's, and the source's own row keeps it in the driver's global minimum, so the row is covered before it exists. +- **A paused source** has no next run to keep, so its copy goes through `create` with the stored record's timing, as Resume does. A one-time task whose time has passed is refused with `Pick a time in the future`, the same sentence Edit uses, because such a copy could never run. +- **Refusals.** An unknown source Session, an unknown id, the same Bot or group chat as the source, and an unknown target each get their own sentence; a copy to the same chat never falls through to `No such task`. Quiet hours are checked as on create (a repeat or cron rule is let through, as the view says). +- **Tests:** three in `core/test/schedules.test.js` — the copy's id, next run, empty history and untouched source; the four refusals; a daily rule's time and zone plus an earlier part's task going to the target's current part. + ### Scheduled tasks: team quiet hours (#1166) A window that a **new or edited** task must fall outside — not a mute. The window itself is a wall clock, but the time a task would be due is read in **that task's own zone** (the next bullet), so the two are compared as instants rather than in one team-wide clock. `state.prefs.quietHours` holds `{ on, from, to }` beside the other team preferences, and its absence or `on: false` is off, which is the default (`src/shared/quiet-hours.js`, and `setPrefs` in `channel.js`). -- **Checked once, at the create/edit moment.** `schedule-create` and `schedule-update` refuse before any `service.create` / `service.update` / `service.delete`, with the named code `bot/quiet-hours` and a sentence naming when the window ends, so the form's existing error line shows it and nothing is created or moved. `when: { kind: keep }` changes no time and is never checked. The window is half-open `[from, to)` — `from` refuses, `to` allows — and a window whose end is earlier than its start crosses midnight. +- **Checked once, at the create/edit moment.** `schedule-create` (a copy through `copyFrom` included, #1287) and `schedule-update` refuse before any `service.create` / `service.update` / `service.delete`, with the named code `bot/quiet-hours` and a sentence naming when the window ends, so the form's existing error line shows it and nothing is created or moved. `when: { kind: keep }` changes no time and is never checked. The window is half-open `[from, to)` — `from` refuses, `to` allows — and a window whose end is earlier than its start crosses midnight. - **What the clock is.** A one-shot `at` is an instant, read in the zone the form sends with the timing (the browser's), falling back to the host's when that zone is unreadable. `daily` and `weekly` carry their wall clock and zone with the time itself, so the check reads the clock as written. - **`every` and `cron` are allowed, and said so.** DSH computes a repeat rule's next fire and `catalog()` answers only for a stored task, so FlyBot cannot check one without duplicating DSH's scheduler. The view carries `quietUnchecked: true` while quiet hours are on and such a task exists, and that flag is the **only** place the rule is computed: the page renders it as one line and does not scan the task list itself (`core/src/client/schedules.js`), so a repeat task deleted between two polls cannot leave the line behind, and a line cannot appear without the host's claim. Visible, never a silent claim of silence. - **Nothing existing is touched.** A task that already fires inside the window keeps firing, and turning quiet hours on makes no `update` or `delete` call. The suppression half — muting a delivery and recording it, with a count on the team view — is **not implemented**: DSH has no pre-delivery veto and no `pause` (`ScheduleService` has `create`, `list`, `catalog`, `history`, `delete`, `update`), so it waits for a hook (#1166, tracked as blocked-on-DSH). diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index e54b778f6..2eb031443 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -1264,7 +1264,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Scheduled tasks': '定时任务', 'Every day': '每天', 'Every {count} days': '每 {count} 天', -@@ -784,7 +784,15 @@ const ZH = { +@@ -784,7 +784,21 @@ const ZH = { 'Every weekend at {time}': '每个周末 {time}', 'On the schedule {expression}': '按计划 {expression}', 'Next: {time}': '下次 {time}', @@ -1275,6 +1275,12 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js + 'Run the task once now': '现在运行一次这个任务', + 'DSH could not deliver it': 'DSH 没能送达', + 'Resume the task first': '请先继续这个任务', ++ 'Copy to…': '复制到…', ++ 'Copy to': '复制到', ++ 'Copy to another Bot or group chat': '复制到其他 Bot 或群聊', ++ 'Copy “{name}” to': '把「{name}」复制到', ++ 'Pick a Bot or group chat': '选择 Bot 或群聊', ++ 'Copying…': '正在复制…', Finished: '已结束', + Resume: '继续', 'in {name}': '在 {name}', @@ -1884,18 +1890,20 @@ 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 -@@ -1,10 +1,11 @@ +@@ -1,10 +1,13 @@ // 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). Each row says how its last run -+// went, and a failed run has Retry, which runs the task once now (#473). ++// 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. import { createElement as h, Fragment, useEffect, useState } from 'react' import { BotAvatar, RoomAvatar } from './mark.js' -import { ClockIcon, PlusIcon, TrashIcon } from './icons.js' -import { openHarnessSettings } from './sidebar.js' -+import { ClockIcon, PencilIcon, PlusIcon, TrashIcon } from './icons.js' ++import { ClockIcon, DuplicateIcon, PencilIcon, PlusIcon, TrashIcon } from './icons.js' import { Segmented } from './motion.js' import { dateLocale, hostText, t } from './i18n.js' @@ -1920,11 +1928,11 @@ diff --git a/src/client/schedules.js b/src/client/schedules.js + } +} + -+function TaskRow({ task, actions, onView, onEdit, showBot, roster }) { ++function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster }) { const [confirming, setConfirming] = useState(false) const [busy, setBusy] = useState(false) useEffect(() => { -@@ -85,14 +85,33 @@ function TaskRow({ task, actions, onView, showBot, roster }) { +@@ -85,14 +85,34 @@ function TaskRow({ task, actions, onView, showBot, roster }) { setBusy(true) void actions.scheduleDelete(task.sessionId, task.id).then(onView, () => setBusy(false)) } @@ -1956,6 +1964,7 @@ diff --git a/src/client/schedules.js b/src/client/schedules.js h('span', { className: 'bt-task-next' }, nextText(task)), + 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, + 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, ++ 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, + 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, @@ -2046,7 +2055,7 @@ diff --git a/src/client/schedules.js b/src/client/schedules.js 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,56 +166,131 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { +@@ -166,56 +166,165 @@ 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')), @@ -2152,18 +2161,52 @@ diff --git a/src/client/schedules.js b/src/client/schedules.js 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. ++ ] ++} ++ ++// One chat's rows; the task being edited opens the form in its place, and so does the one ++// being copied, which opens only the target picker. +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 })) ++ const [copying, setCopying] = useState(null) ++ const source = copying ? tasks.find(task => task.id === copying.id && task.sessionId === copying.sessionId) : undefined ++ const rows = tasks.filter(task => task !== open && task !== source).map(task => h(TaskRow, { key: task.id, task, actions, roster, onView: publish, onEdit, onCopy: actions.scheduleCreate ? setCopying : undefined })) + return h(Fragment, null, + open ? h(TaskForm, { key: `edit-${open.id}`, roster, actions, task: open, onDone, onCancel }) : null, ++ source ? h(CopyRow, { key: `copy-${source.id}`, task: source, roster, actions, onView: (next) => { setCopying(null); onDone(next) }, onCancel: () => setCopying(null) }) : null, + rows.length + extra.length > 0 ? h('div', { className: 'bt-set-card' }, ...rows, ...extra) : null) +} + ++// Copy (#1287): sends the row's task to another Bot or group chat. The picker lists the ++// chats the task does not already run in; the host refuses the same chat as well, because a ++// stale view must not produce a silent duplicate. It posts the create endpoint with ++// `copyFrom`, so there is no second endpoint and no new state. ++export function CopyRow({ task, roster, actions, onView, onCancel }) { ++ const others = [ ++ ...roster.bots.filter(bot => bot.id !== task.botId).map(bot => [bot.id, bot.name]), ++ ...roster.rooms.filter(room => room.id !== task.roomId).map(room => [room.id, room.name]), + ] ++ const [target, setTarget] = useState(others[0]?.[0] ?? '') ++ const [busy, setBusy] = useState(false) ++ const [error, setError] = useState('') ++ const submit = (event) => { ++ event.preventDefault() ++ if (busy || target === '') return ++ setBusy(true) ++ setError('') ++ void actions.scheduleCreate({ target, copyFrom: { sessionId: task.sessionId, id: task.id } }) ++ .then(onView, (failure) => { setBusy(false); setError(hostText(failure?.message ?? String(failure))) }) ++ } ++ return h('form', { className: 'bt-set-row bt-task-copy', onSubmit: submit }, ++ h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, t('Copy “{name}” to', { name: task.title }))), ++ h('select', { className: 'bt-select bt-task-bot', value: target, 'aria-label': t('Copy to'), onChange: event => setTarget(event.target.value) }, ++ h('option', { value: '' }, t('Pick a Bot or group chat')), ++ others.map(([id, name]) => h('option', { key: id, value: id }, name))), ++ h('button', { type: 'submit', className: 'bt-soft bt-soft-sm', disabled: busy || target === '' }, busy ? t('Copying…') : t('Copy')), ++ h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: onCancel }, t('Cancel')), ++ error ? h('span', { className: 'bt-danger bt-set-hint', role: 'alert' }, error) : 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) @@ -2195,7 +2238,7 @@ diff --git a/src/client/schedules.js b/src/client/schedules.js } // The details panel's line: the Bot's next tasks, or how to add one. -@@ -225,7 +225,7 @@ export function ScheduleSummary({ bot, actions }) { +@@ -225,13 +225,15 @@ export function ScheduleSummary({ bot, actions }) { return h('div', null, h('div', { className: 'bt-section-title' }, t('Scheduled tasks')), tasks.length === 0 @@ -2204,6 +2247,14 @@ 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)))))) } + + export const SCHEDULE_CSS = ` + .bt-task{gap:10px} ++.bt-task-copy{flex-wrap:wrap;gap:8px} ++.bt-task-copy .bt-task-bot{flex:1;min-width:140px;max-width:100%} + .bt-task[data-done]{opacity:.6} + .bt-task[data-busy]{opacity:.4} + .bt-task-title{font-size:13px;line-height:18px;font-weight:500;overflow-wrap:anywhere} @@ -239,6 +239,12 @@ 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)} @@ -4782,7 +4833,7 @@ diff --git a/src/host/resume.js b/src/host/resume.js diff --git a/src/host/schedules.js b/src/host/schedules.js --- a/src/host/schedules.js +++ b/src/host/schedules.js -@@ -5,8 +5,35 @@ +@@ -5,8 +5,42 @@ // 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. @@ -4796,6 +4847,10 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js +// closed a recurring task runs once, for its latest missed time, never once per missed +// time (docs/subsystems/core-host.md). +// ++// Copy (#1287). A task can be copied to another Bot or group chat: the same title, prompt ++// and timing on the target's current part, keeping the source's next run. That is a new row ++// under a new id, never `create`, which re-anchors an `every` task (#1245). ++// +// Last run and Retry (#473). DSH's `lastDelivery` says a reminder reached the Session's +// inbox, not how the Bot's turn went, so FlyBot keeps each task's last run in +// `state.scheduleRuns[]`: running, ran, stopped, or failed with a reason. A run fails @@ -4804,6 +4859,7 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js +// task's prompt once now, framed as DSH frames a reminder; it never backfills missed times. +// A failure also joins the ended turns the notifications and the Agent inbox read (#353). + ++import { randomUUID } from 'node:crypto' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { quietHoursOf, inQuietHours, clockIn, quietError } from '../shared/quiet-hours.js' +import { defineTool } from './tool-schema.js' @@ -4815,6 +4871,8 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js +const MISSED_MS = 120_000 +const CHECK_MS = 60_000 +const RUNS_KEPT = 200 ++// A copy mints its own id; a collision retries this many times rather than overwrite a row. ++const COPY_ID_TRIES = 5 const TIME = /^([01]\d|2[0-3]):[0-5]\d$/ const invalid = message => Object.assign(new Error(message), { code: 'bot/invalid' }) @@ -4876,7 +4934,7 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js // 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,63 +89,426 @@ export function install(rt) { +@@ -89,63 +89,482 @@ 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) @@ -5022,6 +5080,61 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js + + // 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) ++ ++ // Copy (#1287): a second task with the source's title, prompt and timing on the target's ++ // current part, which keeps the source's next run. DSH's `create` would anchor an `every` ++ // task to now + interval (#1245), so an active source is written as a new row through the ++ // same private domain `moveSchedules` uses: an id in DSH's own shape, the source's record ++ // under it, and no delivery history. Nothing needs re-arming: the copy's instant is the ++ // source's, and the source's own row already keeps it in the driver's global minimum. A ++ // paused source has no next run to keep, so its copy goes through `create`, as Resume does. ++ async function copySchedule(body) { ++ const from = body.copyFrom ?? {} ++ const sourceSession = String(from.sessionId ?? '') ++ const sourceId = String(from.id ?? '') ++ const owner = ownerOf(sourceSession) ++ if (owner === undefined) throw invalid('No such task') ++ const held = pausedEntry(sourceSession, sourceId) ++ const source = held !== undefined ? held.record : await findRecord(sourceSession, sourceId) ++ if (source === undefined) throw invalid('No such task') ++ const targetBot = state.bots[body.target] ++ const targetRoom = state.rooms[body.target] ++ if (targetBot === undefined && targetRoom === undefined) throw invalid('No such Bot or group chat') ++ const same = owner.botId !== undefined ? targetBot?.id === owner.botId : targetRoom?.id === owner.roomId ++ if (same) throw invalid('Pick a different Bot or group chat to copy it to') ++ const request = requestOf(source) ++ if (request === null) throw invalid('This task cannot be copied') ++ if (request.at !== undefined && Date.parse(request.at) <= Date.now()) throw invalid('Pick a time in the future') ++ // Quiet hours guard a time the owner picks, as they do on create; a repeat or cron rule is ++ // let through because FlyBot cannot know its next fire (`quietUnchecked`, #1166). ++ refuseDuringQuietHours(timingPart(request), request.daily?.time_zone ?? request.weekly?.time_zone ?? request.cron?.time_zone) ++ const toSession = sessionFor(targetBot !== undefined ? { botId: targetBot.id } : { roomId: targetRoom.id }) ++ if (held !== undefined) await service.create(toSession, request) ++ else await copyRow(sourceSession, sourceId, toSession) ++ return schedulesView() ++ } ++ ++ // The row write itself. `put`, `get` and the `tasks` row shape are the same private API ++ // `moveSchedules` documents above; a renamed `put` or `get` throws here and copies nothing. ++ async function copyRow(fromSession, sourceId, toSession) { ++ await service.serialize(async () => { ++ const tasks = (await service.getDomain()).table('tasks') ++ const source = tasks.get(sourceId) ++ if (source === undefined || source.sessionId !== fromSession) throw invalid('No such task') ++ const id = copyId(tasks) ++ await tasks.put(id, { sessionId: toSession, record: { ...source.record, id }, status: 'active', deliveryHistory: { records: [], earlierRecordsUnavailable: false } }) ++ }) ++ } ++ ++ // DSH mints `schedule-` in `create`; a copy keeps that shape here and treats a ++ // collision as a retry, never as an overwrite of the row that already has the id. ++ const copyId = (tasks) => { ++ for (let tries = 0; tries < COPY_ID_TRIES; tries += 1) { ++ const id = `schedule-${randomUUID()}` ++ if (tasks.get(id) === undefined) return id ++ } ++ throw invalid('The task could not be copied. Try again') ++ } async function schedulesView() { - if (service === null) return { available: false, tasks: [] } @@ -5036,14 +5149,14 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js - tasks.push({ ...entry, ...owner }) + const lastRun = lastRunOf(entry) + tasks.push({ ...entry, ...owner, ...(lastRun ? { lastRun } : {}) }) -+ } + } +- return { available: true, tasks } + for (const entry of Object.values(paused())) { + const owner = ownerOf(entry.sessionId) + if (!known(owner)) continue + const lastRun = lastRunOf({ id: entry.id }) + tasks.push({ ...entry.record, id: entry.id, sessionId: entry.sessionId, status: 'paused', pausedAt: entry.pausedAt, ...owner, ...(lastRun ? { lastRun } : {}) }) - } -- return { available: true, tasks } ++ } + // #1166: a repeat rule can still fire inside the window — the page says so instead of + // claiming a silence FlyBot cannot keep. + const unchecked = quietHours.on === true && tasks.some(task => task.kind === 'every' || task.kind === 'cron') @@ -5229,6 +5342,7 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js - 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() ++ if (body.copyFrom !== undefined) return copySchedule(body) + const { sessionId, title, prompt } = contentOf(body) + const timing = selectorOf(body.when) + refuseDuringQuietHours(timing, body.when?.timeZone) @@ -9525,7 +9639,7 @@ diff --git a/test/schedules.test.js b/test/schedules.test.js const refused = await call('schedule-create', { target: 'm1', title: 'Briefing', prompt: 'Sum up', when: { kind: 'daily', time: '09:00', timeZone: 'Asia/Shanghai' } }) assert.equal(refused.ok, false) assert.match(refused.error.message, /Automation tasks are off/) -@@ -121,3 +121,414 @@ test('a due reminder reaches a group as its prompts', () => { +@@ -121,3 +121,508 @@ 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') }) @@ -9667,6 +9781,100 @@ diff --git a/test/schedules.test.js b/test/schedules.test.js + assert.deepEqual([...schedule.tasks.values()].filter(task => task.sessionId === 'm1-fresh').map(task => task.record.id), [active.id], 'the fresh part gets neither inactive row') +}) + ++// #1287: a task can be copied to another Bot or group chat. A copy is a second row with the ++// source's title, prompt and timing, and it keeps the source's next run: going through DSH's ++// create would anchor an `every` task to now + interval, which is #1245 again. ++test('#1287: a copy lands on the target’s current part with the source’s next run, leaving the source alone', 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 }) }) ++ let clock = Date.parse('2030-01-01T12:00:00.000Z') ++ t.mock.method(Date, 'now', () => clock) ++ ++ await call('schedule-create', { target: 'm1', title: 'Watch', prompt: 'Check the board', when: { kind: 'every', minutes: 120 } }) ++ const [sourceId] = schedule.tasks.keys() ++ const source = schedule.tasks.get(sourceId) ++ const due = '2030-01-01T12:30:00.000Z' ++ const delivered = { scheduledAt: '2030-01-01T11:00:00.000Z', deliveredAt: '2030-01-01T11:00:02.000Z', messageId: 'msg-old' } ++ source.record = { ...source.record, scheduledAt: due } ++ source.deliveryHistory = { records: [delivered], earlierRecordsUnavailable: false } ++ source.lastDelivery = delivered ++ ++ clock += 10 * 60_000 ++ const copied = await call('schedule-create', { target: 'w1', copyFrom: { sessionId: 'm1', id: sourceId } }) ++ assert.equal(copied.ok, true, JSON.stringify(copied)) ++ const copies = [...schedule.tasks.entries()].filter(([id]) => id !== sourceId) ++ assert.equal(copies.length, 1, 'the copy is a second row, not a rewrite of the source') ++ const [copyId, copy] = copies[0] ++ assert.notEqual(copyId, sourceId, 'the copy gets its own id') ++ assert.match(copyId, /^schedule-/, 'the id keeps DSH’s own shape') ++ assert.equal(copy.sessionId, 'w2', 'it lands on the target Bot’s current part') ++ assert.deepEqual([copy.record.title, copy.record.prompt, copy.record.everySeconds], ['Watch', 'Check the board', 7200]) ++ assert.equal(copy.record.id, copyId, 'the record carries the new id') ++ assert.equal(copy.record.scheduledAt, due, 'the source’s next run is kept, not now + 7200') ++ assert.equal(copy.status, 'active') ++ assert.equal(copy.lastDelivery, undefined, 'a copy has never been delivered') ++ assert.deepEqual(copy.deliveryHistory, { records: [], earlierRecordsUnavailable: false }, 'a copy carries no delivery history') ++ ++ // The source keeps its row, its next run and its delivery record. ++ assert.equal(schedule.tasks.get(sourceId).sessionId, 'm1') ++ assert.equal(source.record.scheduledAt, due) ++ assert.equal(source.lastDelivery.messageId, 'msg-old') ++ ++ // FlyBot's run record is keyed on the DSH id, so the copy starts with none (#473). ++ const saved = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) ++ assert.equal(Object.hasOwn(saved.scheduleRuns ?? {}, copyId), false) ++ assert.deepEqual(copied.value.tasks.map(task => task.botId).sort(), ['m1', 'w1'], 'both rows are listed, each under its own Bot') ++ assert.equal(new Set(copied.value.tasks.map(task => task.id)).size, 2, 'the view lists two distinct tasks') ++}) ++ ++test('#1287: an unknown source, an unknown target, the same chat and a passed one-time task are refused', 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: 'Watch', prompt: 'Check', when: { kind: 'every', minutes: 30 } }) ++ const [id] = schedule.tasks.keys() ++ const from = { sessionId: 'm1', id } ++ ++ for (const [body, message] of [ ++ [{ target: 'w1', copyFrom: { sessionId: 'm1', id: 'schedule-404' } }, /No such task/], ++ [{ target: 'w1', copyFrom: { sessionId: 'outside', id } }, /No such task/], ++ [{ target: 'nobody', copyFrom: from }, /No such Bot or group chat/], ++ [{ target: 'm1', copyFrom: from }, /different Bot or group chat/], ++ ]) assert.match((await call('schedule-create', body)).error.message, message) ++ assert.equal(schedule.tasks.size, 1, 'a refused copy writes nothing') ++ ++ // A passed one-time task cannot run again, so its copy is refused as an edit is. ++ const once = await schedule.create('m1', { title: 'Once', prompt: 'Ping', at: '2030-01-01T00:00:00.000Z' }) ++ schedule.tasks.get(once.id).record = { ...schedule.tasks.get(once.id).record, scheduledAt: new Date(Date.now() - 1000).toISOString() } ++ assert.match((await call('schedule-create', { target: 'w1', copyFrom: { sessionId: 'm1', id: once.id } })).error.message, /future/) ++}) ++ ++test('#1287: a copy keeps a daily rule’s time and zone, and an earlier part’s task goes to the current one', 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: DAILY }) ++ const [id] = schedule.tasks.keys() ++ const anchor = schedule.tasks.get(id).record.scheduledAt ++ ++ const copied = await call('schedule-create', { target: 'm1', copyFrom: { sessionId: 'w2', id } }) ++ assert.equal(copied.ok, true, JSON.stringify(copied)) ++ const copy = [...schedule.tasks.values()].find(task => task.record.id !== id) ++ assert.deepEqual([copy.record.kind, copy.record.time, copy.record.timeZone], ['daily', '09:00:00', 'Asia/Shanghai']) ++ assert.equal(copy.record.scheduledAt, anchor, 'a daily copy keeps the instant it was already due') ++ assert.equal(copy.sessionId, 'm1') ++ ++ // A task still on the Bot's earlier part: the copy goes to the target's current part. ++ await schedule.create('w1', { title: 'Old part', prompt: 'x', every_seconds: 3600 }) ++ const oldId = [...schedule.tasks.keys()].at(-1) ++ const again = await call('schedule-create', { target: 'g1', copyFrom: { sessionId: 'w1', id: oldId } }) ++ assert.equal(again.ok, true, JSON.stringify(again)) ++ const groupCopy = [...schedule.tasks.values()].find(task => task.record.title === 'Old part' && task.record.id !== oldId) ++ assert.equal(groupCopy.sessionId, 'g1', 'a group copy lands on the group’s own Session') ++ assert.equal(groupCopy.record.scheduledAt, schedule.tasks.get(oldId).record.scheduledAt, 'and keeps the next run') ++}) ++ +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 }) diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index c467560c4..5f858c63d 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -695,7 +695,7 @@ diff --git a/client.js b/client.js "Scheduled tasks": "定时任务", "Every day": "每天", "Every {count} days": "每 {count} 天", -@@ -834,7 +834,15 @@ var ZH = { +@@ -834,7 +834,21 @@ var ZH = { "Every weekend at {time}": "每个周末 {time}", "On the schedule {expression}": "按计划 {expression}", "Next: {time}": "下次 {time}", @@ -706,6 +706,12 @@ diff --git a/client.js b/client.js + "Run the task once now": "现在运行一次这个任务", + "DSH could not deliver it": "DSH 没能送达", + "Resume the task first": "请先继续这个任务", ++ "Copy to…": "复制到…", ++ "Copy to": "复制到", ++ "Copy to another Bot or group chat": "复制到其他 Bot 或群聊", ++ "Copy “{name}” to": "把「{name}」复制到", ++ "Pick a Bot or group chat": "选择 Bot 或群聊", ++ "Copying…": "正在复制…", Finished: "已结束", + Resume: "继续", "in {name}": "在 {name}", @@ -12685,21 +12691,32 @@ diff --git a/client.js b/client.js ); const toLatest = () => { const scroller = document.querySelector("[data-conversation-scroll]"); -@@ -5704,8 +5704,8 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5702,31 +5702,129 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { + if (back) back.click(); + else scroller?.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" }); }; - const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" }); - return (0, import_react_dom4.createPortal)( +- const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" }); +- return (0, import_react_dom4.createPortal)( - (0, import_react9.createElement)( - import_react9.Fragment, +- null, +- showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null, +- showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null +- ), +- document.body +- ); ++ const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" }); ++ return (0, import_react_dom4.createPortal)( + (0, import_react19.createElement)( + import_react19.Fragment, - null, - showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null, - showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null -@@ -5714,19 +5714,117 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { - ); - } - ++ null, ++ showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null, ++ showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null ++ ), ++ document.body ++ ); ++} ++ +// src/client/composer-label.js +var ATTRIBUTES = ["aria-label", "data-placeholder"]; +function holdComposerLabel(doc, label) { @@ -12796,8 +12813,8 @@ diff --git a/client.js b/client.js + for (const held of slots.values()) held.off(); + slots.clear(); + }; -+} -+ + } + // src/client/composer.js -var import_react10 = require("react"); +var import_react20 = require("react"); @@ -13141,26 +13158,10 @@ diff --git a/client.js b/client.js // src/client/speech.js var VOICE_BUNDLE = "@deepseek-ai/dsh-experimental-voice-input-bundle"; var USABLE = /* @__PURE__ */ new Set(["ready", "standby", "waking"]); -@@ -6094,259 +6094,35 @@ async function openCapture() { - source.connect(offline.destination); - source.start(); - return waveBytes((await offline.startRendering()).getChannelData(0)); -- } finally { -- close(); -- } -- } -- }; --} --async function transcribeWave(speech, catalog, wave, signal) { -- if (wave.byteLength > catalog.maxAudioBytes) throw captureError("too-long"); -- const result = await speech.transcribe({ audioBase64: base64Of(wave), ...catalog.selection }, signal); -- if (!result?.ok) throw Object.assign(new Error(result?.error?.message ?? "Transcription failed"), { kind: "service" }); -- return String(result.value?.text ?? "").trim(); --} --function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { -- let spokeAt = 0; -- let quietSince = 0; -- return (level, now) => { +@@ -6110,243 +6110,19 @@ function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { + let spokeAt = 0; + let quietSince = 0; + return (level, now) => { - if (level >= loud) { - spokeAt ||= now; - quietSince = 0; @@ -13394,22 +13395,6 @@ diff --git a/client.js b/client.js - ) - ) - ); -+ } finally { -+ close(); -+ } -+ } -+ }; -+} -+async function transcribeWave(speech, catalog, wave, signal) { -+ if (wave.byteLength > catalog.maxAudioBytes) throw captureError("too-long"); -+ const result = await speech.transcribe({ audioBase64: base64Of(wave), ...catalog.selection }, signal); -+ if (!result?.ok) throw Object.assign(new Error(result?.error?.message ?? "Transcription failed"), { kind: "service" }); -+ return String(result.value?.text ?? "").trim(); -+} -+function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { -+ let spokeAt = 0; -+ let quietSince = 0; -+ return (level, now) => { + if (level >= loud) { + spokeAt ||= now; + quietSince = 0; @@ -14038,14 +14023,14 @@ diff --git a/client.js b/client.js + return t("Last run {time}", { time }); + } +} -+function TaskRow({ task, actions, onView, onEdit, showBot, roster }) { ++function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster }) { + const [confirming, setConfirming] = (0, import_react24.useState)(false); + const [busy, setBusy] = (0, import_react24.useState)(false); + (0, import_react24.useEffect)(() => { if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 3e3); return () => clearTimeout(timer); -@@ -6913,20 +6913,48 @@ function TaskRow({ task, actions, onView, showBot, roster }) { +@@ -6913,20 +6913,49 @@ function TaskRow({ task, actions, onView, showBot, roster }) { setBusy(true); void actions.scheduleDelete(task.sessionId, task.id).then(onView, () => setBusy(false)); }; @@ -14098,6 +14083,7 @@ diff --git a/client.js b/client.js + (0, import_react24.createElement)("span", { className: "bt-task-next" }, nextText(task)), + failed && task.status !== "paused" && actions.scheduleRetry ? (0, import_react24.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, title: t("Run the task once now"), onClick: retry }, t("Retry")) : null, + onEdit ? (0, import_react24.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Edit"), title: t("Edit"), onClick: () => onEdit(task) }, (0, import_react24.createElement)(PencilIcon)) : null, ++ onCopy ? (0, import_react24.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_react24.createElement)(DuplicateIcon)) : null, + stoppable ? (0, import_react24.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, onClick: toggle }, task.status === "paused" ? t("Resume") : t("Pause")) : null, + (0, import_react24.createElement)("button", { type: "button", @@ -14171,7 +14157,7 @@ diff --git a/client.js b/client.js }; const submit = (event) => { event.preventDefault(); -@@ -6975,146 +6975,251 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { +@@ -6975,151 +6975,297 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { setBusy(false); return; } @@ -14411,13 +14397,52 @@ diff --git a/client.js b/client.js } +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_react24.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish, onEdit })); ++ const [copying, setCopying] = (0, import_react24.useState)(null); ++ const source = copying ? tasks.find((task) => task.id === copying.id && task.sessionId === copying.sessionId) : void 0; ++ const rows = tasks.filter((task) => task !== open && task !== source).map((task) => (0, import_react24.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish, onEdit, onCopy: actions.scheduleCreate ? setCopying : void 0 })); + return (0, import_react24.createElement)( + import_react24.Fragment, + null, + open ? (0, import_react24.createElement)(TaskForm, { key: `edit-${open.id}`, roster, actions, task: open, onDone, onCancel }) : null, ++ source ? (0, import_react24.createElement)(CopyRow, { key: `copy-${source.id}`, task: source, roster, actions, onView: (next) => { ++ setCopying(null); ++ onDone(next); ++ }, onCancel: () => setCopying(null) }) : null, + rows.length + extra.length > 0 ? (0, import_react24.createElement)("div", { className: "bt-set-card" }, ...rows, ...extra) : null + ); ++} ++function CopyRow({ task, roster, actions, onView, onCancel }) { ++ const others = [ ++ ...roster.bots.filter((bot) => bot.id !== task.botId).map((bot) => [bot.id, bot.name]), ++ ...roster.rooms.filter((room2) => room2.id !== task.roomId).map((room2) => [room2.id, room2.name]) ++ ]; ++ const [target, setTarget] = (0, import_react24.useState)(others[0]?.[0] ?? ""); ++ const [busy, setBusy] = (0, import_react24.useState)(false); ++ const [error, setError] = (0, import_react24.useState)(""); ++ const submit = (event) => { ++ event.preventDefault(); ++ if (busy || target === "") return; ++ setBusy(true); ++ setError(""); ++ void actions.scheduleCreate({ target, copyFrom: { sessionId: task.sessionId, id: task.id } }).then(onView, (failure) => { ++ setBusy(false); ++ setError(hostText(failure?.message ?? String(failure))); ++ }); ++ }; ++ return (0, import_react24.createElement)( ++ "form", ++ { className: "bt-set-row bt-task-copy", onSubmit: submit }, ++ (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("Copy “{name}” to", { name: task.title }))), ++ (0, import_react24.createElement)( ++ "select", ++ { className: "bt-select bt-task-bot", value: target, "aria-label": t("Copy to"), onChange: (event) => setTarget(event.target.value) }, ++ (0, import_react24.createElement)("option", { value: "" }, t("Pick a Bot or group chat")), ++ others.map(([id, name]) => (0, import_react24.createElement)("option", { key: id, value: id }, name)) ++ ), ++ (0, import_react24.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm", disabled: busy || target === "" }, busy ? t("Copying…") : t("Copy")), ++ (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), ++ error ? (0, import_react24.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error) : null ++ ); +} function BotSchedules({ bot, roster, actions }) { const view = useSchedules(actions); @@ -14485,6 +14510,13 @@ diff --git a/client.js b/client.js ))) ); } + var SCHEDULE_CSS = ` + .bt-task{gap:10px} ++.bt-task-copy{flex-wrap:wrap;gap:8px} ++.bt-task-copy .bt-task-bot{flex:1;min-width:140px;max-width:100%} + .bt-task[data-done]{opacity:.6} + .bt-task[data-busy]{opacity:.4} + .bt-task-title{font-size:13px;line-height:18px;font-weight:500;overflow-wrap:anywhere} @@ -7127,6 +7127,12 @@ 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)} diff --git a/patches/ds-bot/files/test/schedules-client.test.js b/patches/ds-bot/files/test/schedules-client.test.js index 58547ed23..63d6af77b 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, formStart } from './src/client/schedules.js'", + contents: "export { SchedulesPage, BotSchedules, CopyRow, openPlugins, formStart } from './src/client/schedules.js'", resolveDir: fileURLToPath(new URL('..', import.meta.url)), }, bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', @@ -31,7 +31,7 @@ runInNewContext(outputFiles[0].text, { throw new Error(`Unexpected platform module ${name}`) }, }) -const { SchedulesPage, BotSchedules, openPlugins, formStart } = module.exports +const { SchedulesPage, BotSchedules, CopyRow, 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 } @@ -245,3 +245,30 @@ test('the quiet-hours copy has Chinese entries, including the refusal with the w assert.match(source, /Quiet hours are on until \(\\d\\d:\\d\\d\)/) assert.match(source, /安静时段到 \$1 结束/) }) + +// #1287: a row copies its task to another Bot or group chat. The picker offers only the +// other chats, and the copy is the one `schedule-create` call with `copyFrom` — no new +// endpoint, no new state. +const OTHERS = { bots: [{ id: 'b1', name: 'B' }, { id: 'b2', name: 'Writer' }], rooms: [{ id: 'g1', name: 'Launch' }], byId: {}, roomsById: {} } + +test('Copy asks for another chat and posts schedule-create with copyFrom (#1287)', async () => { + const sent = [] + const actions = { ...spy(), scheduleCreate: async (task) => { sent.push(task); return OFF } } + seeded.push('b2') + const form = render(h(CopyRow, { task: ACTIVE, roster: OTHERS, actions, onView() {}, onCancel() {} })) + const select = all(form).find(node => node.type === 'select') + assert.ok(select, 'the form has the target picker') + assert.deepEqual(all(select).filter(node => node.type === 'option').map(node => node.props.value), ['', 'b2', 'g1'], 'the row’s own chat is not offered') + form.props.onSubmit({ preventDefault() {} }) + await new Promise(resolve => setImmediate(resolve)) + assert.deepEqual(JSON.parse(JSON.stringify(sent)), [{ target: 'b2', copyFrom: { sessionId: 'chat-b1', id: 's1' } }]) +}) + +test('a task row offers Copy (#1287)', () => { + seeded.push({ available: true, tasks: [ACTIVE] }, null) + const card = render(BotSchedules({ bot: { id: 'b1' }, roster: OTHERS, actions: { ...spy(), scheduleCreate: async () => OFF } })) + const copy = buttonsOf(card).find(button => button.props['aria-label'] === 'Copy to another Bot or group chat') + assert.ok(copy, 'the row offers Copy') + assert.deepEqual(text(copy), [], 'an icon button, like Edit') +}) +