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/schedules-list-scope.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The Scheduled tasks page lists tasks on a host where a member is signed in — the list no longer needs a Session in its request — and each signed-in member is shown only their own tasks.
27 changes: 24 additions & 3 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ var ZH = {
"1 Bot": "1 个 Bot",
"{count} Bots": "{count} 个 Bot",
"Loading…": "加载中…",
"The scheduled tasks could not be read: {reason}": "定时任务读不出来:{reason}",
"Edit Bot": "编辑 Bot",
"Make Main Bot": "设为主 Bot",
"Remove Main Bot role": "取消主 Bot",
Expand Down Expand Up @@ -13122,6 +13123,9 @@ function publish(view) {
shared = view;
for (const listener of listeners) listener(view);
}
function publishFailure(error) {
publish({ available: false, tasks: [], readFailure: hostText(error?.message ?? String(error)) });
}
function useSchedules(actions) {
const [view, setView] = (0, import_react25.useState)(shared);
(0, import_react25.useEffect)(() => {
Expand All @@ -13130,7 +13134,8 @@ function useSchedules(actions) {
let timer;
const load = () => actions.schedules?.().then((next) => {
if (!stopped) publish(next);
}).catch(() => {
}).catch((error) => {
if (!stopped) publishFailure(error);
}).finally(() => {
if (!stopped) timer = setTimeout(load, POLL_MS4);
});
Expand All @@ -13143,6 +13148,7 @@ function useSchedules(actions) {
}, []);
return view;
}
var schedulesReadFailure = (failure) => typeof failure === "string" && failure !== "" ? t("The scheduled tasks could not be read: {reason}", { reason: failure }) : null;
var localZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
var clock2 = (time) => String(time ?? "").slice(0, 5);
var when2 = (ms, options) => new Intl.DateTimeFormat(dateLocale(), options).format(ms);
Expand Down Expand Up @@ -13497,6 +13503,8 @@ function SchedulesPage({ roster, actions, start }) {
const view = useSchedules(actions);
const [adding, setAdding] = (0, import_react25.useState)(Boolean(start));
const [editing, setEditing] = (0, import_react25.useState)(null);
const refused = schedulesReadFailure(view?.readFailure);
if (refused !== null) return (0, import_react25.createElement)("div", { className: "bt-set-hint bt-danger", role: "status" }, refused);
if (view === null) return (0, import_react25.createElement)("div", { className: "bt-set-hint" }, t("Loading…"));
if (!view.available) return (0, import_react25.createElement)(AutomationOff, { actions });
const shown = (next) => {
Expand Down Expand Up @@ -13578,6 +13586,19 @@ function CopyRow({ task, roster, actions, onView, onCancel }) {
function BotSchedules({ bot, roster, actions }) {
const view = useSchedules(actions);
const [editing, setEditing] = (0, import_react25.useState)(null);
const refused = schedulesReadFailure(view?.readFailure);
if (refused !== null) {
return (0, import_react25.createElement)(
"section",
{ className: "bt-set-section" },
(0, import_react25.createElement)("h3", null, t("Scheduled tasks")),
(0, import_react25.createElement)("div", { className: "bt-set-card" }, (0, import_react25.createElement)(
"div",
{ className: "bt-set-row bt-bs-row" },
(0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-set-hint bt-danger", role: "status" }, refused))
))
);
}
if (view === null) return null;
const tasks = view.available ? view.tasks.filter((task) => task.botId === bot.id) : [];
return (0, import_react25.createElement)(
Expand Down Expand Up @@ -13631,7 +13652,7 @@ function SummaryLines({ tasks, actions }) {
}
function ScheduleSummary({ bot, actions }) {
const view = useSchedules(actions);
if (view === null) return null;
if (view === null || view.readFailure !== void 0) return null;
const tasks = view.available ? view.tasks.filter((task) => task.botId === bot.id) : [];
return (0, import_react25.createElement)(
"div",
Expand All @@ -13642,7 +13663,7 @@ function ScheduleSummary({ bot, actions }) {
}
function RoomSchedules({ room: room2, actions }) {
const view = useSchedules(actions);
if (view === null || room2.origin === "face-to-face") return null;
if (view === null || view.readFailure !== void 0 || room2.origin === "face-to-face") return null;
const tasks = view.available ? view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id) : [];
if (tasks.length === 0) return null;
return (0, import_react25.createElement)("div", null, (0, import_react25.createElement)(SummaryLines, { tasks, actions }));
Expand Down
1 change: 1 addition & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ const ZH = {
'1 Bot': '1 个 Bot',
'{count} Bots': '{count} 个 Bot',
'Loading…': '加载中…',
'The scheduled tasks could not be read: {reason}': '定时任务读不出来:{reason}',
'Edit Bot': '编辑 Bot',
'Make Main Bot': '设为主 Bot',
'Remove Main Bot role': '取消主 Bot',
Expand Down
28 changes: 25 additions & 3 deletions core/src/client/schedules.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,19 @@ import { dateLocale, hostText, t } from './i18n.js'
const POLL_MS = 30_000

// One view for every surface that shows tasks; each asks again when it mounts.
//
// A read that fails is not a skeleton that never ends: it answers a view with `readFailure` set,
// so the page says what went wrong instead of "Loading…" for as long as the host keeps refusing.
// The next poll replaces it as soon as a read works again.
let shared = null
const listeners = new Set()
function publish(view) {
shared = view
for (const listener of listeners) listener(view)
}
function publishFailure(error) {
publish({ available: false, tasks: [], readFailure: hostText(error?.message ?? String(error)) })
}
export function useSchedules(actions) {
const [view, setView] = useState(shared)
useEffect(() => {
Expand All @@ -28,14 +35,20 @@ export function useSchedules(actions) {
let timer
const load = () => actions.schedules?.()
.then((next) => { if (!stopped) publish(next) })
.catch(() => {})
.catch((error) => { if (!stopped) publishFailure(error) })
.finally(() => { if (!stopped) timer = setTimeout(load, POLL_MS) })
load()
return () => { stopped = true; clearTimeout(timer); listeners.delete(setView) }
}, [])
return view
}

// The sentence a failed read gets, or `null` while there is nothing to say. Pure, so the suite
// can hold it without a renderer.
export const schedulesReadFailure = failure => (typeof failure === 'string' && failure !== ''
? t('The scheduled tasks could not be read: {reason}', { reason: failure })
: null)

const localZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone
const clock = time => String(time ?? '').slice(0, 5)
const when = (ms, options) => new Intl.DateTimeFormat(dateLocale(), options).format(ms)
Expand Down Expand Up @@ -331,6 +344,8 @@ export function SchedulesPage({ roster, actions, start }) {
const view = useSchedules(actions)
const [adding, setAdding] = useState(Boolean(start))
const [editing, setEditing] = useState(null)
const refused = schedulesReadFailure(view?.readFailure)
if (refused !== null) return h('div', { className: 'bt-set-hint bt-danger', role: 'status' }, refused)
if (view === null) return h('div', { className: 'bt-set-hint' }, t('Loading…'))
if (!view.available) return h(AutomationOff, { actions })
const shown = next => { publish(next); setAdding(false); setEditing(null) }
Expand Down Expand Up @@ -399,6 +414,13 @@ export function CopyRow({ task, roster, actions, onView, onCancel }) {
export function BotSchedules({ bot, roster, actions }) {
const view = useSchedules(actions)
const [editing, setEditing] = useState(null)
const refused = schedulesReadFailure(view?.readFailure)
if (refused !== null) {
return h('section', { className: 'bt-set-section' },
h('h3', null, t('Scheduled tasks')),
h('div', { className: 'bt-set-card' }, h('div', { className: 'bt-set-row bt-bs-row' },
h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-set-hint bt-danger', role: 'status' }, refused)))))
}
if (view === null) return null
const tasks = view.available ? view.tasks.filter(task => task.botId === bot.id) : []
return h('section', { className: 'bt-set-section' },
Expand Down Expand Up @@ -440,7 +462,7 @@ export function ScheduleSummary({ bot, actions }) {
// not "no tasks". The settings card above draws nothing for it; this line did the opposite and
// offered the new-task form. `view.available === false` (Plugins off) is a different state and
// keeps its own line below.
if (view === null) return null
if (view === null || view.readFailure !== undefined) return null
const tasks = view.available ? view.tasks.filter(task => task.botId === bot.id) : []
return h('div', null,
tasks.length === 0
Expand All @@ -458,7 +480,7 @@ export function ScheduleSummary({ bot, actions }) {
// settings list with no target, because a room id here would open the new-task form (#2030).
export function RoomSchedules({ room, actions }) {
const view = useSchedules(actions)
if (view === null || room.origin === 'face-to-face') return null
if (view === null || view.readFailure !== undefined || room.origin === 'face-to-face') return null
const tasks = view.available ? view.tasks.filter(task => task.botId === undefined && task.roomId === room.id) : []
if (tasks.length === 0) return null
return h('div', null, h(SummaryLines, { tasks, actions }))
Expand Down
11 changes: 9 additions & 2 deletions core/src/host/authz.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const SHARED_BLOCKED = new Set([
const BOT_TARGETED = new Set([
'update-bot', 'delete-bot', 'duplicate-bot', 'set-flags', 'bot-capability',
'memory-view', 'memory-ask', 'memory-forget',
'schedules', 'schedule-create', 'schedule-update', 'schedule-pause', 'schedule-resume', 'schedule-retry', 'schedule-delete',
'schedule-create', 'schedule-update', 'schedule-pause', 'schedule-resume', 'schedule-retry', 'schedule-delete',
'stop-member-work', 'set-main', 'test-model',
])

Expand Down Expand Up @@ -281,8 +281,15 @@ export function install(rt) {
return grant !== undefined && grant.ownerId === actor.memberId ? ALLOW : deny('bot/forbidden', 'You did not give that approval')
}

// Listing scheduled tasks is a read, not an action on one Session: the page asks with no
// payload at all (`call('schedules')`), so a Session-keyed gate refused every list a
// signed-in member ever asked for. Any signed-in actor may list, and the data layer
// answers only their own rows (`schedulesView(actor)` in schedules.js). The `schedule-*`
// writes keep their Session and target gates below, unchanged.
if (ep === 'schedules') return ALLOW

if (BOT_TARGETED.has(ep)) {
if (ep === 'schedules' || ep.startsWith('schedule-')) {
if (ep.startsWith('schedule-')) {
// The create endpoint keys off `body.target` (Bot or room id); `sessionId` is
// the list/delete key. Checking only sessionId would allow a foreign target
// paired with an owned session (#106). An edit needs both: the task's Session
Expand Down
4 changes: 3 additions & 1 deletion core/src/host/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,9 @@ export function install(rt) {
case 'schedule-retry':
case 'schedule-delete':
await rt.load()
return ok(await rt.scheduleEndpoint(endpoint, body))
// The actor goes down with the request here too: the list and the view a write answers
// with are narrowed to this caller's rows in the data layer (schedules.js).
return ok(await rt.scheduleEndpoint(endpoint, body, actor))
case 'schedule-create':
case 'schedule-update': {
await rt.load()
Expand Down
7 changes: 6 additions & 1 deletion core/src/host/project-assistant.js
Original file line number Diff line number Diff line change
Expand Up @@ -234,13 +234,18 @@ export function install(rt) {
// The Bot exists but the Session index has not been rebuilt yet; a task on it must resolve as
// that Bot's own Session, or the schedule path refuses it as somebody else's.
rt.chatOwners?.set(sessionId, botId)
// The host asks on the assistant's own behalf, and a host with a member signed in narrows the
// list it answers with to the caller's rows: the digest belongs to that Bot's owner, so the
// owner goes down with the request the way the channel sends the caller for an edit (#1321).
const owner = rt.botOf?.(botId)?.ownerId
const actor = owner === undefined ? undefined : { memberId: owner }
try {
const view = await rt.scheduleEndpoint('schedule-create', {
target: botId,
title: DIGEST_TITLE,
prompt: DIGEST_PROMPT,
when: { kind: 'daily', time: DIGEST_TIME, timeZone: DIGEST_ZONE },
})
}, actor)
const row = (view?.tasks ?? []).find(task => task.sessionId === sessionId && task.title === DIGEST_TITLE)
return { scheduleId: row?.id ?? null }
} catch (error) {
Expand Down
48 changes: 26 additions & 22 deletions core/src/host/schedules.js
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ export function install(rt) {
const toSession = sessionFor(targetBot !== undefined ? { botId: targetBot.id } : { roomId: targetRoom.id })
if (held !== undefined) await service.create(toSession, request)
else await copyRow(sourceSession, sourceId, toSession)
return schedulesView()
return schedulesView(actor)
}

// The row write itself. `put`, `get` and the `tasks` row shape are the same private API
Expand All @@ -400,21 +400,25 @@ export function install(rt) {
throw invalid('The task could not be copied. Try again')
}

async function schedulesView() {
// `actor` narrows the rows to that caller's own; the page asks every 30 seconds with the
// member's session, so the answer is the list a member may read, not the host's catalog. A row
// is read exactly as a task's source is read (`sourceOf`): unenforced, the whole host answers as
// it always did; enforced, another owner's row and an actor-less call both stay out.
async function schedulesView(actor) {
const quietHours = quietNow()
if (service === null) return { available: false, tasks: [], quietHours }
// The page asks every 30 seconds, so a missed run shows without waiting for the timer.
await checkMissed().catch(error => rt.warn('ds-bot: checking scheduled tasks failed: %s', error?.message ?? error))
const tasks = []
for (const entry of await service.catalog()) {
const owner = ownerOf(entry.sessionId)
if (!known(owner)) continue
if (!known(owner) || sourceOf(entry.sessionId, actor) === undefined) continue
const lastRun = lastRunOf(entry)
tasks.push({ ...entry, ...owner, ...(lastRun ? { lastRun } : {}) })
}
for (const entry of Object.values(paused())) {
const owner = ownerOf(entry.sessionId)
if (!known(owner)) continue
if (!known(owner) || sourceOf(entry.sessionId, actor) === undefined) continue
const lastRun = lastRunOf({ id: entry.id })
tasks.push({ ...entry.record, id: entry.id, sessionId: entry.sessionId, status: 'paused', pausedAt: entry.pausedAt, ...owner, ...(lastRun ? { lastRun } : {}) })
}
Expand All @@ -440,7 +444,7 @@ export function install(rt) {
if (!keep) refuseDuringQuietHours(timing, body.when?.timeZone)
paused()[id] = { ...held, sessionId: content.sessionId, record: { title: content.title, prompt: content.prompt, ...timingOf(timing) } }
await rt.save()
return schedulesView()
return schedulesView(actor)
}
// DSH's update compares the exact record `list` gives (active tasks only).
const active = (await service.list({ sessionId })).find(entry => entry.id === id)
Expand All @@ -454,22 +458,22 @@ export function install(rt) {
const same = JSON.stringify(timingPart(stored)) === JSON.stringify(timing)
const result = await service.update({ sessionId, id, expected: active, title: content.title, prompt: content.prompt, ...(same ? {} : { change: changeOf(timing) }) })
if (result?.record === undefined) throw invalid(UPDATE_MISSES[result?.code] ?? result?.message ?? 'The task could not be saved')
return schedulesView()
return schedulesView(actor)
}
if (timing.at !== undefined && Date.parse(timing.at) <= Date.now()) throw invalid('Pick a time in the future')
await service.create(content.sessionId, { title: content.title, prompt: content.prompt, ...timing })
await service.delete({ sessionId, id })
return schedulesView()
return schedulesView(actor)
}

// Pause: the record is saved in FlyBot's state before DSH lets the task go, so a
// failed delete leaves it running and never loses it.
async function pauseSchedule(body) {
async function pauseSchedule(body, actor) {
ensureService()
const sessionId = String(body.sessionId ?? '')
const id = String(body.id ?? '')
if (ownerOf(sessionId) === undefined) throw invalid('No such task')
if (pausedEntry(sessionId, id) !== undefined) return schedulesView()
if (pausedEntry(sessionId, id) !== undefined) return schedulesView(actor)
const record = await findRecord(sessionId, id)
if (record === undefined) throw invalid('No such task')
if (record.status !== 'active') throw invalid('This task has finished')
Expand All @@ -485,7 +489,7 @@ export function install(rt) {
await rt.save()
throw error
}
return schedulesView()
return schedulesView(actor)
}

// Retiring a Bot disarms what its own chat already holds — every part of it, current and
Expand Down Expand Up @@ -534,7 +538,7 @@ export function install(rt) {

// Resume: DSH gets the task again, on the Bot's current part, before FlyBot forgets
// the paused copy. A one-time task whose time has passed waits for an edit.
async function resumeSchedule(body) {
async function resumeSchedule(body, actor) {
ensureService()
const sessionId = String(body.sessionId ?? '')
const held = pausedEntry(sessionId, String(body.id ?? ''))
Expand All @@ -549,12 +553,12 @@ export function install(rt) {
await service.create(sessionFor(owner), request)
delete paused()[held.id]
await rt.save()
return schedulesView()
return schedulesView(actor)
}

// Retry: the task's prompt once now, to the Bot's current part or the group, as a run of
// that task. DSH's own times are untouched: no missed time is delivered again.
async function retrySchedule(body) {
async function retrySchedule(body, actor) {
ensureService()
const sessionId = String(body.sessionId ?? '')
const owner = ownerOf(sessionId)
Expand All @@ -580,7 +584,7 @@ export function install(rt) {
}
throw error
}
return schedulesView()
return schedulesView(actor)
}

// Which Session a task lives in, named as the Bot reads it: its own chat (now, or an
Expand Down Expand Up @@ -637,30 +641,30 @@ export function install(rt) {
}))

const endpoints = {
schedules: () => schedulesView(),
schedules: (body, actor) => schedulesView(actor),
async 'schedule-create'(body, actor) {
ensureService()
if (body.copyFrom !== undefined) return copySchedule(body, actor)
const { sessionId, title, prompt } = contentOf(body)
const timing = selectorOf(body.when)
refuseDuringQuietHours(timing, body.when?.timeZone)
await service.create(sessionId, { title, prompt, ...timing })
return schedulesView()
return schedulesView(actor)
},
'schedule-update': (body, actor) => updateSchedule(body, actor),
'schedule-pause': body => pauseSchedule(body),
'schedule-resume': body => resumeSchedule(body),
'schedule-retry': body => retrySchedule(body),
async 'schedule-delete'(body) {
'schedule-pause': (body, actor) => pauseSchedule(body, actor),
'schedule-resume': (body, actor) => resumeSchedule(body, actor),
'schedule-retry': (body, actor) => retrySchedule(body, actor),
async 'schedule-delete'(body, actor) {
ensureService()
if (ownerOf(body.sessionId) === undefined) throw invalid('No such task')
if (pausedEntry(body.sessionId, body.id) !== undefined) {
delete paused()[body.id]
await rt.save()
return schedulesView()
return schedulesView(actor)
}
await service.delete({ sessionId: body.sessionId, id: body.id })
return schedulesView()
return schedulesView(actor)
},
}

Expand Down
Loading
Loading