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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/378-automation-tasks-default.changed.md
Original file line number Diff line number Diff line change
@@ -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)).
1 change: 1 addition & 0 deletions core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <home>/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
Expand Down
22 changes: 13 additions & 9 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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": "还没有定时任务,去添加",
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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"))
Expand All @@ -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),
Expand Down
3 changes: 2 additions & 1 deletion core/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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': '还没有定时任务,去添加',
Expand Down
22 changes: 14 additions & 8 deletions core/src/client/schedules.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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)]),
Expand Down Expand Up @@ -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')),
Expand All @@ -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))))))
}
Expand Down
106 changes: 106 additions & 0 deletions core/src/host/automation-default.js
Original file line number Diff line number Diff line change
@@ -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'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use an own-property check for profile markers

When a valid custom DSH profile is named after an inherited object property such as constructor or toString, this lookup finds the prototype value even though no offer was recorded, so Automation tasks are never enabled for that profile. Conversely, assigning a marker for __proto__ does not create a normal own property, causing the bundle to be re-enabled on every restart after the user turns it off. Check marker ownership with Object.hasOwn() or store profile markers in a null-prototype object.

Useful? React with 👍 / 👎.

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
}
Loading
Loading