diff --git a/changelog.d/1900-team-dossier-off-the-poll.fixed.md b/changelog.d/1900-team-dossier-off-the-poll.fixed.md new file mode 100644 index 000000000..802061e11 --- /dev/null +++ b/changelog.d/1900-team-dossier-off-the-poll.fixed.md @@ -0,0 +1 @@ +Project board: the five-second refresh no longer re-reads the team dossier — `project-team` is read once when the board opens and handed back to each poll, so an open board stops re-reading persona and memory files (about 192 KB a minute) for an answer only an edit changes; a dossier that fails to read now leaves the rows, the done group and the tasks on screen with an empty team block instead of showing "No Project yet" ([#1900](https://github.com/ThinkFlowLab/FlyBot/issues/1900)). diff --git a/core/client.js b/core/client.js index aeef9850b..a47a44925 100644 --- a/core/client.js +++ b/core/client.js @@ -11260,7 +11260,7 @@ function ProjectMembersPanel({ project, rows, onClose } = {}) { ) ); } -async function readProjectBoard(read, wanted) { +async function readProjectBoard(read, wanted, knownTeam) { const listing = await read("projects"); const projects = Array.isArray(listing?.projects) ? listing.projects : []; const project = wanted === "" ? projects[0] : projects.find((item) => item.id === wanted); @@ -11270,7 +11270,7 @@ async function readProjectBoard(read, wanted) { read("project-read", { id: project.id }), read("project-board", { id: project.id, timeZone: zone }), read("tasks", {}), - read("project-team", { id: project.id }) + knownTeam !== void 0 ? knownTeam : read("project-team", { id: project.id }).catch(() => null) ]); const tasks = Array.isArray(taskList) ? taskList : taskList?.tasks ?? []; const queues = { waiting: board?.waiting, inflight: board?.inflight, blocked: board?.blocked, counts: board?.counts ?? null }; @@ -11328,11 +11328,15 @@ function ProjectBoardOverlay({ actions, call: request } = {}) { } const wanted = hash.slice("#project".length).replace(/^=/, ""); let gone = false; - const loadBoard = () => readProjectBoard(read, wanted); + let team; + const loadBoard = () => readProjectBoard(read, wanted, team); const refresh = async (first) => { try { const next = await loadBoard(); - if (!gone) setState(next); + if (!gone) { + team = next.team; + setState(next); + } } catch { if (!gone && first) setState({ project: null, rows: [], canManage: false, tasks: [] }); } diff --git a/core/src/client/project-board.js b/core/src/client/project-board.js index 4f8e0cbbc..98dc3ade6 100644 --- a/core/src/client/project-board.js +++ b/core/src/client/project-board.js @@ -865,8 +865,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) { +// `project-team` call. `read` is the channel seam (`call` in the app). #1900: the dossier is the one +// answer the poll hands back — `knownTeam` is what the open read — because the host builds it by +// reading persona and memory files, and a five-second refresh must not pay that again. A refusal +// leaves the rest of the board standing, so a dossier problem is never the empty-Project answer. +export async function readProjectBoard(read, wanted, knownTeam) { const listing = await read('projects') const projects = Array.isArray(listing?.projects) ? listing.projects : [] const project = wanted === '' ? projects[0] : projects.find(item => item.id === wanted) @@ -878,7 +881,7 @@ export async function readProjectBoard(read, wanted) { read('project-read', { id: project.id }), read('project-board', { id: project.id, timeZone: zone }), read('tasks', {}), - read('project-team', { id: project.id }), + knownTeam !== undefined ? knownTeam : read('project-team', { id: project.id }).catch(() => null), ]) 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. @@ -950,12 +953,15 @@ export function ProjectBoardOverlay({ actions, call: request } = {}) { const wanted = hash.slice('#project'.length).replace(/^=/, '') 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) + // and the dossier comes from the single `project-team` call that read makes. #1900: that call is + // this open's, not the poll's — `team` is handed back on every later read, so a tick re-reads + // only the three answers that change and the dossier is read again on the next board open. + let team + const loadBoard = () => readProjectBoard(read, wanted, team) const refresh = async (first) => { try { const next = await loadBoard() - if (!gone) setState(next) + if (!gone) { team = next.team; setState(next) } } catch { // The first read is the page's answer, so it takes the empty state it always took. A poll // that fails keeps the board the last good read drew (#1814): a board that vanishes under diff --git a/core/test/project-board-panels.test.js b/core/test/project-board-panels.test.js index 6f4a2de11..668dc8bbf 100644 --- a/core/test/project-board-panels.test.js +++ b/core/test/project-board-panels.test.js @@ -537,6 +537,68 @@ test('the board reads the dossier from exactly one project-team call', async () assert.deepEqual(opened.map(entry => entry.botId), ['bot-chief']) }) +// #1900: the board re-reads itself every five seconds (#1814), and the team dossier rode that same +// read, so an open board re-read persona and memory files — up to ~192 KB a minute — for an answer +// only an edit changes. The dossier is read when the board opens; the poll hands back the answer it +// already has, and `project-team` is not asked again. +test('#1900: a poll re-reads the board and the task texts, not the team dossier', 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(), tasks: rows, done: [{ id: 'task-done', column: 'done', title: 'Finished' }] } + if (endpoint === 'tasks') return [{ id: 'task-1', title: 'Ship the poll' }] + if (endpoint === 'project-team') return { rows: [dossier(), null, { ...dossier(), botId: 'bot-dev', name: 'Dev' }] } + throw new Error(`unexpected endpoint ${endpoint}`) + } + const open = await readProjectBoard(read, 'project-1') + const poll = await readProjectBoard(read, 'project-1', open.team) + assert.equal(calls.filter(endpoint => endpoint === 'project-team').length, 1, 'the open reads the dossier once, and the poll does not ask again') + assert.equal(calls.filter(endpoint => endpoint === 'project-board').length, 2, 'the poll still re-reads the board, which is what it is for') + assert.deepEqual(poll.rows.map(row => row.id), ['task-1'], 'the poll keeps the rows it read') + assert.deepEqual(poll.done.map(row => row.id), ['task-done'], 'and the done group') + assert.deepEqual(poll.tasks.map(task => task.id), ['task-1'], 'and the task texts') + assert.deepEqual(poll.team.rows.map(row => row?.botId ?? null), ['bot-chief', null, 'bot-dev'], 'the one dossier answer survives whole, null row included') + + // The block still draws its three rows, and the panel its three sections, from that one answer. + const tree = render(ProjectBoardPane, { project: poll.project, rows: poll.rows, done: poll.done, tasks: poll.tasks, team: poll.team, canManage: true }) + assert.deepEqual(all(tree).filter(node => node.props?.['data-team-role'] !== undefined).map(node => node.props['data-team-role']), ['chief', 'agent', 'pool']) + const drawn = text(render(ProjectDossierPanel, { row: poll.team.rows[0], label: 'Project Chief' })) + assert.match(drawn, /Persona/, 'Persona still draws from the one answer') + assert.match(drawn, /Project Chief of FlyBot/) + assert.match(drawn, /Memory/, 'Memory still draws from the one answer') + assert.match(drawn, /The Chief remembers the Project\./) + assert.match(drawn, /Skills/, 'Skills still draw from the one answer') + assert.match(drawn, /ship-it/) +}) + +// #1900: the same read path must not make a failing dossier fatal. The board's first paint used to +// turn a thrown `project-team` into "No Project yet — create one.", which is the answer reserved for +// a Project that really is absent; the rows, the done group and the tasks are a different answer. +test('#1900: a refused dossier still draws rows, done and tasks, not the empty Project', async () => { + resetSlots() + const read = async (endpoint) => { + if (endpoint === 'projects') return { projects: [{ id: 'project-1', name: 'FlyBot' }] } + if (endpoint === 'project-read') return { project: projectRecord(), canManage: true } + if (endpoint === 'project-board') return { ...boardAnswer(), tasks: rows, done: [{ id: 'task-done', column: 'done', title: 'Finished' }] } + if (endpoint === 'tasks') return [{ id: 'task-1', title: 'Ship the poll' }] + if (endpoint === 'project-team') throw new Error('project-team: cannot read the memory of bot-chief') + throw new Error(`unexpected endpoint ${endpoint}`) + } + const state = await readProjectBoard(read, 'project-1') + assert.notEqual(state.project, null, 'a refused dossier is not the empty Project') + assert.equal(state.project.id, 'project-1') + assert.deepEqual(state.rows.map(row => row.id), ['task-1'], 'the rows still come back') + assert.deepEqual(state.done.map(row => row.id), ['task-done'], 'and the done group') + assert.deepEqual(state.tasks.map(task => task.id), ['task-1'], 'and the task texts') + assert.deepEqual(Array.from(state.team.rows), [], 'the team block reads empty instead of failing the page') + const drawn = all(render(ProjectBoardPane, { project: state.project, rows: state.rows, done: state.done, tasks: state.tasks, team: state.team, canManage: true })).map(text).join(' | ') + assert.match(drawn, /Ship the poll/, 'the board the reader gets still carries the tasks') + assert.doesNotMatch(drawn, /No Project yet/, 'and is not the create-a-Project page') +}) + // #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..4542a3c62 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 four calls now — the listing, the record, the board and the task texts. The + // fifth, the team dossier, belongs to the open and is not re-read (see the #1900 test below). + assert.equal(count.calls, afterFirst + 4, 'an open board reads again on the next tick, without 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() @@ -380,6 +382,48 @@ test('#1814: an open board reads on every tick, stops once a row opens its own b assert.equal(count.calls, afterVisible, 'nothing reads after the board closed') }) +// #1900: the dossier is `project-team`, which the host answers by reading persona and memory files +// and resending their heads — up to ~16 KB every 5 s, an answer that only an edit changes. The poll +// is wanted (#1814); the dossier riding on it is not. One open reads it, and every tick after that +// reads only what the board is polling for. +test('#1900: an open board reads the dossier once, never on a tick', async () => { + resetClock() + win.location.hash = '#project' + const calls = [] + const call = async (name) => { + calls.push(name) + if (name === 'projects') return { projects: [{ id: 'project-1', name: 'FlyBot' }] } + if (name === 'project-read') { + return { project: { id: 'project-1', name: 'FlyBot', chief: { botId: 'bot-chief' }, assistant: { botId: 'bot-assistant' }, roles: { maintainers: ['bot-dev'], members: [] } }, canManage: false } + } + if (name === 'project-board') return { tasks: rows, done: [], waiting: [], inflight: [], blocked: [], counts: null } + if (name === 'tasks') return [{ id: 'task-b1', title: 'Ship the poll' }] + if (name === 'project-team') { + return { rows: [{ botId: 'bot-chief', name: 'Project Chief', persona: 'You are the Project Chief of FlyBot.', memory: { head: 'The Chief remembers the Project.', topics: ['work'] }, skills: ['ship-it'] }] } + } + throw new Error(`unexpected endpoint ${name}`) + } + const count = (endpoint) => calls.filter(name => name === endpoint).length + const { tree, redraw } = await mount({ actions: {}, call }) + assert.equal(count('project-team'), 1, 'opening the board reads the dossier once') + assert.equal(count('project-board'), 1, 'and the board once') + // The one answer still feeds the row and the panel the reader opens from it. + const control = all(tree).find(node => node.props?.['data-dossier-bot'] === 'bot-chief') + assert.ok(control, 'the Chief row carries its Dossier control from that answer') + control.props.onClick() + const drawn = all(redraw()).map(textOf).join(' | ') + assert.match(drawn, /Persona/) + assert.match(drawn, /You are the Project Chief of FlyBot\./) + assert.match(drawn, /Memory/) + assert.match(drawn, /The Chief remembers the Project\./) + assert.match(drawn, /Skills/) + assert.match(drawn, /ship-it/) + // Three ticks: the board is re-read each time, the dossier is not read again at all. + await tick(3) + assert.equal(count('project-team'), 1, 'three polls later the dossier has not been read again') + assert.equal(count('project-board'), 4, 'while the board itself is re-read on every tick') +}) + test('#1814: a failing poll keeps the board the last good read drew', async () => { resetClock() win.location.hash = '#project' diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index b8ad95e90..4b96fc1c2 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -323,7 +323,10 @@ 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. Since #1900 that one answer is the open's, not the poll's: the +five-second refresh hands it back instead of asking `project-team` again, because the host builds it by +reading persona and memory files, and a refused dossier leaves the rows, the done group and the tasks +on screen with an empty team block instead of the empty-Project answer. 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,10 +360,12 @@ 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 #1814 those four 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 +answers the empty state as it did; a poll that fails keeps the board the last good read drew. The +dossier is not among the four (#1900): it is read once when the pane opens and again on the next open, +not on a tick. Polling pauses while a row's own board is open (the hash no longer names this Project) and while the tab is hidden, and a read that lands after the pane closed is dropped. Tests: `core/test/project-board-view.test.js`, `core/test/project-board.test.js`, diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 0c2147401..ded5eca38 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 @@ -9854,13 +9853,13 @@ 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; + if (text2 === "" || typeof questionId !== "string") return void 0; + return Promise.resolve(actions.answerTaskInput(card.task.id, questionId, text2)); -+ } + } + if (card?.kind === "task" && card.status === "result") { + if (choice === "accept") return Promise.resolve(actions.acceptTaskResult(card.task.id)); + const note = choice !== null && typeof choice === "object" ? String(choice.note ?? "").trim() : ""; @@ -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,1334 @@ function UpdateButton({ useUpdate, actions }) { window.removeEventListener("keydown", onKey, true); }; }, [open]); @@ -13672,7 +13671,7 @@ 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, knownTeam) { + const listing = await read("projects"); + const projects = Array.isArray(listing?.projects) ? listing.projects : []; + const project = wanted === "" ? projects[0] : projects.find((item) => item.id === wanted); @@ -13682,7 +13681,7 @@ diff --git a/client.js b/client.js + read("project-read", { id: project.id }), + read("project-board", { id: project.id, timeZone: zone }), + read("tasks", {}), -+ read("project-team", { id: project.id }) ++ knownTeam !== void 0 ? knownTeam : read("project-team", { id: project.id }).catch(() => null) + ]); + const tasks = Array.isArray(taskList) ? taskList : taskList?.tasks ?? []; + const queues = { waiting: board?.waiting, inflight: board?.inflight, blocked: board?.blocked, counts: board?.counts ?? null }; @@ -13758,11 +13757,15 @@ 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 team; ++ const loadBoard = () => readProjectBoard(read, wanted, team); + const refresh = async (first) => { + try { + const next = await loadBoard(); -+ if (!gone) setState(next); ++ if (!gone) { ++ team = next.team; ++ setState(next); ++ } + } catch { + if (!gone && first) setState({ project: null, rows: [], canManage: false, tasks: [] }); + } diff --git a/patches/ds-bot/files/src/client/project-board.js b/patches/ds-bot/files/src/client/project-board.js index 4f8e0cbbc..98dc3ade6 100644 --- a/patches/ds-bot/files/src/client/project-board.js +++ b/patches/ds-bot/files/src/client/project-board.js @@ -865,8 +865,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) { +// `project-team` call. `read` is the channel seam (`call` in the app). #1900: the dossier is the one +// answer the poll hands back — `knownTeam` is what the open read — because the host builds it by +// reading persona and memory files, and a five-second refresh must not pay that again. A refusal +// leaves the rest of the board standing, so a dossier problem is never the empty-Project answer. +export async function readProjectBoard(read, wanted, knownTeam) { const listing = await read('projects') const projects = Array.isArray(listing?.projects) ? listing.projects : [] const project = wanted === '' ? projects[0] : projects.find(item => item.id === wanted) @@ -878,7 +881,7 @@ export async function readProjectBoard(read, wanted) { read('project-read', { id: project.id }), read('project-board', { id: project.id, timeZone: zone }), read('tasks', {}), - read('project-team', { id: project.id }), + knownTeam !== undefined ? knownTeam : read('project-team', { id: project.id }).catch(() => null), ]) 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. @@ -950,12 +953,15 @@ export function ProjectBoardOverlay({ actions, call: request } = {}) { const wanted = hash.slice('#project'.length).replace(/^=/, '') 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) + // and the dossier comes from the single `project-team` call that read makes. #1900: that call is + // this open's, not the poll's — `team` is handed back on every later read, so a tick re-reads + // only the three answers that change and the dossier is read again on the next board open. + let team + const loadBoard = () => readProjectBoard(read, wanted, team) const refresh = async (first) => { try { const next = await loadBoard() - if (!gone) setState(next) + if (!gone) { team = next.team; setState(next) } } catch { // The first read is the page's answer, so it takes the empty state it always took. A poll // that fails keeps the board the last good read drew (#1814): a board that vanishes under 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..668dc8bbf 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,68 @@ test('the board reads the dossier from exactly one project-team call', async () assert.deepEqual(opened.map(entry => entry.botId), ['bot-chief']) }) +// #1900: the board re-reads itself every five seconds (#1814), and the team dossier rode that same +// read, so an open board re-read persona and memory files — up to ~192 KB a minute — for an answer +// only an edit changes. The dossier is read when the board opens; the poll hands back the answer it +// already has, and `project-team` is not asked again. +test('#1900: a poll re-reads the board and the task texts, not the team dossier', 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(), tasks: rows, done: [{ id: 'task-done', column: 'done', title: 'Finished' }] } + if (endpoint === 'tasks') return [{ id: 'task-1', title: 'Ship the poll' }] + if (endpoint === 'project-team') return { rows: [dossier(), null, { ...dossier(), botId: 'bot-dev', name: 'Dev' }] } + throw new Error(`unexpected endpoint ${endpoint}`) + } + const open = await readProjectBoard(read, 'project-1') + const poll = await readProjectBoard(read, 'project-1', open.team) + assert.equal(calls.filter(endpoint => endpoint === 'project-team').length, 1, 'the open reads the dossier once, and the poll does not ask again') + assert.equal(calls.filter(endpoint => endpoint === 'project-board').length, 2, 'the poll still re-reads the board, which is what it is for') + assert.deepEqual(poll.rows.map(row => row.id), ['task-1'], 'the poll keeps the rows it read') + assert.deepEqual(poll.done.map(row => row.id), ['task-done'], 'and the done group') + assert.deepEqual(poll.tasks.map(task => task.id), ['task-1'], 'and the task texts') + assert.deepEqual(poll.team.rows.map(row => row?.botId ?? null), ['bot-chief', null, 'bot-dev'], 'the one dossier answer survives whole, null row included') + + // The block still draws its three rows, and the panel its three sections, from that one answer. + const tree = render(ProjectBoardPane, { project: poll.project, rows: poll.rows, done: poll.done, tasks: poll.tasks, team: poll.team, canManage: true }) + assert.deepEqual(all(tree).filter(node => node.props?.['data-team-role'] !== undefined).map(node => node.props['data-team-role']), ['chief', 'agent', 'pool']) + const drawn = text(render(ProjectDossierPanel, { row: poll.team.rows[0], label: 'Project Chief' })) + assert.match(drawn, /Persona/, 'Persona still draws from the one answer') + assert.match(drawn, /Project Chief of FlyBot/) + assert.match(drawn, /Memory/, 'Memory still draws from the one answer') + assert.match(drawn, /The Chief remembers the Project\./) + assert.match(drawn, /Skills/, 'Skills still draw from the one answer') + assert.match(drawn, /ship-it/) +}) + +// #1900: the same read path must not make a failing dossier fatal. The board's first paint used to +// turn a thrown `project-team` into "No Project yet — create one.", which is the answer reserved for +// a Project that really is absent; the rows, the done group and the tasks are a different answer. +test('#1900: a refused dossier still draws rows, done and tasks, not the empty Project', async () => { + resetSlots() + const read = async (endpoint) => { + if (endpoint === 'projects') return { projects: [{ id: 'project-1', name: 'FlyBot' }] } + if (endpoint === 'project-read') return { project: projectRecord(), canManage: true } + if (endpoint === 'project-board') return { ...boardAnswer(), tasks: rows, done: [{ id: 'task-done', column: 'done', title: 'Finished' }] } + if (endpoint === 'tasks') return [{ id: 'task-1', title: 'Ship the poll' }] + if (endpoint === 'project-team') throw new Error('project-team: cannot read the memory of bot-chief') + throw new Error(`unexpected endpoint ${endpoint}`) + } + const state = await readProjectBoard(read, 'project-1') + assert.notEqual(state.project, null, 'a refused dossier is not the empty Project') + assert.equal(state.project.id, 'project-1') + assert.deepEqual(state.rows.map(row => row.id), ['task-1'], 'the rows still come back') + assert.deepEqual(state.done.map(row => row.id), ['task-done'], 'and the done group') + assert.deepEqual(state.tasks.map(task => task.id), ['task-1'], 'and the task texts') + assert.deepEqual(Array.from(state.team.rows), [], 'the team block reads empty instead of failing the page') + const drawn = all(render(ProjectBoardPane, { project: state.project, rows: state.rows, done: state.done, tasks: state.tasks, team: state.team, canManage: true })).map(text).join(' | ') + assert.match(drawn, /Ship the poll/, 'the board the reader gets still carries the tasks') + assert.doesNotMatch(drawn, /No Project yet/, 'and is not the create-a-Project page') +}) + // #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..4542a3c62 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 four calls now — the listing, the record, the board and the task texts. The + // fifth, the team dossier, belongs to the open and is not re-read (see the #1900 test below). + assert.equal(count.calls, afterFirst + 4, 'an open board reads again on the next tick, without 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() @@ -380,6 +382,48 @@ test('#1814: an open board reads on every tick, stops once a row opens its own b assert.equal(count.calls, afterVisible, 'nothing reads after the board closed') }) +// #1900: the dossier is `project-team`, which the host answers by reading persona and memory files +// and resending their heads — up to ~16 KB every 5 s, an answer that only an edit changes. The poll +// is wanted (#1814); the dossier riding on it is not. One open reads it, and every tick after that +// reads only what the board is polling for. +test('#1900: an open board reads the dossier once, never on a tick', async () => { + resetClock() + win.location.hash = '#project' + const calls = [] + const call = async (name) => { + calls.push(name) + if (name === 'projects') return { projects: [{ id: 'project-1', name: 'FlyBot' }] } + if (name === 'project-read') { + return { project: { id: 'project-1', name: 'FlyBot', chief: { botId: 'bot-chief' }, assistant: { botId: 'bot-assistant' }, roles: { maintainers: ['bot-dev'], members: [] } }, canManage: false } + } + if (name === 'project-board') return { tasks: rows, done: [], waiting: [], inflight: [], blocked: [], counts: null } + if (name === 'tasks') return [{ id: 'task-b1', title: 'Ship the poll' }] + if (name === 'project-team') { + return { rows: [{ botId: 'bot-chief', name: 'Project Chief', persona: 'You are the Project Chief of FlyBot.', memory: { head: 'The Chief remembers the Project.', topics: ['work'] }, skills: ['ship-it'] }] } + } + throw new Error(`unexpected endpoint ${name}`) + } + const count = (endpoint) => calls.filter(name => name === endpoint).length + const { tree, redraw } = await mount({ actions: {}, call }) + assert.equal(count('project-team'), 1, 'opening the board reads the dossier once') + assert.equal(count('project-board'), 1, 'and the board once') + // The one answer still feeds the row and the panel the reader opens from it. + const control = all(tree).find(node => node.props?.['data-dossier-bot'] === 'bot-chief') + assert.ok(control, 'the Chief row carries its Dossier control from that answer') + control.props.onClick() + const drawn = all(redraw()).map(textOf).join(' | ') + assert.match(drawn, /Persona/) + assert.match(drawn, /You are the Project Chief of FlyBot\./) + assert.match(drawn, /Memory/) + assert.match(drawn, /The Chief remembers the Project\./) + assert.match(drawn, /Skills/) + assert.match(drawn, /ship-it/) + // Three ticks: the board is re-read each time, the dossier is not read again at all. + await tick(3) + assert.equal(count('project-team'), 1, 'three polls later the dossier has not been read again') + assert.equal(count('project-board'), 4, 'while the board itself is re-read on every tick') +}) + test('#1814: a failing poll keeps the board the last good read drew', async () => { resetClock() win.location.hash = '#project'