From 67b4c62600376efcc267927391f3aff96c64eefe Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Fri, 30 Oct 2026 06:10:22 +0800 Subject: [PATCH 1/2] Give every owner of a Project their own 09:00 digest The digest served one owner: memory was keyed by Project id and the whole build was gated on the GitHub identity. Memory and the last digest are now per (project, owner), one board read and one Project State read serve one render per owner, and delivery goes to each owner's own Session with no identity gate. A per-owner quiet window on the assistant record holds a due digest and delivers it once, labelled, when the window ends; a window that covers 09:00 delivers nothing. The opt-out stops generation and pauses the 09:00 task until one owner turns it back on. A cross-owner memory read is refused naming the boundary, and one owner's failing render is recorded on their own note while the other owner's digest is still delivered. Fixes #1733 Part of #1550 Docs-not-needed: docs/subsystems/core-host.md is updated in this commit. --- changelog.d/1733-digest-owners.added.md | 1 + core/src/host/project-assistant.js | 234 +++++++++++++++--- core/src/host/store.js | 78 ++++-- core/test/project-assistant.test.js | 209 +++++++++++++++- docs/subsystems/core-host.md | 39 ++- patches/ds-bot/0002-flybot-api-host.patch | 105 +++++--- .../files/src/host/project-assistant.js | 234 +++++++++++++++--- .../files/test/project-assistant.test.js | 209 +++++++++++++++- 8 files changed, 973 insertions(+), 136 deletions(-) create mode 100644 changelog.d/1733-digest-owners.added.md diff --git a/changelog.d/1733-digest-owners.added.md b/changelog.d/1733-digest-owners.added.md new file mode 100644 index 000000000..09177199d --- /dev/null +++ b/changelog.d/1733-digest-owners.added.md @@ -0,0 +1 @@ +Each owner of one Project now gets their own 09:00 digest, built from one board read with their own memory and their own decisions list, with a per-owner quiet window that holds a due digest and delivers it once when the window ends, an opt-out that stops generation and pauses the 09:00 task until it is turned back on, a refusal that names the boundary when one owner asks for another's memory, and failure isolation so one owner's broken render still leaves the other owner's digest delivered ([#1733](https://github.com/ThinkFlowLab/FlyBot/issues/1733)). diff --git a/core/src/host/project-assistant.js b/core/src/host/project-assistant.js index 1325bb3fe..f2e72effc 100644 --- a/core/src/host/project-assistant.js +++ b/core/src/host/project-assistant.js @@ -17,6 +17,7 @@ import { defineTool } from './tool-schema.js' import { listProjectState } from './project-state.js' import { output } from './tools.js' +import { inQuietHours, clockIn, quietHoursOf, readQuietHours } from '../shared/quiet-hours.js' export const ASSISTANT_ROLE = 'Project Assistant' export const DIGEST_ZONE = 'Asia/Shanghai' @@ -25,6 +26,9 @@ export const DIGEST_TITLE = '09:00 Project digest' export const DIGEST_PROMPT = 'Build today\'s Project digest: call project_digest once. It answers whether today already has one.' export const MAX_ENTRY = 600 export const MEMORY_KINDS = ['decision', 'question'] +// At most this many cards that need one owner's decision in one digest (#1733): a digest is a +// short list of things to decide, not the board. One constant, not a setting. +export const MAX_ACTIONABLE = 5 export const assistantName = project => `${ASSISTANT_ROLE} (${project.name})` @@ -54,7 +58,7 @@ const stamp = at => new Date(at).toISOString().slice(0, 10) // still in progress and the assignee each one waits on — it never calls one blocked without that // evidence. The next slice is the oldest card that has not started. Nothing is counted that is // not a row, and a Project with no rows answers with no line at all. -export function buildDigest({ project, rows = [], since = null, files = [], at = Date.now() }) { +export function buildDigest({ project, rows = [], since = null, files = [], at = Date.now(), owner = null, actionable = [] }) { const changed = row => since === null || (row?.updatedAt ?? 0) > since const finished = rows.filter(row => row.column === 'done' && changed(row)) const merged = finished.filter(row => row.source === 'workflow') @@ -64,11 +68,16 @@ export function buildDigest({ project, rows = [], since = null, files = [], at = .filter(row => row.column === 'not-started') .sort((a, b) => (a.updatedAt ?? 0) - (b.updatedAt ?? 0) || String(a.id).localeCompare(String(b.id)))[0] const line = (head, entries) => [head, ...entries.map(entry => `- ${entry}`)] + // The personal block comes last: the same rows answer the same four sections for every owner, + // and only this one names the cards that wait on the person reading it. + const asks = owner === null ? [] : actionable.slice(0, MAX_ACTIONABLE).map(item => `${item.id} — ${item.ask}`) + const personal = asks.length === 0 ? [] : line(`Needs ${owner}'s decision:`, asks) const sections = [ ...(merged.length === 0 ? [] : line('Merged since the previous digest:', merged.map(row => `${row.id} (${row.source})`))), ...(closed.length === 0 ? [] : line('Closed:', closed.map(row => `${row.id} (${row.source})`))), ...(blocked.length === 0 ? [] : line('In progress, waiting on:', blocked.map(row => `${row.id} — ${text(row.assignee)}`))), ...(next === undefined ? [] : line('Next slice:', [`${next.id} (${next.source})`])), + ...personal, ] const head = `${assistantName(project)} — ${stamp(at)}` if (sections.length === 0) return { changed: false, day: dayIn(at), text: `${head}: no change since the previous digest.` } @@ -78,6 +87,26 @@ export function buildDigest({ project, rows = [], since = null, files = [], at = return { changed: true, day: dayIn(at), text: [head, stateLine, ...sections].join('\n') } } +// The cards that need *this* owner's decision: still open, and assigned to that owner. A card +// somebody else owns is their decision, and never appears in this owner's digest. +export function actionableFor(rows, ownerId) { + const owner = text(ownerId) + if (owner === '') return [] + return (rows ?? []) + .filter(row => row?.column !== 'done' && text(row.assignee) === owner) + .sort((a, b) => (a.updatedAt ?? 0) - (b.updatedAt ?? 0) || String(a.id).localeCompare(String(b.id))) + .map(row => ({ id: String(row.id), ask: askOf(row) })) +} + +// One line, because the block is a list of decisions and a card description may be long. +const askOf = (row) => { + const title = text(row.title).replace(/\s+/g, ' ') + const where = text(row.column) === '' ? 'open' : `still ${text(row.column)}` + if (title === '') return `decide what happens next — ${where}` + const short = title.length > 80 ? `${title.slice(0, 77)}…` : title + return `decide what happens next — ${short} (${where})` +} + // A write is attributed by text, because one shared GitHub login makes it impossible to tell // otherwise (#627). The login is the Project owner's own account, resolved at write time. export const attribution = (project, login) => `Written-by: ${assistantName(project)}\nPosted-for: ${login}` @@ -97,8 +126,17 @@ export function install(rt) { return typeof botId === 'string' && rt.botOf(botId) !== undefined ? project.assistant : null } - const memoryOf = (project) => { - const memory = rt.projectMemoryOf?.(project.id) + // The owners of one Project (#1733): its primary owner plus every owner listed on the record. + // This list is the boundary a memory read is checked against. + const ownersOf = (project) => { + const primary = text(project.ownerUserId) + const listed = Array.isArray(project.ownerUserIds) ? project.ownerUserIds.map(id => text(id)).filter(id => id !== '') : [] + return [...new Set([...(primary === '' ? [] : [primary]), ...listed])] + } + + const memoryOf = (project, ownerId) => { + const owner = text(ownerId) || ownersOf(project)[0] || '' + const memory = rt.projectMemoryFor?.(project.id, owner) ?? null return { decisions: Array.isArray(memory?.decisions) ? memory.decisions : [], questions: Array.isArray(memory?.questions) ? memory.questions : [], @@ -106,7 +144,18 @@ export function install(rt) { } } - const read = (projectId) => ({ project: activeProject(projectId), memory: memoryOf(activeProject(projectId)) }) + // The refusal names the boundary instead of pretending the Project has no memory (#1733): one + // owner asking for another owner's entries learns whose they are, and nothing else. + const outsideBoundary = (project, ownerId) => { + const owners = ownersOf(project) + return refusal(`Project ${project.name} memory belongs only to ${owners.join(', ') || '(no owner)'}; ${text(ownerId) || '(none)'} is outside that boundary and cannot read it`, 'bot/refused') + } + + const read = (projectId, ownerId = '') => { + const project = activeProject(projectId) + if (text(ownerId) !== '' && !ownersOf(project).includes(text(ownerId))) throw outsideBoundary(project, ownerId) + return { project, memory: memoryOf(project, ownerId) } + } const write = (projectId, body = {}) => { const project = activeProject(projectId) @@ -115,8 +164,10 @@ export function install(rt) { const entry = text(body.text) if (entry === '') throw refusal('A Project memory entry needs text') if (entry.length > MAX_ENTRY) throw refusal(`A Project memory entry is at most ${MAX_ENTRY} characters`) - rt.appendProjectMemory?.(project.id, kind, { text: entry, at: Date.now(), ...(text(body.by) === '' ? {} : { by: text(body.by) }) }) - return { project, memory: memoryOf(project) } + const ownerId = text(body.ownerId) || ownersOf(project)[0] || '' + if (text(body.ownerId) !== '' && !ownersOf(project).includes(text(body.ownerId))) throw outsideBoundary(project, body.ownerId) + rt.appendProjectMemory?.(project.id, kind, { text: entry, at: Date.now(), ownerId, ...(text(body.by) === '' ? {} : { by: text(body.by) }) }) + return { project, memory: memoryOf(project, ownerId) } } // The one identity this slice accepts: the connector account, which on a single-person install @@ -205,39 +256,158 @@ export function install(rt) { return assistant } - // One stored Project note and one delivered message, at most once per calendar day. The second - // build in a day answers `skipped` and writes nothing; it still resolves identity first, because - // a refused identity must not leave a note behind. - const digest = async (projectId, { at = Date.now(), force = false } = {}) => { - const who = identity(projectId) - if (!who.ok) return { ok: false, escalated: who.escalate === true, reason: who.reason } - const { project, login } = who - const memory = memoryOf(project) + // Per-owner controls (#1733) live on the Project's assistant record, never in the host-wide + // prefs: a quiet window read in the owner's own zone, and the digest opt-out. + const assistantOwners = (assistant) => { + if (typeof assistant.owners !== 'object' || assistant.owners === null || Array.isArray(assistant.owners)) assistant.owners = {} + return assistant.owners + } + const ownerRecord = (assistant, ownerId) => { + const rows = assistantOwners(assistant) + const key = text(ownerId) + const kept = rows[key] + if (typeof kept !== 'object' || kept === null || Array.isArray(kept)) rows[key] = {} + return rows[key] + } + const quietOf = (assistant, ownerId) => { + const record = text(ownerId) === '' ? null : (assistant?.owners?.[text(ownerId)] ?? null) + return { ...quietHoursOf({ quietHours: record?.quiet ?? null }), zone: text(record?.zone) || DIGEST_ZONE } + } + + // The opt-out and the quiet window of one owner. The 09:00 task is paused only when every owner + // is off, because it is the one task every owner's digest is built by; turning one owner back on + // resumes it. Nothing here touches GitHub: these are the user's own controls. + const setOwner = async (projectId, ownerId, settings = {}) => { + const project = activeProject(projectId) + const owner = text(ownerId) + if (!ownersOf(project).includes(owner)) throw outsideBoundary(project, ownerId) + const assistant = assistantOf(project) + if (assistant === null) throw refusal(`Project ${project.name} has no assistant yet`, 'bot/refused') + const record = ownerRecord(assistant, owner) + const wasOff = record.off === true + if (settings.quiet !== undefined) { + const next = readQuietHours(settings.quiet ?? null, record.quiet ?? null) + if (next === undefined) delete record.quiet + else record.quiet = next + } + if (settings.zone !== undefined) { + if (text(settings.zone) === '') delete record.zone + else record.zone = text(settings.zone) + } + if (settings.off !== undefined) record.off = settings.off === true + let schedule = null + if (settings.off !== undefined && typeof rt.scheduleEndpoint === 'function' && typeof assistant.scheduleId === 'string') { + const allOff = ownersOf(project).every(id => assistant.owners?.[id]?.off === true) + try { + const view = await rt.scheduleEndpoint(allOff ? 'schedule-pause' : 'schedule-resume', { sessionId: assistant.sessionId, id: assistant.scheduleId }) + schedule = (view?.tasks ?? []).find(task => task.id === assistant.scheduleId)?.status ?? (allOff ? 'paused' : 'active') + } catch (error) { + record.scheduleError = error?.message ?? String(error) + } + } + await rt.save?.() + return { owner, off: record.off === true, quiet: quietOf(assistant, owner), schedule, changed: wasOff !== (record.off === true) } + } + + // In-app delivery goes to the owner's own Session and never through the GitHub identity: the 2.5 + // refusal stays where a GitHub write happens, not on the way to a person's own chat (#1733). + const deliverTo = async (project, ownerId, body) => { + const member = rt.state?.members?.[text(ownerId)] + const toId = text(member?.designatedChiefId) || text(rt.state?.mainBotIds?.[0]) + if (toId === '') return { delivered: false, reason: `owner ${text(ownerId) || '(none)'} has no Session to deliver to` } + await rt.deliver?.({ + fromId: project.assistant?.botId ?? toId, + toId, + sessionId: rt.chatOf?.(toId) ?? toId, + text: body, + role: ASSISTANT_ROLE, + }) + return { delivered: true, toId } + } + + const bodyFor = (project, built, who) => (who.ok === true ? [attribution(project, who.login), built.text].join('\n\n') : built.text) + + // One owner's digest. Generation may proceed while a quiet window holds delivery; a window that + // covers 09:00 delivers nothing that day at all, and that is the expected outcome, not a failure. + const digestForOwner = async ({ project, owner, rows, files, at, force, who }) => { + const assistant = assistantOf(project) ?? project.assistant ?? null + const record = assistant === null ? { } : ownerRecord(assistant, owner) + const quiet = quietOf(assistant, owner) + const clock = clockIn(quiet.zone, at) + const coversDue = inQuietHours(quiet, DIGEST_TIME) + const memory = memoryOf(project, owner) const day = dayIn(at) + if (record.off === true) { + return { owner, ok: true, skipped: true, optedOut: true, delivered: false, day, reason: 'this owner turned the digest off, so nothing is generated' } + } + // A digest held earlier today goes out once, when the window ends, labelled as held. + const held = typeof record.held === 'object' && record.held !== null ? record.held : null + if (held !== null && held.day === day && coversDue === false && inQuietHours(quiet, clock) === false) { + delete record.held + const label = `held from ${DIGEST_TIME} to ${clock} by quiet hours ${quiet.from}–${quiet.to}` + const body = [bodyFor(project, { text: text(held.text) }, who), `(${label})`].join('\n') + const sent = await deliverTo(project, owner, body) + await rt.save?.() + return { owner, ok: true, skipped: false, held: true, delivered: sent.delivered === true, day, changed: true, text: body, label, reason: label } + } if (!force && memory.digest?.day === day) { - return { ok: true, skipped: true, day, reason: 'today already has a digest', project } + return { owner, ok: true, skipped: true, delivered: false, day, reason: 'today already has a digest' } + } + const built = buildDigest({ project, rows, since: memory.digest?.at ?? null, files, at, owner, actionable: actionableFor(rows, owner) }) + const note = { day: built.day, at, text: built.text, ...(who.ok === true ? {} : { error: `GitHub write refused: ${who.reason}` }) } + rt.setProjectDigest?.(project.id, note, owner) + if (coversDue) { + return { owner, ok: true, skipped: false, delivered: false, held: true, dropped: true, day: built.day, changed: built.changed, text: built.text, reason: `quiet hours ${quiet.from}–${quiet.to} cover ${DIGEST_TIME}, so nothing is delivered — the expected outcome` } + } + if (inQuietHours(quiet, clock)) { + record.held = { day: built.day, at, text: built.text, from: quiet.from, to: quiet.to } + await rt.save?.() + return { owner, ok: true, skipped: false, delivered: false, held: true, day: built.day, changed: built.changed, text: built.text, reason: `quiet hours ${quiet.from}–${quiet.to}: held, and delivered once when they end` } } + const body = bodyFor(project, built, who) + const sent = await deliverTo(project, owner, body) + return { owner, ok: true, skipped: false, delivered: sent.delivered === true, day: built.day, changed: built.changed, text: body } + } + + // One read of the board and one of Project State, then one render per owner (#1733): the facts + // are shared, the personalisation is not, and neither side sees the other's memory. A render + // that throws is that owner's failure, recorded on their own note; the other owners still go out. + const digest = async (projectId, { at = Date.now(), force = false, ownerId = '' } = {}) => { + const project = activeProject(projectId) + if (text(ownerId) !== '' && !ownersOf(project).includes(text(ownerId))) throw outsideBoundary(project, ownerId) + const listed = text(ownerId) === '' ? ownersOf(project) : [text(ownerId)] + const owners = listed.length === 0 ? [''] : listed + const who = identity(project.id) const rows = rt.projectBoard?.(project.id) ?? [] - const listed = project.workDir === undefined ? null : await listProjectState(project.workDir).catch(() => null) - const files = listed?.ok === true ? listed.files : [] - const built = buildDigest({ project, rows, since: memory.digest?.at ?? null, files, at }) - const body = [attribution(project, login), built.text].join('\n\n') - const note = { day: built.day, at, text: built.text } - rt.setProjectDigest?.(project.id, note) - const toId = text(project.ownerUserId) === '' ? rt.state?.mainBotIds?.[0] : (rt.state?.members?.[project.ownerUserId]?.designatedChiefId ?? rt.state?.mainBotIds?.[0]) - if (typeof toId === 'string' && toId !== '') { - await rt.deliver?.({ - fromId: project.assistant?.botId ?? toId, - toId, - sessionId: rt.chatOf?.(toId) ?? toId, - text: body, - role: ASSISTANT_ROLE, - }) + const state = project.workDir === undefined ? null : await listProjectState(project.workDir).catch(() => null) + const files = state?.ok === true ? state.files : [] + const results = [] + for (const owner of owners) { + try { + results.push(await digestForOwner({ project, owner, rows, files, at, force, who })) + } catch (error) { + const failure = error?.message ?? String(error) + rt.setProjectDigest?.(project.id, { day: dayIn(at), at, text: `digest failed: ${failure}`, error: failure }, owner) + rt.warn?.('ds-bot: the digest of %s for %s failed: %s', project.name, owner, failure) + results.push({ owner, ok: false, skipped: false, delivered: false, day: dayIn(at), error: failure, recorded: true }) + } + } + const first = results[0] ?? {} + return { + ok: true, + day: first.day, + skipped: first.skipped === true, + changed: first.changed, + reason: first.reason, + text: first.text ?? '', + project, + owners: results, + delivered: results.filter(row => row.delivered === true).length, + identity: who.ok === true ? { ok: true, login: who.login } : { ok: false, escalated: who.escalate === true, reason: who.reason }, } - return { ok: true, skipped: false, day: built.day, changed: built.changed, text: body, project } } - rt.projectAssistant = { create, remove, read, write, identity, digest, buildDigest } + rt.projectAssistant = { create, remove, read, write, identity, digest, buildDigest, setOwner, ownersOf, actionableFor, MAX_ACTIONABLE } // The assistant's own Session calls this. The Project comes from the Session's bound Bot, never // from the arguments, so one Project's assistant cannot reach another Project's memory. diff --git a/core/src/host/store.js b/core/src/host/store.js index 923dbf65e..3fcf1ecf3 100644 --- a/core/src/host/store.js +++ b/core/src/host/store.js @@ -411,6 +411,9 @@ export function install(rt) { repository: repository === '' ? null : repository, defaultBranch: projectText(body.defaultBranch, 120) || 'main', ownerUserId: projectText(body.ownerUserId, 120) || null, + // Further owners of the same Project (#1733): each has their own memory and their own digest, + // built from the same board read. An empty list is a single-owner Project. + ownerUserIds: Array.isArray(body.ownerUserIds) ? [...new Set(body.ownerUserIds.map(id => projectText(id, 120)).filter(id => id !== ''))] : [], createdAt: now, updatedAt: now, status, @@ -420,26 +423,54 @@ export function install(rt) { } const readProject = id => projectOfId(id) const listProjects = () => Object.values(state.projects).filter(isRecord) - // A Project Assistant's memory (#1712): decisions, open questions and the last digest, stored - // per Project id. A Project with no entries answers empty lists, never another Project's. + // A Project Assistant's memory (#1712, per owner since #1733): decisions, open questions and + // the last digest, stored per (Project, owner). The row keyed by the Project id keeps one record + // per owner under `owners`; its own three fields mirror the Project's primary owner, so a + // single-owner Project reads exactly as it did before the owner dimension existed. const projectMemoryOf = (id) => { const kept = ownEntry(state.projectMemory, String(id ?? '')) return isRecord(kept) ? kept : null } + const primaryOwner = project => projectText(project?.ownerUserId, 120) + const ownerRow = (table, ownerId) => { + if (!isRecord(table.owners)) table.owners = {} + const kept = ownEntry(table.owners, String(ownerId ?? '')) + if (isRecord(kept)) return kept + const fresh = { decisions: [], questions: [], digest: null } + setEntry(table.owners, String(ownerId ?? ''), fresh) + return fresh + } + const ownerView = record => ({ + decisions: Array.isArray(record?.decisions) ? record.decisions : [], + questions: Array.isArray(record?.questions) ? record.questions : [], + digest: isRecord(record?.digest) ? record.digest : null, + }) + // Mirrors an owner's record onto the Project row when that owner is the primary one, which is + // the whole row on a single-owner Project. + const mirrorOwner = (table, project, ownerId, record) => { + if (String(ownerId ?? '') !== primaryOwner(project)) return + table.decisions = record.decisions + table.questions = record.questions + table.digest = record.digest + } const projectMemory = (id) => { + const project = projectOfId(id) + if (project === null) throw projectRefusal(`Unknown Project ${String(id ?? '')}`, 'bot/not-found') + return ownerView(projectMemoryOf(project.id)) + } + // One owner's view. A Project with no entries for that owner answers empty lists, never another + // owner's or another Project's. + const projectMemoryFor = (id, ownerId) => { const project = projectOfId(id) if (project === null) throw projectRefusal(`Unknown Project ${String(id ?? '')}`, 'bot/not-found') const kept = projectMemoryOf(project.id) - return { - decisions: Array.isArray(kept?.decisions) ? kept.decisions : [], - questions: Array.isArray(kept?.questions) ? kept.questions : [], - digest: isRecord(kept?.digest) ? kept.digest : null, - } + const row = isRecord(kept?.owners) ? ownEntry(kept.owners, String(ownerId ?? '')) : null + return ownerView(row) } const memoryTable = (project) => { const kept = projectMemoryOf(project.id) if (kept !== null) return kept - const fresh = { decisions: [], questions: [], digest: null } + const fresh = { decisions: [], questions: [], digest: null, owners: {} } setEntry(state.projectMemory, project.id, fresh) return fresh } @@ -447,18 +478,25 @@ export function install(rt) { const project = requireProject(id) if (kind !== 'decision' && kind !== 'question') throw projectRefusal(`Unknown Project memory kind ${String(kind ?? '')}`) const table = memoryTable(project) - const list = kind === 'decision' ? table.decisions : table.questions + const ownerId = projectText(entry.ownerId, 120) || primaryOwner(project) + const record = ownerRow(table, ownerId) + const list = kind === 'decision' ? record.decisions : record.questions list.push({ text: projectText(entry.text, 600), at: Number(entry.at) || Date.now(), ...(projectText(entry.by, 120) === '' ? {} : { by: projectText(entry.by, 120) }) }) + mirrorOwner(table, project, ownerId, record) project.updatedAt = Math.max(project.updatedAt, Date.now()) - return projectMemory(project.id) + return ownerView(record) } - // The last digest is one note per Project, replaced only by a later day's build; the assistant - // refuses a second build in the same day before it gets here. - const setProjectDigest = (id, note = {}) => { + // The last digest is one note per (Project, owner), replaced only by a later day's build; the + // assistant refuses a second build in the same day before it gets here. + const setProjectDigest = (id, note = {}, ownerId) => { const project = requireProject(id) - memoryTable(project).digest = { day: projectText(note.day, 20), at: Number(note.at) || Date.now(), text: projectText(note.text, 4000) } + const table = memoryTable(project) + const owner = projectText(ownerId, 120) || primaryOwner(project) + const record = ownerRow(table, owner) + record.digest = { day: projectText(note.day, 20), at: Number(note.at) || Date.now(), text: projectText(note.text, 4000), ...(projectText(note.error, 300) === '' ? {} : { error: projectText(note.error, 300) }) } + mirrorOwner(table, project, owner, record) project.updatedAt = Math.max(project.updatedAt, Date.now()) - return projectMemory(project.id) + return ownerView(record) } // Deleting a Project takes its bindings and its memory with it (#1712): an unbound member // resolves `null` again, and no orphan memory keyed by a dead id can be read. @@ -553,6 +591,14 @@ export function install(rt) { await save() return { project } } + // The assistant's per-owner controls (#1733): a quiet window and the digest opt-out live on + // the Project's own assistant record, not in the host-wide prefs. + case 'project-assistant-owner': { + const project = requireProject(projectText(body.id, 200)) + const result = await rt.projectAssistant?.setOwner?.(project.id, projectText(body.ownerId, 120), body.settings ?? {}) + await save() + return { project: readProject(project.id), ...(result ?? {}) } + } case 'project-of': return { project: projectOfMember(projectText(body.kind, 20), projectText(body.id, 200)) } // The read-only board (#1696): Project id in, its tasks out. The aggregate lives in // project-board.js; it reads and returns, and writes nothing. The actor goes with the id @@ -852,6 +898,6 @@ export function install(rt) { groupSessionsOf, dropGroupSession, findBot, findRooms, isAdminOf, initialOwnerId, syncDesignatedChief, projectEndpoint, createProject, readProject, listProjects, bindProject, projectOfMember, - projectMemory, projectMemoryOf, appendProjectMemory, setProjectDigest, deleteProject, + projectMemory, projectMemoryOf, projectMemoryFor, appendProjectMemory, setProjectDigest, deleteProject, }) } diff --git a/core/test/project-assistant.test.js b/core/test/project-assistant.test.js index 44ec1b7e5..ec0a40388 100644 --- a/core/test/project-assistant.test.js +++ b/core/test/project-assistant.test.js @@ -8,7 +8,7 @@ import { join } from 'node:path' import { test } from 'node:test' import { createRuntime } from '../index.js' import { fakeContext } from './fake-ctx.js' -import { buildDigest, dayIn } from '../src/host/project-assistant.js' +import { MAX_ACTIONABLE, actionableFor, buildDigest, dayIn } from '../src/host/project-assistant.js' const seed = () => ({ version: 2, @@ -168,25 +168,30 @@ test('a day with no change is one line and the second build adds nothing', async } }) -test('identity refuses with no connector and on a multi-member host that is not the owner', async () => { +// The 2.5 identity refusal stays on the GitHub write; it is not a delivery gate (#1733). A digest +// still reaches the owner's own Session, and the refusal is recorded on the digest's evidence. +test('the digest still reaches the owner in-app while the GitHub write refuses', async () => { const { rt, delivered, stop } = await start({ github: null }) try { const project = await created(rt, { name: 'No Identity', ownerUserId: 'owner-1' }) - const refused = await rt.projectAssistant.digest(project.id, { at: Date.parse('2026-10-29T01:00:00Z') }) - assert.equal(refused.ok, false) - assert.equal(refused.escalated, true) - assert.match(refused.reason, /no GitHub connector is signed in/) - assert.equal(delivered.length, 0, 'a refused identity posts nothing') - assert.equal(rt.projectMemoryOf(project.id)?.digest ?? null, null, 'and leaves no note behind') + rt.projectBoard = () => [] + const result = await rt.projectAssistant.digest(project.id, { at: Date.parse('2026-10-29T01:00:00Z') }) + assert.equal(result.ok, true) + assert.equal(result.identity.ok, false) + assert.equal(result.identity.escalated, true) + assert.match(result.identity.reason, /no GitHub connector is signed in/) + assert.equal(result.delivered, 1, 'in-app delivery does not depend on the GitHub identity') + assert.equal(delivered.length, 1) + assert.doesNotMatch(delivered[0].text, /^Written-by:/, 'no GitHub attribution without an identity') + assert.match(rt.projectMemoryFor(project.id, 'owner-1').digest.error, /GitHub write refused: no GitHub connector/) rt.connectorsView = () => [{ id: 'github', name: 'GitHub', state: 'ready', account: 'Birfy' }] rt.state.members['owner-2'] = { id: 'owner-2', createdAt: 2 } const other = await created(rt, { name: 'Somebody Else', ownerUserId: 'owner-2' }) - const refusedToo = await rt.projectAssistant.digest(other.id, { at: Date.parse('2026-10-29T01:00:00Z') }) - assert.equal(refusedToo.ok, false) - assert.equal(refusedToo.escalated, true) - assert.match(refusedToo.reason, /no per-user GitHub linkage/) - assert.equal(delivered.length, 0) + const second = await rt.projectAssistant.digest(other.id, { at: Date.parse('2026-10-29T01:00:00Z') }) + assert.equal(second.identity.ok, false) + assert.match(second.identity.reason, /no per-user GitHub linkage/) + assert.equal(second.delivered, 1, 'and the owner it cannot post for still gets their own digest') } finally { await stop() } @@ -232,3 +237,181 @@ test('buildDigest is pure: the same rows answer the same four sections', () => { assert.equal(quiet.changed, false) assert.match(quiet.text, /no change since the previous digest\.$/) }) + +// --- #1733: per-owner digests, quiet windows that hold, opt-out and one owner's failure ------- + +// A second owner on the same host, with their own Session (a second chief Bot). +function secondOwner(rt) { + rt.state.members['owner-2'] = { id: 'owner-2', createdAt: 2, designatedChiefId: 'b' } + rt.state.bots.b = { id: 'b', name: 'Second Chief', role: 'Boss', sessionId: 'b', createdAt: 2, ownerId: 'owner-2' } +} + +const AT = Date.parse('2026-10-29T01:00:00Z') + +test('two owners of one Project get two digests from one read, with no memory bleed either way', async () => { + const { rt, delivered, stop } = await start() + try { + const project = await created(rt, { name: 'Shared', ownerUserId: 'owner-1', ownerUserIds: ['owner-2'] }) + secondOwner(rt) + await rt.projectEndpoint('project-memory-write', { id: project.id, kind: 'decision', text: 'Owner one keeps the deploy key', ownerId: 'owner-1' }) + await rt.projectEndpoint('project-memory-write', { id: project.id, kind: 'decision', text: 'Owner two picks the release date', ownerId: 'owner-2' }) + let reads = 0 + rt.projectBoard = () => { + reads += 1 + return [ + { id: 't-1', source: 'bot', column: 'in-progress', assignee: 'owner-1', updatedAt: 1 }, + { id: 't-2', source: 'bot', column: 'in-progress', assignee: 'owner-2', updatedAt: 2 }, + { id: 't-3', source: 'bot', column: 'done', assignee: null, updatedAt: 3 }, + ] + } + const result = await rt.projectAssistant.digest(project.id, { at: AT }) + assert.equal(reads, 1, 'one board read serves every owner') + assert.equal(result.owners.length, 2) + assert.equal(result.delivered, 2) + const [one, two] = result.owners + assert.equal(one.owner, 'owner-1') + assert.equal(two.owner, 'owner-2') + assert.match(one.text, /Needs owner-1's decision:\n- t-1 /) + assert.match(two.text, /Needs owner-2's decision:\n- t-2 /) + assert.doesNotMatch(one.text, /Needs owner-1's decision:[\s\S]*t-2/, "one owner's decisions never list the other's card") + assert.doesNotMatch(two.text, /Needs owner-2's decision:[\s\S]*t-1/) + for (const digest of [one.text, two.text]) { + assert.match(digest, /t-3 \(bot\)/, 'the shared facts are the same for both') + assert.ok(digest.startsWith('Project Assistant (Shared)'), 'each digest names the Project its facts came from') + assert.match(digest, /2026-10-29/) + } + assert.doesNotMatch(one.text, /release date/, "no line of the other owner's memory") + assert.doesNotMatch(two.text, /deploy key/) + assert.deepEqual(delivered.map(message => message.toId), ['a', 'b'], 'one message to each owner Session') + assert.deepEqual(rt.projectMemoryFor(project.id, 'owner-1').decisions.map(entry => entry.text), ['Owner one keeps the deploy key']) + assert.deepEqual(rt.projectMemoryFor(project.id, 'owner-2').decisions.map(entry => entry.text), ['Owner two picks the release date']) + } finally { + await stop() + } +}) + +test('a cross-owner memory read is refused and names the boundary', async () => { + const { rt, stop } = await start() + try { + const project = await created(rt, { name: 'Fenced', ownerUserId: 'owner-1', ownerUserIds: ['owner-2'] }) + secondOwner(rt) + assert.throws(() => rt.projectAssistant.read(project.id, 'owner-3'), error => error.code === 'bot/refused' && /belongs only to owner-1, owner-2/.test(error.message) && /owner-3 is outside that boundary/.test(error.message)) + await assert.rejects(() => rt.projectAssistant.digest(project.id, { at: AT, ownerId: 'owner-3' }), /outside that boundary/) + const mine = rt.projectAssistant.read(project.id, 'owner-2') + assert.deepEqual(mine.memory.decisions, []) + } finally { + await stop() + } +}) + +test('a quiet window holds the digest and delivers it once, labelled, when it ends', async () => { + const { rt, delivered, stop } = await start() + try { + const project = await created(rt, { name: 'Held', ownerUserId: 'owner-1' }) + rt.projectBoard = () => [{ id: 't-hold', source: 'bot', column: 'in-progress', assignee: 'owner-1', updatedAt: 1 }] + await rt.projectAssistant.setOwner(project.id, 'owner-1', { quiet: { on: true, from: '07:00', to: '08:30' }, zone: 'Asia/Shanghai' }) + const inside = Date.parse('2026-10-29T00:00:00Z') // 08:00 Asia/Shanghai + const after = Date.parse('2026-10-29T00:45:00Z') // 08:45 Asia/Shanghai + const held = await rt.projectAssistant.digest(project.id, { at: inside }) + assert.equal(held.skipped, false) + assert.equal(held.delivered, 0) + assert.equal(held.owners[0].held, true) + assert.match(held.owners[0].reason, /held, and delivered once when they end/) + assert.equal(delivered.length, 0, 'nothing is delivered inside the window') + assert.match(rt.projectMemoryFor(project.id, 'owner-1').digest.text, /t-hold/, 'generation still happened') + + const once = await rt.projectAssistant.digest(project.id, { at: after }) + assert.equal(once.delivered, 1) + assert.match(delivered[0].text, /held from 09:00 to 08:45 by quiet hours 07:00\u201308:30/) + const twice = await rt.projectAssistant.digest(project.id, { at: after + 60_000 }) + assert.equal(twice.delivered, 0, 'delivered once, not again') + assert.equal(delivered.length, 1) + } finally { + await stop() + } +}) + +test('a quiet window that covers 09:00 delivers nothing, and that is the expected outcome', async () => { + const { rt, delivered, stop } = await start() + try { + const project = await created(rt, { name: 'Covered', ownerUserId: 'owner-1' }) + rt.projectBoard = () => [] + await rt.projectAssistant.setOwner(project.id, 'owner-1', { quiet: { on: true, from: '09:00', to: '11:00' }, zone: 'Asia/Shanghai' }) + const result = await rt.projectAssistant.digest(project.id, { at: AT }) + assert.equal(result.delivered, 0) + assert.equal(result.owners[0].held, true) + assert.equal(result.owners[0].dropped, true) + assert.match(result.owners[0].reason, /cover 09:00, so nothing is delivered — the expected outcome/) + assert.equal(delivered.length, 0) + assert.match(rt.projectMemoryFor(project.id, 'owner-1').digest.text, /no change/, 'the digest was still built') + } finally { + await stop() + } +}) + +test('opting out stops generation and pauses the 09:00 task, and re-enabling restores both', async () => { + const { rt, service, delivered, stop } = await start() + try { + const project = await created(rt, { name: 'Off', ownerUserId: 'owner-1' }) + assert.equal(service.tasks.size, 1) + const off = await rt.projectAssistant.setOwner(project.id, 'owner-1', { off: true }) + assert.equal(off.off, true) + assert.equal(off.changed, true) + assert.equal(off.schedule, 'paused') + assert.equal(service.tasks.size, 0, 'the 09:00 task no longer wakes the assistant') + const silent = await rt.projectAssistant.digest(project.id, { at: AT }) + assert.equal(silent.owners[0].optedOut, true) + assert.equal(silent.delivered, 0) + assert.equal(delivered.length, 0) + assert.equal(rt.projectMemoryFor(project.id, 'owner-1').digest, null, 'no generation while off') + + const on = await rt.projectAssistant.setOwner(project.id, 'owner-1', { off: false }) + assert.equal(on.off, false) + assert.equal(service.tasks.size, 1, 'the task is back') + const back = await rt.projectAssistant.digest(project.id, { at: AT }) + assert.equal(back.delivered, 1) + assert.equal(delivered.length, 1) + } finally { + await stop() + } +}) + +test("one owner's render failing is recorded while the other owner's digest still goes out", async () => { + const { rt, delivered, stop } = await start() + try { + const project = await created(rt, { name: 'Isolated', ownerUserId: 'owner-1', ownerUserIds: ['owner-2'] }) + secondOwner(rt) + rt.projectBoard = () => [{ id: 't-9', source: 'bot', column: 'in-progress', assignee: null, updatedAt: 1 }] + const realFor = rt.projectMemoryFor + rt.projectMemoryFor = (id, owner) => { + if (owner === 'owner-1') throw new Error('owner-1 memory is unreadable') + return realFor(id, owner) + } + const result = await rt.projectAssistant.digest(project.id, { at: AT }) + const [one, two] = result.owners + assert.equal(one.ok, false) + assert.equal(one.recorded, true) + assert.match(one.error, /owner-1 memory is unreadable/) + assert.equal(two.ok, true) + assert.equal(two.delivered, true, "B's digest still goes out") + assert.equal(delivered.length, 1) + rt.projectMemoryFor = realFor + assert.match(rt.projectMemoryFor(project.id, 'owner-1').digest.text, /digest failed: owner-1 memory is unreadable/) + } finally { + await stop() + } +}) + +test('actionable items are capped and only the owner\'s own cards appear', () => { + const rows = Array.from({ length: MAX_ACTIONABLE + 3 }, (_, index) => ({ id: `t-${index}`, source: 'bot', column: 'in-progress', assignee: 'owner-1', updatedAt: index, title: `card ${index}` })) + rows.push({ id: 'theirs', source: 'bot', column: 'in-progress', assignee: 'owner-2', updatedAt: 99, title: 'not yours' }) + const items = actionableFor(rows, 'owner-1') + assert.equal(items.length, MAX_ACTIONABLE + 3, 'the helper keeps them all…') + assert.ok(items.every(item => item.id !== 'theirs'), '…but never another owner\'s card') + const built = buildDigest({ project: { id: 'p', name: 'Cap' }, rows, owner: 'owner-1', actionable: items, at: AT }) + const personal = built.text.slice(built.text.indexOf("Needs owner-1's decision:")) + const listed = personal.split('\n').filter(line2 => line2.startsWith('- t-')) + assert.equal(listed.length, MAX_ACTIONABLE, 'the digest lists at most MAX_ACTIONABLE') + assert.match(built.text, /- t-0 — decide what happens next — card 0 \(still in-progress\)/) + assert.doesNotMatch(built.text, /not yours/) +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index b26c70d3e..aee7246ee 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: `. 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. Tests: `core/test/project-assistant.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`. | | `core/src/shared/failure-copy.js` | A failed turn's sentence a person can act on (#1347): `failureSentence(raw, translate)` keys on the provider's own `code` field in the stored text — not on the message, which is not stable — and returns the mapped sentence (naming the feature in an `UnsupportedModel` refusal, and where to change the model), or the text unchanged when the code is not mapped, because a failure nobody has mapped reads better as it came than as a guess. `src/shared/turn-failures.js` and `src/client/inbox.js` both read it, so the chat's dead-turn lines and the Inbox card say the same thing. Tests: `core/test/ui-clickthrough.test.js`. | @@ -628,3 +628,40 @@ no second store, no UI-only path and no cron. position under its own name; it may not write the decision file or close the meeting, which is refused `forbidden`. - Tests: `core/test/meetings.test.js`. + +### Per-owner digests: two owners, quiet windows, opt-out and isolation (#1733) + +Stage 4.2 of #1550 turns the Project Assistant from one owner to every owner of the same Project, on +the host side only, so the tier stays T2 and `core/src/shared/` is untouched. + +- **Owners.** A Project lists its primary owner in `ownerUserId` and further owners in + `ownerUserIds`; `rt.projectAssistant.ownersOf(project)` is that list and it is the memory + boundary. `read(projectId, ownerId)` and `digest(projectId, { ownerId })` refuse an id outside + it with `bot/refused`, naming the owners and the boundary — never an empty answer that would read + as "this Project has no memory". +- **One read, two renders.** `digest()` reads `rt.projectBoard(project.id)` once and + `listProjectState(project.workDir)` once, then renders once per owner with that owner's memory and + that owner's actionable block. Shared facts are identical in both texts; a card assigned to + somebody else never appears in the "Needs 's decision:" block, and `MAX_ACTIONABLE` (5) + caps that block, each item carrying the task id and a one-line ask. A prompt-injected line cannot + cross: digest A carries no entry from B's memory because A's render is handed only A's entries. +- **Quiet window per owner.** `rt.projectAssistant.setOwner(projectId, ownerId, { quiet, zone, off })` + stores `{ quiet: { on, from, to }, zone, off }` on `project.assistant.owners[ownerId]`, never in + `state.prefs` (the host-wide #1166 window keeps its own job, refusing a new or edited task). The + window is read in the owner's zone with `clockIn`, and: + a digest due inside it is **held** — still generated and stored, then delivered once when the + window ends, labelled `held from 09:00 to by quiet hours –`; + a window that covers 09:00 delivers **nothing** that day, and that is the expected outcome, not a + failure (the note is still written, so the next day is still one a day). +- **Opt-out.** `off: true` on the owner record stops generation for that owner (no note, no + message) and the 09:00 task is **paused** through `rt.scheduleEndpoint('schedule-pause')` once + every owner is off — the task is shared, so one owner turning back on **resumes** it + (`schedule-resume`), and the setting is restored from the record, not by creating a second task. +- **Failure isolation.** Each owner's render is wrapped: a throw is that owner's failure, recorded + as `digest failed: ` on their own note and warned once, while the other owners' digests + are still built and delivered. A failed render never costs the other owner their 09:00 message. +- **Identity.** The 2.5 GitHub gate is a **write** gate and no longer a delivery gate: the digest is + delivered to each owner's own Session through `deliver()` with no connector signed in, and the + refusal is recorded on that digest's note (`GitHub write refused: …`) instead of suppressing the + message. `Written-by:`/`Posted-for:` appear only when an identity was resolved. +- Endpoint `project-assistant-owner` (`{ id, ownerId, settings }`) exposes the controls. diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index 262461438..71945494d 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -680,7 +680,7 @@ diff --git a/src/host/store.js b/src/host/store.js const EMPTY = JSON.stringify(state) // The other dsh host that holds state.lock while this one only reads (see home-lock.js); -@@ -50,12 +50,88 @@ export function install(rt) { +@@ -50,12 +50,88 @@ } } const readOnly = () => { @@ -770,7 +770,7 @@ diff --git a/src/host/store.js b/src/host/store.js const readFileState = async () => { let contents try { -@@ -87,10 +87,32 @@ export function install(rt) { +@@ -87,10 +87,32 @@ return undefined } if (parsed?.version === 1) { @@ -804,7 +804,7 @@ diff --git a/src/host/store.js b/src/host/store.js return undefined } -@@ -104,6 +104,20 @@ export function install(rt) { +@@ -104,6 +104,20 @@ // Teams saved before the DSH Agent existed carry no `agents`; corrupt values reset. if (state.agents === null || typeof state.agents !== 'object' || Array.isArray(state.agents)) state.agents = {} let migrated = !hadCapabilities @@ -825,7 +825,7 @@ diff --git a/src/host/store.js b/src/host/store.js if (state.capabilities === null || typeof state.capabilities !== 'object' || Array.isArray(state.capabilities)) { state.capabilities = { items: {} } migrated = true -@@ -112,6 +112,11 @@ export function install(rt) { +@@ -112,6 +112,11 @@ migrated = true } for (const bot of Object.values(state.bots)) { @@ -837,7 +837,7 @@ diff --git a/src/host/store.js b/src/host/store.js if (!Array.isArray(bot.capabilities)) { bot.capabilities = [] migrated = true -@@ -120,6 +120,11 @@ export function install(rt) { +@@ -120,6 +120,11 @@ bot.color = currentColor(bot.color) migrated = true } @@ -849,7 +849,7 @@ diff --git a/src/host/store.js b/src/host/store.js // LEGACY: Bots saved with a lab ran on that lab's default model; the model they // last ran on becomes their own, so dropping the lab changes nothing for them. if ('lab' in bot) { -@@ -161,12 +161,290 @@ export function install(rt) { +@@ -161,12 +161,336 @@ migrated = true } // Unnamed groups from before names followed their members may still list old names. @@ -958,6 +958,9 @@ diff --git a/src/host/store.js b/src/host/store.js + repository: repository === '' ? null : repository, + defaultBranch: projectText(body.defaultBranch, 120) || 'main', + ownerUserId: projectText(body.ownerUserId, 120) || null, ++ // Further owners of the same Project (#1733): each has their own memory and their own digest, ++ // built from the same board read. An empty list is a single-owner Project. ++ ownerUserIds: Array.isArray(body.ownerUserIds) ? [...new Set(body.ownerUserIds.map(id => projectText(id, 120)).filter(id => id !== ''))] : [], + createdAt: now, + updatedAt: now, + status, @@ -967,26 +970,54 @@ diff --git a/src/host/store.js b/src/host/store.js + } + const readProject = id => projectOfId(id) + const listProjects = () => Object.values(state.projects).filter(isRecord) -+ // A Project Assistant's memory (#1712): decisions, open questions and the last digest, stored -+ // per Project id. A Project with no entries answers empty lists, never another Project's. ++ // A Project Assistant's memory (#1712, per owner since #1733): decisions, open questions and ++ // the last digest, stored per (Project, owner). The row keyed by the Project id keeps one record ++ // per owner under `owners`; its own three fields mirror the Project's primary owner, so a ++ // single-owner Project reads exactly as it did before the owner dimension existed. + const projectMemoryOf = (id) => { + const kept = ownEntry(state.projectMemory, String(id ?? '')) + return isRecord(kept) ? kept : null + } ++ const primaryOwner = project => projectText(project?.ownerUserId, 120) ++ const ownerRow = (table, ownerId) => { ++ if (!isRecord(table.owners)) table.owners = {} ++ const kept = ownEntry(table.owners, String(ownerId ?? '')) ++ if (isRecord(kept)) return kept ++ const fresh = { decisions: [], questions: [], digest: null } ++ setEntry(table.owners, String(ownerId ?? ''), fresh) ++ return fresh ++ } ++ const ownerView = record => ({ ++ decisions: Array.isArray(record?.decisions) ? record.decisions : [], ++ questions: Array.isArray(record?.questions) ? record.questions : [], ++ digest: isRecord(record?.digest) ? record.digest : null, ++ }) ++ // Mirrors an owner's record onto the Project row when that owner is the primary one, which is ++ // the whole row on a single-owner Project. ++ const mirrorOwner = (table, project, ownerId, record) => { ++ if (String(ownerId ?? '') !== primaryOwner(project)) return ++ table.decisions = record.decisions ++ table.questions = record.questions ++ table.digest = record.digest ++ } + const projectMemory = (id) => { + const project = projectOfId(id) + if (project === null) throw projectRefusal(`Unknown Project ${String(id ?? '')}`, 'bot/not-found') ++ return ownerView(projectMemoryOf(project.id)) ++ } ++ // One owner's view. A Project with no entries for that owner answers empty lists, never another ++ // owner's or another Project's. ++ const projectMemoryFor = (id, ownerId) => { ++ const project = projectOfId(id) ++ if (project === null) throw projectRefusal(`Unknown Project ${String(id ?? '')}`, 'bot/not-found') + const kept = projectMemoryOf(project.id) -+ return { -+ decisions: Array.isArray(kept?.decisions) ? kept.decisions : [], -+ questions: Array.isArray(kept?.questions) ? kept.questions : [], -+ digest: isRecord(kept?.digest) ? kept.digest : null, -+ } ++ const row = isRecord(kept?.owners) ? ownEntry(kept.owners, String(ownerId ?? '')) : null ++ return ownerView(row) + } + const memoryTable = (project) => { + const kept = projectMemoryOf(project.id) + if (kept !== null) return kept -+ const fresh = { decisions: [], questions: [], digest: null } ++ const fresh = { decisions: [], questions: [], digest: null, owners: {} } + setEntry(state.projectMemory, project.id, fresh) + return fresh + } @@ -994,18 +1025,25 @@ diff --git a/src/host/store.js b/src/host/store.js + const project = requireProject(id) + if (kind !== 'decision' && kind !== 'question') throw projectRefusal(`Unknown Project memory kind ${String(kind ?? '')}`) + const table = memoryTable(project) -+ const list = kind === 'decision' ? table.decisions : table.questions ++ const ownerId = projectText(entry.ownerId, 120) || primaryOwner(project) ++ const record = ownerRow(table, ownerId) ++ const list = kind === 'decision' ? record.decisions : record.questions + list.push({ text: projectText(entry.text, 600), at: Number(entry.at) || Date.now(), ...(projectText(entry.by, 120) === '' ? {} : { by: projectText(entry.by, 120) }) }) ++ mirrorOwner(table, project, ownerId, record) + project.updatedAt = Math.max(project.updatedAt, Date.now()) -+ return projectMemory(project.id) ++ return ownerView(record) + } -+ // The last digest is one note per Project, replaced only by a later day's build; the assistant -+ // refuses a second build in the same day before it gets here. -+ const setProjectDigest = (id, note = {}) => { ++ // The last digest is one note per (Project, owner), replaced only by a later day's build; the ++ // assistant refuses a second build in the same day before it gets here. ++ const setProjectDigest = (id, note = {}, ownerId) => { + const project = requireProject(id) -+ memoryTable(project).digest = { day: projectText(note.day, 20), at: Number(note.at) || Date.now(), text: projectText(note.text, 4000) } ++ const table = memoryTable(project) ++ const owner = projectText(ownerId, 120) || primaryOwner(project) ++ const record = ownerRow(table, owner) ++ record.digest = { day: projectText(note.day, 20), at: Number(note.at) || Date.now(), text: projectText(note.text, 4000), ...(projectText(note.error, 300) === '' ? {} : { error: projectText(note.error, 300) }) } ++ mirrorOwner(table, project, owner, record) + project.updatedAt = Math.max(project.updatedAt, Date.now()) -+ return projectMemory(project.id) ++ return ownerView(record) + } + // Deleting a Project takes its bindings and its memory with it (#1712): an unbound member + // resolves `null` again, and no orphan memory keyed by a dead id can be read. @@ -1100,6 +1138,14 @@ diff --git a/src/host/store.js b/src/host/store.js + await save() + return { project } + } ++ // The assistant's per-owner controls (#1733): a quiet window and the digest opt-out live on ++ // the Project's own assistant record, not in the host-wide prefs. ++ case 'project-assistant-owner': { ++ const project = requireProject(projectText(body.id, 200)) ++ const result = await rt.projectAssistant?.setOwner?.(project.id, projectText(body.ownerId, 120), body.settings ?? {}) ++ await save() ++ return { project: readProject(project.id), ...(result ?? {}) } ++ } + case 'project-of': return { project: projectOfMember(projectText(body.kind, 20), projectText(body.id, 200)) } + // The read-only board (#1696): Project id in, its tasks out. The aggregate lives in + // project-board.js; it reads and returns, and writes nothing. The actor goes with the id @@ -1141,7 +1187,7 @@ diff --git a/src/host/store.js b/src/host/store.js const load = () => loaded ??= (async () => { claimLock() if (holder !== undefined) warn('ds-bot: %s', readOnly()) -@@ -185,14 +185,24 @@ export function install(rt) { +@@ -185,14 +185,24 @@ for (const room of stale) void rt.retitleRoom(room) })() @@ -1167,7 +1213,7 @@ diff --git a/src/host/store.js b/src/host/store.js warnedReadOnly = false adopt(await readFileState()) return -@@ -235,11 +235,37 @@ export function install(rt) { +@@ -235,11 +235,37 @@ return warnReadOnly() } @@ -1205,7 +1251,7 @@ diff --git a/src/host/store.js b/src/host/store.js state.revision += 1 state.exchanges = state.exchanges.slice(-MAX_EXCHANGES) const snapshot = JSON.stringify(state, null, 2) -@@ -260,9 +260,10 @@ export function install(rt) { +@@ -260,9 +260,10 @@ ensureLock() await rename(temp, statePath) seen = stamp() @@ -1217,7 +1263,7 @@ diff --git a/src/host/store.js b/src/host/store.js throw error } }) -@@ -273,13 +273,37 @@ export function install(rt) { +@@ -273,13 +273,37 @@ }) } @@ -1257,7 +1303,7 @@ diff --git a/src/host/store.js b/src/host/store.js const isMain = id => id !== undefined && state.mainBotIds.includes(id) // Group Sessions. A member speaks in each group from a hidden Session of its own, -@@ -313,6 +313,13 @@ export function install(rt) { +@@ -313,6 +313,13 @@ } for (const [botId, sessionId] of Object.entries(room.sessions ?? {})) groupOwners.set(sessionId, { botId, roomId: room.id }) } @@ -1271,7 +1317,7 @@ diff --git a/src/host/store.js b/src/host/store.js // The agents module owns the DSH Agent session index; it installs after this. rt.indexAgents?.() } -@@ -328,6 +328,8 @@ export function install(rt) { +@@ -328,6 +328,8 @@ delete room.sessions[botId] if (room.segments !== undefined) delete room.segments[botId] groupOwners.delete(sessionId) @@ -1280,7 +1326,7 @@ diff --git a/src/host/store.js b/src/host/store.js rt.cancelApprovals?.(sessionId) // A plain archive refuses a running Session, which would go on working for a group // that no longer has it. -@@ -335,23 +335,29 @@ export function install(rt) { +@@ -335,23 +335,29 @@ } const findBot = (ref) => { if (ref === undefined) return undefined @@ -1311,9 +1357,10 @@ diff --git a/src/host/store.js b/src/host/store.js groupSessionsOf, dropGroupSession, findBot, findRooms, isAdminOf, + initialOwnerId, syncDesignatedChief, + projectEndpoint, createProject, readProject, listProjects, bindProject, projectOfMember, -+ projectMemory, projectMemoryOf, appendProjectMemory, setProjectDigest, deleteProject, ++ projectMemory, projectMemoryOf, projectMemoryFor, appendProjectMemory, setProjectDigest, deleteProject, }) } + diff --git a/src/host/tools.js b/src/host/tools.js --- a/src/host/tools.js +++ b/src/host/tools.js diff --git a/patches/ds-bot/files/src/host/project-assistant.js b/patches/ds-bot/files/src/host/project-assistant.js index 1325bb3fe..f2e72effc 100644 --- a/patches/ds-bot/files/src/host/project-assistant.js +++ b/patches/ds-bot/files/src/host/project-assistant.js @@ -17,6 +17,7 @@ import { defineTool } from './tool-schema.js' import { listProjectState } from './project-state.js' import { output } from './tools.js' +import { inQuietHours, clockIn, quietHoursOf, readQuietHours } from '../shared/quiet-hours.js' export const ASSISTANT_ROLE = 'Project Assistant' export const DIGEST_ZONE = 'Asia/Shanghai' @@ -25,6 +26,9 @@ export const DIGEST_TITLE = '09:00 Project digest' export const DIGEST_PROMPT = 'Build today\'s Project digest: call project_digest once. It answers whether today already has one.' export const MAX_ENTRY = 600 export const MEMORY_KINDS = ['decision', 'question'] +// At most this many cards that need one owner's decision in one digest (#1733): a digest is a +// short list of things to decide, not the board. One constant, not a setting. +export const MAX_ACTIONABLE = 5 export const assistantName = project => `${ASSISTANT_ROLE} (${project.name})` @@ -54,7 +58,7 @@ const stamp = at => new Date(at).toISOString().slice(0, 10) // still in progress and the assignee each one waits on — it never calls one blocked without that // evidence. The next slice is the oldest card that has not started. Nothing is counted that is // not a row, and a Project with no rows answers with no line at all. -export function buildDigest({ project, rows = [], since = null, files = [], at = Date.now() }) { +export function buildDigest({ project, rows = [], since = null, files = [], at = Date.now(), owner = null, actionable = [] }) { const changed = row => since === null || (row?.updatedAt ?? 0) > since const finished = rows.filter(row => row.column === 'done' && changed(row)) const merged = finished.filter(row => row.source === 'workflow') @@ -64,11 +68,16 @@ export function buildDigest({ project, rows = [], since = null, files = [], at = .filter(row => row.column === 'not-started') .sort((a, b) => (a.updatedAt ?? 0) - (b.updatedAt ?? 0) || String(a.id).localeCompare(String(b.id)))[0] const line = (head, entries) => [head, ...entries.map(entry => `- ${entry}`)] + // The personal block comes last: the same rows answer the same four sections for every owner, + // and only this one names the cards that wait on the person reading it. + const asks = owner === null ? [] : actionable.slice(0, MAX_ACTIONABLE).map(item => `${item.id} — ${item.ask}`) + const personal = asks.length === 0 ? [] : line(`Needs ${owner}'s decision:`, asks) const sections = [ ...(merged.length === 0 ? [] : line('Merged since the previous digest:', merged.map(row => `${row.id} (${row.source})`))), ...(closed.length === 0 ? [] : line('Closed:', closed.map(row => `${row.id} (${row.source})`))), ...(blocked.length === 0 ? [] : line('In progress, waiting on:', blocked.map(row => `${row.id} — ${text(row.assignee)}`))), ...(next === undefined ? [] : line('Next slice:', [`${next.id} (${next.source})`])), + ...personal, ] const head = `${assistantName(project)} — ${stamp(at)}` if (sections.length === 0) return { changed: false, day: dayIn(at), text: `${head}: no change since the previous digest.` } @@ -78,6 +87,26 @@ export function buildDigest({ project, rows = [], since = null, files = [], at = return { changed: true, day: dayIn(at), text: [head, stateLine, ...sections].join('\n') } } +// The cards that need *this* owner's decision: still open, and assigned to that owner. A card +// somebody else owns is their decision, and never appears in this owner's digest. +export function actionableFor(rows, ownerId) { + const owner = text(ownerId) + if (owner === '') return [] + return (rows ?? []) + .filter(row => row?.column !== 'done' && text(row.assignee) === owner) + .sort((a, b) => (a.updatedAt ?? 0) - (b.updatedAt ?? 0) || String(a.id).localeCompare(String(b.id))) + .map(row => ({ id: String(row.id), ask: askOf(row) })) +} + +// One line, because the block is a list of decisions and a card description may be long. +const askOf = (row) => { + const title = text(row.title).replace(/\s+/g, ' ') + const where = text(row.column) === '' ? 'open' : `still ${text(row.column)}` + if (title === '') return `decide what happens next — ${where}` + const short = title.length > 80 ? `${title.slice(0, 77)}…` : title + return `decide what happens next — ${short} (${where})` +} + // A write is attributed by text, because one shared GitHub login makes it impossible to tell // otherwise (#627). The login is the Project owner's own account, resolved at write time. export const attribution = (project, login) => `Written-by: ${assistantName(project)}\nPosted-for: ${login}` @@ -97,8 +126,17 @@ export function install(rt) { return typeof botId === 'string' && rt.botOf(botId) !== undefined ? project.assistant : null } - const memoryOf = (project) => { - const memory = rt.projectMemoryOf?.(project.id) + // The owners of one Project (#1733): its primary owner plus every owner listed on the record. + // This list is the boundary a memory read is checked against. + const ownersOf = (project) => { + const primary = text(project.ownerUserId) + const listed = Array.isArray(project.ownerUserIds) ? project.ownerUserIds.map(id => text(id)).filter(id => id !== '') : [] + return [...new Set([...(primary === '' ? [] : [primary]), ...listed])] + } + + const memoryOf = (project, ownerId) => { + const owner = text(ownerId) || ownersOf(project)[0] || '' + const memory = rt.projectMemoryFor?.(project.id, owner) ?? null return { decisions: Array.isArray(memory?.decisions) ? memory.decisions : [], questions: Array.isArray(memory?.questions) ? memory.questions : [], @@ -106,7 +144,18 @@ export function install(rt) { } } - const read = (projectId) => ({ project: activeProject(projectId), memory: memoryOf(activeProject(projectId)) }) + // The refusal names the boundary instead of pretending the Project has no memory (#1733): one + // owner asking for another owner's entries learns whose they are, and nothing else. + const outsideBoundary = (project, ownerId) => { + const owners = ownersOf(project) + return refusal(`Project ${project.name} memory belongs only to ${owners.join(', ') || '(no owner)'}; ${text(ownerId) || '(none)'} is outside that boundary and cannot read it`, 'bot/refused') + } + + const read = (projectId, ownerId = '') => { + const project = activeProject(projectId) + if (text(ownerId) !== '' && !ownersOf(project).includes(text(ownerId))) throw outsideBoundary(project, ownerId) + return { project, memory: memoryOf(project, ownerId) } + } const write = (projectId, body = {}) => { const project = activeProject(projectId) @@ -115,8 +164,10 @@ export function install(rt) { const entry = text(body.text) if (entry === '') throw refusal('A Project memory entry needs text') if (entry.length > MAX_ENTRY) throw refusal(`A Project memory entry is at most ${MAX_ENTRY} characters`) - rt.appendProjectMemory?.(project.id, kind, { text: entry, at: Date.now(), ...(text(body.by) === '' ? {} : { by: text(body.by) }) }) - return { project, memory: memoryOf(project) } + const ownerId = text(body.ownerId) || ownersOf(project)[0] || '' + if (text(body.ownerId) !== '' && !ownersOf(project).includes(text(body.ownerId))) throw outsideBoundary(project, body.ownerId) + rt.appendProjectMemory?.(project.id, kind, { text: entry, at: Date.now(), ownerId, ...(text(body.by) === '' ? {} : { by: text(body.by) }) }) + return { project, memory: memoryOf(project, ownerId) } } // The one identity this slice accepts: the connector account, which on a single-person install @@ -205,39 +256,158 @@ export function install(rt) { return assistant } - // One stored Project note and one delivered message, at most once per calendar day. The second - // build in a day answers `skipped` and writes nothing; it still resolves identity first, because - // a refused identity must not leave a note behind. - const digest = async (projectId, { at = Date.now(), force = false } = {}) => { - const who = identity(projectId) - if (!who.ok) return { ok: false, escalated: who.escalate === true, reason: who.reason } - const { project, login } = who - const memory = memoryOf(project) + // Per-owner controls (#1733) live on the Project's assistant record, never in the host-wide + // prefs: a quiet window read in the owner's own zone, and the digest opt-out. + const assistantOwners = (assistant) => { + if (typeof assistant.owners !== 'object' || assistant.owners === null || Array.isArray(assistant.owners)) assistant.owners = {} + return assistant.owners + } + const ownerRecord = (assistant, ownerId) => { + const rows = assistantOwners(assistant) + const key = text(ownerId) + const kept = rows[key] + if (typeof kept !== 'object' || kept === null || Array.isArray(kept)) rows[key] = {} + return rows[key] + } + const quietOf = (assistant, ownerId) => { + const record = text(ownerId) === '' ? null : (assistant?.owners?.[text(ownerId)] ?? null) + return { ...quietHoursOf({ quietHours: record?.quiet ?? null }), zone: text(record?.zone) || DIGEST_ZONE } + } + + // The opt-out and the quiet window of one owner. The 09:00 task is paused only when every owner + // is off, because it is the one task every owner's digest is built by; turning one owner back on + // resumes it. Nothing here touches GitHub: these are the user's own controls. + const setOwner = async (projectId, ownerId, settings = {}) => { + const project = activeProject(projectId) + const owner = text(ownerId) + if (!ownersOf(project).includes(owner)) throw outsideBoundary(project, ownerId) + const assistant = assistantOf(project) + if (assistant === null) throw refusal(`Project ${project.name} has no assistant yet`, 'bot/refused') + const record = ownerRecord(assistant, owner) + const wasOff = record.off === true + if (settings.quiet !== undefined) { + const next = readQuietHours(settings.quiet ?? null, record.quiet ?? null) + if (next === undefined) delete record.quiet + else record.quiet = next + } + if (settings.zone !== undefined) { + if (text(settings.zone) === '') delete record.zone + else record.zone = text(settings.zone) + } + if (settings.off !== undefined) record.off = settings.off === true + let schedule = null + if (settings.off !== undefined && typeof rt.scheduleEndpoint === 'function' && typeof assistant.scheduleId === 'string') { + const allOff = ownersOf(project).every(id => assistant.owners?.[id]?.off === true) + try { + const view = await rt.scheduleEndpoint(allOff ? 'schedule-pause' : 'schedule-resume', { sessionId: assistant.sessionId, id: assistant.scheduleId }) + schedule = (view?.tasks ?? []).find(task => task.id === assistant.scheduleId)?.status ?? (allOff ? 'paused' : 'active') + } catch (error) { + record.scheduleError = error?.message ?? String(error) + } + } + await rt.save?.() + return { owner, off: record.off === true, quiet: quietOf(assistant, owner), schedule, changed: wasOff !== (record.off === true) } + } + + // In-app delivery goes to the owner's own Session and never through the GitHub identity: the 2.5 + // refusal stays where a GitHub write happens, not on the way to a person's own chat (#1733). + const deliverTo = async (project, ownerId, body) => { + const member = rt.state?.members?.[text(ownerId)] + const toId = text(member?.designatedChiefId) || text(rt.state?.mainBotIds?.[0]) + if (toId === '') return { delivered: false, reason: `owner ${text(ownerId) || '(none)'} has no Session to deliver to` } + await rt.deliver?.({ + fromId: project.assistant?.botId ?? toId, + toId, + sessionId: rt.chatOf?.(toId) ?? toId, + text: body, + role: ASSISTANT_ROLE, + }) + return { delivered: true, toId } + } + + const bodyFor = (project, built, who) => (who.ok === true ? [attribution(project, who.login), built.text].join('\n\n') : built.text) + + // One owner's digest. Generation may proceed while a quiet window holds delivery; a window that + // covers 09:00 delivers nothing that day at all, and that is the expected outcome, not a failure. + const digestForOwner = async ({ project, owner, rows, files, at, force, who }) => { + const assistant = assistantOf(project) ?? project.assistant ?? null + const record = assistant === null ? { } : ownerRecord(assistant, owner) + const quiet = quietOf(assistant, owner) + const clock = clockIn(quiet.zone, at) + const coversDue = inQuietHours(quiet, DIGEST_TIME) + const memory = memoryOf(project, owner) const day = dayIn(at) + if (record.off === true) { + return { owner, ok: true, skipped: true, optedOut: true, delivered: false, day, reason: 'this owner turned the digest off, so nothing is generated' } + } + // A digest held earlier today goes out once, when the window ends, labelled as held. + const held = typeof record.held === 'object' && record.held !== null ? record.held : null + if (held !== null && held.day === day && coversDue === false && inQuietHours(quiet, clock) === false) { + delete record.held + const label = `held from ${DIGEST_TIME} to ${clock} by quiet hours ${quiet.from}–${quiet.to}` + const body = [bodyFor(project, { text: text(held.text) }, who), `(${label})`].join('\n') + const sent = await deliverTo(project, owner, body) + await rt.save?.() + return { owner, ok: true, skipped: false, held: true, delivered: sent.delivered === true, day, changed: true, text: body, label, reason: label } + } if (!force && memory.digest?.day === day) { - return { ok: true, skipped: true, day, reason: 'today already has a digest', project } + return { owner, ok: true, skipped: true, delivered: false, day, reason: 'today already has a digest' } + } + const built = buildDigest({ project, rows, since: memory.digest?.at ?? null, files, at, owner, actionable: actionableFor(rows, owner) }) + const note = { day: built.day, at, text: built.text, ...(who.ok === true ? {} : { error: `GitHub write refused: ${who.reason}` }) } + rt.setProjectDigest?.(project.id, note, owner) + if (coversDue) { + return { owner, ok: true, skipped: false, delivered: false, held: true, dropped: true, day: built.day, changed: built.changed, text: built.text, reason: `quiet hours ${quiet.from}–${quiet.to} cover ${DIGEST_TIME}, so nothing is delivered — the expected outcome` } + } + if (inQuietHours(quiet, clock)) { + record.held = { day: built.day, at, text: built.text, from: quiet.from, to: quiet.to } + await rt.save?.() + return { owner, ok: true, skipped: false, delivered: false, held: true, day: built.day, changed: built.changed, text: built.text, reason: `quiet hours ${quiet.from}–${quiet.to}: held, and delivered once when they end` } } + const body = bodyFor(project, built, who) + const sent = await deliverTo(project, owner, body) + return { owner, ok: true, skipped: false, delivered: sent.delivered === true, day: built.day, changed: built.changed, text: body } + } + + // One read of the board and one of Project State, then one render per owner (#1733): the facts + // are shared, the personalisation is not, and neither side sees the other's memory. A render + // that throws is that owner's failure, recorded on their own note; the other owners still go out. + const digest = async (projectId, { at = Date.now(), force = false, ownerId = '' } = {}) => { + const project = activeProject(projectId) + if (text(ownerId) !== '' && !ownersOf(project).includes(text(ownerId))) throw outsideBoundary(project, ownerId) + const listed = text(ownerId) === '' ? ownersOf(project) : [text(ownerId)] + const owners = listed.length === 0 ? [''] : listed + const who = identity(project.id) const rows = rt.projectBoard?.(project.id) ?? [] - const listed = project.workDir === undefined ? null : await listProjectState(project.workDir).catch(() => null) - const files = listed?.ok === true ? listed.files : [] - const built = buildDigest({ project, rows, since: memory.digest?.at ?? null, files, at }) - const body = [attribution(project, login), built.text].join('\n\n') - const note = { day: built.day, at, text: built.text } - rt.setProjectDigest?.(project.id, note) - const toId = text(project.ownerUserId) === '' ? rt.state?.mainBotIds?.[0] : (rt.state?.members?.[project.ownerUserId]?.designatedChiefId ?? rt.state?.mainBotIds?.[0]) - if (typeof toId === 'string' && toId !== '') { - await rt.deliver?.({ - fromId: project.assistant?.botId ?? toId, - toId, - sessionId: rt.chatOf?.(toId) ?? toId, - text: body, - role: ASSISTANT_ROLE, - }) + const state = project.workDir === undefined ? null : await listProjectState(project.workDir).catch(() => null) + const files = state?.ok === true ? state.files : [] + const results = [] + for (const owner of owners) { + try { + results.push(await digestForOwner({ project, owner, rows, files, at, force, who })) + } catch (error) { + const failure = error?.message ?? String(error) + rt.setProjectDigest?.(project.id, { day: dayIn(at), at, text: `digest failed: ${failure}`, error: failure }, owner) + rt.warn?.('ds-bot: the digest of %s for %s failed: %s', project.name, owner, failure) + results.push({ owner, ok: false, skipped: false, delivered: false, day: dayIn(at), error: failure, recorded: true }) + } + } + const first = results[0] ?? {} + return { + ok: true, + day: first.day, + skipped: first.skipped === true, + changed: first.changed, + reason: first.reason, + text: first.text ?? '', + project, + owners: results, + delivered: results.filter(row => row.delivered === true).length, + identity: who.ok === true ? { ok: true, login: who.login } : { ok: false, escalated: who.escalate === true, reason: who.reason }, } - return { ok: true, skipped: false, day: built.day, changed: built.changed, text: body, project } } - rt.projectAssistant = { create, remove, read, write, identity, digest, buildDigest } + rt.projectAssistant = { create, remove, read, write, identity, digest, buildDigest, setOwner, ownersOf, actionableFor, MAX_ACTIONABLE } // The assistant's own Session calls this. The Project comes from the Session's bound Bot, never // from the arguments, so one Project's assistant cannot reach another Project's memory. diff --git a/patches/ds-bot/files/test/project-assistant.test.js b/patches/ds-bot/files/test/project-assistant.test.js index 44ec1b7e5..ec0a40388 100644 --- a/patches/ds-bot/files/test/project-assistant.test.js +++ b/patches/ds-bot/files/test/project-assistant.test.js @@ -8,7 +8,7 @@ import { join } from 'node:path' import { test } from 'node:test' import { createRuntime } from '../index.js' import { fakeContext } from './fake-ctx.js' -import { buildDigest, dayIn } from '../src/host/project-assistant.js' +import { MAX_ACTIONABLE, actionableFor, buildDigest, dayIn } from '../src/host/project-assistant.js' const seed = () => ({ version: 2, @@ -168,25 +168,30 @@ test('a day with no change is one line and the second build adds nothing', async } }) -test('identity refuses with no connector and on a multi-member host that is not the owner', async () => { +// The 2.5 identity refusal stays on the GitHub write; it is not a delivery gate (#1733). A digest +// still reaches the owner's own Session, and the refusal is recorded on the digest's evidence. +test('the digest still reaches the owner in-app while the GitHub write refuses', async () => { const { rt, delivered, stop } = await start({ github: null }) try { const project = await created(rt, { name: 'No Identity', ownerUserId: 'owner-1' }) - const refused = await rt.projectAssistant.digest(project.id, { at: Date.parse('2026-10-29T01:00:00Z') }) - assert.equal(refused.ok, false) - assert.equal(refused.escalated, true) - assert.match(refused.reason, /no GitHub connector is signed in/) - assert.equal(delivered.length, 0, 'a refused identity posts nothing') - assert.equal(rt.projectMemoryOf(project.id)?.digest ?? null, null, 'and leaves no note behind') + rt.projectBoard = () => [] + const result = await rt.projectAssistant.digest(project.id, { at: Date.parse('2026-10-29T01:00:00Z') }) + assert.equal(result.ok, true) + assert.equal(result.identity.ok, false) + assert.equal(result.identity.escalated, true) + assert.match(result.identity.reason, /no GitHub connector is signed in/) + assert.equal(result.delivered, 1, 'in-app delivery does not depend on the GitHub identity') + assert.equal(delivered.length, 1) + assert.doesNotMatch(delivered[0].text, /^Written-by:/, 'no GitHub attribution without an identity') + assert.match(rt.projectMemoryFor(project.id, 'owner-1').digest.error, /GitHub write refused: no GitHub connector/) rt.connectorsView = () => [{ id: 'github', name: 'GitHub', state: 'ready', account: 'Birfy' }] rt.state.members['owner-2'] = { id: 'owner-2', createdAt: 2 } const other = await created(rt, { name: 'Somebody Else', ownerUserId: 'owner-2' }) - const refusedToo = await rt.projectAssistant.digest(other.id, { at: Date.parse('2026-10-29T01:00:00Z') }) - assert.equal(refusedToo.ok, false) - assert.equal(refusedToo.escalated, true) - assert.match(refusedToo.reason, /no per-user GitHub linkage/) - assert.equal(delivered.length, 0) + const second = await rt.projectAssistant.digest(other.id, { at: Date.parse('2026-10-29T01:00:00Z') }) + assert.equal(second.identity.ok, false) + assert.match(second.identity.reason, /no per-user GitHub linkage/) + assert.equal(second.delivered, 1, 'and the owner it cannot post for still gets their own digest') } finally { await stop() } @@ -232,3 +237,181 @@ test('buildDigest is pure: the same rows answer the same four sections', () => { assert.equal(quiet.changed, false) assert.match(quiet.text, /no change since the previous digest\.$/) }) + +// --- #1733: per-owner digests, quiet windows that hold, opt-out and one owner's failure ------- + +// A second owner on the same host, with their own Session (a second chief Bot). +function secondOwner(rt) { + rt.state.members['owner-2'] = { id: 'owner-2', createdAt: 2, designatedChiefId: 'b' } + rt.state.bots.b = { id: 'b', name: 'Second Chief', role: 'Boss', sessionId: 'b', createdAt: 2, ownerId: 'owner-2' } +} + +const AT = Date.parse('2026-10-29T01:00:00Z') + +test('two owners of one Project get two digests from one read, with no memory bleed either way', async () => { + const { rt, delivered, stop } = await start() + try { + const project = await created(rt, { name: 'Shared', ownerUserId: 'owner-1', ownerUserIds: ['owner-2'] }) + secondOwner(rt) + await rt.projectEndpoint('project-memory-write', { id: project.id, kind: 'decision', text: 'Owner one keeps the deploy key', ownerId: 'owner-1' }) + await rt.projectEndpoint('project-memory-write', { id: project.id, kind: 'decision', text: 'Owner two picks the release date', ownerId: 'owner-2' }) + let reads = 0 + rt.projectBoard = () => { + reads += 1 + return [ + { id: 't-1', source: 'bot', column: 'in-progress', assignee: 'owner-1', updatedAt: 1 }, + { id: 't-2', source: 'bot', column: 'in-progress', assignee: 'owner-2', updatedAt: 2 }, + { id: 't-3', source: 'bot', column: 'done', assignee: null, updatedAt: 3 }, + ] + } + const result = await rt.projectAssistant.digest(project.id, { at: AT }) + assert.equal(reads, 1, 'one board read serves every owner') + assert.equal(result.owners.length, 2) + assert.equal(result.delivered, 2) + const [one, two] = result.owners + assert.equal(one.owner, 'owner-1') + assert.equal(two.owner, 'owner-2') + assert.match(one.text, /Needs owner-1's decision:\n- t-1 /) + assert.match(two.text, /Needs owner-2's decision:\n- t-2 /) + assert.doesNotMatch(one.text, /Needs owner-1's decision:[\s\S]*t-2/, "one owner's decisions never list the other's card") + assert.doesNotMatch(two.text, /Needs owner-2's decision:[\s\S]*t-1/) + for (const digest of [one.text, two.text]) { + assert.match(digest, /t-3 \(bot\)/, 'the shared facts are the same for both') + assert.ok(digest.startsWith('Project Assistant (Shared)'), 'each digest names the Project its facts came from') + assert.match(digest, /2026-10-29/) + } + assert.doesNotMatch(one.text, /release date/, "no line of the other owner's memory") + assert.doesNotMatch(two.text, /deploy key/) + assert.deepEqual(delivered.map(message => message.toId), ['a', 'b'], 'one message to each owner Session') + assert.deepEqual(rt.projectMemoryFor(project.id, 'owner-1').decisions.map(entry => entry.text), ['Owner one keeps the deploy key']) + assert.deepEqual(rt.projectMemoryFor(project.id, 'owner-2').decisions.map(entry => entry.text), ['Owner two picks the release date']) + } finally { + await stop() + } +}) + +test('a cross-owner memory read is refused and names the boundary', async () => { + const { rt, stop } = await start() + try { + const project = await created(rt, { name: 'Fenced', ownerUserId: 'owner-1', ownerUserIds: ['owner-2'] }) + secondOwner(rt) + assert.throws(() => rt.projectAssistant.read(project.id, 'owner-3'), error => error.code === 'bot/refused' && /belongs only to owner-1, owner-2/.test(error.message) && /owner-3 is outside that boundary/.test(error.message)) + await assert.rejects(() => rt.projectAssistant.digest(project.id, { at: AT, ownerId: 'owner-3' }), /outside that boundary/) + const mine = rt.projectAssistant.read(project.id, 'owner-2') + assert.deepEqual(mine.memory.decisions, []) + } finally { + await stop() + } +}) + +test('a quiet window holds the digest and delivers it once, labelled, when it ends', async () => { + const { rt, delivered, stop } = await start() + try { + const project = await created(rt, { name: 'Held', ownerUserId: 'owner-1' }) + rt.projectBoard = () => [{ id: 't-hold', source: 'bot', column: 'in-progress', assignee: 'owner-1', updatedAt: 1 }] + await rt.projectAssistant.setOwner(project.id, 'owner-1', { quiet: { on: true, from: '07:00', to: '08:30' }, zone: 'Asia/Shanghai' }) + const inside = Date.parse('2026-10-29T00:00:00Z') // 08:00 Asia/Shanghai + const after = Date.parse('2026-10-29T00:45:00Z') // 08:45 Asia/Shanghai + const held = await rt.projectAssistant.digest(project.id, { at: inside }) + assert.equal(held.skipped, false) + assert.equal(held.delivered, 0) + assert.equal(held.owners[0].held, true) + assert.match(held.owners[0].reason, /held, and delivered once when they end/) + assert.equal(delivered.length, 0, 'nothing is delivered inside the window') + assert.match(rt.projectMemoryFor(project.id, 'owner-1').digest.text, /t-hold/, 'generation still happened') + + const once = await rt.projectAssistant.digest(project.id, { at: after }) + assert.equal(once.delivered, 1) + assert.match(delivered[0].text, /held from 09:00 to 08:45 by quiet hours 07:00\u201308:30/) + const twice = await rt.projectAssistant.digest(project.id, { at: after + 60_000 }) + assert.equal(twice.delivered, 0, 'delivered once, not again') + assert.equal(delivered.length, 1) + } finally { + await stop() + } +}) + +test('a quiet window that covers 09:00 delivers nothing, and that is the expected outcome', async () => { + const { rt, delivered, stop } = await start() + try { + const project = await created(rt, { name: 'Covered', ownerUserId: 'owner-1' }) + rt.projectBoard = () => [] + await rt.projectAssistant.setOwner(project.id, 'owner-1', { quiet: { on: true, from: '09:00', to: '11:00' }, zone: 'Asia/Shanghai' }) + const result = await rt.projectAssistant.digest(project.id, { at: AT }) + assert.equal(result.delivered, 0) + assert.equal(result.owners[0].held, true) + assert.equal(result.owners[0].dropped, true) + assert.match(result.owners[0].reason, /cover 09:00, so nothing is delivered — the expected outcome/) + assert.equal(delivered.length, 0) + assert.match(rt.projectMemoryFor(project.id, 'owner-1').digest.text, /no change/, 'the digest was still built') + } finally { + await stop() + } +}) + +test('opting out stops generation and pauses the 09:00 task, and re-enabling restores both', async () => { + const { rt, service, delivered, stop } = await start() + try { + const project = await created(rt, { name: 'Off', ownerUserId: 'owner-1' }) + assert.equal(service.tasks.size, 1) + const off = await rt.projectAssistant.setOwner(project.id, 'owner-1', { off: true }) + assert.equal(off.off, true) + assert.equal(off.changed, true) + assert.equal(off.schedule, 'paused') + assert.equal(service.tasks.size, 0, 'the 09:00 task no longer wakes the assistant') + const silent = await rt.projectAssistant.digest(project.id, { at: AT }) + assert.equal(silent.owners[0].optedOut, true) + assert.equal(silent.delivered, 0) + assert.equal(delivered.length, 0) + assert.equal(rt.projectMemoryFor(project.id, 'owner-1').digest, null, 'no generation while off') + + const on = await rt.projectAssistant.setOwner(project.id, 'owner-1', { off: false }) + assert.equal(on.off, false) + assert.equal(service.tasks.size, 1, 'the task is back') + const back = await rt.projectAssistant.digest(project.id, { at: AT }) + assert.equal(back.delivered, 1) + assert.equal(delivered.length, 1) + } finally { + await stop() + } +}) + +test("one owner's render failing is recorded while the other owner's digest still goes out", async () => { + const { rt, delivered, stop } = await start() + try { + const project = await created(rt, { name: 'Isolated', ownerUserId: 'owner-1', ownerUserIds: ['owner-2'] }) + secondOwner(rt) + rt.projectBoard = () => [{ id: 't-9', source: 'bot', column: 'in-progress', assignee: null, updatedAt: 1 }] + const realFor = rt.projectMemoryFor + rt.projectMemoryFor = (id, owner) => { + if (owner === 'owner-1') throw new Error('owner-1 memory is unreadable') + return realFor(id, owner) + } + const result = await rt.projectAssistant.digest(project.id, { at: AT }) + const [one, two] = result.owners + assert.equal(one.ok, false) + assert.equal(one.recorded, true) + assert.match(one.error, /owner-1 memory is unreadable/) + assert.equal(two.ok, true) + assert.equal(two.delivered, true, "B's digest still goes out") + assert.equal(delivered.length, 1) + rt.projectMemoryFor = realFor + assert.match(rt.projectMemoryFor(project.id, 'owner-1').digest.text, /digest failed: owner-1 memory is unreadable/) + } finally { + await stop() + } +}) + +test('actionable items are capped and only the owner\'s own cards appear', () => { + const rows = Array.from({ length: MAX_ACTIONABLE + 3 }, (_, index) => ({ id: `t-${index}`, source: 'bot', column: 'in-progress', assignee: 'owner-1', updatedAt: index, title: `card ${index}` })) + rows.push({ id: 'theirs', source: 'bot', column: 'in-progress', assignee: 'owner-2', updatedAt: 99, title: 'not yours' }) + const items = actionableFor(rows, 'owner-1') + assert.equal(items.length, MAX_ACTIONABLE + 3, 'the helper keeps them all…') + assert.ok(items.every(item => item.id !== 'theirs'), '…but never another owner\'s card') + const built = buildDigest({ project: { id: 'p', name: 'Cap' }, rows, owner: 'owner-1', actionable: items, at: AT }) + const personal = built.text.slice(built.text.indexOf("Needs owner-1's decision:")) + const listed = personal.split('\n').filter(line2 => line2.startsWith('- t-')) + assert.equal(listed.length, MAX_ACTIONABLE, 'the digest lists at most MAX_ACTIONABLE') + assert.match(built.text, /- t-0 — decide what happens next — card 0 \(still in-progress\)/) + assert.doesNotMatch(built.text, /not yours/) +}) From e07239b91c6acd4b4b42336aecf420288e7d72b2 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Fri, 30 Oct 2026 06:25:39 +0800 Subject: [PATCH 2/2] Address the review of the per-owner digest The ask block compared a member id with the board's assignee, a Bot name, so it was empty for real Projects: the owner now comes from the card's own Bot. A memory record written before the owner dimension is read and migrated onto its primary owner instead of being replaced by the first write. A delivery with no Session of its own never falls back to another owner's main Bot, and its reason is recorded on that owner's own note. Fixes #1733 Part of #1550 Docs-not-needed: docs/subsystems/core-host.md is updated in this commit. --- core/src/host/project-assistant.js | 51 ++++++++++--- core/src/host/store.js | 24 ++++-- core/test/project-assistant.test.js | 73 +++++++++++++++++-- docs/subsystems/core-host.md | 2 +- patches/ds-bot/0002-flybot-api-host.patch | 65 ++++++++++------- .../files/src/host/project-assistant.js | 51 ++++++++++--- .../files/test/project-assistant.test.js | 73 +++++++++++++++++-- 7 files changed, 273 insertions(+), 66 deletions(-) diff --git a/core/src/host/project-assistant.js b/core/src/host/project-assistant.js index f2e72effc..158e22d39 100644 --- a/core/src/host/project-assistant.js +++ b/core/src/host/project-assistant.js @@ -87,13 +87,15 @@ export function buildDigest({ project, rows = [], since = null, files = [], at = return { changed: true, day: dayIn(at), text: [head, stateLine, ...sections].join('\n') } } -// The cards that need *this* owner's decision: still open, and assigned to that owner. A card -// somebody else owns is their decision, and never appears in this owner's digest. -export function actionableFor(rows, ownerId) { +// The cards that need *this* owner's decision: still open, and owned by that owner. A card +// somebody else owns is their decision, and never appears in this owner's digest. The board's +// `assignee` is a Bot name or null, never a member id (#1733), so the owner comes from `ownerOf`: +// the host resolves it from the card's own Bot, and a hand-built row may carry `ownerId` itself. +export function actionableFor(rows, ownerId, ownerOf = row => text(row?.ownerId)) { const owner = text(ownerId) if (owner === '') return [] return (rows ?? []) - .filter(row => row?.column !== 'done' && text(row.assignee) === owner) + .filter(row => row?.column !== 'done' && ownerOf(row) === owner) .sort((a, b) => (a.updatedAt ?? 0) - (b.updatedAt ?? 0) || String(a.id).localeCompare(String(b.id))) .map(row => ({ id: String(row.id), ask: askOf(row) })) } @@ -309,12 +311,38 @@ export function install(rt) { return { owner, off: record.off === true, quiet: quietOf(assistant, owner), schedule, changed: wasOff !== (record.off === true) } } + // A board row carries the card's identity and its column, not its title; the ask line needs a + // subject, so the card's own record supplies it. + const withTitles = rows => rows.map((row) => { + const title = text(rt.state?.tasks?.[String(row?.id ?? '')]?.title) + return title === '' ? row : { ...row, title } + }) + + // A card's owner is the owner of the Bot that owns the card: the board names the Bot, and the + // Bot names its owner. `assignee` is only a Bot name, and a hand-built row may carry `ownerId`. + const cardOwner = (row) => { + const botId = text(rt.state?.tasks?.[String(row?.id ?? '')]?.ownerBotId) + const bot = botId === '' ? undefined : rt.botOf?.(botId) + if (bot !== undefined && bot !== null && text(bot.ownerId) !== '') return text(bot.ownerId) + const named = text(row?.assignee) + if (named !== '') { + for (const candidate of Object.values(rt.state?.bots ?? {})) { + if (text(candidate?.name) === named) return text(candidate.ownerId) + } + } + return text(row?.ownerId) + } + // In-app delivery goes to the owner's own Session and never through the GitHub identity: the 2.5 // refusal stays where a GitHub write happens, not on the way to a person's own chat (#1733). + // Only the primary owner may fall back to the host's main Bot: a second owner's digest quotes + // that owner's private memory, so another owner's Session is never a delivery address for it. const deliverTo = async (project, ownerId, body) => { - const member = rt.state?.members?.[text(ownerId)] - const toId = text(member?.designatedChiefId) || text(rt.state?.mainBotIds?.[0]) - if (toId === '') return { delivered: false, reason: `owner ${text(ownerId) || '(none)'} has no Session to deliver to` } + const owner = text(ownerId) + const member = owner === '' ? undefined : rt.state?.members?.[owner] + const primary = owner === '' || owner === text(project?.ownerUserId) + const toId = text(member?.designatedChiefId) || (primary ? text(rt.state?.mainBotIds?.[0]) : '') + if (toId === '') return { delivered: false, reason: `owner ${owner || '(none)'} has no Session of their own to deliver to` } await rt.deliver?.({ fromId: project.assistant?.botId ?? toId, toId, @@ -329,7 +357,7 @@ export function install(rt) { // One owner's digest. Generation may proceed while a quiet window holds delivery; a window that // covers 09:00 delivers nothing that day at all, and that is the expected outcome, not a failure. - const digestForOwner = async ({ project, owner, rows, files, at, force, who }) => { + const digestForOwner = async ({ project, owner, rows, cards, files, at, force, who }) => { const assistant = assistantOf(project) ?? project.assistant ?? null const record = assistant === null ? { } : ownerRecord(assistant, owner) const quiet = quietOf(assistant, owner) @@ -353,7 +381,7 @@ export function install(rt) { if (!force && memory.digest?.day === day) { return { owner, ok: true, skipped: true, delivered: false, day, reason: 'today already has a digest' } } - const built = buildDigest({ project, rows, since: memory.digest?.at ?? null, files, at, owner, actionable: actionableFor(rows, owner) }) + const built = buildDigest({ project, rows, since: memory.digest?.at ?? null, files, at, owner, actionable: actionableFor(cards ?? rows, owner, cardOwner) }) const note = { day: built.day, at, text: built.text, ...(who.ok === true ? {} : { error: `GitHub write refused: ${who.reason}` }) } rt.setProjectDigest?.(project.id, note, owner) if (coversDue) { @@ -366,7 +394,8 @@ export function install(rt) { } const body = bodyFor(project, built, who) const sent = await deliverTo(project, owner, body) - return { owner, ok: true, skipped: false, delivered: sent.delivered === true, day: built.day, changed: built.changed, text: body } + if (sent.delivered !== true) rt.setProjectDigest?.(project.id, { ...note, error: sent.reason }, owner) + return { owner, ok: true, skipped: false, delivered: sent.delivered === true, day: built.day, changed: built.changed, text: body, ...(sent.delivered === true ? {} : { reason: sent.reason }) } } // One read of the board and one of Project State, then one render per owner (#1733): the facts @@ -384,7 +413,7 @@ export function install(rt) { const results = [] for (const owner of owners) { try { - results.push(await digestForOwner({ project, owner, rows, files, at, force, who })) + results.push(await digestForOwner({ project, owner, rows, cards: withTitles(rows), files, at, force, who })) } catch (error) { const failure = error?.message ?? String(error) rt.setProjectDigest?.(project.id, { day: dayIn(at), at, text: `digest failed: ${failure}`, error: failure }, owner) diff --git a/core/src/host/store.js b/core/src/host/store.js index 3fcf1ecf3..321bd8a2f 100644 --- a/core/src/host/store.js +++ b/core/src/host/store.js @@ -432,8 +432,16 @@ export function install(rt) { return isRecord(kept) ? kept : null } const primaryOwner = project => projectText(project?.ownerUserId, 120) - const ownerRow = (table, ownerId) => { - if (!isRecord(table.owners)) table.owners = {} + const ownerRow = (table, ownerId, project) => { + if (!isRecord(table.owners)) { + // A record written before the owner dimension existed keeps its three fields on the row + // itself (#1712). The first write after the upgrade moves them onto the primary owner + // instead of replacing them with a fresh empty row. + table.owners = {} + const primary = primaryOwner(project) + const legacy = ownerView(table) + if (primary !== '' && (legacy.decisions.length > 0 || legacy.questions.length > 0 || legacy.digest !== null)) setEntry(table.owners, primary, legacy) + } const kept = ownEntry(table.owners, String(ownerId ?? '')) if (isRecord(kept)) return kept const fresh = { decisions: [], questions: [], digest: null } @@ -464,8 +472,12 @@ export function install(rt) { const project = projectOfId(id) if (project === null) throw projectRefusal(`Unknown Project ${String(id ?? '')}`, 'bot/not-found') const kept = projectMemoryOf(project.id) - const row = isRecord(kept?.owners) ? ownEntry(kept.owners, String(ownerId ?? '')) : null - return ownerView(row) + if (kept === null) return ownerView(null) + const row = isRecord(kept.owners) ? ownEntry(kept.owners, String(ownerId ?? '')) : null + if (isRecord(row)) return ownerView(row) + // A pre-#1733 record has no `owners`, and its three fields belong to the primary owner. + if (!isRecord(kept.owners) && String(ownerId ?? '') === primaryOwner(project)) return ownerView(kept) + return ownerView(null) } const memoryTable = (project) => { const kept = projectMemoryOf(project.id) @@ -479,7 +491,7 @@ export function install(rt) { if (kind !== 'decision' && kind !== 'question') throw projectRefusal(`Unknown Project memory kind ${String(kind ?? '')}`) const table = memoryTable(project) const ownerId = projectText(entry.ownerId, 120) || primaryOwner(project) - const record = ownerRow(table, ownerId) + const record = ownerRow(table, ownerId, project) const list = kind === 'decision' ? record.decisions : record.questions list.push({ text: projectText(entry.text, 600), at: Number(entry.at) || Date.now(), ...(projectText(entry.by, 120) === '' ? {} : { by: projectText(entry.by, 120) }) }) mirrorOwner(table, project, ownerId, record) @@ -492,7 +504,7 @@ export function install(rt) { const project = requireProject(id) const table = memoryTable(project) const owner = projectText(ownerId, 120) || primaryOwner(project) - const record = ownerRow(table, owner) + const record = ownerRow(table, owner, project) record.digest = { day: projectText(note.day, 20), at: Number(note.at) || Date.now(), text: projectText(note.text, 4000), ...(projectText(note.error, 300) === '' ? {} : { error: projectText(note.error, 300) }) } mirrorOwner(table, project, owner, record) project.updatedAt = Math.max(project.updatedAt, Date.now()) diff --git a/core/test/project-assistant.test.js b/core/test/project-assistant.test.js index ec0a40388..a55bd844e 100644 --- a/core/test/project-assistant.test.js +++ b/core/test/project-assistant.test.js @@ -259,8 +259,8 @@ test('two owners of one Project get two digests from one read, with no memory bl rt.projectBoard = () => { reads += 1 return [ - { id: 't-1', source: 'bot', column: 'in-progress', assignee: 'owner-1', updatedAt: 1 }, - { id: 't-2', source: 'bot', column: 'in-progress', assignee: 'owner-2', updatedAt: 2 }, + { id: 't-1', source: 'bot', column: 'in-progress', assignee: 'Owner One Bot', ownerId: 'owner-1', updatedAt: 1 }, + { id: 't-2', source: 'bot', column: 'in-progress', assignee: 'Owner Two Bot', ownerId: 'owner-2', updatedAt: 2 }, { id: 't-3', source: 'bot', column: 'done', assignee: null, updatedAt: 3 }, ] } @@ -308,7 +308,7 @@ test('a quiet window holds the digest and delivers it once, labelled, when it en const { rt, delivered, stop } = await start() try { const project = await created(rt, { name: 'Held', ownerUserId: 'owner-1' }) - rt.projectBoard = () => [{ id: 't-hold', source: 'bot', column: 'in-progress', assignee: 'owner-1', updatedAt: 1 }] + rt.projectBoard = () => [{ id: 't-hold', source: 'bot', column: 'in-progress', assignee: 'Owner One Bot', ownerId: 'owner-1', updatedAt: 1 }] await rt.projectAssistant.setOwner(project.id, 'owner-1', { quiet: { on: true, from: '07:00', to: '08:30' }, zone: 'Asia/Shanghai' }) const inside = Date.parse('2026-10-29T00:00:00Z') // 08:00 Asia/Shanghai const after = Date.parse('2026-10-29T00:45:00Z') // 08:45 Asia/Shanghai @@ -403,8 +403,8 @@ test("one owner's render failing is recorded while the other owner's digest stil }) test('actionable items are capped and only the owner\'s own cards appear', () => { - const rows = Array.from({ length: MAX_ACTIONABLE + 3 }, (_, index) => ({ id: `t-${index}`, source: 'bot', column: 'in-progress', assignee: 'owner-1', updatedAt: index, title: `card ${index}` })) - rows.push({ id: 'theirs', source: 'bot', column: 'in-progress', assignee: 'owner-2', updatedAt: 99, title: 'not yours' }) + const rows = Array.from({ length: MAX_ACTIONABLE + 3 }, (_, index) => ({ id: `t-${index}`, source: 'bot', column: 'in-progress', assignee: 'Worker', ownerId: 'owner-1', updatedAt: index, title: `card ${index}` })) + rows.push({ id: 'theirs', source: 'bot', column: 'in-progress', assignee: 'Theirs', ownerId: 'owner-2', updatedAt: 99, title: 'not yours' }) const items = actionableFor(rows, 'owner-1') assert.equal(items.length, MAX_ACTIONABLE + 3, 'the helper keeps them all…') assert.ok(items.every(item => item.id !== 'theirs'), '…but never another owner\'s card') @@ -415,3 +415,66 @@ test('actionable items are capped and only the owner\'s own cards appear', () => assert.match(built.text, /- t-0 — decide what happens next — card 0 \(still in-progress\)/) assert.doesNotMatch(built.text, /not yours/) }) + +test('a card lists for the owner of the Bot that owns it, through the real task and board path', async () => { + const { rt, stop } = await start() + try { + const project = await created(rt, { name: 'Boarded', ownerUserId: 'owner-1', ownerUserIds: ['owner-2'] }) + secondOwner(rt) + rt.state.bots.worker = { id: 'worker', name: 'Worker', role: 'Dev', sessionId: 'w', createdAt: 3, ownerId: 'owner-2' } + await rt.projectEndpoint('project-bind', { kind: 'bot', id: 'a', projectId: project.id }) + await rt.projectEndpoint('project-bind', { kind: 'bot', id: 'worker', projectId: project.id }) + await rt.taskEndpoint('task-create', { botId: 'worker', title: 'Pick the release date' }, null) + await rt.taskEndpoint('task-create', { botId: 'a', title: 'Rotate the deploy key' }, null) + const rows = rt.projectBoard(project.id) + assert.equal(rows.length, 2) + assert.ok(rows.every(row => row.assignee === null), 'the board carries a Bot name or null, never a member id') + assert.equal(actionableFor(rows, 'owner-1').length, 0, 'the raw helper sees no owner without a resolver') + const result = await rt.projectAssistant.digest(project.id, { at: AT }) + const [one, two] = result.owners + assert.match(one.text, /Rotate the deploy key/, 'the card of the primary owner\'s Bot is theirs') + assert.doesNotMatch(one.text, /Pick the release date/, 'another owner\'s card never appears in this digest') + assert.match(two.text, /Pick the release date/, 'the card of the second owner\'s Bot is theirs') + assert.doesNotMatch(two.text, /Rotate the deploy key/) + } finally { + await stop() + } +}) + +test('a memory record written before the owner dimension keeps its entries across the first write', async () => { + const { rt, stop } = await start() + try { + const project = await created(rt, { name: 'Legacy', ownerUserId: 'owner-1' }) + rt.state.projectMemory[project.id] = { + decisions: [{ text: 'the old call', at: 1 }], + questions: [{ text: 'the old question', at: 1 }], + digest: { day: '2026-10-27', at: 1000, text: 'the old note' }, + } + assert.deepEqual(rt.projectMemoryFor(project.id, 'owner-1').decisions.map(entry => entry.text), ['the old call'], 'the pre-slice record reads back for its primary owner') + assert.equal(rt.projectMemoryFor(project.id, 'owner-1').digest.text, 'the old note') + await rt.projectEndpoint('project-memory-write', { id: project.id, kind: 'decision', text: 'the new call', ownerId: 'owner-1' }) + const view = rt.projectMemoryFor(project.id, 'owner-1') + assert.deepEqual(view.decisions.map(entry => entry.text), ['the old call', 'the new call'], 'the first write after the upgrade appends instead of replacing') + assert.deepEqual(view.questions.map(entry => entry.text), ['the old question'], 'the old questions survive') + assert.equal(view.digest.text, 'the old note', 'so does the last digest, so the next build is not re-based') + } finally { + await stop() + } +}) + +test('an owner without a Session never receives another owner\'s digest', async () => { + const { rt, delivered, stop } = await start() + try { + const project = await created(rt, { name: 'Unseated', ownerUserId: 'owner-1', ownerUserIds: ['owner-2'] }) + rt.state.members['owner-2'] = { id: 'owner-2', createdAt: 2 } + const result = await rt.projectAssistant.digest(project.id, { at: AT }) + assert.equal(result.delivered, 1) + assert.deepEqual(delivered.map(message => message.toId), ['a'], 'only the primary owner has a Session of their own') + assert.equal(result.owners[1].owner, 'owner-2') + assert.equal(result.owners[1].delivered, false) + assert.match(result.owners[1].reason, /no Session of their own/) + assert.match(rt.projectMemoryFor(project.id, 'owner-2').digest.error, /no Session of their own/, 'the reason is recorded on that owner\'s own note') + } finally { + await stop() + } +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index aee7246ee..6604936cd 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -208,7 +208,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. 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. 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; plugin writes follow the shared rule. `view()`/`live()` narrowing (`rt.ownedView`/`rt.ownedLive` in authz.js) keeps per-session entries (`questions`, `exchangeTurns`) the actor owns, and deployment-wide surfaces (agents, secrets, connectors, memory reviews) only while a single member exists — the same rule as the shared writes; rooms the actor belongs to and the Bots in them (#50) stay visible. `create-room` refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module `src/host/room-membership.js`, installed after groups: a room may carry typed `participants` (`human` / local `bot` / granted remote `bot` with `handle` + owner fields, roles `admin` / `member`, `joinedSeq` / `leftSeq`); log lines carry `authorId` + `authorKind`; history is the membership window (current members and the owner of an invited Bot see `seq >=` their interval; leave withdraws access; rejoin starts a new interval); local cross-owner rooms accept each side's *granted* Bots (#90: default deny, Chief included); a friend's Bot from `social/remote-bots` (`cleanRemote`) may be added and ungranted Bots stay private, and a room with a friend's Bot holds only granted local Bots (a Bot that joined by room code, `viaCode`, only for that room); `view()` labels remotes with an owner badge and shows an invited owner **Shared room from \**. Face-to-face rooms (#83) reuse the same `participants` / membership-window / owner-badge presentation with `origin: 'face-to-face'`: room-only remotes may sit in the room without a person-grant or a friendship, `presentRoom` treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and `fanoutRoomLine` goes to `rt.faceRoomFanout` instead of friend `flybotSend`. Still not team mode — see [team](team.md). Per-Bot grants (#90) add host module `src/host/reach.js`, installed after room-membership: `state.grants[botId]` is the list of independent grants (one Bot → one grantee, optionally room-scoped); default deny, Chief included; a Bot whose owner is not a member has none. Grants are set with `rt.setGrants`, exposed as `BotView.granted`, `flybot.bot.hasGrant` / `hasAnyGrant`, `flybot.grants` and the `bot.grants` event. `reach()` is the owner policy: a friend's Bot may reach only local Bots granted to that sender, only a granted Bot (or one code-joined in that room) may message a friend's Bot, another owner's Bot is reached only if both sides have matching grants or both are members of the room the message belongs to. It is not a middleware: hooks.js runs it before the `bot.send` chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or `bypass` can skip it and a queued message is judged by what is true when it is delivered. Revoking a grant, or replacing its Chief, removes it from rooms that have friends' Bots or several owners at once (`rt.evictUnshared`). Approval binding (#52) completes the held-send half of this: `state.flybot.approvals` records carry `requestId`, a sha256 `version` over the request content, server-derived `ownerId` and `origin` (`via`/`hop`/audience) — claims in the message (`meta`, text) never confer authority. `flybot/decide` is allowed through authz for any signed-in actor and the per-record check happens in hooks (`decide` requires the sender Bot's owner's session, `bot/forbidden` otherwise, `bot/unauthorized` without a session); `release()` revalidates before dispatch and marks the record `stale` when the request changed, the sender or target Bot is gone (also invalidated eagerly on `deleteBot`), or the Bot changed owners; a demoted Chief's sender-side holds are invalidated eagerly on `setMain` (not inside `staleReason`), and `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. `flybot/approvals` lists only the actor's own holds while enforcement is on; decided stale records keep their attribution in state but appear in no member's list. Which statuses still wait for that decision is one rule in `src/shared/approval-waiting.js` (#754): `decide()`, `heldOf()` in the chat view, the in-flight filter that invalidates holds, `PENDING_MAX` and the `APPROVALS_KEPT` cleanup all ask `isApprovalWaiting()`, and `channel.js` counts a row's waiting replies with it, so a `pending_approval` hold (Bot Social's own spelling) is decidable, drawn as waiting and never dropped mid-wait. +- The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; an existing backup counts only when it is a readable file holding a parseable version-1 state, and anything else (or any malformed ownership) fails closed (read-only). An older binary refuses a newer version read-only. This is schema foundation only — team mode stays disabled, see [team](team.md). On top of it, a durable **Project** record (#1691): `state.projects` holds `{id, name, repository, defaultBranch, ownerUserId, createdAt, updatedAt, status}`, `bot.projectId` / `room.projectId` and `state.projectRuns[runId]` bind a member (a group, a Bot or a workflow run) to at most one Project, and the `api/bot` endpoints `projects`, `project-create`, `project-read`, `project-bind`, `project-of` and `project-board` sit in the task allow list. An unbound member resolves `null`, so an install with zero Projects behaves exactly as before. The read-only board over those bindings is `project-board` (#1696): Project id in, `{ tasks: [...] }` out, one row per core task id with `source` (`bot`/`group`/`workflow`), `column`, `assignee`, `updatedAt` and `runId`/`nodeId` when a run holds it. `core/src/host/project-board.js` reads `state.tasks` and the bindings and asks the registered providers through `rt.projectRunRefs` (`flybot.tasks.onProjectProvider`); it never reads `workflows.json` and writes nothing. 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; plugin writes follow the shared rule. `view()`/`live()` narrowing (`rt.ownedView`/`rt.ownedLive` in authz.js) keeps per-session entries (`questions`, `exchangeTurns`) the actor owns, and deployment-wide surfaces (agents, secrets, connectors, memory reviews) only while a single member exists — the same rule as the shared writes; rooms the actor belongs to and the Bots in them (#50) stay visible. `create-room` refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module `src/host/room-membership.js`, installed after groups: a room may carry typed `participants` (`human` / local `bot` / granted remote `bot` with `handle` + owner fields, roles `admin` / `member`, `joinedSeq` / `leftSeq`); log lines carry `authorId` + `authorKind`; history is the membership window (current members and the owner of an invited Bot see `seq >=` their interval; leave withdraws access; rejoin starts a new interval); local cross-owner rooms accept each side's *granted* Bots (#90: default deny, Chief included); a friend's Bot from `social/remote-bots` (`cleanRemote`) may be added and ungranted Bots stay private, and a room with a friend's Bot holds only granted local Bots (a Bot that joined by room code, `viaCode`, only for that room); `view()` labels remotes with an owner badge and shows an invited owner **Shared room from \**. Face-to-face rooms (#83) reuse the same `participants` / membership-window / owner-badge presentation with `origin: 'face-to-face'`: room-only remotes may sit in the room without a person-grant or a friendship, `presentRoom` treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and `fanoutRoomLine` goes to `rt.faceRoomFanout` instead of friend `flybotSend`. Still not team mode — see [team](team.md). Per-Bot grants (#90) add host module `src/host/reach.js`, installed after room-membership: `state.grants[botId]` is the list of independent grants (one Bot → one grantee, optionally room-scoped); default deny, Chief included; a Bot whose owner is not a member has none. Grants are set with `rt.setGrants`, exposed as `BotView.granted`, `flybot.bot.hasGrant` / `hasAnyGrant`, `flybot.grants` and the `bot.grants` event. `reach()` is the owner policy: a friend's Bot may reach only local Bots granted to that sender, only a granted Bot (or one code-joined in that room) may message a friend's Bot, another owner's Bot is reached only if both sides have matching grants or both are members of the room the message belongs to. It is not a middleware: hooks.js runs it before the `bot.send` chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or `bypass` can skip it and a queued message is judged by what is true when it is delivered. Revoking a grant, or replacing its Chief, removes it from rooms that have friends' Bots or several owners at once (`rt.evictUnshared`). Approval binding (#52) completes the held-send half of this: `state.flybot.approvals` records carry `requestId`, a sha256 `version` over the request content, server-derived `ownerId` and `origin` (`via`/`hop`/audience) — claims in the message (`meta`, text) never confer authority. `flybot/decide` is allowed through authz for any signed-in actor and the per-record check happens in hooks (`decide` requires the sender Bot's owner's session, `bot/forbidden` otherwise, `bot/unauthorized` without a session); `release()` revalidates before dispatch and marks the record `stale` when the request changed, the sender or target Bot is gone (also invalidated eagerly on `deleteBot`), or the Bot changed owners; a demoted Chief's sender-side holds are invalidated eagerly on `setMain` (not inside `staleReason`), and `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. `flybot/approvals` lists only the actor's own holds while enforcement is on; decided stale records keep their attribution in state but appear in no member's list. Which statuses still wait for that decision is one rule in `src/shared/approval-waiting.js` (#754): `decide()`, `heldOf()` in the chat view, the in-flight filter that invalidates holds, `PENDING_MAX` and the `APPROVALS_KEPT` cleanup all ask `isApprovalWaiting()`, and `channel.js` counts a row's waiting replies with it, so a `pending_approval` hold (Bot Social's own spelling) is decidable, drawn as waiting and never dropped mid-wait. - One writer per home (`src/host/home-lock.js`): the first dsh to load `~/.dsh` takes `state.lock` (pid, host, a random token in the file, its mtime bumped on a timer) and a second window on the same data only reads, answering `bot/read-only` and naming the other host. Takeover is **on the next write**, not on a poll (#1027): a window that was read-only claims the free lock inside `save()` too, and a change made outside a request (a plugin, a scheduled task, a delivery) belongs in `rt.mutate(fn)`, which takes over and adopts what the other host left *before* the change is made and written. The writers that changed `rt.state` inline and then called `save()` are gone (#1048): `delivery.js` (a friend's first line and its unread mark, a team exchange) and `agents.js` (add, adopt, archive and remove of the DSH Agent) take the free lock and adopt first, re-resolving their records inside the change, so a write from a window that was read-only keeps what the other host left instead of replacing it with its older copy. `save()` itself never adopts: `adopt()` replaces `rt.state` whole, so adopting there would discard a change the caller has already made (`#1032`). Either takeover prints one line — `the other dsh has closed; this one now saves the Bot team (was pid on )` — from `refresh()` and from `save()` alike, so a window that writes over the file another host left leaves a trace (#1041). A lock file still naming a live holder on this host (its pid answers) keeps the window read-only, which is the #14 rule. A write that fails does not decide the delivery (#1096): the friend-line mark and the exchange record both land before the agent has the line, so a rejected `mutate()` (the lock was taken away, or the disk refused) prints one warning naming what was not recorded and the line is still handed over — a throw there would drop a message that exists nowhere else. The write chain is also readable: `rt.savesQueued()` is a monotonic watermark — every save this host has accepted since it started, never decremented — and `rt.savesSettled()` resolves once the chain is empty, so a caller that must read `state.json` after a save it did not await — a rollback's own write, in the tests — waits on the store instead of on a delay, where a wait long enough on an idle machine reads a half-written team on a busy one (#1368). diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index 71945494d..57952bbb9 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -680,7 +680,7 @@ diff --git a/src/host/store.js b/src/host/store.js const EMPTY = JSON.stringify(state) // The other dsh host that holds state.lock while this one only reads (see home-lock.js); -@@ -50,12 +50,88 @@ +@@ -50,12 +74,88 @@ export function install(rt) { } } const readOnly = () => { @@ -770,7 +770,7 @@ diff --git a/src/host/store.js b/src/host/store.js const readFileState = async () => { let contents try { -@@ -87,10 +87,32 @@ +@@ -87,10 +187,32 @@ export function install(rt) { return undefined } if (parsed?.version === 1) { @@ -786,9 +786,10 @@ diff --git a/src/host/store.js b/src/host/store.js + warn('ds-bot: unreadable state %s: %s', statePath, backupProblem) + return undefined + } -+ stuck = undefined -+ return parsed -+ } + stuck = undefined + return parsed + } +- stuck = 'saved by a newer DS Bot; update DS Bot' + if (parsed?.version === STATE_VERSION) { + const problem = ownershipProblem(parsed) + if (problem !== undefined) { @@ -796,15 +797,14 @@ diff --git a/src/host/store.js b/src/host/store.js + warn('ds-bot: state %s was refused: %s', statePath, problem) + return undefined + } - stuck = undefined - return parsed - } -- stuck = 'saved by a newer DS Bot; update DS Bot' ++ stuck = undefined ++ return parsed ++ } + stuck = 'saved by a newer FlyBot; update FlyBot' return undefined } -@@ -104,6 +104,20 @@ +@@ -104,6 +226,20 @@ export function install(rt) { // Teams saved before the DSH Agent existed carry no `agents`; corrupt values reset. if (state.agents === null || typeof state.agents !== 'object' || Array.isArray(state.agents)) state.agents = {} let migrated = !hadCapabilities @@ -825,7 +825,7 @@ diff --git a/src/host/store.js b/src/host/store.js if (state.capabilities === null || typeof state.capabilities !== 'object' || Array.isArray(state.capabilities)) { state.capabilities = { items: {} } migrated = true -@@ -112,6 +112,11 @@ +@@ -112,6 +248,11 @@ export function install(rt) { migrated = true } for (const bot of Object.values(state.bots)) { @@ -837,7 +837,7 @@ diff --git a/src/host/store.js b/src/host/store.js if (!Array.isArray(bot.capabilities)) { bot.capabilities = [] migrated = true -@@ -120,6 +120,11 @@ +@@ -120,6 +261,11 @@ export function install(rt) { bot.color = currentColor(bot.color) migrated = true } @@ -849,7 +849,7 @@ diff --git a/src/host/store.js b/src/host/store.js // LEGACY: Bots saved with a lab ran on that lab's default model; the model they // last ran on becomes their own, so dropping the lab changes nothing for them. if ('lab' in bot) { -@@ -161,12 +161,336 @@ +@@ -161,12 +307,348 @@ export function install(rt) { migrated = true } // Unnamed groups from before names followed their members may still list old names. @@ -979,8 +979,16 @@ diff --git a/src/host/store.js b/src/host/store.js + return isRecord(kept) ? kept : null + } + const primaryOwner = project => projectText(project?.ownerUserId, 120) -+ const ownerRow = (table, ownerId) => { -+ if (!isRecord(table.owners)) table.owners = {} ++ const ownerRow = (table, ownerId, project) => { ++ if (!isRecord(table.owners)) { ++ // A record written before the owner dimension existed keeps its three fields on the row ++ // itself (#1712). The first write after the upgrade moves them onto the primary owner ++ // instead of replacing them with a fresh empty row. ++ table.owners = {} ++ const primary = primaryOwner(project) ++ const legacy = ownerView(table) ++ if (primary !== '' && (legacy.decisions.length > 0 || legacy.questions.length > 0 || legacy.digest !== null)) setEntry(table.owners, primary, legacy) ++ } + const kept = ownEntry(table.owners, String(ownerId ?? '')) + if (isRecord(kept)) return kept + const fresh = { decisions: [], questions: [], digest: null } @@ -1011,8 +1019,12 @@ diff --git a/src/host/store.js b/src/host/store.js + const project = projectOfId(id) + if (project === null) throw projectRefusal(`Unknown Project ${String(id ?? '')}`, 'bot/not-found') + const kept = projectMemoryOf(project.id) -+ const row = isRecord(kept?.owners) ? ownEntry(kept.owners, String(ownerId ?? '')) : null -+ return ownerView(row) ++ if (kept === null) return ownerView(null) ++ const row = isRecord(kept.owners) ? ownEntry(kept.owners, String(ownerId ?? '')) : null ++ if (isRecord(row)) return ownerView(row) ++ // A pre-#1733 record has no `owners`, and its three fields belong to the primary owner. ++ if (!isRecord(kept.owners) && String(ownerId ?? '') === primaryOwner(project)) return ownerView(kept) ++ return ownerView(null) + } + const memoryTable = (project) => { + const kept = projectMemoryOf(project.id) @@ -1026,7 +1038,7 @@ diff --git a/src/host/store.js b/src/host/store.js + if (kind !== 'decision' && kind !== 'question') throw projectRefusal(`Unknown Project memory kind ${String(kind ?? '')}`) + const table = memoryTable(project) + const ownerId = projectText(entry.ownerId, 120) || primaryOwner(project) -+ const record = ownerRow(table, ownerId) ++ const record = ownerRow(table, ownerId, project) + const list = kind === 'decision' ? record.decisions : record.questions + list.push({ text: projectText(entry.text, 600), at: Number(entry.at) || Date.now(), ...(projectText(entry.by, 120) === '' ? {} : { by: projectText(entry.by, 120) }) }) + mirrorOwner(table, project, ownerId, record) @@ -1039,7 +1051,7 @@ diff --git a/src/host/store.js b/src/host/store.js + const project = requireProject(id) + const table = memoryTable(project) + const owner = projectText(ownerId, 120) || primaryOwner(project) -+ const record = ownerRow(table, owner) ++ const record = ownerRow(table, owner, project) + record.digest = { day: projectText(note.day, 20), at: Number(note.at) || Date.now(), text: projectText(note.text, 4000), ...(projectText(note.error, 300) === '' ? {} : { error: projectText(note.error, 300) }) } + mirrorOwner(table, project, owner, record) + project.updatedAt = Math.max(project.updatedAt, Date.now()) @@ -1187,7 +1199,7 @@ diff --git a/src/host/store.js b/src/host/store.js const load = () => loaded ??= (async () => { claimLock() if (holder !== undefined) warn('ds-bot: %s', readOnly()) -@@ -185,14 +185,24 @@ +@@ -185,14 +667,24 @@ export function install(rt) { for (const room of stale) void rt.retitleRoom(room) })() @@ -1213,7 +1225,7 @@ diff --git a/src/host/store.js b/src/host/store.js warnedReadOnly = false adopt(await readFileState()) return -@@ -235,11 +235,37 @@ +@@ -235,11 +727,37 @@ export function install(rt) { return warnReadOnly() } @@ -1251,7 +1263,7 @@ diff --git a/src/host/store.js b/src/host/store.js state.revision += 1 state.exchanges = state.exchanges.slice(-MAX_EXCHANGES) const snapshot = JSON.stringify(state, null, 2) -@@ -260,9 +260,10 @@ +@@ -260,9 +778,10 @@ export function install(rt) { ensureLock() await rename(temp, statePath) seen = stamp() @@ -1263,7 +1275,7 @@ diff --git a/src/host/store.js b/src/host/store.js throw error } }) -@@ -273,13 +273,37 @@ +@@ -273,13 +792,37 @@ export function install(rt) { }) } @@ -1303,7 +1315,7 @@ diff --git a/src/host/store.js b/src/host/store.js const isMain = id => id !== undefined && state.mainBotIds.includes(id) // Group Sessions. A member speaks in each group from a hidden Session of its own, -@@ -313,6 +313,13 @@ +@@ -313,6 +856,13 @@ export function install(rt) { } for (const [botId, sessionId] of Object.entries(room.sessions ?? {})) groupOwners.set(sessionId, { botId, roomId: room.id }) } @@ -1317,7 +1329,7 @@ diff --git a/src/host/store.js b/src/host/store.js // The agents module owns the DSH Agent session index; it installs after this. rt.indexAgents?.() } -@@ -328,6 +328,8 @@ +@@ -328,6 +878,8 @@ export function install(rt) { delete room.sessions[botId] if (room.segments !== undefined) delete room.segments[botId] groupOwners.delete(sessionId) @@ -1326,7 +1338,7 @@ diff --git a/src/host/store.js b/src/host/store.js rt.cancelApprovals?.(sessionId) // A plain archive refuses a running Session, which would go on working for a group // that no longer has it. -@@ -335,23 +335,29 @@ +@@ -335,23 +887,29 @@ export function install(rt) { } const findBot = (ref) => { if (ref === undefined) return undefined @@ -1360,7 +1372,6 @@ diff --git a/src/host/store.js b/src/host/store.js + projectMemory, projectMemoryOf, projectMemoryFor, appendProjectMemory, setProjectDigest, deleteProject, }) } - diff --git a/src/host/tools.js b/src/host/tools.js --- a/src/host/tools.js +++ b/src/host/tools.js diff --git a/patches/ds-bot/files/src/host/project-assistant.js b/patches/ds-bot/files/src/host/project-assistant.js index f2e72effc..158e22d39 100644 --- a/patches/ds-bot/files/src/host/project-assistant.js +++ b/patches/ds-bot/files/src/host/project-assistant.js @@ -87,13 +87,15 @@ export function buildDigest({ project, rows = [], since = null, files = [], at = return { changed: true, day: dayIn(at), text: [head, stateLine, ...sections].join('\n') } } -// The cards that need *this* owner's decision: still open, and assigned to that owner. A card -// somebody else owns is their decision, and never appears in this owner's digest. -export function actionableFor(rows, ownerId) { +// The cards that need *this* owner's decision: still open, and owned by that owner. A card +// somebody else owns is their decision, and never appears in this owner's digest. The board's +// `assignee` is a Bot name or null, never a member id (#1733), so the owner comes from `ownerOf`: +// the host resolves it from the card's own Bot, and a hand-built row may carry `ownerId` itself. +export function actionableFor(rows, ownerId, ownerOf = row => text(row?.ownerId)) { const owner = text(ownerId) if (owner === '') return [] return (rows ?? []) - .filter(row => row?.column !== 'done' && text(row.assignee) === owner) + .filter(row => row?.column !== 'done' && ownerOf(row) === owner) .sort((a, b) => (a.updatedAt ?? 0) - (b.updatedAt ?? 0) || String(a.id).localeCompare(String(b.id))) .map(row => ({ id: String(row.id), ask: askOf(row) })) } @@ -309,12 +311,38 @@ export function install(rt) { return { owner, off: record.off === true, quiet: quietOf(assistant, owner), schedule, changed: wasOff !== (record.off === true) } } + // A board row carries the card's identity and its column, not its title; the ask line needs a + // subject, so the card's own record supplies it. + const withTitles = rows => rows.map((row) => { + const title = text(rt.state?.tasks?.[String(row?.id ?? '')]?.title) + return title === '' ? row : { ...row, title } + }) + + // A card's owner is the owner of the Bot that owns the card: the board names the Bot, and the + // Bot names its owner. `assignee` is only a Bot name, and a hand-built row may carry `ownerId`. + const cardOwner = (row) => { + const botId = text(rt.state?.tasks?.[String(row?.id ?? '')]?.ownerBotId) + const bot = botId === '' ? undefined : rt.botOf?.(botId) + if (bot !== undefined && bot !== null && text(bot.ownerId) !== '') return text(bot.ownerId) + const named = text(row?.assignee) + if (named !== '') { + for (const candidate of Object.values(rt.state?.bots ?? {})) { + if (text(candidate?.name) === named) return text(candidate.ownerId) + } + } + return text(row?.ownerId) + } + // In-app delivery goes to the owner's own Session and never through the GitHub identity: the 2.5 // refusal stays where a GitHub write happens, not on the way to a person's own chat (#1733). + // Only the primary owner may fall back to the host's main Bot: a second owner's digest quotes + // that owner's private memory, so another owner's Session is never a delivery address for it. const deliverTo = async (project, ownerId, body) => { - const member = rt.state?.members?.[text(ownerId)] - const toId = text(member?.designatedChiefId) || text(rt.state?.mainBotIds?.[0]) - if (toId === '') return { delivered: false, reason: `owner ${text(ownerId) || '(none)'} has no Session to deliver to` } + const owner = text(ownerId) + const member = owner === '' ? undefined : rt.state?.members?.[owner] + const primary = owner === '' || owner === text(project?.ownerUserId) + const toId = text(member?.designatedChiefId) || (primary ? text(rt.state?.mainBotIds?.[0]) : '') + if (toId === '') return { delivered: false, reason: `owner ${owner || '(none)'} has no Session of their own to deliver to` } await rt.deliver?.({ fromId: project.assistant?.botId ?? toId, toId, @@ -329,7 +357,7 @@ export function install(rt) { // One owner's digest. Generation may proceed while a quiet window holds delivery; a window that // covers 09:00 delivers nothing that day at all, and that is the expected outcome, not a failure. - const digestForOwner = async ({ project, owner, rows, files, at, force, who }) => { + const digestForOwner = async ({ project, owner, rows, cards, files, at, force, who }) => { const assistant = assistantOf(project) ?? project.assistant ?? null const record = assistant === null ? { } : ownerRecord(assistant, owner) const quiet = quietOf(assistant, owner) @@ -353,7 +381,7 @@ export function install(rt) { if (!force && memory.digest?.day === day) { return { owner, ok: true, skipped: true, delivered: false, day, reason: 'today already has a digest' } } - const built = buildDigest({ project, rows, since: memory.digest?.at ?? null, files, at, owner, actionable: actionableFor(rows, owner) }) + const built = buildDigest({ project, rows, since: memory.digest?.at ?? null, files, at, owner, actionable: actionableFor(cards ?? rows, owner, cardOwner) }) const note = { day: built.day, at, text: built.text, ...(who.ok === true ? {} : { error: `GitHub write refused: ${who.reason}` }) } rt.setProjectDigest?.(project.id, note, owner) if (coversDue) { @@ -366,7 +394,8 @@ export function install(rt) { } const body = bodyFor(project, built, who) const sent = await deliverTo(project, owner, body) - return { owner, ok: true, skipped: false, delivered: sent.delivered === true, day: built.day, changed: built.changed, text: body } + if (sent.delivered !== true) rt.setProjectDigest?.(project.id, { ...note, error: sent.reason }, owner) + return { owner, ok: true, skipped: false, delivered: sent.delivered === true, day: built.day, changed: built.changed, text: body, ...(sent.delivered === true ? {} : { reason: sent.reason }) } } // One read of the board and one of Project State, then one render per owner (#1733): the facts @@ -384,7 +413,7 @@ export function install(rt) { const results = [] for (const owner of owners) { try { - results.push(await digestForOwner({ project, owner, rows, files, at, force, who })) + results.push(await digestForOwner({ project, owner, rows, cards: withTitles(rows), files, at, force, who })) } catch (error) { const failure = error?.message ?? String(error) rt.setProjectDigest?.(project.id, { day: dayIn(at), at, text: `digest failed: ${failure}`, error: failure }, owner) diff --git a/patches/ds-bot/files/test/project-assistant.test.js b/patches/ds-bot/files/test/project-assistant.test.js index ec0a40388..a55bd844e 100644 --- a/patches/ds-bot/files/test/project-assistant.test.js +++ b/patches/ds-bot/files/test/project-assistant.test.js @@ -259,8 +259,8 @@ test('two owners of one Project get two digests from one read, with no memory bl rt.projectBoard = () => { reads += 1 return [ - { id: 't-1', source: 'bot', column: 'in-progress', assignee: 'owner-1', updatedAt: 1 }, - { id: 't-2', source: 'bot', column: 'in-progress', assignee: 'owner-2', updatedAt: 2 }, + { id: 't-1', source: 'bot', column: 'in-progress', assignee: 'Owner One Bot', ownerId: 'owner-1', updatedAt: 1 }, + { id: 't-2', source: 'bot', column: 'in-progress', assignee: 'Owner Two Bot', ownerId: 'owner-2', updatedAt: 2 }, { id: 't-3', source: 'bot', column: 'done', assignee: null, updatedAt: 3 }, ] } @@ -308,7 +308,7 @@ test('a quiet window holds the digest and delivers it once, labelled, when it en const { rt, delivered, stop } = await start() try { const project = await created(rt, { name: 'Held', ownerUserId: 'owner-1' }) - rt.projectBoard = () => [{ id: 't-hold', source: 'bot', column: 'in-progress', assignee: 'owner-1', updatedAt: 1 }] + rt.projectBoard = () => [{ id: 't-hold', source: 'bot', column: 'in-progress', assignee: 'Owner One Bot', ownerId: 'owner-1', updatedAt: 1 }] await rt.projectAssistant.setOwner(project.id, 'owner-1', { quiet: { on: true, from: '07:00', to: '08:30' }, zone: 'Asia/Shanghai' }) const inside = Date.parse('2026-10-29T00:00:00Z') // 08:00 Asia/Shanghai const after = Date.parse('2026-10-29T00:45:00Z') // 08:45 Asia/Shanghai @@ -403,8 +403,8 @@ test("one owner's render failing is recorded while the other owner's digest stil }) test('actionable items are capped and only the owner\'s own cards appear', () => { - const rows = Array.from({ length: MAX_ACTIONABLE + 3 }, (_, index) => ({ id: `t-${index}`, source: 'bot', column: 'in-progress', assignee: 'owner-1', updatedAt: index, title: `card ${index}` })) - rows.push({ id: 'theirs', source: 'bot', column: 'in-progress', assignee: 'owner-2', updatedAt: 99, title: 'not yours' }) + const rows = Array.from({ length: MAX_ACTIONABLE + 3 }, (_, index) => ({ id: `t-${index}`, source: 'bot', column: 'in-progress', assignee: 'Worker', ownerId: 'owner-1', updatedAt: index, title: `card ${index}` })) + rows.push({ id: 'theirs', source: 'bot', column: 'in-progress', assignee: 'Theirs', ownerId: 'owner-2', updatedAt: 99, title: 'not yours' }) const items = actionableFor(rows, 'owner-1') assert.equal(items.length, MAX_ACTIONABLE + 3, 'the helper keeps them all…') assert.ok(items.every(item => item.id !== 'theirs'), '…but never another owner\'s card') @@ -415,3 +415,66 @@ test('actionable items are capped and only the owner\'s own cards appear', () => assert.match(built.text, /- t-0 — decide what happens next — card 0 \(still in-progress\)/) assert.doesNotMatch(built.text, /not yours/) }) + +test('a card lists for the owner of the Bot that owns it, through the real task and board path', async () => { + const { rt, stop } = await start() + try { + const project = await created(rt, { name: 'Boarded', ownerUserId: 'owner-1', ownerUserIds: ['owner-2'] }) + secondOwner(rt) + rt.state.bots.worker = { id: 'worker', name: 'Worker', role: 'Dev', sessionId: 'w', createdAt: 3, ownerId: 'owner-2' } + await rt.projectEndpoint('project-bind', { kind: 'bot', id: 'a', projectId: project.id }) + await rt.projectEndpoint('project-bind', { kind: 'bot', id: 'worker', projectId: project.id }) + await rt.taskEndpoint('task-create', { botId: 'worker', title: 'Pick the release date' }, null) + await rt.taskEndpoint('task-create', { botId: 'a', title: 'Rotate the deploy key' }, null) + const rows = rt.projectBoard(project.id) + assert.equal(rows.length, 2) + assert.ok(rows.every(row => row.assignee === null), 'the board carries a Bot name or null, never a member id') + assert.equal(actionableFor(rows, 'owner-1').length, 0, 'the raw helper sees no owner without a resolver') + const result = await rt.projectAssistant.digest(project.id, { at: AT }) + const [one, two] = result.owners + assert.match(one.text, /Rotate the deploy key/, 'the card of the primary owner\'s Bot is theirs') + assert.doesNotMatch(one.text, /Pick the release date/, 'another owner\'s card never appears in this digest') + assert.match(two.text, /Pick the release date/, 'the card of the second owner\'s Bot is theirs') + assert.doesNotMatch(two.text, /Rotate the deploy key/) + } finally { + await stop() + } +}) + +test('a memory record written before the owner dimension keeps its entries across the first write', async () => { + const { rt, stop } = await start() + try { + const project = await created(rt, { name: 'Legacy', ownerUserId: 'owner-1' }) + rt.state.projectMemory[project.id] = { + decisions: [{ text: 'the old call', at: 1 }], + questions: [{ text: 'the old question', at: 1 }], + digest: { day: '2026-10-27', at: 1000, text: 'the old note' }, + } + assert.deepEqual(rt.projectMemoryFor(project.id, 'owner-1').decisions.map(entry => entry.text), ['the old call'], 'the pre-slice record reads back for its primary owner') + assert.equal(rt.projectMemoryFor(project.id, 'owner-1').digest.text, 'the old note') + await rt.projectEndpoint('project-memory-write', { id: project.id, kind: 'decision', text: 'the new call', ownerId: 'owner-1' }) + const view = rt.projectMemoryFor(project.id, 'owner-1') + assert.deepEqual(view.decisions.map(entry => entry.text), ['the old call', 'the new call'], 'the first write after the upgrade appends instead of replacing') + assert.deepEqual(view.questions.map(entry => entry.text), ['the old question'], 'the old questions survive') + assert.equal(view.digest.text, 'the old note', 'so does the last digest, so the next build is not re-based') + } finally { + await stop() + } +}) + +test('an owner without a Session never receives another owner\'s digest', async () => { + const { rt, delivered, stop } = await start() + try { + const project = await created(rt, { name: 'Unseated', ownerUserId: 'owner-1', ownerUserIds: ['owner-2'] }) + rt.state.members['owner-2'] = { id: 'owner-2', createdAt: 2 } + const result = await rt.projectAssistant.digest(project.id, { at: AT }) + assert.equal(result.delivered, 1) + assert.deepEqual(delivered.map(message => message.toId), ['a'], 'only the primary owner has a Session of their own') + assert.equal(result.owners[1].owner, 'owner-2') + assert.equal(result.owners[1].delivered, false) + assert.match(result.owners[1].reason, /no Session of their own/) + assert.match(rt.projectMemoryFor(project.id, 'owner-2').digest.error, /no Session of their own/, 'the reason is recorded on that owner\'s own note') + } finally { + await stop() + } +})