From 2c7b70a6c76bb926aed1e9a0dd338595220392db Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:14:49 +0800 Subject: [PATCH] host: open a decision file the board listed, from its row (#2041) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Project board listed every decisions/*.md file as a plain div: a person saw that a decision existed and could not open it. A row is now a button that asks the host for that one name and draws the text under it. The new read accepts only a name the board's own listing produced — the last board read of that Project is the set, including the names the 200-row cut dropped — so a name like decisions/../outside.md, an absolute path, a name one level down or a symlink answers `not listed` without a stat or an open, and the answer keeps the reader's own three states (text, absent, too large). The bounded byte read is now shared with project_state (readListedFile), so the 200,000-byte bound and the lstat rule cannot drift between the two readers. --- changelog.d/2041-decision-open.added.md | 1 + core/client.js | 57 +++- core/src/client/i18n.js | 2 + core/src/client/project-board.js | 52 +++- core/src/host/authz.js | 3 + core/src/host/channel.js | 1 + core/src/host/project-board.js | 68 ++++- core/src/host/project-state.js | 9 +- core/src/host/store.js | 8 + core/test/project-board-view.test.js | 178 ++++++++++++ core/test/project-board.test.js | 267 ++++++++++++++++++ docs/subsystems/core-client.md | 8 +- docs/subsystems/core-host.md | 4 +- patches/ds-bot/0001-core-fixes.patch | 4 +- patches/ds-bot/0002-flybot-api-host.patch | 10 +- patches/ds-bot/0005-social-seams.patch | 3 +- patches/ds-bot/0008-client-bundle.patch | 218 ++++++++------ .../ds-bot/files/src/client/project-board.js | 52 +++- patches/ds-bot/files/src/host/authz.js | 3 + .../ds-bot/files/src/host/project-board.js | 68 ++++- .../ds-bot/files/src/host/project-state.js | 9 +- .../files/test/project-board-view.test.js | 178 ++++++++++++ .../ds-bot/files/test/project-board.test.js | 267 ++++++++++++++++++ 23 files changed, 1349 insertions(+), 121 deletions(-) create mode 100644 changelog.d/2041-decision-open.added.md diff --git a/changelog.d/2041-decision-open.added.md b/changelog.d/2041-decision-open.added.md new file mode 100644 index 000000000..d2b565a03 --- /dev/null +++ b/changelog.d/2041-decision-open.added.md @@ -0,0 +1 @@ +A decision row on the Project board now opens: activating a `decisions/*.md` row (by mouse or keyboard) shows that file's text under the row, and activating it again closes it ([#2041](https://github.com/ThinkFlowLab/FlyBot/issues/2041)) — before this, the board could list 200 decision files and open none of them. The text comes from a new host read that accepts only a name the board's own listing produced, including one past the 200-row cut, so a name like `decisions/../outside.md`, an absolute path, a name one level down or a symlink answers `not listed` without touching the disk; a file over 200,000 bytes answers `too large` rather than being cut, and a row whose file was deleted answers `absent` and stays a row. diff --git a/core/client.js b/core/client.js index 71cda10c1..6b3b0a7d5 100644 --- a/core/client.js +++ b/core/client.js @@ -259,6 +259,8 @@ var ZH = { "{from} → {to}": "{from} → {to}", "+{count} more": "另有 {count} 条", "{count} more not shown": "还有 {count} 条未显示", + "Reading the decision…": "正在读取决策…", + "not listed": "不在列表里", delegated: "委托转达", All: "全部", Filter: "筛选", @@ -10436,6 +10438,14 @@ var PROJECT_BOARD_CSS = ` .bt-project-decision .bt-project-date,.bt-project-meeting .bt-project-date{font-size:11px;opacity:.6;min-width:76px;font-variant-numeric:tabular-nums} .bt-project-decision .bt-project-hash{margin-left:auto;font-size:11px;opacity:.55;font-family:var(--bt-mono,ui-monospace,monospace)} .bt-project-decision .bt-project-title,.bt-project-meeting .bt-project-title{min-width:0} +/* #2041: the decision row became a button, so it keeps the row's look and drops the control's. */ +button.bt-project-decision{width:100%;font:inherit;color:inherit;text-align:left;border:0;cursor:pointer} +button.bt-project-decision:hover{background:var(--bt-card-hover,rgba(127,127,127,.16))} +button.bt-project-decision:focus-visible{outline:2px solid var(--bt-accent,#4c8dff);outline-offset:1px} +button.bt-project-decision[data-open="true"]{outline:1px solid var(--bt-accent,#4c8dff)} +.bt-project-decision-wrap{margin:0} +.bt-project-decision-text{margin:0 0 8px;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));white-space:pre-wrap;overflow-wrap:anywhere;max-height:320px;overflow:auto;font-size:12px;font-family:var(--bt-mono,ui-monospace,monospace)} +.bt-project-decision-refusal{opacity:.75} .bt-project-path{font-size:11px;opacity:.6;font-family:var(--bt-mono,ui-monospace,monospace)} .bt-project-missing{font-size:11px;opacity:.6} .bt-project-status{font-size:11px;text-transform:uppercase;letter-spacing:.04em;opacity:.6;margin-left:auto} @@ -10883,14 +10893,45 @@ function ProjectQueueGroups({ view, onOpen }) { )); } var countText = (value) => value === null ? t("unknown") : String(value); -function ProjectBoardRecord({ record }) { +function ProjectBoardRecord({ record, projectId, call: call4 }) { const { decisions, meetings } = record; + const [open, setOpen] = (0, import_react19.useState)(null); + const toggle = (path) => { + if (open !== null && open.path === path) { + setOpen(null); + return; + } + if (typeof call4 !== "function") return; + setOpen({ path, answer: null }); + const settle = (answer) => setOpen((current) => current !== null && current.path === path ? { path, answer } : current); + call4("project-decision", { id: projectId, path }).then(settle, (error) => settle({ ok: false, reason: String(error?.message ?? error) })); + }; + const decisionBody = (file) => { + if (open === null || open.path !== file.path) return null; + const answer = open.answer; + if (answer === null) return (0, import_react19.createElement)("div", { className: "bt-project-decision-text", "data-open-decision": file.path }, t("Reading the decision…")); + if (answer.ok !== true) return (0, import_react19.createElement)("div", { className: "bt-project-decision-text bt-project-decision-refusal", "data-open-decision": file.path, "data-refused": "true" }, text(answer.reason) || t("not listed")); + return (0, import_react19.createElement)("pre", { className: "bt-project-decision-text", "data-open-decision": file.path }, typeof answer.text === "string" ? answer.text : ""); + }; const decisionRows = decisions.files.map((file) => (0, import_react19.createElement)( "div", - { key: file.key, className: "bt-project-decision", "data-path": file.path, "data-sha256": file.sha256 }, - (0, import_react19.createElement)("span", { className: "bt-project-date" }, file.date), - (0, import_react19.createElement)("span", { className: "bt-project-title" }, file.title === "" ? file.name : file.title), - (0, import_react19.createElement)("span", { className: "bt-project-hash", title: file.sha256 }, file.shortHash) + { key: file.key, className: "bt-project-decision-wrap" }, + (0, import_react19.createElement)( + "button", + { + type: "button", + className: "bt-project-decision", + "data-path": file.path, + "data-sha256": file.sha256, + "data-open": open !== null && open.path === file.path ? "true" : void 0, + "aria-expanded": open !== null && open.path === file.path ? "true" : "false", + onClick: () => toggle(file.path) + }, + (0, import_react19.createElement)("span", { className: "bt-project-date" }, file.date), + (0, import_react19.createElement)("span", { className: "bt-project-title" }, file.title === "" ? file.name : file.title), + (0, import_react19.createElement)("span", { className: "bt-project-hash", title: file.sha256 }, file.shortHash) + ), + decisionBody(file) )); const recipientText = (list) => list.length === 0 ? null : list.map((name) => `@${name}`).join(", "); const proxyText = (meeting) => meeting.participants.filter((participant) => participant.speaksFor !== null).map((participant) => `${participant.name} ${t("for {user}", { user: participant.speaksFor })}`).join(", "); @@ -11133,7 +11174,7 @@ function ProjectAgentEntry({ projectId, read, onOpen } = {}) { onClick: open }, t("My agent")); } -function ProjectBoardPane({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics, onOpen, onSettings, onMembers, onOpenBot, onOpenDossier }) { +function ProjectBoardPane({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics, onOpen, onSettings, onMembers, onOpenBot, onOpenDossier, call: call4 }) { const view = projectBoardView({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics }); return (0, import_react19.createElement)( "div", @@ -11163,7 +11204,7 @@ function ProjectBoardPane({ project, rows, done, canManage, tasks, decisions, me (0, import_react19.createElement)(ProjectBoardRows, { view, onOpen }), (0, import_react19.createElement)(ProjectQueueGroups, { view, onOpen }), (0, import_react19.createElement)(ProjectDoneGroup, { view, onOpen }), - (0, import_react19.createElement)(ProjectBoardRecord, { record: view.record }) + (0, import_react19.createElement)(ProjectBoardRecord, { record: view.record, projectId: view.projectId, call: call4 }) ); } function ProjectSettingsPanel({ project, assistantMine, call: request, onClose, onSaved } = {}) { @@ -11465,6 +11506,8 @@ function ProjectBoardOverlay({ actions, call: request } = {}) { queues: state.queues, metrics: state.metrics, team: state.team, + // #2041: a decision row opens through the same channel read the page polls with. + call: read, onSettings: () => setPanel("settings"), onMembers: () => setPanel("members"), // #1864: a team row's Dossier control opens the read-only panel, fed by the same read. diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index d2b071053..16dc57ac8 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -226,6 +226,8 @@ const ZH = { '{from} → {to}': '{from} → {to}', '+{count} more': '另有 {count} 条', '{count} more not shown': '还有 {count} 条未显示', + 'Reading the decision…': '正在读取决策…', + 'not listed': '不在列表里', delegated: '委托转达', All: '全部', Filter: '筛选', diff --git a/core/src/client/project-board.js b/core/src/client/project-board.js index 996137811..ea9e3086b 100644 --- a/core/src/client/project-board.js +++ b/core/src/client/project-board.js @@ -31,6 +31,14 @@ export const PROJECT_BOARD_CSS = ` .bt-project-decision .bt-project-date,.bt-project-meeting .bt-project-date{font-size:11px;opacity:.6;min-width:76px;font-variant-numeric:tabular-nums} .bt-project-decision .bt-project-hash{margin-left:auto;font-size:11px;opacity:.55;font-family:var(--bt-mono,ui-monospace,monospace)} .bt-project-decision .bt-project-title,.bt-project-meeting .bt-project-title{min-width:0} +/* #2041: the decision row became a button, so it keeps the row's look and drops the control's. */ +button.bt-project-decision{width:100%;font:inherit;color:inherit;text-align:left;border:0;cursor:pointer} +button.bt-project-decision:hover{background:var(--bt-card-hover,rgba(127,127,127,.16))} +button.bt-project-decision:focus-visible{outline:2px solid var(--bt-accent,#4c8dff);outline-offset:1px} +button.bt-project-decision[data-open="true"]{outline:1px solid var(--bt-accent,#4c8dff)} +.bt-project-decision-wrap{margin:0} +.bt-project-decision-text{margin:0 0 8px;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));white-space:pre-wrap;overflow-wrap:anywhere;max-height:320px;overflow:auto;font-size:12px;font-family:var(--bt-mono,ui-monospace,monospace)} +.bt-project-decision-refusal{opacity:.75} .bt-project-path{font-size:11px;opacity:.6;font-family:var(--bt-mono,ui-monospace,monospace)} .bt-project-missing{font-size:11px;opacity:.6} .bt-project-status{font-size:11px;text-transform:uppercase;letter-spacing:.04em;opacity:.6;margin-left:auto} @@ -529,12 +537,44 @@ const countText = value => (value === null ? t('unknown') : String(value)) // The record beside the cards (#1767): decisions first, then the meetings that reached one, and // a meeting still open or blocked listed apart — never as a decision. Every row is text the host // already answered; the only thing this reads again is the hash it was handed. -function ProjectBoardRecord({ record }) { +// #2041: a decision row opens. The row is a button, so it is reachable with a keyboard and by +// activation; one row is open at a time, and the text under it is the host's answer to that one +// name — the board's own listing, never a second read of the folder. +function ProjectBoardRecord({ record, projectId, call }) { const { decisions, meetings } = record - const decisionRows = decisions.files.map(file => h('div', { key: file.key, className: 'bt-project-decision', 'data-path': file.path, 'data-sha256': file.sha256 }, + const [open, setOpen] = useState(null) + const toggle = (path) => { + if (open !== null && open.path === path) { setOpen(null); return } + if (typeof call !== 'function') return + setOpen({ path, answer: null }) + // An answer that lands after another row was opened must not paint this one: it is kept only + // while its own row is the open one. + const settle = (answer) => setOpen(current => (current !== null && current.path === path ? { path, answer } : current)) + call('project-decision', { id: projectId, path }).then(settle, error => settle({ ok: false, reason: String(error?.message ?? error) })) + } + const decisionBody = (file) => { + if (open === null || open.path !== file.path) return null + const answer = open.answer + if (answer === null) return h('div', { className: 'bt-project-decision-text', 'data-open-decision': file.path }, t('Reading the decision…')) + // `absent` and `too large` read as the sentence the host sent, the same words `project_state` + // reports them in; the text of a decision is drawn as text, never as markup it contains. + if (answer.ok !== true) return h('div', { className: 'bt-project-decision-text bt-project-decision-refusal', 'data-open-decision': file.path, 'data-refused': 'true' }, text(answer.reason) || t('not listed')) + return h('pre', { className: 'bt-project-decision-text', 'data-open-decision': file.path }, typeof answer.text === 'string' ? answer.text : '') + } + const decisionRows = decisions.files.map(file => h('div', { key: file.key, className: 'bt-project-decision-wrap' }, + h('button', { + type: 'button', + className: 'bt-project-decision', + 'data-path': file.path, + 'data-sha256': file.sha256, + 'data-open': open !== null && open.path === file.path ? 'true' : undefined, + 'aria-expanded': open !== null && open.path === file.path ? 'true' : 'false', + onClick: () => toggle(file.path), + }, h('span', { className: 'bt-project-date' }, file.date), h('span', { className: 'bt-project-title' }, file.title === '' ? file.name : file.title), - h('span', { className: 'bt-project-hash', title: file.sha256 }, file.shortHash))) + h('span', { className: 'bt-project-hash', title: file.sha256 }, file.shortHash)), + decisionBody(file))) const recipientText = list => (list.length === 0 ? null : list.map(name => `@${name}`).join(', ')) // A proxy line reads in the same words a task row already uses for a proxy holder. const proxyText = meeting => meeting.participants @@ -763,7 +803,7 @@ export function ProjectAgentEntry({ projectId, read, onOpen } = {}) { }, t('My agent')) } -export function ProjectBoardPane({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics, onOpen, onSettings, onMembers, onOpenBot, onOpenDossier }) { +export function ProjectBoardPane({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics, onOpen, onSettings, onMembers, onOpenBot, onOpenDossier, call }) { const view = projectBoardView({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics }) return h('div', { className: 'bt-project-board', 'data-can-manage': view.canManage ? 'true' : 'false' }, h('h3', null, view.title), @@ -785,7 +825,7 @@ export function ProjectBoardPane({ project, rows, done, canManage, tasks, decisi h(ProjectBoardRows, { view, onOpen }), h(ProjectQueueGroups, { view, onOpen }), h(ProjectDoneGroup, { view, onOpen }), - h(ProjectBoardRecord, { record: view.record })) + h(ProjectBoardRecord, { record: view.record, projectId: view.projectId, call })) } // #1749: the settings panel. It writes through the three paths the host already had — bind or @@ -1079,6 +1119,8 @@ export function ProjectBoardOverlay({ actions, call: request } = {}) { : h(ProjectBoardPane, { project: state.project, rows: state.rows, done: state.done, canManage: state.canManage, tasks: state.tasks, decisions: state.decisions, meetings: state.meetings, queues: state.queues, metrics: state.metrics, team: state.team, + // #2041: a decision row opens through the same channel read the page polls with. + call: read, onSettings: () => setPanel('settings'), onMembers: () => setPanel('members'), // #1864: a team row's Dossier control opens the read-only panel, fed by the same read. diff --git a/core/src/host/authz.js b/core/src/host/authz.js index 44e1f546a..b63b4b218 100644 --- a/core/src/host/authz.js +++ b/core/src/host/authz.js @@ -69,6 +69,9 @@ const TASK_ENDPOINTS = new Set([ // other wiring point was present, and a signed-in host answered `bot/not-found: Unknown // endpoint project-board` while `project-read` and `project-of` answered ALLOW. 'projects', 'project-create', 'project-read', 'project-bind', 'project-of', 'project-board', + // #2041: the text of one decision row. It reads the same Project through the same listing the + // board just produced, so it sits beside `project-board` rather than behind a new gate. + 'project-decision', // The team dossier (#1864) is a read over the same Project and the same bindings: the owner // decided on 2026-10-30 that persona and memory are shared by the Project's members, so it sits // beside `project-read` rather than behind the owner gate. diff --git a/core/src/host/channel.js b/core/src/host/channel.js index 0d24db49f..4bca04753 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -675,6 +675,7 @@ export function install(rt) { case 'project-bind': case 'project-of': case 'project-board': + case 'project-decision': case 'project-team': case 'project-role': case 'project-delete': diff --git a/core/src/host/project-board.js b/core/src/host/project-board.js index d031b58b4..c6f55608d 100644 --- a/core/src/host/project-board.js +++ b/core/src/host/project-board.js @@ -19,7 +19,7 @@ import { open } from 'node:fs/promises' import { join } from 'node:path' import { columnOf } from '../shared/task-board.js' -import { DECISIONS_DIR, describeFile, isDecisionPath, listProjectState } from './project-state.js' +import { DECISIONS_DIR, describeFile, isDecisionPath, listProjectState, readListedFile } from './project-state.js' import { cut } from './text.js' import { advanceRefusal } from './meetings.js' import { readMetrics } from './project-metrics.js' @@ -221,6 +221,10 @@ export function decisionCardOf(meeting) { } export function install(rt) { + // #2041: the decision names the board listed, per Project, with the folder each name was read + // from. A render rewrites its Project's entry; nothing else writes here, and a name outside it + // is refused unread. + const readings = new Map() // The folder a Project's decisions live in. A Project names one itself, and when it names none // the folder is the one its members work in — the same folder a meeting resolves through // `workDirFor` before it archives a decision, so the reader and the writer look in one place. @@ -279,13 +283,20 @@ export function install(rt) { .map(file => ({ ...file, dir }))) } // The other source: the folder each decided meeting of this Project recorded, one file each. + // #2041: a name that came from a meeting keeps that meeting's own cards, because opening the + // file is checked against the same rule the board paints those cards with. + const cardsOf = new Map() for (const meeting of Object.values(rt.state?.meetings ?? {})) { if (meeting === null || typeof meeting !== 'object' || meeting.status !== 'decided') continue if (!inProject(meeting, project)) continue const archivedDir = text(meeting.decisionDir) if (archivedDir !== '' && !folders.includes(archivedDir)) folders.push(archivedDir) const archived = await archivedOf(meeting) - if (archived !== undefined) found.push(archived) + if (archived === undefined) continue + found.push(archived) + if (!cardsOf.has(archived.path)) { + cardsOf.set(archived.path, Array.isArray(meeting.taskIds) ? meeting.taskIds.map(id => text(id)).filter(id => id !== '') : []) + } } // One row per path — the client keys a row by path, and the Project folder was read first. const seen = new Set() @@ -324,7 +335,9 @@ export function install(rt) { // list from a truncated one instead of reading the cap as the answer. // #2033: the un-truncated dedupe of this read, paths only — the meeting lookup below has to // tell "fell off the 200-row cut" from "was never here" without asking the disk again. - return { dir: dir === '' ? null : dir, files, total: deduped.length, reason, known: new Set(deduped.map(file => file.path)) } + // #2041: each name keeps the folder it was read from, because a decided meeting archives to a + // folder of its own; opening a row reads through this map and nothing else. + return { dir: dir === '' ? null : dir, files, total: deduped.length, reason, known: new Map(deduped.map(file => [file.path, file.dir])), cards: cardsOf } } // #1831: the card's level, who it is addressed to and whether it may advance ride on every @@ -492,6 +505,13 @@ export function install(rt) { ? { waiting: [], inflight: [], blocked: [], counts: { waiting: null, inflight: null, blocked: null, done: null } } : buildQueues(mine, done.length) const decisions = await decisionsOf(project) + // #2041: the set an opened row is checked against is this read's own listing, kept per Project + // because a page polls this read every few seconds. Opening a decision never lists the folder + // again: it reads one of these names, or answers `not listed` and touches nothing. + // #2041: names and folders only — never an actor's view of them. Visibility is computed per + // call in `projectDecision`, so one member reading the board cannot hand its own reach to the + // next caller, and revoking a card takes effect on the next open. + readings.set(id, { dirs: decisions.known, cards: decisions.cards }) // `known` is this read's own path set for the meeting lookup (#2033); the page renders // `dir`/`files`/`total`/`reason`, so it does not travel with the board. const servedDecisions = { dir: decisions.dir, files: decisions.files, total: decisions.total, reason: decisions.reason } @@ -540,6 +560,48 @@ export function install(rt) { rt.projectRoleOf = (project, memberId) => roleOf(project, memberId) rt.projectRoleChoices = (project, memberId) => roleChoices(project, memberId) rt.projectBoard = projectBoard + // #2041: the text behind one decision row. The accepted set is the listing this host produced for + // that Project — the names the 200-row cut dropped included — so a name outside it answers + // `not listed` without a stat, an open or a byte of content: opening a row is never a second, + // wider read path beside `readProjectState`. The name shape and the `lstat` read are #2038's own; + // this adds the membership check and nothing else. + // #2041: who may open a listed name. The text is behind the same door as the cards (#1723), and + // the two kinds of name have two doors: one a decided meeting named follows that meeting's own + // cards through `rt.taskVisible`, the rule the board paints them with, while a plain record has + // no card and follows the Project's role list (`roleOf`). Neither branch widens when it cannot + // judge: a missing predicate refuses rather than allows. + const decisionVisible = (project, actor, taskIds) => { + // One human on this host: the rule every task read already uses hides nothing from them. + if (rt.authzEnforced?.() !== true) return true + const role = roleOf(project, actor?.memberId) + if (taskIds === undefined) return role !== null + const cards = taskIds.map(id => rt.state?.tasks?.[id]).filter(task => task !== null && typeof task === 'object') + // A decided meeting that produced no card has no card to judge, so the role list decides. + if (cards.length === 0) return role !== null + const visible = typeof rt.taskVisible === 'function' ? rt.taskVisible : null + if (visible === null) return false + return cards.some(task => visible(task, actor)) + } + rt.projectDecision = async (projectId, file, actor) => { + const name = text(file) + if (!isDecisionPath(name)) return { ok: false, reason: `not listed: ${name} is not a decision file` } + const id = text(projectId) + const project = rt.readProject?.(id) + if (project === null || project === undefined) return { ok: false, reason: `no Project: ${id}` } + // A name this caller may not open answers exactly what a name the listing never had answers: + // one sentence for both, so the refusal cannot be used as a list of what exists. + const listed = { ok: false, reason: `not listed: ${name} is not in this board's decision list` } + const entry = readings.get(id) + if (entry === undefined) return listed + // #2041: visibility is read from the caller and the state as they are now, in memory, before + // any `stat` or `open` — a board read never froze one member's reach into this cache. + if (!decisionVisible(project, actor, entry.cards.get(name))) return listed + const dir = entry.dirs.get(name) + if (dir === undefined) return listed + // A file that left the folder since the listing answers `absent`; one over the bound answers + // `too large` with the size and no text. + return await readListedFile(dir, name) + } // The three groups are their own read over the same pass, for a caller that wants only the queue. rt.projectBoardQueues = async (projectId, actor) => { const board = await projectBoard(projectId, actor) diff --git a/core/src/host/project-state.js b/core/src/host/project-state.js index 30bf9ab58..1159e85a9 100644 --- a/core/src/host/project-state.js +++ b/core/src/host/project-state.js @@ -101,9 +101,16 @@ export async function readProjectState(dir, file) { return { ok: false, reason: `not listed: ${path} is not a Project State file` } } if (!await repositoryAt(dir)) return { ok: false, reason: `not a repository: ${dir}` } + return readListedFile(dir, path) +} + +// The bytes of one file whose name the caller already accepted as listed, bounded at `MAX_BYTES`. +// `project_state` and the board's opened decision share this read (#2041), so the bound and the +// symlink rule cannot drift between the two: a file that vanished since the listing, or one +// replaced by a symlink, answers `absent` rather than the text it points at. +export async function readListedFile(dir, path) { let info try { - // A symlink answers like a file that is not there — never with the text it points at. info = await fileAt(dir, path) } catch { return { ok: false, reason: `absent: ${path}` } diff --git a/core/src/host/store.js b/core/src/host/store.js index 4dda6c8fc..bd49b36e1 100644 --- a/core/src/host/store.js +++ b/core/src/host/store.js @@ -735,6 +735,14 @@ export function install(rt) { // with persona, memory head/topics and skills. The read lives in project-team.js; a missing // Bot is a `null` row there, never an error for the whole answer. case 'project-team': return rt.projectTeam?.(projectText(body.id, 200)) ?? { rows: [] } + // #2041: the text behind one decision row. The board's own listing is the accepted set, so a + // name it did not produce answers `not listed` and is never opened; the actor goes with the + // id, because who may read that text is decided per call (`projectDecision`). + case 'project-decision': { + const project = requireProject(projectText(body.id, 200)) + const path = projectText(body.path, 400) + return await rt.projectDecision?.(project.id, path, actor) ?? { ok: false, reason: `not listed: ${path}` } + } // The read-only board (#1696): Project id in, its tasks out. The aggregate lives in // project-board.js; it reads and returns, and writes nothing. The actor goes with the id // so the board applies the same visibility rule `tasks` does (#1723). diff --git a/core/test/project-board-view.test.js b/core/test/project-board-view.test.js index e8781c3e8..55066e26b 100644 --- a/core/test/project-board-view.test.js +++ b/core/test/project-board-view.test.js @@ -755,3 +755,181 @@ test('#2021: no total, or one that matches, draws no line — and each meeting l })) assert.deepEqual(two.filter(node => node.props?.className === 'bt-project-more').map(textOf), ['103 more not shown', '100 more not shown']) }) + +// #2041: a decision row opens. Activating it asks the host for that one name and draws the text +// under the row; a second activation collapses it, and a refusal reads as its own sentence — +// never as markup the answer happens to contain. +const openableRecord = (call) => ({ + project: { id: 'project-1', name: 'FlyBot' }, + rows, + canManage: false, + decisions: { + dir: '/team/flybot', + files: [ + { path: 'decisions/2026-10-30-adopt.md', name: '2026-10-30-adopt.md', date: '2026-10-30', title: 'Adopt the board record', sha256: 'a'.repeat(64), mtime: '2026-10-30T01:00:00.000Z' }, + { path: 'decisions/2026-10-31-second.md', name: '2026-10-31-second.md', date: '2026-10-31', title: 'Keep decisions small', sha256: 'b'.repeat(64), mtime: '2026-10-31T01:00:00.000Z' }, + ], + reason: '', + }, + meetings: { decided: [], pending: [], reason: '' }, + call, +}) + +const drawPane = () => all(render(ProjectBoardPane, openablePaneProps)) +const rowAt = (path) => drawPane().find(node => node.props?.['data-path'] === path) +const openedAt = (path) => all(render(ProjectBoardPane, openablePaneProps)).filter(node => node.props?.['data-open-decision'] === path) +let openablePaneProps = null + +test('#2041: activating a decision row asks for that row and draws its text under it', async () => { + slots = [] + effects = [] + index = 0 + const calls = [] + const call = async (endpoint, payload) => { + calls.push({ endpoint, payload }) + return { ok: true, path: payload.path, text: '# Adopt the board record\n\nThe board lists this file.\n' } + } + openablePaneProps = openableRecord(call) + render(ProjectBoardPane, openablePaneProps) + const row = rowAt('decisions/2026-10-30-adopt.md') + assert.equal(row.type, 'button', 'the decision row is a button, not an inert div') + row.props.onClick() + await new Promise(resolve => setTimeout(resolve, 0)) + assert.deepEqual(plain(calls), [{ endpoint: 'project-decision', payload: { id: 'project-1', path: 'decisions/2026-10-30-adopt.md' } }]) + assert.match(drawPane().map(textOf).join(' | '), /The board lists this file/, 'the text is drawn under the row') + assert.equal(openedAt('decisions/2026-10-30-adopt.md').length, 1, 'the open row is identifiable') +}) + +test('#2041: a second activation collapses the open decision, one row at a time', async () => { + slots = [] + effects = [] + index = 0 + const calls = [] + const call = async (endpoint, payload) => { + calls.push({ endpoint, payload }) + return { ok: true, path: payload.path, text: `text of ${payload.path}\n` } + } + openablePaneProps = openableRecord(call) + render(ProjectBoardPane, openablePaneProps) + rowAt('decisions/2026-10-30-adopt.md').props.onClick() + await new Promise(resolve => setTimeout(resolve, 0)) + assert.equal(openedAt('decisions/2026-10-30-adopt.md').length, 1) + // The other row replaces it: one row open at a time. + rowAt('decisions/2026-10-31-second.md').props.onClick() + await new Promise(resolve => setTimeout(resolve, 0)) + assert.equal(openedAt('decisions/2026-10-30-adopt.md').length, 0, 'the first row closed') + assert.equal(openedAt('decisions/2026-10-31-second.md').length, 1, 'the second row is the open one') + // The same row again closes it, and closing asks the host nothing. + rowAt('decisions/2026-10-31-second.md').props.onClick() + await new Promise(resolve => setTimeout(resolve, 0)) + assert.equal(openedAt('decisions/2026-10-31-second.md').length, 0, 'the second activation collapsed it') + assert.equal(calls.length, 2, 'collapsing read nothing') +}) + +test('#2041: each refused answer reads its own sentence, never the markup it carries', async () => { + slots = [] + effects = [] + index = 0 + // The reader's three refusals, in the words the host sends them. + const answers = [ + 'too large: decisions/2026-10-30-adopt.md is 250000 bytes (bound 200000)', + "not listed: decisions/2026-10-30-adopt.md is not in this board's decision list", + 'absent: decisions/2026-10-30-adopt.md', + ] + const call = async () => ({ ok: false, reason: answers.shift() }) + openablePaneProps = openableRecord(call) + render(ProjectBoardPane, openablePaneProps) + const path = 'decisions/2026-10-30-adopt.md' + const sentences = [ + 'too large: decisions/2026-10-30-adopt.md is 250000 bytes (bound 200000)', + "not listed: decisions/2026-10-30-adopt.md is not in this board's decision list", + 'absent: decisions/2026-10-30-adopt.md', + ] + for (const [index_, sentence] of sentences.entries()) { + rowAt(path).props.onClick() + await new Promise(resolve => setTimeout(resolve, 0)) + const opened = openedAt(path) + assert.equal(opened.length, 1, `answer ${index_ + 1} is drawn once`) + assert.equal(textOf(opened[0]), sentence, 'the reason is text, whole') + assert.equal(opened[0].props['data-refused'], 'true') + // The row stays a row: a refusal is an answer beside the list, not an error banner replacing it. + assert.equal(rowAt(path).type, 'button') + // Closing reads nothing, and the next activation asks again. + rowAt(path).props.onClick() + await new Promise(resolve => setTimeout(resolve, 0)) + assert.equal(openedAt(path).length, 0, 'a second activation collapses the refusal') + } + assert.equal(drawPane().some(node => node.type === 'script'), false, 'nothing in an answer becomes an element') +}) + +// #2041: two rows, two answers, and the order they land in is not the order they were asked in. +// The record keeps the answer only while its own row is the open one, so a late answer to a row the +// reader already left is dropped instead of painted under the row now open. +test('#2041: an answer that lands after another row opened is never drawn', async () => { + slots = [] + effects = [] + index = 0 + const waiting = new Map() + const call = (endpoint, payload) => new Promise(resolve => waiting.set(payload.path, resolve)) + openablePaneProps = openableRecord(call) + render(ProjectBoardPane, openablePaneProps) + const first = 'decisions/2026-10-30-adopt.md' + const second = 'decisions/2026-10-31-second.md' + rowAt(first).props.onClick() + rowAt(second).props.onClick() + // The first row's answer arrives now, after the second row became the open one. + waiting.get(first)({ ok: true, path: first, text: '# An answer nobody is waiting for\n' }) + await new Promise(resolve => setTimeout(resolve, 0)) + const nodes = all(render(ProjectBoardPane, openablePaneProps)) + const drawn = nodes.map(textOf).join(' | ') + assert.equal(/An answer nobody is waiting for/.test(drawn), false, 'the late answer is not drawn') + const open = nodes.filter(node => node.props && node.props['data-open-decision']) + assert.equal(open.length, 1, 'one row is open') + assert.equal(open[0].props['data-open-decision'], second, 'the block belongs to the row now open') + assert.equal(/Reading the decision/.test(textOf(open[0])), true, 'it still waits for its own answer') + // The second row's own answer lands in its own block, and nothing else appears. + waiting.get(second)({ ok: true, path: second, text: '# The answer this row asked for\n' }) + await new Promise(resolve => setTimeout(resolve, 0)) + const after = all(render(ProjectBoardPane, openablePaneProps)) + assert.equal(textOf(openedAt(second)[0]), '# The answer this row asked for\n') + assert.equal(/An answer nobody is waiting for/.test(after.map(textOf).join(' | ')), false) +}) + +// #2041: the wire from the page to the panel. `ProjectBoardOverlay` passes its own read down as +// `call`; if that line goes, the panel's `call` is undefined and an activation returns silently — +// nothing happens and nothing says why. So the activation is driven from the overlay, not by +// handing the panel a fake `call` directly. +test('#2041: the overlay hands the board its own read, so a row opens through it', async () => { + resetClock() + win.location.hash = '#project' + const path = 'decisions/2026-10-30-adopt.md' + const asked = [] + const call = async (name, payload) => { + if (name === 'project-decision') asked.push({ name, payload }) + if (name === 'projects') return { projects: [{ id: 'project-1', name: 'FlyBot' }] } + if (name === 'project-read') return { project: { id: 'project-1', name: 'FlyBot' }, canManage: false } + if (name === 'project-board') { + return { + tasks: [], + waiting: [], inflight: [], blocked: [], counts: { waiting: 0, inflight: 0, blocked: 0, done: 0 }, + decisions: { + dir: '/team/flybot', reason: '', total: 1, + files: [{ path, name: '2026-10-30-adopt.md', date: '2026-10-30', title: 'Adopt the board record', sha256: 'a'.repeat(64), mtime: '2026-10-30T01:00:00.000Z' }], + }, + meetings: { decided: [], pending: [], reason: '' }, + } + } + if (name === 'tasks') return [] + if (name === 'project-team') return { rows: [] } + if (name === 'project-decision') return { ok: true, path: payload.path, text: '# Opened through the overlay\n' } + return {} + } + const { tree, redraw } = await mount({ actions: {}, call }) + const button = all(tree).find(node => node.type === 'button' && node.props && node.props['data-path'] === path) + assert.ok(button, 'the board draws the decision row') + assert.equal(typeof button.props.onClick, 'function', 'the row is activatable') + button.props.onClick() + await new Promise(resolve => setTimeout(resolve, 0)) + assert.deepEqual(plain(asked), [{ name: 'project-decision', payload: { id: 'project-1', path } }], 'the overlay own read carried the ask') + assert.match(textIn(redraw()), /Opened through the overlay/, 'the answer is drawn under the row') +}) diff --git a/core/test/project-board.test.js b/core/test/project-board.test.js index c64348ed1..0ebc93df5 100644 --- a/core/test/project-board.test.js +++ b/core/test/project-board.test.js @@ -8,6 +8,7 @@ import { join, resolve } from 'node:path' import { test } from 'node:test' import { createRuntime, provideFlyBot } from '../index.js' import { blockedOf } from '../src/host/project-board.js' +import { MAX_BYTES } from '../src/host/project-state.js' import { fakeContext } from './fake-ctx.js' const seed = () => ({ @@ -1103,3 +1104,269 @@ test('#2021: the meetings read reports how many of each list it found', async () assert.equal(board.meetings.pendingTotal, 103, 'the pending list says how many it found') } finally { await stop() } }) + +// #2041: a decision row can be opened. The board already listed the file; opening it asks the host +// for that one name, and the host reads only a name its own listing produced — `decisions/..`, +// an absolute path, a name one level down and a symlink are refused before any stat. +const folderOpenable = () => { + const base = mkdtempSync(join(tmpdir(), 'ds-bot-decisions-open-')) + const dir = join(base, 'project') + mkdirSync(join(dir, '.git'), { recursive: true }) + mkdirSync(join(dir, 'decisions')) + mkdirSync(join(dir, 'decisions', 'sub')) + writeFileSync(join(dir, 'decisions', '2026-10-30-adopt.md'), '# Adopt the board record\n\nThe board lists this file.\n') + writeFileSync(join(dir, 'decisions', '2026-10-30-big.md'), `# Big\n\n${'x'.repeat(MAX_BYTES + 1)}\n`) + writeFileSync(join(dir, 'decisions', 'sub', '2026-10-30-deep.md'), '# One level down\n') + // `decisions/../outside.md` resolves here. It is over the bound on purpose: a host that stat'd it + // would answer `too large`, so `not listed` is the proof the name was refused before the disk. + writeFileSync(join(dir, 'outside.md'), `# Outside the folder\n\n${'y'.repeat(MAX_BYTES + 1)}\n`) + // A legal name standing for a file outside the folder: the listing skips it, so opening answers + // as if the file were not there, never with the target's text. + symlinkSync(join(dir, 'outside.md'), join(dir, 'decisions', '2026-10-30-link.md')) + const absolute = join(base, 'secret.md') + writeFileSync(absolute, '# A file outside the Project folder\n') + return { base, dir, absolute } +} + +const openableSeed = (dir) => { + const team = seed() + team.projects['project-p1'].workDir = dir + return team +} + +async function startOpenable() { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-board-')) + const { base, dir, absolute } = folderOpenable() + const started = await start(home, openableSeed(dir)) + return { ...started, home, base, dir, absolute } +} + +test('#2041: a decision the board listed opens with its text', async () => { + const { rt, call, base, stop } = await startOpenable() + try { + // The page reads the board first; the set opening is checked against is that read's own. + const board = await call('project-board', { id: 'project-p1' }) + assert.equal(board.ok, true, JSON.stringify(board)) + assert.equal(board.value.decisions.files.some(file => file.path === 'decisions/2026-10-30-adopt.md'), true) + const opened = await call('project-decision', { id: 'project-p1', path: 'decisions/2026-10-30-adopt.md' }) + assert.equal(opened.ok, true, JSON.stringify(opened)) + assert.equal(opened.value.text, '# Adopt the board record\n\nThe board lists this file.\n') + assert.match(opened.value.sha256, /^[0-9a-f]{64}$/) + assert.equal(opened.value.path, 'decisions/2026-10-30-adopt.md') + // The declared read is the same answer: one rule, one place. + const direct = await rt.projectDecision('project-p1', 'decisions/2026-10-30-adopt.md') + assert.equal(direct.text, opened.value.text) + } finally { + await stop() + rmSync(base, { recursive: true, force: true }) + } +}) + +test('#2041: a name the board did not list answers not listed and is never read', async () => { + const { rt, base, absolute, stop } = await startOpenable() + try { + await rt.projectBoard('project-p1') + const refused = [ + ['decisions/../outside.md', 'a name that leaves decisions/'], + [absolute, 'an absolute path'], + ['decisions/sub/2026-10-30-deep.md', 'a name one level down'], + ['decisions/2026-10-30-link.md', 'a symlink with a legal name'], + ] + for (const [path, why] of refused) { + const answer = await rt.projectDecision('project-p1', path) + assert.equal(answer.ok, false, `${why} must not open`) + assert.match(answer.reason, /^not listed: /, `${why} answers not listed`) + assert.equal('text' in answer, false, `${why} carries no text`) + } + // `decisions/../outside.md` is over the bound: `too large` would prove a stat happened. + const escape = await rt.projectDecision('project-p1', 'decisions/../outside.md') + assert.equal(/too large/.test(escape.reason), false, 'the size was never measured') + assert.equal(existsSync(join(base, 'project', 'outside.md')), true, 'the file is there to be read') + } finally { + await stop() + rmSync(base, { recursive: true, force: true }) + } +}) + +test('#2041: a listed decision over the bound answers too large and hands over no text', async () => { + const { rt, base, stop } = await startOpenable() + try { + const board = await rt.projectBoard('project-p1') + assert.equal(board.decisions.files.some(file => file.path === 'decisions/2026-10-30-big.md'), true, 'the row is listed') + const answer = await rt.projectDecision('project-p1', 'decisions/2026-10-30-big.md') + assert.equal(answer.ok, false) + assert.match(answer.reason, /^too large: /) + assert.match(answer.reason, new RegExp(`bound ${MAX_BYTES}`)) + assert.equal('text' in answer, false, 'no partial text is returned') + } finally { + await stop() + rmSync(base, { recursive: true, force: true }) + } +}) + +test('#2041: a decision past the 200-row cut still opens', async () => { + const { rt, base, stop } = await startPastCap() + try { + const board = await rt.projectBoard('project-p1') + assert.equal(board.decisions.files.some(file => file.path === 'decisions/2026-01-02-000.md'), false, 'the row is outside the cut') + const answer = await rt.projectDecision('project-p1', 'decisions/2026-01-02-000.md') + assert.equal(answer.ok, true, JSON.stringify(answer)) + assert.equal(answer.text, '# Old decision 0\n') + } finally { + await stop() + rmSync(base, { recursive: true, force: true }) + } +}) + +test('#2041: a decision deleted after the board listed it answers absent', async () => { + const { rt, base, dir, stop } = await startOpenable() + try { + const board = await rt.projectBoard('project-p1') + assert.equal(board.decisions.files.some(file => file.path === 'decisions/2026-10-30-adopt.md'), true) + rmSync(join(dir, 'decisions', '2026-10-30-adopt.md')) + const answer = await rt.projectDecision('project-p1', 'decisions/2026-10-30-adopt.md') + assert.equal(answer.ok, false) + assert.equal(answer.reason, 'absent: decisions/2026-10-30-adopt.md') + assert.equal('text' in answer, false) + } finally { + await stop() + rmSync(base, { recursive: true, force: true }) + } +}) + +test('#2041: a decision a meeting archived opens from the folder the listing read it in', async () => { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-board-')) + const base = mkdtempSync(join(tmpdir(), 'ds-bot-decisions-archive-')) + const dir = join(base, 'project') + const archive = join(base, 'room') + mkdirSync(join(dir, '.git'), { recursive: true }) + mkdirSync(join(dir, 'decisions')) + mkdirSync(join(archive, 'decisions'), { recursive: true }) + // The file exists only in the folder the meeting recorded, never in the Project folder. + writeFileSync(join(archive, 'decisions', '2026-10-29-archived.md'), '# Archived by a meeting\n') + const team = seed() + team.projects['project-p1'].workDir = dir + team.meetings = { + 'meeting-archived': { + id: 'meeting-archived', status: 'decided', question: 'Where did this one land?', owner: 'Chief', + project: 'project-p1', decisionDir: archive, decisionFile: 'decisions/2026-10-29-archived.md', + createdAt: 1, updatedAt: 2, decidedAt: 3, + }, + } + const { rt, stop } = await start(home, team) + try { + const board = await rt.projectBoard('project-p1') + assert.equal(board.decisions.dir, dir, 'the board still names the Project folder as its own') + assert.equal(board.decisions.files.some(file => file.path === 'decisions/2026-10-29-archived.md'), true, 'the archived file is a row') + const answer = await rt.projectDecision('project-p1', 'decisions/2026-10-29-archived.md') + assert.equal(answer.ok, true, JSON.stringify(answer)) + assert.equal(answer.text, '# Archived by a meeting\n', 'read from the folder the listing read it in, not the Project folder') + } finally { + await stop() + rmSync(base, { recursive: true, force: true }) + } +}) + +// #2041: opening a decision is behind the same door as its cards (#1723). The board already answers +// `tasks: []` for a member who cannot see a Project's cards; the text of its decisions must not +// answer instead. A plain record follows the Project's role list; a name a decided meeting archived +// follows that meeting's own cards. Both refuse with the sentence a missing name answers, so the +// refusal cannot be read as a list of what exists. +const visibleSeed = (dir) => { + const team = crossSeed() + team.projects['project-p2'].workDir = dir + team.meetings = { + 'meeting-decided': { + id: 'meeting-decided', status: 'decided', question: 'What shipped?', owner: 'Writer', + project: 'project-p2', decisionDir: dir, decisionFile: 'decisions/2026-11-01-decided.md', + taskIds: ['task-b1'], createdAt: 1, updatedAt: 2, decidedAt: 3, + }, + } + return team +} + +test('#2041: a decision text is answered only to a caller who can see the Project', async () => { + const base = mkdtempSync(join(tmpdir(), 'ds-bot-decisions-visible-')) + const dir = join(base, 'project') + mkdirSync(join(dir, '.git'), { recursive: true }) + mkdirSync(join(dir, 'decisions')) + writeFileSync(join(dir, 'decisions', '2026-11-01-decided.md'), '# P2 decided this\n') + writeFileSync(join(dir, 'decisions', '2026-11-01-plain.md'), '# A plain record\n') + // Over the bound on purpose: a host that stat'd it would answer `too large`, so `not listed` is + // the proof that a caller who may not read it never reached the disk. + writeFileSync(join(dir, 'decisions', '2026-11-01-secret.md'), `# Secret\n\n${'s'.repeat(MAX_BYTES + 1)}\n`) + const { rt, callAs, stop } = await start(undefined, visibleSeed(dir)) + try { + rt.authenticate = async token => (token === 'tok-1' ? { memberId: 'owner-1' } : token === 'tok-2' ? { memberId: 'owner-2' } : null) + rt.state.authSessions = { 's-1': { memberId: 'owner-1', tokenHash: 'a' }, 's-2': { memberId: 'owner-2', tokenHash: 'b' } } + // The board the two members read: owner-1 sees no card of this Project at all. + const theirs = await callAs('project-board', { id: 'project-p2' }, 'tok-1') + assert.equal(theirs.ok, true, JSON.stringify(theirs)) + assert.deepEqual(theirs.value.tasks ?? [], [], 'owner-1 sees no card of this Project') + // Member B, whose Bot owns the card and the Project, opens the meeting's decision. + await callAs('project-board', { id: 'project-p2' }, 'tok-2') + const own = await callAs('project-decision', { id: 'project-p2', path: 'decisions/2026-11-01-decided.md' }, 'tok-2') + assert.equal(own.ok, true, JSON.stringify(own)) + assert.equal(own.value.text, '# P2 decided this\n', 'the meeting card is visible to its owner') + // The plain record has no card: the Project's own role list decides, and owner-2 owns it. + const plain = await callAs('project-decision', { id: 'project-p2', path: 'decisions/2026-11-01-plain.md' }, 'tok-2') + assert.equal(plain.ok, true, JSON.stringify(plain)) + assert.equal(plain.value.text, '# A plain record\n') + // Owner-1 is neither in the Project's role list nor the owner of a card: every name refuses, + // in the words a name the listing never had answers. + for (const path of ['decisions/2026-11-01-decided.md', 'decisions/2026-11-01-plain.md', 'decisions/2026-11-01-secret.md']) { + const answer = await callAs('project-decision', { id: 'project-p2', path }, 'tok-1') + assert.equal(answer.ok, true, JSON.stringify(answer)) + assert.equal(answer.value.ok, false, `${path} must not open for a caller who sees no card`) + assert.equal(answer.value.reason, `not listed: ${path} is not in this board's decision list`) + assert.equal('text' in answer.value, false, `${path} carries no text`) + assert.equal('sha256' in answer.value, false, `${path} carries no hash`) + } + // The bound file is the proof: the caller who may read it is told its size, the caller who may + // not is never told anything the disk would have said. + const sized = await callAs('project-decision', { id: 'project-p2', path: 'decisions/2026-11-01-secret.md' }, 'tok-2') + assert.match(sized.value.reason, /^too large: /, 'a visible caller does reach the file') + } finally { + await stop() + rmSync(base, { recursive: true, force: true }) + } +}) + +test('#2041: revoking a card closes the name a board read already cached', async () => { + const base = mkdtempSync(join(tmpdir(), 'ds-bot-decisions-revoke-')) + const dir = join(base, 'project') + mkdirSync(join(dir, '.git'), { recursive: true }) + mkdirSync(join(dir, 'decisions')) + writeFileSync(join(dir, 'decisions', '2026-11-02-team.md'), '# Team decision\n') + const team = crossSeed() + team.projects['project-p1'].workDir = dir + team.meetings = { + 'meeting-team': { + id: 'meeting-team', status: 'decided', question: 'Who ships it?', owner: 'Chief', + project: 'project-p1', decisionDir: dir, decisionFile: 'decisions/2026-11-02-team.md', + taskIds: ['task-group'], createdAt: 1, updatedAt: 2, decidedAt: 3, + }, + } + const { rt, callAs, stop } = await start(undefined, team) + try { + rt.authenticate = async token => (token === 'tok-1' ? { memberId: 'owner-1' } : null) + rt.state.authSessions = { 's-1': { memberId: 'owner-1', tokenHash: 'a' } } + await callAs('project-board', { id: 'project-p1' }, 'tok-1') + const first = await callAs('project-decision', { id: 'project-p1', path: 'decisions/2026-11-02-team.md' }, 'tok-1') + assert.equal(first.ok, true, JSON.stringify(first)) + assert.equal(first.value.text, '# Team decision\n') + // The same cached read, after the card it was judged against moved to another owner and another + // room: the name is still in the listing, and the next open is judged again rather than served + // from the cache the first read left behind. + Object.assign(rt.state.tasks['task-group'], { ownerBotId: 'b', origin: { roomId: 'r2' } }) + const again = await callAs('project-board', { id: 'project-p1' }, 'tok-1') + assert.equal(again.value.decisions.files.some(file => file.path === 'decisions/2026-11-02-team.md'), true, 'the row is still listed') + const revoked = await callAs('project-decision', { id: 'project-p1', path: 'decisions/2026-11-02-team.md' }, 'tok-1') + assert.equal(revoked.value.ok, false, JSON.stringify(revoked)) + assert.equal(revoked.value.reason, "not listed: decisions/2026-11-02-team.md is not in this board's decision list") + assert.equal('text' in revoked.value, false) + } finally { + await stop() + rmSync(base, { recursive: true, force: true }) + } +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 669f5c1b2..60d691151 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -286,7 +286,13 @@ carries `total` on the decisions and `decidedTotal`/`pendingTotal` on the meetin `ProjectBoardRecord` draws one `bt-project-more` line reading `N more not shown` (还有 N 条未显示) under each capped list when that total is above the rows drawn — nothing when it is absent or equal, so a host that sends no total draws the section exactly as before; `data-decisions` keeps meaning -"rows drawn" beside `data-decisions-total`. +"rows drawn" beside `data-decisions-total`. Since #2041 a decision row is a `button` (`data-path` with +the `data-sha256` the host computed) and activating it — by click or keyboard, no `tabIndex` needed — +opens that one file under the row: `ProjectBoardRecord` keeps one open path, asks the host's +`project-decision` read for that name, and draws the answer as text under a `data-open-decision` block +(a `pre` for the body, the host's own sentence for `absent` / `too large` / `not listed`, never markup +the answer carries). Activating the same row again closes it, another row replaces it, and closing +reads nothing ([#2041](https://github.com/ThinkFlowLab/FlyBot/issues/2041)). The **Project settings** panel (#1749) saves through the three host write paths it draws — `project-bind`, `project-assistant-owner` and `project-memory-write` — and through no fourth. Its **Pause the daily digest** diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 778e2a61b..50db96f01 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -50,7 +50,7 @@ The server side of the patched `ds-bot` core (`core/`): the part of [ds-bot-core | `core/src/host/experience.js` | Experience library read API (#874 Step 2). `lookupExperience({ repo, techStack, query, root })` reads `core/dev/experience/`. Data and `check.mjs` stay under `core/dev/` (see [ds-bot-core](ds-bot-core.md)). Since #1899 the 600-character bound on `trigger`, `wrong` and `right` counts code points through `codePoints` (`text.js`), so an emoji costs one character, not two, and an entry written before this change whose text is over 600 UTF-16 units but within 600 characters still loads through the read path. | | `core/src/host/usage.js` | Usage ledger at `$DSH_HOME/bot/usage.sqlite` (`calls`). Ledger v3 (#903): every row carries `harness`, `issue_pr`, `tool_calls`, `wall_ms`, plus existing token columns (`withUsageFields` / `missingUsageFields`). Session turns are metered via `foldCalls` over turn events (`tool/call` count, wall time from attempt start, `issuePr` from turn text or `rt.noteUsageIssuePr(sessionId, ref)`), not from llm/stream alone. A triage tier is stamped with `rt.noteUsageHarness(sessionId, idOrLabels)` and lands on the row's `harness` field; the resume cursor (`marks.fold`) stays provider / model / retry. Sample row fixture: `core/test/fixtures/usage-sample.sqlite`. Tests: `core/test/usage-fields.test.js`, `core/test/usage.test.js`. | | `core/src/host/tool-schema.js` | The tool definitions the model sees: its `defineTool` wraps DSH's and completes the compiled schema with `explicitObjectShape`, which fills `properties: {}` and `required: []` on every object node (recursing through `properties`, `items`, `oneOf`/`anyOf`/`allOf`, `$defs`, …). A strict OpenAI-compatible gateway reads an absent member as null and rejects the whole request (`null is not of type "array"`), so one argument-less tool would break every turn; argument validation keeps using DSH's schema unchanged. Every host module registers tools through this wrapper. Tests: `core/test/tool-schema.test.js`. | -| `core/src/host/project-state.js` | The `project_state` tool (#1702, #1550 Stage 2.4): the Project State files of the Session working folder (`PROJECT.md`, `ROADMAP.md`, `docs/governance.md`, `decisions/*.md`, `LESSONS.md`), listed with byte size, sha256 content hash and last-modified time, or one file handed over whole up to 200,000 bytes. A caller compares the hash with the copy it already had, so a stale copy is visible instead of assumed. Answers `absent`, `too large` (with the size) or `not a repository` rather than an empty document, refuses any path outside that list, and reads nothing at install time, no prompt, system message or Session context is written by a call. Since #2037 a decision name has exactly one shape — one path segment under `decisions/`, ending in `.md` — and `isDecisionPath` is the one rule for it: the listing, this reader and the board take a name only when it matches, and both this reader and the board open a file through `describeFile`, which `lstat`s the name and refuses anything that is not a plain file, so a symlink answers `absent` — the shape a missing file already has — instead of the text it points at ([#2037](https://github.com/ThinkFlowLab/FlyBot/issues/2037)). Tests: `core/test/project-state.test.js`. | +| `core/src/host/project-state.js` | The `project_state` tool (#1702, #1550 Stage 2.4): the Project State files of the Session working folder (`PROJECT.md`, `ROADMAP.md`, `docs/governance.md`, `decisions/*.md`, `LESSONS.md`), listed with byte size, sha256 content hash and last-modified time, or one file handed over whole up to 200,000 bytes. A caller compares the hash with the copy it already had, so a stale copy is visible instead of assumed. Answers `absent`, `too large` (with the size) or `not a repository` rather than an empty document, refuses any path outside that list, and reads nothing at install time, no prompt, system message or Session context is written by a call. Since #2037 a decision name has exactly one shape — one path segment under `decisions/`, ending in `.md` — and `isDecisionPath` is the one rule for it: the listing, this reader and the board take a name only when it matches, and both this reader and the board open a file through `describeFile`, which `lstat`s the name and refuses anything that is not a plain file, so a symlink answers `absent` — the shape a missing file already has — instead of the text it points at; since #2041 the bounded byte read is its own export, `readListedFile(dir, path)`, because the board's opened decision reads through it too, so the 200,000-byte bound and the `lstat` rule cannot drift between the two readers ([#2037](https://github.com/ThinkFlowLab/FlyBot/issues/2037), [#2041](https://github.com/ThinkFlowLab/FlyBot/issues/2041)). Tests: `core/test/project-state.test.js`. | | `core/src/host/project-assistant.js` | The Project Assistant (#1712, #1550 Stage 2.5): one hidden Bot and Session per Project, created by `project-create` through the roster and refusing an unknown or archived id, with exactly one 09:00 Asia/Shanghai task on that Session through the host's own `schedule-create` path and removed with the Project by `project-delete` — never a cron inside the plugin, and no backfill for a Project made before this slice. Memory lives in `state.projectMemory[id]` (`{decisions, questions, digest}`, behind `rt.projectMemoryOf` / `appendProjectMemory` / `setProjectDigest` and the `project-memory` / `project-memory-write` endpoints) keyed by Project id, so one Project cannot read another's entries. Since #1847 that bound is enforced twice, from one value: `MAX_ENTRY` (600, in `constants.js`) is checked by the assistant writer and again by `appendProjectMemory`, the one write door, and since #1886 both count code points (`codePoints` in `src/host/text.js`, one helper since #1895), not UTF-16 units, so a 600-character emoji note is stored rather than refused as 1,200 units; the endpoint refuses an empty entry (`A Project memory entry needs text`) and one over the cap (`A Project memory entry is at most 600 characters`) instead of storing a blank row or cutting the text, and a refusal leaves the table and `project.updatedAt` untouched; the panel quotes either sentence, in Chinese under a zh locale (`core/src/client/i18n.js`). `buildDigest` answers, in order, what merged since the previous digest, what closed, what is still in progress and on whom, and the next slice, using only board rows (`source`/`column`/`assignee`/`updatedAt`) and the Project State file list; a day with no change is one line, one day has one note and one `deliver()`, and the second build answers `skipped`. Identity is the install's GitHub connector account (the owner's own on a single-person install): with no connector signed in, or on a multi-member host whose `projects.ownerUserId` is not this host's own user, `digest` refuses and escalates instead of posting as the shared app, and every write carries `Written-by: Project Assistant ()` and `Posted-for: `. Since #1733 the memory and the digest are per (Project, owner) — `state.projectMemory[projectId].owners[ownerId]` with the primary owner mirrored on the row, behind `rt.projectMemoryFor(id, ownerId)`, `appendProjectMemory(id, kind, entry)`, `setProjectDigest(id, note, ownerId)` — and one `projectBoard` read plus one `listProjectState` read serve one render per owner, so the facts are shared and the personalisation is not; a read outside `ownersOf(project)` is refused naming that boundary. Since #1846 the digest reads the Project's own board, not one member's page: `rt.projectBoard(id, null, { internal: true })` is the one opt-in that skips the per-reader `taskVisible` rule, and the channel builds its options from the zone alone, so no request body can ask for it. Tests: `core/test/project-assistant.test.js`, `core/test/project-digest-authz.test.js`. | | `core/src/host/project-chief.js` | The Project Chief (#1859): every Project gets its own Chief, created with the Project by `project-create` — never the user's main Chief acting part-time. `rt.projectChief` has the same shape as `rt.projectAssistant` (`create(projectId)`, `chiefOf(projectId)`, `remove(projectId)`), the record carries `chief: { botId, sessionId, createdAt }` beside `assistant`, and the spawned Bot is named `Project Chief ()`, bound with `bindProject` so it carries `projectId`, and given instructions that name that one Project and forbid answering for any other (another Project's name is never quoted). A host without the seam logs once and answers `undefined`, so `project-create` still creates the Project with `chief: { error }`; `project-delete` retires the Chief through `deleteBot` and clears its binding. Tests: `core/test/projects.test.js`. | | `core/src/host/project-team.js` | The Project's team dossier (#1864), read-only. `rt.projectTeam(projectId)` answers `{ rows }` in the board's order — the Project's Chief, its assistant, then `roles.maintainers` / `roles.members` — with each row `{ botId, name, role, persona, model, color, createdAt, memory: { head, topics }, skills, rooms }`. `persona` is the Bot's `instructions`; `memory.head` is its own `MEMORY.md` (read through `rt.memoryDigest` in `memory.js`, which also supplies the topic names and never a topic body); `skills` is the attached capabilities the library records as `skill` (a pack contributes its skill children); `rooms` is only the rooms this Project owns. Every string and list is capped (`DOSSIER_LIMITS`: persona and memory head 4000, 64 topics, 64 skills, 32 rooms), a deleted Bot is a `null` row instead of an error for the whole answer, and an unknown Project answers `{ rows: [] }`. The endpoint sits beside `project-read` in `authz.js`'s `TASK_ENDPOINTS`, per the owner decision that persona and memory are shared by the Project's members. Tests: `core/test/project-team.test.js`. | @@ -211,7 +211,7 @@ An admin's `@` used to be the room's control plane: every `@Dev #1234 rebase and ### Team state and access -- The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; an existing backup counts only when it is a readable file holding a parseable version-1 state, and anything else (or any malformed ownership) fails closed (read-only). An older binary refuses a newer version read-only. This is schema foundation only — team mode stays disabled, see [team](team.md). On top of it, a durable **Project** record (#1691): `state.projects` holds `{id, name, repository, defaultBranch, ownerUserId, createdAt, updatedAt, status}`, `bot.projectId` / `room.projectId` and `state.projectRuns[runId]` bind a member (a group, a Bot or a workflow run) to at most one Project, and the `api/bot` endpoints `projects`, `project-create`, `project-read`, `project-bind`, `project-of` and `project-board` sit in the task allow list. An unbound member resolves `null`, so an install with zero Projects behaves exactly as before. The read-only board over those bindings is `project-board` (#1696): Project id in, `{ tasks: [...] }` out, one row per core task id with `source` (`bot`/`group`/`workflow`), `column`, `assignee`, `updatedAt` and `runId`/`nodeId` when a run holds it. `core/src/host/project-board.js` reads `state.tasks` and the bindings and asks the registered providers through `rt.projectRunRefs` (`flybot.tasks.onProjectProvider`); it never reads `workflows.json` and writes nothing. Since #2021 the board's record halves say what their caps left out and cut the right end of the list: the decision read answers `total` — the files left after the one-row-per-path dedup, before the cut — and takes `.slice(-DECISIONS_MAX)` over the still-ascending sort, so a Project past 200 decisions keeps the newest instead of dropping the ones just written and the order on screen does not change, while the meetings read answers `decidedTotal`/`pendingTotal` per list beside the 100 rows it already kept newest; a list at or under its cap answers the same rows as before, plus the count ([#2021](https://github.com/ThinkFlowLab/FlyBot/issues/2021)). Since #2033 the lookup a decided meeting uses for its own file falls back to the un-truncated dedupe set of that same read when the name is not among the kept rows, so a file that fell off the oldest end of the cut is still named by the meeting (`path` set, and no `sha256`, because only the kept rows carry one) while a name the folder no longer holds still answers `path: ''`; the fallback reads names from that dedupe set and never asks the filesystem, so a crafted `../outside.md` yields no path either ([#2033](https://github.com/ThinkFlowLab/FlyBot/issues/2033)). Since #2037 the name is also checked where the listing is produced, by the same `isDecisionPath` the reader uses: a name with a `..` segment, an absolute path, a symlink standing in for the file, or a name this read never listed yields no row and no meeting `path` (and no `sha256`), so the fallback past the cut cannot bring one back — `archivedOf` reads through `describeFile`, the reader's own `lstat`, never `stat` ([#2037](https://github.com/ThinkFlowLab/FlyBot/issues/2037)). Since [#2029](https://github.com/ThinkFlowLab/FlyBot/issues/2029) a blocked card also carries the age of its own question: `blockedOf(task, now)` answers `since` (the `inputRequest.requestedAt` stamp `tasks.js` wrote, preserved across a re-ask) and `waitedMs` (`now - since`, the same shape `waitingOf` answers), both `null` when the ask wrote no stamp — never derived from `createdAt`/`updatedAt` — and `buildQueues` sorts `blocked` oldest-first with the comparator `waiting.sort` already uses, so an undated card sorts last; the Waiting group's own output is unchanged. Tests: `core/test/project-board.test.js`. On top of the record and the board sits the **Project Assistant** (#1712, Stage 2.5): `project-delete` removes the Project, its bindings and its memory; the assistant's digest is one stored note plus one `deliver()` to the owner, idempotent per Asia/Shanghai day, and its identity is the connector account and never the shared app — no connector signed in, or a multi-member host whose Project owner is not this host's own user, refuses and escalates with nothing written. Per-owner memory (#1733) keys those three fields by `(Project, owner)` under `projectMemory[id].owners`, mirrors the primary owner onto the row itself so a single-owner Project reads as before, and migrates a pre-#1733 record onto its primary owner on the first write instead of replacing it; the boundary a memory read is checked against is the Project's own owner list, so an owner asking for another owner's entries is refused naming that list, while the assistant's own render walks every owner. A card needs an owner's decision when the Bot that owns the card belongs to that owner, never from the board's `assignee` (a Bot name or null); the ask names the card's own title, at most `MAX_ACTIONABLE` (5) per owner. A quiet window on the assistant record holds a due digest and releases it once, labelled, when the window ends; a window covering 09:00 delivers nothing that day; an owner's opt-out stops generation and pauses the shared 09:00 task once every owner is off, and a re-enable resumes it; delivery goes to each owner's own Session and falls back to the main Bot only for the primary owner. A held digest is released by the next `digest()` call, not by the 09:00 task, which does not make one after a window ends. On top of it, member auth (#48) adds host module `src/host/member-auth.js`, installed after `store` in `MODULES` (`core/index.js`): `invitations` and `authSessions` maps live on state (no `STATE_VERSION` bump), the channel resolves the actor from the `x-flybot-member-session` header, and endpoints are `auth-invite` / `auth-redeem` / `auth-revoke` / `auth-whoami` / `auth-remove-member` / `auth-revoke-invite` with `body.actorId` ignored; `auth-invite` alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), `auth-revoke-invite` kills an unredeemed invitation (redeeming it afterwards fails with `revoked`), `removeMember` refuses a member that still owns a Bot (`has_bots`), and the auth mutations are serialized behind an in-process lock so concurrent `removeMember` calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in [team](team.md). On top of it, owner-scoped access (#49) adds host module `src/host/authz.js`, installed after member-auth: when any `authSessions` exist, the channel `authorize()` default-denies cross-owner Bot/room/session access, blocks shared non-isolatable resources (secrets, connectors, folder picks, browser bridge, update-install, diagnostics, set-prefs, `add-agent` — the DSH Agent is one per deployment and carries no owner) with `bot/blocked-shared` when more than one member exists, stamps `create-bot` with `actor.memberId`, and filters `view()`/`live()` to the actor; with no sessions the single-human behavior is unchanged. Session targets resolve through the store's owner maps — a Bot id via `botOf`, current chats and group Sessions and earlier parts via `selfOf`/`groupOwners`/`pastOwners` (a shared room Session also resolves for its current participants, #50) — and unresolvable ids deny. `approval-answer` gates on the approval's own Bot or room (resolved by approval id), never on caller-supplied session fields. `flybot/*` plugin endpoints (src/host/hooks.js) reach any signed-in actor for reads — except a Bot Social read naming a `body.botId` the actor does not own, which `bot/forbidden` refuses before the plugin handler registers that Bot's identity or syncs its grants (#1788, an owner-decided tightening); plugin writes follow the shared rule. `view()`/`live()` narrowing (`rt.ownedView`/`rt.ownedLive` in authz.js) keeps per-session entries (`questions`, `exchangeTurns`) the actor owns, and deployment-wide surfaces (agents, secrets, connectors, memory reviews) only while a single member exists — the same rule as the shared writes; rooms the actor belongs to and the Bots in them (#50) stay visible. `create-room` refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module `src/host/room-membership.js`, installed after groups: a room may carry typed `participants` (`human` / local `bot` / granted remote `bot` with `handle` + owner fields, roles `admin` / `member`, `joinedSeq` / `leftSeq`); log lines carry `authorId` + `authorKind`; history is the membership window (current members and the owner of an invited Bot see `seq >=` their interval; leave withdraws access; rejoin starts a new interval); local cross-owner rooms accept each side's *granted* Bots (#90: default deny, Chief included); a friend's Bot from `social/remote-bots` (`cleanRemote`) may be added and ungranted Bots stay private, and a room with a friend's Bot holds only granted local Bots (a Bot that joined by room code, `viaCode`, only for that room); `view()` labels remotes with an owner badge and shows an invited owner **Shared room from \**. Face-to-face rooms (#83) reuse the same `participants` / membership-window / owner-badge presentation with `origin: 'face-to-face'`: room-only remotes may sit in the room without a person-grant or a friendship, `presentRoom` treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and `fanoutRoomLine` goes to `rt.faceRoomFanout` instead of friend `flybotSend`. Still not team mode — see [team](team.md). Per-Bot grants (#90) add host module `src/host/reach.js`, installed after room-membership: `state.grants[botId]` is the list of independent grants (one Bot → one grantee, optionally room-scoped); default deny, Chief included; a Bot whose owner is not a member has none. Grants are set with `rt.setGrants`, exposed as `BotView.granted`, `flybot.bot.hasGrant` / `hasAnyGrant`, `flybot.grants` and the `bot.grants` event. `reach()` is the owner policy: a friend's Bot may reach only local Bots granted to that sender, only a granted Bot (or one code-joined in that room) may message a friend's Bot, another owner's Bot is reached only if both sides have matching grants or both are members of the room the message belongs to. It is not a middleware: hooks.js runs it before the `bot.send` chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or `bypass` can skip it and a queued message is judged by what is true when it is delivered. Revoking a grant, or replacing its Chief, removes it from rooms that have friends' Bots or several owners at once (`rt.evictUnshared`). Approval binding (#52) completes the held-send half of this: `state.flybot.approvals` records carry `requestId`, a sha256 `version` over the request content, server-derived `ownerId` and `origin` (`via`/`hop`/audience) — claims in the message (`meta`, text) never confer authority. `flybot/decide` is allowed through authz for any signed-in actor and the per-record check happens in hooks (`decide` requires the sender Bot's owner's session, `bot/forbidden` otherwise, `bot/unauthorized` without a session); `release()` revalidates before dispatch and marks the record `stale` when the request changed, the sender or target Bot is gone (also invalidated eagerly on `deleteBot`), or the Bot changed owners; a demoted Chief's sender-side holds are invalidated eagerly on `setMain` (not inside `staleReason`), and `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. `flybot/approvals` lists only the actor's own holds while enforcement is on; decided stale records keep their attribution in state but appear in no member's list. Which statuses still wait for that decision is one rule in `src/shared/approval-waiting.js` (#754): `decide()`, `heldOf()` in the chat view, the in-flight filter that invalidates holds, `PENDING_MAX` and the `APPROVALS_KEPT` cleanup all ask `isApprovalWaiting()`, and `channel.js` counts a row's waiting replies with it, so a `pending_approval` hold (Bot Social's own spelling) is decidable, drawn as waiting and never dropped mid-wait. +- The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; an existing backup counts only when it is a readable file holding a parseable version-1 state, and anything else (or any malformed ownership) fails closed (read-only). An older binary refuses a newer version read-only. This is schema foundation only — team mode stays disabled, see [team](team.md). On top of it, a durable **Project** record (#1691): `state.projects` holds `{id, name, repository, defaultBranch, ownerUserId, createdAt, updatedAt, status}`, `bot.projectId` / `room.projectId` and `state.projectRuns[runId]` bind a member (a group, a Bot or a workflow run) to at most one Project, and the `api/bot` endpoints `projects`, `project-create`, `project-read`, `project-bind`, `project-of` and `project-board` sit in the task allow list. An unbound member resolves `null`, so an install with zero Projects behaves exactly as before. The read-only board over those bindings is `project-board` (#1696): Project id in, `{ tasks: [...] }` out, one row per core task id with `source` (`bot`/`group`/`workflow`), `column`, `assignee`, `updatedAt` and `runId`/`nodeId` when a run holds it. `core/src/host/project-board.js` reads `state.tasks` and the bindings and asks the registered providers through `rt.projectRunRefs` (`flybot.tasks.onProjectProvider`); it never reads `workflows.json` and writes nothing. Since #2021 the board's record halves say what their caps left out and cut the right end of the list: the decision read answers `total` — the files left after the one-row-per-path dedup, before the cut — and takes `.slice(-DECISIONS_MAX)` over the still-ascending sort, so a Project past 200 decisions keeps the newest instead of dropping the ones just written and the order on screen does not change, while the meetings read answers `decidedTotal`/`pendingTotal` per list beside the 100 rows it already kept newest; a list at or under its cap answers the same rows as before, plus the count ([#2021](https://github.com/ThinkFlowLab/FlyBot/issues/2021)). Since #2033 the lookup a decided meeting uses for its own file falls back to the un-truncated dedupe set of that same read when the name is not among the kept rows, so a file that fell off the oldest end of the cut is still named by the meeting (`path` set, and no `sha256`, because only the kept rows carry one) while a name the folder no longer holds still answers `path: ''`; the fallback reads names from that dedupe set and never asks the filesystem, so a crafted `../outside.md` yields no path either ([#2033](https://github.com/ThinkFlowLab/FlyBot/issues/2033)). Since #2037 the name is also checked where the listing is produced, by the same `isDecisionPath` the reader uses: a name with a `..` segment, an absolute path, a symlink standing in for the file, or a name this read never listed yields no row and no meeting `path` (and no `sha256`), so the fallback past the cut cannot bring one back — `archivedOf` reads through `describeFile`, the reader's own `lstat`, never `stat` ([#2037](https://github.com/ThinkFlowLab/FlyBot/issues/2037)). Since #2041 a decision row can be opened: `rt.projectDecision(projectId, name)` reads one name through `readListedFile` and answers the reader's own three states (`text`, `absent`, `too large`), but only for a name the last `projectBoard` read of that Project produced — that read's un-truncated dedupe set is kept per Project, each name with the folder it was read from, so a name past the 200-row cut still opens while `decisions/../outside.md`, an absolute path, a name one level down or a symlink answers `not listed` without a `stat`, an open or a byte of content; the endpoint is `project-decision`, dispatched in `channel.js` beside `project-board` and listed in `authz.js`'s `TASK_ENDPOINTS` ([#2041](https://github.com/ThinkFlowLab/FlyBot/issues/2041)). Since [#2029](https://github.com/ThinkFlowLab/FlyBot/issues/2029) a blocked card also carries the age of its own question: `blockedOf(task, now)` answers `since` (the `inputRequest.requestedAt` stamp `tasks.js` wrote, preserved across a re-ask) and `waitedMs` (`now - since`, the same shape `waitingOf` answers), both `null` when the ask wrote no stamp — never derived from `createdAt`/`updatedAt` — and `buildQueues` sorts `blocked` oldest-first with the comparator `waiting.sort` already uses, so an undated card sorts last; the Waiting group's own output is unchanged. Tests: `core/test/project-board.test.js`. On top of the record and the board sits the **Project Assistant** (#1712, Stage 2.5): `project-delete` removes the Project, its bindings and its memory; the assistant's digest is one stored note plus one `deliver()` to the owner, idempotent per Asia/Shanghai day, and its identity is the connector account and never the shared app — no connector signed in, or a multi-member host whose Project owner is not this host's own user, refuses and escalates with nothing written. Per-owner memory (#1733) keys those three fields by `(Project, owner)` under `projectMemory[id].owners`, mirrors the primary owner onto the row itself so a single-owner Project reads as before, and migrates a pre-#1733 record onto its primary owner on the first write instead of replacing it; the boundary a memory read is checked against is the Project's own owner list, so an owner asking for another owner's entries is refused naming that list, while the assistant's own render walks every owner. A card needs an owner's decision when the Bot that owns the card belongs to that owner, never from the board's `assignee` (a Bot name or null); the ask names the card's own title, at most `MAX_ACTIONABLE` (5) per owner. A quiet window on the assistant record holds a due digest and releases it once, labelled, when the window ends; a window covering 09:00 delivers nothing that day; an owner's opt-out stops generation and pauses the shared 09:00 task once every owner is off, and a re-enable resumes it; delivery goes to each owner's own Session and falls back to the main Bot only for the primary owner. A held digest is released by the next `digest()` call, not by the 09:00 task, which does not make one after a window ends. On top of it, member auth (#48) adds host module `src/host/member-auth.js`, installed after `store` in `MODULES` (`core/index.js`): `invitations` and `authSessions` maps live on state (no `STATE_VERSION` bump), the channel resolves the actor from the `x-flybot-member-session` header, and endpoints are `auth-invite` / `auth-redeem` / `auth-revoke` / `auth-whoami` / `auth-remove-member` / `auth-revoke-invite` with `body.actorId` ignored; `auth-invite` alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), `auth-revoke-invite` kills an unredeemed invitation (redeeming it afterwards fails with `revoked`), `removeMember` refuses a member that still owns a Bot (`has_bots`), and the auth mutations are serialized behind an in-process lock so concurrent `removeMember` calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in [team](team.md). On top of it, owner-scoped access (#49) adds host module `src/host/authz.js`, installed after member-auth: when any `authSessions` exist, the channel `authorize()` default-denies cross-owner Bot/room/session access, blocks shared non-isolatable resources (secrets, connectors, folder picks, browser bridge, update-install, diagnostics, set-prefs, `add-agent` — the DSH Agent is one per deployment and carries no owner) with `bot/blocked-shared` when more than one member exists, stamps `create-bot` with `actor.memberId`, and filters `view()`/`live()` to the actor; with no sessions the single-human behavior is unchanged. Session targets resolve through the store's owner maps — a Bot id via `botOf`, current chats and group Sessions and earlier parts via `selfOf`/`groupOwners`/`pastOwners` (a shared room Session also resolves for its current participants, #50) — and unresolvable ids deny. `approval-answer` gates on the approval's own Bot or room (resolved by approval id), never on caller-supplied session fields. `flybot/*` plugin endpoints (src/host/hooks.js) reach any signed-in actor for reads — except a Bot Social read naming a `body.botId` the actor does not own, which `bot/forbidden` refuses before the plugin handler registers that Bot's identity or syncs its grants (#1788, an owner-decided tightening); plugin writes follow the shared rule. `view()`/`live()` narrowing (`rt.ownedView`/`rt.ownedLive` in authz.js) keeps per-session entries (`questions`, `exchangeTurns`) the actor owns, and deployment-wide surfaces (agents, secrets, connectors, memory reviews) only while a single member exists — the same rule as the shared writes; rooms the actor belongs to and the Bots in them (#50) stay visible. `create-room` refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module `src/host/room-membership.js`, installed after groups: a room may carry typed `participants` (`human` / local `bot` / granted remote `bot` with `handle` + owner fields, roles `admin` / `member`, `joinedSeq` / `leftSeq`); log lines carry `authorId` + `authorKind`; history is the membership window (current members and the owner of an invited Bot see `seq >=` their interval; leave withdraws access; rejoin starts a new interval); local cross-owner rooms accept each side's *granted* Bots (#90: default deny, Chief included); a friend's Bot from `social/remote-bots` (`cleanRemote`) may be added and ungranted Bots stay private, and a room with a friend's Bot holds only granted local Bots (a Bot that joined by room code, `viaCode`, only for that room); `view()` labels remotes with an owner badge and shows an invited owner **Shared room from \**. Face-to-face rooms (#83) reuse the same `participants` / membership-window / owner-badge presentation with `origin: 'face-to-face'`: room-only remotes may sit in the room without a person-grant or a friendship, `presentRoom` treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and `fanoutRoomLine` goes to `rt.faceRoomFanout` instead of friend `flybotSend`. Still not team mode — see [team](team.md). Per-Bot grants (#90) add host module `src/host/reach.js`, installed after room-membership: `state.grants[botId]` is the list of independent grants (one Bot → one grantee, optionally room-scoped); default deny, Chief included; a Bot whose owner is not a member has none. Grants are set with `rt.setGrants`, exposed as `BotView.granted`, `flybot.bot.hasGrant` / `hasAnyGrant`, `flybot.grants` and the `bot.grants` event. `reach()` is the owner policy: a friend's Bot may reach only local Bots granted to that sender, only a granted Bot (or one code-joined in that room) may message a friend's Bot, another owner's Bot is reached only if both sides have matching grants or both are members of the room the message belongs to. It is not a middleware: hooks.js runs it before the `bot.send` chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or `bypass` can skip it and a queued message is judged by what is true when it is delivered. Revoking a grant, or replacing its Chief, removes it from rooms that have friends' Bots or several owners at once (`rt.evictUnshared`). Approval binding (#52) completes the held-send half of this: `state.flybot.approvals` records carry `requestId`, a sha256 `version` over the request content, server-derived `ownerId` and `origin` (`via`/`hop`/audience) — claims in the message (`meta`, text) never confer authority. `flybot/decide` is allowed through authz for any signed-in actor and the per-record check happens in hooks (`decide` requires the sender Bot's owner's session, `bot/forbidden` otherwise, `bot/unauthorized` without a session); `release()` revalidates before dispatch and marks the record `stale` when the request changed, the sender or target Bot is gone (also invalidated eagerly on `deleteBot`), or the Bot changed owners; a demoted Chief's sender-side holds are invalidated eagerly on `setMain` (not inside `staleReason`), and `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. `flybot/approvals` lists only the actor's own holds while enforcement is on; decided stale records keep their attribution in state but appear in no member's list. Which statuses still wait for that decision is one rule in `src/shared/approval-waiting.js` (#754): `decide()`, `heldOf()` in the chat view, the in-flight filter that invalidates holds, `PENDING_MAX` and the `APPROVALS_KEPT` cleanup all ask `isApprovalWaiting()`, and `channel.js` counts a row's waiting replies with it, so a `pending_approval` hold (Bot Social's own spelling) is decidable, drawn as waiting and never dropped mid-wait. - One writer per home (`src/host/home-lock.js`): the first dsh to load `~/.dsh` takes `state.lock` (pid, host, a random token in the file, its mtime bumped on a timer) and a second window on the same data only reads, answering `bot/read-only` and naming the other host. Takeover is **on the next write**, not on a poll (#1027): a window that was read-only claims the free lock inside `save()` too, and a change made outside a request (a plugin, a scheduled task, a delivery) belongs in `rt.mutate(fn)`, which takes over and adopts what the other host left *before* the change is made and written. The writers that changed `rt.state` inline and then called `save()` are gone (#1048): `delivery.js` (a friend's first line and its unread mark, a team exchange) and `agents.js` (add, adopt, archive and remove of the DSH Agent) take the free lock and adopt first, re-resolving their records inside the change, so a write from a window that was read-only keeps what the other host left instead of replacing it with its older copy. `save()` itself never adopts: `adopt()` replaces `rt.state` whole, so adopting there would discard a change the caller has already made (`#1032`). Either takeover prints one line — `the other dsh has closed; this one now saves the Bot team (was pid on )` — from `refresh()` and from `save()` alike, so a window that writes over the file another host left leaves a trace (#1041). A lock file still naming a live holder on this host (its pid answers) keeps the window read-only, which is the #14 rule. A write that fails does not decide the delivery (#1096): the friend-line mark and the exchange record both land before the agent has the line, so a rejected `mutate()` (the lock was taken away, or the disk refused) prints one warning naming what was not recorded and the line is still handed over — a throw there would drop a message that exists nowhere else. The write chain is also readable: `rt.savesQueued()` is a monotonic watermark — every save this host has accepted since it started, never decremented — and `rt.savesSettled()` resolves once the chain is empty, so a caller that must read `state.json` after a save it did not await — a rollback's own write, in the tests — waits on the store instead of on a delay, where a wait long enough on an idle machine reads a half-written team on a busy one (#1368). diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index c749e8573..74dda7354 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -748,7 +748,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js Content: '内容', 'Referenced {labels}': '引用了 {labels}', ', ': '、', -@@ -41,22 +41,74 @@ const ZH = { +@@ -41,22 +41,76 @@ const ZH = { ' and ': '、', and: '和', '{count} members': '{count} 位成员', @@ -805,6 +805,8 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js + '{from} → {to}': '{from} → {to}', + '+{count} more': '另有 {count} 条', + '{count} more not shown': '还有 {count} 条未显示', ++ 'Reading the decision…': '正在读取决策…', ++ 'not listed': '不在列表里', + delegated: '委托转达', + All: '全部', + Filter: '筛选', diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index 2a10de1d7..a9b49e4f0 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -1010,7 +1010,7 @@ diff --git a/src/host/store.js b/src/host/store.js // LEGACY: Bots saved with a lab ran on that lab's default model; the model they // last ran on becomes their own, so dropping the lab changes nothing for them. if ('lab' in bot) { -@@ -160,13 +160,489 @@ export function install(rt) { +@@ -160,13 +160,497 @@ export function install(rt) { room.notice ??= '' migrated = true } @@ -1441,6 +1441,14 @@ diff --git a/src/host/store.js b/src/host/store.js + // with persona, memory head/topics and skills. The read lives in project-team.js; a missing + // Bot is a `null` row there, never an error for the whole answer. + case 'project-team': return rt.projectTeam?.(projectText(body.id, 200)) ?? { rows: [] } ++ // #2041: the text behind one decision row. The board's own listing is the accepted set, so a ++ // name it did not produce answers `not listed` and is never opened; the actor goes with the ++ // id, because who may read that text is decided per call (`projectDecision`). ++ case 'project-decision': { ++ const project = requireProject(projectText(body.id, 200)) ++ const path = projectText(body.path, 400) ++ return await rt.projectDecision?.(project.id, path, actor) ?? { ok: false, reason: `not listed: ${path}` } ++ } + // The read-only board (#1696): Project id in, its tasks out. The aggregate lives in + // project-board.js; it reads and returns, and writes nothing. The actor goes with the id + // so the board applies the same visibility rule `tasks` does (#1723). diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 90ce0fe62..b74b679bf 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -841,7 +841,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js content: [{ type: 'text', text }], }, AbortSignal.timeout(30_000)) return ok(true) -@@ -319,32 +319,135 @@ export function install(rt) { +@@ -319,32 +319,136 @@ export function install(rt) { case 'diagnostics-clear': await rt.diagnostics.clear() return ok(true) @@ -871,6 +871,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js + case 'project-bind': + case 'project-of': + case 'project-board': ++ case 'project-decision': + case 'project-team': + case 'project-role': + case 'project-delete': diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index e53baeb21..3a322f0f5 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -157,7 +157,7 @@ diff --git a/client.js b/client.js Content: "内容", "Referenced {labels}": "引用了 {labels}", ", ": "、", -@@ -78,21 +78,73 @@ var ZH = { +@@ -78,21 +78,75 @@ var ZH = { " and ": "、", and: "和", "{count} members": "{count} 位成员", @@ -213,6 +213,8 @@ diff --git a/client.js b/client.js + "{from} → {to}": "{from} → {to}", + "+{count} more": "另有 {count} 条", + "{count} more not shown": "还有 {count} 条未显示", ++ "Reading the decision…": "正在读取决策…", ++ "not listed": "不在列表里", + delegated: "委托转达", + All: "全部", + Filter: "筛选", @@ -4991,7 +4993,7 @@ diff --git a/client.js b/client.js @keyframes bt-mem-line{from{opacity:0;transform:translateY(4px)}} .bt-mem-skel{display:flex;flex-direction:column;gap:24px} .bt-mem-skel-block{display:flex;flex-direction:column;gap:10px} -@@ -2574,2935 +2574,6720 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} +@@ -2574,2935 +2574,6761 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} @media (max-width:640px){.bt-mem{width:100vw;height:100%;border-radius:0;border:0}.bt-mem-body{padding:18px 18px 20px}.bt-mem-foot{padding:8px 12px 12px}} @media (prefers-reduced-motion:reduce){.bt-mem,.bt-mem-scrim,.bt-mem-summary>*,.bt-mem-empty,.bt-mem-reply,.bt-mem-skel-block span,.bt-mem-entries,.bt-mem-send>*,.bt-mem-changes li svg{animation:none}} @@ -11459,11 +11461,11 @@ diff --git a/client.js b/client.js - onOpen: (bot) => actions.openExchange(selfId, bot.id), - whole: true - }); -- } + } - if (source.kind === "schedule") { - const prompts = reminderPrompts(contentText(data.content)); - return (0, import_react6.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); - } +- } - if (source.kind === "user-question-reply") return null; - return (0, import_react6.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); - } @@ -12918,6 +12920,14 @@ diff --git a/client.js b/client.js +.bt-project-decision .bt-project-date,.bt-project-meeting .bt-project-date{font-size:11px;opacity:.6;min-width:76px;font-variant-numeric:tabular-nums} +.bt-project-decision .bt-project-hash{margin-left:auto;font-size:11px;opacity:.55;font-family:var(--bt-mono,ui-monospace,monospace)} +.bt-project-decision .bt-project-title,.bt-project-meeting .bt-project-title{min-width:0} ++/* #2041: the decision row became a button, so it keeps the row's look and drops the control's. */ ++button.bt-project-decision{width:100%;font:inherit;color:inherit;text-align:left;border:0;cursor:pointer} ++button.bt-project-decision:hover{background:var(--bt-card-hover,rgba(127,127,127,.16))} ++button.bt-project-decision:focus-visible{outline:2px solid var(--bt-accent,#4c8dff);outline-offset:1px} ++button.bt-project-decision[data-open="true"]{outline:1px solid var(--bt-accent,#4c8dff)} ++.bt-project-decision-wrap{margin:0} ++.bt-project-decision-text{margin:0 0 8px;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));white-space:pre-wrap;overflow-wrap:anywhere;max-height:320px;overflow:auto;font-size:12px;font-family:var(--bt-mono,ui-monospace,monospace)} ++.bt-project-decision-refusal{opacity:.75} +.bt-project-path{font-size:11px;opacity:.6;font-family:var(--bt-mono,ui-monospace,monospace)} +.bt-project-missing{font-size:11px;opacity:.6} +.bt-project-status{font-size:11px;text-transform:uppercase;letter-spacing:.04em;opacity:.6;margin-left:auto} @@ -13279,46 +13289,6 @@ diff --git a/client.js b/client.js - return () => { - window.removeEventListener("mousedown", onDown); - window.removeEventListener("keydown", onKey, true); -- }; -- }, [open]); -- const phase = update?.phase ?? "idle"; -- const dsh = update?.dsh; -- const dshPhase = dsh?.phase ?? "idle"; -- const dshRow = showsDshRow(dsh, update?.app); -- if (!update?.available && !dshRow && phase === "idle" && dshPhase === "idle") return null; -- const cloudPhase = phase === "installing" || dshPhase === "installing" ? "installing" : phase !== "idle" ? phase : dshPhase; -- const toggle = () => { -- const rect = button.current?.getBoundingClientRect(); -- if (rect) setBox({ left: Math.max(8, Math.min(rect.left, window.innerWidth - 288)), top: rect.bottom + 6 }); -- setOpen((value) => !value); -- }; -- const start = () => { -- setError(""); -- void actions.installUpdate().catch((failure) => setError(hostText(failure?.message ?? String(failure)))); -- }; -- const startDsh = () => { -- setDshError(""); -- void actions.installDshUpdate().catch((failure) => setDshError(hostText(failure?.message ?? String(failure)))); -- }; -- const label = phase === "installing" ? t("Updating DS Bot…") : dshPhase === "installing" ? t("Updating DSH…") : phase === "installed" ? t("DS Bot {version} is installed. Restart DSH to use it.", { version: update.installed ?? update.latest }) : dshPhase === "installed" ? t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified }) : update?.available ? t("DS Bot {version} is available", { version: update.latest }) : t("DSH {version} is available", { version: dsh?.verified }); -- const message = error || (phase === "failed" ? update.error : ""); -- const dshMessage = dshError || (dshPhase === "failed" ? dsh?.error : ""); -- const dshText = dshPhase === "installed" ? t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified }) : dsh?.available === false && dsh?.blockedBy ? t("Update DS Bot {version} first.", { version: dsh.blockedBy }) : t("You have {version}.", { version: dsh?.current }); -- return (0, import_react8.createElement)( -- import_react8.Fragment, -- null, -- (0, import_react8.createElement)("button", { -- ref: button, -- type: "button", -- className: "bt-round bt-update", -- "data-phase": cloudPhase, -- "aria-label": label, -- title: label, -- "aria-haspopup": "dialog", -- "aria-expanded": open, -- onClick: toggle -- }, (0, import_react8.createElement)(CloudDownloadIcon, { done: phase === "installed" }), phase === "failed" || dshPhase === "failed" ? (0, import_react8.createElement)("span", { className: "bt-update-dot", "aria-hidden": true }) : null), -- open && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( +function ProjectBoardRows({ view, onOpen }) { + if (view.empty) return (0, import_react19.createElement)("div", { className: "bt-project-empty" }, t(view.emptyText)); + return view.groups.map((group) => (0, import_react19.createElement)( @@ -13381,7 +13351,46 @@ diff --git a/client.js b/client.js + lastActivityAt: Number.isFinite(row.lastActivityAt) ? row.lastActivityAt : null, + overdue: Number.isFinite(row.sinceMs) && row.sinceMs > RUNNING_OVERDUE_MS, + board: boardOfRow({ source: text(row.source) }, task) -+ }; + }; +- }, [open]); +- const phase = update?.phase ?? "idle"; +- const dsh = update?.dsh; +- const dshPhase = dsh?.phase ?? "idle"; +- const dshRow = showsDshRow(dsh, update?.app); +- if (!update?.available && !dshRow && phase === "idle" && dshPhase === "idle") return null; +- const cloudPhase = phase === "installing" || dshPhase === "installing" ? "installing" : phase !== "idle" ? phase : dshPhase; +- const toggle = () => { +- const rect = button.current?.getBoundingClientRect(); +- if (rect) setBox({ left: Math.max(8, Math.min(rect.left, window.innerWidth - 288)), top: rect.bottom + 6 }); +- setOpen((value) => !value); +- }; +- const start = () => { +- setError(""); +- void actions.installUpdate().catch((failure) => setError(hostText(failure?.message ?? String(failure)))); +- }; +- const startDsh = () => { +- setDshError(""); +- void actions.installDshUpdate().catch((failure) => setDshError(hostText(failure?.message ?? String(failure)))); +- }; +- const label = phase === "installing" ? t("Updating DS Bot…") : dshPhase === "installing" ? t("Updating DSH…") : phase === "installed" ? t("DS Bot {version} is installed. Restart DSH to use it.", { version: update.installed ?? update.latest }) : dshPhase === "installed" ? t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified }) : update?.available ? t("DS Bot {version} is available", { version: update.latest }) : t("DSH {version} is available", { version: dsh?.verified }); +- const message = error || (phase === "failed" ? update.error : ""); +- const dshMessage = dshError || (dshPhase === "failed" ? dsh?.error : ""); +- const dshText = dshPhase === "installed" ? t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified }) : dsh?.available === false && dsh?.blockedBy ? t("Update DS Bot {version} first.", { version: dsh.blockedBy }) : t("You have {version}.", { version: dsh?.current }); +- return (0, import_react8.createElement)( +- import_react8.Fragment, +- null, +- (0, import_react8.createElement)("button", { +- ref: button, +- type: "button", +- className: "bt-round bt-update", +- "data-phase": cloudPhase, +- "aria-label": label, +- title: label, +- "aria-haspopup": "dialog", +- "aria-expanded": open, +- onClick: toggle +- }, (0, import_react8.createElement)(CloudDownloadIcon, { done: phase === "installed" }), phase === "failed" || dshPhase === "failed" ? (0, import_react8.createElement)("span", { className: "bt-update-dot", "aria-hidden": true }) : null), +- open && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( + }); + return { + available: source?.ok === true, @@ -13464,30 +13473,6 @@ diff --git a/client.js b/client.js - setBox({ left, bottom: window.innerHeight - rect.top + 10 }); - setTip(true); - }, 250); -- }; -- const hide = () => { -- clearTimeout(timer.current); -- setTip(false); -- }; -- return (0, import_react8.createElement)( -- "span", -- { className: "bt-connect-wrap" }, -- (0, import_react8.createElement)( -- "button", -- { -- ref: button, -- type: "button", -- className: "bt-connect bt-connect-foot", -- "aria-describedby": tip ? tipId : void 0, -- onMouseEnter: show, -- onMouseLeave: hide, -- onFocus: show, -- onBlur: hide, -- onClick: () => actions.selectPanel("plugins") -- }, -- (0, import_react8.createElement)(PlugIcon), -- t("Connect plugins"), -- (0, import_react8.createElement)("span", { className: "bt-connect-warn", "aria-hidden": true }, "!") +function ProjectRunningTable({ view, onOpen }) { + if (view.running.length === 0) return (0, import_react19.createElement)("section", { className: "bt-project-running", "data-running": "0" }, (0, import_react19.createElement)("h4", null, t("Running tasks")), (0, import_react19.createElement)("div", { className: "bt-project-empty" }, t("Nothing is running."))); + return (0, import_react19.createElement)( @@ -13503,12 +13488,7 @@ diff --git a/client.js b/client.js + (0, import_react19.createElement)("span", null, t("Claimed for")), + (0, import_react19.createElement)("span", null, t("Source")), + (0, import_react19.createElement)("span", null, t("Last activity")) - ), -- tip && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( -- "span", -- { role: "tooltip", id: tipId, className: "bt-connect-tip", style: box }, -- t("Plugin compatibility is not fully verified.") -- ), document.body) : null ++ ), + view.running.map((row) => { + const open = row.board === null ? void 0 : () => onOpen?.(row.board); + return (0, import_react19.createElement)( @@ -13530,14 +13510,8 @@ diff --git a/client.js b/client.js + (0, import_react19.createElement)("span", null, row.lastActivityAt === null ? METRIC_DASH : new Date(row.lastActivityAt).toLocaleString(dateLocale())) + ); + }) - ); - } --function YouAvatar() { -- return (0, import_react8.createElement)( -- "span", -- { className: "bt-you-wrap" }, -- (0, import_react8.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })), -- (0, import_react8.createElement)("span", { style: { position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" } }, t("Settings")) ++ ); ++} +function ProjectQueueGroups({ view, onOpen }) { + return view.queues.map((entry) => (0, import_react19.createElement)( + "section", @@ -13571,14 +13545,76 @@ diff --git a/client.js b/client.js + )); +} +var countText = (value) => value === null ? t("unknown") : String(value); -+function ProjectBoardRecord({ record }) { ++function ProjectBoardRecord({ record, projectId, call: call4 }) { + const { decisions, meetings } = record; ++ const [open, setOpen] = (0, import_react19.useState)(null); ++ const toggle = (path) => { ++ if (open !== null && open.path === path) { ++ setOpen(null); ++ return; ++ } ++ if (typeof call4 !== "function") return; ++ setOpen({ path, answer: null }); ++ const settle = (answer) => setOpen((current) => current !== null && current.path === path ? { path, answer } : current); ++ call4("project-decision", { id: projectId, path }).then(settle, (error) => settle({ ok: false, reason: String(error?.message ?? error) })); + }; +- const hide = () => { +- clearTimeout(timer.current); +- setTip(false); ++ const decisionBody = (file) => { ++ if (open === null || open.path !== file.path) return null; ++ const answer = open.answer; ++ if (answer === null) return (0, import_react19.createElement)("div", { className: "bt-project-decision-text", "data-open-decision": file.path }, t("Reading the decision…")); ++ if (answer.ok !== true) return (0, import_react19.createElement)("div", { className: "bt-project-decision-text bt-project-decision-refusal", "data-open-decision": file.path, "data-refused": "true" }, text(answer.reason) || t("not listed")); ++ return (0, import_react19.createElement)("pre", { className: "bt-project-decision-text", "data-open-decision": file.path }, typeof answer.text === "string" ? answer.text : ""); + }; +- return (0, import_react8.createElement)( +- "span", +- { className: "bt-connect-wrap" }, +- (0, import_react8.createElement)( + const decisionRows = decisions.files.map((file) => (0, import_react19.createElement)( + "div", -+ { key: file.key, className: "bt-project-decision", "data-path": file.path, "data-sha256": file.sha256 }, -+ (0, import_react19.createElement)("span", { className: "bt-project-date" }, file.date), -+ (0, import_react19.createElement)("span", { className: "bt-project-title" }, file.title === "" ? file.name : file.title), -+ (0, import_react19.createElement)("span", { className: "bt-project-hash", title: file.sha256 }, file.shortHash) ++ { key: file.key, className: "bt-project-decision-wrap" }, ++ (0, import_react19.createElement)( + "button", + { +- ref: button, + type: "button", +- className: "bt-connect bt-connect-foot", +- "aria-describedby": tip ? tipId : void 0, +- onMouseEnter: show, +- onMouseLeave: hide, +- onFocus: show, +- onBlur: hide, +- onClick: () => actions.selectPanel("plugins") ++ className: "bt-project-decision", ++ "data-path": file.path, ++ "data-sha256": file.sha256, ++ "data-open": open !== null && open.path === file.path ? "true" : void 0, ++ "aria-expanded": open !== null && open.path === file.path ? "true" : "false", ++ onClick: () => toggle(file.path) + }, +- (0, import_react8.createElement)(PlugIcon), +- t("Connect plugins"), +- (0, import_react8.createElement)("span", { className: "bt-connect-warn", "aria-hidden": true }, "!") ++ (0, import_react19.createElement)("span", { className: "bt-project-date" }, file.date), ++ (0, import_react19.createElement)("span", { className: "bt-project-title" }, file.title === "" ? file.name : file.title), ++ (0, import_react19.createElement)("span", { className: "bt-project-hash", title: file.sha256 }, file.shortHash) + ), +- tip && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( +- "span", +- { role: "tooltip", id: tipId, className: "bt-connect-tip", style: box }, +- t("Plugin compatibility is not fully verified.") +- ), document.body) : null +- ); +-} +-function YouAvatar() { +- return (0, import_react8.createElement)( +- "span", +- { className: "bt-you-wrap" }, +- (0, import_react8.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })), +- (0, import_react8.createElement)("span", { style: { position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" } }, t("Settings")) ++ decisionBody(file) + )); + const recipientText = (list) => list.length === 0 ? null : list.map((name) => `@${name}`).join(", "); + const proxyText = (meeting) => meeting.participants.filter((participant) => participant.speaksFor !== null).map((participant) => `${participant.name} ${t("for {user}", { user: participant.speaksFor })}`).join(", "); @@ -13868,7 +13904,7 @@ diff --git a/client.js b/client.js + onClick: open + }, t("My agent")); +} -+function ProjectBoardPane({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics, onOpen, onSettings, onMembers, onOpenBot, onOpenDossier }) { ++function ProjectBoardPane({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics, onOpen, onSettings, onMembers, onOpenBot, onOpenDossier, call: call4 }) { + const view = projectBoardView({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics }); + return (0, import_react19.createElement)( + "div", @@ -13898,7 +13934,7 @@ diff --git a/client.js b/client.js + (0, import_react19.createElement)(ProjectBoardRows, { view, onOpen }), + (0, import_react19.createElement)(ProjectQueueGroups, { view, onOpen }), + (0, import_react19.createElement)(ProjectDoneGroup, { view, onOpen }), -+ (0, import_react19.createElement)(ProjectBoardRecord, { record: view.record }) ++ (0, import_react19.createElement)(ProjectBoardRecord, { record: view.record, projectId: view.projectId, call: call4 }) + ); } -function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboarding, actions }) { @@ -14315,6 +14351,8 @@ diff --git a/client.js b/client.js + queues: state.queues, + metrics: state.metrics, + team: state.team, ++ // #2041: a decision row opens through the same channel read the page polls with. ++ call: read, + onSettings: () => setPanel("settings"), + onMembers: () => setPanel("members"), + // #1864: a team row's Dossier control opens the read-only panel, fed by the same read. diff --git a/patches/ds-bot/files/src/client/project-board.js b/patches/ds-bot/files/src/client/project-board.js index 996137811..ea9e3086b 100644 --- a/patches/ds-bot/files/src/client/project-board.js +++ b/patches/ds-bot/files/src/client/project-board.js @@ -31,6 +31,14 @@ export const PROJECT_BOARD_CSS = ` .bt-project-decision .bt-project-date,.bt-project-meeting .bt-project-date{font-size:11px;opacity:.6;min-width:76px;font-variant-numeric:tabular-nums} .bt-project-decision .bt-project-hash{margin-left:auto;font-size:11px;opacity:.55;font-family:var(--bt-mono,ui-monospace,monospace)} .bt-project-decision .bt-project-title,.bt-project-meeting .bt-project-title{min-width:0} +/* #2041: the decision row became a button, so it keeps the row's look and drops the control's. */ +button.bt-project-decision{width:100%;font:inherit;color:inherit;text-align:left;border:0;cursor:pointer} +button.bt-project-decision:hover{background:var(--bt-card-hover,rgba(127,127,127,.16))} +button.bt-project-decision:focus-visible{outline:2px solid var(--bt-accent,#4c8dff);outline-offset:1px} +button.bt-project-decision[data-open="true"]{outline:1px solid var(--bt-accent,#4c8dff)} +.bt-project-decision-wrap{margin:0} +.bt-project-decision-text{margin:0 0 8px;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));white-space:pre-wrap;overflow-wrap:anywhere;max-height:320px;overflow:auto;font-size:12px;font-family:var(--bt-mono,ui-monospace,monospace)} +.bt-project-decision-refusal{opacity:.75} .bt-project-path{font-size:11px;opacity:.6;font-family:var(--bt-mono,ui-monospace,monospace)} .bt-project-missing{font-size:11px;opacity:.6} .bt-project-status{font-size:11px;text-transform:uppercase;letter-spacing:.04em;opacity:.6;margin-left:auto} @@ -529,12 +537,44 @@ const countText = value => (value === null ? t('unknown') : String(value)) // The record beside the cards (#1767): decisions first, then the meetings that reached one, and // a meeting still open or blocked listed apart — never as a decision. Every row is text the host // already answered; the only thing this reads again is the hash it was handed. -function ProjectBoardRecord({ record }) { +// #2041: a decision row opens. The row is a button, so it is reachable with a keyboard and by +// activation; one row is open at a time, and the text under it is the host's answer to that one +// name — the board's own listing, never a second read of the folder. +function ProjectBoardRecord({ record, projectId, call }) { const { decisions, meetings } = record - const decisionRows = decisions.files.map(file => h('div', { key: file.key, className: 'bt-project-decision', 'data-path': file.path, 'data-sha256': file.sha256 }, + const [open, setOpen] = useState(null) + const toggle = (path) => { + if (open !== null && open.path === path) { setOpen(null); return } + if (typeof call !== 'function') return + setOpen({ path, answer: null }) + // An answer that lands after another row was opened must not paint this one: it is kept only + // while its own row is the open one. + const settle = (answer) => setOpen(current => (current !== null && current.path === path ? { path, answer } : current)) + call('project-decision', { id: projectId, path }).then(settle, error => settle({ ok: false, reason: String(error?.message ?? error) })) + } + const decisionBody = (file) => { + if (open === null || open.path !== file.path) return null + const answer = open.answer + if (answer === null) return h('div', { className: 'bt-project-decision-text', 'data-open-decision': file.path }, t('Reading the decision…')) + // `absent` and `too large` read as the sentence the host sent, the same words `project_state` + // reports them in; the text of a decision is drawn as text, never as markup it contains. + if (answer.ok !== true) return h('div', { className: 'bt-project-decision-text bt-project-decision-refusal', 'data-open-decision': file.path, 'data-refused': 'true' }, text(answer.reason) || t('not listed')) + return h('pre', { className: 'bt-project-decision-text', 'data-open-decision': file.path }, typeof answer.text === 'string' ? answer.text : '') + } + const decisionRows = decisions.files.map(file => h('div', { key: file.key, className: 'bt-project-decision-wrap' }, + h('button', { + type: 'button', + className: 'bt-project-decision', + 'data-path': file.path, + 'data-sha256': file.sha256, + 'data-open': open !== null && open.path === file.path ? 'true' : undefined, + 'aria-expanded': open !== null && open.path === file.path ? 'true' : 'false', + onClick: () => toggle(file.path), + }, h('span', { className: 'bt-project-date' }, file.date), h('span', { className: 'bt-project-title' }, file.title === '' ? file.name : file.title), - h('span', { className: 'bt-project-hash', title: file.sha256 }, file.shortHash))) + h('span', { className: 'bt-project-hash', title: file.sha256 }, file.shortHash)), + decisionBody(file))) const recipientText = list => (list.length === 0 ? null : list.map(name => `@${name}`).join(', ')) // A proxy line reads in the same words a task row already uses for a proxy holder. const proxyText = meeting => meeting.participants @@ -763,7 +803,7 @@ export function ProjectAgentEntry({ projectId, read, onOpen } = {}) { }, t('My agent')) } -export function ProjectBoardPane({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics, onOpen, onSettings, onMembers, onOpenBot, onOpenDossier }) { +export function ProjectBoardPane({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics, onOpen, onSettings, onMembers, onOpenBot, onOpenDossier, call }) { const view = projectBoardView({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics }) return h('div', { className: 'bt-project-board', 'data-can-manage': view.canManage ? 'true' : 'false' }, h('h3', null, view.title), @@ -785,7 +825,7 @@ export function ProjectBoardPane({ project, rows, done, canManage, tasks, decisi h(ProjectBoardRows, { view, onOpen }), h(ProjectQueueGroups, { view, onOpen }), h(ProjectDoneGroup, { view, onOpen }), - h(ProjectBoardRecord, { record: view.record })) + h(ProjectBoardRecord, { record: view.record, projectId: view.projectId, call })) } // #1749: the settings panel. It writes through the three paths the host already had — bind or @@ -1079,6 +1119,8 @@ export function ProjectBoardOverlay({ actions, call: request } = {}) { : h(ProjectBoardPane, { project: state.project, rows: state.rows, done: state.done, canManage: state.canManage, tasks: state.tasks, decisions: state.decisions, meetings: state.meetings, queues: state.queues, metrics: state.metrics, team: state.team, + // #2041: a decision row opens through the same channel read the page polls with. + call: read, onSettings: () => setPanel('settings'), onMembers: () => setPanel('members'), // #1864: a team row's Dossier control opens the read-only panel, fed by the same read. diff --git a/patches/ds-bot/files/src/host/authz.js b/patches/ds-bot/files/src/host/authz.js index 44e1f546a..b63b4b218 100644 --- a/patches/ds-bot/files/src/host/authz.js +++ b/patches/ds-bot/files/src/host/authz.js @@ -69,6 +69,9 @@ const TASK_ENDPOINTS = new Set([ // other wiring point was present, and a signed-in host answered `bot/not-found: Unknown // endpoint project-board` while `project-read` and `project-of` answered ALLOW. 'projects', 'project-create', 'project-read', 'project-bind', 'project-of', 'project-board', + // #2041: the text of one decision row. It reads the same Project through the same listing the + // board just produced, so it sits beside `project-board` rather than behind a new gate. + 'project-decision', // The team dossier (#1864) is a read over the same Project and the same bindings: the owner // decided on 2026-10-30 that persona and memory are shared by the Project's members, so it sits // beside `project-read` rather than behind the owner gate. diff --git a/patches/ds-bot/files/src/host/project-board.js b/patches/ds-bot/files/src/host/project-board.js index d031b58b4..c6f55608d 100644 --- a/patches/ds-bot/files/src/host/project-board.js +++ b/patches/ds-bot/files/src/host/project-board.js @@ -19,7 +19,7 @@ import { open } from 'node:fs/promises' import { join } from 'node:path' import { columnOf } from '../shared/task-board.js' -import { DECISIONS_DIR, describeFile, isDecisionPath, listProjectState } from './project-state.js' +import { DECISIONS_DIR, describeFile, isDecisionPath, listProjectState, readListedFile } from './project-state.js' import { cut } from './text.js' import { advanceRefusal } from './meetings.js' import { readMetrics } from './project-metrics.js' @@ -221,6 +221,10 @@ export function decisionCardOf(meeting) { } export function install(rt) { + // #2041: the decision names the board listed, per Project, with the folder each name was read + // from. A render rewrites its Project's entry; nothing else writes here, and a name outside it + // is refused unread. + const readings = new Map() // The folder a Project's decisions live in. A Project names one itself, and when it names none // the folder is the one its members work in — the same folder a meeting resolves through // `workDirFor` before it archives a decision, so the reader and the writer look in one place. @@ -279,13 +283,20 @@ export function install(rt) { .map(file => ({ ...file, dir }))) } // The other source: the folder each decided meeting of this Project recorded, one file each. + // #2041: a name that came from a meeting keeps that meeting's own cards, because opening the + // file is checked against the same rule the board paints those cards with. + const cardsOf = new Map() for (const meeting of Object.values(rt.state?.meetings ?? {})) { if (meeting === null || typeof meeting !== 'object' || meeting.status !== 'decided') continue if (!inProject(meeting, project)) continue const archivedDir = text(meeting.decisionDir) if (archivedDir !== '' && !folders.includes(archivedDir)) folders.push(archivedDir) const archived = await archivedOf(meeting) - if (archived !== undefined) found.push(archived) + if (archived === undefined) continue + found.push(archived) + if (!cardsOf.has(archived.path)) { + cardsOf.set(archived.path, Array.isArray(meeting.taskIds) ? meeting.taskIds.map(id => text(id)).filter(id => id !== '') : []) + } } // One row per path — the client keys a row by path, and the Project folder was read first. const seen = new Set() @@ -324,7 +335,9 @@ export function install(rt) { // list from a truncated one instead of reading the cap as the answer. // #2033: the un-truncated dedupe of this read, paths only — the meeting lookup below has to // tell "fell off the 200-row cut" from "was never here" without asking the disk again. - return { dir: dir === '' ? null : dir, files, total: deduped.length, reason, known: new Set(deduped.map(file => file.path)) } + // #2041: each name keeps the folder it was read from, because a decided meeting archives to a + // folder of its own; opening a row reads through this map and nothing else. + return { dir: dir === '' ? null : dir, files, total: deduped.length, reason, known: new Map(deduped.map(file => [file.path, file.dir])), cards: cardsOf } } // #1831: the card's level, who it is addressed to and whether it may advance ride on every @@ -492,6 +505,13 @@ export function install(rt) { ? { waiting: [], inflight: [], blocked: [], counts: { waiting: null, inflight: null, blocked: null, done: null } } : buildQueues(mine, done.length) const decisions = await decisionsOf(project) + // #2041: the set an opened row is checked against is this read's own listing, kept per Project + // because a page polls this read every few seconds. Opening a decision never lists the folder + // again: it reads one of these names, or answers `not listed` and touches nothing. + // #2041: names and folders only — never an actor's view of them. Visibility is computed per + // call in `projectDecision`, so one member reading the board cannot hand its own reach to the + // next caller, and revoking a card takes effect on the next open. + readings.set(id, { dirs: decisions.known, cards: decisions.cards }) // `known` is this read's own path set for the meeting lookup (#2033); the page renders // `dir`/`files`/`total`/`reason`, so it does not travel with the board. const servedDecisions = { dir: decisions.dir, files: decisions.files, total: decisions.total, reason: decisions.reason } @@ -540,6 +560,48 @@ export function install(rt) { rt.projectRoleOf = (project, memberId) => roleOf(project, memberId) rt.projectRoleChoices = (project, memberId) => roleChoices(project, memberId) rt.projectBoard = projectBoard + // #2041: the text behind one decision row. The accepted set is the listing this host produced for + // that Project — the names the 200-row cut dropped included — so a name outside it answers + // `not listed` without a stat, an open or a byte of content: opening a row is never a second, + // wider read path beside `readProjectState`. The name shape and the `lstat` read are #2038's own; + // this adds the membership check and nothing else. + // #2041: who may open a listed name. The text is behind the same door as the cards (#1723), and + // the two kinds of name have two doors: one a decided meeting named follows that meeting's own + // cards through `rt.taskVisible`, the rule the board paints them with, while a plain record has + // no card and follows the Project's role list (`roleOf`). Neither branch widens when it cannot + // judge: a missing predicate refuses rather than allows. + const decisionVisible = (project, actor, taskIds) => { + // One human on this host: the rule every task read already uses hides nothing from them. + if (rt.authzEnforced?.() !== true) return true + const role = roleOf(project, actor?.memberId) + if (taskIds === undefined) return role !== null + const cards = taskIds.map(id => rt.state?.tasks?.[id]).filter(task => task !== null && typeof task === 'object') + // A decided meeting that produced no card has no card to judge, so the role list decides. + if (cards.length === 0) return role !== null + const visible = typeof rt.taskVisible === 'function' ? rt.taskVisible : null + if (visible === null) return false + return cards.some(task => visible(task, actor)) + } + rt.projectDecision = async (projectId, file, actor) => { + const name = text(file) + if (!isDecisionPath(name)) return { ok: false, reason: `not listed: ${name} is not a decision file` } + const id = text(projectId) + const project = rt.readProject?.(id) + if (project === null || project === undefined) return { ok: false, reason: `no Project: ${id}` } + // A name this caller may not open answers exactly what a name the listing never had answers: + // one sentence for both, so the refusal cannot be used as a list of what exists. + const listed = { ok: false, reason: `not listed: ${name} is not in this board's decision list` } + const entry = readings.get(id) + if (entry === undefined) return listed + // #2041: visibility is read from the caller and the state as they are now, in memory, before + // any `stat` or `open` — a board read never froze one member's reach into this cache. + if (!decisionVisible(project, actor, entry.cards.get(name))) return listed + const dir = entry.dirs.get(name) + if (dir === undefined) return listed + // A file that left the folder since the listing answers `absent`; one over the bound answers + // `too large` with the size and no text. + return await readListedFile(dir, name) + } // The three groups are their own read over the same pass, for a caller that wants only the queue. rt.projectBoardQueues = async (projectId, actor) => { const board = await projectBoard(projectId, actor) diff --git a/patches/ds-bot/files/src/host/project-state.js b/patches/ds-bot/files/src/host/project-state.js index 30bf9ab58..1159e85a9 100644 --- a/patches/ds-bot/files/src/host/project-state.js +++ b/patches/ds-bot/files/src/host/project-state.js @@ -101,9 +101,16 @@ export async function readProjectState(dir, file) { return { ok: false, reason: `not listed: ${path} is not a Project State file` } } if (!await repositoryAt(dir)) return { ok: false, reason: `not a repository: ${dir}` } + return readListedFile(dir, path) +} + +// The bytes of one file whose name the caller already accepted as listed, bounded at `MAX_BYTES`. +// `project_state` and the board's opened decision share this read (#2041), so the bound and the +// symlink rule cannot drift between the two: a file that vanished since the listing, or one +// replaced by a symlink, answers `absent` rather than the text it points at. +export async function readListedFile(dir, path) { let info try { - // A symlink answers like a file that is not there — never with the text it points at. info = await fileAt(dir, path) } catch { return { ok: false, reason: `absent: ${path}` } 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 e8781c3e8..55066e26b 100644 --- a/patches/ds-bot/files/test/project-board-view.test.js +++ b/patches/ds-bot/files/test/project-board-view.test.js @@ -755,3 +755,181 @@ test('#2021: no total, or one that matches, draws no line — and each meeting l })) assert.deepEqual(two.filter(node => node.props?.className === 'bt-project-more').map(textOf), ['103 more not shown', '100 more not shown']) }) + +// #2041: a decision row opens. Activating it asks the host for that one name and draws the text +// under the row; a second activation collapses it, and a refusal reads as its own sentence — +// never as markup the answer happens to contain. +const openableRecord = (call) => ({ + project: { id: 'project-1', name: 'FlyBot' }, + rows, + canManage: false, + decisions: { + dir: '/team/flybot', + files: [ + { path: 'decisions/2026-10-30-adopt.md', name: '2026-10-30-adopt.md', date: '2026-10-30', title: 'Adopt the board record', sha256: 'a'.repeat(64), mtime: '2026-10-30T01:00:00.000Z' }, + { path: 'decisions/2026-10-31-second.md', name: '2026-10-31-second.md', date: '2026-10-31', title: 'Keep decisions small', sha256: 'b'.repeat(64), mtime: '2026-10-31T01:00:00.000Z' }, + ], + reason: '', + }, + meetings: { decided: [], pending: [], reason: '' }, + call, +}) + +const drawPane = () => all(render(ProjectBoardPane, openablePaneProps)) +const rowAt = (path) => drawPane().find(node => node.props?.['data-path'] === path) +const openedAt = (path) => all(render(ProjectBoardPane, openablePaneProps)).filter(node => node.props?.['data-open-decision'] === path) +let openablePaneProps = null + +test('#2041: activating a decision row asks for that row and draws its text under it', async () => { + slots = [] + effects = [] + index = 0 + const calls = [] + const call = async (endpoint, payload) => { + calls.push({ endpoint, payload }) + return { ok: true, path: payload.path, text: '# Adopt the board record\n\nThe board lists this file.\n' } + } + openablePaneProps = openableRecord(call) + render(ProjectBoardPane, openablePaneProps) + const row = rowAt('decisions/2026-10-30-adopt.md') + assert.equal(row.type, 'button', 'the decision row is a button, not an inert div') + row.props.onClick() + await new Promise(resolve => setTimeout(resolve, 0)) + assert.deepEqual(plain(calls), [{ endpoint: 'project-decision', payload: { id: 'project-1', path: 'decisions/2026-10-30-adopt.md' } }]) + assert.match(drawPane().map(textOf).join(' | '), /The board lists this file/, 'the text is drawn under the row') + assert.equal(openedAt('decisions/2026-10-30-adopt.md').length, 1, 'the open row is identifiable') +}) + +test('#2041: a second activation collapses the open decision, one row at a time', async () => { + slots = [] + effects = [] + index = 0 + const calls = [] + const call = async (endpoint, payload) => { + calls.push({ endpoint, payload }) + return { ok: true, path: payload.path, text: `text of ${payload.path}\n` } + } + openablePaneProps = openableRecord(call) + render(ProjectBoardPane, openablePaneProps) + rowAt('decisions/2026-10-30-adopt.md').props.onClick() + await new Promise(resolve => setTimeout(resolve, 0)) + assert.equal(openedAt('decisions/2026-10-30-adopt.md').length, 1) + // The other row replaces it: one row open at a time. + rowAt('decisions/2026-10-31-second.md').props.onClick() + await new Promise(resolve => setTimeout(resolve, 0)) + assert.equal(openedAt('decisions/2026-10-30-adopt.md').length, 0, 'the first row closed') + assert.equal(openedAt('decisions/2026-10-31-second.md').length, 1, 'the second row is the open one') + // The same row again closes it, and closing asks the host nothing. + rowAt('decisions/2026-10-31-second.md').props.onClick() + await new Promise(resolve => setTimeout(resolve, 0)) + assert.equal(openedAt('decisions/2026-10-31-second.md').length, 0, 'the second activation collapsed it') + assert.equal(calls.length, 2, 'collapsing read nothing') +}) + +test('#2041: each refused answer reads its own sentence, never the markup it carries', async () => { + slots = [] + effects = [] + index = 0 + // The reader's three refusals, in the words the host sends them. + const answers = [ + 'too large: decisions/2026-10-30-adopt.md is 250000 bytes (bound 200000)', + "not listed: decisions/2026-10-30-adopt.md is not in this board's decision list", + 'absent: decisions/2026-10-30-adopt.md', + ] + const call = async () => ({ ok: false, reason: answers.shift() }) + openablePaneProps = openableRecord(call) + render(ProjectBoardPane, openablePaneProps) + const path = 'decisions/2026-10-30-adopt.md' + const sentences = [ + 'too large: decisions/2026-10-30-adopt.md is 250000 bytes (bound 200000)', + "not listed: decisions/2026-10-30-adopt.md is not in this board's decision list", + 'absent: decisions/2026-10-30-adopt.md', + ] + for (const [index_, sentence] of sentences.entries()) { + rowAt(path).props.onClick() + await new Promise(resolve => setTimeout(resolve, 0)) + const opened = openedAt(path) + assert.equal(opened.length, 1, `answer ${index_ + 1} is drawn once`) + assert.equal(textOf(opened[0]), sentence, 'the reason is text, whole') + assert.equal(opened[0].props['data-refused'], 'true') + // The row stays a row: a refusal is an answer beside the list, not an error banner replacing it. + assert.equal(rowAt(path).type, 'button') + // Closing reads nothing, and the next activation asks again. + rowAt(path).props.onClick() + await new Promise(resolve => setTimeout(resolve, 0)) + assert.equal(openedAt(path).length, 0, 'a second activation collapses the refusal') + } + assert.equal(drawPane().some(node => node.type === 'script'), false, 'nothing in an answer becomes an element') +}) + +// #2041: two rows, two answers, and the order they land in is not the order they were asked in. +// The record keeps the answer only while its own row is the open one, so a late answer to a row the +// reader already left is dropped instead of painted under the row now open. +test('#2041: an answer that lands after another row opened is never drawn', async () => { + slots = [] + effects = [] + index = 0 + const waiting = new Map() + const call = (endpoint, payload) => new Promise(resolve => waiting.set(payload.path, resolve)) + openablePaneProps = openableRecord(call) + render(ProjectBoardPane, openablePaneProps) + const first = 'decisions/2026-10-30-adopt.md' + const second = 'decisions/2026-10-31-second.md' + rowAt(first).props.onClick() + rowAt(second).props.onClick() + // The first row's answer arrives now, after the second row became the open one. + waiting.get(first)({ ok: true, path: first, text: '# An answer nobody is waiting for\n' }) + await new Promise(resolve => setTimeout(resolve, 0)) + const nodes = all(render(ProjectBoardPane, openablePaneProps)) + const drawn = nodes.map(textOf).join(' | ') + assert.equal(/An answer nobody is waiting for/.test(drawn), false, 'the late answer is not drawn') + const open = nodes.filter(node => node.props && node.props['data-open-decision']) + assert.equal(open.length, 1, 'one row is open') + assert.equal(open[0].props['data-open-decision'], second, 'the block belongs to the row now open') + assert.equal(/Reading the decision/.test(textOf(open[0])), true, 'it still waits for its own answer') + // The second row's own answer lands in its own block, and nothing else appears. + waiting.get(second)({ ok: true, path: second, text: '# The answer this row asked for\n' }) + await new Promise(resolve => setTimeout(resolve, 0)) + const after = all(render(ProjectBoardPane, openablePaneProps)) + assert.equal(textOf(openedAt(second)[0]), '# The answer this row asked for\n') + assert.equal(/An answer nobody is waiting for/.test(after.map(textOf).join(' | ')), false) +}) + +// #2041: the wire from the page to the panel. `ProjectBoardOverlay` passes its own read down as +// `call`; if that line goes, the panel's `call` is undefined and an activation returns silently — +// nothing happens and nothing says why. So the activation is driven from the overlay, not by +// handing the panel a fake `call` directly. +test('#2041: the overlay hands the board its own read, so a row opens through it', async () => { + resetClock() + win.location.hash = '#project' + const path = 'decisions/2026-10-30-adopt.md' + const asked = [] + const call = async (name, payload) => { + if (name === 'project-decision') asked.push({ name, payload }) + if (name === 'projects') return { projects: [{ id: 'project-1', name: 'FlyBot' }] } + if (name === 'project-read') return { project: { id: 'project-1', name: 'FlyBot' }, canManage: false } + if (name === 'project-board') { + return { + tasks: [], + waiting: [], inflight: [], blocked: [], counts: { waiting: 0, inflight: 0, blocked: 0, done: 0 }, + decisions: { + dir: '/team/flybot', reason: '', total: 1, + files: [{ path, name: '2026-10-30-adopt.md', date: '2026-10-30', title: 'Adopt the board record', sha256: 'a'.repeat(64), mtime: '2026-10-30T01:00:00.000Z' }], + }, + meetings: { decided: [], pending: [], reason: '' }, + } + } + if (name === 'tasks') return [] + if (name === 'project-team') return { rows: [] } + if (name === 'project-decision') return { ok: true, path: payload.path, text: '# Opened through the overlay\n' } + return {} + } + const { tree, redraw } = await mount({ actions: {}, call }) + const button = all(tree).find(node => node.type === 'button' && node.props && node.props['data-path'] === path) + assert.ok(button, 'the board draws the decision row') + assert.equal(typeof button.props.onClick, 'function', 'the row is activatable') + button.props.onClick() + await new Promise(resolve => setTimeout(resolve, 0)) + assert.deepEqual(plain(asked), [{ name: 'project-decision', payload: { id: 'project-1', path } }], 'the overlay own read carried the ask') + assert.match(textIn(redraw()), /Opened through the overlay/, 'the answer is drawn under the row') +}) diff --git a/patches/ds-bot/files/test/project-board.test.js b/patches/ds-bot/files/test/project-board.test.js index c64348ed1..0ebc93df5 100644 --- a/patches/ds-bot/files/test/project-board.test.js +++ b/patches/ds-bot/files/test/project-board.test.js @@ -8,6 +8,7 @@ import { join, resolve } from 'node:path' import { test } from 'node:test' import { createRuntime, provideFlyBot } from '../index.js' import { blockedOf } from '../src/host/project-board.js' +import { MAX_BYTES } from '../src/host/project-state.js' import { fakeContext } from './fake-ctx.js' const seed = () => ({ @@ -1103,3 +1104,269 @@ test('#2021: the meetings read reports how many of each list it found', async () assert.equal(board.meetings.pendingTotal, 103, 'the pending list says how many it found') } finally { await stop() } }) + +// #2041: a decision row can be opened. The board already listed the file; opening it asks the host +// for that one name, and the host reads only a name its own listing produced — `decisions/..`, +// an absolute path, a name one level down and a symlink are refused before any stat. +const folderOpenable = () => { + const base = mkdtempSync(join(tmpdir(), 'ds-bot-decisions-open-')) + const dir = join(base, 'project') + mkdirSync(join(dir, '.git'), { recursive: true }) + mkdirSync(join(dir, 'decisions')) + mkdirSync(join(dir, 'decisions', 'sub')) + writeFileSync(join(dir, 'decisions', '2026-10-30-adopt.md'), '# Adopt the board record\n\nThe board lists this file.\n') + writeFileSync(join(dir, 'decisions', '2026-10-30-big.md'), `# Big\n\n${'x'.repeat(MAX_BYTES + 1)}\n`) + writeFileSync(join(dir, 'decisions', 'sub', '2026-10-30-deep.md'), '# One level down\n') + // `decisions/../outside.md` resolves here. It is over the bound on purpose: a host that stat'd it + // would answer `too large`, so `not listed` is the proof the name was refused before the disk. + writeFileSync(join(dir, 'outside.md'), `# Outside the folder\n\n${'y'.repeat(MAX_BYTES + 1)}\n`) + // A legal name standing for a file outside the folder: the listing skips it, so opening answers + // as if the file were not there, never with the target's text. + symlinkSync(join(dir, 'outside.md'), join(dir, 'decisions', '2026-10-30-link.md')) + const absolute = join(base, 'secret.md') + writeFileSync(absolute, '# A file outside the Project folder\n') + return { base, dir, absolute } +} + +const openableSeed = (dir) => { + const team = seed() + team.projects['project-p1'].workDir = dir + return team +} + +async function startOpenable() { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-board-')) + const { base, dir, absolute } = folderOpenable() + const started = await start(home, openableSeed(dir)) + return { ...started, home, base, dir, absolute } +} + +test('#2041: a decision the board listed opens with its text', async () => { + const { rt, call, base, stop } = await startOpenable() + try { + // The page reads the board first; the set opening is checked against is that read's own. + const board = await call('project-board', { id: 'project-p1' }) + assert.equal(board.ok, true, JSON.stringify(board)) + assert.equal(board.value.decisions.files.some(file => file.path === 'decisions/2026-10-30-adopt.md'), true) + const opened = await call('project-decision', { id: 'project-p1', path: 'decisions/2026-10-30-adopt.md' }) + assert.equal(opened.ok, true, JSON.stringify(opened)) + assert.equal(opened.value.text, '# Adopt the board record\n\nThe board lists this file.\n') + assert.match(opened.value.sha256, /^[0-9a-f]{64}$/) + assert.equal(opened.value.path, 'decisions/2026-10-30-adopt.md') + // The declared read is the same answer: one rule, one place. + const direct = await rt.projectDecision('project-p1', 'decisions/2026-10-30-adopt.md') + assert.equal(direct.text, opened.value.text) + } finally { + await stop() + rmSync(base, { recursive: true, force: true }) + } +}) + +test('#2041: a name the board did not list answers not listed and is never read', async () => { + const { rt, base, absolute, stop } = await startOpenable() + try { + await rt.projectBoard('project-p1') + const refused = [ + ['decisions/../outside.md', 'a name that leaves decisions/'], + [absolute, 'an absolute path'], + ['decisions/sub/2026-10-30-deep.md', 'a name one level down'], + ['decisions/2026-10-30-link.md', 'a symlink with a legal name'], + ] + for (const [path, why] of refused) { + const answer = await rt.projectDecision('project-p1', path) + assert.equal(answer.ok, false, `${why} must not open`) + assert.match(answer.reason, /^not listed: /, `${why} answers not listed`) + assert.equal('text' in answer, false, `${why} carries no text`) + } + // `decisions/../outside.md` is over the bound: `too large` would prove a stat happened. + const escape = await rt.projectDecision('project-p1', 'decisions/../outside.md') + assert.equal(/too large/.test(escape.reason), false, 'the size was never measured') + assert.equal(existsSync(join(base, 'project', 'outside.md')), true, 'the file is there to be read') + } finally { + await stop() + rmSync(base, { recursive: true, force: true }) + } +}) + +test('#2041: a listed decision over the bound answers too large and hands over no text', async () => { + const { rt, base, stop } = await startOpenable() + try { + const board = await rt.projectBoard('project-p1') + assert.equal(board.decisions.files.some(file => file.path === 'decisions/2026-10-30-big.md'), true, 'the row is listed') + const answer = await rt.projectDecision('project-p1', 'decisions/2026-10-30-big.md') + assert.equal(answer.ok, false) + assert.match(answer.reason, /^too large: /) + assert.match(answer.reason, new RegExp(`bound ${MAX_BYTES}`)) + assert.equal('text' in answer, false, 'no partial text is returned') + } finally { + await stop() + rmSync(base, { recursive: true, force: true }) + } +}) + +test('#2041: a decision past the 200-row cut still opens', async () => { + const { rt, base, stop } = await startPastCap() + try { + const board = await rt.projectBoard('project-p1') + assert.equal(board.decisions.files.some(file => file.path === 'decisions/2026-01-02-000.md'), false, 'the row is outside the cut') + const answer = await rt.projectDecision('project-p1', 'decisions/2026-01-02-000.md') + assert.equal(answer.ok, true, JSON.stringify(answer)) + assert.equal(answer.text, '# Old decision 0\n') + } finally { + await stop() + rmSync(base, { recursive: true, force: true }) + } +}) + +test('#2041: a decision deleted after the board listed it answers absent', async () => { + const { rt, base, dir, stop } = await startOpenable() + try { + const board = await rt.projectBoard('project-p1') + assert.equal(board.decisions.files.some(file => file.path === 'decisions/2026-10-30-adopt.md'), true) + rmSync(join(dir, 'decisions', '2026-10-30-adopt.md')) + const answer = await rt.projectDecision('project-p1', 'decisions/2026-10-30-adopt.md') + assert.equal(answer.ok, false) + assert.equal(answer.reason, 'absent: decisions/2026-10-30-adopt.md') + assert.equal('text' in answer, false) + } finally { + await stop() + rmSync(base, { recursive: true, force: true }) + } +}) + +test('#2041: a decision a meeting archived opens from the folder the listing read it in', async () => { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-board-')) + const base = mkdtempSync(join(tmpdir(), 'ds-bot-decisions-archive-')) + const dir = join(base, 'project') + const archive = join(base, 'room') + mkdirSync(join(dir, '.git'), { recursive: true }) + mkdirSync(join(dir, 'decisions')) + mkdirSync(join(archive, 'decisions'), { recursive: true }) + // The file exists only in the folder the meeting recorded, never in the Project folder. + writeFileSync(join(archive, 'decisions', '2026-10-29-archived.md'), '# Archived by a meeting\n') + const team = seed() + team.projects['project-p1'].workDir = dir + team.meetings = { + 'meeting-archived': { + id: 'meeting-archived', status: 'decided', question: 'Where did this one land?', owner: 'Chief', + project: 'project-p1', decisionDir: archive, decisionFile: 'decisions/2026-10-29-archived.md', + createdAt: 1, updatedAt: 2, decidedAt: 3, + }, + } + const { rt, stop } = await start(home, team) + try { + const board = await rt.projectBoard('project-p1') + assert.equal(board.decisions.dir, dir, 'the board still names the Project folder as its own') + assert.equal(board.decisions.files.some(file => file.path === 'decisions/2026-10-29-archived.md'), true, 'the archived file is a row') + const answer = await rt.projectDecision('project-p1', 'decisions/2026-10-29-archived.md') + assert.equal(answer.ok, true, JSON.stringify(answer)) + assert.equal(answer.text, '# Archived by a meeting\n', 'read from the folder the listing read it in, not the Project folder') + } finally { + await stop() + rmSync(base, { recursive: true, force: true }) + } +}) + +// #2041: opening a decision is behind the same door as its cards (#1723). The board already answers +// `tasks: []` for a member who cannot see a Project's cards; the text of its decisions must not +// answer instead. A plain record follows the Project's role list; a name a decided meeting archived +// follows that meeting's own cards. Both refuse with the sentence a missing name answers, so the +// refusal cannot be read as a list of what exists. +const visibleSeed = (dir) => { + const team = crossSeed() + team.projects['project-p2'].workDir = dir + team.meetings = { + 'meeting-decided': { + id: 'meeting-decided', status: 'decided', question: 'What shipped?', owner: 'Writer', + project: 'project-p2', decisionDir: dir, decisionFile: 'decisions/2026-11-01-decided.md', + taskIds: ['task-b1'], createdAt: 1, updatedAt: 2, decidedAt: 3, + }, + } + return team +} + +test('#2041: a decision text is answered only to a caller who can see the Project', async () => { + const base = mkdtempSync(join(tmpdir(), 'ds-bot-decisions-visible-')) + const dir = join(base, 'project') + mkdirSync(join(dir, '.git'), { recursive: true }) + mkdirSync(join(dir, 'decisions')) + writeFileSync(join(dir, 'decisions', '2026-11-01-decided.md'), '# P2 decided this\n') + writeFileSync(join(dir, 'decisions', '2026-11-01-plain.md'), '# A plain record\n') + // Over the bound on purpose: a host that stat'd it would answer `too large`, so `not listed` is + // the proof that a caller who may not read it never reached the disk. + writeFileSync(join(dir, 'decisions', '2026-11-01-secret.md'), `# Secret\n\n${'s'.repeat(MAX_BYTES + 1)}\n`) + const { rt, callAs, stop } = await start(undefined, visibleSeed(dir)) + try { + rt.authenticate = async token => (token === 'tok-1' ? { memberId: 'owner-1' } : token === 'tok-2' ? { memberId: 'owner-2' } : null) + rt.state.authSessions = { 's-1': { memberId: 'owner-1', tokenHash: 'a' }, 's-2': { memberId: 'owner-2', tokenHash: 'b' } } + // The board the two members read: owner-1 sees no card of this Project at all. + const theirs = await callAs('project-board', { id: 'project-p2' }, 'tok-1') + assert.equal(theirs.ok, true, JSON.stringify(theirs)) + assert.deepEqual(theirs.value.tasks ?? [], [], 'owner-1 sees no card of this Project') + // Member B, whose Bot owns the card and the Project, opens the meeting's decision. + await callAs('project-board', { id: 'project-p2' }, 'tok-2') + const own = await callAs('project-decision', { id: 'project-p2', path: 'decisions/2026-11-01-decided.md' }, 'tok-2') + assert.equal(own.ok, true, JSON.stringify(own)) + assert.equal(own.value.text, '# P2 decided this\n', 'the meeting card is visible to its owner') + // The plain record has no card: the Project's own role list decides, and owner-2 owns it. + const plain = await callAs('project-decision', { id: 'project-p2', path: 'decisions/2026-11-01-plain.md' }, 'tok-2') + assert.equal(plain.ok, true, JSON.stringify(plain)) + assert.equal(plain.value.text, '# A plain record\n') + // Owner-1 is neither in the Project's role list nor the owner of a card: every name refuses, + // in the words a name the listing never had answers. + for (const path of ['decisions/2026-11-01-decided.md', 'decisions/2026-11-01-plain.md', 'decisions/2026-11-01-secret.md']) { + const answer = await callAs('project-decision', { id: 'project-p2', path }, 'tok-1') + assert.equal(answer.ok, true, JSON.stringify(answer)) + assert.equal(answer.value.ok, false, `${path} must not open for a caller who sees no card`) + assert.equal(answer.value.reason, `not listed: ${path} is not in this board's decision list`) + assert.equal('text' in answer.value, false, `${path} carries no text`) + assert.equal('sha256' in answer.value, false, `${path} carries no hash`) + } + // The bound file is the proof: the caller who may read it is told its size, the caller who may + // not is never told anything the disk would have said. + const sized = await callAs('project-decision', { id: 'project-p2', path: 'decisions/2026-11-01-secret.md' }, 'tok-2') + assert.match(sized.value.reason, /^too large: /, 'a visible caller does reach the file') + } finally { + await stop() + rmSync(base, { recursive: true, force: true }) + } +}) + +test('#2041: revoking a card closes the name a board read already cached', async () => { + const base = mkdtempSync(join(tmpdir(), 'ds-bot-decisions-revoke-')) + const dir = join(base, 'project') + mkdirSync(join(dir, '.git'), { recursive: true }) + mkdirSync(join(dir, 'decisions')) + writeFileSync(join(dir, 'decisions', '2026-11-02-team.md'), '# Team decision\n') + const team = crossSeed() + team.projects['project-p1'].workDir = dir + team.meetings = { + 'meeting-team': { + id: 'meeting-team', status: 'decided', question: 'Who ships it?', owner: 'Chief', + project: 'project-p1', decisionDir: dir, decisionFile: 'decisions/2026-11-02-team.md', + taskIds: ['task-group'], createdAt: 1, updatedAt: 2, decidedAt: 3, + }, + } + const { rt, callAs, stop } = await start(undefined, team) + try { + rt.authenticate = async token => (token === 'tok-1' ? { memberId: 'owner-1' } : null) + rt.state.authSessions = { 's-1': { memberId: 'owner-1', tokenHash: 'a' } } + await callAs('project-board', { id: 'project-p1' }, 'tok-1') + const first = await callAs('project-decision', { id: 'project-p1', path: 'decisions/2026-11-02-team.md' }, 'tok-1') + assert.equal(first.ok, true, JSON.stringify(first)) + assert.equal(first.value.text, '# Team decision\n') + // The same cached read, after the card it was judged against moved to another owner and another + // room: the name is still in the listing, and the next open is judged again rather than served + // from the cache the first read left behind. + Object.assign(rt.state.tasks['task-group'], { ownerBotId: 'b', origin: { roomId: 'r2' } }) + const again = await callAs('project-board', { id: 'project-p1' }, 'tok-1') + assert.equal(again.value.decisions.files.some(file => file.path === 'decisions/2026-11-02-team.md'), true, 'the row is still listed') + const revoked = await callAs('project-decision', { id: 'project-p1', path: 'decisions/2026-11-02-team.md' }, 'tok-1') + assert.equal(revoked.value.ok, false, JSON.stringify(revoked)) + assert.equal(revoked.value.reason, "not listed: decisions/2026-11-02-team.md is not in this board's decision list") + assert.equal('text' in revoked.value, false) + } finally { + await stop() + rmSync(base, { recursive: true, force: true }) + } +})