diff --git a/changelog.d/1252-board-archive.changed.md b/changelog.d/1252-board-archive.changed.md new file mode 100644 index 00000000..651ef465 --- /dev/null +++ b/changelog.d/1252-board-archive.changed.md @@ -0,0 +1 @@ +A finished task card leaves the task board's active list, and the Done column shows the newest 20 archived cards; a group's overall card now completes when its last child finishes, is cancelled by a rejected approval, or fails because its Bot was deleted, and a Bot's board asks for that Bot's own finished cards (#1252). diff --git a/core/client.js b/core/client.js index da5d3952..602720e2 100644 --- a/core/client.js +++ b/core/client.js @@ -7427,7 +7427,7 @@ function useTasks(actions) { taskListeners.add(setTasks); let stopped = false; let timer; - const load = () => Promise.resolve(actions?.tasks?.()).then((list) => { + const load = () => Promise.resolve(actions?.tasks?.({ archive: true })).then((list) => { if (!stopped && list !== void 0) publishTasks(list); }).catch(() => { }).finally(() => { @@ -8904,6 +8904,11 @@ function shortId(id) { const hex = String(id ?? "").replace(/^task-/, ""); return `#${hex.slice(-6)}`; } +var ARCHIVE_SHOWN = 20; +function boardTasksFor(tasks, archive, limit = ARCHIVE_SHOWN) { + const newest = [...archive ?? []].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || (b.createdAt ?? 0) - (a.createdAt ?? 0)); + return [...tasks ?? [], ...newest.slice(0, limit)]; +} function boardColumns(tasks) { const columns = { "not-started": [], "in-progress": [], done: [] }; for (const task of tasks ?? []) { @@ -9024,19 +9029,28 @@ function lanesOf(tasks, roster) { } return order.map((id) => ({ id, name: botName(roster, map.get(id)[0]) || id, tasks: map.get(id) })); } +var upsert = (list, task) => list.some((item) => item.id === task.id) ? list.map((item) => item.id === task.id ? task : item) : [...list, task]; +var without = (list, id) => list.filter((item) => item.id !== id); function useBoardTasks(actions, scope) { const [own, setOwn] = (0, import_react17.useState)([]); const [groupTasks, setGroupTasks] = (0, import_react17.useState)([]); + const [archive, setArchive] = (0, import_react17.useState)([]); const key = scope?.kind === "group" ? `group:${scope.roomId}` : "own"; (0, import_react17.useEffect)(() => { let stopped = false; const load = () => { - const job = scope?.kind === "group" ? Promise.resolve(actions?.tasks?.({ roomId: scope.roomId })) : Promise.resolve(actions?.tasks?.()); + const query = scope?.kind === "group" ? { roomId: scope.roomId } : {}; + const archiveQuery = scope?.kind === "bot" ? { botId: scope.botId } : query; + const job = scope?.kind === "group" ? Promise.resolve(actions?.tasks?.(query)) : Promise.resolve(actions?.tasks?.()); void job.then((list) => { if (stopped || !Array.isArray(list)) return; if (scope?.kind === "group") setGroupTasks(list); else setOwn(list); }).catch(() => void 0); + void Promise.resolve(actions?.tasks?.({ ...archiveQuery, archive: true, limit: ARCHIVE_SHOWN })).then((list) => { + if (stopped || !Array.isArray(list)) return; + setArchive(list.filter((task) => columnOf(task) === "done")); + }).catch(() => void 0); }; load(); const timer = setInterval(load, POLL_MS4); @@ -9047,11 +9061,13 @@ function useBoardTasks(actions, scope) { }, [key]); const tasks = scope?.kind === "group" ? groupTasks : own.filter((task) => !scope?.botId || task.ownerBotId === scope.botId); const replace = (task) => { - const write = (list) => list.some((item) => item.id === task.id) ? list.map((item) => item.id === task.id ? task : item) : [...list, task]; - if (scope?.kind === "group") setGroupTasks(write); - else setOwn(write); + const done2 = columnOf(task) === "done"; + if (scope?.kind === "group") setGroupTasks((list) => done2 ? without(list, task.id) : upsert(list, task)); + else setOwn((list) => done2 ? without(list, task.id) : upsert(list, task)); + setArchive((list) => done2 ? [task, ...without(list, task.id)] : without(list, task.id)); }; - return { own, tasks, replace }; + const done = scope?.kind === "group" ? archive : archive.filter((task) => !scope?.botId || task.ownerBotId === scope.botId); + return { own, tasks, archive: done, replace }; } function RailButton({ row, selected, meta, onSelect }) { return (0, import_react17.createElement)( @@ -9260,11 +9276,12 @@ function KanbanDialog({ roster, actions, leaving = false }) { const [dragging, setDragging] = (0, import_react17.useState)(null); const [error, setError] = (0, import_react17.useState)(""); const [draft, setDraft] = (0, import_react17.useState)(""); - const { own, tasks, replace } = useBoardTasks(actions, scope); + const { own, tasks, archive, replace } = useBoardTasks(actions, scope); + const boardTasks = boardTasksFor(tasks, archive); const close = (0, import_react17.useCallback)(() => actions.closeBoard(), []); useEscape(close); const counts2 = {}; - for (const task of own) { + for (const task of [...own, ...archive]) { const id = task.ownerBotId; const bucket = counts2[id] ?? (counts2[id] = { done: 0, total: 0 }); if (task.status === "cancelled" || task.status === "failed") continue; @@ -9316,7 +9333,7 @@ function KanbanDialog({ roster, actions, leaving = false }) { ), view === "delivery" ? (0, import_react17.createElement)(DeliveryView, { actions }) : (0, import_react17.createElement)(BoardView, { roster, - tasks, + tasks: boardTasks, rail, scope, counts: counts2, diff --git a/core/src/client/inbox.js b/core/src/client/inbox.js index d9299800..c9936306 100644 --- a/core/src/client/inbox.js +++ b/core/src/client/inbox.js @@ -72,7 +72,10 @@ export function useTasks(actions) { taskListeners.add(setTasks) let stopped = false let timer - const load = () => Promise.resolve(actions?.tasks?.()) + // #1252: the default read is active work only, but this inbox classifies finished and failed + // cards too (Done, and Needs you after a failure). It asks for the archive so those rows reach + // it; the board's own bounded read is a separate call. + const load = () => Promise.resolve(actions?.tasks?.({ archive: true })) .then((list) => { if (!stopped && list !== undefined) publishTasks(list) }) // A host that cannot list tasks (a reading host, an older core) shows none. .catch(() => {}) diff --git a/core/src/client/kanban.js b/core/src/client/kanban.js index 27366228..aac525cf 100644 --- a/core/src/client/kanban.js +++ b/core/src/client/kanban.js @@ -24,6 +24,17 @@ export function shortId(id) { return `#${hex.slice(-6)}` } +// #1252: the board lists active work; the Done column reads a bounded slice of the archive, +// so a room with hundreds of finished cards still renders a column, not the whole history. +export const ARCHIVE_SHOWN = 20 + +export function boardTasksFor(tasks, archive, limit = ARCHIVE_SHOWN) { + // Newest first before the cut: a card finished a minute ago has an old `createdAt`, so ordering + // by it would drop exactly the card a member just finished (#1252). + const newest = [...(archive ?? [])].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || (b.createdAt ?? 0) - (a.createdAt ?? 0)) + return [...(tasks ?? []), ...newest.slice(0, limit)] +} + export function boardColumns(tasks) { const columns = { 'not-started': [], 'in-progress': [], done: [] } for (const task of tasks ?? []) { @@ -150,15 +161,26 @@ function lanesOf(tasks, roster) { return order.map(id => ({ id, name: botName(roster, map.get(id)[0]) || id, tasks: map.get(id) })) } +const upsert = (list, task) => (list.some(item => item.id === task.id) ? list.map(item => item.id === task.id ? task : item) : [...list, task]) +const without = (list, id) => list.filter(item => item.id !== id) + export function useBoardTasks(actions, scope) { const [own, setOwn] = useState([]) const [groupTasks, setGroupTasks] = useState([]) + const [archive, setArchive] = useState([]) const key = scope?.kind === 'group' ? `group:${scope.roomId}` : 'own' useEffect(() => { let stopped = false const load = () => { + const query = scope?.kind === 'group' ? { roomId: scope.roomId } : {} + // #1252: the host cuts the archive page before the board can see it, so a Bot board has to + // name the Bot it asks for — an unfiltered page spends its whole budget on other Bots' newer + // cards and this Bot's Done column empties. The default read stays unfiltered on purpose: it + // is active work, a superset the board filters below without losing a card. + const archiveQuery = scope?.kind === 'bot' ? { botId: scope.botId } : query + // #1252: the default list is active work, so the Done column asks for the archive. const job = scope?.kind === 'group' - ? Promise.resolve(actions?.tasks?.({ roomId: scope.roomId })) + ? Promise.resolve(actions?.tasks?.(query)) : Promise.resolve(actions?.tasks?.()) // A reading host or an older core has no board; keep the list already shown. void job.then((list) => { @@ -166,18 +188,25 @@ export function useBoardTasks(actions, scope) { if (scope?.kind === 'group') setGroupTasks(list) else setOwn(list) }).catch(() => undefined) + void Promise.resolve(actions?.tasks?.({ ...archiveQuery, archive: true, limit: ARCHIVE_SHOWN })).then((list) => { + if (stopped || !Array.isArray(list)) return + setArchive(list.filter(task => columnOf(task) === 'done')) + }).catch(() => undefined) } load() const timer = setInterval(load, POLL_MS) return () => { stopped = true; clearInterval(timer) } }, [key]) const tasks = scope?.kind === 'group' ? groupTasks : own.filter(task => !scope?.botId || task.ownerBotId === scope.botId) + // A finished card moves off the active list into the archive immediately, before the next poll. const replace = (task) => { - const write = (list) => (list.some(item => item.id === task.id) ? list.map(item => item.id === task.id ? task : item) : [...list, task]) - if (scope?.kind === 'group') setGroupTasks(write) - else setOwn(write) + const done = columnOf(task) === 'done' + if (scope?.kind === 'group') setGroupTasks(list => done ? without(list, task.id) : upsert(list, task)) + else setOwn(list => done ? without(list, task.id) : upsert(list, task)) + setArchive(list => done ? [task, ...without(list, task.id)] : without(list, task.id)) } - return { own, tasks, replace } + const done = scope?.kind === 'group' ? archive : archive.filter(task => !scope?.botId || task.ownerBotId === scope.botId) + return { own, tasks, archive: done, replace } } function RailButton({ row, selected, meta, onSelect }) { @@ -338,11 +367,12 @@ export function KanbanDialog({ roster, actions, leaving = false }) { const [dragging, setDragging] = useState(null) const [error, setError] = useState('') const [draft, setDraft] = useState('') - const { own, tasks, replace } = useBoardTasks(actions, scope) + const { own, tasks, archive, replace } = useBoardTasks(actions, scope) + const boardTasks = boardTasksFor(tasks, archive) const close = useCallback(() => actions.closeBoard(), []) useEscape(close) const counts = {} - for (const task of own) { + for (const task of [...own, ...archive]) { const id = task.ownerBotId const bucket = counts[id] ?? (counts[id] = { done: 0, total: 0 }) if (task.status === 'cancelled' || task.status === 'failed') continue @@ -385,7 +415,7 @@ export function KanbanDialog({ roster, actions, leaving = false }) { view === 'delivery' ? h(DeliveryView, { actions }) : h(BoardView, { - roster, tasks, rail, scope, counts, dragging, error, draft, + roster, tasks: boardTasks, rail, scope, counts, dragging, error, draft, onDraft: setDraft, onSelect: setScope, onMove: move, onDrag: setDragging, onCreate: create, }))) } diff --git a/core/src/host/tasks.js b/core/src/host/tasks.js index 3ec3baa3..8e14fee9 100644 --- a/core/src/host/tasks.js +++ b/core/src/host/tasks.js @@ -57,6 +57,20 @@ const refuse = (code, message) => Object.assign(new Error(`${code}: ${message}`) const WALK = ['open', 'claimed', 'working', 'result-ready', 'accepted'] const TERMINAL_MATCH = new Set(['accepted', 'cancelled', 'failed']) +const ARCHIVE_LIMIT_MAX = 200 +const readArchiveLimit = (value) => { + if (value === undefined || value === null) return undefined + // A number, not something that coerces into one: `true` is not "the first card", and "20" is a + // wrong request a reader should see refused rather than quietly honoured. + if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) { + throw invalid('A task list limit must be a whole number of at least 1') + } + return Math.min(value, ARCHIVE_LIMIT_MAX) +} +// Newest work first for the bounded archive: a card finished a minute ago has an old `createdAt` +// and a fresh `updatedAt`, and it is the one a member is looking for (#1252). +const newestFirst = (a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || (b.createdAt ?? 0) - (a.createdAt ?? 0) + const readPriority = (value) => { if (value === undefined || value === null || value === '') return undefined const priority = String(value) @@ -292,9 +306,30 @@ export function install(rt) { transition(task, next, actor, extra) if (next === 'result-ready') task.result = { kind: 'summary', summary: (task.note || 'Completed').slice(0, 2000) } } + if (target === 'accepted') settleOverall(task) return true } + // #1252: a group whose last child finishes otherwise leaves its overall card working forever, + // so the room board keeps a card nobody can close. When every child has ended, the overall card + // completes: `accepted` if each child was accepted or cancelled, `failed` if any child failed — + // a failed part is not hidden behind a green overall card. Both go through `transition` / + // `applyColumn`, the same writes a person's own move makes, so history and actors stay honest. + function settleOverall(child) { + const parent = child?.parentId ? store()[child.parentId] : undefined + if (parent === undefined || parent.kind !== 'group' || !openTask(parent)) return false + if (!['open', 'claimed', 'working'].includes(parent.status)) return false + const children = Object.values(store()).filter(task => task.parentId === parent.id) + if (children.length === 0 || children.some(task => !TERMINAL_MATCH.has(task.status))) return false + if (children.some(task => task.status === 'failed')) { + transition(parent, 'failed', null, { why: 'a part of this group failed' }) + return true + } + const moved = applyColumn(parent, 'done', null, 'last child completed') + if (moved) parent.progress = 100 + return moved + } + function applyFields(task, body) { const title = body.title !== undefined ? readTitle(body.title) : undefined if (title !== undefined) { @@ -716,6 +751,7 @@ export function install(rt) { if (waitingForInput(task) && task.inputApprovalIds?.includes(approval.id)) { if (decision !== 'approve') { transition(task, 'cancelled', { memberId: approval.ownerId }, { approvalId: approval.id, by: 'owner decision' }) + settleOverall(task) continue } task.inputApprovalIds = task.inputApprovalIds.filter(id => id !== approval.id) @@ -727,6 +763,8 @@ export function install(rt) { if (task.status !== 'blocked' || task.approvalId !== approval.id) continue delete task.approvalId transition(task, decision === 'approve' ? 'working' : 'cancelled', { memberId: approval.ownerId }, { by: 'owner decision' }) + // A rejection is one of the doors a child ends through, so the overall card settles here too. + settleOverall(task) } return save() } @@ -735,6 +773,8 @@ export function install(rt) { for (const task of Object.values(store())) { if (!ACTIVE.has(task.status) || (task.ownerBotId !== botId && task.workBotId !== botId)) continue transition(task, 'failed', null, { why: 'the responsible Bot was deleted' }) + // Deletion is another door a child ends through; its overall card must not stay working. + settleOverall(task) } return save() } @@ -764,7 +804,20 @@ export function install(rt) { // the room query, so a plain list never becomes a group-admin view. rows = all.filter(task => mayAct(task, actor) || ancestorOwned(actor, task.ownerBotId)) } - return rows.sort((a, b) => a.createdAt - b.createdAt).map(view) + // #1252: the board lists active work; the archive is the same read with a flag, so a + // group that has finished hundreds of cards does not ship them on every poll. A caller + // that asks for a bounded archive gets the newest rows; without `limit` every row stays + // readable, because a lookup by id must still find a card outside the newest page. + const archived = body.archive === true + const limit = archived ? readArchiveLimit(body.limit) : undefined + if (limit !== undefined) { + // The page exists for the Done column, so its budget is spent on finished rows only: a + // busy room's freshly updated working cards must not push the finished ones off it. + const settled = rows.filter(task => !openTask(task)).sort(newestFirst).slice(0, limit) + return settled.map(view) + } + const shown = archived ? rows : rows.filter(openTask) + return shown.sort((a, b) => a.createdAt - b.createdAt).map(view) } case 'task-create': { const botId = String(body.botId ?? '') @@ -866,6 +919,7 @@ export function install(rt) { if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`) requireRequester(task, actor) transition(task, 'accepted', actor) + settleOverall(task) requestWriteBack(task, 'accepted', undefined, actor) await save() return view(task) @@ -888,6 +942,7 @@ export function install(rt) { if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`) requireRequester(task, actor) transition(task, 'cancelled', actor) + settleOverall(task) await save() return view(task) } diff --git a/core/test/bot-tasks.test.js b/core/test/bot-tasks.test.js index ff49a66c..818af613 100644 --- a/core/test/bot-tasks.test.js +++ b/core/test/bot-tasks.test.js @@ -138,7 +138,7 @@ test('a Bot records conversation tasks and the owner moves them across the board await first.stop({ keepHome: true }) const restored = await start(t, { home: first.home }) app = restored - const saved = (await list(restored.call, alice.sessionToken, { botId: 's' })).find(task => task.id === open.id) + const saved = (await list(restored.call, alice.sessionToken, { botId: 's', archive: true })).find(task => task.id === open.id) assert.equal(saved.status, 'accepted') assert.equal(saved.progress, 100) assert.equal(saved.priority, 'high') @@ -434,3 +434,236 @@ test('the board asks the owning Bot for the comment a decision needs, and takes assert.equal(late.writeBack.wakes, 2, "the count stays with the record") assert.equal(late.status, "accepted") }) + +// #1252: a finished card leaves the active list, or a long-lived group's board grows without +// bound; the archive is the same read with a flag, not a second endpoint. +test('#1252: a room board lists active work only, and the archive is explicit', async (t) => { + const app = await start(t) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + await app.track('a', { tasks: [ + { title: 'Keep this moving', group: 'War', column: 'in-progress', progress: 30 }, + { title: 'This one is done', group: 'War', column: 'in-progress', progress: 10 }, + ] }) + const before = await list(app.call, alice.sessionToken, { roomId: 'war' }) + const finished = before.find(task => task.title === 'This one is done') + const done = await app.call('task-update', { id: finished.id, column: 'done' }, alice.sessionToken) + assert.equal(done.ok, true, JSON.stringify(done)) + + const active = await list(app.call, alice.sessionToken, { roomId: 'war' }) + assert.equal(active.some(task => task.id === finished.id), false, 'a done card leaves the room board by default') + assert.equal(active.some(task => task.title === 'Keep this moving'), true, 'unfinished work stays') + + const all = await list(app.call, alice.sessionToken, { roomId: 'war', archive: true }) + assert.equal(all.some(task => task.id === finished.id), true, 'the archive still carries the finished card') + assert.equal(all.length, active.length + 1) +}) + +// #1252: an overall card with no open child left has to be able to complete, or a group keeps a +// card working that nobody can close. +test('#1252: the last child completing completes the overall card instead of leaving it working', async (t) => { + const app = await start(t) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + await app.track('a', { tasks: [ + { title: 'Ship the note', overall: true, group: 'War' }, + { title: 'Write it', parent: 'Ship the note', assignee: 'Writer', group: 'War' }, + { title: 'Review it', parent: 'Ship the note', assignee: 'ChiefA', group: 'War' }, + ] }) + const room = await list(app.call, alice.sessionToken, { roomId: 'war' }) + const overall = room.find(task => task.kind === 'group') + const write = room.find(task => task.title === 'Write it') + const review = room.find(task => task.title === 'Review it') + + const firstMove = await app.call('task-update', { id: write.id, column: 'done' }, alice.sessionToken) + assert.equal(firstMove.ok, true, JSON.stringify(firstMove)) + const afterOne = (await list(app.call, alice.sessionToken, { roomId: 'war' })).find(task => task.id === overall.id) + assert.equal(afterOne.status, 'open', 'one child still open keeps the overall card where it was') + + const lastMove = await app.call('task-update', { id: review.id, column: 'done' }, alice.sessionToken) + assert.equal(lastMove.ok, true, JSON.stringify(lastMove)) + const parent = (await list(app.call, alice.sessionToken, { roomId: 'war', archive: true })).find(task => task.id === overall.id) + assert.equal(parent.status, 'accepted', 'the overall card completes with its last child') + assert.equal(columnOf(parent), 'done') + assert.equal(parent.progress, 100) +}) + +// #1252: a failed part is not hidden behind an accepted overall card. The card completes `accepted` +// only when every child ended accepted or cancelled, and `failed` when any child ended failed. +test('#1252: an overall card fails when one of its children failed', async (t) => { + const at = Date.now() + const state = team() + state.tasks = { + 'task-overall': { + id: 'task-overall', status: 'working', kind: 'group', createdAt: at - 60_000, updatedAt: at - 5000, + requesterId: 'owner-1', ownerBotId: 'a', title: 'Ship the note', source: 'conversation', + origin: { roomId: 'war' }, actors: [{ status: 'working', at: at - 5000, memberId: 'owner-1' }], + }, + 'task-child-ok': { + id: 'task-child-ok', status: 'working', createdAt: at - 50_000, updatedAt: at - 4000, + requesterId: 'owner-1', ownerBotId: 's', title: 'Write it', parentId: 'task-overall', + source: 'conversation', origin: { roomId: 'war' }, actors: [{ status: 'working', at: at - 4000, memberId: 'owner-1' }], + }, + 'task-child-failed': { + id: 'task-child-failed', status: 'failed', createdAt: at - 40_000, updatedAt: at - 3000, + requesterId: 'owner-1', ownerBotId: 'a', title: 'Review it', parentId: 'task-overall', + source: 'conversation', origin: { roomId: 'war' }, actors: [{ status: 'failed', at: at - 3000, memberId: 'owner-1' }], + }, + } + const app = await start(t, { state }) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + const moved = await app.call('task-update', { id: 'task-child-ok', column: 'done' }, alice.sessionToken) + assert.equal(moved.ok, true, JSON.stringify(moved)) + const parent = (await list(app.call, alice.sessionToken, { roomId: 'war', archive: true })).find(task => task.id === 'task-overall') + assert.equal(parent.status, 'failed', 'a failed child makes the overall card fail, not pass') +}) + +// #1252: the wire must not carry the whole archive. A requested `limit` bounds the finished rows, +// while a read without one still returns every row, so lookups by id keep working. +test('#1252: an archive read can ask for the newest rows, and an unbounded read still sees them all', async (t) => { + const at = Date.now() + const state = team() + state.tasks = {} + for (let i = 0; i < 200; i++) { + state.tasks[`task-settled-${i}`] = { + id: `task-settled-${i}`, status: 'accepted', createdAt: at - 200_000 + i, updatedAt: at - 100_000 + i, + requesterId: 'owner-1', ownerBotId: 'a', title: `Settled ${i}`, source: 'conversation', + origin: { roomId: 'war' }, actors: [{ status: 'accepted', at: at - 100_000 + i, memberId: 'owner-1' }], + } + } + const app = await start(t, { state }) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + + const bounded = await list(app.call, alice.sessionToken, { roomId: 'war', archive: true, limit: 20 }) + assert.equal(bounded.length, 20, 'the requested limit bounds the payload') + assert.equal(bounded.some(task => task.id === 'task-settled-199'), true, 'the newest finished card is in the slice') + assert.equal(bounded.some(task => task.id === 'task-settled-0'), false, 'the oldest finished card is not') + + const unbounded = await list(app.call, alice.sessionToken, { roomId: 'war', archive: true }) + assert.equal(unbounded.length, 200, 'a read with no limit still returns every settled row') + assert.equal(unbounded.some(task => task.id === 'task-settled-0'), true, 'including the oldest, so an id lookup still finds it') +}) + +// #1252: a bounded archive read is finished rows only. With the budget spent on every row, a busy +// room returns twenty working cards and an empty Done column — the exact row the client asked for. +test('#1252: a bounded archive read carries the newest finished rows and no active ones', async (t) => { + const at = Date.now() + const state = team() + state.tasks = {} + const row = (id, status, updatedAt) => ({ + id, status, createdAt: at - 500_000, updatedAt, + requesterId: 'owner-1', ownerBotId: 'a', title: id, source: 'conversation', + origin: { roomId: 'war' }, actors: [{ status, at: updatedAt, memberId: 'owner-1' }], + }) + for (let i = 0; i < 25; i++) state.tasks[`task-active-${i}`] = row(`task-active-${i}`, 'working', at - i) + for (let i = 0; i < 5; i++) state.tasks[`task-settled-${i}`] = row(`task-settled-${i}`, 'accepted', at - 100 - i) + + const app = await start(t, { state }) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + + const bounded = await list(app.call, alice.sessionToken, { roomId: 'war', archive: true, limit: 20 }) + assert.equal(bounded.every(task => ['accepted', 'cancelled', 'failed'].includes(task.status)), true, 'the bounded archive carries finished rows only') + assert.equal(bounded.some(task => task.id === 'task-settled-0'), true, 'the newest finished card is present') + assert.equal(bounded.some(task => task.id.startsWith('task-active-')), false, 'no active row is in the bounded payload') + assert.equal(bounded.length, 5, 'all five finished cards, since five is under the limit') + + // `limit` is a number or nothing: a coerced `true` or "20" is a wrong request, not a page. + for (const limit of [true, '20', 0, -3, 1.5, 'abc', {}]) { + const refused = await app.call('tasks', { roomId: 'war', archive: true, limit }, alice.sessionToken) + assert.equal(refused.ok, false, `limit ${JSON.stringify(limit)} is refused`) + assert.equal(refused.error.code, 'bot/invalid', `limit ${JSON.stringify(limit)} is bot/invalid`) + } +}) + +// #1252: a child does not only end through a column move. Deleting its Bot fails it, and an owner +// rejecting the send it waits on cancels it; both leave the overall card working unless the same +// settle runs on those doors too. Read the parent back through the archive — that is the card a +// person sees, not the write's return value. +test('#1252: deleting a child\'s Bot fails the overall card, read back from the archive', async (t) => { + const app = await start(t) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + await app.track('a', { tasks: [ + { title: 'Ship the note', overall: true, group: 'War' }, + { title: 'Write it', parent: 'Ship the note', assignee: 'Writer', group: 'War' }, + ] }) + const room = await list(app.call, alice.sessionToken, { roomId: 'war' }) + const overall = room.find(task => task.kind === 'group') + const child = room.find(task => task.title === 'Write it') + const started = await app.call('task-update', { id: child.id, column: 'in-progress' }, alice.sessionToken) + assert.equal(started.ok, true, JSON.stringify(started)) + const removed = await app.call('delete-bot', { id: 's' }, alice.sessionToken) + assert.equal(removed.ok, true, JSON.stringify(removed)) + + const after = await list(app.call, alice.sessionToken, { archive: true }) + const failedChild = after.find(task => task.id === child.id) + assert.equal(failedChild?.status, 'failed', 'the deleted Bot\'s child fails') + const parent = after.find(task => task.id === overall.id) + assert.equal(parent?.status, 'failed', 'a failed child closes the overall card as failed') + assert.equal(columnOf(parent), 'done') +}) + +test('#1252: a rejected approval cancels the child and completes the overall card, read back', async (t) => { + const app = await start(t) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + await app.track('a', { tasks: [ + { title: 'Ship the note', overall: true, group: 'War' }, + { title: 'Write it', parent: 'Ship the note', assignee: 'Writer', group: 'War' }, + ] }) + const room = await list(app.call, alice.sessionToken, { roomId: 'war' }) + const overall = room.find(task => task.kind === 'group') + const child = room.find(task => task.title === 'Write it') + const started = await app.call('task-update', { id: child.id, column: 'in-progress' }, alice.sessionToken) + assert.equal(started.ok, true, JSON.stringify(started)) + + app.ctx.flybot.bot.send.use({ name: 'gate', handle: msg => msg.meta?.hold ? { action: 'hold', reason: 'held by test' } : undefined }) + const held = await app.ctx.flybot.bot.send({ from: 's', to: 'a', text: 'send this out', meta: { hold: true } }) + assert.equal(held.action, 'hold', JSON.stringify(held)) + const blocked = (await list(app.call, alice.sessionToken, { roomId: 'war' })).find(task => task.id === child.id) + assert.equal(blocked.status, 'blocked', 'the held send blocks the child') + + const rejected = await app.call('flybot/decide', { id: held.approvalId, decision: 'reject' }, alice.sessionToken) + assert.equal(rejected.ok, true, JSON.stringify(rejected)) + const after = await list(app.call, alice.sessionToken, { roomId: 'war', archive: true }) + const cancelled = after.find(task => task.id === child.id) + assert.equal(cancelled.status, 'cancelled', 'the rejected send cancels the child') + const parent = after.find(task => task.id === overall.id) + assert.equal(parent.status, 'accepted', 'a cancelled child completes the overall card as accepted') + assert.equal(parent.progress, 100) + assert.equal(columnOf(parent), 'done') +}) + +// #1252: the Bot filter runs before the bounded page is cut. Twenty newer cards on another Bot +// must not push this Bot's only finished card off the page — that is the read a Bot board makes. +test('#1252: a Bot-scoped archive page is cut after the Bot filter', async (t) => { + const at = Date.now() + const state = team() + state.tasks = {} + const row = (id, ownerBotId, updatedAt) => ({ + id, status: 'accepted', createdAt: updatedAt, updatedAt, + requesterId: 'owner-1', ownerBotId, title: id, source: 'conversation', + origin: { roomId: 'war' }, actors: [{ status: 'accepted', at: updatedAt, memberId: 'owner-1' }], + }) + for (let i = 0; i < 20; i++) state.tasks[`task-writer-${i}`] = row(`task-writer-${i}`, 's', at - i) + state.tasks['task-chief-old'] = row('task-chief-old', 'a', at - 10_000) + const app = await start(t, { state }) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + + const mine = await list(app.call, alice.sessionToken, { botId: 'a', archive: true, limit: 20 }) + assert.equal(mine.length, 1, 'the page is cut after the Bot filter, so the older card survives') + assert.equal(mine[0].id, 'task-chief-old') + + const writers = await list(app.call, alice.sessionToken, { botId: 's', archive: true, limit: 20 }) + assert.equal(writers.length, 20) + assert.equal(writers.every(task => task.ownerBotId === 's'), true) + + // The unfiltered page is the defect's precondition: its whole budget goes to the newer Bot. + const scope = await list(app.call, alice.sessionToken, { archive: true, limit: 20 }) + assert.equal(scope.length, 20) + assert.equal(scope.some(task => task.id === 'task-chief-old'), false, 'the unfiltered page spends the budget on the newer Bot') +}) diff --git a/core/test/inbox.test.js b/core/test/inbox.test.js index fb3b72a8..a35b1bbd 100644 --- a/core/test/inbox.test.js +++ b/core/test/inbox.test.js @@ -14,14 +14,17 @@ const bundle = await build({ external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], }) const module = { exports: {} } +// A hook's effects are collected, never run: a test that drives a load calls the one it needs. +// Timers are stubs so a driven load does not arm a background poll. +const effects = [] runInNewContext(bundle.outputFiles[0].text, { - module, exports: module.exports, setTimeout, clearTimeout, setInterval, clearInterval, Date, Promise, + module, exports: module.exports, setTimeout: () => 0, clearTimeout: () => {}, setInterval: () => 0, clearInterval: () => {}, Date, Promise, require: (name) => { const platform = { react: { createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], - useEffect: () => {}, useLayoutEffect: () => {}, useRef: current => ({ current }), useCallback: fn => fn, useMemo: fn => fn(), Fragment: 'fragment', + useEffect: fn => { effects.push(fn) }, useLayoutEffect: () => {}, useRef: current => ({ current }), useCallback: fn => fn, useMemo: fn => fn(), Fragment: 'fragment', }, 'react-dom': {}, '@deepseek-ai/dsh-client-ui-primitives': {}, @@ -30,7 +33,7 @@ runInNewContext(bundle.outputFiles[0].text, { return platform[name] }, }) -const { inboxModel, needsCount, InboxCard, InboxDialog, InboxRow, GROUPS, STATUSES, KEEP_MS, INBOX_CSS } = module.exports +const { inboxModel, needsCount, InboxCard, InboxDialog, InboxRow, GROUPS, STATUSES, KEEP_MS, INBOX_CSS, useTasks } = module.exports const plain = value => JSON.parse(JSON.stringify(value)) // Draws the inbox's own components; Bot marks and icons stay as elements. @@ -728,3 +731,47 @@ test('a Needs you card shows the whole ask, and the phone clamp cannot cut it to assert.ok(buttons(tree).some(node => textOf(node) === 'Submit')) assert.ok(buttons(tree).some(node => textOf(node) === 'Open')) }) + +// #1252: the Inbox is a second consumer of `tasks`. When the default read became active-only it +// silently stopped receiving finished and failed cards, so the inbox's own load has to ask for the +// settled rows — and the response has to reach the badges and the groups, not just the wire. +test('#1252: the Inbox asks for the settled rows, and they reach Done and Needs you', async () => { + const at = Date.now() - 1000 + const accepted = task('i1', 'accepted', { createdAt: at - 60_000, updatedAt: at }) + const failed = task('i2', 'failed', { createdAt: at - 60_000, updatedAt: at, actors: [{ status: 'failed', why: 'the responsible Bot was deleted' }] }) + // The host answers like the real one: the active-only default carries neither settled row. + const calls = [] + const actions = { + openInbox: () => {}, + tasks: (...args) => { + calls.push(args) + return Promise.resolve(args[0]?.archive === true ? [accepted, failed] : []) + }, + } + + effects.length = 0 + useTasks(actions) + assert.equal(effects.length, 1, 'the Inbox loads tasks through one effect') + effects.pop()() + await new Promise(resolve => setImmediate(resolve)) + + const base = needsCount(roster, work, []) + const counted = InboxRow({ wide: true, useRoster: pick => pick(roster), useWork: pick => pick(work), useUi: pick => pick({}), actions }) + const badge = textOf(byClass(counted, 'bt-inbox-count')[0]) + const cards = cardsIn(dialog(actions)) + const drawn = node => (node ? byClass(expand(node), 'bt-inbox-card')[0]?.props['data-status'] : undefined) + const done = drawn(cards.find(node => node.props.card.id === 'task:i1')) + const needs = drawn(cards.find(node => node.props.card.id === 'task:i2')) + assert.equal(calls[0].length, 1, 'the Inbox read sends a body asking for the settled rows') + assert.deepStrictEqual({ ...calls[0][0] }, { archive: true }, 'the Inbox read asks for the settled rows') + assert.equal(badge, String(base + 1), 'the row badge counts the failed task') + assert.equal(done, 'done', 'an accepted task from that response is a Done card') + assert.equal(needs, 'failed', 'a failed task from that response is a Needs you card') + + // Put the shared list back for any later read in this file. + effects.length = 0 + actions.tasks = () => Promise.resolve([]) + useTasks(actions) + effects.pop()() + await new Promise(resolve => setImmediate(resolve)) +}) diff --git a/core/test/kanban.test.js b/core/test/kanban.test.js index 5a459279..a64ff061 100644 --- a/core/test/kanban.test.js +++ b/core/test/kanban.test.js @@ -15,21 +15,23 @@ const bundle = await build({ const module = { exports: {} } // Preset state for the next useState calls; KanbanDialog's first hook is the view. const states = [] +// A hook's effects are collected, never run: a test that drives a load calls the one it needs. +const effects = [] runInNewContext(bundle.outputFiles[0].text, { - module, exports: module.exports, setTimeout, clearTimeout, setInterval, clearInterval, Date, Promise, + module, exports: module.exports, setTimeout, clearTimeout, setInterval: () => 0, clearInterval: () => {}, Date, Promise, require: (name) => { const platform = { react: { createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), useState: initial => [states.length > 0 ? states.shift() : (typeof initial === 'function' ? initial() : initial), () => {}], - useEffect: () => {}, useCallback: fn => fn, useRef: current => ({ current }), + useEffect: fn => { effects.push(fn) }, useCallback: fn => fn, useRef: current => ({ current }), }, } if (!(name in platform)) throw new Error(`no platform module ${name}`) return platform[name] }, }) -const { BoardView, KanbanDialog, boardColumns, boardRail, canMoveTask, columnTree, shortId } = module.exports +const { ARCHIVE_SHOWN, BoardView, KanbanDialog, boardColumns, boardTasksFor, boardRail, canMoveTask, columnTree, shortId, useBoardTasks } = module.exports const expand = (node) => { if (Array.isArray(node)) return node.flatMap(expand) @@ -213,3 +215,67 @@ test('a delivery read with no token leaves the task cards and their tab in place assert.equal(byClass(tasksView, 'bt-board-card').length, 1) assert.deepEqual(all(tasksView).filter(node => node.props?.role === 'tab').map(node => node.props['data-view']), ['tasks', 'delivery']) }) + +// #1252: the Done column is archive, not the board, and the slice has to be the *newest* finished +// cards — a card a member just finished has an old `createdAt`, so ordering by it would hide exactly +// the card they are looking for. +test('#1252: a group board renders the newest finished cards, not the oldest', () => { + const active = [{ id: 'task-live', ownerBotId: 'a', title: 'Live', status: 'working', updatedAt: 1000 }] + const archive = Array.from({ length: 37 }, (_, i) => ({ + id: `task-done-${i}`, ownerBotId: 'a', title: `Done ${i}`, status: 'accepted', progress: 100, createdAt: i, updatedAt: i, + })) + const merged = boardTasksFor(active, archive) + assert.equal(merged.length, 1 + ARCHIVE_SHOWN) + const columns = boardColumns(merged) + assert.equal(columns['in-progress'].length, 1) + assert.equal(columns.done.length, 20, 'the column is a literal twenty cards') + assert.equal(columns.done.length, ARCHIVE_SHOWN) + assert.equal(columns.done[0].id, 'task-done-36', 'the column starts at the newest finished card') + assert.equal(columns.done.some(card => card.id === 'task-done-0'), false, 'the oldest finished card is outside the slice') + + // The card just finished: old `createdAt`, fresh `updatedAt`. It must be first, not cut off. + const justFinished = { id: 'task-just-finished', ownerBotId: 'a', title: 'Just finished', status: 'accepted', progress: 100, createdAt: 1, updatedAt: 99 } + const withFresh = boardColumns(boardTasksFor(active, [...archive, justFinished])) + assert.equal(withFresh.done[0].id, 'task-just-finished', 'the card finished just now leads the column') + assert.equal(withFresh.done.length, ARCHIVE_SHOWN) + + // The host's bounded page now carries finished rows only; the client still draws them. + const settledPage = Array.from({ length: 5 }, (_, i) => ({ + id: `task-settled-${i}`, ownerBotId: 'a', title: `Settled ${i}`, status: 'accepted', progress: 100, createdAt: i, updatedAt: 100 + i, + })) + const fromBoundedPage = boardColumns(boardTasksFor(active, settledPage)) + assert.equal(fromBoundedPage.done.length, 5, 'a finished-only page still fills the Done column') +}) + +// #1252: the Bot-scoped archive read has to name the Bot. The host cuts the newest finished rows +// before the board filters by Bot, so a request that only carries `{ archive, limit }` spends the +// whole page on other Bots' cards and this Bot's Done column empties. +test('#1252: a Bot board asks the host for that Bot\'s archive, not the whole scope', async () => { + const calls = [] + const otherBotCard = { id: 'task-a-old', ownerBotId: 'a', title: 'Mine', status: 'accepted', progress: 100, createdAt: 1, updatedAt: 2 } + const actions = { tasks: (...args) => { calls.push(args); return Promise.resolve([]) } } + effects.length = 0 + states.length = 0 + useBoardTasks(actions, { kind: 'bot', botId: 'a' }) + assert.equal(effects.length, 1, 'the board loads through one effect') + effects.pop()() + await new Promise(resolve => setImmediate(resolve)) + + assert.equal(calls[0].length, 0, 'the default read stays the unfiltered active list') + const archiveCall = calls.find(args => args[0]?.archive === true) + assert.deepStrictEqual({ ...archiveCall?.[0] }, { botId: 'a', archive: true, limit: ARCHIVE_SHOWN }, 'the archive read names the Bot') + + // A group board has no Bot to name; its read stays scoped to the room. + calls.length = 0 + effects.length = 0 + states.length = 0 + useBoardTasks(actions, { kind: 'group', roomId: 'war' }) + effects.pop()() + await new Promise(resolve => setImmediate(resolve)) + const groupArchive = calls.find(args => args[0]?.archive === true) + assert.deepStrictEqual({ ...groupArchive?.[0] }, { roomId: 'war', archive: true, limit: ARCHIVE_SHOWN }, 'the group archive read stays room-scoped') + + // The page the host returns for that query still fills the Done column after the Bot filter. + const done = boardColumns(boardTasksFor([], [otherBotCard].filter(task => task.ownerBotId === 'a'))).done + assert.equal(done.length, 1, 'the Bot\'s own finished card fills the Done column') +}) diff --git a/core/test/tasks.test.js b/core/test/tasks.test.js index 06ca2212..73cf4480 100644 --- a/core/test/tasks.test.js +++ b/core/test/tasks.test.js @@ -91,7 +91,7 @@ async function start(t, config = {}) { } const statusOf = async (call, token, id) => { - const list = await call('tasks', {}, token) + const list = await call('tasks', { archive: true }, token) assert.equal(list.ok, true, JSON.stringify(list)) return list.value.find(task => task.id === id) } diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 829f5417..1d8d48eb 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -42,7 +42,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | `core/src/client/delivery-chart.js` | The 7-day line each delivery metric draws (#1212): `chartGeometry` is pure — seven x positions, a y per known day (`span = max(known)`, baseline 0), the path split at every day with no number, so a gap can never read as zero. A day without a number draws no dot at all; the `full` / `partial` split is a geometry state, not an attribute for the missing day. `DeliveryChart` renders one 168×28 SVG per metric with a 1.5px stroke, `r=2` dots (`r=2.5` for the one hollow dot) and a `title` on every point (`2026-10-26 · 12`, `2026-10-26 · 未取到`); each drawn dot carries `data-state` `full` or `partial`, a real `0` is a filled dot on the baseline, and today with `partial: true` is hollow (`fill:none`, `stroke-width:1.5`) with a `2 2` dashed stretch. `DeliveryMetrics` is the whole block the view calls once — one row per metric, `label | line | Today | 7 days`, five lines (the four counts plus the CI share), with `Σ` only on the counting rows (a ratio and a median are not sums) and a star plus a one-line note, naming the window length, when some days were not read. The CI share's denominator — `counted`, else `total` — is the one the header row now reads too (#1244), so the number on the row and the point on the line come from the same day. A snapshot with no `metrics.daily` draws no SVG at all and says `7 天数据未取到` in one line. Colours come from `--bt-accent` / `--bt-ink-*` only. Tests: `core/test/delivery-chart.test.js`. | | `core/src/client/board-card.js` | One card component behind two boards (#1265 step 1): `BoardCard` draws the identity line, the title, what the card wears, who has it and what it is doing, from a card model that says `source: 'github'` or `source: 'task'`. Each board passes its own `skin` (`DELIVERY_SKIN` / `TASK_SKIN`), so the delivery board and the task board keep their stylesheets while sharing the markup, and every card carries `data-source` and `data-key`. The two callers are `delivery-view.js`'s `DeliveryCard` and `kanban.js`'s `BoardCard`. Tests: `core/test/board-card.test.js`, `core/test/delivery-view.test.js`, `core/test/kanban.test.js`. | | `core/src/shared/board-card.js` | Card identity for the one board (#1265 step 1): `githubCardKey(repo, number)` is `owner/name#number` — the same key for an issue and the pull request that closes it, which is the identity step 3 hangs a FlyBot record under — and `taskCardKey(id)` is `task:`. `githubCard(row, repo)` and `taskCard(task)` build the model the component draws, `sameCard(a, b)` is the one comparison both sides dedupe by, and a GitHub row with no repository falls back to `github::` rather than imply an identity across repositories. Tests: `core/test/board-card.test.js`. | -| `core/src/client/kanban.js` | Per-Bot task board (#1058, #1099): the **Tasks** row under Inbox (`KanbanRow`, `ui.board`, `actions.openBoard` / `closeBoard`) and `KanbanDialog`. `boardRail` lists the viewer's Bots nested by `createdBy` and the groups they administer; `boardColumns` maps task status onto **Not started** / **In progress** / **Done**. Cards show id, priority, title, note, assignee and progress, and are drawn by the shared `board-card.js` component: a task card says `source: 'task'`, keys `task:` and keeps `data-task` / `data-column` for this board's own selectors (#1265 step 1). The dialog has two tabs (#1173), **Tasks** and **Delivery**; the Delivery view is repository-wide, so its Bot rail is not rendered and it cannot be pointed at one Bot. In a group scope, `columnTree` nests subtasks under the overall task (`kind: 'group'`) when they share a column, and a subtask in another column says which overall task it is part of. That scope also says assigning, claiming, and progress live on the board (overall task, subtask, assignee, column, progress) and that @ only wakes someone. Drag or **Start** / **Complete** calls `actions.taskUpdate`; only the Bot's owner gets those actions. A group scope loads `tasks { roomId }`. Tests: `core/test/kanban.test.js`. | +| `core/src/client/kanban.js` | Per-Bot task board (#1058, #1099): the **Tasks** row under Inbox (`KanbanRow`, `ui.board`, `actions.openBoard` / `closeBoard`) and `KanbanDialog`. `boardRail` lists the viewer's Bots nested by `createdBy` and the groups they administer; `boardColumns` maps task status onto **Not started** / **In progress** / **Done**. Cards show id, priority, title, note, assignee and progress, and are drawn by the shared `board-card.js` component: a task card says `source: 'task'`, keys `task:` and keeps `data-task` / `data-column` for this board's own selectors (#1265 step 1). The dialog has two tabs (#1173), **Tasks** and **Delivery**; the Delivery view is repository-wide, so its Bot rail is not rendered and it cannot be pointed at one Bot. In a group scope, `columnTree` nests subtasks under the overall task (`kind: 'group'`) when they share a column, and a subtask in another column says which overall task it is part of. That scope also says assigning, claiming, and progress live on the board (overall task, subtask, assignee, column, progress) and that @ only wakes someone. Drag or **Start** / **Complete** calls `actions.taskUpdate`; only the Bot's owner gets those actions. A group scope loads `tasks { roomId }`. Since #1252 the default read is active work only and the Done column merges a second read of the finished rows — `tasks { ...query, archive: true, limit: ARCHIVE_SHOWN }`, which is room-scoped for a group and names the Bot for a Bot board, because the host cuts the page before the board can filter by Bot — newest-settled first by `updatedAt` (tiebreak `createdAt`), finished rows only, and cut to that limit, so a room with hundreds of finished cards still renders a twenty-card column while the poll carries the slice and not the history. That read without `limit` stays uncapped on purpose: it is the id-lookup and tooling path (`statusOf`, a Bot reading a settled row), not what the board polls. Finishing a card moves it off the active list at once, before the next poll. Tests: `core/test/kanban.test.js`. | | `core/src/client/standing-approvals.js` | Standing tool approvals (#540): `StandingApprovalsCard`, the **Always allowed** card in Bot settings (the Bot's grants) and Group settings (the grants this person gave in the group), one row per kind with **Remove** (`actions.removeStandingApproval`). The approval cards (`member-status.js` `ApprovalCard`, the inbox card) add **Always allow for this Bot** and, for an ask from a group, **Always allow for this group** (`alwaysChoices`; never on a friend's Bot). Host side in [core-host](core-host.md#standing-tool-approvals-540). Tests: `core/test/member-status.test.js`, `core/test/standing-approvals.test.js`. | | `core/src/client/mobile.js` | Phone-width layout, step 1 (#362): `MOBILE_CSS` under `@media (max-width:640px)` for held-reply and floating approval buttons, the chat pointers and Agent inbox cards. Tests: `core/test/mobile-layout.test.js`. | diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index d5aa4b24..52caea11 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -238,11 +238,11 @@ credentials and team mode remain separate work under #55/#198. ### Bot task board (#1058) -Each Bot's work sits on the same `state.tasks` records. A column is read from the status (`open`/`claimed` not started, `working`/`blocked`/`result-ready` in progress, `accepted`/`cancelled`/`failed` done) by `src/shared/task-board.js`. `task-update` sets `priority` (`urgent`/`high`/`normal`/`low`), `progress` (0–100), `note` and a column; a column walks the existing states forward (`open` → `claimed` → `working` → `result-ready` → `accepted`) and refuses a reverse move or a blocked wait. The requester or the Bot's owner may update. Anyone else, including a group admin, may not. +Each Bot's work sits on the same `state.tasks` records. A column is read from the status (`open`/`claimed` not started, `working`/`blocked`/`result-ready` in progress, `accepted`/`cancelled`/`failed` done) by `src/shared/task-board.js`. `task-update` sets `priority` (`urgent`/`high`/`normal`/`low`), `progress` (0–100), `note` and a column; a column walks the existing states forward (`open` → `claimed` → `working` → `result-ready` → `accepted`) and refuses a reverse move or a blocked wait. The requester or the Bot's owner may update. Anyone else, including a group admin, may not. The `tasks` read lists active work only; `{ archive: true }` is the same read including the finished rows, and `{ archive: true, limit: N }` returns the newest N finished rows by `updatedAt` (tiebreak `createdAt`) and no active rows — the default read is what carries active work — so the board's poll carries the slice and not the history. A `botId` or `roomId` read filters before that cut, so the page is that Bot's or room's finished rows and not the whole visible scope. A read without `limit` still returns every settled row on purpose: it is the id-lookup and tooling path (`statusOf`, a Bot reading a settled row), not what the board polls (#1252). `track_tasks` is the conversation path. The calling Bot creates or updates only its own tasks (`source: 'conversation'`, `requesterId` the Bot's owner, `origin.sessionId` the turn, `origin.roomId` the group when the turn is in one). The same title in the same group updates the open task instead of adding another. The prompt tells the Bot to record work the user or a group actually handed it, to continue the unfinished task the tool names next, and to pass `ask` when it cannot continue without an answer. -A group job is one overall task plus a subtask per Bot (#1099). The overall task is `kind: 'group'`, owned by the Bot that records it, and tagged with `origin.roomId`. A group keeps one open overall task; recording another updates that one. A subtask sets `parentId` to that task. One Bot divides the board (#1182): `room.taskLead` when that Bot is still a member, otherwise the group admin. A face-to-face room has no board, so it has no task lead. Another Main Bot does not plan. The user, a Main Bot, or the group's admin sets the lead with `update_group` (`task_lead`, `none` clears it) or `update-room` (`taskLead`); group settings shows **Make task lead** / **Use the admin** on a group that lives here. Assigning a subtask to another member claims it (`open` → `claimed`) so the assignee starts without a confirmation, and wakes that assignee in its own chat (#1205); the assignment does not open a group round. The assignee then sets the column to in-progress with `track_tasks`; a chat line does not assign, claim, or record progress. The group board shows that above the columns. The requester stays that Bot's owner, so the lead still cannot move the card through `task-update`. A member updates the card assigned to it, records its own room task, or links its own subtask to the overall task. Only the lead records the overall task or assigns a subtask to someone else. +A group job is one overall task plus a subtask per Bot (#1099). The overall task is `kind: 'group'`, owned by the Bot that records it, and tagged with `origin.roomId`. A group keeps one open overall task; recording another updates that one. A subtask sets `parentId` to that task. One Bot divides the board (#1182): `room.taskLead` when that Bot is still a member, otherwise the group admin. A face-to-face room has no board, so it has no task lead. Another Main Bot does not plan. The user, a Main Bot, or the group's admin sets the lead with `update_group` (`task_lead`, `none` clears it) or `update-room` (`taskLead`); group settings shows **Make task lead** / **Use the admin** on a group that lives here. Assigning a subtask to another member claims it (`open` → `claimed`) so the assignee starts without a confirmation, and wakes that assignee in its own chat (#1205); the assignment does not open a group round. The assignee then sets the column to in-progress with `track_tasks`; a chat line does not assign, claim, or record progress. The group board shows that above the columns. The requester stays that Bot's owner, so the lead still cannot move the card through `task-update`. A member updates the card assigned to it, records its own room task, or links its own subtask to the overall task. Only the lead records the overall task or assigns a subtask to someone else. When every child has ended, the overall card completes through the same transition a person's move uses: `accepted` with `progress` 100 when each child ended accepted or cancelled, `failed` when any child ended failed, so a failed part is never hidden behind a green overall card; an overall card already blocked or waiting on a result is left to its owner (#1252). A child also ends when its Bot is deleted (`failed`) or when the owner rejects the send it was waiting on (`cancelled`); both doors settle the overall card the same way, so the card a person sees is closed rather than left working. A lead can put work in a pull queue instead of naming an assignee (#1247). `track_tasks { tasks: [{ queue: true, role: 'Dev', priority, title }] }` records one item for that role: `queueRole` names it, `queueBy` is the lead, and `ownerBotId` stays the enqueuer until a claim, so an unclaimed item wakes nobody — `task-drive` skips it. Only the room's lead enqueues, and an item whose role no live member of that room carries is refused (`no-member-with-role`) instead of sitting for ever for a role nobody has. `track_tasks { claim: true }` takes the highest-priority item of the caller's own role, oldest first on a tie (`compareForQueue`, not `compareForWork`, whose tiebreak is newest first), and `id` claims a named item; a member of another role is refused by name (`not-in-role`), never silently. One member holds one card in progress, so a member already holding one gets nothing — a blocked card waits on a person and does not count. The claim is compare-and-set: the item is read again after the pick yields, and only one still open is taken, so two claims leave exactly one owner. `track_tasks { release: { task, reason, retryInSec } }` returns a claimed item (`blocked → open` included) with `retryAt`, and it is not offered again before then. A claim sets `ownerBotId` and `claimedBy` to the member and keeps `queueRole`, so the card the board, the delivery view and room presence read is the same card: claiming is a board write, not a second row. Tests: `core/test/task-queue.test.js`. Reading the list orders unfinished work by priority (urgent, high, normal, low) and prefers an in-progress task when the priority is the same. `ask` walks the task to working and then to the existing input wait (`waitingFor: 'input'`); the reply tells the Bot to call `ask_user` for the user, and to post one short question in the group only when a person there has to decide. `answer` resumes that wait. The question is the same record the inbox already shows for a blocked task. diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index c905adeb..d329ab72 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -1710,7 +1710,7 @@ diff --git a/client.js b/client.js } return ""; } -@@ -1624,3156 +1624,6953 @@ function usageOf(list, ids) { +@@ -1624,3156 +1624,6970 @@ function usageOf(list, ids) { const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); } @@ -8841,7 +8841,7 @@ diff --git a/client.js b/client.js + taskListeners.add(setTasks); + let stopped = false; + let timer; -+ const load = () => Promise.resolve(actions?.tasks?.()).then((list) => { ++ const load = () => Promise.resolve(actions?.tasks?.({ archive: true })).then((list) => { + if (!stopped && list !== void 0) publishTasks(list); + }).catch(() => { + }).finally(() => { @@ -10856,6 +10856,11 @@ diff --git a/client.js b/client.js + const hex = String(id ?? "").replace(/^task-/, ""); + return `#${hex.slice(-6)}`; +} ++var ARCHIVE_SHOWN = 20; ++function boardTasksFor(tasks, archive, limit = ARCHIVE_SHOWN) { ++ const newest = [...archive ?? []].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || (b.createdAt ?? 0) - (a.createdAt ?? 0)); ++ return [...tasks ?? [], ...newest.slice(0, limit)]; ++} +function boardColumns(tasks) { + const columns = { "not-started": [], "in-progress": [], done: [] }; + for (const task of tasks ?? []) { @@ -11015,21 +11020,58 @@ diff --git a/client.js b/client.js - frame = 0; - const node = document.querySelector("[data-chat-flow] > [data-chat-running]"); - setHost((prev) => prev === node ? prev : node); -- }; ++ map.get(id).push(task); ++ } ++ return order.map((id) => ({ id, name: botName(roster, map.get(id)[0]) || id, tasks: map.get(id) })); ++} ++var upsert = (list, task) => list.some((item) => item.id === task.id) ? list.map((item) => item.id === task.id ? task : item) : [...list, task]; ++var without = (list, id) => list.filter((item) => item.id !== id); ++function useBoardTasks(actions, scope) { ++ const [own, setOwn] = (0, import_react17.useState)([]); ++ const [groupTasks, setGroupTasks] = (0, import_react17.useState)([]); ++ const [archive, setArchive] = (0, import_react17.useState)([]); ++ const key = scope?.kind === "group" ? `group:${scope.roomId}` : "own"; ++ (0, import_react17.useEffect)(() => { ++ let stopped = false; ++ const load = () => { ++ const query = scope?.kind === "group" ? { roomId: scope.roomId } : {}; ++ const archiveQuery = scope?.kind === "bot" ? { botId: scope.botId } : query; ++ const job = scope?.kind === "group" ? Promise.resolve(actions?.tasks?.(query)) : Promise.resolve(actions?.tasks?.()); ++ void job.then((list) => { ++ if (stopped || !Array.isArray(list)) return; ++ if (scope?.kind === "group") setGroupTasks(list); ++ else setOwn(list); ++ }).catch(() => void 0); ++ void Promise.resolve(actions?.tasks?.({ ...archiveQuery, archive: true, limit: ARCHIVE_SHOWN })).then((list) => { ++ if (stopped || !Array.isArray(list)) return; ++ setArchive(list.filter((task) => columnOf(task) === "done")); ++ }).catch(() => void 0); + }; - const observer = new MutationObserver(() => { - if (!frame) frame = requestAnimationFrame(find); - }); - observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); - find(); -- return () => { ++ load(); ++ const timer = setInterval(load, POLL_MS4); + return () => { - cancelAnimationFrame(frame); - observer.disconnect(); -- }; ++ stopped = true; ++ clearInterval(timer); + }; - }, [active]); - return host; -+ map.get(id).push(task); -+ } -+ return order.map((id) => ({ id, name: botName(roster, map.get(id)[0]) || id, tasks: map.get(id) })); ++ }, [key]); ++ const tasks = scope?.kind === "group" ? groupTasks : own.filter((task) => !scope?.botId || task.ownerBotId === scope.botId); ++ const replace = (task) => { ++ const done2 = columnOf(task) === "done"; ++ if (scope?.kind === "group") setGroupTasks((list) => done2 ? without(list, task.id) : upsert(list, task)); ++ else setOwn((list) => done2 ? without(list, task.id) : upsert(list, task)); ++ setArchive((list) => done2 ? [task, ...without(list, task.id)] : without(list, task.id)); ++ }; ++ const done = scope?.kind === "group" ? archive : archive.filter((task) => !scope?.botId || task.ownerBotId === scope.botId); ++ return { own, tasks, archive: done, replace }; } -function useRowEntrances(replacing) { - const check = (0, import_react7.useRef)(replacing); @@ -11067,41 +11109,32 @@ diff --git a/client.js b/client.js - item.dataset.btEnter = reply && check.current() ? "after-collapse" : "new"; - item.addEventListener("animationend", settle); - } -+function useBoardTasks(actions, scope) { -+ const [own, setOwn] = (0, import_react17.useState)([]); -+ const [groupTasks, setGroupTasks] = (0, import_react17.useState)([]); -+ const key = scope?.kind === "group" ? `group:${scope.roomId}` : "own"; -+ (0, import_react17.useEffect)(() => { -+ let stopped = false; -+ const load = () => { -+ const job = scope?.kind === "group" ? Promise.resolve(actions?.tasks?.({ roomId: scope.roomId })) : Promise.resolve(actions?.tasks?.()); -+ void job.then((list) => { -+ if (stopped || !Array.isArray(list)) return; -+ if (scope?.kind === "group") setGroupTasks(list); -+ else setOwn(list); -+ }).catch(() => void 0); - }; +- }; - const observer = new MutationObserver(() => { - if (!frame) frame = requestAnimationFrame(scan); - }); - scan(); -+ load(); -+ const timer = setInterval(load, POLL_MS4); - return () => { +- return () => { - cancelAnimationFrame(frame); - observer.disconnect(); -+ stopped = true; -+ clearInterval(timer); - }; +- }; - }, []); -+ }, [key]); -+ const tasks = scope?.kind === "group" ? groupTasks : own.filter((task) => !scope?.botId || task.ownerBotId === scope.botId); -+ const replace = (task) => { -+ const write = (list) => list.some((item) => item.id === task.id) ? list.map((item) => item.id === task.id ? task : item) : [...list, task]; -+ if (scope?.kind === "group") setGroupTasks(write); -+ else setOwn(write); -+ }; -+ return { own, tasks, replace }; ++function RailButton({ row, selected, meta, onSelect }) { ++ return (0, import_react17.createElement)( ++ "button", ++ { ++ type: "button", ++ className: "bt-board-rail-item", ++ "data-kind": row.kind, ++ "data-id": row.id, ++ "data-selected": selected || void 0, ++ "aria-current": selected ? "true" : void 0, ++ style: row.depth ? { paddingLeft: `${10 + row.depth * 14}px` } : void 0, ++ onClick: () => onSelect(row.kind === "group" ? { kind: "group", roomId: row.id } : { kind: "bot", botId: row.id }) ++ }, ++ (0, import_react17.createElement)("span", { className: "bt-board-rail-name" }, row.name), ++ meta ? (0, import_react17.createElement)("span", { className: "bt-board-rail-meta" }, meta) : null ++ ); } -function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) { - const roster = useRoster((value) => value); @@ -11177,23 +11210,6 @@ diff --git a/client.js b/client.js - if (!host || phase === null || !shown) return null; - const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null; - return (0, import_react_dom2.createPortal)((0, import_react7.createElement)( -+function RailButton({ row, selected, meta, onSelect }) { -+ return (0, import_react17.createElement)( -+ "button", -+ { -+ type: "button", -+ className: "bt-board-rail-item", -+ "data-kind": row.kind, -+ "data-id": row.id, -+ "data-selected": selected || void 0, -+ "aria-current": selected ? "true" : void 0, -+ style: row.depth ? { paddingLeft: `${10 + row.depth * 14}px` } : void 0, -+ onClick: () => onSelect(row.kind === "group" ? { kind: "group", roomId: row.id } : { kind: "bot", botId: row.id }) -+ }, -+ (0, import_react17.createElement)("span", { className: "bt-board-rail-name" }, row.name), -+ meta ? (0, import_react17.createElement)("span", { className: "bt-board-rail-meta" }, meta) : null -+ ); -+} +function BoardCard2({ task, roster, movable, onMove, onDrag, partOf: partOf2 }) { + const column = columnOf(task); + const priority = priorityOf(task); @@ -11404,11 +11420,12 @@ diff --git a/client.js b/client.js + const [dragging, setDragging] = (0, import_react17.useState)(null); + const [error, setError] = (0, import_react17.useState)(""); + const [draft, setDraft] = (0, import_react17.useState)(""); -+ const { own, tasks, replace } = useBoardTasks(actions, scope); ++ const { own, tasks, archive, replace } = useBoardTasks(actions, scope); ++ const boardTasks = boardTasksFor(tasks, archive); + const close = (0, import_react17.useCallback)(() => actions.closeBoard(), []); + useEscape(close); + const counts2 = {}; -+ for (const task of own) { ++ for (const task of [...own, ...archive]) { + const id = task.ownerBotId; + const bucket = counts2[id] ?? (counts2[id] = { done: 0, total: 0 }); + if (task.status === "cancelled" || task.status === "failed") continue; @@ -11460,7 +11477,7 @@ diff --git a/client.js b/client.js + ), + view === "delivery" ? (0, import_react17.createElement)(DeliveryView, { actions }) : (0, import_react17.createElement)(BoardView, { + roster, -+ tasks, ++ tasks: boardTasks, + rail, + scope, + counts: counts2, diff --git a/patches/ds-bot/files/src/client/inbox.js b/patches/ds-bot/files/src/client/inbox.js index d9299800..c9936306 100644 --- a/patches/ds-bot/files/src/client/inbox.js +++ b/patches/ds-bot/files/src/client/inbox.js @@ -72,7 +72,10 @@ export function useTasks(actions) { taskListeners.add(setTasks) let stopped = false let timer - const load = () => Promise.resolve(actions?.tasks?.()) + // #1252: the default read is active work only, but this inbox classifies finished and failed + // cards too (Done, and Needs you after a failure). It asks for the archive so those rows reach + // it; the board's own bounded read is a separate call. + const load = () => Promise.resolve(actions?.tasks?.({ archive: true })) .then((list) => { if (!stopped && list !== undefined) publishTasks(list) }) // A host that cannot list tasks (a reading host, an older core) shows none. .catch(() => {}) diff --git a/patches/ds-bot/files/src/client/kanban.js b/patches/ds-bot/files/src/client/kanban.js index 27366228..aac525cf 100644 --- a/patches/ds-bot/files/src/client/kanban.js +++ b/patches/ds-bot/files/src/client/kanban.js @@ -24,6 +24,17 @@ export function shortId(id) { return `#${hex.slice(-6)}` } +// #1252: the board lists active work; the Done column reads a bounded slice of the archive, +// so a room with hundreds of finished cards still renders a column, not the whole history. +export const ARCHIVE_SHOWN = 20 + +export function boardTasksFor(tasks, archive, limit = ARCHIVE_SHOWN) { + // Newest first before the cut: a card finished a minute ago has an old `createdAt`, so ordering + // by it would drop exactly the card a member just finished (#1252). + const newest = [...(archive ?? [])].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || (b.createdAt ?? 0) - (a.createdAt ?? 0)) + return [...(tasks ?? []), ...newest.slice(0, limit)] +} + export function boardColumns(tasks) { const columns = { 'not-started': [], 'in-progress': [], done: [] } for (const task of tasks ?? []) { @@ -150,15 +161,26 @@ function lanesOf(tasks, roster) { return order.map(id => ({ id, name: botName(roster, map.get(id)[0]) || id, tasks: map.get(id) })) } +const upsert = (list, task) => (list.some(item => item.id === task.id) ? list.map(item => item.id === task.id ? task : item) : [...list, task]) +const without = (list, id) => list.filter(item => item.id !== id) + export function useBoardTasks(actions, scope) { const [own, setOwn] = useState([]) const [groupTasks, setGroupTasks] = useState([]) + const [archive, setArchive] = useState([]) const key = scope?.kind === 'group' ? `group:${scope.roomId}` : 'own' useEffect(() => { let stopped = false const load = () => { + const query = scope?.kind === 'group' ? { roomId: scope.roomId } : {} + // #1252: the host cuts the archive page before the board can see it, so a Bot board has to + // name the Bot it asks for — an unfiltered page spends its whole budget on other Bots' newer + // cards and this Bot's Done column empties. The default read stays unfiltered on purpose: it + // is active work, a superset the board filters below without losing a card. + const archiveQuery = scope?.kind === 'bot' ? { botId: scope.botId } : query + // #1252: the default list is active work, so the Done column asks for the archive. const job = scope?.kind === 'group' - ? Promise.resolve(actions?.tasks?.({ roomId: scope.roomId })) + ? Promise.resolve(actions?.tasks?.(query)) : Promise.resolve(actions?.tasks?.()) // A reading host or an older core has no board; keep the list already shown. void job.then((list) => { @@ -166,18 +188,25 @@ export function useBoardTasks(actions, scope) { if (scope?.kind === 'group') setGroupTasks(list) else setOwn(list) }).catch(() => undefined) + void Promise.resolve(actions?.tasks?.({ ...archiveQuery, archive: true, limit: ARCHIVE_SHOWN })).then((list) => { + if (stopped || !Array.isArray(list)) return + setArchive(list.filter(task => columnOf(task) === 'done')) + }).catch(() => undefined) } load() const timer = setInterval(load, POLL_MS) return () => { stopped = true; clearInterval(timer) } }, [key]) const tasks = scope?.kind === 'group' ? groupTasks : own.filter(task => !scope?.botId || task.ownerBotId === scope.botId) + // A finished card moves off the active list into the archive immediately, before the next poll. const replace = (task) => { - const write = (list) => (list.some(item => item.id === task.id) ? list.map(item => item.id === task.id ? task : item) : [...list, task]) - if (scope?.kind === 'group') setGroupTasks(write) - else setOwn(write) + const done = columnOf(task) === 'done' + if (scope?.kind === 'group') setGroupTasks(list => done ? without(list, task.id) : upsert(list, task)) + else setOwn(list => done ? without(list, task.id) : upsert(list, task)) + setArchive(list => done ? [task, ...without(list, task.id)] : without(list, task.id)) } - return { own, tasks, replace } + const done = scope?.kind === 'group' ? archive : archive.filter(task => !scope?.botId || task.ownerBotId === scope.botId) + return { own, tasks, archive: done, replace } } function RailButton({ row, selected, meta, onSelect }) { @@ -338,11 +367,12 @@ export function KanbanDialog({ roster, actions, leaving = false }) { const [dragging, setDragging] = useState(null) const [error, setError] = useState('') const [draft, setDraft] = useState('') - const { own, tasks, replace } = useBoardTasks(actions, scope) + const { own, tasks, archive, replace } = useBoardTasks(actions, scope) + const boardTasks = boardTasksFor(tasks, archive) const close = useCallback(() => actions.closeBoard(), []) useEscape(close) const counts = {} - for (const task of own) { + for (const task of [...own, ...archive]) { const id = task.ownerBotId const bucket = counts[id] ?? (counts[id] = { done: 0, total: 0 }) if (task.status === 'cancelled' || task.status === 'failed') continue @@ -385,7 +415,7 @@ export function KanbanDialog({ roster, actions, leaving = false }) { view === 'delivery' ? h(DeliveryView, { actions }) : h(BoardView, { - roster, tasks, rail, scope, counts, dragging, error, draft, + roster, tasks: boardTasks, rail, scope, counts, dragging, error, draft, onDraft: setDraft, onSelect: setScope, onMove: move, onDrag: setDragging, onCreate: create, }))) } diff --git a/patches/ds-bot/files/src/host/tasks.js b/patches/ds-bot/files/src/host/tasks.js index 3ec3baa3..8e14fee9 100644 --- a/patches/ds-bot/files/src/host/tasks.js +++ b/patches/ds-bot/files/src/host/tasks.js @@ -57,6 +57,20 @@ const refuse = (code, message) => Object.assign(new Error(`${code}: ${message}`) const WALK = ['open', 'claimed', 'working', 'result-ready', 'accepted'] const TERMINAL_MATCH = new Set(['accepted', 'cancelled', 'failed']) +const ARCHIVE_LIMIT_MAX = 200 +const readArchiveLimit = (value) => { + if (value === undefined || value === null) return undefined + // A number, not something that coerces into one: `true` is not "the first card", and "20" is a + // wrong request a reader should see refused rather than quietly honoured. + if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) { + throw invalid('A task list limit must be a whole number of at least 1') + } + return Math.min(value, ARCHIVE_LIMIT_MAX) +} +// Newest work first for the bounded archive: a card finished a minute ago has an old `createdAt` +// and a fresh `updatedAt`, and it is the one a member is looking for (#1252). +const newestFirst = (a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || (b.createdAt ?? 0) - (a.createdAt ?? 0) + const readPriority = (value) => { if (value === undefined || value === null || value === '') return undefined const priority = String(value) @@ -292,9 +306,30 @@ export function install(rt) { transition(task, next, actor, extra) if (next === 'result-ready') task.result = { kind: 'summary', summary: (task.note || 'Completed').slice(0, 2000) } } + if (target === 'accepted') settleOverall(task) return true } + // #1252: a group whose last child finishes otherwise leaves its overall card working forever, + // so the room board keeps a card nobody can close. When every child has ended, the overall card + // completes: `accepted` if each child was accepted or cancelled, `failed` if any child failed — + // a failed part is not hidden behind a green overall card. Both go through `transition` / + // `applyColumn`, the same writes a person's own move makes, so history and actors stay honest. + function settleOverall(child) { + const parent = child?.parentId ? store()[child.parentId] : undefined + if (parent === undefined || parent.kind !== 'group' || !openTask(parent)) return false + if (!['open', 'claimed', 'working'].includes(parent.status)) return false + const children = Object.values(store()).filter(task => task.parentId === parent.id) + if (children.length === 0 || children.some(task => !TERMINAL_MATCH.has(task.status))) return false + if (children.some(task => task.status === 'failed')) { + transition(parent, 'failed', null, { why: 'a part of this group failed' }) + return true + } + const moved = applyColumn(parent, 'done', null, 'last child completed') + if (moved) parent.progress = 100 + return moved + } + function applyFields(task, body) { const title = body.title !== undefined ? readTitle(body.title) : undefined if (title !== undefined) { @@ -716,6 +751,7 @@ export function install(rt) { if (waitingForInput(task) && task.inputApprovalIds?.includes(approval.id)) { if (decision !== 'approve') { transition(task, 'cancelled', { memberId: approval.ownerId }, { approvalId: approval.id, by: 'owner decision' }) + settleOverall(task) continue } task.inputApprovalIds = task.inputApprovalIds.filter(id => id !== approval.id) @@ -727,6 +763,8 @@ export function install(rt) { if (task.status !== 'blocked' || task.approvalId !== approval.id) continue delete task.approvalId transition(task, decision === 'approve' ? 'working' : 'cancelled', { memberId: approval.ownerId }, { by: 'owner decision' }) + // A rejection is one of the doors a child ends through, so the overall card settles here too. + settleOverall(task) } return save() } @@ -735,6 +773,8 @@ export function install(rt) { for (const task of Object.values(store())) { if (!ACTIVE.has(task.status) || (task.ownerBotId !== botId && task.workBotId !== botId)) continue transition(task, 'failed', null, { why: 'the responsible Bot was deleted' }) + // Deletion is another door a child ends through; its overall card must not stay working. + settleOverall(task) } return save() } @@ -764,7 +804,20 @@ export function install(rt) { // the room query, so a plain list never becomes a group-admin view. rows = all.filter(task => mayAct(task, actor) || ancestorOwned(actor, task.ownerBotId)) } - return rows.sort((a, b) => a.createdAt - b.createdAt).map(view) + // #1252: the board lists active work; the archive is the same read with a flag, so a + // group that has finished hundreds of cards does not ship them on every poll. A caller + // that asks for a bounded archive gets the newest rows; without `limit` every row stays + // readable, because a lookup by id must still find a card outside the newest page. + const archived = body.archive === true + const limit = archived ? readArchiveLimit(body.limit) : undefined + if (limit !== undefined) { + // The page exists for the Done column, so its budget is spent on finished rows only: a + // busy room's freshly updated working cards must not push the finished ones off it. + const settled = rows.filter(task => !openTask(task)).sort(newestFirst).slice(0, limit) + return settled.map(view) + } + const shown = archived ? rows : rows.filter(openTask) + return shown.sort((a, b) => a.createdAt - b.createdAt).map(view) } case 'task-create': { const botId = String(body.botId ?? '') @@ -866,6 +919,7 @@ export function install(rt) { if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`) requireRequester(task, actor) transition(task, 'accepted', actor) + settleOverall(task) requestWriteBack(task, 'accepted', undefined, actor) await save() return view(task) @@ -888,6 +942,7 @@ export function install(rt) { if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`) requireRequester(task, actor) transition(task, 'cancelled', actor) + settleOverall(task) await save() return view(task) } diff --git a/patches/ds-bot/files/test/bot-tasks.test.js b/patches/ds-bot/files/test/bot-tasks.test.js index ff49a66c..818af613 100644 --- a/patches/ds-bot/files/test/bot-tasks.test.js +++ b/patches/ds-bot/files/test/bot-tasks.test.js @@ -138,7 +138,7 @@ test('a Bot records conversation tasks and the owner moves them across the board await first.stop({ keepHome: true }) const restored = await start(t, { home: first.home }) app = restored - const saved = (await list(restored.call, alice.sessionToken, { botId: 's' })).find(task => task.id === open.id) + const saved = (await list(restored.call, alice.sessionToken, { botId: 's', archive: true })).find(task => task.id === open.id) assert.equal(saved.status, 'accepted') assert.equal(saved.progress, 100) assert.equal(saved.priority, 'high') @@ -434,3 +434,236 @@ test('the board asks the owning Bot for the comment a decision needs, and takes assert.equal(late.writeBack.wakes, 2, "the count stays with the record") assert.equal(late.status, "accepted") }) + +// #1252: a finished card leaves the active list, or a long-lived group's board grows without +// bound; the archive is the same read with a flag, not a second endpoint. +test('#1252: a room board lists active work only, and the archive is explicit', async (t) => { + const app = await start(t) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + await app.track('a', { tasks: [ + { title: 'Keep this moving', group: 'War', column: 'in-progress', progress: 30 }, + { title: 'This one is done', group: 'War', column: 'in-progress', progress: 10 }, + ] }) + const before = await list(app.call, alice.sessionToken, { roomId: 'war' }) + const finished = before.find(task => task.title === 'This one is done') + const done = await app.call('task-update', { id: finished.id, column: 'done' }, alice.sessionToken) + assert.equal(done.ok, true, JSON.stringify(done)) + + const active = await list(app.call, alice.sessionToken, { roomId: 'war' }) + assert.equal(active.some(task => task.id === finished.id), false, 'a done card leaves the room board by default') + assert.equal(active.some(task => task.title === 'Keep this moving'), true, 'unfinished work stays') + + const all = await list(app.call, alice.sessionToken, { roomId: 'war', archive: true }) + assert.equal(all.some(task => task.id === finished.id), true, 'the archive still carries the finished card') + assert.equal(all.length, active.length + 1) +}) + +// #1252: an overall card with no open child left has to be able to complete, or a group keeps a +// card working that nobody can close. +test('#1252: the last child completing completes the overall card instead of leaving it working', async (t) => { + const app = await start(t) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + await app.track('a', { tasks: [ + { title: 'Ship the note', overall: true, group: 'War' }, + { title: 'Write it', parent: 'Ship the note', assignee: 'Writer', group: 'War' }, + { title: 'Review it', parent: 'Ship the note', assignee: 'ChiefA', group: 'War' }, + ] }) + const room = await list(app.call, alice.sessionToken, { roomId: 'war' }) + const overall = room.find(task => task.kind === 'group') + const write = room.find(task => task.title === 'Write it') + const review = room.find(task => task.title === 'Review it') + + const firstMove = await app.call('task-update', { id: write.id, column: 'done' }, alice.sessionToken) + assert.equal(firstMove.ok, true, JSON.stringify(firstMove)) + const afterOne = (await list(app.call, alice.sessionToken, { roomId: 'war' })).find(task => task.id === overall.id) + assert.equal(afterOne.status, 'open', 'one child still open keeps the overall card where it was') + + const lastMove = await app.call('task-update', { id: review.id, column: 'done' }, alice.sessionToken) + assert.equal(lastMove.ok, true, JSON.stringify(lastMove)) + const parent = (await list(app.call, alice.sessionToken, { roomId: 'war', archive: true })).find(task => task.id === overall.id) + assert.equal(parent.status, 'accepted', 'the overall card completes with its last child') + assert.equal(columnOf(parent), 'done') + assert.equal(parent.progress, 100) +}) + +// #1252: a failed part is not hidden behind an accepted overall card. The card completes `accepted` +// only when every child ended accepted or cancelled, and `failed` when any child ended failed. +test('#1252: an overall card fails when one of its children failed', async (t) => { + const at = Date.now() + const state = team() + state.tasks = { + 'task-overall': { + id: 'task-overall', status: 'working', kind: 'group', createdAt: at - 60_000, updatedAt: at - 5000, + requesterId: 'owner-1', ownerBotId: 'a', title: 'Ship the note', source: 'conversation', + origin: { roomId: 'war' }, actors: [{ status: 'working', at: at - 5000, memberId: 'owner-1' }], + }, + 'task-child-ok': { + id: 'task-child-ok', status: 'working', createdAt: at - 50_000, updatedAt: at - 4000, + requesterId: 'owner-1', ownerBotId: 's', title: 'Write it', parentId: 'task-overall', + source: 'conversation', origin: { roomId: 'war' }, actors: [{ status: 'working', at: at - 4000, memberId: 'owner-1' }], + }, + 'task-child-failed': { + id: 'task-child-failed', status: 'failed', createdAt: at - 40_000, updatedAt: at - 3000, + requesterId: 'owner-1', ownerBotId: 'a', title: 'Review it', parentId: 'task-overall', + source: 'conversation', origin: { roomId: 'war' }, actors: [{ status: 'failed', at: at - 3000, memberId: 'owner-1' }], + }, + } + const app = await start(t, { state }) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + const moved = await app.call('task-update', { id: 'task-child-ok', column: 'done' }, alice.sessionToken) + assert.equal(moved.ok, true, JSON.stringify(moved)) + const parent = (await list(app.call, alice.sessionToken, { roomId: 'war', archive: true })).find(task => task.id === 'task-overall') + assert.equal(parent.status, 'failed', 'a failed child makes the overall card fail, not pass') +}) + +// #1252: the wire must not carry the whole archive. A requested `limit` bounds the finished rows, +// while a read without one still returns every row, so lookups by id keep working. +test('#1252: an archive read can ask for the newest rows, and an unbounded read still sees them all', async (t) => { + const at = Date.now() + const state = team() + state.tasks = {} + for (let i = 0; i < 200; i++) { + state.tasks[`task-settled-${i}`] = { + id: `task-settled-${i}`, status: 'accepted', createdAt: at - 200_000 + i, updatedAt: at - 100_000 + i, + requesterId: 'owner-1', ownerBotId: 'a', title: `Settled ${i}`, source: 'conversation', + origin: { roomId: 'war' }, actors: [{ status: 'accepted', at: at - 100_000 + i, memberId: 'owner-1' }], + } + } + const app = await start(t, { state }) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + + const bounded = await list(app.call, alice.sessionToken, { roomId: 'war', archive: true, limit: 20 }) + assert.equal(bounded.length, 20, 'the requested limit bounds the payload') + assert.equal(bounded.some(task => task.id === 'task-settled-199'), true, 'the newest finished card is in the slice') + assert.equal(bounded.some(task => task.id === 'task-settled-0'), false, 'the oldest finished card is not') + + const unbounded = await list(app.call, alice.sessionToken, { roomId: 'war', archive: true }) + assert.equal(unbounded.length, 200, 'a read with no limit still returns every settled row') + assert.equal(unbounded.some(task => task.id === 'task-settled-0'), true, 'including the oldest, so an id lookup still finds it') +}) + +// #1252: a bounded archive read is finished rows only. With the budget spent on every row, a busy +// room returns twenty working cards and an empty Done column — the exact row the client asked for. +test('#1252: a bounded archive read carries the newest finished rows and no active ones', async (t) => { + const at = Date.now() + const state = team() + state.tasks = {} + const row = (id, status, updatedAt) => ({ + id, status, createdAt: at - 500_000, updatedAt, + requesterId: 'owner-1', ownerBotId: 'a', title: id, source: 'conversation', + origin: { roomId: 'war' }, actors: [{ status, at: updatedAt, memberId: 'owner-1' }], + }) + for (let i = 0; i < 25; i++) state.tasks[`task-active-${i}`] = row(`task-active-${i}`, 'working', at - i) + for (let i = 0; i < 5; i++) state.tasks[`task-settled-${i}`] = row(`task-settled-${i}`, 'accepted', at - 100 - i) + + const app = await start(t, { state }) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + + const bounded = await list(app.call, alice.sessionToken, { roomId: 'war', archive: true, limit: 20 }) + assert.equal(bounded.every(task => ['accepted', 'cancelled', 'failed'].includes(task.status)), true, 'the bounded archive carries finished rows only') + assert.equal(bounded.some(task => task.id === 'task-settled-0'), true, 'the newest finished card is present') + assert.equal(bounded.some(task => task.id.startsWith('task-active-')), false, 'no active row is in the bounded payload') + assert.equal(bounded.length, 5, 'all five finished cards, since five is under the limit') + + // `limit` is a number or nothing: a coerced `true` or "20" is a wrong request, not a page. + for (const limit of [true, '20', 0, -3, 1.5, 'abc', {}]) { + const refused = await app.call('tasks', { roomId: 'war', archive: true, limit }, alice.sessionToken) + assert.equal(refused.ok, false, `limit ${JSON.stringify(limit)} is refused`) + assert.equal(refused.error.code, 'bot/invalid', `limit ${JSON.stringify(limit)} is bot/invalid`) + } +}) + +// #1252: a child does not only end through a column move. Deleting its Bot fails it, and an owner +// rejecting the send it waits on cancels it; both leave the overall card working unless the same +// settle runs on those doors too. Read the parent back through the archive — that is the card a +// person sees, not the write's return value. +test('#1252: deleting a child\'s Bot fails the overall card, read back from the archive', async (t) => { + const app = await start(t) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + await app.track('a', { tasks: [ + { title: 'Ship the note', overall: true, group: 'War' }, + { title: 'Write it', parent: 'Ship the note', assignee: 'Writer', group: 'War' }, + ] }) + const room = await list(app.call, alice.sessionToken, { roomId: 'war' }) + const overall = room.find(task => task.kind === 'group') + const child = room.find(task => task.title === 'Write it') + const started = await app.call('task-update', { id: child.id, column: 'in-progress' }, alice.sessionToken) + assert.equal(started.ok, true, JSON.stringify(started)) + const removed = await app.call('delete-bot', { id: 's' }, alice.sessionToken) + assert.equal(removed.ok, true, JSON.stringify(removed)) + + const after = await list(app.call, alice.sessionToken, { archive: true }) + const failedChild = after.find(task => task.id === child.id) + assert.equal(failedChild?.status, 'failed', 'the deleted Bot\'s child fails') + const parent = after.find(task => task.id === overall.id) + assert.equal(parent?.status, 'failed', 'a failed child closes the overall card as failed') + assert.equal(columnOf(parent), 'done') +}) + +test('#1252: a rejected approval cancels the child and completes the overall card, read back', async (t) => { + const app = await start(t) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + await app.track('a', { tasks: [ + { title: 'Ship the note', overall: true, group: 'War' }, + { title: 'Write it', parent: 'Ship the note', assignee: 'Writer', group: 'War' }, + ] }) + const room = await list(app.call, alice.sessionToken, { roomId: 'war' }) + const overall = room.find(task => task.kind === 'group') + const child = room.find(task => task.title === 'Write it') + const started = await app.call('task-update', { id: child.id, column: 'in-progress' }, alice.sessionToken) + assert.equal(started.ok, true, JSON.stringify(started)) + + app.ctx.flybot.bot.send.use({ name: 'gate', handle: msg => msg.meta?.hold ? { action: 'hold', reason: 'held by test' } : undefined }) + const held = await app.ctx.flybot.bot.send({ from: 's', to: 'a', text: 'send this out', meta: { hold: true } }) + assert.equal(held.action, 'hold', JSON.stringify(held)) + const blocked = (await list(app.call, alice.sessionToken, { roomId: 'war' })).find(task => task.id === child.id) + assert.equal(blocked.status, 'blocked', 'the held send blocks the child') + + const rejected = await app.call('flybot/decide', { id: held.approvalId, decision: 'reject' }, alice.sessionToken) + assert.equal(rejected.ok, true, JSON.stringify(rejected)) + const after = await list(app.call, alice.sessionToken, { roomId: 'war', archive: true }) + const cancelled = after.find(task => task.id === child.id) + assert.equal(cancelled.status, 'cancelled', 'the rejected send cancels the child') + const parent = after.find(task => task.id === overall.id) + assert.equal(parent.status, 'accepted', 'a cancelled child completes the overall card as accepted') + assert.equal(parent.progress, 100) + assert.equal(columnOf(parent), 'done') +}) + +// #1252: the Bot filter runs before the bounded page is cut. Twenty newer cards on another Bot +// must not push this Bot's only finished card off the page — that is the read a Bot board makes. +test('#1252: a Bot-scoped archive page is cut after the Bot filter', async (t) => { + const at = Date.now() + const state = team() + state.tasks = {} + const row = (id, ownerBotId, updatedAt) => ({ + id, status: 'accepted', createdAt: updatedAt, updatedAt, + requesterId: 'owner-1', ownerBotId, title: id, source: 'conversation', + origin: { roomId: 'war' }, actors: [{ status: 'accepted', at: updatedAt, memberId: 'owner-1' }], + }) + for (let i = 0; i < 20; i++) state.tasks[`task-writer-${i}`] = row(`task-writer-${i}`, 's', at - i) + state.tasks['task-chief-old'] = row('task-chief-old', 'a', at - 10_000) + const app = await start(t, { state }) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + + const mine = await list(app.call, alice.sessionToken, { botId: 'a', archive: true, limit: 20 }) + assert.equal(mine.length, 1, 'the page is cut after the Bot filter, so the older card survives') + assert.equal(mine[0].id, 'task-chief-old') + + const writers = await list(app.call, alice.sessionToken, { botId: 's', archive: true, limit: 20 }) + assert.equal(writers.length, 20) + assert.equal(writers.every(task => task.ownerBotId === 's'), true) + + // The unfiltered page is the defect's precondition: its whole budget goes to the newer Bot. + const scope = await list(app.call, alice.sessionToken, { archive: true, limit: 20 }) + assert.equal(scope.length, 20) + assert.equal(scope.some(task => task.id === 'task-chief-old'), false, 'the unfiltered page spends the budget on the newer Bot') +}) diff --git a/patches/ds-bot/files/test/inbox.test.js b/patches/ds-bot/files/test/inbox.test.js index fb3b72a8..a35b1bbd 100644 --- a/patches/ds-bot/files/test/inbox.test.js +++ b/patches/ds-bot/files/test/inbox.test.js @@ -14,14 +14,17 @@ const bundle = await build({ external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], }) const module = { exports: {} } +// A hook's effects are collected, never run: a test that drives a load calls the one it needs. +// Timers are stubs so a driven load does not arm a background poll. +const effects = [] runInNewContext(bundle.outputFiles[0].text, { - module, exports: module.exports, setTimeout, clearTimeout, setInterval, clearInterval, Date, Promise, + module, exports: module.exports, setTimeout: () => 0, clearTimeout: () => {}, setInterval: () => 0, clearInterval: () => {}, Date, Promise, require: (name) => { const platform = { react: { createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], - useEffect: () => {}, useLayoutEffect: () => {}, useRef: current => ({ current }), useCallback: fn => fn, useMemo: fn => fn(), Fragment: 'fragment', + useEffect: fn => { effects.push(fn) }, useLayoutEffect: () => {}, useRef: current => ({ current }), useCallback: fn => fn, useMemo: fn => fn(), Fragment: 'fragment', }, 'react-dom': {}, '@deepseek-ai/dsh-client-ui-primitives': {}, @@ -30,7 +33,7 @@ runInNewContext(bundle.outputFiles[0].text, { return platform[name] }, }) -const { inboxModel, needsCount, InboxCard, InboxDialog, InboxRow, GROUPS, STATUSES, KEEP_MS, INBOX_CSS } = module.exports +const { inboxModel, needsCount, InboxCard, InboxDialog, InboxRow, GROUPS, STATUSES, KEEP_MS, INBOX_CSS, useTasks } = module.exports const plain = value => JSON.parse(JSON.stringify(value)) // Draws the inbox's own components; Bot marks and icons stay as elements. @@ -728,3 +731,47 @@ test('a Needs you card shows the whole ask, and the phone clamp cannot cut it to assert.ok(buttons(tree).some(node => textOf(node) === 'Submit')) assert.ok(buttons(tree).some(node => textOf(node) === 'Open')) }) + +// #1252: the Inbox is a second consumer of `tasks`. When the default read became active-only it +// silently stopped receiving finished and failed cards, so the inbox's own load has to ask for the +// settled rows — and the response has to reach the badges and the groups, not just the wire. +test('#1252: the Inbox asks for the settled rows, and they reach Done and Needs you', async () => { + const at = Date.now() - 1000 + const accepted = task('i1', 'accepted', { createdAt: at - 60_000, updatedAt: at }) + const failed = task('i2', 'failed', { createdAt: at - 60_000, updatedAt: at, actors: [{ status: 'failed', why: 'the responsible Bot was deleted' }] }) + // The host answers like the real one: the active-only default carries neither settled row. + const calls = [] + const actions = { + openInbox: () => {}, + tasks: (...args) => { + calls.push(args) + return Promise.resolve(args[0]?.archive === true ? [accepted, failed] : []) + }, + } + + effects.length = 0 + useTasks(actions) + assert.equal(effects.length, 1, 'the Inbox loads tasks through one effect') + effects.pop()() + await new Promise(resolve => setImmediate(resolve)) + + const base = needsCount(roster, work, []) + const counted = InboxRow({ wide: true, useRoster: pick => pick(roster), useWork: pick => pick(work), useUi: pick => pick({}), actions }) + const badge = textOf(byClass(counted, 'bt-inbox-count')[0]) + const cards = cardsIn(dialog(actions)) + const drawn = node => (node ? byClass(expand(node), 'bt-inbox-card')[0]?.props['data-status'] : undefined) + const done = drawn(cards.find(node => node.props.card.id === 'task:i1')) + const needs = drawn(cards.find(node => node.props.card.id === 'task:i2')) + assert.equal(calls[0].length, 1, 'the Inbox read sends a body asking for the settled rows') + assert.deepStrictEqual({ ...calls[0][0] }, { archive: true }, 'the Inbox read asks for the settled rows') + assert.equal(badge, String(base + 1), 'the row badge counts the failed task') + assert.equal(done, 'done', 'an accepted task from that response is a Done card') + assert.equal(needs, 'failed', 'a failed task from that response is a Needs you card') + + // Put the shared list back for any later read in this file. + effects.length = 0 + actions.tasks = () => Promise.resolve([]) + useTasks(actions) + effects.pop()() + await new Promise(resolve => setImmediate(resolve)) +}) diff --git a/patches/ds-bot/files/test/kanban.test.js b/patches/ds-bot/files/test/kanban.test.js index 5a459279..a64ff061 100644 --- a/patches/ds-bot/files/test/kanban.test.js +++ b/patches/ds-bot/files/test/kanban.test.js @@ -15,21 +15,23 @@ const bundle = await build({ const module = { exports: {} } // Preset state for the next useState calls; KanbanDialog's first hook is the view. const states = [] +// A hook's effects are collected, never run: a test that drives a load calls the one it needs. +const effects = [] runInNewContext(bundle.outputFiles[0].text, { - module, exports: module.exports, setTimeout, clearTimeout, setInterval, clearInterval, Date, Promise, + module, exports: module.exports, setTimeout, clearTimeout, setInterval: () => 0, clearInterval: () => {}, Date, Promise, require: (name) => { const platform = { react: { createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), useState: initial => [states.length > 0 ? states.shift() : (typeof initial === 'function' ? initial() : initial), () => {}], - useEffect: () => {}, useCallback: fn => fn, useRef: current => ({ current }), + useEffect: fn => { effects.push(fn) }, useCallback: fn => fn, useRef: current => ({ current }), }, } if (!(name in platform)) throw new Error(`no platform module ${name}`) return platform[name] }, }) -const { BoardView, KanbanDialog, boardColumns, boardRail, canMoveTask, columnTree, shortId } = module.exports +const { ARCHIVE_SHOWN, BoardView, KanbanDialog, boardColumns, boardTasksFor, boardRail, canMoveTask, columnTree, shortId, useBoardTasks } = module.exports const expand = (node) => { if (Array.isArray(node)) return node.flatMap(expand) @@ -213,3 +215,67 @@ test('a delivery read with no token leaves the task cards and their tab in place assert.equal(byClass(tasksView, 'bt-board-card').length, 1) assert.deepEqual(all(tasksView).filter(node => node.props?.role === 'tab').map(node => node.props['data-view']), ['tasks', 'delivery']) }) + +// #1252: the Done column is archive, not the board, and the slice has to be the *newest* finished +// cards — a card a member just finished has an old `createdAt`, so ordering by it would hide exactly +// the card they are looking for. +test('#1252: a group board renders the newest finished cards, not the oldest', () => { + const active = [{ id: 'task-live', ownerBotId: 'a', title: 'Live', status: 'working', updatedAt: 1000 }] + const archive = Array.from({ length: 37 }, (_, i) => ({ + id: `task-done-${i}`, ownerBotId: 'a', title: `Done ${i}`, status: 'accepted', progress: 100, createdAt: i, updatedAt: i, + })) + const merged = boardTasksFor(active, archive) + assert.equal(merged.length, 1 + ARCHIVE_SHOWN) + const columns = boardColumns(merged) + assert.equal(columns['in-progress'].length, 1) + assert.equal(columns.done.length, 20, 'the column is a literal twenty cards') + assert.equal(columns.done.length, ARCHIVE_SHOWN) + assert.equal(columns.done[0].id, 'task-done-36', 'the column starts at the newest finished card') + assert.equal(columns.done.some(card => card.id === 'task-done-0'), false, 'the oldest finished card is outside the slice') + + // The card just finished: old `createdAt`, fresh `updatedAt`. It must be first, not cut off. + const justFinished = { id: 'task-just-finished', ownerBotId: 'a', title: 'Just finished', status: 'accepted', progress: 100, createdAt: 1, updatedAt: 99 } + const withFresh = boardColumns(boardTasksFor(active, [...archive, justFinished])) + assert.equal(withFresh.done[0].id, 'task-just-finished', 'the card finished just now leads the column') + assert.equal(withFresh.done.length, ARCHIVE_SHOWN) + + // The host's bounded page now carries finished rows only; the client still draws them. + const settledPage = Array.from({ length: 5 }, (_, i) => ({ + id: `task-settled-${i}`, ownerBotId: 'a', title: `Settled ${i}`, status: 'accepted', progress: 100, createdAt: i, updatedAt: 100 + i, + })) + const fromBoundedPage = boardColumns(boardTasksFor(active, settledPage)) + assert.equal(fromBoundedPage.done.length, 5, 'a finished-only page still fills the Done column') +}) + +// #1252: the Bot-scoped archive read has to name the Bot. The host cuts the newest finished rows +// before the board filters by Bot, so a request that only carries `{ archive, limit }` spends the +// whole page on other Bots' cards and this Bot's Done column empties. +test('#1252: a Bot board asks the host for that Bot\'s archive, not the whole scope', async () => { + const calls = [] + const otherBotCard = { id: 'task-a-old', ownerBotId: 'a', title: 'Mine', status: 'accepted', progress: 100, createdAt: 1, updatedAt: 2 } + const actions = { tasks: (...args) => { calls.push(args); return Promise.resolve([]) } } + effects.length = 0 + states.length = 0 + useBoardTasks(actions, { kind: 'bot', botId: 'a' }) + assert.equal(effects.length, 1, 'the board loads through one effect') + effects.pop()() + await new Promise(resolve => setImmediate(resolve)) + + assert.equal(calls[0].length, 0, 'the default read stays the unfiltered active list') + const archiveCall = calls.find(args => args[0]?.archive === true) + assert.deepStrictEqual({ ...archiveCall?.[0] }, { botId: 'a', archive: true, limit: ARCHIVE_SHOWN }, 'the archive read names the Bot') + + // A group board has no Bot to name; its read stays scoped to the room. + calls.length = 0 + effects.length = 0 + states.length = 0 + useBoardTasks(actions, { kind: 'group', roomId: 'war' }) + effects.pop()() + await new Promise(resolve => setImmediate(resolve)) + const groupArchive = calls.find(args => args[0]?.archive === true) + assert.deepStrictEqual({ ...groupArchive?.[0] }, { roomId: 'war', archive: true, limit: ARCHIVE_SHOWN }, 'the group archive read stays room-scoped') + + // The page the host returns for that query still fills the Done column after the Bot filter. + const done = boardColumns(boardTasksFor([], [otherBotCard].filter(task => task.ownerBotId === 'a'))).done + assert.equal(done.length, 1, 'the Bot\'s own finished card fills the Done column') +}) diff --git a/patches/ds-bot/files/test/tasks.test.js b/patches/ds-bot/files/test/tasks.test.js index 06ca2212..73cf4480 100644 --- a/patches/ds-bot/files/test/tasks.test.js +++ b/patches/ds-bot/files/test/tasks.test.js @@ -91,7 +91,7 @@ async function start(t, config = {}) { } const statusOf = async (call, token, id) => { - const list = await call('tasks', {}, token) + const list = await call('tasks', { archive: true }, token) assert.equal(list.ok, true, JSON.stringify(list)) return list.value.find(task => task.id === id) }