From bf4fed1cefcb7190775b91578103fc1004add153 Mon Sep 17 00:00:00 2001 From: Dev-3 Date: Mon, 2 Nov 2026 18:01:00 +0000 Subject: [PATCH] fix(project-board): a finished card keeps its text and the board it opens (#2198) The Done column holds finished records and the `tasks` read keeps open cards only, so a card there had no record to join for its title, its priority or the board it opens: it drew as `#` with no priority, and it could not be clicked through to the work it belongs to. The row now carries `title`, `priority` and `board`, and the client prefers the row and falls back to the join. --- changelog.d/2198-board-done-rows.fixed.md | 1 + core/client.js | 9 +- core/src/client/project-board.js | 24 ++- core/src/host/project-board.js | 13 +- core/test/project-board-view.test.js | 27 ++++ core/test/project-board.test.js | 49 +++++- docs/subsystems/core-client.md | 2 +- docs/subsystems/core-host.md | 2 +- docs/subsystems/workflow.md | 2 +- patches/ds-bot/0008-client-bundle.patch | 142 +++++++++--------- .../ds-bot/files/src/client/project-board.js | 24 ++- .../ds-bot/files/src/host/project-board.js | 13 +- .../files/test/project-board-view.test.js | 27 ++++ .../ds-bot/files/test/project-board.test.js | 49 +++++- 14 files changed, 290 insertions(+), 94 deletions(-) create mode 100644 changelog.d/2198-board-done-rows.fixed.md diff --git a/changelog.d/2198-board-done-rows.fixed.md b/changelog.d/2198-board-done-rows.fixed.md new file mode 100644 index 000000000..9c1cc0d3a --- /dev/null +++ b/changelog.d/2198-board-done-rows.fixed.md @@ -0,0 +1 @@ +A card in the Project board's **Done** group keeps its title, its priority and the board it opens: the done column no longer draws `#` with no priority, and clicking the card opens the Bot or group the work lives on ([#2198](https://github.com/ThinkFlowLab/FlyBot/issues/2198)). diff --git a/core/client.js b/core/client.js index 7da4f3072..d0c77cac6 100644 --- a/core/client.js +++ b/core/client.js @@ -10622,6 +10622,11 @@ var MEMORY_TEXT_MAX = 600; var memoryLength = (value) => [...text(value)].length; var DONE_COLUMN = "done"; var boardOfRow = (row, task) => { + const stated = row?.board; + if (stated !== null && typeof stated === "object") { + const id = text(stated.id); + if (id !== "" && (stated.kind === "group" || stated.kind === "bot")) return { kind: stated.kind, id }; + } if (task === void 0) return null; if (row.source === "group") { const id = text(task.origin?.roomId); @@ -10728,8 +10733,8 @@ function projectBoardView({ project, rows, done, canManage, tasks, decisions, me assignee: text(row.assignee) || null, runId: text(row.runId) || null, nodeId: text(row.nodeId) || null, - title: text(task?.title) || text(task?.note) || null, - priority: text(task?.priority) || null, + title: text(row.title) || text(task?.title) || text(task?.note) || null, + priority: text(row.priority) || text(task?.priority) || null, audit: text(row.audit) || null, // #1831: who is doing the card, and who is speaking for whom. Both are host truth; // a card carrying neither renders exactly as before. diff --git a/core/src/client/project-board.js b/core/src/client/project-board.js index ea9e3086b..e1d65516b 100644 --- a/core/src/client/project-board.js +++ b/core/src/client/project-board.js @@ -148,10 +148,18 @@ const memoryLength = (value) => [...text(value)].length // keeps them out of every group of work in flight. const DONE_COLUMN = 'done' -// The board a row opens, read from the host's own `source` rather than re-deciding membership: -// a group row names its room, a bot row its owner, and a workflow row is one the run carried in, -// so it has no board of its own to open. +// The board a row opens. The host row names it (#2198), which is the only way a finished card can +// open at all: the `tasks` read keeps open cards, so a Done row has no record to derive it from. +// A row from a host that predates the field still derives it the old way — a group row names its +// room, a bot row its owner, and a workflow row is one the run carried in, so it has no board of +// its own to open. The kind is checked against the two boards a card can live on, because the +// value becomes a `#task=:` hash and an unknown kind would land nowhere. const boardOfRow = (row, task) => { + const stated = row?.board + if (stated !== null && typeof stated === 'object') { + const id = text(stated.id) + if (id !== '' && (stated.kind === 'group' || stated.kind === 'bot')) return { kind: stated.kind, id } + } if (task === undefined) return null if (row.source === 'group') { const id = text(task.origin?.roomId) @@ -256,8 +264,10 @@ export function projectBoardRecord({ decisions, meetings } = {}) { // Pure: no fetch, no React, no store. `rows` is the host's read model, `canManage` comes from // `project-read` and only decides what the caller may *ask* for — the host refuses regardless. -// `tasks` is the one task read joined by id: the row carries an id and who holds it, not its text. -// `decisions` and `meetings` are the host's read of the Project's record (#1767), passed through. +// `tasks` is the one task read joined by id, and it is a fallback only: the row carries the card's +// own title, priority and board, which is all a finished card has (the `tasks` read keeps open +// cards, so a Done card has no record there to join). `decisions` and `meetings` are the host's read +// of the Project's record (#1767), passed through. // `queues` is the other half of that same read (#1768): the waiting, in-flight and blocked cards // and their counts, as the host answered them. export function projectBoardView({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics } = {}) { @@ -288,8 +298,8 @@ export function projectBoardView({ project, rows, done, canManage, tasks, decisi assignee: text(row.assignee) || null, runId: text(row.runId) || null, nodeId: text(row.nodeId) || null, - title: text(task?.title) || text(task?.note) || null, - priority: text(task?.priority) || null, + title: text(row.title) || text(task?.title) || text(task?.note) || null, + priority: text(row.priority) || text(task?.priority) || null, audit: text(row.audit) || null, // #1831: who is doing the card, and who is speaking for whom. Both are host truth; // a card carrying neither renders exactly as before. diff --git a/core/src/host/project-board.js b/core/src/host/project-board.js index 65205556c..f904a3311 100644 --- a/core/src/host/project-board.js +++ b/core/src/host/project-board.js @@ -18,7 +18,7 @@ // sections. It writes nothing, starts nothing and is not called unless someone asks for the board. import { open } from 'node:fs/promises' import { join } from 'node:path' -import { columnOf } from '../shared/task-board.js' +import { columnOf, priorityOf } from '../shared/task-board.js' import { DECISIONS_DIR, describeFile, isDecisionPath, listProjectState, readListedFile } from './project-state.js' import { cut } from './text.js' import { advanceRefusal } from './meetings.js' @@ -491,9 +491,20 @@ export function install(rt) { mine.push(task) const fromRun = ref !== undefined && (runProject === null || runProject.id === id) const audit = typeof rt.taskAudit === 'function' ? rt.taskAudit(task.id) : '' + const title = text(task.title) rows.push({ id: task.id, source, + // The card's own text and priority, read here so a row is enough to draw a card. A finished + // card is not in the open `tasks` read, and a join by id would leave the Done column with + // `#` and no priority; a record stored before this field reads `null` / `'normal'`. + title: title === '' ? null : title, + priority: priorityOf(task), + // The board this row opens (#2198), so a finished card — which the open `tasks` read no + // longer lists — still opens from the Done column instead of drawing as a dead card. It + // names what `source` names: a run-carried row (`source: 'workflow'`) has no board of its + // own to open, which is the same `null` the client derives for it today. + board: source === 'workflow' || board === null ? null : { kind: board.kind, id: board.id }, column: columnOf(task), assignee: task.assignee ?? null, // #1831: "who is doing" is the claim, not the assignee label, and "who is speaking for diff --git a/core/test/project-board-view.test.js b/core/test/project-board-view.test.js index 55066e26b..a1fd5842d 100644 --- a/core/test/project-board-view.test.js +++ b/core/test/project-board-view.test.js @@ -117,6 +117,33 @@ test('the board groups rows by their source and keeps each task id', () => { assert.deepEqual(view.done.rows.map(row => row.id), ['task-g1'], 'the finished row is the archive') }) +// P-167: the `tasks` read keeps open cards only, so a finished card has no record there to join. +// The row carries the card's own text and priority, and the Done column draws that — never `#`. +test('#P-167: a finished card keeps its title and priority with no task record to join', () => { + const done = { id: 'task-done', source: 'bot', column: 'done', assignee: null, updatedAt: 7, title: 'Ship the release', priority: 'high' } + const view = plain(projectBoardView({ project: { id: 'p', name: 'P' }, rows: [], done: [done], tasks: [] })) + assert.equal(view.done.rows[0].title, 'Ship the release') + assert.equal(view.done.rows[0].priority, 'high') + const drawn = all(ProjectBoardPane({ project: { id: 'p', name: 'FlyBot' }, rows: [], done: [done], tasks: [] })) + const title = drawn.find(node => node.props?.className === 'bt-project-title') + assert.equal(textOf(title), 'Ship the release') +}) + +// #2198: a finished card is not only text — it is a card, so it opens the board it lives on. With +// no `tasks` record to join, the board has to come from the row; without it the card drew as a +// dead row with no click at all. +test('#2198: a finished card opens the board its row names, with no task record to join', () => { + const done = { id: 'task-done', source: 'group', column: 'done', assignee: null, updatedAt: 7, title: 'Ship the release', priority: 'high', board: { kind: 'group', id: 'r1' } } + const view = plain(projectBoardView({ project: { id: 'p', name: 'P' }, rows: [], done: [done], tasks: [] })) + assert.deepEqual(view.done.rows[0].board, { kind: 'group', id: 'r1' }) + const opened = [] + const drawn = all(ProjectBoardPane({ project: { id: 'p', name: 'FlyBot' }, rows: [], done: [done], tasks: [], onOpen: board => opened.push(board) })) + const card = drawn.find(node => node.props?.className === 'bt-project-row') + assert.equal(card.props.role, 'button', 'the finished card is offered as a control') + card.props.onClick() + assert.deepEqual(plain(opened), [{ kind: 'group', id: 'r1' }]) +}) + test('a workflow row carries its run and node; other rows carry none', () => { const view = plain(projectBoardView({ project: { id: 'p', name: 'P' }, rows, canManage: false })) const [botRow] = view.groups[0].rows diff --git a/core/test/project-board.test.js b/core/test/project-board.test.js index c6925ca7a..a8e71d800 100644 --- a/core/test/project-board.test.js +++ b/core/test/project-board.test.js @@ -100,9 +100,9 @@ test('a Project board reads a group card and a run-created card without repeatin const rows = (await rt.projectBoard('project-p1')).rows assert.equal(rows.length, 2) const group = rows.find(row => row.id === 'task-group') - assert.deepEqual(group, { id: 'task-group', source: 'group', column: 'in-progress', assignee: 'Writer', updatedAt: 10, runId: 'run-2', nodeId: 'planner' }) + assert.deepEqual(group, { id: 'task-group', source: 'group', title: 'Ship it', priority: 'normal', board: { kind: 'group', id: 'r1' }, column: 'in-progress', assignee: 'Writer', updatedAt: 10, runId: 'run-2', nodeId: 'planner' }) const run = rows.find(row => row.id === 'task-run') - assert.deepEqual(run, { id: 'task-run', source: 'workflow', column: 'in-progress', assignee: null, updatedAt: 20, runId: 'run-1', nodeId: 'writer' }) + assert.deepEqual(run, { id: 'task-run', source: 'workflow', title: 'Run card', priority: 'normal', board: null, column: 'in-progress', assignee: null, updatedAt: 20, runId: 'run-1', nodeId: 'writer' }) // Never the same task twice. assert.equal(new Set(rows.map(row => row.id)).size, rows.length) } finally { @@ -111,6 +111,51 @@ test('a Project board reads a group card and a run-created card without repeatin } }) +// P-167: a finished card is still a card. The `tasks` read lists open cards only, so the Done +// column has no task record to join against — the row itself has to carry the text and the +// priority, or the card draws as `#` with no priority at all. +test('a finished card carries its title and priority on the row the Done column draws', async () => { + const team = seed() + team.tasks['task-done'] = { id: 'task-done', kind: 'group', status: 'accepted', ownerBotId: 'a', origin: { roomId: 'r1' }, assignee: 'Writer', title: 'Ship the release', priority: 'high', createdAt: 3, updatedAt: 4 } + const { rt, stop } = await start(undefined, team) + try { + const board = await rt.projectBoard('project-p1') + const card = board.done.find(row => row.id === 'task-done') + assert.equal(card.title, 'Ship the release') + assert.equal(card.priority, 'high') + // The read the page joins against keeps open cards only, so a finished card is not in it. + const listed = await rt.taskEndpoint('tasks', {}, null) + assert.equal(listed.some(task => task.id === 'task-done'), false) + } finally { + await stop() + } +}) + +// #2198: a row names the board it opens, so the Done column is not a shelf of dead cards. With no +// `tasks` record to join, the client had no way to derive the board and the card drew without a +// click at all; the row carries it, and a run-carried row keeps naming none of its own. +test('a finished card names the board it opens on the row the Done column draws', async () => { + const team = seed() + team.tasks['task-done'] = { id: 'task-done', kind: 'group', status: 'accepted', ownerBotId: 'a', origin: { roomId: 'r1' }, assignee: 'Writer', title: 'Ship the release', priority: 'high', createdAt: 3, updatedAt: 4 } + const { rt, stop } = await start(undefined, team) + const off = rt.onProjectProvider(({ hasTask }) => [ + { taskId: 'task-run', runId: 'run-1' }, + ].filter(row => hasTask(row.taskId))) + try { + const board = await rt.projectBoard('project-p1') + const card = board.done.find(row => row.id === 'task-done') + assert.deepEqual(card.board, { kind: 'group', id: 'r1' }) + // The run carried this card in, and `source: 'workflow'` is the run's binding, not a board. + const runCard = board.rows.find(row => row.id === 'task-run') + assert.equal(runCard.source, 'workflow') + assert.equal(runCard.board, null) + } finally { + off() + await stop() + } +}) + + test('the board takes its token from the host secret store and its zone from the call', async () => { const { rt, stop } = await start() const realSecret = rt.secretValue diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 4c8729699..fb9cc45fd 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -446,4 +446,4 @@ inferred from an assignee label. ## Project board metrics and the running table (#1829) -`core/src/client/project-board.js` draws the seven cards the host's `project-board` read carries in `metrics`: four day counts and three seven-day medians, each with a one-line caption naming the field and the window. A value the host answered `null` renders `—` (u+2014) and never `0`; an `ok: false` read draws the same cards plus one line naming the host's reason. `metricsView` is pure and takes the host's object as it came. A meeting row also draws the proxy line (#1861): `projectBoardRecord` keeps each meeting's `participants` with their `speaksFor`, and the decided and pending rows render every proxy as ` for {user}` in the same `for {user}` words a task row uses for its holder, with the spoken-for ids also on `data-speaks-for`. The running table below renders `metrics.running` — a claimed card's title, status, holder, claim age (from the claim's own start), source and last activity — marks a row whose claim passed `RUNNING_OVERDUE_MS` and sends a click through the same `onOpen` the board rows use; with no rows it draws one empty line. The Waiting / In flight / Blocked numbers stay on the one-line count header beside those groups. +`core/src/client/project-board.js` draws the seven cards the host's `project-board` read carries in `metrics`: four day counts and three seven-day medians, each with a one-line caption naming the field and the window. A value the host answered `null` renders `—` (u+2014) and never `0`; an `ok: false` read draws the same cards plus one line naming the host's reason. `metricsView` is pure and takes the host's object as it came. A meeting row also draws the proxy line (#1861): `projectBoardRecord` keeps each meeting's `participants` with their `speaksFor`, and the decided and pending rows render every proxy as ` for {user}` in the same `for {user}` words a task row uses for its holder, with the spoken-for ids also on `data-speaks-for`. The running table below renders `metrics.running` — a claimed card's title, status, holder, claim age (from the claim's own start), source and last activity — marks a row whose claim passed `RUNNING_OVERDUE_MS` and sends a click through the same `onOpen` the board rows use; with no rows it draws one empty line. The Waiting / In flight / Blocked numbers stay on the one-line count header beside those groups. A card's title, priority and board come off the row the host sent and fall back to the `tasks` read joined by id — that read keeps open cards only, so a finished card in the `done` half has no record to join and would otherwise draw as `#` with no priority and no board to open; `boardOfRow` prefers the row's `{ kind, id }` and derives it only when the row carries none, and a `workflow` row keeps no board. A row stored or served before the row carried text still reads exactly as it did. diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 8d10e8c23..41e2fcd25 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -212,7 +212,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 #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)). A decided meeting that saved no folder keeps the cards it decided: its file is one of the Project folder's own rows, and binding those cards to that row is what keeps the name behind the same door as the cards, while a meeting that decided no card still answers through the role list ([#2052](https://github.com/ThinkFlowLab/FlyBot/issues/2052)). A record that named a folder whose file this read could not open is not that folder-less shape: it binds no cards, so the same relative name in the Project's own folder answers through the role list instead of inheriting the cards of a file in another folder. 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, plus the card's own `title` (`null` when the record has none), `priority` (`priorityOf`, `'normal'` when the record stored none) and `board` (the `{ kind, id }` the row opens, `null` on a `workflow` row) so a row draws a card and opens the board it lives on: the open `tasks` read a page joins by id keeps open cards only, so a finished card in the `done` half has no record there and would otherwise draw as `#` with no priority and nowhere to go. `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)). A decided meeting that saved no folder keeps the cards it decided: its file is one of the Project folder's own rows, and binding those cards to that row is what keeps the name behind the same door as the cards, while a meeting that decided no card still answers through the role list ([#2052](https://github.com/ThinkFlowLab/FlyBot/issues/2052)). A record that named a folder whose file this read could not open is not that folder-less shape: it binds no cards, so the same relative name in the Project's own folder answers through the role list instead of inheriting the cards of a file in another folder. 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/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index 9dbd43c8d..4349dfe7d 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -69,7 +69,7 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher, ## One task identity (#1696) -A core board task (`state.tasks`, `core/src/host/tasks.js`) is the only task identity. A run that works on a task holds that same id in `run.task.id` and never mints a second record: the id on the card, the id in `run.task` and the id in the claim map are one string. The Project board proves it in one read: `project-board` on `api/bot` (`core/src/host/project-board.js`) takes a Project id and answers `{ tasks: [{ id, source, column, assignee, updatedAt, runId?, nodeId? }] }`, one row per task. A card a run consumed and a card it created are each one row; `source` is `bot` or `group` from the card's own board, and `workflow` when only the run's Project binding carries the card. +A core board task (`state.tasks`, `core/src/host/tasks.js`) is the only task identity. A run that works on a task holds that same id in `run.task.id` and never mints a second record: the id on the card, the id in `run.task` and the id in the claim map are one string. The Project board proves it in one read: `project-board` on `api/bot` (`core/src/host/project-board.js`) takes a Project id and answers `{ tasks: [{ id, source, column, assignee, updatedAt, runId?, nodeId?, title, priority, board }] }`, one row per task. A card a run consumed and a card it created are each one row; `source` is `bot` or `group` from the card's own board, and `workflow` when only the run's Project binding carries the card. `title`, `priority` and `board` (the `{ kind, id }` the card opens, `null` on a `workflow` row) travel on the row because the `tasks` read keeps open cards only: a finished card in the `done` half has no record to join, and the row is what draws it and lets it be opened. Core never reads `$DSH_HOME/bot/flybot-workflow`. The plugin registers `flybot.tasks.onProjectProvider(fn)`; the host calls it with `hasTask(taskId)` and the plugin answers `{ taskId, runId, nodeId? }` from `service.projectTasks()` — one entry per live run that holds a card, `nodeId` being the running node (else the last node handed the card). A run whose card is gone is dropped by the plugin before it can become a row. The aggregate writes nothing: no card, claim or run changes when the board is read, so an install with no Projects, and every caller that never asks, behaves exactly as before. diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index a22782175..1a199ab0c 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -5022,7 +5022,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,6823 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} +@@ -2574,2935 +2574,6828 @@ 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}} @@ -10580,10 +10580,6 @@ 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")); +function dropLabel(column) { + if (column === "in-progress") return t("Start task"); + if (column === "done") return t("Complete task"); @@ -10606,6 +10602,10 @@ diff --git a/client.js b/client.js + map.set(id, []); + order.push(id); } +- 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")); + map.get(id).push(task); @@ -11274,65 +11274,10 @@ diff --git a/client.js b/client.js - }, [done, sessionId, callId]); - let replies = live.replies; - if (done) { -- try { -- replies = JSON.parse(contentText(content)).replies ?? []; -- } catch { -- replies = []; -- } -- } -- const pending = done || !live.speaking ? [] : [live.speaking]; -- const speakerTool = useWork?.((work) => work.bots?.[live.speaking]?.tool) ?? void 0; -- if (replies.length === 0 && pending.length === 0) return null; -- const room2 = roster.roomsById[sessionId]; -- return (0, import_react6.createElement)( + }, [onClose]); + const items = projectMenuItems(project); + return (0, import_react_dom3.createPortal)((0, import_react16.createElement)( - "div", -- { className: "bt-group-replies" }, -- replies.map((reply, index) => { -- const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" }; -- if (reply.kind === "user") { -- return (0, import_react6.createElement)("div", { key: `user-${index}`, className: "bt-urow" }, (0, import_react6.createElement)( -- "div", -- { className: "bt-ububble" }, -- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: reply.text, labels: mdLabels(), variant: "compact" }) -- )); -- } -- if (reply.kind === "failed") { -- return (0, import_react6.createElement)( -- "div", -- { key: `${reply.botId}-${index}`, className: "bt-event bt-event-error", role: "status", title: [reply.code, reply.status, reply.text].filter(Boolean).join(" · ") }, -- t("{name} could not answer: {reason}", { name: bot.name, reason: failureLabel(reply.code, reply.text) }) -- ); -- } -- if (reply.kind === "lookup") { -- return (0, import_react6.createElement)( -- "div", -- { key: `${reply.botId}-${index}`, className: "bt-event" }, -- reply.text ? t("{name} looked up its own chat for “{text}”", { name: bot.name, text: reply.text }) : t("{name} looked up its own chat", { name: bot.name }) -- ); -- } -- if (reply.kind === "missed") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} didn't answer in time", { name: bot.name })); -- } -- if (reply.kind === "resumed") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart and picked its work back up", { name: bot.name })); -- } -- if (reply.kind === "cut") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart again and stopped", { name: bot.name })); -- } -- if (reply.kind === "approval" || reply.kind === "working") { -- return (0, import_react6.createElement)( -- "div", -- { key: `${reply.botId}-${index}`, className: "bt-event" }, -- (0, import_react6.createElement)( -- "button", -- { type: "button", className: "bt-event-whole", onClick: () => actions.showDetails(sessionId) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), -- (0, import_react6.createElement)("span", { className: "bt-event-name" }, t(reply.kind === "working" ? "{name} started on it" : "{name} paused for your approval", { name: bot.name })) -- ) -- ); ++ "div", + { + ref, + className: "bt-project-menu", @@ -11352,9 +11297,7 @@ diff --git a/client.js b/client.js + onClick: () => { + if (item.pick) onPick(item.role); + else onClose(); - } -- if (reply.kind === "stopped") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); ++ } + }, item.label)) + ), document.body); +} @@ -11373,7 +11316,10 @@ diff --git a/client.js b/client.js + const [note, setNote] = (0, import_react16.useState)(""); + const [menu, setMenu] = (0, import_react16.useState)(null); + const load = (0, import_react16.useCallback)(async () => { -+ try { + try { +- replies = JSON.parse(contentText(content)).replies ?? []; +- } catch { +- replies = []; + const listing = await read("projects"); + const list = Array.isArray(listing?.projects) ? listing.projects : []; + setProjects(list); @@ -11381,7 +11327,13 @@ diff --git a/client.js b/client.js + } catch (failure) { + setProjects([]); + setError(failure?.message ?? String(failure)); -+ } + } +- } +- const pending = done || !live.speaking ? [] : [live.speaking]; +- const speakerTool = useWork?.((work) => work.bots?.[live.speaking]?.tool) ?? void 0; +- if (replies.length === 0 && pending.length === 0) return null; +- const room2 = roster.roomsById[sessionId]; +- return (0, import_react6.createElement)( + }, [read]); + (0, import_react16.useEffect)(() => { + if (!expanded) return; @@ -11397,7 +11349,54 @@ diff --git a/client.js b/client.js + }; + const listed = expanded && wide; + return (0, import_react16.createElement)( -+ "div", + "div", +- { className: "bt-group-replies" }, +- replies.map((reply, index) => { +- const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" }; +- if (reply.kind === "user") { +- return (0, import_react6.createElement)("div", { key: `user-${index}`, className: "bt-urow" }, (0, import_react6.createElement)( +- "div", +- { className: "bt-ububble" }, +- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: reply.text, labels: mdLabels(), variant: "compact" }) +- )); +- } +- if (reply.kind === "failed") { +- return (0, import_react6.createElement)( +- "div", +- { key: `${reply.botId}-${index}`, className: "bt-event bt-event-error", role: "status", title: [reply.code, reply.status, reply.text].filter(Boolean).join(" · ") }, +- t("{name} could not answer: {reason}", { name: bot.name, reason: failureLabel(reply.code, reply.text) }) +- ); +- } +- if (reply.kind === "lookup") { +- return (0, import_react6.createElement)( +- "div", +- { key: `${reply.botId}-${index}`, className: "bt-event" }, +- reply.text ? t("{name} looked up its own chat for “{text}”", { name: bot.name, text: reply.text }) : t("{name} looked up its own chat", { name: bot.name }) +- ); +- } +- if (reply.kind === "missed") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} didn't answer in time", { name: bot.name })); +- } +- if (reply.kind === "resumed") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart and picked its work back up", { name: bot.name })); +- } +- if (reply.kind === "cut") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart again and stopped", { name: bot.name })); +- } +- if (reply.kind === "approval" || reply.kind === "working") { +- return (0, import_react6.createElement)( +- "div", +- { key: `${reply.botId}-${index}`, className: "bt-event" }, +- (0, import_react6.createElement)( +- "button", +- { type: "button", className: "bt-event-whole", onClick: () => actions.showDetails(sessionId) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), +- (0, import_react6.createElement)("span", { className: "bt-event-name" }, t(reply.kind === "working" ? "{name} started on it" : "{name} paused for your approval", { name: bot.name })) +- ) +- ); +- } +- if (reply.kind === "stopped") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); + { className: "bt-projects" }, + (0, import_react16.createElement)( + "button", @@ -13116,6 +13115,11 @@ diff --git a/client.js b/client.js +var memoryLength = (value) => [...text(value)].length; +var DONE_COLUMN = "done"; +var boardOfRow = (row, task) => { ++ const stated = row?.board; ++ if (stated !== null && typeof stated === "object") { ++ const id = text(stated.id); ++ if (id !== "" && (stated.kind === "group" || stated.kind === "bot")) return { kind: stated.kind, id }; ++ } + if (task === void 0) return null; + if (row.source === "group") { + const id = text(task.origin?.roomId); @@ -13234,8 +13238,8 @@ diff --git a/client.js b/client.js + assignee: text(row.assignee) || null, + runId: text(row.runId) || null, + nodeId: text(row.nodeId) || null, -+ title: text(task?.title) || text(task?.note) || null, -+ priority: text(task?.priority) || null, ++ title: text(row.title) || text(task?.title) || text(task?.note) || null, ++ priority: text(row.priority) || text(task?.priority) || null, + audit: text(row.audit) || null, + // #1831: who is doing the card, and who is speaking for whom. Both are host truth; + // a card carrying neither renders exactly as before. diff --git a/patches/ds-bot/files/src/client/project-board.js b/patches/ds-bot/files/src/client/project-board.js index ea9e3086b..e1d65516b 100644 --- a/patches/ds-bot/files/src/client/project-board.js +++ b/patches/ds-bot/files/src/client/project-board.js @@ -148,10 +148,18 @@ const memoryLength = (value) => [...text(value)].length // keeps them out of every group of work in flight. const DONE_COLUMN = 'done' -// The board a row opens, read from the host's own `source` rather than re-deciding membership: -// a group row names its room, a bot row its owner, and a workflow row is one the run carried in, -// so it has no board of its own to open. +// The board a row opens. The host row names it (#2198), which is the only way a finished card can +// open at all: the `tasks` read keeps open cards, so a Done row has no record to derive it from. +// A row from a host that predates the field still derives it the old way — a group row names its +// room, a bot row its owner, and a workflow row is one the run carried in, so it has no board of +// its own to open. The kind is checked against the two boards a card can live on, because the +// value becomes a `#task=:` hash and an unknown kind would land nowhere. const boardOfRow = (row, task) => { + const stated = row?.board + if (stated !== null && typeof stated === 'object') { + const id = text(stated.id) + if (id !== '' && (stated.kind === 'group' || stated.kind === 'bot')) return { kind: stated.kind, id } + } if (task === undefined) return null if (row.source === 'group') { const id = text(task.origin?.roomId) @@ -256,8 +264,10 @@ export function projectBoardRecord({ decisions, meetings } = {}) { // Pure: no fetch, no React, no store. `rows` is the host's read model, `canManage` comes from // `project-read` and only decides what the caller may *ask* for — the host refuses regardless. -// `tasks` is the one task read joined by id: the row carries an id and who holds it, not its text. -// `decisions` and `meetings` are the host's read of the Project's record (#1767), passed through. +// `tasks` is the one task read joined by id, and it is a fallback only: the row carries the card's +// own title, priority and board, which is all a finished card has (the `tasks` read keeps open +// cards, so a Done card has no record there to join). `decisions` and `meetings` are the host's read +// of the Project's record (#1767), passed through. // `queues` is the other half of that same read (#1768): the waiting, in-flight and blocked cards // and their counts, as the host answered them. export function projectBoardView({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics } = {}) { @@ -288,8 +298,8 @@ export function projectBoardView({ project, rows, done, canManage, tasks, decisi assignee: text(row.assignee) || null, runId: text(row.runId) || null, nodeId: text(row.nodeId) || null, - title: text(task?.title) || text(task?.note) || null, - priority: text(task?.priority) || null, + title: text(row.title) || text(task?.title) || text(task?.note) || null, + priority: text(row.priority) || text(task?.priority) || null, audit: text(row.audit) || null, // #1831: who is doing the card, and who is speaking for whom. Both are host truth; // a card carrying neither renders exactly as before. diff --git a/patches/ds-bot/files/src/host/project-board.js b/patches/ds-bot/files/src/host/project-board.js index 65205556c..f904a3311 100644 --- a/patches/ds-bot/files/src/host/project-board.js +++ b/patches/ds-bot/files/src/host/project-board.js @@ -18,7 +18,7 @@ // sections. It writes nothing, starts nothing and is not called unless someone asks for the board. import { open } from 'node:fs/promises' import { join } from 'node:path' -import { columnOf } from '../shared/task-board.js' +import { columnOf, priorityOf } from '../shared/task-board.js' import { DECISIONS_DIR, describeFile, isDecisionPath, listProjectState, readListedFile } from './project-state.js' import { cut } from './text.js' import { advanceRefusal } from './meetings.js' @@ -491,9 +491,20 @@ export function install(rt) { mine.push(task) const fromRun = ref !== undefined && (runProject === null || runProject.id === id) const audit = typeof rt.taskAudit === 'function' ? rt.taskAudit(task.id) : '' + const title = text(task.title) rows.push({ id: task.id, source, + // The card's own text and priority, read here so a row is enough to draw a card. A finished + // card is not in the open `tasks` read, and a join by id would leave the Done column with + // `#` and no priority; a record stored before this field reads `null` / `'normal'`. + title: title === '' ? null : title, + priority: priorityOf(task), + // The board this row opens (#2198), so a finished card — which the open `tasks` read no + // longer lists — still opens from the Done column instead of drawing as a dead card. It + // names what `source` names: a run-carried row (`source: 'workflow'`) has no board of its + // own to open, which is the same `null` the client derives for it today. + board: source === 'workflow' || board === null ? null : { kind: board.kind, id: board.id }, column: columnOf(task), assignee: task.assignee ?? null, // #1831: "who is doing" is the claim, not the assignee label, and "who is speaking for 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 55066e26b..a1fd5842d 100644 --- a/patches/ds-bot/files/test/project-board-view.test.js +++ b/patches/ds-bot/files/test/project-board-view.test.js @@ -117,6 +117,33 @@ test('the board groups rows by their source and keeps each task id', () => { assert.deepEqual(view.done.rows.map(row => row.id), ['task-g1'], 'the finished row is the archive') }) +// P-167: the `tasks` read keeps open cards only, so a finished card has no record there to join. +// The row carries the card's own text and priority, and the Done column draws that — never `#`. +test('#P-167: a finished card keeps its title and priority with no task record to join', () => { + const done = { id: 'task-done', source: 'bot', column: 'done', assignee: null, updatedAt: 7, title: 'Ship the release', priority: 'high' } + const view = plain(projectBoardView({ project: { id: 'p', name: 'P' }, rows: [], done: [done], tasks: [] })) + assert.equal(view.done.rows[0].title, 'Ship the release') + assert.equal(view.done.rows[0].priority, 'high') + const drawn = all(ProjectBoardPane({ project: { id: 'p', name: 'FlyBot' }, rows: [], done: [done], tasks: [] })) + const title = drawn.find(node => node.props?.className === 'bt-project-title') + assert.equal(textOf(title), 'Ship the release') +}) + +// #2198: a finished card is not only text — it is a card, so it opens the board it lives on. With +// no `tasks` record to join, the board has to come from the row; without it the card drew as a +// dead row with no click at all. +test('#2198: a finished card opens the board its row names, with no task record to join', () => { + const done = { id: 'task-done', source: 'group', column: 'done', assignee: null, updatedAt: 7, title: 'Ship the release', priority: 'high', board: { kind: 'group', id: 'r1' } } + const view = plain(projectBoardView({ project: { id: 'p', name: 'P' }, rows: [], done: [done], tasks: [] })) + assert.deepEqual(view.done.rows[0].board, { kind: 'group', id: 'r1' }) + const opened = [] + const drawn = all(ProjectBoardPane({ project: { id: 'p', name: 'FlyBot' }, rows: [], done: [done], tasks: [], onOpen: board => opened.push(board) })) + const card = drawn.find(node => node.props?.className === 'bt-project-row') + assert.equal(card.props.role, 'button', 'the finished card is offered as a control') + card.props.onClick() + assert.deepEqual(plain(opened), [{ kind: 'group', id: 'r1' }]) +}) + test('a workflow row carries its run and node; other rows carry none', () => { const view = plain(projectBoardView({ project: { id: 'p', name: 'P' }, rows, canManage: false })) const [botRow] = view.groups[0].rows diff --git a/patches/ds-bot/files/test/project-board.test.js b/patches/ds-bot/files/test/project-board.test.js index c6925ca7a..a8e71d800 100644 --- a/patches/ds-bot/files/test/project-board.test.js +++ b/patches/ds-bot/files/test/project-board.test.js @@ -100,9 +100,9 @@ test('a Project board reads a group card and a run-created card without repeatin const rows = (await rt.projectBoard('project-p1')).rows assert.equal(rows.length, 2) const group = rows.find(row => row.id === 'task-group') - assert.deepEqual(group, { id: 'task-group', source: 'group', column: 'in-progress', assignee: 'Writer', updatedAt: 10, runId: 'run-2', nodeId: 'planner' }) + assert.deepEqual(group, { id: 'task-group', source: 'group', title: 'Ship it', priority: 'normal', board: { kind: 'group', id: 'r1' }, column: 'in-progress', assignee: 'Writer', updatedAt: 10, runId: 'run-2', nodeId: 'planner' }) const run = rows.find(row => row.id === 'task-run') - assert.deepEqual(run, { id: 'task-run', source: 'workflow', column: 'in-progress', assignee: null, updatedAt: 20, runId: 'run-1', nodeId: 'writer' }) + assert.deepEqual(run, { id: 'task-run', source: 'workflow', title: 'Run card', priority: 'normal', board: null, column: 'in-progress', assignee: null, updatedAt: 20, runId: 'run-1', nodeId: 'writer' }) // Never the same task twice. assert.equal(new Set(rows.map(row => row.id)).size, rows.length) } finally { @@ -111,6 +111,51 @@ test('a Project board reads a group card and a run-created card without repeatin } }) +// P-167: a finished card is still a card. The `tasks` read lists open cards only, so the Done +// column has no task record to join against — the row itself has to carry the text and the +// priority, or the card draws as `#` with no priority at all. +test('a finished card carries its title and priority on the row the Done column draws', async () => { + const team = seed() + team.tasks['task-done'] = { id: 'task-done', kind: 'group', status: 'accepted', ownerBotId: 'a', origin: { roomId: 'r1' }, assignee: 'Writer', title: 'Ship the release', priority: 'high', createdAt: 3, updatedAt: 4 } + const { rt, stop } = await start(undefined, team) + try { + const board = await rt.projectBoard('project-p1') + const card = board.done.find(row => row.id === 'task-done') + assert.equal(card.title, 'Ship the release') + assert.equal(card.priority, 'high') + // The read the page joins against keeps open cards only, so a finished card is not in it. + const listed = await rt.taskEndpoint('tasks', {}, null) + assert.equal(listed.some(task => task.id === 'task-done'), false) + } finally { + await stop() + } +}) + +// #2198: a row names the board it opens, so the Done column is not a shelf of dead cards. With no +// `tasks` record to join, the client had no way to derive the board and the card drew without a +// click at all; the row carries it, and a run-carried row keeps naming none of its own. +test('a finished card names the board it opens on the row the Done column draws', async () => { + const team = seed() + team.tasks['task-done'] = { id: 'task-done', kind: 'group', status: 'accepted', ownerBotId: 'a', origin: { roomId: 'r1' }, assignee: 'Writer', title: 'Ship the release', priority: 'high', createdAt: 3, updatedAt: 4 } + const { rt, stop } = await start(undefined, team) + const off = rt.onProjectProvider(({ hasTask }) => [ + { taskId: 'task-run', runId: 'run-1' }, + ].filter(row => hasTask(row.taskId))) + try { + const board = await rt.projectBoard('project-p1') + const card = board.done.find(row => row.id === 'task-done') + assert.deepEqual(card.board, { kind: 'group', id: 'r1' }) + // The run carried this card in, and `source: 'workflow'` is the run's binding, not a board. + const runCard = board.rows.find(row => row.id === 'task-run') + assert.equal(runCard.source, 'workflow') + assert.equal(runCard.board, null) + } finally { + off() + await stop() + } +}) + + test('the board takes its token from the host secret store and its zone from the call', async () => { const { rt, stop } = await start() const realSecret = rt.secretValue