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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1252-board-archive.changed.md
Original file line number Diff line number Diff line change
@@ -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).
35 changes: 26 additions & 9 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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 ?? []) {
Expand Down Expand Up @@ -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);
Expand All @@ -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)(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion core/src/client/inbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {})
Expand Down
46 changes: 38 additions & 8 deletions core/src/client/kanban.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? []) {
Expand Down Expand Up @@ -150,34 +161,52 @@ 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) => {
if (stopped || !Array.isArray(list)) return
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 }) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
})))
}
Expand Down
57 changes: 56 additions & 1 deletion core/src/host/tasks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -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()
}
Expand All @@ -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()
}
Expand Down Expand Up @@ -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 ?? '')
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
Expand Down
Loading
Loading