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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1287-copy-scheduled-task.added.md
Original file line number Diff line number Diff line change
@@ -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)).
52 changes: 50 additions & 2 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down Expand Up @@ -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)(() => {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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}
Expand Down
6 changes: 6 additions & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}',
Expand Down
49 changes: 44 additions & 5 deletions core/src/client/schedules.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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}
Expand Down
63 changes: 63 additions & 0 deletions core/src/host/schedules.js
Original file line number Diff line number Diff line change
Expand Up @@ -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[<id>]`: running, ran, stopped, or failed with a reason. A run fails
Expand All @@ -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'
Expand All @@ -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' })
Expand Down Expand Up @@ -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-<uuid>` 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 }
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading