From 561d0e32097636194ff0a698fa6621ad7c4a6b98 Mon Sep 17 00:00:00 2001 From: Birfy <15378933+Birfy@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:07:32 +0800 Subject: [PATCH] Show each scheduled task's last run, and Retry a failed one (#473) A run fails only when DSH could not deliver it or the Bot's turn errored; a finished turn without an answer has run. Retry sends the same prompt once now, without backfilling missed times. A reminder DSH did not deliver joins the ended turns that notifications and the Agent inbox read (#353). --- .../473-schedule-last-run-retry.added.md | 1 + core/client.js | 43 +- core/src/client/i18n.js | 7 + core/src/client/index.js | 1 + core/src/client/schedules.js | 30 +- core/src/host/authz.js | 2 +- core/src/host/channel.js | 1 + core/src/host/groups.js | 2 + core/src/host/schedules.js | 178 +++++- core/src/host/turns.js | 7 +- core/test/authz.test.js | 2 +- core/test/install.test.js | 1 + core/test/schedules-client.test.js | 19 + core/test/schedules.test.js | 103 ++++ docs/subsystems/core-client.md | 2 +- docs/subsystems/core-host.md | 15 +- patches/ds-bot/0001-core-fixes.patch | 378 +++++++++++- patches/ds-bot/0003-flybot-api-client.patch | 3 +- patches/ds-bot/0005-social-seams.patch | 3 +- patches/ds-bot/0008-client-bundle.patch | 579 +++++++++++------- patches/ds-bot/0012-room-membership.patch | 9 +- patches/ds-bot/files/src/host/authz.js | 2 +- patches/ds-bot/files/test/authz.test.js | 2 +- .../files/test/schedules-client.test.js | 19 + 24 files changed, 1147 insertions(+), 262 deletions(-) create mode 100644 changelog.d/473-schedule-last-run-retry.added.md diff --git a/changelog.d/473-schedule-last-run-retry.added.md b/changelog.d/473-schedule-last-run-retry.added.md new file mode 100644 index 00000000..32eaaf4c --- /dev/null +++ b/changelog.d/473-schedule-last-run-retry.added.md @@ -0,0 +1 @@ +Each scheduled task in Settings → Scheduled tasks now says when it last ran and how it went (still running, finished, stopped, or failed with the reason). A run fails only when DSH could not deliver it or the Bot's turn errored; a failed run gets **Retry**, which runs the task once now with the same prompt, and the failure also shows up in notifications ([#473](https://github.com/ThinkFlowLab/FlyBot/issues/473)). diff --git a/core/client.js b/core/client.js index a5051ae9..44c982b0 100644 --- a/core/client.js +++ b/core/client.js @@ -1065,6 +1065,13 @@ var ZH = { "Every weekend at {time}": "每个周末 {time}", "On the schedule {expression}": "按计划 {expression}", "Next: {time}": "下次 {time}", + "Running since {time}": "运行中,{time} 开始", + "Last run {time}": "上次运行 {time}", + "Last run {time} · Stopped": "上次运行 {time} · 已停止", + "Last run {time} · Failed: {reason}": "上次运行 {time} · 失败:{reason}", + "Run the task once now": "现在运行一次这个任务", + "DSH could not deliver it": "DSH 没能送达", + "Resume the task first": "请先继续这个任务", Finished: "已结束", Resume: "继续", "in {name}": "在 {name}", @@ -9491,6 +9498,20 @@ function timingText(task) { } } var nextText = (task) => task.status === "paused" ? t("Paused") : task.status === "active" ? t("Next: {time}", { time: dayAndTime(Date.parse(task.scheduledAt)) }) : t("Finished"); +function lastRunText(run) { + if (run === void 0 || !Number.isFinite(run.at)) return ""; + const time = dayAndTime(run.at); + switch (run.result) { + case "running": + return t("Running since {time}", { time }); + case "stopped": + return t("Last run {time} · Stopped", { time }); + case "failed": + return t("Last run {time} · Failed: {reason}", { time, reason: hostText(run.reason || "Unknown error") }); + default: + return t("Last run {time}", { time }); + } +} function TaskRow({ task, actions, onView, onEdit, showBot, roster }) { const [confirming, setConfirming] = (0, import_react19.useState)(false); const [busy, setBusy] = (0, import_react19.useState)(false); @@ -9515,8 +9536,22 @@ function TaskRow({ task, actions, onView, onEdit, showBot, roster }) { onView(next); }, () => setBusy(false)); }; + const [retryError, setRetryError] = (0, import_react19.useState)(""); + const retry = () => { + setBusy(true); + setRetryError(""); + void actions.scheduleRetry(task.sessionId, task.id).then((next) => { + setBusy(false); + onView(next); + }, (error) => { + setBusy(false); + setRetryError(hostText(error?.message ?? String(error))); + }); + }; const room2 = task.roomId ? roster.roomsById[task.roomId] : void 0; const stoppable = task.status === "active" || task.status === "paused"; + const failed = task.lastRun?.result === "failed"; + const last = lastRunText(task.lastRun); return (0, import_react19.createElement)( "div", { className: "bt-set-row bt-task", "data-done": stoppable ? void 0 : "", "data-paused": task.status === "paused" || void 0, "data-busy": busy || void 0 }, @@ -9526,9 +9561,12 @@ function TaskRow({ task, actions, onView, onEdit, showBot, roster }) { { className: "bt-set-copy" }, (0, import_react19.createElement)("span", { className: "bt-task-title" }, task.title), (0, import_react19.createElement)("span", { className: "bt-set-hint" }, [timingText(task), room2 ? t("in {name}", { name: room2.name }) : ""].filter(Boolean).join(" · ")), - task.prompt && task.prompt !== task.title ? (0, import_react19.createElement)("span", { className: "bt-task-prompt" }, task.prompt) : null + task.prompt && task.prompt !== task.title ? (0, import_react19.createElement)("span", { className: "bt-task-prompt" }, task.prompt) : null, + last ? (0, import_react19.createElement)("span", { className: "bt-task-last", "data-failed": failed || void 0 }, last) : null, + retryError ? (0, import_react19.createElement)("span", { className: "bt-task-last", "data-failed": "", role: "alert" }, retryError) : null ), (0, import_react19.createElement)("span", { className: "bt-task-next" }, nextText(task)), + failed && task.status !== "paused" && actions.scheduleRetry ? (0, import_react19.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_react19.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Edit"), title: t("Edit"), onClick: () => onEdit(task) }, (0, import_react19.createElement)(PencilIcon)) : null, stoppable ? (0, import_react19.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, onClick: toggle }, task.status === "paused" ? t("Resume") : t("Pause")) : null, (0, import_react19.createElement)("button", { @@ -9796,6 +9834,8 @@ var SCHEDULE_CSS = ` .bt-task-tool{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} .bt-task-tool svg{width:14px;height:14px} .bt-task-pause{height:26px;color:var(--bt-ink-2)} +.bt-task-last{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow-wrap:anywhere} +.bt-task-last[data-failed]{color:#c21d2e} .bt-task[data-paused] .bt-task-title,.bt-task[data-paused] .bt-task-prompt{color:var(--bt-ink-3)} .bt-task-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135} .bt-task-head{display:flex;align-items:center;gap:6px} @@ -17353,6 +17393,7 @@ function apply(ctx) { scheduleUpdate: (task) => call("schedule-update", task), schedulePause: (sessionId, id) => call("schedule-pause", { sessionId, id }), scheduleResume: (sessionId, id) => call("schedule-resume", { sessionId, id }), + scheduleRetry: (sessionId, id) => call("schedule-retry", { sessionId, id }), scheduleDelete: (sessionId, id) => call("schedule-delete", { sessionId, id }), exchange: (a, b) => call("exchange", { a, b }).catch(() => []), roomProgress: (roomId) => call("room-progress", { roomId }).catch(() => []), diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 7832798d..d0000ca9 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -1015,6 +1015,13 @@ const ZH = { 'Every weekend at {time}': '每个周末 {time}', 'On the schedule {expression}': '按计划 {expression}', 'Next: {time}': '下次 {time}', + 'Running since {time}': '运行中,{time} 开始', + 'Last run {time}': '上次运行 {time}', + 'Last run {time} · Stopped': '上次运行 {time} · 已停止', + 'Last run {time} · Failed: {reason}': '上次运行 {time} · 失败:{reason}', + 'Run the task once now': '现在运行一次这个任务', + 'DSH could not deliver it': 'DSH 没能送达', + 'Resume the task first': '请先继续这个任务', Finished: '已结束', Resume: '继续', 'in {name}': '在 {name}', diff --git a/core/src/client/index.js b/core/src/client/index.js index 13496f79..009849d5 100644 --- a/core/src/client/index.js +++ b/core/src/client/index.js @@ -607,6 +607,7 @@ export function apply(ctx) { scheduleUpdate: task => call('schedule-update', task), schedulePause: (sessionId, id) => call('schedule-pause', { sessionId, id }), scheduleResume: (sessionId, id) => call('schedule-resume', { sessionId, id }), + scheduleRetry: (sessionId, id) => call('schedule-retry', { sessionId, id }), scheduleDelete: (sessionId, id) => call('schedule-delete', { sessionId, id }), exchange: (a, b) => call('exchange', { a, b }).catch(() => []), roomProgress: roomId => call('room-progress', { roomId }).catch(() => []), diff --git a/core/src/client/schedules.js b/core/src/client/schedules.js index a7e0cc44..de85e4ed 100644 --- a/core/src/client/schedules.js +++ b/core/src/client/schedules.js @@ -1,7 +1,8 @@ // 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. A row edits its task in the same -// form that creates one, and pauses or resumes it (#354). +// 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). import { createElement as h, Fragment, useEffect, useState } from 'react' import { BotAvatar, RoomAvatar } from './mark.js' import { ClockIcon, PencilIcon, PlusIcon, TrashIcon } from './icons.js' @@ -73,6 +74,18 @@ export function timingText(task) { const nextText = task => (task.status === 'paused' ? t('Paused') : task.status === 'active' ? t('Next: {time}', { time: dayAndTime(Date.parse(task.scheduledAt)) }) : t('Finished')) +// The last run, in one line: when, and how it went (src/host/schedules.js). +export function lastRunText(run) { + if (run === undefined || !Number.isFinite(run.at)) return '' + const time = dayAndTime(run.at) + switch (run.result) { + case 'running': return t('Running since {time}', { time }) + case 'stopped': return t('Last run {time} · Stopped', { time }) + case 'failed': return t('Last run {time} · Failed: {reason}', { time, reason: hostText(run.reason || 'Unknown error') }) + default: return t('Last run {time}', { time }) + } +} + function TaskRow({ task, actions, onView, onEdit, showBot, roster }) { const [confirming, setConfirming] = useState(false) const [busy, setBusy] = useState(false) @@ -91,15 +104,26 @@ function TaskRow({ task, actions, onView, onEdit, showBot, roster }) { const step = task.status === 'paused' ? actions.scheduleResume : actions.schedulePause void step(task.sessionId, task.id).then((next) => { setBusy(false); onView(next) }, () => setBusy(false)) } + const [retryError, setRetryError] = useState('') + const retry = () => { + setBusy(true) + setRetryError('') + void actions.scheduleRetry(task.sessionId, task.id).then((next) => { setBusy(false); onView(next) }, (error) => { setBusy(false); setRetryError(hostText(error?.message ?? String(error))) }) + } const room = task.roomId ? roster.roomsById[task.roomId] : undefined const stoppable = task.status === 'active' || task.status === 'paused' + const failed = task.lastRun?.result === 'failed' + const last = lastRunText(task.lastRun) return h('div', { className: 'bt-set-row bt-task', 'data-done': stoppable ? undefined : '', 'data-paused': task.status === 'paused' || undefined, 'data-busy': busy || undefined }, showBot ? h(BotAvatar, { bot: roster.byId[task.botId], size: 28, badge: false, live: false }) : null, h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-task-title' }, task.title), h('span', { className: 'bt-set-hint' }, [timingText(task), room ? t('in {name}', { name: room.name }) : ''].filter(Boolean).join(' · ')), - task.prompt && task.prompt !== task.title ? h('span', { className: 'bt-task-prompt' }, task.prompt) : null), + task.prompt && task.prompt !== task.title ? h('span', { className: 'bt-task-prompt' }, task.prompt) : null, + last ? h('span', { className: 'bt-task-last', 'data-failed': failed || undefined }, last) : null, + retryError ? h('span', { className: 'bt-task-last', 'data-failed': '', role: 'alert' }, retryError) : null), 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, 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', { @@ -303,6 +327,8 @@ export const SCHEDULE_CSS = ` .bt-task-tool{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} .bt-task-tool svg{width:14px;height:14px} .bt-task-pause{height:26px;color:var(--bt-ink-2)} +.bt-task-last{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow-wrap:anywhere} +.bt-task-last[data-failed]{color:#c21d2e} .bt-task[data-paused] .bt-task-title,.bt-task[data-paused] .bt-task-prompt{color:var(--bt-ink-3)} .bt-task-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135} .bt-task-head{display:flex;align-items:center;gap:6px} diff --git a/core/src/host/authz.js b/core/src/host/authz.js index 33a24551..1660b957 100644 --- a/core/src/host/authz.js +++ b/core/src/host/authz.js @@ -38,7 +38,7 @@ const SHARED_BLOCKED = new Set([ const BOT_TARGETED = new Set([ 'update-bot', 'delete-bot', 'duplicate-bot', 'set-flags', 'bot-capability', 'memory-view', 'memory-ask', 'memory-forget', - 'schedules', 'schedule-create', 'schedule-update', 'schedule-pause', 'schedule-resume', 'schedule-delete', + 'schedules', 'schedule-create', 'schedule-update', 'schedule-pause', 'schedule-resume', 'schedule-retry', 'schedule-delete', 'agent-session', 'agent-adopt', 'agent-archive', 'agent-rename', 'remove-agent', 'stop-member-work', 'set-main', 'test-model', ]) diff --git a/core/src/host/channel.js b/core/src/host/channel.js index 5c34ffe2..d841d1d2 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -355,6 +355,7 @@ export function install(rt) { case 'schedules': case 'schedule-pause': case 'schedule-resume': + case 'schedule-retry': case 'schedule-delete': await rt.load() return ok(await rt.scheduleEndpoint(endpoint, body)) diff --git a/core/src/host/groups.js b/core/src/host/groups.js index 6a3352e2..8df8bcb5 100644 --- a/core/src/host/groups.js +++ b/core/src/host/groups.js @@ -614,6 +614,8 @@ export function install(rt) { rt.roomProgress.set(exec.callId, round) try { await runRound(room, round, exec.signal, exec.agent) + // A scheduled task's run in this group fails with its members' failed turns (#473). + if (round.failed.length > 0) rt.scheduleRoundFailed?.(room.id, round.failed) } catch (error) { rt.warn('ds-bot: group round in %s stopped: %s', room.name, error) } finally { diff --git a/core/src/host/schedules.js b/core/src/host/schedules.js index a9481049..601fdcc5 100644 --- a/core/src/host/schedules.js +++ b/core/src/host/schedules.js @@ -14,8 +14,23 @@ // creates it again on the Bot's current part. A missed run is DSH's: after the app was // closed a recurring task runs once, for its latest missed time, never once per missed // time (docs/subsystems/core-host.md). +// +// 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 +// only when DSH could not deliver it (the task stays due past MISSED_MS while the app runs) +// or the Bot's turn errored; a turn that ends without an answer has run. Retry sends the +// 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 { createUserMessage } from '@deepseek-ai/dsh-llm' +import { textOf } from './text.js' const MAX_TITLE = 120 +// How long a due task may stay undelivered while the app runs before it counts as failed. +const MISSED_MS = 120_000 +const CHECK_MS = 60_000 +const RUNS_KEPT = 200 const TIME = /^([01]\d|2[0-3]):[0-5]\d$/ const invalid = message => Object.assign(new Error(message), { code: 'bot/invalid' }) @@ -37,6 +52,28 @@ export function reminderText(text) { return lines.length === 0 ? text : lines.map(prompt => `[Scheduled task] ${prompt}`).join('\n') } +// The task ids a reminder's framing names (one, or each of a batch). +export function reminderIds(text) { + const one = /^schedule_id_json: (.*)$/m.exec(text) + const batch = /^reminders_json: (.*)$/m.exec(text) + try { + const ids = one ? [JSON.parse(one[1])] : batch ? JSON.parse(batch[1]).map(entry => entry?.schedule_id) : [] + return ids.filter(id => typeof id === 'string' && id !== '') + } catch { + return [] + } +} + +// A run again of one task, in DSH's reminder framing, so the turn reads it like the task's +// own run and a group gets its prompt (reminderText). +export const retryText = (record, at) => [ + '[SCHEDULE REMINDER]', + 'This is a scheduled message from the user', + `schedule_id_json: ${JSON.stringify(record.id)}`, + `occurrence_at: ${new Date(at).toISOString()}`, + `reminder_prompt_json: ${JSON.stringify(record.prompt)}`, +].join('\n') + // The create request that gives a stored record its timing again, for a move. function requestOf(record) { const base = { title: record.title, prompt: record.prompt } @@ -134,6 +171,98 @@ export function install(rt) { const sessionFor = owner => (owner.botId !== undefined ? rt.chatOf(owner.botId) : owner.roomId) const ensureService = () => { if (service === null) throw invalid('Automation tasks are off in DSH') } + // Each task's last run, by its DSH id: { at, result, reason?, messageId?, missed? }. + const runs = () => { + if (state.scheduleRuns === null || typeof state.scheduleRuns !== 'object' || Array.isArray(state.scheduleRuns)) state.scheduleRuns = {} + return state.scheduleRuns + } + const noteRun = (id, run) => { + const all = runs() + delete all[id] + all[id] = run + const ids = Object.keys(all) + if (ids.length > RUNS_KEPT) for (const old of ids.slice(0, ids.length - RUNS_KEPT)) delete all[old] + void rt.save().catch(() => {}) + } + // What a row shows: FlyBot's record, or DSH's newer delivery the Bot has not taken up yet. + // A run still marked running from before this start has no known end: it shows as a run. + const startedAt = Date.now() + const lastRunOf = (entry) => { + const run = Object.hasOwn(runs(), entry.id) ? runs()[entry.id] : undefined + const delivered = entry.lastDelivery + if (delivered !== undefined && run?.messageId !== delivered.messageId && Date.parse(delivered.deliveredAt) > (run?.at ?? 0)) { + return { at: Date.parse(delivered.deliveredAt), result: 'delivered' } + } + if (run === undefined) return undefined + const result = run.result === 'running' && run.at < startedAt ? 'delivered' : run.result + return { at: run.at, result, ...(run.reason ? { reason: run.reason } : {}) } + } + + // The turns that run a task, by Session: the task ids and a group round's failure. + /** @type {Map} */ + const runningRuns = new Map() + const runStarted = (sessionId, message) => { + const ids = reminderIds(textOf(message?.content)) + if (ids.length === 0) return + const at = Date.now() + const current = runningRuns.get(sessionId) + runningRuns.set(sessionId, { ...current, ids: [...new Set([...(current?.ids ?? []), ...ids])] }) + for (const id of ids) noteRun(id, { at, result: 'running', ...(typeof message?.id === 'string' ? { messageId: message.id } : {}) }) + } + // `failed`: the turn's error line (turns.js failureLine); `aborted`: the user stopped it. + const runEnded = (sessionId, { failed, aborted = false } = {}) => { + const entry = runningRuns.get(sessionId) + if (entry === undefined) return + runningRuns.delete(sessionId) + const reason = failed ?? entry.failed + for (const id of entry.ids) { + const run = runs()[id] + if (run === undefined) continue + noteRun(id, { ...run, result: reason !== undefined ? 'failed' : aborted ? 'stopped' : 'ran', ...(reason !== undefined ? { reason: reason.slice(0, 200) } : {}) }) + } + } + // A group round whose members' turns failed: the task's run in that group failed. + const roundFailed = (sessionId, names) => { + const entry = runningRuns.get(sessionId) + if (entry !== undefined && names.length > 0) entry.failed = `${names.join(', ')} could not answer: the model call failed` + } + + // A due task DSH has not delivered: seen due at one check and still due MISSED_MS later + // (DSH delivers a time missed while the app was closed as soon as it starts). + /** @type {Map} */ + const due = new Map() + async function checkMissed(now = Date.now()) { + if (service === null) return + const seen = new Set() + for (const entry of await service.catalog()) { + if (entry.status !== 'active' || !(Date.parse(entry.scheduledAt) <= now) || entry.lastDelivery?.scheduledAt === entry.scheduledAt) continue + const owner = ownerOf(entry.sessionId) + if (!known(owner)) continue + seen.add(entry.id) + const first = due.get(entry.id) + if (first?.scheduledAt !== entry.scheduledAt) { due.set(entry.id, { scheduledAt: entry.scheduledAt, since: now }); continue } + if (now - first.since < MISSED_MS || runs()[entry.id]?.missed === entry.scheduledAt) continue + noteRun(entry.id, { at: Date.parse(entry.scheduledAt), result: 'failed', reason: 'DSH could not deliver it', missed: entry.scheduledAt }) + // The same event the finished and failed turns use (#353), for my own Bots only. + const room = owner.roomId === undefined ? undefined : state.rooms[owner.roomId] + const bot = state.bots[owner.botId ?? room?.admin ?? room?.members?.[0]] + if (bot === undefined || bot.remote !== undefined || room?.origin !== undefined) continue + rt.noteRunFailure?.({ + id: `schedule:${entry.id}:${entry.scheduledAt}`, + botId: bot.id, + ...(room ? { roomId: room.id } : { sessionId: entry.sessionId }), + at: now, + failed: `Scheduled task "${entry.title}" was not delivered`.slice(0, 160), + }) + } + for (const id of due.keys()) if (!seen.has(id)) due.delete(id) + } + ctx.effect(() => { + const timer = setInterval(() => { void checkMissed().catch(error => rt.warn('ds-bot: checking scheduled tasks failed: %s', error?.message ?? error)) }, CHECK_MS) + timer.unref?.() + return () => clearInterval(timer) + }, 'ds-bot: missed scheduled runs') + // The form's name, instruction and target, checked as creation checks them. function contentOf(body) { const bot = state.bots[body.target] @@ -152,16 +281,20 @@ export function install(rt) { async function schedulesView() { if (service === null) return { available: false, tasks: [] } + // The page asks every 30 seconds, so a missed run shows without waiting for the timer. + await checkMissed().catch(error => rt.warn('ds-bot: checking scheduled tasks failed: %s', error?.message ?? error)) const tasks = [] for (const entry of await service.catalog()) { const owner = ownerOf(entry.sessionId) if (!known(owner)) continue - tasks.push({ ...entry, ...owner }) + const lastRun = lastRunOf(entry) + tasks.push({ ...entry, ...owner, ...(lastRun ? { lastRun } : {}) }) } for (const entry of Object.values(paused())) { const owner = ownerOf(entry.sessionId) if (!known(owner)) continue - tasks.push({ ...entry.record, id: entry.id, sessionId: entry.sessionId, status: 'paused', pausedAt: entry.pausedAt, ...owner }) + 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 } } @@ -245,6 +378,37 @@ export function install(rt) { return schedulesView() } + // Retry: the task's prompt once now, to the Bot's current part or the group, as a run of + // that task. DSH's own times are untouched: no missed time is delivered again. + async function retrySchedule(body) { + ensureService() + const sessionId = String(body.sessionId ?? '') + const owner = ownerOf(sessionId) + if (!known(owner)) throw invalid('No such task') + if (pausedEntry(sessionId, String(body.id ?? '')) !== undefined) throw invalid('Resume the task first') + const record = await findRecord(sessionId, String(body.id ?? '')) + if (record === undefined) throw invalid('No such task') + const target = sessionFor(owner) + const at = Date.now() + await rt.ensureCapabilities?.(target) + const agent = await rt.resolveAgent(target) + // Noted first: the turn that takes the message up may start (and end) before the flush. + const before = Object.hasOwn(runs(), record.id) ? runs()[record.id] : undefined + noteRun(record.id, { at, result: 'running' }) + try { + agent.followup(createUserMessage({ content: [{ type: 'text', text: retryText(record, at) }], source: { kind: 'schedule' } })) + await ctx.sessions.flush(agent.session) + } catch (error) { + if (before !== undefined) noteRun(record.id, before) + else { + delete runs()[record.id] + void rt.save().catch(() => {}) + } + throw error + } + return schedulesView() + } + const endpoints = { schedules: () => schedulesView(), async 'schedule-create'(body) { @@ -256,6 +420,7 @@ export function install(rt) { 'schedule-update': body => updateSchedule(body), 'schedule-pause': body => pauseSchedule(body), 'schedule-resume': body => resumeSchedule(body), + 'schedule-retry': body => retrySchedule(body), async 'schedule-delete'(body) { ensureService() if (ownerOf(body.sessionId) === undefined) throw invalid('No such task') @@ -292,5 +457,12 @@ export function install(rt) { } } - Object.assign(rt, { scheduleEndpoint: (endpoint, body) => endpoints[endpoint](body ?? {}), moveSchedules }) + Object.assign(rt, { + scheduleEndpoint: (endpoint, body) => endpoints[endpoint](body ?? {}), + moveSchedules, + scheduleRunStarted: runStarted, + scheduleRunEnded: runEnded, + scheduleRoundFailed: roundFailed, + checkMissedSchedules: checkMissed, + }) } diff --git a/core/src/host/turns.js b/core/src/host/turns.js index 6f031bcd..127954fa 100644 --- a/core/src/host/turns.js +++ b/core/src/host/turns.js @@ -277,6 +277,8 @@ export function install(rt) { }].slice(-ENDED_KEPT) } const endedView = () => ended + // A scheduled run DSH could not deliver never became a turn; it joins them as a failure (#473). + const noteRunFailure = (entry) => { ended = [...ended, entry].slice(-ENDED_KEPT) } // Where a Session speaks, for the diagnostics log. const placeOf = (id) => { @@ -344,6 +346,8 @@ export function install(rt) { break case 'user/message': { const source = event.data?.source + // A scheduled task's run (schedules.js, #473): its outcome is this turn's. + if (source?.kind === 'schedule') rt.scheduleRunStarted?.(id, event.data) // The strictest verdict of everything the turn took in; one 'other' stays for the whole turn. const verdict = originOfSource(source, rt.botOf(rt.selfOf(id))) if (verdict !== undefined) { @@ -477,6 +481,7 @@ export function install(rt) { const reason = event.data?.reason const failure = reason?.kind === 'error' && !switched ? turnFailure(reason.error) : undefined if (failure !== undefined) noteTurnFailure(id, failure) + rt.scheduleRunEnded?.(id, { failed: failure === undefined ? undefined : failureLine(failure), aborted: reason?.kind === 'aborted' }) if (switched) break // A model that writes its tool call as text ends the turn as if it were done, // though nothing ran. It gets one more turn on the same trigger, so a waiting @@ -592,5 +597,5 @@ export function install(rt) { return found } - Object.assign(rt, { botTurnOrigin, live, liveOf, roomWaiters, roomProgress, roomInbox, activityView, workView, roomWorkView, endedView, callDetail, stopRoomTurn }) + Object.assign(rt, { botTurnOrigin, live, liveOf, roomWaiters, roomProgress, roomInbox, activityView, workView, roomWorkView, endedView, noteRunFailure, callDetail, stopRoomTurn }) } diff --git a/core/test/authz.test.js b/core/test/authz.test.js index 91c2560b..0559b846 100644 --- a/core/test/authz.test.js +++ b/core/test/authz.test.js @@ -227,7 +227,7 @@ test('editing, pausing and resuming a task need its Session, and an edit its new assert.equal(rt.authorize(actorA, 'schedule-update', { sessionId: 'chat-a1', id: 's1', target: 'a1' }).ok, true) assert.equal(rt.authorize(actorA, 'schedule-update', { sessionId: 'chat-a1', id: 's1', target: 'b1' }).code, 'bot/forbidden') assert.equal(rt.authorize(actorA, 'schedule-update', { sessionId: 'chat-b1', id: 's1', target: 'a1' }).code, 'bot/forbidden') - for (const ep of ['schedule-pause', 'schedule-resume']) { + for (const ep of ['schedule-pause', 'schedule-resume', 'schedule-retry']) { assert.equal(rt.authorize(actorA, ep, { sessionId: 'chat-a1', id: 's1' }).ok, true) assert.equal(rt.authorize(actorA, ep, { sessionId: 'chat-b1', id: 's1' }).code, 'bot/forbidden') } diff --git a/core/test/install.test.js b/core/test/install.test.js index 7c384212..451188e9 100644 --- a/core/test/install.test.js +++ b/core/test/install.test.js @@ -96,6 +96,7 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { 'on agent/created', 'on agent/created', 'effect ds-bot: capabilities', + 'effect ds-bot: missed scheduled runs', 'effect ds-bot: automation tasks default', 'effect ds-bot: usage ledger', 'on llm/stream', diff --git a/core/test/schedules-client.test.js b/core/test/schedules-client.test.js index 8632f588..a2a7401b 100644 --- a/core/test/schedules-client.test.js +++ b/core/test/schedules-client.test.js @@ -135,3 +135,22 @@ test('the form starts from the edited task: repeat interval, one time, cron kept assert.equal(formStart({ kind: 'cron', expression: '0 9 * * 1', timeZone: 'UTC' }).kind, 'cron') assert.deepEqual([formStart(undefined).kind, formStart(undefined).title, formStart(undefined).time], ['daily', '', '09:00']) }) + +test('a row says how its last run went; only a failed run offers Retry, which runs the task now (#473)', async () => { + const calls = [] + const actions = { ...spy(), scheduleRetry: async (...args) => { calls.push(['retry', ...args]); return OFF } } + const at = new Date(2030, 0, 2, 9, 0).getTime() + const failed = { ...ACTIVE, lastRun: { at, result: 'failed', reason: 'SERVER 503: upstream busy' } } + const ran = { ...ACTIVE, id: 's3', lastRun: { at, result: 'ran' } } + const missed = { ...PAUSED, lastRun: { at, result: 'failed', reason: 'DSH could not deliver it' } } + seeded.push({ available: true, tasks: [failed, ran, missed] }, null) + const card = render(BotSchedules({ bot: { id: 'b1' }, roster: ROSTER, actions })) + const lines = text(card) + assert.ok(lines.some(line => /^Last run .+ · Failed: SERVER 503: upstream busy$/.test(line)), lines.join('|')) + assert.ok(lines.some(line => /^Last run [^·]+$/.test(line))) + const retries = buttonsOf(card).filter(button => text(button).includes('Retry')) + assert.equal(retries.length, 1, 'a ran task and a paused one have no Retry') + retries[0].props.onClick() + await new Promise(resolve => setImmediate(resolve)) + assert.deepEqual(JSON.parse(JSON.stringify(calls)), [['retry', 'chat-b1', 's1']]) +}) diff --git a/core/test/schedules.test.js b/core/test/schedules.test.js index 9b285c77..040e15bc 100644 --- a/core/test/schedules.test.js +++ b/core/test/schedules.test.js @@ -290,3 +290,106 @@ test('a recurring task missed while the app was closed runs once, for its latest assert.ok(Date.parse(hourly.nextScheduledAt) > created + 50 * 3600_000 + 60_000) assert.ok(Date.parse(hourly.occurrenceAt) > created + 49 * 3600_000, 'fifty missed hours give one run') }) + +// Last run and Retry (#473): FlyBot records how each run's turn went; DSH only says it delivered. +function withAgents(ctx) { + const sent = [] + Object.assign(ctx, { + sessionController: { resolveAgent: async sessionId => ({ agent: { id: sessionId, session: { id: sessionId }, followup: message => sent.push([sessionId, message]), steer() {}, inbox: { nextStep: [], nextTurn: [], remove() {} } } }) }, + sessions: { flush: async () => true }, + }) + return sent +} +const reminder = (id, prompt) => ['[SCHEDULE REMINDER]', 'This is a scheduled message from the user', `schedule_id_json: ${JSON.stringify(id)}`, 'occurrence_at: 2030-01-01T09:00:00.000Z', `reminder_prompt_json: ${JSON.stringify(prompt)}`].join('\n') +function runTurn(ctx, sessionId, turn, message, reason) { + let seq = 0 + const emit = (type, data) => ctx.emitArgs('session/event', { id: sessionId }, { type, seq: ++seq, data }) + emit('turn/start', { turn }) + emit('user/message', message) + emit('turn/end', { turn, reason }) +} +const lastRuns = async call => (await call('schedules', {})).value.tasks.map(task => task.lastRun && [task.lastRun.result, task.lastRun.reason]) + +test('a run fails only when its turn errors; a turn without an answer has run, a stopped one says so', 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: 'Briefing', prompt: 'Sum up', when: DAILY }) + const [id] = schedule.tasks.keys() + assert.deepEqual(await lastRuns(call), [undefined], 'no run yet') + + const message = { id: 'msg-1', content: [{ type: 'text', text: reminder(id, 'Sum up') }], source: { kind: 'schedule' } } + runTurn(ctx, 'm1', 1, message, { kind: 'error', error: { code: 'SERVER', status: 503, message: 'upstream busy' } }) + assert.deepEqual(await lastRuns(call), [['failed', 'SERVER 503: upstream busy']]) + runTurn(ctx, 'm1', 2, { ...message, id: 'msg-2' }, { kind: 'completed' }) + assert.deepEqual(await lastRuns(call), [['ran', undefined]], 'a finished turn is a run, answer or not') + runTurn(ctx, 'm1', 3, { ...message, id: 'msg-3' }, { kind: 'aborted', reason: { kind: 'user' } }) + assert.deepEqual(await lastRuns(call), [['stopped', undefined]]) + // A turn that took in no reminder leaves the record alone. + runTurn(ctx, 'm1', 4, { id: 'msg-4', content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { kind: 'error', error: { code: 'SERVER' } }) + assert.deepEqual(await lastRuns(call), [['stopped', undefined]]) + // DSH delivered a newer reminder the Bot has not taken up yet. + const [task] = schedule.tasks.values() + task.record = { ...task.record, lastDelivery: { scheduledAt: '2030-01-01T09:00:00.000Z', deliveredAt: new Date(Date.now() + 5000).toISOString(), messageId: 'msg-5' } } + assert.deepEqual(await lastRuns(call), [['delivered', undefined]]) + // The record is saved with the team, so it survives a restart. + const saved = () => JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')).scheduleRuns?.[id]?.result + for (let tries = 0; tries < 50 && saved() !== 'stopped'; tries++) await new Promise(resolve => setTimeout(resolve, 20)) + assert.equal(saved(), 'stopped') +}) + +test('Retry runs the same prompt once now in the Bot’s current part, and its turn is the task’s run', async (t) => { + const schedule = scheduleService() + const { ctx, home, call } = setup({ schedule }) + const sent = withAgents(ctx) + 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 the day', when: DAILY }) + const [id] = schedule.tasks.keys() + const retried = await call('schedule-retry', { sessionId: 'w2', id }) + assert.equal(retried.ok, true, JSON.stringify(retried)) + assert.deepEqual(retried.value.tasks.map(task => task.lastRun.result), ['running']) + const [[sessionId, message]] = sent + assert.equal(sessionId, 'w2') + assert.equal(message.source.kind, 'schedule', 'it reads like the task’s own run') + const text = message.content[0].text + assert.equal(reminderText(text), '[Scheduled task] Sum up the day') + assert.equal([...schedule.tasks.values()][0].record.scheduledAt, '2030-01-01T01:00:00.000Z', 'DSH’s next time is untouched') + runTurn(ctx, 'w2', 1, message, { kind: 'completed' }) + assert.deepEqual(await lastRuns(call), [['ran', undefined]]) + + // A group task is retried in the group's Session. + await call('schedule-create', { target: 'g1', title: 'Standup', prompt: 'Where are we?', when: DAILY }) + const groupId = [...schedule.tasks.keys()].at(-1) + assert.equal((await call('schedule-retry', { sessionId: 'g1', id: groupId })).ok, true) + assert.equal(sent.at(-1)[0], 'g1') + // Unknown, foreign and paused tasks are refused. + assert.match((await call('schedule-retry', { sessionId: 'w2', id: 'nope' })).error.message, /No such task/) + assert.match((await call('schedule-retry', { sessionId: 'outside', id })).error.message, /No such task/) + await call('schedule-pause', { sessionId: 'w2', id }) + assert.match((await call('schedule-retry', { sessionId: 'w2', id })).error.message, /Resume the task first/) + assert.equal(sent.length, 2) +}) + +test('a due task DSH does not deliver while the app runs is a failed run, told once like a failed turn', async (t) => { + const schedule = scheduleService() + const { ctx, home, call } = setup({ schedule }) + t.after(async () => { t.mock.restoreAll(); await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) + await call('schedule-create', { target: 'm1', title: 'Briefing', prompt: 'Sum up', when: DAILY }) + const [task] = schedule.tasks.values() + const due = Date.now() - 60_000 + task.record = { ...task.record, scheduledAt: new Date(due).toISOString() } + // First seen due: DSH may be delivering it right now (a time missed while the app was closed). + assert.deepEqual(await lastRuns(call), [undefined]) + const start = Date.now() + t.mock.method(Date, 'now', () => start + 150_000) + assert.deepEqual(await lastRuns(call), [['failed', 'DSH could not deliver it']]) + assert.equal((await call('schedules', {})).value.tasks[0].lastRun.at, due) + const ended = (await call('state', {})).value.work.ended + assert.deepEqual(ended.map(entry => [entry.botId, entry.sessionId, entry.failed]), [['m1', 'm1', 'Scheduled task "Briefing" was not delivered']]) + await call('schedules', {}) + const again = (await call('state', {})).value.work.ended + assert.equal(again.length, 1, 'told once per missed time') + // Delivered after all: the next time is in the future again and the failure stays the last run. + task.record = { ...task.record, scheduledAt: '2030-01-02T01:00:00.000Z' } + assert.deepEqual(await lastRuns(call), [['failed', 'DSH could not deliver it']]) +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index e07f093a..185fde35 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -29,7 +29,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | `core/src/client/face-at-filter.js` | A face room's `@` menu without DSH's Sessions rows (#305): `planFaceMenu` (rows to hide, where the highlight goes, when to dismiss), `faceMenuCss`, `watchFaceMenu`. Tests: `core/test/face-at-filter.test.js`. | | `core/test/new-chat-menu.test.js` | **+** menu rows (#283): an action row's description is its own line under the title, an inline Bot hint is capped, so hovering never hides a title. | | `core/test/new-chat.test.js` | Picker filters and the **+** short action menu (`data-menu`; New chat, New Bot, New group chat, plugin actions, then Add coding agent; #385 marks, verbs and hints). `ungrantedLocalFix`, `copiedToClipboard` (no clipboard or a refused write is a failure) and the refusal card (invite and leave-out actions, the copy-by-hand link field, no grant written by the client). | -| `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). 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). 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) 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). 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`). Tests: `core/test/inbox.test.js`. | ## Invariants diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index da72f545..365825e3 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -42,7 +42,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). | -| `core/src/host/schedules.js` | Scheduled tasks through DSH's schedule service: list, create, edit, pause/resume and delete for Settings → Scheduled tasks, and moving a Bot's tasks to its fresh part (#354, see **Scheduled tasks: edit, pause, missed runs**). Tests: `core/test/schedules.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 (#354, #473, see **Scheduled tasks: edit, pause, missed runs** and **Scheduled tasks: last run and Retry**). Tests: `core/test/schedules.test.js`. | | `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/shared/remote-label.js` | Owner-badge labels for a friend's Bot and “Shared room from …” titles. `isListedRemoteChat` / `listedChatBots` keep gone, hidden, and home-level (`@something.home`) remotes out of the sidebar, the **发给** picker, mentions, and `list_bots` friends lines; history stays on disk. | @@ -211,6 +211,19 @@ DSH 0.2 offers the schedule service only through its optional bundle `@deepseek- - **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: last run and Retry (#473) + +DSH's catalog carries `lastDelivery`, an inbox receipt (the reminder reached the Session), not how the Bot's turn went. FlyBot records each task's last run in `state.scheduleRuns[]` (`{ at, result, reason?, messageId?, missed? }`, the newest 200 tasks), saved with the team. + +- **A run.** `turns.js` hands every `user/message` whose source is `schedule` to `rt.scheduleRunStarted`, which reads the task ids from DSH's framing (`schedule_id_json`, or each `reminders_json` entry of a batch) and records `running`. The turn's `turn/end` settles it (`rt.scheduleRunEnded`): `failed` with the turn's error line when the turn errored, `stopped` when the user stopped it, `ran` otherwise. A group task's run is the group Session's turn; it fails when a member's turn in that round failed (`rt.scheduleRoundFailed`, from `group_relay`). +- **What counts as failed** (Owner decision 2026-10-20): DSH could not deliver the reminder, or the Bot's turn errored. A turn that ends without an answer has run. +- **Not delivered.** DSH logs a delivery it could not make and does not retry it by itself (its runtime: "refused admission does not retry automatically"), so the task stays active with its time in the past. A check every minute (and on each `schedules` read) notes such a task the first time it sees it and calls it failed ("DSH could not deliver it") when it is still due 2 minutes later, once per missed time. Seeing it due once is not enough: right after the app starts, DSH is still delivering the times it missed while closed. +- **The row.** `schedules` adds `lastRun: { at, result, reason? }` to each task: FlyBot's record, or `delivered` when DSH's `lastDelivery` is a newer message than the record (the Bot has not taken it up yet). +- **Retry** (`schedule-retry`, `{ sessionId, id }`, the same ownership check as Pause). It sends the task's prompt once now to the Bot's current part (or the group's Session) as a `schedule`-source message in DSH's reminder framing, so the turn is that task's run, a group gets its prompt (`reminderText`), and the connector verdict is the scheduled one (`turn-origin.js`). DSH's own times are untouched: no missed time is delivered again. A paused task is refused ("Resume the task first"). +- **Needs you.** A failed turn of a scheduled run is already one of the ended turns (`work.ended`) that the finish/fail notifications read (#353). A reminder DSH did not deliver ran no turn, so `schedules.js` adds it there itself (`rt.noteRunFailure`, `failed: Scheduled task "" was not delivered`), with the same exclusions (a friend's Bot, a room shared with other people). The Agent inbox reads failed ended turns from that same list (#471); no second event path. +- **Limits** (#56) are not part of this step. +- Tests: `core/test/schedules.test.js` (`a run fails only when its turn errors…`, `Retry runs the same prompt once now…`, `a due task DSH does not deliver…`). + ### Discarded promise `.catch` (#213) A discarded `.catch(() => {})` in the host sources either starts with `void` (fire and forget) or carries a one-line reason comment (an awaited wait); a `.catch(() => undefined)` whose value is used stays. `check-empty-catch` enforces it (see `docs/code-standards.md`). diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index ad614280..c91ae2c2 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -833,9 +833,17 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Scheduled tasks': '定时任务', 'Every day': '每天', 'Every {count} days': '每 {count} 天', -@@ -785,6 +785,7 @@ const ZH = { +@@ -784,7 +784,15 @@ const ZH = { + 'Every weekend at {time}': '每个周末 {time}', 'On the schedule {expression}': '按计划 {expression}', 'Next: {time}': '下次 {time}', ++ 'Running since {time}': '运行中,{time} 开始', ++ 'Last run {time}': '上次运行 {time}', ++ 'Last run {time} · Stopped': '上次运行 {time} · 已停止', ++ 'Last run {time} · Failed: {reason}': '上次运行 {time} · 失败:{reason}', ++ 'Run the task once now': '现在运行一次这个任务', ++ 'DSH could not deliver it': 'DSH 没能送达', ++ 'Resume the task first': '请先继续这个任务', Finished: '已结束', + Resume: '继续', 'in {name}': '在 {name}', @@ -1105,12 +1113,13 @@ 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,10 @@ +@@ -1,10 +1,11 @@ // 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). ++// 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). import { createElement as h, Fragment, useEffect, useState } from 'react' import { BotAvatar, RoomAvatar } from './mark.js' -import { ClockIcon, PlusIcon, TrashIcon } from './icons.js' @@ -1119,7 +1128,7 @@ diff --git a/src/client/schedules.js b/src/client/schedules.js import { Segmented } from './motion.js' import { dateLocale, hostText, t } from './i18n.js' -@@ -70,9 +70,10 @@ export function timingText(task) { +@@ -70,9 +70,22 @@ export function timingText(task) { default: return '' } } @@ -1128,11 +1137,23 @@ diff --git a/src/client/schedules.js b/src/client/schedules.js + : task.status === 'active' ? t('Next: {time}', { time: dayAndTime(Date.parse(task.scheduledAt)) }) : t('Finished')) -function TaskRow({ task, actions, onView, showBot, roster }) { ++// The last run, in one line: when, and how it went (src/host/schedules.js). ++export function lastRunText(run) { ++ if (run === undefined || !Number.isFinite(run.at)) return '' ++ const time = dayAndTime(run.at) ++ switch (run.result) { ++ case 'running': return t('Running since {time}', { time }) ++ case 'stopped': return t('Last run {time} · Stopped', { time }) ++ case 'failed': return t('Last run {time} · Failed: {reason}', { time, reason: hostText(run.reason || 'Unknown error') }) ++ default: return t('Last run {time}', { time }) ++ } ++} ++ +function TaskRow({ task, actions, onView, onEdit, showBot, roster }) { const [confirming, setConfirming] = useState(false) const [busy, setBusy] = useState(false) useEffect(() => { -@@ -85,14 +85,22 @@ function TaskRow({ task, actions, onView, showBot, roster }) { +@@ -85,14 +85,33 @@ function TaskRow({ task, actions, onView, showBot, roster }) { setBusy(true) void actions.scheduleDelete(task.sessionId, task.id).then(onView, () => setBusy(false)) } @@ -1140,17 +1161,29 @@ diff --git a/src/client/schedules.js b/src/client/schedules.js + setBusy(true) + const step = task.status === 'paused' ? actions.scheduleResume : actions.schedulePause + void step(task.sessionId, task.id).then((next) => { setBusy(false); onView(next) }, () => setBusy(false)) ++ } ++ const [retryError, setRetryError] = useState('') ++ const retry = () => { ++ setBusy(true) ++ setRetryError('') ++ void actions.scheduleRetry(task.sessionId, task.id).then((next) => { setBusy(false); onView(next) }, (error) => { setBusy(false); setRetryError(hostText(error?.message ?? String(error))) }) + } const room = task.roomId ? roster.roomsById[task.roomId] : undefined - return h('div', { className: 'bt-set-row bt-task', 'data-done': task.status === 'active' ? undefined : '', 'data-busy': busy || undefined }, + const stoppable = task.status === 'active' || task.status === 'paused' ++ const failed = task.lastRun?.result === 'failed' ++ const last = lastRunText(task.lastRun) + return h('div', { className: 'bt-set-row bt-task', 'data-done': stoppable ? undefined : '', 'data-paused': task.status === 'paused' || undefined, 'data-busy': busy || undefined }, showBot ? h(BotAvatar, { bot: roster.byId[task.botId], size: 28, badge: false, live: false }) : null, h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-task-title' }, task.title), h('span', { className: 'bt-set-hint' }, [timingText(task), room ? t('in {name}', { name: room.name }) : ''].filter(Boolean).join(' · ')), - task.prompt && task.prompt !== task.title ? h('span', { className: 'bt-task-prompt' }, task.prompt) : null), +- task.prompt && task.prompt !== task.title ? h('span', { className: 'bt-task-prompt' }, task.prompt) : null), ++ task.prompt && task.prompt !== task.title ? h('span', { className: 'bt-task-prompt' }, task.prompt) : null, ++ last ? h('span', { className: 'bt-task-last', 'data-failed': failed || undefined }, last) : null, ++ retryError ? h('span', { className: 'bt-task-last', 'data-failed': '', role: 'alert' }, retryError) : null), 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, + 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', { @@ -1344,13 +1377,15 @@ 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)))))) } -@@ -239,6 +239,10 @@ export const SCHEDULE_CSS = ` +@@ -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)} .bt-task-remove svg{width:14px;height:14px} +.bt-task-tool{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} +.bt-task-tool svg{width:14px;height:14px} +.bt-task-pause{height:26px;color:var(--bt-ink-2)} ++.bt-task-last{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow-wrap:anywhere} ++.bt-task-last[data-failed]{color:#c21d2e} +.bt-task[data-paused] .bt-task-title,.bt-task[data-paused] .bt-task-prompt{color:var(--bt-ink-3)} .bt-task-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135} .bt-task-head{display:flex;align-items:center;gap:6px} @@ -2638,7 +2673,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,6 +5,15 @@ +@@ -5,8 +5,32 @@ // 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. @@ -2651,9 +2686,55 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js +// creates it again on the Bot's current part. A missed run is DSH's: after the app was +// closed a recurring task runs once, for its latest missed time, never once per missed +// time (docs/subsystems/core-host.md). ++// ++// 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 ++// only when DSH could not deliver it (the task stays due past MISSED_MS while the app runs) ++// or the Bot's turn errored; a turn that ends without an answer has run. Retry sends the ++// 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 { createUserMessage } from '@deepseek-ai/dsh-llm' ++import { textOf } from './text.js' const MAX_TITLE = 120 ++// How long a due task may stay undelivered while the app runs before it counts as failed. ++const MISSED_MS = 120_000 ++const CHECK_MS = 60_000 ++const RUNS_KEPT = 200 const TIME = /^([01]\d|2[0-3]):[0-5]\d$/ + + const invalid = message => Object.assign(new Error(message), { code: 'bot/invalid' }) +@@ -28,6 +28,28 @@ export function reminderText(text) { + return lines.length === 0 ? text : lines.map(prompt => `[Scheduled task] ${prompt}`).join('\n') + } + ++// The task ids a reminder's framing names (one, or each of a batch). ++export function reminderIds(text) { ++ const one = /^schedule_id_json: (.*)$/m.exec(text) ++ const batch = /^reminders_json: (.*)$/m.exec(text) ++ try { ++ const ids = one ? [JSON.parse(one[1])] : batch ? JSON.parse(batch[1]).map(entry => entry?.schedule_id) : [] ++ return ids.filter(id => typeof id === 'string' && id !== '') ++ } catch { ++ return [] ++ } ++} ++ ++// A run again of one task, in DSH's reminder framing, so the turn reads it like the task's ++// own run and a group gets its prompt (reminderText). ++export const retryText = (record, at) => [ ++ '[SCHEDULE REMINDER]', ++ 'This is a scheduled message from the user', ++ `schedule_id_json: ${JSON.stringify(record.id)}`, ++ `occurrence_at: ${new Date(at).toISOString()}`, ++ `reminder_prompt_json: ${JSON.stringify(record.prompt)}`, ++].join('\n') ++ + // The create request that gives a stored record its timing again, for a move. + function requestOf(record) { + const base = { title: record.title, prompt: record.prompt } @@ -39,6 +39,28 @@ function requestOf(record) { return null } @@ -2683,7 +2764,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,6 +89,35 @@ export function install(rt) { +@@ -89,36 +89,278 @@ 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) @@ -2701,6 +2782,98 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js + const sessionFor = owner => (owner.botId !== undefined ? rt.chatOf(owner.botId) : owner.roomId) + const ensureService = () => { if (service === null) throw invalid('Automation tasks are off in DSH') } + ++ // Each task's last run, by its DSH id: { at, result, reason?, messageId?, missed? }. ++ const runs = () => { ++ if (state.scheduleRuns === null || typeof state.scheduleRuns !== 'object' || Array.isArray(state.scheduleRuns)) state.scheduleRuns = {} ++ return state.scheduleRuns ++ } ++ const noteRun = (id, run) => { ++ const all = runs() ++ delete all[id] ++ all[id] = run ++ const ids = Object.keys(all) ++ if (ids.length > RUNS_KEPT) for (const old of ids.slice(0, ids.length - RUNS_KEPT)) delete all[old] ++ void rt.save().catch(() => {}) ++ } ++ // What a row shows: FlyBot's record, or DSH's newer delivery the Bot has not taken up yet. ++ // A run still marked running from before this start has no known end: it shows as a run. ++ const startedAt = Date.now() ++ const lastRunOf = (entry) => { ++ const run = Object.hasOwn(runs(), entry.id) ? runs()[entry.id] : undefined ++ const delivered = entry.lastDelivery ++ if (delivered !== undefined && run?.messageId !== delivered.messageId && Date.parse(delivered.deliveredAt) > (run?.at ?? 0)) { ++ return { at: Date.parse(delivered.deliveredAt), result: 'delivered' } ++ } ++ if (run === undefined) return undefined ++ const result = run.result === 'running' && run.at < startedAt ? 'delivered' : run.result ++ return { at: run.at, result, ...(run.reason ? { reason: run.reason } : {}) } ++ } ++ ++ // The turns that run a task, by Session: the task ids and a group round's failure. ++ /** @type {Map} */ ++ const runningRuns = new Map() ++ const runStarted = (sessionId, message) => { ++ const ids = reminderIds(textOf(message?.content)) ++ if (ids.length === 0) return ++ const at = Date.now() ++ const current = runningRuns.get(sessionId) ++ runningRuns.set(sessionId, { ...current, ids: [...new Set([...(current?.ids ?? []), ...ids])] }) ++ for (const id of ids) noteRun(id, { at, result: 'running', ...(typeof message?.id === 'string' ? { messageId: message.id } : {}) }) ++ } ++ // `failed`: the turn's error line (turns.js failureLine); `aborted`: the user stopped it. ++ const runEnded = (sessionId, { failed, aborted = false } = {}) => { ++ const entry = runningRuns.get(sessionId) ++ if (entry === undefined) return ++ runningRuns.delete(sessionId) ++ const reason = failed ?? entry.failed ++ for (const id of entry.ids) { ++ const run = runs()[id] ++ if (run === undefined) continue ++ noteRun(id, { ...run, result: reason !== undefined ? 'failed' : aborted ? 'stopped' : 'ran', ...(reason !== undefined ? { reason: reason.slice(0, 200) } : {}) }) ++ } ++ } ++ // A group round whose members' turns failed: the task's run in that group failed. ++ const roundFailed = (sessionId, names) => { ++ const entry = runningRuns.get(sessionId) ++ if (entry !== undefined && names.length > 0) entry.failed = `${names.join(', ')} could not answer: the model call failed` ++ } ++ ++ // A due task DSH has not delivered: seen due at one check and still due MISSED_MS later ++ // (DSH delivers a time missed while the app was closed as soon as it starts). ++ /** @type {Map} */ ++ const due = new Map() ++ async function checkMissed(now = Date.now()) { ++ if (service === null) return ++ const seen = new Set() ++ for (const entry of await service.catalog()) { ++ if (entry.status !== 'active' || !(Date.parse(entry.scheduledAt) <= now) || entry.lastDelivery?.scheduledAt === entry.scheduledAt) continue ++ const owner = ownerOf(entry.sessionId) ++ if (!known(owner)) continue ++ seen.add(entry.id) ++ const first = due.get(entry.id) ++ if (first?.scheduledAt !== entry.scheduledAt) { due.set(entry.id, { scheduledAt: entry.scheduledAt, since: now }); continue } ++ if (now - first.since < MISSED_MS || runs()[entry.id]?.missed === entry.scheduledAt) continue ++ noteRun(entry.id, { at: Date.parse(entry.scheduledAt), result: 'failed', reason: 'DSH could not deliver it', missed: entry.scheduledAt }) ++ // The same event the finished and failed turns use (#353), for my own Bots only. ++ const room = owner.roomId === undefined ? undefined : state.rooms[owner.roomId] ++ const bot = state.bots[owner.botId ?? room?.admin ?? room?.members?.[0]] ++ if (bot === undefined || bot.remote !== undefined || room?.origin !== undefined) continue ++ rt.noteRunFailure?.({ ++ id: `schedule:${entry.id}:${entry.scheduledAt}`, ++ botId: bot.id, ++ ...(room ? { roomId: room.id } : { sessionId: entry.sessionId }), ++ at: now, ++ failed: `Scheduled task "${entry.title}" was not delivered`.slice(0, 160), ++ }) ++ } ++ for (const id of due.keys()) if (!seen.has(id)) due.delete(id) ++ } ++ ctx.effect(() => { ++ const timer = setInterval(() => { void checkMissed().catch(error => rt.warn('ds-bot: checking scheduled tasks failed: %s', error?.message ?? error)) }, CHECK_MS) ++ timer.unref?.() ++ return () => clearInterval(timer) ++ }, 'ds-bot: missed scheduled runs') ++ + // The form's name, instruction and target, checked as creation checks them. + function contentOf(body) { + const bot = state.bots[body.target] @@ -2719,15 +2892,22 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js async function schedulesView() { if (service === null) return { available: false, tasks: [] } -@@ -98,27 +98,112 @@ export function install(rt) { ++ // The page asks every 30 seconds, so a missed run shows without waiting for the timer. ++ await checkMissed().catch(error => rt.warn('ds-bot: checking scheduled tasks failed: %s', error?.message ?? error)) + const tasks = [] + for (const entry of await service.catalog()) { + const owner = ownerOf(entry.sessionId) if (!known(owner)) continue - tasks.push({ ...entry, ...owner }) - } +- tasks.push({ ...entry, ...owner }) ++ const lastRun = lastRunOf(entry) ++ tasks.push({ ...entry, ...owner, ...(lastRun ? { lastRun } : {}) }) ++ } + for (const entry of Object.values(paused())) { + const owner = ownerOf(entry.sessionId) + if (!known(owner)) continue -+ tasks.push({ ...entry.record, id: entry.id, sessionId: entry.sessionId, status: 'paused', pausedAt: entry.pausedAt, ...owner }) -+ } ++ 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 } } @@ -2809,6 +2989,37 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js + await rt.save() + return schedulesView() + } ++ ++ // Retry: the task's prompt once now, to the Bot's current part or the group, as a run of ++ // that task. DSH's own times are untouched: no missed time is delivered again. ++ async function retrySchedule(body) { ++ ensureService() ++ const sessionId = String(body.sessionId ?? '') ++ const owner = ownerOf(sessionId) ++ if (!known(owner)) throw invalid('No such task') ++ if (pausedEntry(sessionId, String(body.id ?? '')) !== undefined) throw invalid('Resume the task first') ++ const record = await findRecord(sessionId, String(body.id ?? '')) ++ if (record === undefined) throw invalid('No such task') ++ const target = sessionFor(owner) ++ const at = Date.now() ++ await rt.ensureCapabilities?.(target) ++ const agent = await rt.resolveAgent(target) ++ // Noted first: the turn that takes the message up may start (and end) before the flush. ++ const before = Object.hasOwn(runs(), record.id) ? runs()[record.id] : undefined ++ noteRun(record.id, { at, result: 'running' }) ++ try { ++ agent.followup(createUserMessage({ content: [{ type: 'text', text: retryText(record, at) }], source: { kind: 'schedule' } })) ++ await ctx.sessions.flush(agent.session) ++ } catch (error) { ++ if (before !== undefined) noteRun(record.id, before) ++ else { ++ delete runs()[record.id] ++ void rt.save().catch(() => {}) ++ } ++ throw error ++ } ++ return schedulesView() ++ } + const endpoints = { schedules: () => schedulesView(), @@ -2831,6 +3042,7 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js + 'schedule-update': body => updateSchedule(body), + 'schedule-pause': body => pauseSchedule(body), + 'schedule-resume': body => resumeSchedule(body), ++ 'schedule-retry': body => retrySchedule(body), async 'schedule-delete'(body) { - if (service === null) throw invalid('Automation tasks are off in DSH') + ensureService() @@ -2843,6 +3055,20 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js await service.delete({ sessionId: body.sessionId, id: body.id }) return schedulesView() }, +@@ -147,5 +147,12 @@ export function install(rt) { + } + } + +- Object.assign(rt, { scheduleEndpoint: (endpoint, body) => endpoints[endpoint](body ?? {}), moveSchedules }) ++ Object.assign(rt, { ++ scheduleEndpoint: (endpoint, body) => endpoints[endpoint](body ?? {}), ++ moveSchedules, ++ scheduleRunStarted: runStarted, ++ scheduleRunEnded: runEnded, ++ scheduleRoundFailed: roundFailed, ++ checkMissedSchedules: checkMissed, ++ }) + } diff --git a/src/host/time-context.js b/src/host/time-context.js --- a/src/host/time-context.js +++ b/src/host/time-context.js @@ -2919,7 +3145,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js markExchangeTurn(sessionId, entry.turn) } } -@@ -238,6 +238,31 @@ export function install(rt) { +@@ -238,6 +238,33 @@ export function install(rt) { return { ...(text ? { text: text.trim().slice(0, 300) } : {}), ...(code ? { code: code.trim().slice(0, 400) } : {}) } } @@ -2947,6 +3173,8 @@ diff --git a/src/host/turns.js b/src/host/turns.js + }].slice(-ENDED_KEPT) + } + const endedView = () => ended ++ // A scheduled run DSH could not deliver never became a turn; it joins them as a failure (#473). ++ const noteRunFailure = (entry) => { ended = [...ended, entry].slice(-ENDED_KEPT) } + // Where a Session speaks, for the diagnostics log. const placeOf = (id) => { @@ -2962,10 +3190,12 @@ diff --git a/src/host/turns.js b/src/host/turns.js if (entry.pending !== undefined) { claimTrigger(id, entry, entry.pending) entry.pending = undefined -@@ -300,6 +300,15 @@ export function install(rt) { +@@ -300,6 +300,17 @@ export function install(rt) { break case 'user/message': { const source = event.data?.source ++ // A scheduled task's run (schedules.js, #473): its outcome is this turn's. ++ if (source?.kind === 'schedule') rt.scheduleRunStarted?.(id, event.data) + // The strictest verdict of everything the turn took in; one 'other' stays for the whole turn. + const verdict = originOfSource(source, rt.botOf(rt.selfOf(id))) + if (verdict !== undefined) { @@ -3029,7 +3259,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js entry.turn = undefined entry.info = undefined entry.texts = [] -@@ -401,6 +401,8 @@ export function install(rt) { +@@ -401,9 +401,12 @@ export function install(rt) { entry.calls = undefined entry.lastText = undefined entry.lastCalled = undefined @@ -3038,6 +3268,10 @@ diff --git a/src/host/turns.js b/src/host/turns.js const reason = event.data?.reason const failure = reason?.kind === 'error' && !switched ? turnFailure(reason.error) : undefined if (failure !== undefined) noteTurnFailure(id, failure) ++ rt.scheduleRunEnded?.(id, { failed: failure === undefined ? undefined : failureLine(failure), aborted: reason?.kind === 'aborted' }) + if (switched) break + // A model that writes its tool call as text ends the turn as if it were done, + // though nothing ran. It gets one more turn on the same trigger, so a waiting @@ -411,7 +411,8 @@ export function install(rt) { // the call. const wroteCall = failure === undefined && reason?.kind === 'completed' && lastCalled !== true && writesToolCall(lastText) @@ -3120,7 +3354,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js + return found + } + -+ Object.assign(rt, { botTurnOrigin, live, liveOf, roomWaiters, roomProgress, roomInbox, activityView, workView, roomWorkView, endedView, callDetail, stopRoomTurn }) ++ Object.assign(rt, { botTurnOrigin, live, liveOf, roomWaiters, roomProgress, roomInbox, activityView, workView, roomWorkView, endedView, noteRunFailure, callDetail, stopRoomTurn }) } diff --git a/src/host/usage.js b/src/host/usage.js --- a/src/host/usage.js @@ -4007,7 +4241,7 @@ diff --git a/test/home-lock.test.js b/test/home-lock.test.js diff --git a/test/install.test.js b/test/install.test.js --- a/test/install.test.js +++ b/test/install.test.js -@@ -91,12 +91,19 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { +@@ -91,12 +91,20 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { 'on llm/stream', 'effect ds-bot: load secrets', 'effect ds-bot: connectors', @@ -4016,6 +4250,7 @@ diff --git a/test/install.test.js b/test/install.test.js + 'on agent/created', 'on agent/created', 'effect ds-bot: capabilities', ++ 'effect ds-bot: missed scheduled runs', + 'effect ds-bot: automation tasks default', 'effect ds-bot: usage ledger', 'on llm/stream', @@ -4540,7 +4775,7 @@ diff --git a/test/schedules.test.js b/test/schedules.test.js if (tasks.get(id)?.sessionId !== sessionId) return { id, deleted: false, code: 'schedule_not_found' } tasks.delete(id) return { id, deleted: true } -@@ -121,3 +121,146 @@ test('a due reminder reaches a group as its prompts', () => { +@@ -121,3 +121,249 @@ 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') }) @@ -4687,6 +4922,109 @@ diff --git a/test/schedules.test.js b/test/schedules.test.js + assert.ok(Date.parse(hourly.nextScheduledAt) > created + 50 * 3600_000 + 60_000) + assert.ok(Date.parse(hourly.occurrenceAt) > created + 49 * 3600_000, 'fifty missed hours give one run') +}) ++ ++// Last run and Retry (#473): FlyBot records how each run's turn went; DSH only says it delivered. ++function withAgents(ctx) { ++ const sent = [] ++ Object.assign(ctx, { ++ sessionController: { resolveAgent: async sessionId => ({ agent: { id: sessionId, session: { id: sessionId }, followup: message => sent.push([sessionId, message]), steer() {}, inbox: { nextStep: [], nextTurn: [], remove() {} } } }) }, ++ sessions: { flush: async () => true }, ++ }) ++ return sent ++} ++const reminder = (id, prompt) => ['[SCHEDULE REMINDER]', 'This is a scheduled message from the user', `schedule_id_json: ${JSON.stringify(id)}`, 'occurrence_at: 2030-01-01T09:00:00.000Z', `reminder_prompt_json: ${JSON.stringify(prompt)}`].join('\n') ++function runTurn(ctx, sessionId, turn, message, reason) { ++ let seq = 0 ++ const emit = (type, data) => ctx.emitArgs('session/event', { id: sessionId }, { type, seq: ++seq, data }) ++ emit('turn/start', { turn }) ++ emit('user/message', message) ++ emit('turn/end', { turn, reason }) ++} ++const lastRuns = async call => (await call('schedules', {})).value.tasks.map(task => task.lastRun && [task.lastRun.result, task.lastRun.reason]) ++ ++test('a run fails only when its turn errors; a turn without an answer has run, a stopped one says so', 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: 'Briefing', prompt: 'Sum up', when: DAILY }) ++ const [id] = schedule.tasks.keys() ++ assert.deepEqual(await lastRuns(call), [undefined], 'no run yet') ++ ++ const message = { id: 'msg-1', content: [{ type: 'text', text: reminder(id, 'Sum up') }], source: { kind: 'schedule' } } ++ runTurn(ctx, 'm1', 1, message, { kind: 'error', error: { code: 'SERVER', status: 503, message: 'upstream busy' } }) ++ assert.deepEqual(await lastRuns(call), [['failed', 'SERVER 503: upstream busy']]) ++ runTurn(ctx, 'm1', 2, { ...message, id: 'msg-2' }, { kind: 'completed' }) ++ assert.deepEqual(await lastRuns(call), [['ran', undefined]], 'a finished turn is a run, answer or not') ++ runTurn(ctx, 'm1', 3, { ...message, id: 'msg-3' }, { kind: 'aborted', reason: { kind: 'user' } }) ++ assert.deepEqual(await lastRuns(call), [['stopped', undefined]]) ++ // A turn that took in no reminder leaves the record alone. ++ runTurn(ctx, 'm1', 4, { id: 'msg-4', content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { kind: 'error', error: { code: 'SERVER' } }) ++ assert.deepEqual(await lastRuns(call), [['stopped', undefined]]) ++ // DSH delivered a newer reminder the Bot has not taken up yet. ++ const [task] = schedule.tasks.values() ++ task.record = { ...task.record, lastDelivery: { scheduledAt: '2030-01-01T09:00:00.000Z', deliveredAt: new Date(Date.now() + 5000).toISOString(), messageId: 'msg-5' } } ++ assert.deepEqual(await lastRuns(call), [['delivered', undefined]]) ++ // The record is saved with the team, so it survives a restart. ++ const saved = () => JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')).scheduleRuns?.[id]?.result ++ for (let tries = 0; tries < 50 && saved() !== 'stopped'; tries++) await new Promise(resolve => setTimeout(resolve, 20)) ++ assert.equal(saved(), 'stopped') ++}) ++ ++test('Retry runs the same prompt once now in the Bot’s current part, and its turn is the task’s run', async (t) => { ++ const schedule = scheduleService() ++ const { ctx, home, call } = setup({ schedule }) ++ const sent = withAgents(ctx) ++ 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 the day', when: DAILY }) ++ const [id] = schedule.tasks.keys() ++ const retried = await call('schedule-retry', { sessionId: 'w2', id }) ++ assert.equal(retried.ok, true, JSON.stringify(retried)) ++ assert.deepEqual(retried.value.tasks.map(task => task.lastRun.result), ['running']) ++ const [[sessionId, message]] = sent ++ assert.equal(sessionId, 'w2') ++ assert.equal(message.source.kind, 'schedule', 'it reads like the task’s own run') ++ const text = message.content[0].text ++ assert.equal(reminderText(text), '[Scheduled task] Sum up the day') ++ assert.equal([...schedule.tasks.values()][0].record.scheduledAt, '2030-01-01T01:00:00.000Z', 'DSH’s next time is untouched') ++ runTurn(ctx, 'w2', 1, message, { kind: 'completed' }) ++ assert.deepEqual(await lastRuns(call), [['ran', undefined]]) ++ ++ // A group task is retried in the group's Session. ++ await call('schedule-create', { target: 'g1', title: 'Standup', prompt: 'Where are we?', when: DAILY }) ++ const groupId = [...schedule.tasks.keys()].at(-1) ++ assert.equal((await call('schedule-retry', { sessionId: 'g1', id: groupId })).ok, true) ++ assert.equal(sent.at(-1)[0], 'g1') ++ // Unknown, foreign and paused tasks are refused. ++ assert.match((await call('schedule-retry', { sessionId: 'w2', id: 'nope' })).error.message, /No such task/) ++ assert.match((await call('schedule-retry', { sessionId: 'outside', id })).error.message, /No such task/) ++ await call('schedule-pause', { sessionId: 'w2', id }) ++ assert.match((await call('schedule-retry', { sessionId: 'w2', id })).error.message, /Resume the task first/) ++ assert.equal(sent.length, 2) ++}) ++ ++test('a due task DSH does not deliver while the app runs is a failed run, told once like a failed turn', async (t) => { ++ const schedule = scheduleService() ++ const { ctx, home, call } = setup({ schedule }) ++ t.after(async () => { t.mock.restoreAll(); await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) ++ await call('schedule-create', { target: 'm1', title: 'Briefing', prompt: 'Sum up', when: DAILY }) ++ const [task] = schedule.tasks.values() ++ const due = Date.now() - 60_000 ++ task.record = { ...task.record, scheduledAt: new Date(due).toISOString() } ++ // First seen due: DSH may be delivering it right now (a time missed while the app was closed). ++ assert.deepEqual(await lastRuns(call), [undefined]) ++ const start = Date.now() ++ t.mock.method(Date, 'now', () => start + 150_000) ++ assert.deepEqual(await lastRuns(call), [['failed', 'DSH could not deliver it']]) ++ assert.equal((await call('schedules', {})).value.tasks[0].lastRun.at, due) ++ const ended = (await call('state', {})).value.work.ended ++ assert.deepEqual(ended.map(entry => [entry.botId, entry.sessionId, entry.failed]), [['m1', 'm1', 'Scheduled task "Briefing" was not delivered']]) ++ await call('schedules', {}) ++ const again = (await call('state', {})).value.work.ended ++ assert.equal(again.length, 1, 'told once per missed time') ++ // Delivered after all: the next time is in the future again and the failure stays the last run. ++ task.record = { ...task.record, scheduledAt: '2030-01-02T01:00:00.000Z' } ++ assert.deepEqual(await lastRuns(call), [['failed', 'DSH could not deliver it']]) ++}) diff --git a/test/tool-cells.test.js b/test/tool-cells.test.js --- a/test/tool-cells.test.js +++ b/test/tool-cells.test.js diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index e02c40ed..5cda2a15 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -1202,7 +1202,7 @@ diff --git a/src/client/index.js b/src/client/index.js endVisit: (id) => { leaveVisits([id]) ui.set((state) => { -@@ -539,15 +539,23 @@ export function apply(ctx) { +@@ -539,15 +539,24 @@ export function apply(ctx) { secretCancel: requestId => after(call('secret-cancel', { requestId })), secretUpdate: (name, scope) => after(call('secret-update', { name, scope })), secretDelete: name => after(call('secret-delete', { name })), @@ -1224,6 +1224,7 @@ diff --git a/src/client/index.js b/src/client/index.js + scheduleUpdate: task => call('schedule-update', task), + schedulePause: (sessionId, id) => call('schedule-pause', { sessionId, id }), + scheduleResume: (sessionId, id) => call('schedule-resume', { sessionId, id }), ++ scheduleRetry: (sessionId, id) => call('schedule-retry', { sessionId, id }), scheduleDelete: (sessionId, id) => call('schedule-delete', { sessionId, id }), exchange: (a, b) => call('exchange', { a, b }).catch(() => []), roomProgress: roomId => call('room-progress', { roomId }).catch(() => []), diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 6bf22bb9..5c603fe1 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -490,7 +490,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js case 'connector-disconnect': case 'connector-retry': await rt.load() -@@ -241,11 +241,21 @@ export function install(rt) { +@@ -241,11 +241,22 @@ export function install(rt) { case 'bot-capability': await rt.load() return ok(await rt.capabilityEndpoint(endpoint, body)) @@ -500,6 +500,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js - case 'schedule-create': + case 'schedule-pause': + case 'schedule-resume': ++ case 'schedule-retry': case 'schedule-delete': await rt.load() return ok(await rt.scheduleEndpoint(endpoint, body)) diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index c1f83416..5dafc4ae 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -473,9 +473,17 @@ diff --git a/client.js b/client.js "Scheduled tasks": "定时任务", "Every day": "每天", "Every {count} days": "每 {count} 天", -@@ -835,6 +835,7 @@ var ZH = { +@@ -834,7 +834,15 @@ var ZH = { + "Every weekend at {time}": "每个周末 {time}", "On the schedule {expression}": "按计划 {expression}", "Next: {time}": "下次 {time}", ++ "Running since {time}": "运行中,{time} 开始", ++ "Last run {time}": "上次运行 {time}", ++ "Last run {time} · Stopped": "上次运行 {time} · 已停止", ++ "Last run {time} · Failed: {reason}": "上次运行 {time} · 失败:{reason}", ++ "Run the task once now": "现在运行一次这个任务", ++ "DSH could not deliver it": "DSH 没能送达", ++ "Resume the task first": "请先继续这个任务", Finished: "已结束", + Resume: "继续", "in {name}": "在 {name}", @@ -1030,6 +1038,7 @@ diff --git a/client.js b/client.js - value = resolved; - for (const listener of [...listeners2]) listener(); - } +- }; +// src/client/mark.js +var import_react = require("react"); +var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; @@ -1066,7 +1075,21 @@ diff --git a/client.js b/client.js + document.documentElement.removeEventListener("pointerleave", onLeave); + }; + }, [enabled]); -+} + } +-var EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], agents: [], exchangeTurns: {}, questions: {}, secrets: [], capabilities: [], capabilityStatuses: {}, secretRequests: {}, models: [], defaultModel: null, modelsKey: null, prefs: {}, byId: {}, roomsById: {}, agentsById: {}, agentOf: {}, ready: false }; +-var isMainOf = (roster, id) => id !== void 0 && (roster.mainBotIds ?? []).includes(id); +-function indexRoster(value) { +- const byId = {}; +- for (const bot of value.bots) { +- for (const id of [...bot.parts ?? [], bot.sessionId ?? bot.id, bot.id]) byId[id] = bot; +- } +- const roomsById = {}; +- for (const room2 of value.rooms) roomsById[room2.id] = room2; +- const agentsById = {}; +- const agentOf = {}; +- for (const agent of value.agents ?? []) { +- agentsById[agent.id] = agent; +- for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; +var FIDGETS = ["hop", "sway", "stretch", "look"]; +function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { + const look = given ?? (bot?.remote ? lookAvoidingLocals(bot) : lookOf(bot)); @@ -1092,30 +1115,35 @@ diff --git a/client.js b/client.js + onAnimationEnd: poked ? (event) => { + if (/^bt-poke/.test(event.animationName)) setPoked(false); + } : void 0 - }; ++ }; + if (look.image) return (0, import_react.createElement)("span", common, (0, import_react.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); + if (look.emoji) { + return (0, import_react.createElement)("span", common, (0, import_react.createElement)("span", { + className: "bt-mark-emoji", + style: { fontSize: Math.max(12, Math.round(size * 0.62)), lineHeight: 1 } + }, look.emoji)); -+ } + } +- return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; + const box = shapeOf(look.shape).size; + return (0, import_react.createElement)("span", common, (0, import_react.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); } --var EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], agents: [], exchangeTurns: {}, questions: {}, secrets: [], capabilities: [], capabilityStatuses: {}, secretRequests: {}, models: [], defaultModel: null, modelsKey: null, prefs: {}, byId: {}, roomsById: {}, agentsById: {}, agentOf: {}, ready: false }; --var isMainOf = (roster, id) => id !== void 0 && (roster.mainBotIds ?? []).includes(id); --function indexRoster(value) { -- const byId = {}; -- for (const bot of value.bots) { -- for (const id of [...bot.parts ?? [], bot.sessionId ?? bot.id, bot.id]) byId[id] = bot; +-function isAgentSession(roster, cache, sessionId) { +- if (sessionId === void 0 || sessionId === null || sessionId === "") return false; +- if (roster.ready) return roster.agentOf[sessionId] !== void 0; +- return cache.has(sessionId); +function StarIcon({ size = 8 }) { + return (0, import_react.createElement)( + "svg", + { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, + (0, import_react.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) + ); -+} + } +-function countCards(state, sessionId, callIds, delta) { +- const own = { ...state[sessionId] }; +- for (const callId of callIds) { +- const count = (own[callId] ?? 0) + delta; +- if (count > 0) own[callId] = count; +- else delete own[callId]; +var personInitial = (name) => (Array.from(String(name ?? "").trim())[0] ?? "").toUpperCase(); +var personColor = (name) => defaultColor(`person:${String(name ?? "").trim().toLowerCase()}`); +function PersonMark({ size = 22, name }) { @@ -1128,13 +1156,7 @@ diff --git a/client.js b/client.js + "aria-hidden": true + }, initial); } -- const roomsById = {}; -- for (const room2 of value.rooms) roomsById[room2.id] = room2; -- const agentsById = {}; -- const agentOf = {}; -- for (const agent of value.agents ?? []) { -- agentsById[agent.id] = agent; -- for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; +- return { ...state, [sessionId]: own }; + return (0, import_react.createElement)( + "span", + { className: "bt-person-mark", style: { width: size, height: size }, "aria-hidden": true }, @@ -1145,10 +1167,93 @@ diff --git a/client.js b/client.js + (0, import_react.createElement)("path", { d: "M3.5 21c0-4.7 3.8-7.6 8.5-7.6s8.5 2.9 8.5 7.6z" }) + ) + ); -+} + } +- +-// src/client/themes.js +-var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; +-var THEMES = { +- deepseek: { +- label: "DeepSeek", +- light: { bg: "#fcfcfc", sidebar: "#f7f7f7", bubble: "#eeeeee", card: "#ffffff", text: "#141414", user: "#070707", userText: "#fcfcfc", accent: "#4d6bfe" }, +- dark: { bg: "#141414", sidebar: "#1b1b1b", bubble: "#262626", card: "#1f1f1f", text: "#f2f2f2", user: "#f2f2f2", userText: "#141414", accent: "#5b7bff" } +- }, +- mono: { +- label: "Mono", +- light: { bg: "#ffffff", sidebar: "#f9f9f9", bubble: "#f0f0f0", card: "#ffffff", text: "#0d0d0d", user: "#0d0d0d", userText: "#ffffff", accent: "#0d0d0d" }, +- dark: { bg: "#0f0f0f", sidebar: "#171717", bubble: "#242424", card: "#1c1c1c", text: "#f4f4f4", user: "#f4f4f4", userText: "#0f0f0f", accent: "#f4f4f4" } +- }, +- paper: { +- label: "Paper", +- light: { bg: "#faf9f5", sidebar: "#f3f0e8", bubble: "#ebe7dc", card: "#ffffff", text: "#1f1e1b", user: "#2b2a26", userText: "#faf9f5", accent: "#d97757" }, +- dark: { bg: "#1f1e1b", sidebar: "#262521", bubble: "#34322d", card: "#2a2925", text: "#f3f0e8", user: "#f3f0e8", userText: "#1f1e1b", accent: "#e08a6d" } +- }, +- moonlight: { +- label: "Moonlight", +- light: { bg: "#f7f9fc", sidebar: "#eef2f8", bubble: "#e5ebf5", card: "#ffffff", text: "#121826", user: "#1e3a8a", userText: "#ffffff", accent: "#1e86ff" }, +- dark: { bg: "#0b1020", sidebar: "#10172a", bubble: "#1a2340", card: "#141c33", text: "#e8edf7", user: "#3b6fd8", userText: "#ffffff", accent: "#4f9dff" } +- } +-}; +-var rgba = (hex, alpha) => `rgba(${[1, 3, 5].map((index) => parseInt(hex.slice(index, index + 2), 16)).join(",")},${alpha})`; +-function themeVars(mode, dark, accent = mode.accent) { +- return { +- "--bt-ink": mode.text, +- "--bt-ink-2": rgba(mode.text, 0.6), +- "--bt-ink-3": rgba(mode.text, 0.36), +- "--bt-bubble": mode.bubble, +- "--bt-sidebar": mode.sidebar, +- "--bt-main": mode.bg, +- "--bt-card": mode.card, +- "--bt-line": rgba(mode.text, 0.08), +- "--bt-line-2": rgba(mode.text, 0.15), +- "--bt-hover": dark ? "rgba(160,160,160,.12)" : "rgba(119,119,119,.09)", +- "--bt-active": dark ? "rgba(160,160,160,.2)" : "rgba(119,119,119,.17)", +- "--bt-accent": accent, +- "--bt-accent-ink": luminance(accent) > 0.45 ? "#141414" : "#ffffff", +- "--bt-user": mode.user, +- "--bt-user-ink": mode.userText, +- "--bt-tag-bg": rgba(mode.text, dark ? 0.09 : 0.06), +- "--bt-tag-ink": rgba(mode.text, 0.58) +- }; +function AdminStar() { + return (0, import_react.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react.createElement)(StarIcon, { size: 7 })); -+} + } +-var declarations = (vars) => Object.entries(vars).map(([name, value]) => `${name}:${value}`).join(";"); +-var themeCss = (theme, accent) => `html body{${declarations(themeVars(theme.light, false, accent ?? theme.light.accent))}} +-html body[data-ds-dark-theme]{${declarations(themeVars(theme.dark, true, accent ?? theme.dark.accent))}} +-${theme.css ?? ""}`; +-var shellTokens = ({ light, dark }) => ({ +- "--dsw-alias-bg-base": { light: light.bg, dark: dark.bg }, +- "--dsw-specific-sidebar-fill": { light: light.sidebar, dark: dark.sidebar }, +- "--dsw-specific-input-major": { light: light.bg, dark: dark.sidebar }, +- "--dsw-alias-label-primary": { light: light.text, dark: dark.text }, +- "--dsw-alias-label-secondary": { light: rgba(light.text, 0.74), dark: rgba(dark.text, 0.74) }, +- "--dsw-alias-label-tertiary": { light: rgba(light.text, 0.6), dark: rgba(dark.text, 0.6) } +-}); +-var customThemes = {}; +-var registry = createSource(0); +-var isPoint = (point) => Array.isArray(point) && point.length === 2 && point.every(Number.isFinite); +-function registerShape(id, shape) { +- if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Shape ids are lowercase words"); +- if (typeof shape?.body !== "function" || typeof shape.body("#000") !== "string") throw new Error("A shape needs body(paint) returning SVG markup"); +- const at = shape.eyes?.at; +- if (!Array.isArray(at) || at.length !== 2 || !at.every(isPoint)) throw new Error("A shape needs eyes.at: two [x, y] points"); +- const size = Number.isFinite(shape.size) && shape.size > 0 ? shape.size : 100; +- const pick2 = (key, fallback) => Number.isFinite(shape.eyes[key]) ? shape.eyes[key] : fallback; +- const eyes = at.map(([x, y]) => [x, y, pick2("w", size * 0.1), pick2("h", size * 0.23), pick2("tilt", -27)]); +- const previous = SHAPES[id]; +- SHAPES[id] = { +- label: String(shape.label ?? id), +- size, +- body: shape.body, +- eyes, +- ...typeof shape.wrap === "string" ? { wrap: shape.wrap } : {} +- }; +- const refresh = () => { +- markupCache.clear(); +- maskCache.delete(id); +- registry.set((value) => value + 1); +- }; +- refresh(); +function MainStar() { + return (0, import_react.createElement)( + "svg", @@ -1194,15 +1299,10 @@ diff --git a/client.js b/client.js + const size = frame * 5 / 9; + const rest = frame - size; + return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; - } -- return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; ++ } + const half = frame / 2; + return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; - } --function isAgentSession(roster, cache, sessionId) { -- if (sessionId === void 0 || sessionId === null || sessionId === "") return false; -- if (roster.ready) return roster.agentOf[sessionId] !== void 0; -- return cache.has(sessionId); ++} +function roomAvatarPeople(room2, roster) { + const members = (room2?.members ?? []).map((id) => roster?.byId?.[id]).filter(Boolean); + const seen = new Set(members.map((bot) => bot.id)); @@ -1235,13 +1335,7 @@ diff --git a/client.js b/client.js + } + if (members.length === 1 && remotes.length === 0) return [members[0], null]; + return [...members, ...remotes]; - } --function countCards(state, sessionId, callIds, delta) { -- const own = { ...state[sessionId] }; -- for (const callId of callIds) { -- const count = (own[callId] ?? 0) + delta; -- if (count > 0) own[callId] = count; -- else delete own[callId]; ++} +function RoomAvatar({ room: room2, roster, size = 36 }) { + const people2 = roomAvatarPeople(room2, roster); + const seat = (bot, seatSize, key) => bot ? (0, import_react.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); @@ -1253,8 +1347,7 @@ diff --git a/client.js b/client.js + people2.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), + people2.length > 3 ? (0, import_react.createElement)("span", { className: "bt-stack-more" }, `+${people2.length - 3}`) : null + ); - } -- return { ...state, [sessionId]: own }; ++ } + const seats = clusterSeats(Math.min(people2.length, 4), size); + const overflow = people2.length > 4 ? people2.length - 3 : 0; + const gap = size / 14; @@ -1273,97 +1366,15 @@ diff --git a/client.js b/client.js + return (0, import_react.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); + }) + ); - } - --// src/client/themes.js --var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; --var THEMES = { -- deepseek: { -- label: "DeepSeek", -- light: { bg: "#fcfcfc", sidebar: "#f7f7f7", bubble: "#eeeeee", card: "#ffffff", text: "#141414", user: "#070707", userText: "#fcfcfc", accent: "#4d6bfe" }, -- dark: { bg: "#141414", sidebar: "#1b1b1b", bubble: "#262626", card: "#1f1f1f", text: "#f2f2f2", user: "#f2f2f2", userText: "#141414", accent: "#5b7bff" } -- }, -- mono: { -- label: "Mono", -- light: { bg: "#ffffff", sidebar: "#f9f9f9", bubble: "#f0f0f0", card: "#ffffff", text: "#0d0d0d", user: "#0d0d0d", userText: "#ffffff", accent: "#0d0d0d" }, -- dark: { bg: "#0f0f0f", sidebar: "#171717", bubble: "#242424", card: "#1c1c1c", text: "#f4f4f4", user: "#f4f4f4", userText: "#0f0f0f", accent: "#f4f4f4" } -- }, -- paper: { -- label: "Paper", -- light: { bg: "#faf9f5", sidebar: "#f3f0e8", bubble: "#ebe7dc", card: "#ffffff", text: "#1f1e1b", user: "#2b2a26", userText: "#faf9f5", accent: "#d97757" }, -- dark: { bg: "#1f1e1b", sidebar: "#262521", bubble: "#34322d", card: "#2a2925", text: "#f3f0e8", user: "#f3f0e8", userText: "#1f1e1b", accent: "#e08a6d" } -- }, -- moonlight: { -- label: "Moonlight", -- light: { bg: "#f7f9fc", sidebar: "#eef2f8", bubble: "#e5ebf5", card: "#ffffff", text: "#121826", user: "#1e3a8a", userText: "#ffffff", accent: "#1e86ff" }, -- dark: { bg: "#0b1020", sidebar: "#10172a", bubble: "#1a2340", card: "#141c33", text: "#e8edf7", user: "#3b6fd8", userText: "#ffffff", accent: "#4f9dff" } -- } ++} ++ +// src/client/approval-claims.js +var claims = /* @__PURE__ */ new Map(); +var claimListeners = /* @__PURE__ */ new Set(); +var openChats = /* @__PURE__ */ new Map(); +var changed = () => { + for (const fn of [...claimListeners]) fn(); - }; --var rgba = (hex, alpha) => `rgba(${[1, 3, 5].map((index) => parseInt(hex.slice(index, index + 2), 16)).join(",")},${alpha})`; --function themeVars(mode, dark, accent = mode.accent) { -- return { -- "--bt-ink": mode.text, -- "--bt-ink-2": rgba(mode.text, 0.6), -- "--bt-ink-3": rgba(mode.text, 0.36), -- "--bt-bubble": mode.bubble, -- "--bt-sidebar": mode.sidebar, -- "--bt-main": mode.bg, -- "--bt-card": mode.card, -- "--bt-line": rgba(mode.text, 0.08), -- "--bt-line-2": rgba(mode.text, 0.15), -- "--bt-hover": dark ? "rgba(160,160,160,.12)" : "rgba(119,119,119,.09)", -- "--bt-active": dark ? "rgba(160,160,160,.2)" : "rgba(119,119,119,.17)", -- "--bt-accent": accent, -- "--bt-accent-ink": luminance(accent) > 0.45 ? "#141414" : "#ffffff", -- "--bt-user": mode.user, -- "--bt-user-ink": mode.userText, -- "--bt-tag-bg": rgba(mode.text, dark ? 0.09 : 0.06), -- "--bt-tag-ink": rgba(mode.text, 0.58) -- }; --} --var declarations = (vars) => Object.entries(vars).map(([name, value]) => `${name}:${value}`).join(";"); --var themeCss = (theme, accent) => `html body{${declarations(themeVars(theme.light, false, accent ?? theme.light.accent))}} --html body[data-ds-dark-theme]{${declarations(themeVars(theme.dark, true, accent ?? theme.dark.accent))}} --${theme.css ?? ""}`; --var shellTokens = ({ light, dark }) => ({ -- "--dsw-alias-bg-base": { light: light.bg, dark: dark.bg }, -- "--dsw-specific-sidebar-fill": { light: light.sidebar, dark: dark.sidebar }, -- "--dsw-specific-input-major": { light: light.bg, dark: dark.sidebar }, -- "--dsw-alias-label-primary": { light: light.text, dark: dark.text }, -- "--dsw-alias-label-secondary": { light: rgba(light.text, 0.74), dark: rgba(dark.text, 0.74) }, -- "--dsw-alias-label-tertiary": { light: rgba(light.text, 0.6), dark: rgba(dark.text, 0.6) } --}); --var customThemes = {}; --var registry = createSource(0); --var isPoint = (point) => Array.isArray(point) && point.length === 2 && point.every(Number.isFinite); --function registerShape(id, shape) { -- if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Shape ids are lowercase words"); -- if (typeof shape?.body !== "function" || typeof shape.body("#000") !== "string") throw new Error("A shape needs body(paint) returning SVG markup"); -- const at = shape.eyes?.at; -- if (!Array.isArray(at) || at.length !== 2 || !at.every(isPoint)) throw new Error("A shape needs eyes.at: two [x, y] points"); -- const size = Number.isFinite(shape.size) && shape.size > 0 ? shape.size : 100; -- const pick2 = (key, fallback) => Number.isFinite(shape.eyes[key]) ? shape.eyes[key] : fallback; -- const eyes = at.map(([x, y]) => [x, y, pick2("w", size * 0.1), pick2("h", size * 0.23), pick2("tilt", -27)]); -- const previous = SHAPES[id]; -- SHAPES[id] = { -- label: String(shape.label ?? id), -- size, -- body: shape.body, -- eyes, -- ...typeof shape.wrap === "string" ? { wrap: shape.wrap } : {} -- }; -- const refresh = () => { -- markupCache.clear(); -- maskCache.delete(id); -- registry.set((value) => value + 1); -- }; -- refresh(); ++}; +function chatOpened(sessionId, delta) { + const count = (openChats.get(sessionId) ?? 0) + delta; + if (count > 0) openChats.set(sessionId, count); @@ -1668,13 +1679,10 @@ diff --git a/client.js b/client.js // src/shared/mentions.js var ALL_WORDS = ["all", "everyone", "所有人", "全体"]; -@@ -1443,7 +1443,28 @@ function mentionSpans(text, bots, { references = [] } = {}) { - spans.push(bot ? { start: at, end, bot } : { start: at, end, all: true }); - at = end - 1; +@@ -1445,6 +1445,27 @@ function mentionSpans(text, bots, { references = [] } = {}) { } -- return spans; -+ return spans; -+} + return spans; + } +function unmatchedMentions(text, bots, { references = [] } = {}) { + const value = String(text ?? ""); + const skip = [ @@ -1695,9 +1703,10 @@ diff --git a/client.js b/client.js + if (word) missing.push(`@${word}`); + } + return missing; - } ++} // src/client/text.js + var MENTION_ORIGIN = "https://bots.local/"; @@ -1463,6 +1463,19 @@ function mentionTarget(event) { const anchor = event.target instanceof Element ? event.target.closest(`a[href^="${MENTION_ORIGIN}"]`) : null; return anchor ? anchor.getAttribute("href").slice(MENTION_ORIGIN.length) : null; @@ -1786,11 +1795,35 @@ diff --git a/client.js b/client.js var historyLoading = (snapshot) => snapshot?.openState === "cold" || snapshot?.openState === "loading"; function toolArgs(root) { if (typeof root.args?.text === "function") { -@@ -1597,14 +1597,21 @@ function commRunOf(order, peers, turn, hiddenTurns = []) { +@@ -1573,60 +1573,246 @@ function relaysBefore(contexts, steps, step) { + break; + } } - return { count, peerIds }; - } --function previewOf(list, id, hiddenTurns = []) { +- return (contexts ?? []).filter((data) => isRelayContext(data) && typeof data.time === "number" && data.time >= prev && data.time < step.time); ++ return (contexts ?? []).filter((data) => isRelayContext(data) && typeof data.time === "number" && data.time >= prev && data.time < step.time); ++} ++function commRunOf(order, peers, turn, hiddenTurns = []) { ++ const turns = []; ++ for (const entry of order) { ++ const peer = peers[entry]; ++ const hidden = hiddenTurns.includes(entry); ++ if (hidden && peer === void 0) continue; ++ turns.push({ turn: entry, peer, hidden }); ++ } ++ const at = turns.findIndex((entry) => entry.turn === turn); ++ if (at === -1 || turns[at].peer === void 0) return null; ++ const previous = turns[at - 1]; ++ if (previous?.peer !== void 0 && previous.hidden) return "folded"; ++ let count = 0; ++ const peerIds = []; ++ for (let index = at; index < turns.length; index += 1) { ++ const entry = turns[index]; ++ if (entry.peer === void 0 || index > at && !turns[index - 1].hidden) break; ++ count += entry.hidden ? 2 : 1; ++ if (!peerIds.includes(entry.peer)) peerIds.push(entry.peer); ++ } ++ return { count, peerIds }; ++} +function heldReplyOf(bot, sessionId, turn) { + const list = Array.isArray(bot?.heldReplies) ? bot.heldReplies : []; + let found; @@ -1799,22 +1832,32 @@ diff --git a/client.js b/client.js +} +var unsentReplyTurns = (bot, sessionId) => (Array.isArray(bot?.heldReplies) ? bot.heldReplies : []).filter((reply) => reply.sessionId === sessionId && reply.status !== "approved" && reply.status !== "auto").map((reply) => reply.turn); +function previewOf(list, id, hiddenTurns = [], unsentTurns = []) { - const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; - if (!Array.isArray(outline)) return ""; - for (let index = outline.length - 1; index >= 0; index -= 1) { - const entry = outline[index]; - if (hiddenTurns.includes(entry.turn)) continue; -- const text = entry.response || entry.prompt || ""; -- if (text !== "") return stripHeader(text).replace(/\s+/g, " ").trim(); ++ const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; ++ if (!Array.isArray(outline)) return ""; ++ for (let index = outline.length - 1; index >= 0; index -= 1) { ++ const entry = outline[index]; ++ if (hiddenTurns.includes(entry.turn)) continue; + const text = (unsentTurns.includes(entry.turn) ? "" : entry.response) || entry.prompt || ""; + if (text !== "") return stripTrace(stripHeader(text)).replace(/\s+/g, " ").trim().replace(/^[^\s→]{1,40}(?: → [^\s→]{1,40})+ (?=\S)/, ""); - } - return ""; - } -@@ -1624,9 +1624,188 @@ function usageOf(list, ids) { - const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; - return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); - } ++ } ++ return ""; ++} ++var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); ++function usageOf(list, ids) { ++ let input = 0; ++ let cacheRead = 0; ++ let output = 0; ++ for (const id of ids) { ++ const usage = list.projectionsBySession?.[id]?.values?.tokenUsage; ++ if (!usage) continue; ++ input += (usage.uncachedInputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0); ++ cacheRead += usage.cacheReadTokens ?? 0; ++ output += usage.outputTokens ?? 0; ++ } ++ if (input + output === 0) return ""; ++ const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; ++ return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); ++} +function faceRowTitle(title) { + const text = String(title ?? ""); + const named = text.match(/^Face-to-face · (.+) · (\d{6})$/); @@ -1874,7 +1917,26 @@ diff --git a/client.js b/client.js + for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; + } + return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; -+} + } +-function commRunOf(order, peers, turn, hiddenTurns = []) { +- const turns = []; +- for (const entry of order) { +- const peer = peers[entry]; +- const hidden = hiddenTurns.includes(entry); +- if (hidden && peer === void 0) continue; +- turns.push({ turn: entry, peer, hidden }); +- } +- const at = turns.findIndex((entry) => entry.turn === turn); +- if (at === -1 || turns[at].peer === void 0) return null; +- const previous = turns[at - 1]; +- if (previous?.peer !== void 0 && previous.hidden) return "folded"; +- let count = 0; +- const peerIds = []; +- for (let index = at; index < turns.length; index += 1) { +- const entry = turns[index]; +- if (entry.peer === void 0 || index > at && !turns[index - 1].hidden) break; +- count += entry.hidden ? 2 : 1; +- if (!peerIds.includes(entry.peer)) peerIds.push(entry.peer); +function isAgentSession(roster, cache, sessionId) { + if (sessionId === void 0 || sessionId === null || sessionId === "") return false; + if (roster.ready) return roster.agentOf[sessionId] !== void 0; @@ -1886,9 +1948,18 @@ diff --git a/client.js b/client.js + const count = (own[callId] ?? 0) + delta; + if (count > 0) own[callId] = count; + else delete own[callId]; -+ } + } +- return { count, peerIds }; + return { ...state, [sessionId]: own }; -+} + } +-function previewOf(list, id, hiddenTurns = []) { +- const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; +- if (!Array.isArray(outline)) return ""; +- for (let index = outline.length - 1; index >= 0; index -= 1) { +- const entry = outline[index]; +- if (hiddenTurns.includes(entry.turn)) continue; +- const text = entry.response || entry.prompt || ""; +- if (text !== "") return stripHeader(text).replace(/\s+/g, " ").trim(); + +// src/client/themes.js +var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; @@ -1912,7 +1983,8 @@ diff --git a/client.js b/client.js + label: "Moonlight", + light: { bg: "#f7f9fc", sidebar: "#eef2f8", bubble: "#e5ebf5", card: "#ffffff", text: "#121826", user: "#1e3a8a", userText: "#ffffff", accent: "#1e86ff" }, + dark: { bg: "#0b1020", sidebar: "#10172a", bubble: "#1a2340", card: "#141c33", text: "#e8edf7", user: "#3b6fd8", userText: "#ffffff", accent: "#4f9dff" } -+ } + } +- return ""; +}; +var rgba = (hex, alpha) => `rgba(${[1, 3, 5].map((index) => parseInt(hex.slice(index, index + 2), 16)).join(",")},${alpha})`; +function themeVars(mode, dark, accent = mode.accent) { @@ -1935,7 +2007,18 @@ diff --git a/client.js b/client.js + "--bt-tag-bg": rgba(mode.text, dark ? 0.09 : 0.06), + "--bt-tag-ink": rgba(mode.text, 0.58) + }; -+} + } +-var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); +-function usageOf(list, ids) { +- let input = 0; +- let cacheRead = 0; +- let output = 0; +- for (const id of ids) { +- const usage = list.projectionsBySession?.[id]?.values?.tokenUsage; +- if (!usage) continue; +- input += (usage.uncachedInputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0); +- cacheRead += usage.cacheReadTokens ?? 0; +- output += usage.outputTokens ?? 0; +var declarations = (vars) => Object.entries(vars).map(([name, value]) => `${name}:${value}`).join(";"); +var themeCss = (theme, accent) => `html body{${declarations(themeVars(theme.light, false, accent ?? theme.light.accent))}} +html body[data-ds-dark-theme]{${declarations(themeVars(theme.dark, true, accent ?? theme.dark.accent))}} @@ -1983,7 +2066,10 @@ diff --git a/client.js b/client.js + if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Theme ids are lowercase words"); + for (const mode of ["light", "dark"]) { + for (const key of THEME_KEYS) if (!isHex(theme?.[mode]?.[key])) throw new Error(`Theme ${mode}.${key} must be a #rrggbb color`); -+ } + } +- if (input + output === 0) return ""; +- const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; +- return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); + const previous = customThemes[id]; + customThemes[id] = { label: String(theme.label ?? id), light: theme.light, dark: theme.dark, css: typeof theme.css === "string" ? theme.css : "" }; + registry3.set((value) => value + 1); @@ -1992,7 +2078,7 @@ diff --git a/client.js b/client.js + else delete customThemes[id]; + registry3.set((value) => value + 1); + }; -+} + } +var allThemes = () => ({ ...THEMES, ...customThemes }); // src/client/motion.js @@ -2329,10 +2415,14 @@ diff --git a/client.js b/client.js .bt-ububble{max-width:min(560px,78%);background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:14px;line-height:20px;box-sizing:border-box;overflow-wrap:anywhere} .bt-ububble [class*="_markdown"],.bt-ububble a{color:inherit!important} .bt-ububble p{margin:0;line-height:22px} -@@ -2852,229 +2852,211 @@ function installCompiledStyles(text, tag) { - document.removeEventListener("load", onLoad, true); - clearTimeout(timer); - style.remove(); +@@ -2848,233 +2848,215 @@ function installCompiledStyles(text, tag) { + observer.observe(document.head, { childList: true }); + document.addEventListener("load", onLoad, true); + return () => { +- observer.disconnect(); +- document.removeEventListener("load", onLoad, true); +- clearTimeout(timer); +- style.remove(); - }; -} - @@ -2419,8 +2509,7 @@ diff --git a/client.js b/client.js - { viewBox: "0 0 20 20", "aria-hidden": true }, - (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) - ); -+ }; - } +-} -function useSettled(state) { - const prev = (0, import_react2.useRef)(state); - const [settled, setSettled] = (0, import_react2.useState)(false); @@ -2437,6 +2526,25 @@ diff --git a/client.js b/client.js - return () => clearTimeout(timer); - }, [state]); - return settled; ++ observer.disconnect(); ++ document.removeEventListener("load", onLoad, true); ++ clearTimeout(timer); ++ style.remove(); ++ }; + } +-function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { +- const settled = useSettled(state); +- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); +- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, +- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), +- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, +- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, +- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, +- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null +- ); + +// src/shared/face-title.js +var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; @@ -2469,19 +2577,13 @@ diff --git a/client.js b/client.js + rows.sort((a, b) => a.sort.localeCompare(b.sort)); + return rows.slice(0, 3).map((row) => row.owner); } --function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { -- const settled = useSettled(state); -- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); -- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, -- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), -- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, -- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, -- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, -- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null -- ); +-function clusterSeats(count, frame) { +- if (count === 1) return [[0, 0, frame]]; +- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; +- if (count === 3) { +- const size = frame * 5 / 9; +- const rest = frame - size; +- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; +function uniqueOwners(owners) { + const seen = /* @__PURE__ */ new Set(); + const out = []; @@ -2491,7 +2593,9 @@ diff --git a/client.js b/client.js + if (!owner || seen.has(key)) continue; + seen.add(key); + out.push(owner); -+ } + } +- const half = frame / 2; +- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; + out.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + return out.slice(0, 3); +} @@ -2516,13 +2620,18 @@ diff --git a/client.js b/client.js + bits.push(join.length === 6 ? join : when3); + return bits.join(" · "); } --function clusterSeats(count, frame) { -- if (count === 1) return [[0, 0, frame]]; -- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; -- if (count === 3) { -- const size = frame * 5 / 9; -- const rest = frame - size; -- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; +-function RoomAvatar({ room: room2, roster, size = 36 }) { +- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); +- const people = members.length === 1 ? [members[0], null] : members; +- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); +- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); +- if (size <= 20 && people.length > 1) { +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, +- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), +- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null +- ); + +// src/client/brand.js +var FLYBOT_WING = "M150 238 C 96 200, 40 196, 22 214 C 8 230, 40 262, 150 300 Z"; @@ -2550,24 +2659,28 @@ diff --git a/client.js b/client.js + isChief: bot.remote.isChief + }); } -- const half = frame / 2; -- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; +- const seats = clusterSeats(Math.min(people.length, 4), size); +- const overflow = people.length > 4 ? people.length - 3 : 0; +- const gap = size / 14; +- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-cluster", style: { width: size, height: size } }, +- seats.map(([x, y, s], index) => { +- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); +- const style = { left: x, top: y, width: s, height: s }; +- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); +- if (overflow > 0 && index === 3) { +- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); +- } +- const bot = people[index]; +- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); +- }) +- ); + if (room2 && room2.origin === "face-to-face" && (room2.face?.status === "closed" || room2.face?.status === "left") && isAutoFaceTitle(room2.name)) return "Face-to-face · closed"; + if (room2) return room2.displayName || (room2.sharedFrom ? sharedRoomTitle(room2.sharedFrom.inviterName) : room2.name) || ""; + return bot?.name || ""; - } --function RoomAvatar({ room: room2, roster, size = 36 }) { -- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); -- const people = members.length === 1 ? [members[0], null] : members; -- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); -- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); -- if (size <= 20 && people.length > 1) { -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, -- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), -- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null -- ); ++} +function installTabTitle(sessions, roster, doc = globalThis.document) { + if (!doc || !sessions || !roster) return () => { + }; @@ -2597,25 +2710,7 @@ diff --git a/client.js b/client.js + link.rel = "icon"; + link.type = "image/svg+xml"; + document.head.appendChild(link); - } -- const seats = clusterSeats(Math.min(people.length, 4), size); -- const overflow = people.length > 4 ? people.length - 3 : 0; -- const gap = size / 14; -- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-cluster", style: { width: size, height: size } }, -- seats.map(([x, y, s], index) => { -- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); -- const style = { left: x, top: y, width: s, height: s }; -- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); -- if (overflow > 0 && index === 3) { -- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); -- } -- const bot = people[index]; -- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); -- }) -- ); ++ } + link.href = href; + const retitle = () => { + const next = brandTitle(document.title); @@ -3849,7 +3944,8 @@ diff --git a/client.js b/client.js { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, - (0, import_react6.createElement)(BotMark, { bot, size: 22 }) + (0, import_react8.createElement)(BotMark, { bot, size: 22 }) -+ ); + ); +- return (0, import_react6.createElement)( + const name = (0, import_react8.createElement)( + "span", + { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, @@ -3944,8 +4040,7 @@ diff --git a/client.js b/client.js + { width: 10, height: 10, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, + (0, import_react8.createElement)("circle", { cx: 12, cy: 12, r: 9 }), + (0, import_react8.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) - ); -- return (0, import_react6.createElement)( ++ ); +} +function RemoteFiles({ files, plugin, botId }) { + const [busy, setBusy] = (0, import_react8.useState)(""); @@ -4372,12 +4467,12 @@ diff --git a/client.js b/client.js if (reply.kind === "stopped") { - return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); + return (0, import_react8.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); - } -- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); ++ } + if (reply.kind === "notice" && room2?.origin === "face-to-face" && String(reply.text ?? "").trim() === WAITING_OTHERS_NOTICE) return null; + if (reply.kind === "notice" || reply.kind === "joined" || reply.kind === "left" || reply.kind === "started") { + return (0, import_react8.createElement)("div", { key: `notice-${index}`, className: "bt-event", "data-flybot": "face-notice" }, remoteMessageText(reply.text)); -+ } + } +- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); + const remote = reply.remote || reply.handle && !roster.byId[reply.botId]; + if (remote) { + return (0, import_react8.createElement)(RemoteMessage, { @@ -7384,7 +7479,7 @@ diff --git a/client.js b/client.js }); load(); return () => { -@@ -6896,11 +6896,11 @@ function timingText(task) { +@@ -6896,11 +6896,25 @@ function timingText(task) { return ""; } } @@ -7394,6 +7489,20 @@ diff --git a/client.js b/client.js - const [busy, setBusy] = (0, import_react15.useState)(false); - (0, import_react15.useEffect)(() => { +var nextText = (task) => task.status === "paused" ? t("Paused") : task.status === "active" ? t("Next: {time}", { time: dayAndTime(Date.parse(task.scheduledAt)) }) : t("Finished"); ++function lastRunText(run) { ++ if (run === void 0 || !Number.isFinite(run.at)) return ""; ++ const time = dayAndTime(run.at); ++ switch (run.result) { ++ case "running": ++ return t("Running since {time}", { time }); ++ case "stopped": ++ return t("Last run {time} · Stopped", { time }); ++ case "failed": ++ return t("Last run {time} · Failed: {reason}", { time, reason: hostText(run.reason || "Unknown error") }); ++ default: ++ return t("Last run {time}", { time }); ++ } ++} +function TaskRow({ task, actions, onView, onEdit, showBot, roster }) { + const [confirming, setConfirming] = (0, import_react19.useState)(false); + const [busy, setBusy] = (0, import_react19.useState)(false); @@ -7401,7 +7510,7 @@ diff --git a/client.js b/client.js if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 3e3); return () => clearTimeout(timer); -@@ -6913,20 +6913,31 @@ function TaskRow({ task, actions, onView, showBot, roster }) { +@@ -6913,20 +6913,48 @@ function TaskRow({ task, actions, onView, showBot, roster }) { setBusy(true); void actions.scheduleDelete(task.sessionId, task.id).then(onView, () => setBusy(false)); }; @@ -7412,10 +7521,24 @@ diff --git a/client.js b/client.js + setBusy(false); + onView(next); + }, () => setBusy(false)); ++ }; ++ const [retryError, setRetryError] = (0, import_react19.useState)(""); ++ const retry = () => { ++ setBusy(true); ++ setRetryError(""); ++ void actions.scheduleRetry(task.sessionId, task.id).then((next) => { ++ setBusy(false); ++ onView(next); ++ }, (error) => { ++ setBusy(false); ++ setRetryError(hostText(error?.message ?? String(error))); ++ }); + }; const room2 = task.roomId ? roster.roomsById[task.roomId] : void 0; - return (0, import_react15.createElement)( + const stoppable = task.status === "active" || task.status === "paused"; ++ const failed = task.lastRun?.result === "failed"; ++ const last = lastRunText(task.lastRun); + return (0, import_react19.createElement)( "div", - { className: "bt-set-row bt-task", "data-done": task.status === "active" ? void 0 : "", "data-busy": busy || void 0 }, @@ -7431,11 +7554,14 @@ diff --git a/client.js b/client.js - task.prompt && task.prompt !== task.title ? (0, import_react15.createElement)("span", { className: "bt-task-prompt" }, task.prompt) : null + (0, import_react19.createElement)("span", { className: "bt-task-title" }, task.title), + (0, import_react19.createElement)("span", { className: "bt-set-hint" }, [timingText(task), room2 ? t("in {name}", { name: room2.name }) : ""].filter(Boolean).join(" · ")), -+ task.prompt && task.prompt !== task.title ? (0, import_react19.createElement)("span", { className: "bt-task-prompt" }, task.prompt) : null ++ task.prompt && task.prompt !== task.title ? (0, import_react19.createElement)("span", { className: "bt-task-prompt" }, task.prompt) : null, ++ last ? (0, import_react19.createElement)("span", { className: "bt-task-last", "data-failed": failed || void 0 }, last) : null, ++ retryError ? (0, import_react19.createElement)("span", { className: "bt-task-last", "data-failed": "", role: "alert" }, retryError) : null ), - (0, import_react15.createElement)("span", { className: "bt-task-next" }, nextText(task)), - (0, import_react15.createElement)("button", { + (0, import_react19.createElement)("span", { className: "bt-task-next" }, nextText(task)), ++ failed && task.status !== "paused" && actions.scheduleRetry ? (0, import_react19.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_react19.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Edit"), title: t("Edit"), onClick: () => onEdit(task) }, (0, import_react19.createElement)(PencilIcon)) : null, + stoppable ? (0, import_react19.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, onClick: toggle }, task.status === "paused" ? t("Resume") : t("Pause")) : null, + (0, import_react19.createElement)("button", { @@ -7757,13 +7883,15 @@ diff --git a/client.js b/client.js ))) ); } -@@ -7127,6 +7127,10 @@ var SCHEDULE_CSS = ` +@@ -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)} .bt-task-remove svg{width:14px;height:14px} +.bt-task-tool{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} +.bt-task-tool svg{width:14px;height:14px} +.bt-task-pause{height:26px;color:var(--bt-ink-2)} ++.bt-task-last{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow-wrap:anywhere} ++.bt-task-last[data-failed]{color:#c21d2e} +.bt-task[data-paused] .bt-task-title,.bt-task[data-paused] .bt-task-prompt{color:var(--bt-ink-3)} .bt-task-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135} .bt-task-head{display:flex;align-items:center;gap:6px} @@ -13602,7 +13730,7 @@ diff --git a/client.js b/client.js endVisit: (id) => { leaveVisits([id]); ui2.set((state) => { -@@ -13819,15 +13819,23 @@ function apply(ctx) { +@@ -13819,15 +13819,24 @@ function apply(ctx) { secretCancel: (requestId) => after(call("secret-cancel", { requestId })), secretUpdate: (name, scope) => after(call("secret-update", { name, scope })), secretDelete: (name) => after(call("secret-delete", { name })), @@ -13624,6 +13752,7 @@ diff --git a/client.js b/client.js + scheduleUpdate: (task) => call("schedule-update", task), + schedulePause: (sessionId, id) => call("schedule-pause", { sessionId, id }), + scheduleResume: (sessionId, id) => call("schedule-resume", { sessionId, id }), ++ scheduleRetry: (sessionId, id) => call("schedule-retry", { sessionId, id }), scheduleDelete: (sessionId, id) => call("schedule-delete", { sessionId, id }), exchange: (a, b) => call("exchange", { a, b }).catch(() => []), roomProgress: (roomId) => call("room-progress", { roomId }).catch(() => []), diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 5c949605..2ba2c0b6 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -1198,7 +1198,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js for (const post of posts.filter(item => item.failed)) { const bot = rt.botOf(post.botId) const { code, status, message } = post.failed -@@ -450,6 +450,12 @@ export function install(rt) { +@@ -450,17 +450,25 @@ export function install(rt) { appendRoom(room, { who: name, botId: post.botId, kind, text: '' }) round.replies.push({ kind, botId: post.botId, name, color: bot?.color, time: Date.now() }) } @@ -1211,7 +1211,12 @@ diff --git a/src/host/groups.js b/src/host/groups.js if (posts.some(post => post.resumed || post.stopped)) await rt.save() rt.roomProgress.set(exec.callId, round) try { -@@ -460,7 +460,7 @@ export function install(rt) { + await runRound(room, round, exec.signal, exec.agent) ++ // A scheduled task's run in this group fails with its members' failed turns (#473). ++ if (round.failed.length > 0) rt.scheduleRoundFailed?.(room.id, round.failed) + } catch (error) { + rt.warn('ds-bot: group round in %s stopped: %s', room.name, error) + } finally { rt.roomProgress.delete(exec.callId) } if (exec.signal?.aborted) { diff --git a/patches/ds-bot/files/src/host/authz.js b/patches/ds-bot/files/src/host/authz.js index 33a24551..1660b957 100644 --- a/patches/ds-bot/files/src/host/authz.js +++ b/patches/ds-bot/files/src/host/authz.js @@ -38,7 +38,7 @@ const SHARED_BLOCKED = new Set([ const BOT_TARGETED = new Set([ 'update-bot', 'delete-bot', 'duplicate-bot', 'set-flags', 'bot-capability', 'memory-view', 'memory-ask', 'memory-forget', - 'schedules', 'schedule-create', 'schedule-update', 'schedule-pause', 'schedule-resume', 'schedule-delete', + 'schedules', 'schedule-create', 'schedule-update', 'schedule-pause', 'schedule-resume', 'schedule-retry', 'schedule-delete', 'agent-session', 'agent-adopt', 'agent-archive', 'agent-rename', 'remove-agent', 'stop-member-work', 'set-main', 'test-model', ]) diff --git a/patches/ds-bot/files/test/authz.test.js b/patches/ds-bot/files/test/authz.test.js index 91c2560b..0559b846 100644 --- a/patches/ds-bot/files/test/authz.test.js +++ b/patches/ds-bot/files/test/authz.test.js @@ -227,7 +227,7 @@ test('editing, pausing and resuming a task need its Session, and an edit its new assert.equal(rt.authorize(actorA, 'schedule-update', { sessionId: 'chat-a1', id: 's1', target: 'a1' }).ok, true) assert.equal(rt.authorize(actorA, 'schedule-update', { sessionId: 'chat-a1', id: 's1', target: 'b1' }).code, 'bot/forbidden') assert.equal(rt.authorize(actorA, 'schedule-update', { sessionId: 'chat-b1', id: 's1', target: 'a1' }).code, 'bot/forbidden') - for (const ep of ['schedule-pause', 'schedule-resume']) { + for (const ep of ['schedule-pause', 'schedule-resume', 'schedule-retry']) { assert.equal(rt.authorize(actorA, ep, { sessionId: 'chat-a1', id: 's1' }).ok, true) assert.equal(rt.authorize(actorA, ep, { sessionId: 'chat-b1', id: 's1' }).code, 'bot/forbidden') } diff --git a/patches/ds-bot/files/test/schedules-client.test.js b/patches/ds-bot/files/test/schedules-client.test.js index 8632f588..a2a7401b 100644 --- a/patches/ds-bot/files/test/schedules-client.test.js +++ b/patches/ds-bot/files/test/schedules-client.test.js @@ -135,3 +135,22 @@ test('the form starts from the edited task: repeat interval, one time, cron kept assert.equal(formStart({ kind: 'cron', expression: '0 9 * * 1', timeZone: 'UTC' }).kind, 'cron') assert.deepEqual([formStart(undefined).kind, formStart(undefined).title, formStart(undefined).time], ['daily', '', '09:00']) }) + +test('a row says how its last run went; only a failed run offers Retry, which runs the task now (#473)', async () => { + const calls = [] + const actions = { ...spy(), scheduleRetry: async (...args) => { calls.push(['retry', ...args]); return OFF } } + const at = new Date(2030, 0, 2, 9, 0).getTime() + const failed = { ...ACTIVE, lastRun: { at, result: 'failed', reason: 'SERVER 503: upstream busy' } } + const ran = { ...ACTIVE, id: 's3', lastRun: { at, result: 'ran' } } + const missed = { ...PAUSED, lastRun: { at, result: 'failed', reason: 'DSH could not deliver it' } } + seeded.push({ available: true, tasks: [failed, ran, missed] }, null) + const card = render(BotSchedules({ bot: { id: 'b1' }, roster: ROSTER, actions })) + const lines = text(card) + assert.ok(lines.some(line => /^Last run .+ · Failed: SERVER 503: upstream busy$/.test(line)), lines.join('|')) + assert.ok(lines.some(line => /^Last run [^·]+$/.test(line))) + const retries = buttonsOf(card).filter(button => text(button).includes('Retry')) + assert.equal(retries.length, 1, 'a ran task and a paused one have no Retry') + retries[0].props.onClick() + await new Promise(resolve => setImmediate(resolve)) + assert.deepEqual(JSON.parse(JSON.stringify(calls)), [['retry', 'chat-b1', 's1']]) +})