From 42ae8581a6471a46071754e8958be56b6deb02c8 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Mon, 19 Oct 2026 00:26:19 +0800 Subject: [PATCH] Switch on DSH Automation tasks by default, once per profile; the hint opens Plugins (#378) DSH 0.2 offers the schedule service only through its optional bundle @deepseek-ai/dsh-experimental-schedule-bundle, so Settings > Scheduled tasks showed only "Turn on Automation tasks in DSH" on a fresh profile. - automation-default.js asks DSH's plugin manager to switch the bundle on the first time FlyBot runs in a profile, and writes the offer to dsh-defaults.json next to the team. A person who switches it off keeps it off. No new dependency: the bundle ships inside the DSH install. automationTasks: false in the bot config opts out. - The Open Harness settings button (Settings page and a Bot's card) now closes Settings and opens the Plugins panel, where the switch is; the hint says "Switch on Automation tasks on the Plugins page." (en, zh). --- .../378-automation-tasks-default.changed.md | 1 + core/README.md | 1 + core/client.js | 22 +-- core/index.js | 3 +- core/src/client/i18n.js | 4 +- core/src/client/schedules.js | 22 ++- core/src/host/automation-default.js | 106 +++++++++++++ core/test/automation-default.test.js | 144 ++++++++++++++++++ core/test/install.test.js | 1 + core/test/schedules-client.test.js | 82 ++++++++++ docs/subsystems/core-client.md | 1 + docs/subsystems/core-host.md | 10 ++ patches/ds-bot/0001-core-fixes.patch | 82 +++++++++- patches/ds-bot/0002-flybot-api-host.patch | 5 +- patches/ds-bot/0008-client-bundle.patch | 32 +++- .../files/src/host/automation-default.js | 106 +++++++++++++ .../files/test/automation-default.test.js | 144 ++++++++++++++++++ .../files/test/schedules-client.test.js | 82 ++++++++++ patches/ds-bot/topics.json | 1 + 19 files changed, 817 insertions(+), 32 deletions(-) create mode 100644 changelog.d/378-automation-tasks-default.changed.md create mode 100644 core/src/host/automation-default.js create mode 100644 core/test/automation-default.test.js create mode 100644 core/test/schedules-client.test.js create mode 100644 patches/ds-bot/files/src/host/automation-default.js create mode 100644 patches/ds-bot/files/test/automation-default.test.js create mode 100644 patches/ds-bot/files/test/schedules-client.test.js diff --git a/changelog.d/378-automation-tasks-default.changed.md b/changelog.d/378-automation-tasks-default.changed.md new file mode 100644 index 000000000..1cd2da078 --- /dev/null +++ b/changelog.d/378-automation-tasks-default.changed.md @@ -0,0 +1 @@ +Settings → Scheduled tasks works out of the box: the first time FlyBot runs in a DSH profile it switches on DSH's Automation tasks bundle (`@deepseek-ai/dsh-experimental-schedule-bundle`) through the plugin manager, once per profile, so a person who switches it off on the Plugins page keeps it off (`automationTasks: false` in the `bot` config opts out). When it is off, the hint now reads "Switch on Automation tasks on the Plugins page." and its button opens the Plugins panel instead of a Harness settings dialog that has no such switch ([#378](https://github.com/ThinkFlowLab/FlyBot/issues/378)). diff --git a/core/README.md b/core/README.md index 75eb3ae2a..0fa160704 100644 --- a/core/README.md +++ b/core/README.md @@ -73,6 +73,7 @@ Options go in the profile's `cordis.patch.yml`, under the `bot` entry. All of th memoryReview: true # false leaves memory to the Bots' own remember calls diagnosticsFile: false # keep the error log in memory only; default /logs/diagnostics.jsonl updateCheck: true # look for a newer DS Bot when a window opens; default true + automationTasks: false # false stops FlyBot switching on DSH's Automation tasks bundle (once per profile) for Scheduled tasks; default on updateRegistry: https://registry.example/ # ask only this npm registry for updates github: false # hide the GitHub connector; default shown in Connectors composio: false # hide the Composio connector; default shown in Connectors diff --git a/core/client.js b/core/client.js index 9b55fee6a..d9bf4a550 100644 --- a/core/client.js +++ b/core/client.js @@ -1005,8 +1005,8 @@ var ZH = { Unit: "单位", "Create task": "创建任务", "Turn on Automation tasks in DSH": "在 DSH 里打开「自动化任务」", - "Switch on Automation tasks on the Plugins page in Harness settings.": "在 Harness 设置的插件页启用「自动化任务」。", - "Open Harness settings": "打开 Harness 设置", + "Switch on Automation tasks on the Plugins page.": "在「插件」页面打开「自动化任务」。", + "Open Plugins": "打开「插件」页面", "New task": "新建任务", "No scheduled tasks yet.": "还没有定时任务。", "No scheduled tasks yet. Add one": "还没有定时任务,去添加", @@ -8518,21 +8518,25 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { ) ); } -function AutomationOff() { +function openPlugins(actions) { + actions.closeSettings(); + actions.selectPanel("plugins"); +} +function AutomationOff({ actions }) { return (0, import_react16.createElement)( "div", { className: "bt-set-card bt-task-off" }, (0, import_react16.createElement)("span", { className: "bt-task-off-mark" }, (0, import_react16.createElement)(ClockIcon)), (0, import_react16.createElement)("span", { className: "bt-task-off-title" }, t("Turn on Automation tasks in DSH")), - (0, import_react16.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page in Harness settings.")), - (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: openHarnessSettings }, t("Open Harness settings")) + (0, import_react16.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page.")), + (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins")) ); } function SchedulesPage({ roster, actions, start }) { const view = useSchedules(actions); const [adding, setAdding] = (0, import_react16.useState)(Boolean(start)); if (view === null) return (0, import_react16.createElement)("div", { className: "bt-set-hint" }, t("Loading…")); - if (!view.available) return (0, import_react16.createElement)(AutomationOff); + if (!view.available) return (0, import_react16.createElement)(AutomationOff, { actions }); const shown = (next) => { publish(next); setAdding(false); @@ -8565,8 +8569,8 @@ function BotSchedules({ bot, roster, actions }) { !view.available ? (0, import_react16.createElement)( "div", { className: "bt-set-row bt-bs-row" }, - (0, import_react16.createElement)("span", { className: "bt-set-copy" }, (0, import_react16.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page in Harness settings."))), - (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: openHarnessSettings }, t("Open Harness settings")) + (0, import_react16.createElement)("span", { className: "bt-set-copy" }, (0, import_react16.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page."))), + (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins")) ) : [ ...tasks.map((task) => (0, import_react16.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish })), (0, import_react16.createElement)("button", { key: "new", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => actions.openSettings("schedules", bot.id) }, (0, import_react16.createElement)(PlusIcon), t("New task")) @@ -8581,7 +8585,7 @@ function ScheduleSummary({ bot, actions }) { "div", null, (0, import_react16.createElement)("div", { className: "bt-section-title" }, t("Scheduled tasks")), - tasks.length === 0 ? (0, import_react16.createElement)("button", { type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules", bot.id) }, view?.available === false ? t("Switch on Automation tasks on the Plugins page in Harness settings.") : t("No scheduled tasks yet. Add one")) : (0, import_react16.createElement)("div", { className: "bt-task-list" }, tasks.slice(0, 3).map((task) => (0, import_react16.createElement)( + tasks.length === 0 ? (0, import_react16.createElement)("button", { type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules", bot.id) }, view?.available === false ? t("Switch on Automation tasks on the Plugins page.") : t("No scheduled tasks yet. Add one")) : (0, import_react16.createElement)("div", { className: "bt-task-list" }, tasks.slice(0, 3).map((task) => (0, import_react16.createElement)( "button", { key: task.id, type: "button", className: "bt-task-line", onClick: () => actions.openSettings("schedules") }, (0, import_react16.createElement)(ClockIcon), diff --git a/core/index.js b/core/index.js index f929209e7..86ef38df2 100644 --- a/core/index.js +++ b/core/index.js @@ -32,6 +32,7 @@ import * as connectors from './src/host/connectors.js' import * as connectorAccess from './src/host/connector-access.js' import * as capabilities from './src/host/capabilities.js' import * as schedules from './src/host/schedules.js' +import * as automationDefault from './src/host/automation-default.js' import * as usage from './src/host/usage.js' import * as update from './src/host/update.js' import * as dshUpdate from './src/host/dsh-update.js' @@ -48,7 +49,7 @@ export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController', 'ses // the functions other modules call; they call them through `rt` at run time, never during // install. Listeners run and tools are listed in registration order, so this order holds: // usage meters model requests after every listener that rewrites them. -const MODULES = [diagnostics, store, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, roomMembership, reach, tasks, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, browser, usage, update, dshUpdate, hooks, remoteChat, channel] +const MODULES = [diagnostics, store, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, roomMembership, reach, tasks, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, channel] export function apply(ctx, config = {}) { const rt = createRuntime(ctx, config) diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index fd1fcaefc..8a745c059 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -955,8 +955,8 @@ const ZH = { Unit: '单位', 'Create task': '创建任务', 'Turn on Automation tasks in DSH': '在 DSH 里打开「自动化任务」', - 'Switch on Automation tasks on the Plugins page in Harness settings.': '在 Harness 设置的插件页启用「自动化任务」。', - 'Open Harness settings': '打开 Harness 设置', + 'Switch on Automation tasks on the Plugins page.': '在「插件」页面打开「自动化任务」。', + 'Open Plugins': '打开「插件」页面', 'New task': '新建任务', 'No scheduled tasks yet.': '还没有定时任务。', 'No scheduled tasks yet. Add one': '还没有定时任务,去添加', diff --git a/core/src/client/schedules.js b/core/src/client/schedules.js index 6916b7cb1..a61fe2544 100644 --- a/core/src/client/schedules.js +++ b/core/src/client/schedules.js @@ -4,7 +4,6 @@ import { createElement as h, Fragment, useEffect, useState } from 'react' import { BotAvatar, RoomAvatar } from './mark.js' import { ClockIcon, PlusIcon, TrashIcon } from './icons.js' -import { openHarnessSettings } from './sidebar.js' import { Segmented } from './motion.js' import { dateLocale, hostText, t } from './i18n.js' @@ -169,20 +168,27 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { h('button', { type: 'submit', className: 'bt-send bt-soft-sm', disabled: busy || !target || title.trim() === '' || prompt.trim() === '' }, busy ? t('Creating…') : t('Create task')))) } +// The switch for Automation tasks is DSH's Plugins panel, not its settings dialog. The +// Settings dialog closes first so the panel is not left behind it. +export function openPlugins(actions) { + actions.closeSettings() + actions.selectPanel('plugins') +} + // Settings has no schedule service to show: Automation tasks are off in DSH. -function AutomationOff() { +function AutomationOff({ actions }) { return h('div', { className: 'bt-set-card bt-task-off' }, h('span', { className: 'bt-task-off-mark' }, h(ClockIcon)), h('span', { className: 'bt-task-off-title' }, t('Turn on Automation tasks in DSH')), - h('span', { className: 'bt-set-hint' }, t('Switch on Automation tasks on the Plugins page in Harness settings.')), - h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: openHarnessSettings }, t('Open Harness settings'))) + h('span', { className: 'bt-set-hint' }, t('Switch on Automation tasks on the Plugins page.')), + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => openPlugins(actions) }, t('Open Plugins'))) } export function SchedulesPage({ roster, actions, start }) { const view = useSchedules(actions) const [adding, setAdding] = useState(Boolean(start)) if (view === null) return h('div', { className: 'bt-set-hint' }, t('Loading…')) - if (!view.available) return h(AutomationOff) + if (!view.available) return h(AutomationOff, { actions }) const shown = next => { publish(next); setAdding(false) } const byChat = [ ...roster.bots.map(bot => [bot.id, h(BotAvatar, { bot, size: 18, badge: false, live: false }), bot.name, view.tasks.filter(task => task.botId === bot.id)]), @@ -210,8 +216,8 @@ export function BotSchedules({ bot, roster, actions }) { h('div', { className: 'bt-set-card' }, !view.available ? h('div', { className: 'bt-set-row bt-bs-row' }, - h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-set-hint' }, t('Switch on Automation tasks on the Plugins page in Harness settings.'))), - h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: openHarnessSettings }, t('Open Harness settings'))) + h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-set-hint' }, t('Switch on Automation tasks on the Plugins page.'))), + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => openPlugins(actions) }, t('Open Plugins'))) : [ ...tasks.map(task => h(TaskRow, { key: task.id, task, actions, roster, onView: publish })), h('button', { key: 'new', type: 'button', className: 'bt-set-row bt-bs-row bt-gs-add', onClick: () => actions.openSettings('schedules', bot.id) }, h(PlusIcon), t('New task')), @@ -225,7 +231,7 @@ export function ScheduleSummary({ bot, actions }) { return h('div', null, h('div', { className: 'bt-section-title' }, t('Scheduled tasks')), tasks.length === 0 - ? h('button', { type: 'button', className: 'bt-more', onClick: () => actions.openSettings('schedules', bot.id) }, view?.available === false ? t('Switch on Automation tasks on the Plugins page in Harness settings.') : t('No scheduled tasks yet. Add one')) + ? h('button', { type: 'button', className: 'bt-more', onClick: () => actions.openSettings('schedules', bot.id) }, view?.available === false ? t('Switch on Automation tasks on the Plugins page.') : t('No scheduled tasks yet. Add one')) : 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)))))) } diff --git a/core/src/host/automation-default.js b/core/src/host/automation-default.js new file mode 100644 index 000000000..d9e7c1a31 --- /dev/null +++ b/core/src/host/automation-default.js @@ -0,0 +1,106 @@ +// Automation tasks on by default. DSH 0.2 ships the schedule service as an optional bundle +// (`@deepseek-ai/dsh-experimental-schedule-bundle`) that its plugin manager offers switched off, +// and without it Settings → Scheduled tasks has no service to talk to (see schedules.js). +// DSH keeps that choice in the profile, which FlyBot must not edit by hand, so the first time a +// profile runs FlyBot, FlyBot asks the plugin manager to switch the bundle on and writes the +// offer down in `dsh-defaults.json` next to the team. From then on the profile decides: a person +// who switches Automation tasks off on the Plugins page keeps it off across restarts and updates. +// A DSH that does not offer the bundle is left alone, and so is a second dsh on the same data. +import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +export const SCHEDULE_BUNDLE = '@deepseek-ai/dsh-experimental-schedule-bundle' +const MARKER = 'dsh-defaults.json' +// DSH is still composing its tree in the first seconds; the manager reloads it live afterwards. +const STARTUP_DELAY_MS = 20_000 + +const isRecord = value => value !== null && typeof value === 'object' && !Array.isArray(value) + +// The offers made so far, `{ [bundle]: { [profile]: ISO time } }`. A file that exists but cannot +// be read is an unknown history, which must never read as "not offered yet". +export async function readOffers(file) { + let text + try { + text = await readFile(file, 'utf8') + } catch (error) { + if (error?.code === 'ENOENT') return {} + throw error + } + const parsed = JSON.parse(text) + if (!isRecord(parsed) || (parsed.offers !== undefined && !isRecord(parsed.offers))) throw new Error(`${file} is not a FlyBot defaults file`) + return parsed.offers ?? {} +} + +async function writeOffers(file, offers) { + await mkdir(join(file, '..'), { recursive: true }) + const temp = `${file}.${process.pid}.tmp` + try { + await writeFile(temp, `${JSON.stringify({ version: 1, offers }, null, 2)}\n`, 'utf8') + await rename(temp, file) + } catch (error) { + await unlink(temp).catch(() => { /* never written: the write error is the one to report */ }) + throw error + } +} + +// Switches the bundle on once per profile. Returns what happened, for the log and the tests: +// 'decided' already offered here; the profile is the person's now +// 'unavailable' this DSH does not offer the bundle (or has no plugin manager); tried again at the next start +// 'already-on' on already (switched on by hand); noted, so a later "off" is respected +// 'unrecorded' the offer could not be written down, so nothing was changed +// 'enabled' switched on +// 'failed' the manager refused; the note is withdrawn so the next start tries again +export async function offerBundle({ manager, bundle = SCHEDULE_BUNDLE, profile, file, now = () => new Date() }) { + let offers + try { + offers = await readOffers(file) + } catch { + return 'unrecorded' + } + if (offers[bundle]?.[profile] !== undefined) return 'decided' + if (typeof manager?.listBundles !== 'function' || typeof manager.setBundleEnabled !== 'function') return 'unavailable' + const listed = await manager.listBundles() + const entry = Array.isArray(listed) ? listed.find(item => item?.name === bundle) : undefined + if (entry === undefined || entry.error !== undefined) return 'unavailable' + const note = async (value) => { + const next = { ...offers, [bundle]: { ...offers[bundle] } } + if (value === undefined) delete next[bundle][profile] + else next[bundle][profile] = value + await writeOffers(file, next) + } + try { + await note(now().toISOString()) + } catch { + return 'unrecorded' + } + if (entry.enabled === true) return 'already-on' + const result = await manager.setBundleEnabled(bundle, true).catch(error => ({ error: { code: 'operation-error', diagnostic: String(error?.message ?? error) } })) + if (result?.error !== undefined || result?.application === 'failed') { + await note(undefined).catch(() => { /* best effort: a note that stays only means this profile is not asked again */ }) + return 'failed' + } + return 'enabled' +} + +export function install(rt) { + const { ctx, config, home } = rt + if (config.automationTasks === false) return + + async function run() { + await rt.load() + // The window that shows the team without owning it leaves the profile to the other one. + if (rt.readOnly() !== undefined) return + const profile = ctx.get?.('profileContext')?.name ?? 'default' + const outcome = await offerBundle({ manager: ctx.get?.('pluginManager'), profile, file: join(home, MARKER) }) + if (outcome === 'failed') rt.warn('ds-bot: could not switch on Automation tasks; switch them on from the Plugins page') + } + + ctx.effect(() => { + const timer = setTimeout(() => { run().catch(error => rt.warn('ds-bot: Automation tasks default: %s', error?.message ?? error)) }, STARTUP_DELAY_MS) + // A pending first offer never keeps the process alive. + timer.unref?.() + return () => clearTimeout(timer) + }, 'ds-bot: automation tasks default') + + rt.offerAutomationTasks = run +} diff --git a/core/test/automation-default.test.js b/core/test/automation-default.test.js new file mode 100644 index 000000000..e227d95da --- /dev/null +++ b/core/test/automation-default.test.js @@ -0,0 +1,144 @@ +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { test } from 'node:test' +import { createRuntime } from '../index.js' +import { SCHEDULE_BUNDLE, offerBundle } from '../src/host/automation-default.js' +import { fakeContext } from './fake-ctx.js' + +// A stand-in for DSH's plugin manager: one optional bundle, switched on and off like the Plugins page does. +function manager({ enabled = false, listed = true, error, refuse } = {}) { + const calls = [] + let on = enabled + return { + calls, + get on() { return on }, + set on(value) { on = value }, + async listBundles() { + calls.push('list') + return listed ? [{ name: '@deepseek-ai/dsh-base', enabled: true }, { name: SCHEDULE_BUNDLE, enabled: on, optional: true, error }] : [{ name: '@deepseek-ai/dsh-base', enabled: true }] + }, + async setBundleEnabled(name, value) { + calls.push(`set ${name} ${value}`) + if (refuse) return { changed: false, application: 'failed', stage: 'enable', target: name, error: { code: 'operation-error' } } + on = value + return { changed: true, application: 'applied', stage: 'enable', target: name, enabled: value } + }, + } +} + +const scratch = (t) => { + const dir = mkdtempSync(join(tmpdir(), 'dsb-automation-')) + t.after(() => rmSync(dir, { recursive: true, force: true, maxRetries: 5 })) + return join(dir, 'dsh-defaults.json') +} + +test('a profile that has the bundle off gets it switched on once, and the offer is written down', async (t) => { + const file = scratch(t) + const plugins = manager() + assert.equal(await offerBundle({ manager: plugins, profile: 'web', file }), 'enabled') + assert.equal(plugins.on, true) + assert.deepEqual(plugins.calls, ['list', `set ${SCHEDULE_BUNDLE} true`]) + assert.equal(typeof JSON.parse(readFileSync(file, 'utf8')).offers[SCHEDULE_BUNDLE].web, 'string') +}) + +test('a person who switches it off keeps it off: the next start does not ask again', async (t) => { + const file = scratch(t) + const plugins = manager() + await offerBundle({ manager: plugins, profile: 'web', file }) + plugins.on = false + plugins.calls.length = 0 + assert.equal(await offerBundle({ manager: plugins, profile: 'web', file }), 'decided') + assert.equal(plugins.on, false) + assert.deepEqual(plugins.calls, []) +}) + +test('a bundle that is on already is noted, so turning it off later sticks', async (t) => { + const file = scratch(t) + const plugins = manager({ enabled: true }) + assert.equal(await offerBundle({ manager: plugins, profile: 'desktop', file }), 'already-on') + assert.deepEqual(plugins.calls, ['list']) + plugins.on = false + assert.equal(await offerBundle({ manager: plugins, profile: 'desktop', file }), 'decided') + assert.equal(plugins.on, false) +}) + +test('each profile is offered once, on its own', async (t) => { + const file = scratch(t) + const web = manager() + const desktop = manager() + assert.equal(await offerBundle({ manager: web, profile: 'web', file }), 'enabled') + assert.equal(await offerBundle({ manager: desktop, profile: 'desktop', file }), 'enabled') + assert.equal(await offerBundle({ manager: web, profile: 'web', file }), 'decided') +}) + +test('a DSH that does not offer the bundle, or cannot read it, is left alone and asked again later', async (t) => { + const file = scratch(t) + assert.equal(await offerBundle({ manager: manager({ listed: false }), profile: 'web', file }), 'unavailable') + assert.equal(await offerBundle({ manager: manager({ error: { code: 'operation-error' } }), profile: 'web', file }), 'unavailable') + assert.equal(await offerBundle({ manager: undefined, profile: 'web', file }), 'unavailable') + assert.equal(await offerBundle({ manager: {}, profile: 'web', file }), 'unavailable') + assert.equal(existsSync(file), false) +}) + +test('a refusal is not remembered, so the next start tries again', async (t) => { + const file = scratch(t) + assert.equal(await offerBundle({ manager: manager({ refuse: true }), profile: 'web', file }), 'failed') + assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')).offers[SCHEDULE_BUNDLE], {}) + assert.equal(await offerBundle({ manager: manager(), profile: 'web', file }), 'enabled') +}) + +test('a manager that throws is a refusal, not a crash', async (t) => { + const file = scratch(t) + const plugins = manager() + plugins.setBundleEnabled = async () => { throw new Error('pnpm is missing') } + assert.equal(await offerBundle({ manager: plugins, profile: 'web', file }), 'failed') +}) + +test('an unreadable record changes nothing: an unknown history never reads as "not offered"', async (t) => { + const file = scratch(t) + writeFileSync(file, '{ not json') + const plugins = manager() + assert.equal(await offerBundle({ manager: plugins, profile: 'web', file }), 'unrecorded') + assert.deepEqual(plugins.calls, []) + assert.equal(readFileSync(file, 'utf8'), '{ not json') +}) + +test('a record that is not a file (a directory in its place) is an unknown history too', async (t) => { + const file = scratch(t) + const plugins = manager() + assert.equal(await offerBundle({ manager: plugins, profile: 'web', file: dirname(file) }), 'unrecorded') + assert.equal(plugins.on, false) +}) + +function setup(t, { config = {}, services = {} } = {}) { + const home = mkdtempSync(join(tmpdir(), 'dsb-automation-host-')) + const ctx = fakeContext({ services }) + const rt = createRuntime(ctx, { home, ...config }) + t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) + return { ctx, rt, home } +} + +test('the host offers the bundle through the plugin manager under the profile name', async (t) => { + const plugins = manager() + const { ctx, rt, home } = setup(t, { services: { pluginManager: plugins, profileContext: { name: 'web' } } }) + assert.ok(ctx.calls.includes('effect ds-bot: automation tasks default')) + await rt.offerAutomationTasks() + assert.equal(plugins.on, true) + assert.ok(JSON.parse(readFileSync(join(home, 'dsh-defaults.json'), 'utf8')).offers[SCHEDULE_BUNDLE].web) +}) + +test('a window that only shows the team leaves the profile to the other dsh', async (t) => { + const plugins = manager() + const { rt } = setup(t, { services: { pluginManager: plugins } }) + rt.readOnly = () => 'open in another dsh' + await rt.offerAutomationTasks() + assert.deepEqual(plugins.calls, []) +}) + +test('config.automationTasks = false never offers it', (t) => { + const { ctx, rt } = setup(t, { config: { automationTasks: false }, services: { pluginManager: manager() } }) + assert.equal(ctx.calls.includes('effect ds-bot: automation tasks default'), false) + assert.equal(rt.offerAutomationTasks, undefined) +}) diff --git a/core/test/install.test.js b/core/test/install.test.js index 8706f0146..b41d4d543 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: automation tasks default', 'effect ds-bot: usage ledger', 'on llm/stream', 'effect ds-bot: update check', diff --git a/core/test/schedules-client.test.js b/core/test/schedules-client.test.js new file mode 100644 index 000000000..b50f0c660 --- /dev/null +++ b/core/test/schedules-client.test.js @@ -0,0 +1,82 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +// The "Automation tasks are off" hint and its button send people to DSH's Plugins panel, +// where the switch is; the Harness settings dialog has none. +const { outputFiles } = await build({ + stdin: { + contents: "export { SchedulesPage, BotSchedules, openPlugins } from './src/client/schedules.js'", + resolveDir: fileURLToPath(new URL('..', import.meta.url)), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) +const h = (type, props, ...children) => ({ type, props: { ...props, children } }) +const OFF = { available: false, tasks: [] } +const react = { createElement: h, Fragment: Symbol('F'), useEffect() {}, useLayoutEffect() {}, useState: v => [v === null ? OFF : v, () => {}], useMemo: f => f(), useRef: current => ({ current }), useCallback: f => f, useSyncExternalStore: (_s, get) => get() } +const module = { exports: {} } +runInNewContext(outputFiles[0].text, { + module, exports: module.exports, document: { hidden: false }, navigator: { language: 'en' }, localStorage: { getItem: () => null, setItem() {} }, + requestAnimationFrame: run => run(), + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: node => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return {} + throw new Error(`Unexpected platform module ${name}`) + }, +}) +const { SchedulesPage, BotSchedules, openPlugins } = module.exports +// Every node and string under a tree; an array (what a page returns) is walked too. +const all = (node, out = []) => { + if (Array.isArray(node)) { for (const item of node) all(item, out); return out } + if (typeof node === 'string') out.push(node) + else if (node && typeof node === 'object') { out.push(node); all(node.props?.children, out) } + return out +} +const text = node => all(node).filter(item => typeof item === 'string') +const spy = () => { + const log = [] + return { log, closeSettings: () => log.push('closeSettings'), selectPanel: id => log.push(`selectPanel ${id}`), openSettings: () => log.push('openSettings') } +} +const buttonsOf = tree => all(tree).filter(node => node.type === 'button') + +test('openPlugins closes the Settings dialog first, then opens the Plugins panel', () => { + const actions = spy() + openPlugins(actions) + assert.deepEqual(actions.log, ['closeSettings', 'selectPanel plugins']) +}) + +test('Settings → Scheduled tasks, with Automation tasks off, has one button and it opens Plugins', () => { + const actions = spy() + const element = SchedulesPage({ roster: { bots: [], rooms: [] }, actions, start: null }) + const page = element.type(element.props) + const buttons = buttonsOf(page) + assert.equal(buttons.length, 1) + assert.deepEqual(text(buttons[0]), ['Open Plugins']) + buttons[0].props.onClick() + assert.deepEqual(actions.log, ['closeSettings', 'selectPanel plugins']) + assert.ok(text(page).includes('Switch on Automation tasks on the Plugins page.')) + assert.ok(!text(page).some(line => /Harness settings/.test(line))) +}) + +test("a Bot's Scheduled tasks card, with Automation tasks off, opens Plugins too", () => { + const actions = spy() + const card = BotSchedules({ bot: { id: 'b1' }, roster: { bots: [], rooms: [] }, actions }) + const buttons = buttonsOf(card) + assert.equal(buttons.length, 1) + assert.deepEqual(text(buttons[0]), ['Open Plugins']) + buttons[0].props.onClick() + assert.deepEqual(actions.log, ['closeSettings', 'selectPanel plugins']) +}) + +test('the hint reads the same in English and Chinese, and says Plugins page, not Harness settings', () => { + const source = readFileSync(new URL('../src/client/i18n.js', import.meta.url), 'utf8') + assert.match(source, /'Switch on Automation tasks on the Plugins page\.': '在「插件」页面打开「自动化任务」。'/) + assert.match(source, /'Open Plugins': /) + assert.doesNotMatch(source, /Automation tasks on the Plugins page in Harness settings/) + assert.doesNotMatch(readFileSync(new URL('../src/client/schedules.js', import.meta.url), 'utf8'), /openHarnessSettings|Open Harness settings/) +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 6d8fa1ed4..b7b24b068 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -27,6 +27,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | `core/src/client/new-chat.js` | Sidebar **+** picker. Plugin rows via `flybot.new-chat.action` (`newChatActionRows` in `flybot-registry.js`); social adds Face-to-face group and Add friend (copy actions live inside Add friend, #181). The DSH Agent row reads **Add coding agent** (hint "A plain DSH session, not a Bot") so it is not mistaken for **Create new Bot** (#181). The short + menu does not list individual Bots. A group the host refuses because one of my Bots is not shared with the friend whose Bot is in it (`error.details.reason === 'ungranted-local'`, put on the error by `client/api.js`) is drawn as a card, not a red line: it names the Bot(s) and friend(s), and offers **Copy invite link for {Bot}** (`__flybot__.social.createInvite`; shown only when social is loaded and can invite that Bot, nothing is granted by the click; it says *copied* only when `copiedToClipboard` in `shared/remote-label.js` reports the write took, else it shows the link in a read-only, select-on-focus field to copy by hand) and **Leave {Bot} out** (`ungrantedLocalFix`, `shared/remote-label.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, then Create group chat, plugin actions, then Add Agent). `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. 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`. | ## Invariants diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 07d1cdf40..b8e0199cf 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -42,6 +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/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. | ## Invariants @@ -172,6 +173,15 @@ The About page's facts come from `src/host/update.js` and the core's `package.js - **How far behind.** For a branch install with a known commit, a check makes one unauthenticated `GET /repos///compare/...` and reports `behind` = `ahead_by` (null on a failure, a diverged branch, or a branch name with odd characters). Nothing is ever offered to install for a branch install: updates come from the maintainer reinstalling FlyBot. `config.packagePath` overrides the path read, for tests. - **Version.** `current` is still the core's own version (`0.1.5-flybot.1`); the page adds `· ` to it. FlyBot's product version (tags such as v0.1.0) is not in the install, so it is not shown. +### Automation tasks on by default (#378) + +DSH 0.2 offers the schedule service only through its optional bundle `@deepseek-ai/dsh-experimental-schedule-bundle` ("Automation tasks"), and the bundle list in a profile belongs to DSH. Without the bundle `schedules.js` has no service and Settings → Scheduled tasks shows only the hint. The bundle ships inside the DSH install, so the core adds no dependency (`core/package.json` is unchanged). + +- **How.** About 20 seconds after start, `automation-default.js` calls `pluginManager.listBundles()` and, for a profile that has not been offered yet, `setBundleEnabled(bundle, true)`: the same call the Plugins page makes, applied live. It does not write the profile's manifest or patch files itself. A static `cordis.patch.yml` insert was rejected: the Plugins page could not turn it off and it would duplicate the rows of anyone who already switched the bundle on. +- **Once per profile.** The offer is written first to `dsh-defaults.json` in the team's home (`offers[][]`), then made. A bundle that is already on is noted the same way. After that the profile decides, so a person who switches Automation tasks off keeps it off across restarts and updates. +- **Fail closed.** An unreadable record, a record that cannot be written, a DSH without the bundle or without a plugin manager, a bundle with a load error, and a window that only shows the team (`rt.readOnly()`) change nothing. A refusal by the manager withdraws the note so the next start tries again. +- **Opt out.** `automationTasks: false` in the `bot` config. + ### 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 2a87a2464..5aaa8cdcc 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -11,8 +11,11 @@ diff --git a/README.md b/README.md - **Usage**: Settings → Usage shows token usage by time (24 hours to 12 months, or days picked on a calendar), by Bot and by model, with a page for each Bot and each model. Calls that a Session logs are read from the logs, so older chats count too. Calls that leave no log (checkpoints, handoff notes, the steward, image readings, dsh titles and compaction) are counted while they stream, from the time the plugin runs. Tokens only, no cost. - **Make it yours**: name, role, instructions, model and look (20 characters and 12 colors, or your own photo) for each Bot; a theme, an accent color and an avatar animation level (quiet, normal or lively) for the team. A pinned Bot or group chat shows a small pushpin in the sidebar. Settings has these pages: General (look), Bots (Main Bots and each Bot's settings), Group chats (each group's settings), Connectors, Capabilities, Scheduled tasks, Usage, Secrets, Feedback and About DS Bot (versions, updates and links). -@@ -74,6 +74,7 @@ Options go in the profile's `cordis.patch.yml`, under the `bot` entry. All of th +@@ -72,8 +72,10 @@ Options go in the profile's `cordis.patch.yml`, under the `bot` entry. All of th + memoryReview: true # false leaves memory to the Bots' own remember calls + diagnosticsFile: false # keep the error log in memory only; default /logs/diagnostics.jsonl updateCheck: true # look for a newer DS Bot when a window opens; default true ++ automationTasks: false # false stops FlyBot switching on DSH's Automation tasks bundle (once per profile) for Scheduled tasks; default on updateRegistry: https://registry.example/ # ask only this npm registry for updates github: false # hide the GitHub connector; default shown in Connectors + composio: false # hide the Composio connector; default shown in Connectors @@ -570,6 +573,17 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Scheduled tasks': '定时任务', 'Every day': '每天', 'Every {count} days': '每 {count} 天', +@@ -806,8 +806,8 @@ const ZH = { + Unit: '单位', + 'Create task': '创建任务', + 'Turn on Automation tasks in DSH': '在 DSH 里打开「自动化任务」', +- 'Switch on Automation tasks on the Plugins page in Harness settings.': '在 Harness 设置的插件页启用「自动化任务」。', +- 'Open Harness settings': '打开 Harness 设置', ++ 'Switch on Automation tasks on the Plugins page.': '在「插件」页面打开「自动化任务」。', ++ 'Open Plugins': '打开「插件」页面', + 'New task': '新建任务', + 'No scheduled tasks yet.': '还没有定时任务。', + 'No scheduled tasks yet. Add one': '还没有定时任务,去添加', @@ -851,10 +851,13 @@ const HOST_ERRORS = [ [/^A group chat holds at most (\d+) Bots$/, '一个群聊最多 $1 个 Bot'], [/^Reply mode must be one of (.+)$/, '回复模式只能是 $1'], @@ -665,6 +679,69 @@ diff --git a/src/client/model-menu.js b/src/client/model-menu.js // Ways to get a first model. The DeepSeek sign-in exists only where the shell carries // its account plugin (the Desktop); the API key and custom models use the shell's // own editors. +diff --git a/src/client/schedules.js b/src/client/schedules.js +--- a/src/client/schedules.js ++++ b/src/client/schedules.js +@@ -4,7 +4,6 @@ + import { createElement as h, Fragment, useEffect, useState } from 'react' + import { BotAvatar, RoomAvatar } from './mark.js' + import { ClockIcon, PlusIcon, TrashIcon } from './icons.js' +-import { openHarnessSettings } from './sidebar.js' + import { Segmented } from './motion.js' + import { dateLocale, hostText, t } from './i18n.js' + +@@ -169,20 +169,27 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { + h('button', { type: 'submit', className: 'bt-send bt-soft-sm', disabled: busy || !target || title.trim() === '' || prompt.trim() === '' }, busy ? t('Creating…') : t('Create task')))) + } + ++// The switch for Automation tasks is DSH's Plugins panel, not its settings dialog. The ++// Settings dialog closes first so the panel is not left behind it. ++export function openPlugins(actions) { ++ actions.closeSettings() ++ actions.selectPanel('plugins') ++} ++ + // Settings has no schedule service to show: Automation tasks are off in DSH. +-function AutomationOff() { ++function AutomationOff({ actions }) { + return h('div', { className: 'bt-set-card bt-task-off' }, + h('span', { className: 'bt-task-off-mark' }, h(ClockIcon)), + h('span', { className: 'bt-task-off-title' }, t('Turn on Automation tasks in DSH')), +- h('span', { className: 'bt-set-hint' }, t('Switch on Automation tasks on the Plugins page in Harness settings.')), +- h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: openHarnessSettings }, t('Open Harness settings'))) ++ h('span', { className: 'bt-set-hint' }, t('Switch on Automation tasks on the Plugins page.')), ++ h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => openPlugins(actions) }, t('Open Plugins'))) + } + + export function SchedulesPage({ roster, actions, start }) { + const view = useSchedules(actions) + const [adding, setAdding] = useState(Boolean(start)) + if (view === null) return h('div', { className: 'bt-set-hint' }, t('Loading…')) +- if (!view.available) return h(AutomationOff) ++ if (!view.available) return h(AutomationOff, { actions }) + const shown = next => { publish(next); setAdding(false) } + const byChat = [ + ...roster.bots.map(bot => [bot.id, h(BotAvatar, { bot, size: 18, badge: false, live: false }), bot.name, view.tasks.filter(task => task.botId === bot.id)]), +@@ -210,8 +210,8 @@ export function BotSchedules({ bot, roster, actions }) { + h('div', { className: 'bt-set-card' }, + !view.available + ? h('div', { className: 'bt-set-row bt-bs-row' }, +- h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-set-hint' }, t('Switch on Automation tasks on the Plugins page in Harness settings.'))), +- h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: openHarnessSettings }, t('Open Harness settings'))) ++ h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-set-hint' }, t('Switch on Automation tasks on the Plugins page.'))), ++ h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => openPlugins(actions) }, t('Open Plugins'))) + : [ + ...tasks.map(task => h(TaskRow, { key: task.id, task, actions, roster, onView: publish })), + h('button', { key: 'new', type: 'button', className: 'bt-set-row bt-bs-row bt-gs-add', onClick: () => actions.openSettings('schedules', bot.id) }, h(PlusIcon), t('New task')), +@@ -225,7 +225,7 @@ export function ScheduleSummary({ bot, actions }) { + return h('div', null, + h('div', { className: 'bt-section-title' }, t('Scheduled tasks')), + tasks.length === 0 +- ? h('button', { type: 'button', className: 'bt-more', onClick: () => actions.openSettings('schedules', bot.id) }, view?.available === false ? t('Switch on Automation tasks on the Plugins page in Harness settings.') : t('No scheduled tasks yet. Add one')) ++ ? h('button', { type: 'button', className: 'bt-more', onClick: () => actions.openSettings('schedules', bot.id) }, view?.available === false ? t('Switch on Automation tasks on the Plugins page.') : t('No scheduled tasks yet. Add one')) + : 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)))))) + } diff --git a/src/client/text.js b/src/client/text.js --- a/src/client/text.js +++ b/src/client/text.js @@ -2560,7 +2637,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,18 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { +@@ -91,12 +91,19 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { 'on llm/stream', 'effect ds-bot: load secrets', 'effect ds-bot: connectors', @@ -2569,6 +2646,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: automation tasks default', 'effect ds-bot: usage ledger', 'on llm/stream', 'effect ds-bot: update check', diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index 6ca871d71..9f749b44a 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -15,13 +15,14 @@ diff --git a/index.js b/index.js import * as models from './src/host/models.js' import * as permissions from './src/host/permissions.js' import * as turns from './src/host/turns.js' -@@ -24,11 +24,14 @@ import * as images from './src/host/images.js' +@@ -24,11 +24,15 @@ import * as images from './src/host/images.js' import * as browser from './src/host/browser.js' import * as secrets from './src/host/secrets.js' import * as connectors from './src/host/connectors.js' +import * as connectorAccess from './src/host/connector-access.js' import * as capabilities from './src/host/capabilities.js' import * as schedules from './src/host/schedules.js' ++import * as automationDefault from './src/host/automation-default.js' import * as usage from './src/host/usage.js' import * as update from './src/host/update.js' import * as dshUpdate from './src/host/dsh-update.js' @@ -35,7 +36,7 @@ diff --git a/index.js b/index.js // install. Listeners run and tools are listed in registration order, so this order holds: // usage meters model requests after every listener that rewrites them. -const MODULES = [diagnostics, store, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, approvals, resume, memory, parts, images, secrets, connectors, capabilities, schedules, browser, usage, update, dshUpdate, channel] -+const MODULES = [diagnostics, store, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, roomMembership, reach, tasks, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, browser, usage, update, dshUpdate, hooks, remoteChat, channel] ++const MODULES = [diagnostics, store, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, roomMembership, reach, tasks, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, channel] export function apply(ctx, config = {}) { - createRuntime(ctx, config) diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index a09206216..62b79e762 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -330,6 +330,17 @@ diff --git a/client.js b/client.js "Scheduled tasks": "定时任务", "Every day": "每天", "Every {count} days": "每 {count} 天", +@@ -856,8 +856,8 @@ var ZH = { + Unit: "单位", + "Create task": "创建任务", + "Turn on Automation tasks in DSH": "在 DSH 里打开「自动化任务」", +- "Switch on Automation tasks on the Plugins page in Harness settings.": "在 Harness 设置的插件页启用「自动化任务」。", +- "Open Harness settings": "打开 Harness 设置", ++ "Switch on Automation tasks on the Plugins page.": "在「插件」页面打开「自动化任务」。", ++ "Open Plugins": "打开「插件」页面", + "New task": "新建任务", + "No scheduled tasks yet.": "还没有定时任务。", + "No scheduled tasks yet. Add one": "还没有定时任务,去添加", @@ -898,10 +898,13 @@ var HOST_ERRORS = [ [/^A group chat holds at most (\d+) Bots$/, "一个群聊最多 $1 个 Bot"], [/^Reply mode must be one of (.+)$/, "回复模式只能是 $1"], @@ -5730,7 +5741,7 @@ diff --git a/client.js b/client.js const timing = () => { if (kind === "at") return { kind, at: new Date(at).toISOString() }; if (kind === "every") return { kind, minutes: Math.round(Number(count) * UNITS.find(([id]) => id === unit)[1]) }; -@@ -6981,101 +6981,101 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { +@@ -6981,101 +6981,105 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { }); }; const toggleDay = (day) => setWeekdays((days) => days.includes(day) ? days.filter((item) => item !== day) : [...days, day].sort()); @@ -5828,8 +5839,13 @@ diff --git a/client.js b/client.js ) ); } - function AutomationOff() { +-function AutomationOff() { - return (0, import_react15.createElement)( ++function openPlugins(actions) { ++ actions.closeSettings(); ++ actions.selectPanel("plugins"); ++} ++function AutomationOff({ actions }) { + return (0, import_react16.createElement)( "div", { className: "bt-set-card bt-task-off" }, @@ -5839,8 +5855,8 @@ diff --git a/client.js b/client.js - (0, import_react15.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: openHarnessSettings }, t("Open Harness settings")) + (0, import_react16.createElement)("span", { className: "bt-task-off-mark" }, (0, import_react16.createElement)(ClockIcon)), + (0, import_react16.createElement)("span", { className: "bt-task-off-title" }, t("Turn on Automation tasks in DSH")), -+ (0, import_react16.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page in Harness settings.")), -+ (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: openHarnessSettings }, t("Open Harness settings")) ++ (0, import_react16.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page.")), ++ (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins")) ); } function SchedulesPage({ roster, actions, start }) { @@ -5850,7 +5866,7 @@ diff --git a/client.js b/client.js - if (!view.available) return (0, import_react15.createElement)(AutomationOff); + const [adding, setAdding] = (0, import_react16.useState)(Boolean(start)); + if (view === null) return (0, import_react16.createElement)("div", { className: "bt-set-hint" }, t("Loading…")); -+ if (!view.available) return (0, import_react16.createElement)(AutomationOff); ++ if (!view.available) return (0, import_react16.createElement)(AutomationOff, { actions }); const shown = (next) => { publish(next); setAdding(false); @@ -5895,8 +5911,8 @@ diff --git a/client.js b/client.js { className: "bt-set-row bt-bs-row" }, - (0, import_react15.createElement)("span", { className: "bt-set-copy" }, (0, import_react15.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page in Harness settings."))), - (0, import_react15.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: openHarnessSettings }, t("Open Harness settings")) -+ (0, import_react16.createElement)("span", { className: "bt-set-copy" }, (0, import_react16.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page in Harness settings."))), -+ (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: openHarnessSettings }, t("Open Harness settings")) ++ (0, import_react16.createElement)("span", { className: "bt-set-copy" }, (0, import_react16.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page."))), ++ (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins")) ) : [ - ...tasks.map((task) => (0, import_react15.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish })), - (0, import_react15.createElement)("button", { key: "new", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => actions.openSettings("schedules", bot.id) }, (0, import_react15.createElement)(PlusIcon), t("New task")) @@ -5916,7 +5932,7 @@ diff --git a/client.js b/client.js - (0, import_react15.createElement)("div", { className: "bt-section-title" }, t("Scheduled tasks")), - tasks.length === 0 ? (0, import_react15.createElement)("button", { type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules", bot.id) }, view?.available === false ? t("Switch on Automation tasks on the Plugins page in Harness settings.") : t("No scheduled tasks yet. Add one")) : (0, import_react15.createElement)("div", { className: "bt-task-list" }, tasks.slice(0, 3).map((task) => (0, import_react15.createElement)( + (0, import_react16.createElement)("div", { className: "bt-section-title" }, t("Scheduled tasks")), -+ tasks.length === 0 ? (0, import_react16.createElement)("button", { type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules", bot.id) }, view?.available === false ? t("Switch on Automation tasks on the Plugins page in Harness settings.") : t("No scheduled tasks yet. Add one")) : (0, import_react16.createElement)("div", { className: "bt-task-list" }, tasks.slice(0, 3).map((task) => (0, import_react16.createElement)( ++ tasks.length === 0 ? (0, import_react16.createElement)("button", { type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules", bot.id) }, view?.available === false ? t("Switch on Automation tasks on the Plugins page.") : t("No scheduled tasks yet. Add one")) : (0, import_react16.createElement)("div", { className: "bt-task-list" }, tasks.slice(0, 3).map((task) => (0, import_react16.createElement)( "button", { key: task.id, type: "button", className: "bt-task-line", onClick: () => actions.openSettings("schedules") }, - (0, import_react15.createElement)(ClockIcon), diff --git a/patches/ds-bot/files/src/host/automation-default.js b/patches/ds-bot/files/src/host/automation-default.js new file mode 100644 index 000000000..d9e7c1a31 --- /dev/null +++ b/patches/ds-bot/files/src/host/automation-default.js @@ -0,0 +1,106 @@ +// Automation tasks on by default. DSH 0.2 ships the schedule service as an optional bundle +// (`@deepseek-ai/dsh-experimental-schedule-bundle`) that its plugin manager offers switched off, +// and without it Settings → Scheduled tasks has no service to talk to (see schedules.js). +// DSH keeps that choice in the profile, which FlyBot must not edit by hand, so the first time a +// profile runs FlyBot, FlyBot asks the plugin manager to switch the bundle on and writes the +// offer down in `dsh-defaults.json` next to the team. From then on the profile decides: a person +// who switches Automation tasks off on the Plugins page keeps it off across restarts and updates. +// A DSH that does not offer the bundle is left alone, and so is a second dsh on the same data. +import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +export const SCHEDULE_BUNDLE = '@deepseek-ai/dsh-experimental-schedule-bundle' +const MARKER = 'dsh-defaults.json' +// DSH is still composing its tree in the first seconds; the manager reloads it live afterwards. +const STARTUP_DELAY_MS = 20_000 + +const isRecord = value => value !== null && typeof value === 'object' && !Array.isArray(value) + +// The offers made so far, `{ [bundle]: { [profile]: ISO time } }`. A file that exists but cannot +// be read is an unknown history, which must never read as "not offered yet". +export async function readOffers(file) { + let text + try { + text = await readFile(file, 'utf8') + } catch (error) { + if (error?.code === 'ENOENT') return {} + throw error + } + const parsed = JSON.parse(text) + if (!isRecord(parsed) || (parsed.offers !== undefined && !isRecord(parsed.offers))) throw new Error(`${file} is not a FlyBot defaults file`) + return parsed.offers ?? {} +} + +async function writeOffers(file, offers) { + await mkdir(join(file, '..'), { recursive: true }) + const temp = `${file}.${process.pid}.tmp` + try { + await writeFile(temp, `${JSON.stringify({ version: 1, offers }, null, 2)}\n`, 'utf8') + await rename(temp, file) + } catch (error) { + await unlink(temp).catch(() => { /* never written: the write error is the one to report */ }) + throw error + } +} + +// Switches the bundle on once per profile. Returns what happened, for the log and the tests: +// 'decided' already offered here; the profile is the person's now +// 'unavailable' this DSH does not offer the bundle (or has no plugin manager); tried again at the next start +// 'already-on' on already (switched on by hand); noted, so a later "off" is respected +// 'unrecorded' the offer could not be written down, so nothing was changed +// 'enabled' switched on +// 'failed' the manager refused; the note is withdrawn so the next start tries again +export async function offerBundle({ manager, bundle = SCHEDULE_BUNDLE, profile, file, now = () => new Date() }) { + let offers + try { + offers = await readOffers(file) + } catch { + return 'unrecorded' + } + if (offers[bundle]?.[profile] !== undefined) return 'decided' + if (typeof manager?.listBundles !== 'function' || typeof manager.setBundleEnabled !== 'function') return 'unavailable' + const listed = await manager.listBundles() + const entry = Array.isArray(listed) ? listed.find(item => item?.name === bundle) : undefined + if (entry === undefined || entry.error !== undefined) return 'unavailable' + const note = async (value) => { + const next = { ...offers, [bundle]: { ...offers[bundle] } } + if (value === undefined) delete next[bundle][profile] + else next[bundle][profile] = value + await writeOffers(file, next) + } + try { + await note(now().toISOString()) + } catch { + return 'unrecorded' + } + if (entry.enabled === true) return 'already-on' + const result = await manager.setBundleEnabled(bundle, true).catch(error => ({ error: { code: 'operation-error', diagnostic: String(error?.message ?? error) } })) + if (result?.error !== undefined || result?.application === 'failed') { + await note(undefined).catch(() => { /* best effort: a note that stays only means this profile is not asked again */ }) + return 'failed' + } + return 'enabled' +} + +export function install(rt) { + const { ctx, config, home } = rt + if (config.automationTasks === false) return + + async function run() { + await rt.load() + // The window that shows the team without owning it leaves the profile to the other one. + if (rt.readOnly() !== undefined) return + const profile = ctx.get?.('profileContext')?.name ?? 'default' + const outcome = await offerBundle({ manager: ctx.get?.('pluginManager'), profile, file: join(home, MARKER) }) + if (outcome === 'failed') rt.warn('ds-bot: could not switch on Automation tasks; switch them on from the Plugins page') + } + + ctx.effect(() => { + const timer = setTimeout(() => { run().catch(error => rt.warn('ds-bot: Automation tasks default: %s', error?.message ?? error)) }, STARTUP_DELAY_MS) + // A pending first offer never keeps the process alive. + timer.unref?.() + return () => clearTimeout(timer) + }, 'ds-bot: automation tasks default') + + rt.offerAutomationTasks = run +} diff --git a/patches/ds-bot/files/test/automation-default.test.js b/patches/ds-bot/files/test/automation-default.test.js new file mode 100644 index 000000000..e227d95da --- /dev/null +++ b/patches/ds-bot/files/test/automation-default.test.js @@ -0,0 +1,144 @@ +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { test } from 'node:test' +import { createRuntime } from '../index.js' +import { SCHEDULE_BUNDLE, offerBundle } from '../src/host/automation-default.js' +import { fakeContext } from './fake-ctx.js' + +// A stand-in for DSH's plugin manager: one optional bundle, switched on and off like the Plugins page does. +function manager({ enabled = false, listed = true, error, refuse } = {}) { + const calls = [] + let on = enabled + return { + calls, + get on() { return on }, + set on(value) { on = value }, + async listBundles() { + calls.push('list') + return listed ? [{ name: '@deepseek-ai/dsh-base', enabled: true }, { name: SCHEDULE_BUNDLE, enabled: on, optional: true, error }] : [{ name: '@deepseek-ai/dsh-base', enabled: true }] + }, + async setBundleEnabled(name, value) { + calls.push(`set ${name} ${value}`) + if (refuse) return { changed: false, application: 'failed', stage: 'enable', target: name, error: { code: 'operation-error' } } + on = value + return { changed: true, application: 'applied', stage: 'enable', target: name, enabled: value } + }, + } +} + +const scratch = (t) => { + const dir = mkdtempSync(join(tmpdir(), 'dsb-automation-')) + t.after(() => rmSync(dir, { recursive: true, force: true, maxRetries: 5 })) + return join(dir, 'dsh-defaults.json') +} + +test('a profile that has the bundle off gets it switched on once, and the offer is written down', async (t) => { + const file = scratch(t) + const plugins = manager() + assert.equal(await offerBundle({ manager: plugins, profile: 'web', file }), 'enabled') + assert.equal(plugins.on, true) + assert.deepEqual(plugins.calls, ['list', `set ${SCHEDULE_BUNDLE} true`]) + assert.equal(typeof JSON.parse(readFileSync(file, 'utf8')).offers[SCHEDULE_BUNDLE].web, 'string') +}) + +test('a person who switches it off keeps it off: the next start does not ask again', async (t) => { + const file = scratch(t) + const plugins = manager() + await offerBundle({ manager: plugins, profile: 'web', file }) + plugins.on = false + plugins.calls.length = 0 + assert.equal(await offerBundle({ manager: plugins, profile: 'web', file }), 'decided') + assert.equal(plugins.on, false) + assert.deepEqual(plugins.calls, []) +}) + +test('a bundle that is on already is noted, so turning it off later sticks', async (t) => { + const file = scratch(t) + const plugins = manager({ enabled: true }) + assert.equal(await offerBundle({ manager: plugins, profile: 'desktop', file }), 'already-on') + assert.deepEqual(plugins.calls, ['list']) + plugins.on = false + assert.equal(await offerBundle({ manager: plugins, profile: 'desktop', file }), 'decided') + assert.equal(plugins.on, false) +}) + +test('each profile is offered once, on its own', async (t) => { + const file = scratch(t) + const web = manager() + const desktop = manager() + assert.equal(await offerBundle({ manager: web, profile: 'web', file }), 'enabled') + assert.equal(await offerBundle({ manager: desktop, profile: 'desktop', file }), 'enabled') + assert.equal(await offerBundle({ manager: web, profile: 'web', file }), 'decided') +}) + +test('a DSH that does not offer the bundle, or cannot read it, is left alone and asked again later', async (t) => { + const file = scratch(t) + assert.equal(await offerBundle({ manager: manager({ listed: false }), profile: 'web', file }), 'unavailable') + assert.equal(await offerBundle({ manager: manager({ error: { code: 'operation-error' } }), profile: 'web', file }), 'unavailable') + assert.equal(await offerBundle({ manager: undefined, profile: 'web', file }), 'unavailable') + assert.equal(await offerBundle({ manager: {}, profile: 'web', file }), 'unavailable') + assert.equal(existsSync(file), false) +}) + +test('a refusal is not remembered, so the next start tries again', async (t) => { + const file = scratch(t) + assert.equal(await offerBundle({ manager: manager({ refuse: true }), profile: 'web', file }), 'failed') + assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')).offers[SCHEDULE_BUNDLE], {}) + assert.equal(await offerBundle({ manager: manager(), profile: 'web', file }), 'enabled') +}) + +test('a manager that throws is a refusal, not a crash', async (t) => { + const file = scratch(t) + const plugins = manager() + plugins.setBundleEnabled = async () => { throw new Error('pnpm is missing') } + assert.equal(await offerBundle({ manager: plugins, profile: 'web', file }), 'failed') +}) + +test('an unreadable record changes nothing: an unknown history never reads as "not offered"', async (t) => { + const file = scratch(t) + writeFileSync(file, '{ not json') + const plugins = manager() + assert.equal(await offerBundle({ manager: plugins, profile: 'web', file }), 'unrecorded') + assert.deepEqual(plugins.calls, []) + assert.equal(readFileSync(file, 'utf8'), '{ not json') +}) + +test('a record that is not a file (a directory in its place) is an unknown history too', async (t) => { + const file = scratch(t) + const plugins = manager() + assert.equal(await offerBundle({ manager: plugins, profile: 'web', file: dirname(file) }), 'unrecorded') + assert.equal(plugins.on, false) +}) + +function setup(t, { config = {}, services = {} } = {}) { + const home = mkdtempSync(join(tmpdir(), 'dsb-automation-host-')) + const ctx = fakeContext({ services }) + const rt = createRuntime(ctx, { home, ...config }) + t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) + return { ctx, rt, home } +} + +test('the host offers the bundle through the plugin manager under the profile name', async (t) => { + const plugins = manager() + const { ctx, rt, home } = setup(t, { services: { pluginManager: plugins, profileContext: { name: 'web' } } }) + assert.ok(ctx.calls.includes('effect ds-bot: automation tasks default')) + await rt.offerAutomationTasks() + assert.equal(plugins.on, true) + assert.ok(JSON.parse(readFileSync(join(home, 'dsh-defaults.json'), 'utf8')).offers[SCHEDULE_BUNDLE].web) +}) + +test('a window that only shows the team leaves the profile to the other dsh', async (t) => { + const plugins = manager() + const { rt } = setup(t, { services: { pluginManager: plugins } }) + rt.readOnly = () => 'open in another dsh' + await rt.offerAutomationTasks() + assert.deepEqual(plugins.calls, []) +}) + +test('config.automationTasks = false never offers it', (t) => { + const { ctx, rt } = setup(t, { config: { automationTasks: false }, services: { pluginManager: manager() } }) + assert.equal(ctx.calls.includes('effect ds-bot: automation tasks default'), false) + assert.equal(rt.offerAutomationTasks, undefined) +}) diff --git a/patches/ds-bot/files/test/schedules-client.test.js b/patches/ds-bot/files/test/schedules-client.test.js new file mode 100644 index 000000000..b50f0c660 --- /dev/null +++ b/patches/ds-bot/files/test/schedules-client.test.js @@ -0,0 +1,82 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +// The "Automation tasks are off" hint and its button send people to DSH's Plugins panel, +// where the switch is; the Harness settings dialog has none. +const { outputFiles } = await build({ + stdin: { + contents: "export { SchedulesPage, BotSchedules, openPlugins } from './src/client/schedules.js'", + resolveDir: fileURLToPath(new URL('..', import.meta.url)), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) +const h = (type, props, ...children) => ({ type, props: { ...props, children } }) +const OFF = { available: false, tasks: [] } +const react = { createElement: h, Fragment: Symbol('F'), useEffect() {}, useLayoutEffect() {}, useState: v => [v === null ? OFF : v, () => {}], useMemo: f => f(), useRef: current => ({ current }), useCallback: f => f, useSyncExternalStore: (_s, get) => get() } +const module = { exports: {} } +runInNewContext(outputFiles[0].text, { + module, exports: module.exports, document: { hidden: false }, navigator: { language: 'en' }, localStorage: { getItem: () => null, setItem() {} }, + requestAnimationFrame: run => run(), + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: node => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return {} + throw new Error(`Unexpected platform module ${name}`) + }, +}) +const { SchedulesPage, BotSchedules, openPlugins } = module.exports +// Every node and string under a tree; an array (what a page returns) is walked too. +const all = (node, out = []) => { + if (Array.isArray(node)) { for (const item of node) all(item, out); return out } + if (typeof node === 'string') out.push(node) + else if (node && typeof node === 'object') { out.push(node); all(node.props?.children, out) } + return out +} +const text = node => all(node).filter(item => typeof item === 'string') +const spy = () => { + const log = [] + return { log, closeSettings: () => log.push('closeSettings'), selectPanel: id => log.push(`selectPanel ${id}`), openSettings: () => log.push('openSettings') } +} +const buttonsOf = tree => all(tree).filter(node => node.type === 'button') + +test('openPlugins closes the Settings dialog first, then opens the Plugins panel', () => { + const actions = spy() + openPlugins(actions) + assert.deepEqual(actions.log, ['closeSettings', 'selectPanel plugins']) +}) + +test('Settings → Scheduled tasks, with Automation tasks off, has one button and it opens Plugins', () => { + const actions = spy() + const element = SchedulesPage({ roster: { bots: [], rooms: [] }, actions, start: null }) + const page = element.type(element.props) + const buttons = buttonsOf(page) + assert.equal(buttons.length, 1) + assert.deepEqual(text(buttons[0]), ['Open Plugins']) + buttons[0].props.onClick() + assert.deepEqual(actions.log, ['closeSettings', 'selectPanel plugins']) + assert.ok(text(page).includes('Switch on Automation tasks on the Plugins page.')) + assert.ok(!text(page).some(line => /Harness settings/.test(line))) +}) + +test("a Bot's Scheduled tasks card, with Automation tasks off, opens Plugins too", () => { + const actions = spy() + const card = BotSchedules({ bot: { id: 'b1' }, roster: { bots: [], rooms: [] }, actions }) + const buttons = buttonsOf(card) + assert.equal(buttons.length, 1) + assert.deepEqual(text(buttons[0]), ['Open Plugins']) + buttons[0].props.onClick() + assert.deepEqual(actions.log, ['closeSettings', 'selectPanel plugins']) +}) + +test('the hint reads the same in English and Chinese, and says Plugins page, not Harness settings', () => { + const source = readFileSync(new URL('../src/client/i18n.js', import.meta.url), 'utf8') + assert.match(source, /'Switch on Automation tasks on the Plugins page\.': '在「插件」页面打开「自动化任务」。'/) + assert.match(source, /'Open Plugins': /) + assert.doesNotMatch(source, /Automation tasks on the Plugins page in Harness settings/) + assert.doesNotMatch(readFileSync(new URL('../src/client/schedules.js', import.meta.url), 'utf8'), /openHarnessSettings|Open Harness settings/) +}) diff --git a/patches/ds-bot/topics.json b/patches/ds-bot/topics.json index 18ac27c22..2f1970534 100644 --- a/patches/ds-bot/topics.json +++ b/patches/ds-bot/topics.json @@ -30,6 +30,7 @@ "src/client/connectors.js", "test/connectors.test.js", "test/work-dir.test.js", + "src/client/schedules.js", "README.md" ], "0002-flybot-api-host": [