diff --git a/changelog.d/1900-team-dossier-off-poll.changed.md b/changelog.d/1900-team-dossier-off-poll.changed.md new file mode 100644 index 000000000..8b4072983 --- /dev/null +++ b/changelog.d/1900-team-dossier-off-poll.changed.md @@ -0,0 +1 @@ +The Project board reads the team dossier (personas, memory heads, skills) once when the board opens instead of on every five-second refresh, so an open board no longer pulls about 16 KB of unchanged data per tick ([#1900](https://github.com/ThinkFlowLab/FlyBot/issues/1900)). A dossier read that fails now leaves the board drawn with an empty team area instead of falling back to the empty-project state. diff --git a/core/client.js b/core/client.js index aeef9850b..d4ef5f7bc 100644 --- a/core/client.js +++ b/core/client.js @@ -11260,17 +11260,19 @@ function ProjectMembersPanel({ project, rows, onClose } = {}) { ) ); } -async function readProjectBoard(read, wanted) { +async function readProjectBoard(read, wanted, cached) { const listing = await read("projects"); const projects = Array.isArray(listing?.projects) ? listing.projects : []; const project = wanted === "" ? projects[0] : projects.find((item) => item.id === wanted); if (project === void 0) return { project: null, rows: [], done: [], canManage: false, tasks: [], team: { rows: [] } }; const zone = typeof Intl === "undefined" || typeof Intl.DateTimeFormat !== "function" ? void 0 : Intl.DateTimeFormat().resolvedOptions().timeZone; + const seeded = cached !== null && typeof cached === "object" && cached.team !== void 0; + const teamAnswer = seeded ? Promise.resolve(cached.team) : Promise.resolve().then(() => read("project-team", { id: project.id })).catch(() => null); const [record, board, taskList, team] = await Promise.all([ read("project-read", { id: project.id }), read("project-board", { id: project.id, timeZone: zone }), read("tasks", {}), - read("project-team", { id: project.id }) + teamAnswer ]); const tasks = Array.isArray(taskList) ? taskList : taskList?.tasks ?? []; const queues = { waiting: board?.waiting, inflight: board?.inflight, blocked: board?.blocked, counts: board?.counts ?? null }; @@ -11328,7 +11330,12 @@ function ProjectBoardOverlay({ actions, call: request } = {}) { } const wanted = hash.slice("#project".length).replace(/^=/, ""); let gone = false; - const loadBoard = () => readProjectBoard(read, wanted); + let teamAnswer; + const loadBoard = async () => { + const next = await readProjectBoard(read, wanted, teamAnswer === void 0 ? void 0 : { team: teamAnswer }); + if (next.project !== null) teamAnswer = next.team; + return next; + }; const refresh = async (first) => { try { const next = await loadBoard(); diff --git a/core/src/client/project-board.js b/core/src/client/project-board.js index 4f8e0cbbc..a3ba1a31e 100644 --- a/core/src/client/project-board.js +++ b/core/src/client/project-board.js @@ -866,7 +866,11 @@ export function ProjectMembersPanel({ project, rows, onClose } = {}) { // #1864: one read for the whole board, exported so a test can prove the dossier comes from a single // `project-team` call. `read` is the channel seam (`call` in the app). -export async function readProjectBoard(read, wanted) { +// #1900: the dossier is the one answer in that read which does not change on a five-second clock, so +// the caller hands back the answer it already has (`cached.team`) and the read skips the call: the +// board reads it once when it opens and every poll after that draws that same answer. The other +// three channels are read every time — they are the reason a poll exists. +export async function readProjectBoard(read, wanted, cached) { const listing = await read('projects') const projects = Array.isArray(listing?.projects) ? listing.projects : [] const project = wanted === '' ? projects[0] : projects.find(item => item.id === wanted) @@ -874,11 +878,20 @@ export async function readProjectBoard(read, wanted) { const zone = typeof Intl === 'undefined' || typeof Intl.DateTimeFormat !== 'function' ? undefined : Intl.DateTimeFormat().resolvedOptions().timeZone + const seeded = cached !== null && typeof cached === 'object' && cached.team !== undefined + // #1900: a dossier that throws is the team area's loss, not the board's. The rejection used to + // reject the whole `Promise.all`, and the first paint turned that into the empty state — a broken + // persona file made a Project that exists read as "No Project yet", which is the one answer + // `:873` reserves for a Project that really is gone. Rows, `done`, tasks and queues still draw; + // only `team` gives up, as `{ rows: [] }` below. + const teamAnswer = seeded + ? Promise.resolve(cached.team) + : Promise.resolve().then(() => read('project-team', { id: project.id })).catch(() => null) const [record, board, taskList, team] = await Promise.all([ read('project-read', { id: project.id }), read('project-board', { id: project.id, timeZone: zone }), read('tasks', {}), - read('project-team', { id: project.id }), + teamAnswer, ]) const tasks = Array.isArray(taskList) ? taskList : (taskList?.tasks ?? []) // The groups are the same read as the rows, so the header cannot disagree with the cards. @@ -951,7 +964,17 @@ export function ProjectBoardOverlay({ actions, call: request } = {}) { let gone = false // #1864: the board, its record, its rows, the task texts and the team dossier are one read path, // and the dossier comes from the single `project-team` call that read makes. - const loadBoard = () => readProjectBoard(read, wanted) + // #1900: that one dossier answer is held for as long as this board is open. The effect re-runs + // per `#project` entry, so re-opening the board reads the dossier again and a persona or memory + // edit lands then; the five-second poll only draws the answer the open read already took. + let teamAnswer + const loadBoard = async () => { + const next = await readProjectBoard(read, wanted, teamAnswer === undefined ? undefined : { team: teamAnswer }) + // A board with no Project took no dossier read at all, so it seeds nothing: the first poll + // that does find the Project reads the dossier itself instead of caching the empty answer. + if (next.project !== null) teamAnswer = next.team + return next + } const refresh = async (first) => { try { const next = await loadBoard() diff --git a/core/test/project-board-panels.test.js b/core/test/project-board-panels.test.js index 6f4a2de11..cbe1fcdbb 100644 --- a/core/test/project-board-panels.test.js +++ b/core/test/project-board-panels.test.js @@ -537,6 +537,53 @@ test('the board reads the dossier from exactly one project-team call', async () assert.deepEqual(opened.map(entry => entry.botId), ['bot-chief']) }) +// #1900: the dossier only changes when someone edits a persona or a memory file, so it is read +// once, when the board opens, and the five-second poll (#1814/#1824) draws that answer instead of +// asking again. The poll was re-reading the whole team dossier — host side a `memoryDigest` per row, +// ~16 KB every 5 s — to receive an answer that had not changed. +test('#1900: the five-second poll reuses the dossier answer the open read took', async () => { + resetSlots() + const calls = [] + const read = async (endpoint) => { + calls.push(endpoint) + if (endpoint === 'projects') return { projects: [{ id: 'project-1', name: 'FlyBot', role: 'owner', choices: [] }] } + if (endpoint === 'project-read') return { project: projectRecord(), canManage: true } + if (endpoint === 'project-board') return boardAnswer() + if (endpoint === 'tasks') return [] + if (endpoint === 'project-team') return { rows: [dossier(), null, { ...dossier(), botId: 'bot-dev', name: 'Dev' }] } + throw new Error(`unexpected endpoint ${endpoint}`) + } + const count = () => calls.filter(endpoint => endpoint === 'project-team').length + const opened = await readProjectBoard(read, 'project-1') + assert.equal(count(), 1, 'opening the board reads the dossier once') + const polled = await readProjectBoard(read, 'project-1', { team: opened.team }) + assert.equal(count(), 1, 'the poll does not ask for the dossier again') + assert.equal(polled.team.rows.length, 3, 'the poll keeps drawing the one answer') + assert.deepEqual(polled.team.rows.map(row => row?.botId), opened.team.rows.map(row => row?.botId)) +}) + +// #1900: a dossier that fails is not the board failing. It keeps its rows, its `done` group and its +// tasks, and the team area draws empty — the empty-project answer (`project-board.js:873`) stays +// reserved for a Project that really is absent, so a broken dossier cannot paint "No Project yet". +test('#1900: a dossier read that throws leaves the board drawn and the team empty', async () => { + resetSlots() + const read = async (endpoint) => { + if (endpoint === 'projects') return { projects: [{ id: 'project-1', name: 'FlyBot', role: 'owner', choices: [] }] } + if (endpoint === 'project-read') return { project: projectRecord(), canManage: true } + if (endpoint === 'project-board') return { ...boardAnswer(), tasks: [{ id: 'row-1' }], done: [{ id: 'done-1' }] } + if (endpoint === 'tasks') return [{ id: 'task-1' }] + if (endpoint === 'project-team') throw new Error('the dossier is unreadable this tick') + throw new Error(`unexpected endpoint ${endpoint}`) + } + const state = await readProjectBoard(read, 'project-1') + assert.equal(state.project?.id, 'project-1', 'the Project still draws: no empty state') + assert.equal(state.rows.length, 1) + assert.equal(state.done.length, 1) + assert.equal(state.tasks.length, 1) + assert.deepEqual(Object.keys(state.team).sort(), ['rows'], 'the team area is the one thing that gives up') + assert.equal(state.team.rows.length, 0) +}) + // #1892: the role pool is a rank list, not a Bot the board holds a dossier answer for. // `project-team` answers rows keyed by the Project's own Bots, so a pool click asked for a row the // host never sent and drew an empty panel. A pool entry now reads `@id` beside its rank; the Chief diff --git a/core/test/project-board-view.test.js b/core/test/project-board-view.test.js index c4310a890..9728903c8 100644 --- a/core/test/project-board-view.test.js +++ b/core/test/project-board-view.test.js @@ -353,7 +353,9 @@ test('#1814: an open board reads on every tick, stops once a row opens its own b assert.match(textIn(tree), /Dev4 · stalled/, 'the board draws what the first read answered') const afterFirst = count.calls await tick() - assert.equal(count.calls, afterFirst + 5, 'an open board reads again on the next tick') + // #1900: a tick is the four reads that change — the dossier was read once above and is drawn from + // that answer, so it is not part of a tick's count. + assert.equal(count.calls, afterFirst + 4, 'an open board reads the four that change on the next tick, not the dossier') // A row opens its own board: the hash names the task and this board stops drawing, so a tick that // lands before the cleanup must not read. This segment pins the unmount, not the predicate. const afterTick = count.calls @@ -363,7 +365,7 @@ test('#1814: an open board reads on every tick, stops once a row opens its own b // Closing that board puts this one back in front: the next tick reads again. win.location.hash = '#project' await tick() - assert.equal(count.calls, afterTick + 5, 'closing the row’s board resumes the poll') + assert.equal(count.calls, afterTick + 4, 'closing the row’s board resumes the poll') // A hidden tab reads nothing either — this is the segment the named predicate is pinned by. const afterResume = count.calls doc.visibilityState = 'hidden' @@ -371,7 +373,7 @@ test('#1814: an open board reads on every tick, stops once a row opens its own b assert.equal(count.calls, afterResume, 'no read in a hidden tab') doc.visibilityState = 'visible' await tick() - assert.equal(count.calls, afterResume + 5, 'a visible tab reads again on the next tick') + assert.equal(count.calls, afterResume + 4, 'a visible tab reads again on the next tick') // Close the board: the timer goes with it, so a tick afterwards reads nothing at all. const afterVisible = count.calls for (const off of cleanups) off() diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index b8ad95e90..3dbeb1cd7 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -323,7 +323,13 @@ instructions, role and model), **Memory** (the `MEMORY.md` head and the topic na (the attached `skill` capability names). The panel never re-fetches per section, and a `null` row — the host's answer for a deleted Bot — shows *Not set* with nothing invented. `readProjectBoard` keeps the dossier on the same read as the rows, so the board and the panel cannot disagree, and the -"my agent" row uses the same panel. A role-pool entry draws none: since #1892 it is plain `@id` +"my agent" row uses the same panel. That read is taken once, when the board opens (#1900): the export +takes the previous answer back as `cached.team` and skips the `project-team` call when it has one, so +the five-second refresh draws the dossier the open read took instead of re-reading every persona and +memory head on a timer — the answer only changes when someone edits one of those files, and a re-open +reads it again. A dossier that throws is non-fatal: rows, `done`, tasks and queues still draw, `team` +becomes `{ rows: [] }`, and the empty-project answer stays reserved for a Project that is really +absent. A role-pool entry draws none: since #1892 it is plain `@id` beside its rank, because `project-team` answers rows keyed by the Project's own Bots and the old control asked for a row the host never sent. The entry in the owner's own Bot chat is `ProjectAgentEntry` in the header: the roster already carries `bot.projectId`, the entry reads @@ -357,7 +363,8 @@ carried in and has no board of its own). A row with a board is a button: it writ group and clears the hash, so a reload does not reopen the same board. A row without a board (or without a task record) is not a button — `role` alone opens nothing. Opening the pane is four host reads, never a per-Bot read: `projects`, `project-read`, `project-board`, and the `tasks` list the rows are joined -against. Since #1814 they repeat every five seconds while the pane is open, because what a card has +against; since #1900 the fourth, `project-team`, is read once at that open and the poll below draws +that answer instead. The other three repeat every five seconds while the pane is open, because what a card has waited and whether its lease is live are computed by the host at read time: a lease that expires during the visit turns the card `stalled` on its own instead of freezing the board. The first read failing answers the empty state as it did; a poll that fails keeps the board the last good read drew. Polling diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 0c2147401..8c745ad23 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -7527,11 +7527,22 @@ diff --git a/client.js b/client.js + setBusy(""); } - }); --} ++ }; ++ return (0, import_react9.createElement)( ++ "div", ++ { className: "bt-file-chips" }, ++ files.map((file) => (0, import_react9.createElement)( ++ "button", ++ { key: file.id, type: "button", className: "bt-file-chip", disabled: busy === file.id, title: `Download ${file.name} (untrusted file from a remote Bot)`, onClick: () => download2(file) }, ++ `📎 ${file.name} · ${fileSize(file.size)}` ++ )), ++ error ? (0, import_react9.createElement)("span", { className: "bt-file-error", role: "alert" }, error) : null ++ ); + } -function ScopeSwitch({ value, onChange, bot }) { - const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; - return (0, import_react5.createElement)( -+ }; ++function GroupEvent({ prefix, room: room2, name, roster, actions }) { + return (0, import_react9.createElement)( "div", - { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, @@ -7544,18 +7555,6 @@ diff --git a/client.js b/client.js - className: "bt-secret-seg", - onClick: () => onChange(id) - }, text)) -+ { className: "bt-file-chips" }, -+ files.map((file) => (0, import_react9.createElement)( -+ "button", -+ { key: file.id, type: "button", className: "bt-file-chip", disabled: busy === file.id, title: `Download ${file.name} (untrusted file from a remote Bot)`, onClick: () => download2(file) }, -+ `📎 ${file.name} · ${fileSize(file.size)}` -+ )), -+ error ? (0, import_react9.createElement)("span", { className: "bt-file-error", role: "alert" }, error) : null -+ ); -+} -+function GroupEvent({ prefix, room: room2, name, roster, actions }) { -+ return (0, import_react9.createElement)( -+ "div", + { className: "bt-event" }, + (0, import_react9.createElement)("span", null, prefix), + room2 ? (0, import_react9.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react9.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react9.createElement)("span", null, name) : null @@ -9033,6 +9032,10 @@ diff --git a/client.js b/client.js + const approvals = askedIn(room2, work, id, doing); + return { id, approvals, doing, here: doing !== void 0, state: approvals.length > 0 ? "ask" : doing ? "work" : "idle" }; + }); ++} ++function busyMembers(room2, work, roster) { ++ const busy = memberWork(room2, work).filter((entry) => entry.state !== "idle" && roster.byId[entry.id]); ++ return [...busy.filter((entry) => entry.state === "ask"), ...busy.filter((entry) => entry.state === "work")].map((entry) => ({ ...entry, bot: roster.byId[entry.id] })); } -function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { - if (whole && bots.length === 1) { @@ -9058,10 +9061,6 @@ diff --git a/client.js b/client.js - (0, import_react6.createElement)(BotMark, { bot, size: 16 }), - bot.name - )); -+function busyMembers(room2, work, roster) { -+ const busy = memberWork(room2, work).filter((entry) => entry.state !== "idle" && roster.byId[entry.id]); -+ return [...busy.filter((entry) => entry.state === "ask"), ...busy.filter((entry) => entry.state === "work")].map((entry) => ({ ...entry, bot: roster.byId[entry.id] })); -+} +function splitBusy(busy) { + const here = busy.filter((entry) => entry.state === "work" && entry.here); + const rows = [...here.filter((entry) => entry.doing.speaking === true), ...here.filter((entry) => entry.doing.speaking !== true)].slice(0, ROWS_MAX); @@ -9854,7 +9853,7 @@ diff --git a/client.js b/client.js + const custom = typeof reply.custom === "string" ? reply.custom.trim() : ""; + if (selected.length === 0 && custom === "") return void 0; + return Promise.resolve(actions.answerQuestion(card.sessionId, card.question.id, { selected, custom })); - } ++ } + if (card?.kind === "task" && card.status === "input") { + const text2 = typeof choice === "string" ? choice.trim() : ""; + const questionId = card.task?.inputRequest?.id; @@ -9866,7 +9865,7 @@ diff --git a/client.js b/client.js + const note = choice !== null && typeof choice === "object" ? String(choice.note ?? "").trim() : ""; + if (note === "") return void 0; + return Promise.resolve(actions.sendBackTask(card.task.id, note)); -+ } + } + if (card?.kind === "approval" && card.approval?.answerable === true) { + return Promise.resolve(actions.answerApproval(card.approval.id, choice)); + } @@ -12248,7 +12247,7 @@ diff --git a/client.js b/client.js setOpen(false); }; const onKey = (event) => { -@@ -5097,412 +5097,1330 @@ function UpdateButton({ useUpdate, actions }) { +@@ -5097,412 +5097,1337 @@ function UpdateButton({ useUpdate, actions }) { window.removeEventListener("keydown", onKey, true); }; }, [open]); @@ -13496,8 +13495,7 @@ diff --git a/client.js b/client.js + { className: "bt-project-actions" }, + (0, import_react19.createElement)("button", { type: "button", "data-action": "project-settings", onClick: () => onSettings?.() }, t("Project settings")), + (0, import_react19.createElement)("button", { type: "button", "data-action": "project-members", onClick: () => onMembers?.() }, t("Members")) - ), -- open && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( ++ ), + (0, import_react19.createElement)(ProjectMetrics, { view: view.metrics }), + (0, import_react19.createElement)(ProjectRunningTable, { view: view.metrics, onOpen }), + (0, import_react19.createElement)(ProjectBoardRows, { view, onOpen }), @@ -13533,14 +13531,7 @@ diff --git a/client.js b/client.js + { className: "bt-project-panel", "data-panel": "project-settings" }, + (0, import_react19.createElement)("h3", null, t("Project settings")), + (0, import_react19.createElement)( - "div", -- { className: "bt-menu bt-menu-up bt-account-menu", role: "menu", "aria-label": t("Account"), style: box }, -- groups.map((group, index) => (0, import_react8.createElement)( -- import_react8.Fragment, -- { key: index }, -- index > 0 ? (0, import_react8.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, -- group.map(([label, Icon, run, hint]) => (0, import_react8.createElement)("button", { -- key: label, ++ "div", + { className: "bt-project-field" }, + (0, import_react19.createElement)("label", { htmlFor: "bt-project-member-id" }, t("Bind a member to this Project")), + (0, import_react19.createElement)( @@ -13556,9 +13547,17 @@ diff --git a/client.js b/client.js + (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-bind", onClick: () => run("project-bind", { kind, id: text(memberId), projectId: id }, t("Member bound.")) }, t("Bind")), + (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-unbind", onClick: () => run("project-bind", { kind, id: text(memberId), projectId: null }, t("Member unbound.")) }, t("Unbind")) + ) -+ ), + ), +- open && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( + (0, import_react19.createElement)( -+ "div", + "div", +- { className: "bt-menu bt-menu-up bt-account-menu", role: "menu", "aria-label": t("Account"), style: box }, +- groups.map((group, index) => (0, import_react8.createElement)( +- import_react8.Fragment, +- { key: index }, +- index > 0 ? (0, import_react8.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, +- group.map(([label, Icon, run, hint]) => (0, import_react8.createElement)("button", { +- key: label, + { className: "bt-project-field" }, + (0, import_react19.createElement)("label", null, t("Project Assistant")), + (0, import_react19.createElement)( @@ -13672,17 +13671,19 @@ diff --git a/client.js b/client.js - } - if (id !== "" && (roster.roomsById[id] || (actions.isAgentSession?.(id) ?? roster.agentOf[id] !== void 0) || blank3 !== "1")) { - lastBot.current = null; -+async function readProjectBoard(read, wanted) { ++async function readProjectBoard(read, wanted, cached) { + const listing = await read("projects"); + const projects = Array.isArray(listing?.projects) ? listing.projects : []; + const project = wanted === "" ? projects[0] : projects.find((item) => item.id === wanted); + if (project === void 0) return { project: null, rows: [], done: [], canManage: false, tasks: [], team: { rows: [] } }; + const zone = typeof Intl === "undefined" || typeof Intl.DateTimeFormat !== "function" ? void 0 : Intl.DateTimeFormat().resolvedOptions().timeZone; ++ const seeded = cached !== null && typeof cached === "object" && cached.team !== void 0; ++ const teamAnswer = seeded ? Promise.resolve(cached.team) : Promise.resolve().then(() => read("project-team", { id: project.id })).catch(() => null); + const [record, board, taskList, team] = await Promise.all([ + read("project-read", { id: project.id }), + read("project-board", { id: project.id, timeZone: zone }), + read("tasks", {}), -+ read("project-team", { id: project.id }) ++ teamAnswer + ]); + const tasks = Array.isArray(taskList) ? taskList : taskList?.tasks ?? []; + const queues = { waiting: board?.waiting, inflight: board?.inflight, blocked: board?.blocked, counts: board?.counts ?? null }; @@ -13758,7 +13759,12 @@ diff --git a/client.js b/client.js - wide ? (0, import_react8.createElement)("span", { className: "bt-return-name" }, t("DS Bot")) : null + const wanted = hash.slice("#project".length).replace(/^=/, ""); + let gone = false; -+ const loadBoard = () => readProjectBoard(read, wanted); ++ let teamAnswer; ++ const loadBoard = async () => { ++ const next = await readProjectBoard(read, wanted, teamAnswer === void 0 ? void 0 : { team: teamAnswer }); ++ if (next.project !== null) teamAnswer = next.team; ++ return next; ++ }; + const refresh = async (first) => { + try { + const next = await loadBoard(); diff --git a/patches/ds-bot/files/src/client/project-board.js b/patches/ds-bot/files/src/client/project-board.js index 4f8e0cbbc..a3ba1a31e 100644 --- a/patches/ds-bot/files/src/client/project-board.js +++ b/patches/ds-bot/files/src/client/project-board.js @@ -866,7 +866,11 @@ export function ProjectMembersPanel({ project, rows, onClose } = {}) { // #1864: one read for the whole board, exported so a test can prove the dossier comes from a single // `project-team` call. `read` is the channel seam (`call` in the app). -export async function readProjectBoard(read, wanted) { +// #1900: the dossier is the one answer in that read which does not change on a five-second clock, so +// the caller hands back the answer it already has (`cached.team`) and the read skips the call: the +// board reads it once when it opens and every poll after that draws that same answer. The other +// three channels are read every time — they are the reason a poll exists. +export async function readProjectBoard(read, wanted, cached) { const listing = await read('projects') const projects = Array.isArray(listing?.projects) ? listing.projects : [] const project = wanted === '' ? projects[0] : projects.find(item => item.id === wanted) @@ -874,11 +878,20 @@ export async function readProjectBoard(read, wanted) { const zone = typeof Intl === 'undefined' || typeof Intl.DateTimeFormat !== 'function' ? undefined : Intl.DateTimeFormat().resolvedOptions().timeZone + const seeded = cached !== null && typeof cached === 'object' && cached.team !== undefined + // #1900: a dossier that throws is the team area's loss, not the board's. The rejection used to + // reject the whole `Promise.all`, and the first paint turned that into the empty state — a broken + // persona file made a Project that exists read as "No Project yet", which is the one answer + // `:873` reserves for a Project that really is gone. Rows, `done`, tasks and queues still draw; + // only `team` gives up, as `{ rows: [] }` below. + const teamAnswer = seeded + ? Promise.resolve(cached.team) + : Promise.resolve().then(() => read('project-team', { id: project.id })).catch(() => null) const [record, board, taskList, team] = await Promise.all([ read('project-read', { id: project.id }), read('project-board', { id: project.id, timeZone: zone }), read('tasks', {}), - read('project-team', { id: project.id }), + teamAnswer, ]) const tasks = Array.isArray(taskList) ? taskList : (taskList?.tasks ?? []) // The groups are the same read as the rows, so the header cannot disagree with the cards. @@ -951,7 +964,17 @@ export function ProjectBoardOverlay({ actions, call: request } = {}) { let gone = false // #1864: the board, its record, its rows, the task texts and the team dossier are one read path, // and the dossier comes from the single `project-team` call that read makes. - const loadBoard = () => readProjectBoard(read, wanted) + // #1900: that one dossier answer is held for as long as this board is open. The effect re-runs + // per `#project` entry, so re-opening the board reads the dossier again and a persona or memory + // edit lands then; the five-second poll only draws the answer the open read already took. + let teamAnswer + const loadBoard = async () => { + const next = await readProjectBoard(read, wanted, teamAnswer === undefined ? undefined : { team: teamAnswer }) + // A board with no Project took no dossier read at all, so it seeds nothing: the first poll + // that does find the Project reads the dossier itself instead of caching the empty answer. + if (next.project !== null) teamAnswer = next.team + return next + } const refresh = async (first) => { try { const next = await loadBoard() diff --git a/patches/ds-bot/files/test/project-board-panels.test.js b/patches/ds-bot/files/test/project-board-panels.test.js index 6f4a2de11..cbe1fcdbb 100644 --- a/patches/ds-bot/files/test/project-board-panels.test.js +++ b/patches/ds-bot/files/test/project-board-panels.test.js @@ -537,6 +537,53 @@ test('the board reads the dossier from exactly one project-team call', async () assert.deepEqual(opened.map(entry => entry.botId), ['bot-chief']) }) +// #1900: the dossier only changes when someone edits a persona or a memory file, so it is read +// once, when the board opens, and the five-second poll (#1814/#1824) draws that answer instead of +// asking again. The poll was re-reading the whole team dossier — host side a `memoryDigest` per row, +// ~16 KB every 5 s — to receive an answer that had not changed. +test('#1900: the five-second poll reuses the dossier answer the open read took', async () => { + resetSlots() + const calls = [] + const read = async (endpoint) => { + calls.push(endpoint) + if (endpoint === 'projects') return { projects: [{ id: 'project-1', name: 'FlyBot', role: 'owner', choices: [] }] } + if (endpoint === 'project-read') return { project: projectRecord(), canManage: true } + if (endpoint === 'project-board') return boardAnswer() + if (endpoint === 'tasks') return [] + if (endpoint === 'project-team') return { rows: [dossier(), null, { ...dossier(), botId: 'bot-dev', name: 'Dev' }] } + throw new Error(`unexpected endpoint ${endpoint}`) + } + const count = () => calls.filter(endpoint => endpoint === 'project-team').length + const opened = await readProjectBoard(read, 'project-1') + assert.equal(count(), 1, 'opening the board reads the dossier once') + const polled = await readProjectBoard(read, 'project-1', { team: opened.team }) + assert.equal(count(), 1, 'the poll does not ask for the dossier again') + assert.equal(polled.team.rows.length, 3, 'the poll keeps drawing the one answer') + assert.deepEqual(polled.team.rows.map(row => row?.botId), opened.team.rows.map(row => row?.botId)) +}) + +// #1900: a dossier that fails is not the board failing. It keeps its rows, its `done` group and its +// tasks, and the team area draws empty — the empty-project answer (`project-board.js:873`) stays +// reserved for a Project that really is absent, so a broken dossier cannot paint "No Project yet". +test('#1900: a dossier read that throws leaves the board drawn and the team empty', async () => { + resetSlots() + const read = async (endpoint) => { + if (endpoint === 'projects') return { projects: [{ id: 'project-1', name: 'FlyBot', role: 'owner', choices: [] }] } + if (endpoint === 'project-read') return { project: projectRecord(), canManage: true } + if (endpoint === 'project-board') return { ...boardAnswer(), tasks: [{ id: 'row-1' }], done: [{ id: 'done-1' }] } + if (endpoint === 'tasks') return [{ id: 'task-1' }] + if (endpoint === 'project-team') throw new Error('the dossier is unreadable this tick') + throw new Error(`unexpected endpoint ${endpoint}`) + } + const state = await readProjectBoard(read, 'project-1') + assert.equal(state.project?.id, 'project-1', 'the Project still draws: no empty state') + assert.equal(state.rows.length, 1) + assert.equal(state.done.length, 1) + assert.equal(state.tasks.length, 1) + assert.deepEqual(Object.keys(state.team).sort(), ['rows'], 'the team area is the one thing that gives up') + assert.equal(state.team.rows.length, 0) +}) + // #1892: the role pool is a rank list, not a Bot the board holds a dossier answer for. // `project-team` answers rows keyed by the Project's own Bots, so a pool click asked for a row the // host never sent and drew an empty panel. A pool entry now reads `@id` beside its rank; the Chief diff --git a/patches/ds-bot/files/test/project-board-view.test.js b/patches/ds-bot/files/test/project-board-view.test.js index c4310a890..9728903c8 100644 --- a/patches/ds-bot/files/test/project-board-view.test.js +++ b/patches/ds-bot/files/test/project-board-view.test.js @@ -353,7 +353,9 @@ test('#1814: an open board reads on every tick, stops once a row opens its own b assert.match(textIn(tree), /Dev4 · stalled/, 'the board draws what the first read answered') const afterFirst = count.calls await tick() - assert.equal(count.calls, afterFirst + 5, 'an open board reads again on the next tick') + // #1900: a tick is the four reads that change — the dossier was read once above and is drawn from + // that answer, so it is not part of a tick's count. + assert.equal(count.calls, afterFirst + 4, 'an open board reads the four that change on the next tick, not the dossier') // A row opens its own board: the hash names the task and this board stops drawing, so a tick that // lands before the cleanup must not read. This segment pins the unmount, not the predicate. const afterTick = count.calls @@ -363,7 +365,7 @@ test('#1814: an open board reads on every tick, stops once a row opens its own b // Closing that board puts this one back in front: the next tick reads again. win.location.hash = '#project' await tick() - assert.equal(count.calls, afterTick + 5, 'closing the row’s board resumes the poll') + assert.equal(count.calls, afterTick + 4, 'closing the row’s board resumes the poll') // A hidden tab reads nothing either — this is the segment the named predicate is pinned by. const afterResume = count.calls doc.visibilityState = 'hidden' @@ -371,7 +373,7 @@ test('#1814: an open board reads on every tick, stops once a row opens its own b assert.equal(count.calls, afterResume, 'no read in a hidden tab') doc.visibilityState = 'visible' await tick() - assert.equal(count.calls, afterResume + 5, 'a visible tab reads again on the next tick') + assert.equal(count.calls, afterResume + 4, 'a visible tab reads again on the next tick') // Close the board: the timer goes with it, so a tick afterwards reads nothing at all. const afterVisible = count.calls for (const off of cleanups) off()