From 581bb2d59c5aa17feeca7ffcf5800f760b04bc87 Mon Sep 17 00:00:00 2001 From: Dev4 Date: Fri, 30 Oct 2026 15:57:31 +0000 Subject: [PATCH] Read the Project's own board for the 09:00 digest (#1846) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The digest called `rt.projectBoard(id)` with no actor. Once anyone has signed in, `authzEnforced()` is on and the board's per-reader `taskVisible` rule filters every card out, so every digest said "no change since the previous digest" — and that sentence was stored as the next run's `since`, hiding every finished card for good. `projectBoard` now takes an explicit `{ internal: true }` option that skips the one `visible` decision, and only the digest passes it (`project.id, null, { internal: true }`). The channel builds its options from the time zone alone, so an `internal` field in a request body never reaches the flag, and a call without it keeps today's behaviour (0 rows without an actor). --- changelog.d/1846-digest-empty.fixed.md | 1 + core/src/host/project-assistant.js | 6 +- core/src/host/project-board.js | 6 +- core/test/project-digest-authz.test.js | 166 ++++++++++++++++++ docs/subsystems/core-host.md | 2 +- .../files/src/host/project-assistant.js | 6 +- .../ds-bot/files/src/host/project-board.js | 6 +- .../files/test/project-digest-authz.test.js | 166 ++++++++++++++++++ 8 files changed, 352 insertions(+), 7 deletions(-) create mode 100644 changelog.d/1846-digest-empty.fixed.md create mode 100644 core/test/project-digest-authz.test.js create mode 100644 patches/ds-bot/files/test/project-digest-authz.test.js diff --git a/changelog.d/1846-digest-empty.fixed.md b/changelog.d/1846-digest-empty.fixed.md new file mode 100644 index 000000000..47edadf69 --- /dev/null +++ b/changelog.d/1846-digest-empty.fixed.md @@ -0,0 +1 @@ +Project Assistant: the 09:00 digest now reads the Project's own board instead of an actor-less one, so a signed-in install no longer gets "no change since the previous digest" every day and the archived watermark stops hiding finished cards ([#1846](https://github.com/ThinkFlowLab/FlyBot/issues/1846)). diff --git a/core/src/host/project-assistant.js b/core/src/host/project-assistant.js index 75dee93bc..197419267 100644 --- a/core/src/host/project-assistant.js +++ b/core/src/host/project-assistant.js @@ -474,8 +474,10 @@ export function install(rt) { const owners = listed.length === 0 ? [''] : listed const who = identity(project.id) // The board read is the same one the page uses (#1767): the digest wants its cards, not the - // decisions and meetings that answer beside them. - const board = (await rt.projectBoard?.(project.id)) ?? [] + // decisions and meetings that answer beside them. It asks for the Project's own facts (#1846): + // with no actor every card is invisible once a member has signed in, and a digest built from one + // reader's page reported "no change" on a moving board. The flag is this call's alone. + const board = (await rt.projectBoard?.(project.id, null, { internal: true })) ?? [] const rows = Array.isArray(board) ? board : (board.rows ?? []) const state = project.workDir === undefined ? null : await listProjectState(project.workDir).catch(() => null) const files = state?.ok === true ? state.files : [] diff --git a/core/src/host/project-board.js b/core/src/host/project-board.js index 4074f5c9f..75098fad9 100644 --- a/core/src/host/project-board.js +++ b/core/src/host/project-board.js @@ -399,11 +399,15 @@ export function install(rt) { // predicate lives in tasks.js (`rt.taskVisible`); a host that has not installed it shows // nothing, because an unreadable rule is not a reason to widen a board. const visible = typeof rt.taskVisible === 'function' ? rt.taskVisible : () => false + // #1846: the Project's own daily digest reports the facts of the Project, not one member's page, + // and it is the one caller that says so explicitly. The flag is read from this argument and + // nowhere else: the channel builds `options` from the zone alone, so no request body reaches it. + const internal = options.internal === true const rows = [] const mine = [] for (const task of readable ? Object.values(tasks) : []) { if (task === null || typeof task !== 'object') continue - if (!visible(task, actor)) continue + if (!internal && !visible(task, actor)) continue const board = boardOf(task) // The Project of the card's own board. A card that has a board already belongs somewhere; a // board whose member is unbound names no Project, and only then may a run carry the card. diff --git a/core/test/project-digest-authz.test.js b/core/test/project-digest-authz.test.js new file mode 100644 index 000000000..783280134 --- /dev/null +++ b/core/test/project-digest-authz.test.js @@ -0,0 +1,166 @@ +// #1846: the 09:00 Project digest read the board with no actor, so on any install where somebody +// had signed in the visibility rule filtered every card out and the digest answered "no change" +// every day — and that sentence was stored as the next run's `since`. The digest now asks the +// Project's own board through an explicit internal read; the channel path keeps the reader's rule. +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createRuntime } from '../index.js' +import { fakeContext } from './fake-ctx.js' + +// One member has signed in (#1846): that single line is what turns `authzEnforced()` on, and it is +// the only difference from `project-assistant.test.js`'s seed. `owner-1` and `owner-2` own the two +// Bots, `member-3` is a signed-in member who owns neither. +const seed = () => ({ + version: 2, + revision: 1, + mainBotIds: ['a'], + members: { + 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' }, + 'owner-2': { id: 'owner-2', createdAt: 2 }, + 'member-3': { id: 'member-3', createdAt: 3 }, + }, + bots: { + a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1, ownerId: 'owner-1' }, + worker: { id: 'worker', name: 'Worker', role: 'Dev', sessionId: 'w', createdAt: 2, ownerId: 'owner-2' }, + }, + rooms: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, grants: {}, + authSessions: { 's-3': { memberId: 'member-3', tokenHash: 't' } }, +}) + +function scheduleService() { + const tasks = new Map() + let next = 1 + return { + async create(sessionId, request) { + const id = `schedule-${next++}` + const kind = Object.keys(request).find(key => !['title', 'prompt'].includes(key)) + const record = { id, title: request.title, prompt: request.prompt, timing: request[kind], kind, scheduledAt: '2030-01-01T01:00:00.000Z' } + tasks.set(id, { sessionId, record, status: 'active' }) + return record + }, + async list({ sessionId }) { return [...tasks.values()].filter(task => task.sessionId === sessionId && task.status === 'active').map(task => task.record) }, + async catalog() { return [...tasks.values()].map(task => ({ ...task.record, sessionId: task.sessionId, status: task.status })) }, + async delete({ sessionId, id }) { + const task = tasks.get(id) + if (task === undefined || task.sessionId !== sessionId) return { deleted: false } + tasks.delete(id) + return { deleted: true } + }, + } +} + +async function start() { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-digest-authz-')) + if (!existsSync(join(home, 'state.json'))) writeFileSync(join(home, 'state.json'), JSON.stringify(seed())) + const ctx = fakeContext({ services: { schedule: scheduleService() } }) + const agents = new Map() + const agentOf = (sessionId) => { + if (!agents.has(sessionId)) agents.set(sessionId, { session: { id: sessionId }, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle() {}, inbox: { nextStep: [], nextTurn: [], remove() {} } }) + return agents.get(sessionId) + } + let sessions = 0 + Object.assign(ctx, { + llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionController: { create: async () => ({ sessionId: `s${++sessions}` }), resolveAgent: async id => ({ agent: agentOf(id) }), selectModel: async () => {} }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + const rt = createRuntime(ctx, { home }) + await rt.load() + rt.connectorsView = () => [{ id: 'github', name: 'GitHub', state: 'ready', account: 'Birfy' }] + rt.deliver = async () => {} + const stop = async () => { + await ctx.dispose() + rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + } + return { rt, stop } +} + +const created = async (rt, body) => (await rt.projectEndpoint('project-create', body)).project +const at = Date.parse('2026-10-30T01:00:00Z') + +// A Project bound to the two Bots, and one card per Bot driven to `working`. The board row names the +// Bot a card is assigned to and a Bot-owned card carries no assignee of its own, so the fixture +// writes the field the row reads — the probe's own row was `["task-1","in-progress","Worker"]`. +async function projectWithCards(rt, titles = ['The card on the board'], { wait = false } = {}) { + const project = await created(rt, { name: 'P1', ownerUserId: 'owner-1', ownerUserIds: ['owner-1', 'owner-2'] }) + const cards = [] + const seats = [['a', 'owner-1', 'Chief'], ['worker', 'owner-2', 'Worker']] + for (const [index, [botId, owner, name]] of seats.slice(0, titles.length).entries()) { + // Binding a Bot to a Project is the primary owner's write (#1740), whatever owner the Bot has. + await rt.projectEndpoint('project-bind', { kind: 'bot', id: botId, projectId: project.id }, { memberId: 'owner-1' }) + const card = await rt.taskEndpoint('task-create', { botId, title: titles[index] }, { memberId: owner }) + await rt.taskEndpoint('task-accept', { id: card.id }, { memberId: owner }) + await rt.taskEndpoint('task-start', { id: card.id }, { memberId: owner }) + if (wait) await rt.taskEndpoint('task-wait-input', { id: card.id, question: 'Which one?' }, { memberId: owner }) + rt.state.tasks[card.id].assignee = name + cards.push(card) + } + return { project, cards } +} + +test('#1846: on a signed-in install the digest names the card in progress on the board', async () => { + const { rt, stop } = await start() + try { + assert.equal(rt.authzEnforced(), true, 'the seed has a session, so the per-reader rule is live') + const { project, cards } = await projectWithCards(rt) + const result = await rt.projectAssistant.digest(project.id, { at }) + const owner = result.owners.find(entry => entry.owner === 'owner-1') + assert.match(owner.text, new RegExp(cards[0].id), 'the digest names the card it can no longer read blind') + assert.match(owner.text, /In progress, waiting on:\n- \S+ — Chief/) + assert.doesNotMatch(owner.text, /no change since the previous digest/, 'a moving board is not "no change"') + } finally { await stop() } +}) + +test('#1846: the digest stored for the next run is the same text, so the watermark is not empty', async () => { + const { rt, stop } = await start() + try { + const { project, cards } = await projectWithCards(rt) + const result = await rt.projectAssistant.digest(project.id, { at }) + const archived = rt.projectMemoryFor(project.id, 'owner-1').digest + assert.ok(archived !== null && typeof archived === 'object', 'the run archives its note as the next `since`') + assert.match(archived.text, new RegExp(cards[0].id), 'the archived text names the card too') + const delivered = result.owners.find(entry => entry.owner === 'owner-1') + assert.match(delivered.text, new RegExp(cards[0].id), 'and it is the text that was delivered') + } finally { await stop() } +}) + +test('#1846: the internal read is opt-in — the channel and the plain call keep the reader rule', async () => { + const { rt, stop } = await start() + try { + const { project, cards } = await projectWithCards(rt, ['First card', 'Second card']) + // The shape the digest used to call: no actor, no options. A default stays a default. + const plain = await rt.projectBoard(project.id) + assert.deepEqual(plain.rows, [], 'a call without the flag still shows a reader-less caller nothing') + // The channel path: the owner of the card's Bot still sees it, another signed-in member does not, + // and `internal` in the request body is not a way in — the store never forwards it. + const theirs = await rt.projectEndpoint('project-board', { id: project.id, internal: true }, { memberId: 'member-3' }) + assert.deepEqual(theirs.tasks, [], 'a body flag cannot widen the channel read') + const owner = await rt.projectEndpoint('project-board', { id: project.id }, { memberId: 'owner-2' }) + assert.deepEqual(owner.tasks.map(row => row.id), [cards[1].id], 'the reading member sees their own card') + // The digest's own read: explicit, and only explicit. + const internal = await rt.projectBoard(project.id, null, { internal: true }) + assert.deepEqual(internal.rows.map(row => row.id).sort(), cards.map(card => card.id).sort(), 'the internal read sees the Project') + } finally { await stop() } +}) + +test('#1846: both owners get the shared facts, and each personal block keeps its own cards', async () => { + const { rt, stop } = await start() + try { + const { project, cards } = await projectWithCards(rt, ['Alpha card', 'Beta card'], { wait: true }) + const result = await rt.projectAssistant.digest(project.id, { at }) + const one = result.owners.find(entry => entry.owner === 'owner-1') + const two = result.owners.find(entry => entry.owner === 'owner-2') + for (const entry of [one, two]) { + for (const card of cards) assert.match(entry.text, new RegExp(card.id), 'the shared sections name every card') + } + assert.match(one.text, /Alpha card/, 'the primary owner sees their own decision') + assert.doesNotMatch(one.text, /Beta card/, 'and never the other owner\'s') + assert.match(two.text, /Beta card/) + assert.doesNotMatch(two.text, /Alpha card/) + } finally { await stop() } +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index adf49abc9..68b79fbeb 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/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. 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. `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. Tests: `core/test/project-assistant.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. `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/shared/artifact.js` | The typed facts a workflow node takes in and hands out (#1272): `parseArtifactSpec` reads `check:tests-ok` / `issue.ready` / `text:topic` into `{ kind, name }` and refuses a spelling the vocabulary does not have; `artifact(spec, revision, { value })` is one instance, and `REVISION_RULES` says what the revision of each kind is (a verdict its SHA, a check the SHA it ran on, a PR its number and `headRefOid`). `freshness(input, currentRevision)` answers present-and-current separately from present, and a fact nobody could read is `unreadable`, never a match. Tests: `core/test/workflow.test.js`. | | `core/src/shared/preview.js` | What a stored line may show a person (#130, #1347): `visibleText(text)` unwraps a complete `` wrap, then removes leftover marker fragments until neither changes the text, then folds to one line. `stripFence` is the marker half alone. The sidebar rows read it (`previewOf` in `src/client/text.js`), and so does the `preview` this channel puts on a room in the `state` payload, which used to be a plain `unwrapUntrusted` and so leaked `from="User"> … >` on a line whose opener had already been cut. Tests: `core/test/ui-clickthrough.test.js`. | diff --git a/patches/ds-bot/files/src/host/project-assistant.js b/patches/ds-bot/files/src/host/project-assistant.js index 75dee93bc..197419267 100644 --- a/patches/ds-bot/files/src/host/project-assistant.js +++ b/patches/ds-bot/files/src/host/project-assistant.js @@ -474,8 +474,10 @@ export function install(rt) { const owners = listed.length === 0 ? [''] : listed const who = identity(project.id) // The board read is the same one the page uses (#1767): the digest wants its cards, not the - // decisions and meetings that answer beside them. - const board = (await rt.projectBoard?.(project.id)) ?? [] + // decisions and meetings that answer beside them. It asks for the Project's own facts (#1846): + // with no actor every card is invisible once a member has signed in, and a digest built from one + // reader's page reported "no change" on a moving board. The flag is this call's alone. + const board = (await rt.projectBoard?.(project.id, null, { internal: true })) ?? [] const rows = Array.isArray(board) ? board : (board.rows ?? []) const state = project.workDir === undefined ? null : await listProjectState(project.workDir).catch(() => null) const files = state?.ok === true ? state.files : [] diff --git a/patches/ds-bot/files/src/host/project-board.js b/patches/ds-bot/files/src/host/project-board.js index 4074f5c9f..75098fad9 100644 --- a/patches/ds-bot/files/src/host/project-board.js +++ b/patches/ds-bot/files/src/host/project-board.js @@ -399,11 +399,15 @@ export function install(rt) { // predicate lives in tasks.js (`rt.taskVisible`); a host that has not installed it shows // nothing, because an unreadable rule is not a reason to widen a board. const visible = typeof rt.taskVisible === 'function' ? rt.taskVisible : () => false + // #1846: the Project's own daily digest reports the facts of the Project, not one member's page, + // and it is the one caller that says so explicitly. The flag is read from this argument and + // nowhere else: the channel builds `options` from the zone alone, so no request body reaches it. + const internal = options.internal === true const rows = [] const mine = [] for (const task of readable ? Object.values(tasks) : []) { if (task === null || typeof task !== 'object') continue - if (!visible(task, actor)) continue + if (!internal && !visible(task, actor)) continue const board = boardOf(task) // The Project of the card's own board. A card that has a board already belongs somewhere; a // board whose member is unbound names no Project, and only then may a run carry the card. diff --git a/patches/ds-bot/files/test/project-digest-authz.test.js b/patches/ds-bot/files/test/project-digest-authz.test.js new file mode 100644 index 000000000..783280134 --- /dev/null +++ b/patches/ds-bot/files/test/project-digest-authz.test.js @@ -0,0 +1,166 @@ +// #1846: the 09:00 Project digest read the board with no actor, so on any install where somebody +// had signed in the visibility rule filtered every card out and the digest answered "no change" +// every day — and that sentence was stored as the next run's `since`. The digest now asks the +// Project's own board through an explicit internal read; the channel path keeps the reader's rule. +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createRuntime } from '../index.js' +import { fakeContext } from './fake-ctx.js' + +// One member has signed in (#1846): that single line is what turns `authzEnforced()` on, and it is +// the only difference from `project-assistant.test.js`'s seed. `owner-1` and `owner-2` own the two +// Bots, `member-3` is a signed-in member who owns neither. +const seed = () => ({ + version: 2, + revision: 1, + mainBotIds: ['a'], + members: { + 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' }, + 'owner-2': { id: 'owner-2', createdAt: 2 }, + 'member-3': { id: 'member-3', createdAt: 3 }, + }, + bots: { + a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1, ownerId: 'owner-1' }, + worker: { id: 'worker', name: 'Worker', role: 'Dev', sessionId: 'w', createdAt: 2, ownerId: 'owner-2' }, + }, + rooms: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, grants: {}, + authSessions: { 's-3': { memberId: 'member-3', tokenHash: 't' } }, +}) + +function scheduleService() { + const tasks = new Map() + let next = 1 + return { + async create(sessionId, request) { + const id = `schedule-${next++}` + const kind = Object.keys(request).find(key => !['title', 'prompt'].includes(key)) + const record = { id, title: request.title, prompt: request.prompt, timing: request[kind], kind, scheduledAt: '2030-01-01T01:00:00.000Z' } + tasks.set(id, { sessionId, record, status: 'active' }) + return record + }, + async list({ sessionId }) { return [...tasks.values()].filter(task => task.sessionId === sessionId && task.status === 'active').map(task => task.record) }, + async catalog() { return [...tasks.values()].map(task => ({ ...task.record, sessionId: task.sessionId, status: task.status })) }, + async delete({ sessionId, id }) { + const task = tasks.get(id) + if (task === undefined || task.sessionId !== sessionId) return { deleted: false } + tasks.delete(id) + return { deleted: true } + }, + } +} + +async function start() { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-digest-authz-')) + if (!existsSync(join(home, 'state.json'))) writeFileSync(join(home, 'state.json'), JSON.stringify(seed())) + const ctx = fakeContext({ services: { schedule: scheduleService() } }) + const agents = new Map() + const agentOf = (sessionId) => { + if (!agents.has(sessionId)) agents.set(sessionId, { session: { id: sessionId }, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle() {}, inbox: { nextStep: [], nextTurn: [], remove() {} } }) + return agents.get(sessionId) + } + let sessions = 0 + Object.assign(ctx, { + llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionController: { create: async () => ({ sessionId: `s${++sessions}` }), resolveAgent: async id => ({ agent: agentOf(id) }), selectModel: async () => {} }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + const rt = createRuntime(ctx, { home }) + await rt.load() + rt.connectorsView = () => [{ id: 'github', name: 'GitHub', state: 'ready', account: 'Birfy' }] + rt.deliver = async () => {} + const stop = async () => { + await ctx.dispose() + rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + } + return { rt, stop } +} + +const created = async (rt, body) => (await rt.projectEndpoint('project-create', body)).project +const at = Date.parse('2026-10-30T01:00:00Z') + +// A Project bound to the two Bots, and one card per Bot driven to `working`. The board row names the +// Bot a card is assigned to and a Bot-owned card carries no assignee of its own, so the fixture +// writes the field the row reads — the probe's own row was `["task-1","in-progress","Worker"]`. +async function projectWithCards(rt, titles = ['The card on the board'], { wait = false } = {}) { + const project = await created(rt, { name: 'P1', ownerUserId: 'owner-1', ownerUserIds: ['owner-1', 'owner-2'] }) + const cards = [] + const seats = [['a', 'owner-1', 'Chief'], ['worker', 'owner-2', 'Worker']] + for (const [index, [botId, owner, name]] of seats.slice(0, titles.length).entries()) { + // Binding a Bot to a Project is the primary owner's write (#1740), whatever owner the Bot has. + await rt.projectEndpoint('project-bind', { kind: 'bot', id: botId, projectId: project.id }, { memberId: 'owner-1' }) + const card = await rt.taskEndpoint('task-create', { botId, title: titles[index] }, { memberId: owner }) + await rt.taskEndpoint('task-accept', { id: card.id }, { memberId: owner }) + await rt.taskEndpoint('task-start', { id: card.id }, { memberId: owner }) + if (wait) await rt.taskEndpoint('task-wait-input', { id: card.id, question: 'Which one?' }, { memberId: owner }) + rt.state.tasks[card.id].assignee = name + cards.push(card) + } + return { project, cards } +} + +test('#1846: on a signed-in install the digest names the card in progress on the board', async () => { + const { rt, stop } = await start() + try { + assert.equal(rt.authzEnforced(), true, 'the seed has a session, so the per-reader rule is live') + const { project, cards } = await projectWithCards(rt) + const result = await rt.projectAssistant.digest(project.id, { at }) + const owner = result.owners.find(entry => entry.owner === 'owner-1') + assert.match(owner.text, new RegExp(cards[0].id), 'the digest names the card it can no longer read blind') + assert.match(owner.text, /In progress, waiting on:\n- \S+ — Chief/) + assert.doesNotMatch(owner.text, /no change since the previous digest/, 'a moving board is not "no change"') + } finally { await stop() } +}) + +test('#1846: the digest stored for the next run is the same text, so the watermark is not empty', async () => { + const { rt, stop } = await start() + try { + const { project, cards } = await projectWithCards(rt) + const result = await rt.projectAssistant.digest(project.id, { at }) + const archived = rt.projectMemoryFor(project.id, 'owner-1').digest + assert.ok(archived !== null && typeof archived === 'object', 'the run archives its note as the next `since`') + assert.match(archived.text, new RegExp(cards[0].id), 'the archived text names the card too') + const delivered = result.owners.find(entry => entry.owner === 'owner-1') + assert.match(delivered.text, new RegExp(cards[0].id), 'and it is the text that was delivered') + } finally { await stop() } +}) + +test('#1846: the internal read is opt-in — the channel and the plain call keep the reader rule', async () => { + const { rt, stop } = await start() + try { + const { project, cards } = await projectWithCards(rt, ['First card', 'Second card']) + // The shape the digest used to call: no actor, no options. A default stays a default. + const plain = await rt.projectBoard(project.id) + assert.deepEqual(plain.rows, [], 'a call without the flag still shows a reader-less caller nothing') + // The channel path: the owner of the card's Bot still sees it, another signed-in member does not, + // and `internal` in the request body is not a way in — the store never forwards it. + const theirs = await rt.projectEndpoint('project-board', { id: project.id, internal: true }, { memberId: 'member-3' }) + assert.deepEqual(theirs.tasks, [], 'a body flag cannot widen the channel read') + const owner = await rt.projectEndpoint('project-board', { id: project.id }, { memberId: 'owner-2' }) + assert.deepEqual(owner.tasks.map(row => row.id), [cards[1].id], 'the reading member sees their own card') + // The digest's own read: explicit, and only explicit. + const internal = await rt.projectBoard(project.id, null, { internal: true }) + assert.deepEqual(internal.rows.map(row => row.id).sort(), cards.map(card => card.id).sort(), 'the internal read sees the Project') + } finally { await stop() } +}) + +test('#1846: both owners get the shared facts, and each personal block keeps its own cards', async () => { + const { rt, stop } = await start() + try { + const { project, cards } = await projectWithCards(rt, ['Alpha card', 'Beta card'], { wait: true }) + const result = await rt.projectAssistant.digest(project.id, { at }) + const one = result.owners.find(entry => entry.owner === 'owner-1') + const two = result.owners.find(entry => entry.owner === 'owner-2') + for (const entry of [one, two]) { + for (const card of cards) assert.match(entry.text, new RegExp(card.id), 'the shared sections name every card') + } + assert.match(one.text, /Alpha card/, 'the primary owner sees their own decision') + assert.doesNotMatch(one.text, /Beta card/, 'and never the other owner\'s') + assert.match(two.text, /Beta card/) + assert.doesNotMatch(two.text, /Alpha card/) + } finally { await stop() } +})