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/2323-schedule-connector-reach.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A scheduled task's row now says which connectors its unattended run will carry, in the words of the Bot's own switches at the moment it fires: turning Composio on for that Bot changes what a task written weeks ago uses, and the row names it instead of the owner learning after the fact ([#2323](https://github.com/ThinkFlowLab/FlyBot/issues/2323)).
2,858 changes: 1,434 additions & 1,424 deletions core/client.js

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -1262,6 +1262,8 @@ const ZH = {
'Last run {time}': '上次运行 {time}',
'Last run {time} · Stopped': '上次运行 {time} · 已停止',
'Last run {time} · Failed: {reason}': '上次运行 {time} · 失败:{reason}',
'Runs with {names}': '运行时带上 {names}',
'Runs with no connectors': '运行时不带任何连接器',
'Run the task once now': '现在运行一次这个任务',
'DSH could not deliver it': 'DSH 没能送达',
'Resume the task first': '请先继续这个任务',
Expand Down
19 changes: 18 additions & 1 deletion core/src/client/schedules.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { BotAvatar, RoomAvatar } from './mark.js'
import { ClockIcon, DuplicateIcon, PencilIcon, PlusIcon, TrashIcon } from './icons.js'
import { Segmented } from './motion.js'
import { dateLocale, hostText, t } from './i18n.js'
import { connectedConnectors, isOn } from './connector-bots.js'

const POLL_MS = 30_000

Expand Down Expand Up @@ -117,6 +118,19 @@ export function lastRunText(run) {
}
}

// What an unattended run carries (#2323): the Bot's reach at the moment the task fires, not a copy
// stored with the task, so turning a connector on today changes what a task written weeks ago will
// use. A room task (no `botId`) has no single Bot's switch, and a Bot the roster no longer carries
// says nothing rather than guessing. `connectedConnectors` drops a sender and a webhook-only
// channel, which have neither tools nor a way in; `isOn` keeps only what this Bot may use.
export function reachText(task, roster) {
if (!task?.botId) return ''
const bot = roster?.byId?.[task.botId] ?? (roster?.bots ?? []).find(entry => entry.id === task.botId)
if (bot === undefined) return ''
const names = connectedConnectors(roster).filter(connector => isOn(bot, connector.id)).map(connector => connector.name)
return names.length === 0 ? t('Runs with no connectors') : t('Runs with {names}', { names: names.join(', ') })
}

// The window lock, read the way the rest of the client reads it (#2232): the Settings header draws
// the sentence, and this page draws no write the host would refuse.
export const lockedWindow = roster => typeof roster?.readOnly === 'string' && roster.readOnly.trim() !== ''
Expand Down Expand Up @@ -154,14 +168,17 @@ function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster, locke
.then((next) => { setBusy(false); onView(next) }, (error) => { setBusy(false); refuse(error) })
}
const room = task.roomId ? roster.roomsById[task.roomId] : undefined
// The hint reads as one line: timing, the room, then the reach. Each part stays its own string so
// the timing half is still exactly what `timingText` answers (#1651).
const hint = [timingText(task), room ? t('in {name}', { name: room.name }) : '', reachText(task, roster)].filter(Boolean)
const stoppable = task.status === 'active' || task.status === 'paused'
const failed = task.lastRun?.result === 'failed'
const last = lastRunText(task.lastRun)
return h('div', { className: 'bt-set-row bt-task', 'data-done': stoppable ? undefined : '', 'data-paused': task.status === 'paused' || undefined, 'data-busy': busy || undefined },
showBot ? h(BotAvatar, { bot: roster.byId[task.botId], size: 28, badge: false, live: false }) : null,
h('span', { className: 'bt-set-copy' },
h('span', { className: 'bt-task-title' }, task.title),
h('span', { className: 'bt-set-hint' }, [timingText(task), room ? t('in {name}', { name: room.name }) : ''].filter(Boolean).join(' · ')),
h('span', { className: 'bt-set-hint' }, hint.flatMap((part, index) => (index === 0 ? [part] : [' · ', part]))),
task.prompt && task.prompt !== task.title ? h('span', { className: 'bt-task-prompt' }, task.prompt) : null,
last ? h('span', { className: 'bt-task-last', 'data-failed': failed || undefined }, last) : null,
rowError ? h('span', { className: 'bt-task-last', 'data-failed': '', role: 'alert' }, rowError) : null),
Expand Down
46 changes: 45 additions & 1 deletion core/test/schedules-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ runInNewContext(outputFiles[0].text, {
throw new Error(`Unexpected platform module ${name}`)
},
})
const { SchedulesPage, BotSchedules, ScheduleSummary, RoomSchedules, CopyRow, openPlugins, formStart, timingText, schedulesReadFailure } = module.exports
const { SchedulesPage, BotSchedules, ScheduleSummary, RoomSchedules, CopyRow, openPlugins, formStart, timingText, schedulesReadFailure, reachText } = 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 }
Expand Down Expand Up @@ -841,3 +841,47 @@ test('P-482: a locked Bot card with tasks keeps the heading and the rows', () =>
assert.ok(text(locked).includes('Scheduled tasks'), 'the heading stays with tasks')
assert.ok(text(locked).includes('Briefing'), 'the task row stays')
})

// #2323: a schedule's reach is the Bot's reach at the moment the task fires, not a copy stored
// with the task, so its row says which connectors an unattended run would carry. A room task has
// no single Bot's switch, so it says nothing: drawing one would state a falsehood.
const CONNECTORS = [
{ id: 'github', name: 'GitHub', state: 'connected' },
{ id: 'composio', name: 'Composio', state: 'connected' },
]
const reachRoster = bot => ({ bots: [bot], rooms: [], byId: { [bot.id]: bot }, roomsById: {}, connectors: CONNECTORS })
const reachLines = roster => {
seeded.push({ available: true, tasks: [ACTIVE] }, null)
return text(render(BotSchedules({ bot: { id: 'b1' }, roster, actions: spy() })))
}
// The reach is the last segment of the row's one hint line, beside the timing.
const reachOf = lines => lines.find(line => line.startsWith('Runs with ')) ?? ''

test('#2323 a: two connected connectors and no per-Bot record read as both', () => {
assert.equal(reachOf(reachLines(reachRoster({ id: 'b1', name: 'B' }))), 'Runs with GitHub, Composio')
})

test('#2323 b: a connector turned off for this Bot drops out of the sentence', () => {
const lines = reachLines(reachRoster({ id: 'b1', name: 'B', connectors: { composio: false } }))
assert.equal(reachOf(lines), 'Runs with GitHub')
assert.ok(!lines.join('|').includes('Composio'), 'a switched-off connector is not named')
})

test('#2323 c: every connected connector off reads as no connectors', () => {
assert.equal(reachOf(reachLines(reachRoster({ id: 'b1', name: 'B', connectors: { github: false, composio: false } }))), 'Runs with no connectors')
})

test('#2323 d: a sender connector never appears in the sentence', () => {
const roster = { ...reachRoster({ id: 'b1', name: 'B' }), connectors: [...CONNECTORS, { id: 'feishu', name: 'Feishu', state: 'connected', kind: 'sender' }] }
const lines = reachLines(roster)
assert.equal(reachOf(lines), 'Runs with GitHub, Composio')
assert.ok(!lines.join('|').includes('Feishu'), 'a sender has neither tools nor a way in')
})

test('#2323 e: a room task says nothing about reach', () => {
const roomTask = { id: 'r1', sessionId: 'room-1', roomId: 'room-1', title: 'Team', kind: 'daily', status: 'active' }
assert.equal(reachText(roomTask, reachRoster({ id: 'b1', name: 'B' })), '')
seeded.push({ available: true, tasks: [roomTask] })
const lines = text(render(RoomSchedules({ room: { id: 'room-1', name: 'Team room', origin: 'group' }, actions: spy() })))
assert.ok(!lines.some(line => line.startsWith('Runs with')), lines.join('|'))
})
Loading
Loading