From 5f1458caef47cabdc86f3d99964249b27da16ca8 Mon Sep 17 00:00:00 2001 From: Dev Date: Wed, 11 Nov 2026 11:49:55 +0800 Subject: [PATCH] refactor(plugins): move the Project domain into the standalone @flybot/project plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Project domain — Project State, the Project board, the Project’s own Chief, the team dossier, the metrics and the assistant — moves out of `core/src/host/project-*.js` into a peer plugin, `plugins/project` (`@flybot/project`), mounted next to `plugins/social`, `plugins/tree`, `plugins/workflow` and `plugins/timeline`. Behavior is unchanged; the domain’s tests moved with the code and the core keeps two thin seams for what it still calls by name. Refs #2684 --- Dockerfile | 2 +- README.md | 5 +- README.zh.md | 5 +- changelog.d/project-plugin.added.md | 1 + core/index.js | 7 +- core/scripts/install-doctor.mjs | 3 +- core/scripts/local-ci.sh | 4 + core/src/host/authz.js | 2 +- core/src/host/constants.js | 4 + core/src/host/day-key.js | 12 + core/src/host/hooks.js | 6 + core/src/host/meetings.js | 4 +- core/src/host/plugins.js | 11 +- core/src/host/project-host.js | 56 + core/src/host/project-metrics.js | 265 --- core/src/host/project-state.js | 205 -- core/src/host/store.js | 4 +- core/src/host/turn-budget.js | 2 +- core/src/host/work-dir.js | 15 + core/test/agents.test.js | 4 + core/test/install-doctor.test.js | 11 +- core/test/install.test.js | 5 +- core/test/meetings.test.js | 4 +- core/test/plugin-switches.test.js | 8 +- core/test/project-assistant.test.js | 734 ------- core/test/project-board-levels.test.js | 3 +- core/test/project-board-meeting-date.test.js | 92 - core/test/project-board-panels.test.js | 2 + core/test/project-digest-authz.test.js | 2 + core/test/project-domain.js | 12 + core/test/project-role-authz.test.js | 2 + core/test/project-state-unreadable.test.js | 114 -- core/test/project-state.test.js | 229 --- core/test/project-team.test.js | 234 --- core/test/projects.test.js | 2 + core/test/readonly-window-reads.test.js | 2 + core/test/run-bot-tools.test.js | 4 + core/test/task-contract.test.js | 4 +- core/types/flybot.d.ts | 14 + docs/subsystems/core-host.md | 15 +- docs/subsystems/install.md | 6 +- docs/subsystems/plugin-runtime.md | 2 + docs/subsystems/project.md | 92 + docs/testing-layers.json | 9 + docs/testing-layers.md | 1 + package.json | 2 +- patches/ds-bot/0001-core-fixes.patch | 34 +- patches/ds-bot/0002-flybot-api-host.patch | 21 +- .../ds-bot/files/scripts/install-doctor.mjs | 3 +- patches/ds-bot/files/scripts/local-ci.sh | 4 + patches/ds-bot/files/src/host/authz.js | 2 +- patches/ds-bot/files/src/host/day-key.js | 12 + patches/ds-bot/files/src/host/hooks.js | 6 + patches/ds-bot/files/src/host/meetings.js | 4 +- patches/ds-bot/files/src/host/plugins.js | 11 +- .../files/src/host/project-assistant.js | 557 ------ .../ds-bot/files/src/host/project-board.js | 652 ------- .../ds-bot/files/src/host/project-chief.js | 82 - patches/ds-bot/files/src/host/project-host.js | 56 + patches/ds-bot/files/src/host/project-team.js | 89 - patches/ds-bot/files/src/host/turn-budget.js | 2 +- patches/ds-bot/files/src/host/work-dir.js | 15 + .../ds-bot/files/test/install-doctor.test.js | 11 +- patches/ds-bot/files/test/meetings.test.js | 4 +- .../ds-bot/files/test/plugin-switches.test.js | 8 +- .../files/test/project-board-levels.test.js | 3 +- .../files/test/project-board-panels.test.js | 2 + .../ds-bot/files/test/project-board.test.js | 1720 ----------------- .../files/test/project-digest-authz.test.js | 2 + patches/ds-bot/files/test/project-domain.js | 12 + .../test/project-metrics-cache-bound.test.js | 22 - .../files/test/project-metrics-scope.test.js | 278 --- .../ds-bot/files/test/project-metrics.test.js | 286 --- .../files/test/project-role-authz.test.js | 2 + .../ds-bot/files/test/project-roles.test.js | 71 - patches/ds-bot/files/test/projects.test.js | 2 + .../files/test/readonly-window-reads.test.js | 2 + .../ds-bot/files/test/run-bot-tools.test.js | 4 + .../ds-bot/files/test/task-contract.test.js | 4 +- patches/ds-bot/files/types/flybot.d.ts | 14 + plugins/README.md | 8 +- plugins/project/README.md | 99 + plugins/project/client.js | 14 + plugins/project/cordis.patch.yml | 4 + plugins/project/index.js | 36 + plugins/project/package.json | 41 + plugins/project/src/host.js | 60 + plugins/project/src/install.js | 33 + .../project/src}/project-assistant.js | 7 +- .../project/src}/project-board.js | 6 +- .../project/src}/project-chief.js | 0 .../project/src}/project-metrics.js | 16 +- .../project/src}/project-state.js | 18 +- .../project/src}/project-team.js | 2 +- plugins/project/test/host-bind.js | 40 + plugins/project/test/host.test.js | 109 ++ .../project}/test/project-assistant.test.js | 11 +- .../test/project-board-meeting-date.test.js | 7 +- .../project}/test/project-board.test.js | 12 +- .../test/project-metrics-cache-bound.test.js | 3 +- .../test/project-metrics-scope.test.js | 9 +- .../project}/test/project-metrics.test.js | 3 +- .../project}/test/project-roles.test.js | 2 +- .../test/project-state-unreadable.test.js | 9 +- .../project}/test/project-state.test.js | 9 +- .../project}/test/project-team.test.js | 9 +- plugins/project/test/resolve.mjs | 34 + 107 files changed, 1041 insertions(+), 5779 deletions(-) create mode 100644 changelog.d/project-plugin.added.md create mode 100644 core/src/host/day-key.js create mode 100644 core/src/host/project-host.js delete mode 100644 core/src/host/project-metrics.js delete mode 100644 core/src/host/project-state.js create mode 100644 core/src/host/work-dir.js delete mode 100644 core/test/project-assistant.test.js delete mode 100644 core/test/project-board-meeting-date.test.js create mode 100644 core/test/project-domain.js delete mode 100644 core/test/project-state-unreadable.test.js delete mode 100644 core/test/project-state.test.js delete mode 100644 core/test/project-team.test.js create mode 100644 docs/subsystems/project.md create mode 100644 patches/ds-bot/files/src/host/day-key.js delete mode 100644 patches/ds-bot/files/src/host/project-assistant.js delete mode 100644 patches/ds-bot/files/src/host/project-board.js delete mode 100644 patches/ds-bot/files/src/host/project-chief.js create mode 100644 patches/ds-bot/files/src/host/project-host.js delete mode 100644 patches/ds-bot/files/src/host/project-team.js create mode 100644 patches/ds-bot/files/src/host/work-dir.js delete mode 100644 patches/ds-bot/files/test/project-board.test.js create mode 100644 patches/ds-bot/files/test/project-domain.js delete mode 100644 patches/ds-bot/files/test/project-metrics-cache-bound.test.js delete mode 100644 patches/ds-bot/files/test/project-metrics-scope.test.js delete mode 100644 patches/ds-bot/files/test/project-metrics.test.js delete mode 100644 patches/ds-bot/files/test/project-roles.test.js create mode 100644 plugins/project/README.md create mode 100644 plugins/project/client.js create mode 100644 plugins/project/cordis.patch.yml create mode 100644 plugins/project/index.js create mode 100644 plugins/project/package.json create mode 100644 plugins/project/src/host.js create mode 100644 plugins/project/src/install.js rename {core/src/host => plugins/project/src}/project-assistant.js (99%) rename {core/src/host => plugins/project/src}/project-board.js (99%) rename {core/src/host => plugins/project/src}/project-chief.js (100%) rename {patches/ds-bot/files/src/host => plugins/project/src}/project-metrics.js (94%) rename {patches/ds-bot/files/src/host => plugins/project/src}/project-state.js (94%) rename {core/src/host => plugins/project/src}/project-team.js (99%) create mode 100644 plugins/project/test/host-bind.js create mode 100644 plugins/project/test/host.test.js rename {patches/ds-bot/files => plugins/project}/test/project-assistant.test.js (99%) rename {patches/ds-bot/files => plugins/project}/test/project-board-meeting-date.test.js (95%) rename {core => plugins/project}/test/project-board.test.js (99%) rename {core => plugins/project}/test/project-metrics-cache-bound.test.js (93%) rename {core => plugins/project}/test/project-metrics-scope.test.js (98%) rename {core => plugins/project}/test/project-metrics.test.js (99%) rename {core => plugins/project}/test/project-roles.test.js (99%) rename {patches/ds-bot/files => plugins/project}/test/project-state-unreadable.test.js (95%) rename {patches/ds-bot/files => plugins/project}/test/project-state.test.js (97%) rename {patches/ds-bot/files => plugins/project}/test/project-team.test.js (97%) create mode 100644 plugins/project/test/resolve.mjs diff --git a/Dockerfile b/Dockerfile index 70e1685be..3cb124947 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # FlyBot in a container (#352): one person, one instance. `dsh web` with the FlyBot core, -# social, tree, workflow and timeline packages of this checkout. Build it yourself; nothing is published. +# social, tree, workflow, timeline and project packages of this checkout. Build it yourself; nothing is published. # docker compose up -d --build (see docs/docker.md) # No keys or tokens are baked in: DEEPSEEK_API_KEY comes from the environment or from the # credentials file on the data volume. diff --git a/README.md b/README.md index b071bcd2f..356fafa4a 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ npx -y @deepseek-ai/dsh@0.2.1-rc.2 plugin --profile web add "github:ThinkFlowLab ``` The first line is the FlyBot core (package `ds-bot`); the two plugins are what turn it into a team. Add -`/plugins/workflow` or `/plugins/timeline` the same way if you want them. +`/plugins/workflow`, `/plugins/timeline` or `/plugins/project` the same way if you want them. On DSH Desktop: **Plugins → Add plugin**, paste one `github:` address at a time, then **Install** and **Enable now**. @@ -153,7 +153,8 @@ old credentials are kept as a backup. Details: [social subsystem](docs/subsystem - Core options, models and updates: [`core/README.md`](core/README.md) - Plugins: [`tree`](plugins/tree/README.md), [`social`](plugins/social/README.md), - [`workflow`](plugins/workflow/README.md), [`timeline`](plugins/timeline/README.md) + [`workflow`](plugins/workflow/README.md), [`timeline`](plugins/timeline/README.md), + [`project`](plugins/project/README.md) - Architecture and subsystem pages: [docs/architecture.md](docs/architecture.md), [docs/subsystems/](docs/subsystems/README.md) - Known issues: [docs/known-issues.md](docs/known-issues.md) diff --git a/README.zh.md b/README.zh.md index a0565cd02..ac1b5c534 100644 --- a/README.zh.md +++ b/README.zh.md @@ -98,7 +98,7 @@ npx -y @deepseek-ai/dsh@0.2.1-rc.2 plugin --profile web add "github:ThinkFlowLab ``` 第一行是 FlyBot 核心(包名 `ds-bot`),后两行才把它变成一支团队。想要工作流或时间线,照样再加 -`/plugins/workflow`、`/plugins/timeline`。 +`/plugins/workflow`、`/plugins/timeline`、`/plugins/project`。 桌面版:**插件 → 添加插件**,一次粘一个 `github:` 地址,然后**安装**并**立即启用**。 @@ -139,7 +139,8 @@ npx -y @deepseek-ai/dsh@0.2.1-rc.2 web --port 3080 - 核心选项、模型与更新:[`core/README.md`](core/README.md) - 插件:[`tree`](plugins/tree/README.md)、[`social`](plugins/social/README.md)、 - [`workflow`](plugins/workflow/README.md)、[`timeline`](plugins/timeline/README.md) + [`workflow`](plugins/workflow/README.md)、[`timeline`](plugins/timeline/README.md)、 + [`project`](plugins/project/README.md) - 架构与子系统页:[docs/architecture.md](docs/architecture.md)、[docs/subsystems/](docs/subsystems/README.md) - 已知问题:[docs/known-issues.md](docs/known-issues.md) - 参与开发:[CONTRIBUTING.md](CONTRIBUTING.md)、[AGENTS.md](AGENTS.md)、[LESSONS.md](LESSONS.md) diff --git a/changelog.d/project-plugin.added.md b/changelog.d/project-plugin.added.md new file mode 100644 index 000000000..f60c2e523 --- /dev/null +++ b/changelog.d/project-plugin.added.md @@ -0,0 +1 @@ +The Project domain now ships as its own peer plugin, `@flybot/project` (`plugins/project`), instead of living inside the core: Project State, the Project board, the Project's own Chief, the team dossier, the Project metrics and the Project Assistant moved out of `core/src/host/project-*.js`, and the `project_state` / `project_digest` tools are registered by that plugin. Settings → Plugins gains a **Project** switch that turns the whole domain off at the next start; with it off (or with the plugin not installed) the core keeps its documented fallbacks — a Project is created without a Chief or an assistant and the board and the dossier answer empty. Nothing a Project does changes: same reads, same refusals, same strings, and every existing test moved with the code. See [#2684](https://github.com/ThinkFlowLab/FlyBot/issues/2684). diff --git a/core/index.js b/core/index.js index df6e8d4f5..4c6b1da72 100644 --- a/core/index.js +++ b/core/index.js @@ -10,7 +10,6 @@ import * as authz from './src/host/authz.js' import * as roomMembership from './src/host/room-membership.js' import * as reach from './src/host/reach.js' import * as tasks from './src/host/tasks.js' -import * as projectBoard from './src/host/project-board.js' import * as taskDrive from './src/host/task-drive.js' import * as models from './src/host/models.js' import * as permissions from './src/host/permissions.js' @@ -25,7 +24,6 @@ import * as prompt from './src/host/prompt.js' import * as timeContext from './src/host/time-context.js' import * as tools from './src/host/tools.js' import * as decisions from './src/host/decisions.js' -import * as projectState from './src/host/project-state.js' import * as meetings from './src/host/meetings.js' import * as chatIndex from './src/host/chat-index.js' import * as groups from './src/host/groups.js' @@ -41,9 +39,6 @@ import * as connectors from './src/host/connectors.js' import * as connectorAccess from './src/host/connector-access.js' import * as capabilities from './src/host/capabilities.js' import * as schedules from './src/host/schedules.js' -import * as projectAssistant from './src/host/project-assistant.js' -import * as projectChief from './src/host/project-chief.js' -import * as projectTeam from './src/host/project-team.js' import * as automationDefault from './src/host/automation-default.js' import * as usage from './src/host/usage.js' import * as update from './src/host/update.js' @@ -63,7 +58,7 @@ export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController', 'ses // the functions other modules call; they call them through `rt` at run time, never during // install. Listeners run and tools are listed in registration order, so this order holds: // usage meters model requests after every listener that rewrites them. -const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, projectState, meetings, chatIndex, groups, roomMembership, reach, tasks, projectBoard, taskDrive, approvals, resume, memory, parts, turnBudget, images, secrets, connectors, connectorAccess, capabilities, schedules, projectAssistant, projectChief, projectTeam, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, health, channel] +const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, meetings, chatIndex, groups, roomMembership, reach, tasks, taskDrive, approvals, resume, memory, parts, turnBudget, images, secrets, connectors, connectorAccess, capabilities, schedules, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, health, channel] export function apply(ctx, config = {}) { const rt = createRuntime(ctx, config) diff --git a/core/scripts/install-doctor.mjs b/core/scripts/install-doctor.mjs index e8b2e4227..8109fb059 100644 --- a/core/scripts/install-doctor.mjs +++ b/core/scripts/install-doctor.mjs @@ -18,6 +18,7 @@ const PACKAGES = [ ['plugins/tree', '@flybot/tree'], ['plugins/workflow', '@flybot/workflow'], ['plugins/timeline', '@flybot/timeline'], + ['plugins/project', '@flybot/project'], ] export function parseNodeVersion(versionString) { @@ -76,7 +77,7 @@ export function buildInstallCommands({ dshPackage = '@deepseek-ai/dsh@0.2.1-rc.2', timeline = false, // @flybot/timeline is opt-in (#281): its install command is printed only on request } = {}) { - return ['/core', '/plugins/social', '/plugins/tree', '/plugins/workflow', ...(timeline ? ['/plugins/timeline'] : [])] + return ['/core', '/plugins/social', '/plugins/tree', '/plugins/workflow', '/plugins/project', ...(timeline ? ['/plugins/timeline'] : [])] .map(path => `npx -y ${dshPackage} plugin --profile ${profile} add "github:${repo}#${ref}&path:${path}"`) } diff --git a/core/scripts/local-ci.sh b/core/scripts/local-ci.sh index 4ac58bdbc..a74a657fe 100644 --- a/core/scripts/local-ci.sh +++ b/core/scripts/local-ci.sh @@ -65,3 +65,7 @@ echo "=== $step ===" step="plugin tests (workflow)" echo "=== $step ===" (cd ../plugins/workflow && npm test) + +step="plugin tests (project)" +echo "=== $step ===" +(cd ../plugins/project && npm test) diff --git a/core/src/host/authz.js b/core/src/host/authz.js index d2cb8c9be..b62eba237 100644 --- a/core/src/host/authz.js +++ b/core/src/host/authz.js @@ -83,7 +83,7 @@ const TASK_ENDPOINTS = new Set([ 'project-memory', 'project-memory-write', 'project-delete', 'project-assistant-owner', // The role switch (#1836) is the third of the same family after `project-board` (#1713) and // `project-assistant-owner` (#1759): `channel.js` dispatches it and `store.js` implements it, and - // the policy in front of the write is `changeRole` (`project-board.js`), which refuses a + // the policy in front of the write is `changeRole` (plugins/project/src/project-board.js), which refuses a // non-owner itself. Leaving it out only answered `bot/not-found: Unknown endpoint project-role` // before the handler ran, so the role menu failed on every host with a member session. 'project-role', diff --git a/core/src/host/constants.js b/core/src/host/constants.js index d6ef5d367..47007441c 100644 --- a/core/src/host/constants.js +++ b/core/src/host/constants.js @@ -27,6 +27,10 @@ export const MAX_HOPS = 6 // character note was refused as "over 600 characters". Count with `[...text].length`, as // `chat-index.js` and `busy-ack.js` already do, and keep both writers on that same unit. export const MAX_ENTRY = 600 +// The role label a Project Assistant carries (#2684). The meetings module refuses a close from +// this role, and the Project assistant (now in plugins/project) creates the Bot with it; the seam +// hands the plugin this same constant, so the two cannot spell it differently. +export const ASSISTANT_ROLE = 'Project Assistant' // Bot requests a Bot's own chat may hold unread; past this the Bot is most likely // stuck, and more messages only pile up behind it. export const MAX_UNREAD_REQUESTS = 10 diff --git a/core/src/host/day-key.js b/core/src/host/day-key.js new file mode 100644 index 000000000..45ce6cdd8 --- /dev/null +++ b/core/src/host/day-key.js @@ -0,0 +1,12 @@ +// The natural day a timestamp falls in, in the caller's time zone (#2684). `en-CA` prints ISO +// order, which is what the comparison needs; an unknown zone throws here, so a caller that takes +// one from outside (a board read) resolves it with `resolveTimeZone` first. +// +// This was exported by `project-metrics.js`; the core's turn budget needs the same key, and the +// metrics moved to `plugins/project`, so the function lives here and the plugin takes it back +// through the host seam `flybot.projectHost()` (`core/src/host/project-host.js`). +export function dayKey(ms, timeZone = 'UTC') { + if (!Number.isFinite(ms)) return '' + const parts = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }) + return parts.format(new Date(ms)) +} diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index 072120ccb..6ddd70338 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -25,6 +25,7 @@ import { createPluginModel } from './plugin-model.js' import { defineTool } from './tool-schema.js' import { retiredBot } from './store.js' import { pluginEnabledAtStart, pluginSwitches, setPluginSwitch } from './plugins.js' +import { projectHost as createProjectHost } from './project-host.js' import { codePoints } from './text.js' import { cut } from '../shared/text-boundary.js' import { EPHEMERAL_DENIED_TOOLS } from './tools.js' @@ -1160,6 +1161,11 @@ export function install(rt) { // apply(), and registers nothing when this answers false. Read from the state file // the last run wrote, because plugins load before the team is loaded. pluginEnabled: id => pluginEnabledAtStart(rt.statePath, id), + // The Project domain's host (#2684): plugins/project implements Project State, the board, the + // Chief, the dossier, the metrics and the assistant, and cannot import core/src/*. It asks for + // the runtime and the shared pure helpers once, in its own apply(); a core without the plugin + // never calls this and keeps the store's existing fallbacks. + projectHost: () => createProjectHost(rt), plugin, scope: build, bot: { diff --git a/core/src/host/meetings.js b/core/src/host/meetings.js index 89c656450..f55b3e78e 100644 --- a/core/src/host/meetings.js +++ b/core/src/host/meetings.js @@ -11,8 +11,8 @@ import { join } from 'node:path' import { cut } from './text.js' import { defineTool } from './tool-schema.js' import { output } from './tools.js' -import { workDirFor } from './project-state.js' -import { ASSISTANT_ROLE } from './project-assistant.js' +import { workDirFor } from './work-dir.js' +import { ASSISTANT_ROLE } from './constants.js' // The Project Assistant takes a position like anyone else, but it does not write the decision // file and does not close the meeting (the Chief or the owner does). diff --git a/core/src/host/plugins.js b/core/src/host/plugins.js index 7d2a249d4..5421e9aa6 100644 --- a/core/src/host/plugins.js +++ b/core/src/host/plugins.js @@ -1,4 +1,6 @@ -// The four FlyBot feature plugins and the owner's switch for each (#2564). +// The FlyBot feature plugins and the owner's switch for each (#2564). The Project domain is one +// of them since #2684: its code lives in plugins/project and this switch is how an owner turns +// the whole domain (tools, board, Chief, assistant) off without uninstalling anything. // // The switch does not install or uninstall anything: the packages stay in the DSH // profile as they are, and the answer lives in FlyBot's own state.json. A plugin reads @@ -13,20 +15,21 @@ export const FEATURE_PLUGINS = Object.freeze([ { id: 'tree', name: 'Tree', package: '@flybot/tree', purpose: 'Run Bots as a tree under a coordinator.' }, { id: 'timeline', name: 'Timeline', package: '@flybot/timeline', purpose: 'Read the team activity as a timeline.' }, { id: 'workflow', name: 'Workflow', package: '@flybot/workflow', purpose: 'Run workflow graphs across several Bots.' }, + { id: 'project', name: 'Project', package: '@flybot/project', purpose: 'Run a Project: Project State, its board, its own Chief, its team and its assistant.' }, ]) export const FEATURE_PLUGIN_IDS = Object.freeze(FEATURE_PLUGINS.map(plugin => plugin.id)) const known = id => FEATURE_PLUGIN_IDS.includes(id) -// The four switches as the Settings page shows them: on unless state.json says off. +// The switches as the Settings page shows them: on unless state.json says off. export function pluginSwitches(state) { const saved = state?.flybot?.plugins ?? {} return FEATURE_PLUGINS.map(plugin => ({ ...plugin, enabled: saved[plugin.id]?.enabled !== false })) } -// Writes one switch into the state the next save persists. Only these four ids are -// accepted, so a typo cannot grow a fifth switch nobody reads. +// Writes one switch into the state the next save persists. Only these ids are accepted, +// so a typo cannot grow a switch nobody reads. export function setPluginSwitch(state, id, enabled) { const name = String(id ?? '') if (!known(name)) throw new Error(`flybot: unknown plugin ${JSON.stringify(name)}`) diff --git a/core/src/host/project-host.js b/core/src/host/project-host.js new file mode 100644 index 000000000..90cf84fae --- /dev/null +++ b/core/src/host/project-host.js @@ -0,0 +1,56 @@ +// The Project domain's host seam (#2684). The domain — Project State, the board, the Project's own +// Chief, the team dossier, the metrics and the assistant — moved to `plugins/project`, which ships +// as its own package and therefore cannot import `core/src/*`. The core hands it the two things it +// cannot get by itself: the runtime it puts its seams on, and the pure helpers the two packages +// share (one function each, never a copy: a second implementation of `cut` or `dayKey` would drift). +// +// The seam is the *core's* side of the contract, not a third-party plugin surface: only +// `plugins/project` asks for it, and only in its `apply()`. A core without the plugin installed +// never calls it — the store's Project endpoints then keep the fallbacks they already have (a +// Project is created without a Chief or an assistant, the board and the dossier answer empty). +// +// Every helper is a pure function or a constant; nothing here reads state or writes anything when +// the object is built, so a caller can build it once at startup. +import { ASSISTANT_ROLE, MAX_ENTRY } from './constants.js' +import { dayKey } from './day-key.js' +import { advanceRefusal } from './meetings.js' +import { dirKind } from './real-dir.js' +import { codePoints, cut } from './text.js' +import { defineTool } from './tool-schema.js' +import { output } from './tools.js' +import { workDirFor } from './work-dir.js' +import { clockIn, inQuietHours, quietHoursOf, readQuietHours } from '../shared/quiet-hours.js' +import { columnOf, priorityOf } from '../shared/task-board.js' + +export const HELPER_NAMES = Object.freeze([ + 'cut', 'codePoints', 'dirKind', 'defineTool', 'output', 'columnOf', 'priorityOf', 'advanceRefusal', + 'workDirFor', 'dayKey', 'MAX_ENTRY', 'ASSISTANT_ROLE', + 'inQuietHours', 'clockIn', 'quietHoursOf', 'readQuietHours', +]) + +export function projectHost(rt) { + return { + // The live runtime: the seams the domain installs (`rt.projectBoard`, `rt.projectChief`, + // `rt.projectTeam`, `rt.projectAssistant`) are the ones the core's store, meetings and channel + // already call by those names. + runtime: rt, + helpers: { + cut, + codePoints, + dirKind, + defineTool, + output, + columnOf, + priorityOf, + advanceRefusal, + workDirFor, + dayKey, + MAX_ENTRY, + ASSISTANT_ROLE, + inQuietHours, + clockIn, + quietHoursOf, + readQuietHours, + }, + } +} diff --git a/core/src/host/project-metrics.js b/core/src/host/project-metrics.js deleted file mode 100644 index 1d7b1b5e9..000000000 --- a/core/src/host/project-metrics.js +++ /dev/null @@ -1,265 +0,0 @@ -// The board's numbers (#1829): seven metrics read from GitHub REST and the local task store, with a -// five-minute cache and one honest failure answer. A missing credential or an unreadable source is -// `unavailable` and every value reads `null`, never `0`: a zero claims "none today" and the board -// must not turn "cannot read" into a number. The reads are injected, so a test counts calls without -// a network, and the day boundary is the reader's own time zone, not the host's UTC clock. -import { columnOf } from '../shared/task-board.js' - -export const METRICS_TTL_MS = 5 * 60 * 1000 -export const METRIC_KEYS = [ - 'prsOpenedToday', 'prsMergedToday', 'issuesOpenedToday', 'issuesClosedToday', - 'prMergeMedianMs', 'issueCloseMedianMs', 'taskDoneMedianMs', -] -const DAY_MS = 24 * 60 * 60 * 1000 -const RECENT_MS = 7 * DAY_MS -// #2650: the lists are followed page by page, and a `next` that never ends must not hang the board. -// Reaching the cap keeps the rows read so far and answers `truncated`, never a window claimed whole. -const MAX_PAGES = 30 -const API_ORIGIN = 'https://api.github.com' -const apiUrl = path => (path.startsWith('/') ? `${API_ORIGIN}${path}` : path) - -// The next page lives in the `Link` header, not in the body, so the reader has to see the response -// and not only `response.json()`. A foreign host is never followed with the token. -const nextPage = response => { - const link = typeof response.headers?.get === 'function' ? text(response.headers.get('link')) : '' - for (const part of link.split(',')) { - const match = part.match(/<([^>]+)>\s*;\s*rel="?next"?/i) - if (!match) continue - const href = match[1].trim() - if (href.startsWith(`${API_ORIGIN}/`)) return href - if (href.startsWith('/')) return `${API_ORIGIN}${href}` - } - return null -} -const text = value => (typeof value === 'string' ? value.trim() : '') -const timeOf = value => { - const ms = Date.parse(text(value)) - return Number.isFinite(ms) ? ms : null -} - -// One zone, one decision (#1849): `timeZone` reaches this module from a request body, and an -// unknown name used to throw out of the whole board read — rows, queues and decisions with it. -// It is validated here once, and an unknown name answers the named `UTC` rather than guessing: -// callers pass the resolved value to `dayKey`, to the answer and to the cache key. -export function resolveTimeZone(timeZone = 'UTC') { - const wanted = text(timeZone) || 'UTC' - try { - new Intl.DateTimeFormat('en-CA', { timeZone: wanted }) - return wanted - } catch { - return 'UTC' - } -} - -// The natural day a timestamp falls in, in the caller's time zone. `en-CA` prints ISO order, which -// is what the comparison below needs; an unknown zone throws here, so a caller that takes one from -// outside (the board read) resolves it with `resolveTimeZone` first. -export function dayKey(ms, timeZone = 'UTC') { - if (!Number.isFinite(ms)) return '' - const parts = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }) - return parts.format(new Date(ms)) -} - -// A median over the durations of records in the window: empty is `null`, which the client paints -// as `—`. Half a millisecond of rounding is not worth carrying into the view. -export function median(values) { - const clean = values.filter(value => Number.isFinite(value)).sort((a, b) => a - b) - if (clean.length === 0) return null - const middle = Math.floor(clean.length / 2) - return clean.length % 2 === 0 ? Math.round((clean[middle - 1] + clean[middle]) / 2) : clean[middle] -} - -const countToday = (items, at, day) => items.reduce((count, item) => { - const ms = timeOf(at(item)) - return ms !== null && dayKey(ms, day.timeZone) === day.key ? count + 1 : count -}, 0) - -const durationInWindow = (items, from, to, now) => items.reduce((values, item) => { - const start = timeOf(from(item)) - const end = timeOf(to(item)) - if (start === null || end === null || end < start) return values - if (now - end > RECENT_MS) return values - values.push(end - start) - return values -}, []) - -// The task rows this read is about (#1848): one board's cards that its reader may see, not the -// deployment's. The caller passes the same list its rows were built from; an object is still read -// as a map, so a caller handing the whole store keeps its old answer. -const taskList = tasks => (Array.isArray(tasks) ? tasks : Object.values(tasks ?? {})) - -// The task half: a card that really completed. The board's own reading decides that column -// (`columnOf`, so no second status table exists here), and `accepted` is the one column value that -// means "finished": a cancelled or failed card is in the done column but has no completion moment, -// so timing it from its cancellation would only stretch the median. A card finishes with -// `transition(task, 'accepted')`, which writes `status` and pushes a terminal entry onto `actors`; -// `completedAt` is not a field that path ever writes, so it is only a fallback for older records, -// after the last terminal actor's `at`. -const finished = task => columnOf(task) === 'done' && task.status === 'accepted' -const finishedAt = task => { - const actors = Array.isArray(task.actors) ? task.actors : [] - for (let index = actors.length - 1; index >= 0; index -= 1) { - const entry = actors[index] - if (entry !== null && typeof entry === 'object' && finished(entry) && Number.isFinite(entry.at)) return entry.at - } - if (Number.isFinite(task.completedAt)) return task.completedAt - return Number.isFinite(task.updatedAt) ? task.updatedAt : null -} -const doneTaskDurations = (tasks, now) => taskList(tasks).reduce((values, task) => { - if (task === null || typeof task !== 'object' || !finished(task)) return values - const start = Number.isFinite(task.createdAt) ? task.createdAt : null - const end = finishedAt(task) - if (start === null || end === null || end < start) return values - if (now - end > RECENT_MS) return values - values.push(end - start) - return values -}, []) - -// The running table (#1829): a claimed card is running, its age is the claim's own start, and the -// source is the board it belongs to — the same three sources the rows already carry. -export function runningOf(task, now) { - if (task === null || typeof task !== 'object') return null - if (task.status !== 'claimed' && task.status !== 'working') return null - const who = text(task.claimedBy) || text(task.lease?.holder) || text(task.assignee) - if (who === '') return null - const since = Number.isFinite(task.claimedAt) ? task.claimedAt : (Number.isFinite(task.updatedAt) ? task.updatedAt : null) - const last = Number.isFinite(task.updatedAt) ? task.updatedAt : (Number.isFinite(task.createdAt) ? task.createdAt : null) - const source = task.kind === 'group' ? 'group' : text(task.ownerBotId) !== '' ? 'bot' : 'workflow' - return { - id: String(task.id), - title: text(task.title), - status: task.status, - who, - sinceMs: since === null ? null : Math.max(0, now - since), - source, - lastActivityAt: last, - } -} - -// One cache for the whole host: the key carries the time zone, because the four day counts move -// with it. A hit answers the same object and spends no source call, which is what the issue's -// "cache hit reads the source once" case is about. -const cache = new Map() -// #2806: the key carries the five-minute window, so a bucket that is over can never be answered -// again — a new key arrives every five minutes for each repository and zone, and nothing ever -// removed the old ones. Every write now drops what it can no longer answer, and the cap catches a -// table whose writes outpace its reads, because nothing sweeps in between. -const CACHE_MAX = 512 -const pruneCache = (cacheStore, now) => { - // The default store is the Map above. An injected store that keeps only `get`/`set` (a test seam) - // is left as it is rather than given a `delete` it never promised. - if (typeof cacheStore.delete !== 'function') return - for (const [key, entry] of cacheStore) if (entry.expiresAt <= now) cacheStore.delete(key) - while (cacheStore.size >= CACHE_MAX) { - const oldest = cacheStore.keys().next() - if (oldest.done) break - cacheStore.delete(oldest.value) - } -} - -const emptyMetrics = () => Object.fromEntries(METRIC_KEYS.map(key => [key, null])) - -// `source` is `{ ok, pullRequests, issues, reason }`; a failed half is answered as unavailable for -// the metrics it feeds and never as zero. -export function summarizeMetrics({ now = Date.now(), timeZone = 'UTC', source, tasks } = {}) { - // #1848: the running rows are the board's own visible cards in every branch, so a source that is - // missing or failed no longer empties a table that needs no token to read. - const running = () => taskList(tasks).map(task => runningOf(task, now)).filter(row => row !== null) - // #1849: one zone, one decision — an unknown name is answered as the named `UTC` here, not thrown - // out of the whole board read. - const zone = resolveTimeZone(timeZone) - const unavailable = (reason) => ({ ok: false, reason, timeZone: zone, generatedAt: now, metrics: emptyMetrics(), running: running() }) - if (source === undefined || source === null) return unavailable('no-source') - if (source.ok !== true) return unavailable(text(source.reason) || 'source-unavailable') - const pullRequests = Array.isArray(source.pullRequests) ? source.pullRequests : [] - const issues = Array.isArray(source.issues) ? source.issues : [] - const key = dayKey(now, zone) - const day = { key, timeZone: zone } - return { - ok: true, - timeZone: zone, - generatedAt: now, - metrics: { - prsOpenedToday: countToday(pullRequests, item => item.created_at, day), - prsMergedToday: countToday(pullRequests, item => item.merged_at, day), - issuesOpenedToday: countToday(issues, item => item.created_at, day), - issuesClosedToday: countToday(issues, item => item.closed_at, day), - prMergeMedianMs: median(durationInWindow(pullRequests, item => item.created_at, item => item.merged_at, now)), - issueCloseMedianMs: median(durationInWindow(issues, item => item.created_at, item => item.closed_at, now)), - taskDoneMedianMs: median(doneTaskDurations(tasks, now)), - }, - running: running(), - } -} - -// The read the endpoint calls. `fetchImpl` and `token` are seams: no token answers unavailable -// without a request, and one failed request answers unavailable for this tick rather than a cache -// entry that would keep the failure for five minutes. `tasks` is the board's visible rows (#1848). -export async function readMetrics({ repo, token, fetchImpl, timeZone = 'UTC', now = Date.now(), tasks, ttlMs = METRICS_TTL_MS, cacheStore = cache } = {}) { - // The cache key is the repository and the five-minute bucket, not the reader, so it carries the - // repository-level numbers only. Everything computed from `tasks` — the running rows and the done - // median — is projected from this caller's list on every read, hit or miss (#1829, #1880): a - // shared entry must not answer one board with the numbers of the board that filled it. - const liveRunning = () => taskList(tasks).map(task => runningOf(task, now)).filter(row => row !== null) - const withLive = value => ({ ...value, metrics: { ...value.metrics, taskDoneMedianMs: median(doneTaskDurations(tasks, now)) }, running: liveRunning() }) - // #1849: resolved once, before any early return, so the cache key, every `summarizeMetrics` call - // and the answer all carry the same zone. - const zone = resolveTimeZone(timeZone) - const bearer = text(token) - if (bearer === '') return { ...summarizeMetrics({ now, timeZone: zone, tasks, source: { ok: false, reason: 'no-credentials' } }), cache: 'miss' } - if (typeof fetchImpl !== 'function') return { ...summarizeMetrics({ now, timeZone: zone, tasks, source: { ok: false, reason: 'no-fetch' } }), cache: 'miss' } - const key = `${repo}|${zone}|${Math.floor(now / ttlMs)}` - const hit = cacheStore.get(key) - if (hit !== undefined && hit.expiresAt > now) return { ...withLive(hit.value), cache: 'hit' } - const since = new Date(now - RECENT_MS).toISOString() - const headers = { Authorization: `Bearer ${bearer}`, Accept: 'application/vnd.github+json', 'User-Agent': 'flybot-project-metrics' } - const get = async (path) => { - const response = await fetchImpl(apiUrl(path), { headers }) - if (!response.ok) throw new Error(`${path} answered ${response.status}`) - return { rows: await response.json(), next: nextPage(response) } - } - // #2650: both lists are longer than one page in a seven-day window, and `/pulls` does not accept - // `since` at all, so a one-page read was the newest 100 rows rather than the window. Follow - // `Link: rel="next"` until it ends; MAX_PAGES bounds a repository that keeps advertising one. - const readAll = async (path) => { - const rows = [] - let next = path - let pages = 0 - while (next !== null && pages < MAX_PAGES) { - const page = await get(next) - if (!Array.isArray(page.rows)) throw new Error(`${next} answered a non-list`) - rows.push(...page.rows) - next = page.next - pages += 1 - } - return { rows, truncated: next !== null } - } - let source - try { - const [pullRequests, issues] = await Promise.all([ - readAll(`/repos/${repo}/pulls?state=all&sort=updated&direction=desc&per_page=100`), - readAll(`/repos/${repo}/issues?state=all&sort=updated&direction=desc&per_page=100&since=${since}`), - ]) - source = { - ok: true, - pullRequests: pullRequests.rows, - issues: issues.rows.filter(item => item.pull_request === undefined), - truncated: pullRequests.truncated || issues.truncated, - } - } catch (error) { - return { ...summarizeMetrics({ now, timeZone: zone, tasks, source: { ok: false, reason: `read-failed: ${error.message}` } }), cache: 'miss' } - } - const value = summarizeMetrics({ now, timeZone: zone, source, tasks }) - // Written field by field rather than by spreading, so a caller-scoped number added later cannot - // ride into a shared entry by accident: this list is the whole shape the cache may hold. - const { prsOpenedToday, prsMergedToday, issuesOpenedToday, issuesClosedToday, prMergeMedianMs, issueCloseMedianMs } = value.metrics - const repositoryValue = { - ok: value.ok, - timeZone: value.timeZone, - generatedAt: value.generatedAt, - metrics: { prsOpenedToday, prsMergedToday, issuesOpenedToday, issuesClosedToday, prMergeMedianMs, issueCloseMedianMs }, - } - pruneCache(cacheStore, now) - cacheStore.set(key, { expiresAt: now + ttlMs, value: repositoryValue }) - return { ...withLive(value), cache: 'miss', ...(source.truncated ? { truncated: true } : {}) } -} diff --git a/core/src/host/project-state.js b/core/src/host/project-state.js deleted file mode 100644 index e3d45d117..000000000 --- a/core/src/host/project-state.js +++ /dev/null @@ -1,205 +0,0 @@ -// A reader over the Project State files a Bot already works from (#1702): `PROJECT.md`, -// `ROADMAP.md`, `docs/governance.md`, `decisions/*.md` and `LESSONS.md` in the Session's -// working folder. Listing each one with a sha256 content hash is what lets a caller say -// "I read decisions/x.md at hash abc1234" and notice a stale copy, instead of reading the -// files with `cat` and assuming the copy it has is the one on `main`. -// -// The reader is a tool a Bot calls. Nothing here runs at install time, reads a prompt or -// writes a context: the answer is the whole effect. A folder with no repository, a missing -// file and a file too large to hand over each answer with the reason, never with an empty -// string that would read as an empty document. -import { createHash } from 'node:crypto' -import { lstat, readdir, readFile, stat } from 'node:fs/promises' -import { dirname, join } from 'node:path' -import { defineTool } from './tool-schema.js' -import { output } from './tools.js' -import { dirKind } from './real-dir.js' - -// The files this reader answers for, and nothing else: a named file outside this list is -// refused rather than read, so the tool never becomes a way to walk the working folder. -export const PROJECT_STATE_PATHS = ['PROJECT.md', 'ROADMAP.md', 'docs/governance.md', 'LESSONS.md'] -export const DECISIONS_DIR = 'decisions' -// One file handed over whole, bounded: a reader that copies a megabyte into a turn spends -// the context it was meant to save. Over the bound the answer is the size, not the text. -export const MAX_BYTES = 200_000 - -// The one name shape a decision has: exactly one segment under `decisions/`, ending in `.md`. -// The lister, the reader and the board all take a name only when it matches this. -export const isDecisionPath = path => /^decisions\/[^/]+\.md$/.test(path) -const allowed = path => PROJECT_STATE_PATHS.includes(path) || isDecisionPath(path) -export const sha256 = bytes => createHash('sha256').update(bytes).digest('hex') - -async function repositoryAt(dir) { - try { - const info = await stat(join(dir, '.git')) - return info.isDirectory() || info.isFile() - } catch { - return false - } -} - -async function decisionFiles(dir) { - // #2055: `decisions` itself must be a real directory here. `readdir` follows a link, so a - // Project whose `decisions` points at another folder listed that folder's files as its own; a - // link, a plain file or a missing entry is "no decisions", the answer an absent folder gives. - if (await dirKind(join(dir, DECISIONS_DIR)) !== 'real') return [] - let entries - try { - entries = await readdir(join(dir, DECISIONS_DIR), { withFileTypes: true }) - } catch { - return [] - } - return entries - .filter(entry => entry.isFile() && entry.name.endsWith('.md')) - .map(entry => `${DECISIONS_DIR}/${entry.name}`) - .sort() -} - -// One file sitting directly in the folder, never through a link. `readdir` already reports a -// symlink as "not a plain file", and reading a name — here, in the reader, and on the board — -// answers the same way: a symlink is an absent file, not its target. The `lstat` is what makes -// that one rule hold for a name that was never listed, a name from a record, or `PROJECT.md`. -// -// #2055: a leaf `lstat` does not follow a link, but it does not see that the *folder* above the -// name is one either — `decisions/secret.md` under a symlinked `decisions` stats as a plain file -// and reads the outside text. So a decision path is read only when its folder is a real directory -// here, the same entry `decisionFiles` checks; anything else is treated as an absent file, which -// is what `project_state` and the board's opened row already answer for a missing one. -async function fileAt(dir, path) { - if (isDecisionPath(path) && await dirKind(dirname(join(dir, path))) !== 'real') { - throw notPlainFile(path, true) - } - const info = await lstat(join(dir, path)) - if (!info.isFile()) throw notPlainFile(path, info.isSymbolicLink()) - return info -} - -// A name that is there but is not a plain file. `absent` marks the shapes this reader already -// answers as a missing file — a symlink (#2055) and a decision whose `decisions/` is not a real -// folder here — so the listing drops them exactly like an absent name. Anything else (a folder, a -// fifo, a device) is a name the reader cannot read, which the listing answers instead of dropping -// (#2461): a folder whose `PROJECT.md` is a directory is not a folder with no Project State file. -const notPlainFile = (path, absent) => Object.assign(new Error(`not a plain file: ${path}`), { absent }) - -// One Project State file with its byte size, content hash and last-modified time. Exported -// because the board reads the file a decided meeting archived, and it must refuse exactly what -// this reader refuses — one rule, one place. -export async function describeFile(dir, path) { - const info = await fileAt(dir, path) - const bytes = await readFile(join(dir, path)) - return { path, bytes: bytes.length, sha256: sha256(bytes), mtime: new Date(info.mtimeMs).toISOString() } -} - -// Every Project State file in `dir` with its byte size, content hash and last-modified time. -// A file that is absent from the folder is left out rather than reported as empty; the -// decisions folder is read one level deep and only for `*.md`. -// -// The repository gate belongs to the tool, not to every reader (#1809): a decision file is the -// board's own artifact and shows up in a folder that is not a repository, while `project_state` -// keeps refusing such a folder by default. Only the board passes `{ repository: false }`. -// The reason a listed name has no row, short enough to sit inside one: the system's own errno when -// it gave one, else the rule the name broke (a folder where a plain file belongs). The name itself -// stays in the row, so the reason never repeats it and never carries a parenthesis of its own. -const unreadableReason = (error) => { - if (typeof error?.code === 'string' && error.code !== '') return error.code - const message = String(error?.message ?? error ?? '').split('\n')[0] - return message.startsWith('not a plain file') ? 'not a plain file' : (message.replace(/[()]/g, '') || 'unknown') -} - -// One listed name: a row with its byte size, hash and mtime, or the reason it has none. A name that -// is absent — or is a link, which this reader reads as absent (#2055) — adds nothing; a name that is -// there and could not be read is `unreadable: ()`, never a silent omission (#2461). -async function listedRow(dir, path) { - try { - return { file: await describeFile(dir, path) } - } catch (error) { - if (error?.absent === true || error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return null - return { unreadable: { path, reason: unreadableReason(error) } } - } -} - -export async function listProjectState(dir, { repository = true } = {}) { - if (repository && !await repositoryAt(dir)) return { ok: false, reason: `not a repository: ${dir}` } - const found = [] - const unreadable = [] - const add = async (path) => { - const row = await listedRow(dir, path) - if (row === null) return - if (row.file !== undefined) found.push(row.file) - else unreadable.push(row.unreadable) - } - for (const path of PROJECT_STATE_PATHS) await add(path) - for (const path of await decisionFiles(dir)) await add(path) - return { ok: true, dir, files: found, unreadable } -} - -// The bytes of one named Project State file, bounded at `MAX_BYTES`. The path is checked -// against the list first, so `..`, an absolute path or any other file answers `not listed`. -export async function readProjectState(dir, file) { - const path = String(file ?? '').trim().replaceAll('\\', '/').replace(/^\.\//, '') - if (path === '') return { ok: false, reason: 'absent: no file named' } - if (path.startsWith('/') || path.split('/').includes('..') || !allowed(path)) { - return { ok: false, reason: `not listed: ${path} is not a Project State file` } - } - if (!await repositoryAt(dir)) return { ok: false, reason: `not a repository: ${dir}` } - return readListedFile(dir, path) -} - -// The bytes of one file whose name the caller already accepted as listed, bounded at `MAX_BYTES`. -// `project_state` and the board's opened decision share this read (#2041), so the bound and the -// symlink rule cannot drift between the two: a file that vanished since the listing, or one -// replaced by a symlink, answers `absent` rather than the text it points at. -export async function readListedFile(dir, path) { - let info - try { - info = await fileAt(dir, path) - } catch { - return { ok: false, reason: `absent: ${path}` } - } - if (info.size > MAX_BYTES) return { ok: false, reason: `too large: ${path} is ${info.size} bytes (bound ${MAX_BYTES})` } - const bytes = await readFile(join(dir, path)) - return { ok: true, path, bytes: bytes.length, sha256: sha256(bytes), mtime: new Date(info.mtimeMs).toISOString(), text: bytes.toString('utf8') } -} - -// The Session's working folder: the cwd its Agent runs in, else the folder the Bot or room -// is set to work in, else the team workspace. Undefined only when none of them is known. -export function workDirFor(rt, exec) { - const cwd = exec?.agent?.session?.header?.cwd - if (typeof cwd === 'string' && cwd !== '') return cwd - const botId = rt.selfOf?.(exec?.agent?.id) ?? exec?.agent?.id - const dir = rt.workDirOf?.({ botId, roomId: exec?.agent?.session?.header?.roomId }) - if (typeof dir === 'string' && dir !== '') return dir - return rt.teamFolder?.() -} - -// The answer the tool prints: the rows a caller compares hashes with, and — after them — the names -// that are there but could not be read. `none` is the answer for a folder with nothing to read, -// which is exactly what an unreadable name is not (#2461). -const listed = ({ files, unreadable }) => files.length === 0 && unreadable.length === 0 - ? 'project state: no Project State files in this folder' - : [ - 'project state:', - ...files.map(file => `${file.path}\t${file.bytes} bytes\tsha256:${file.sha256}\t${file.mtime}`), - ...unreadable.map(({ path, reason }) => `unreadable: ${path} (${reason})`), - ].join('\n') - -export function install(rt) { - rt.ctx.tools.register(defineTool({ - name: 'project_state', - description: 'Read the Project State files of your working folder: PROJECT.md, ROADMAP.md, docs/governance.md, decisions/*.md, LESSONS.md. Without `file` it lists each with its byte size, sha256 content hash and last-modified time, so you can compare the hash with the copy you already have. With `file` it returns that one file, bounded. Answers `absent`, `too large` or `not a repository` with the reason instead of an empty document, and a listed name that is there but cannot be read as `unreadable: ()` rather than leaving it out.', - parameters: { - file: { type: 'string', description: 'Optional Project State path to read, e.g. decisions/x.md; omit to list them all' }, - }, - output, - async execute(args, exec) { - const dir = workDirFor(rt, exec) - if (dir === undefined) return 'not a repository: the Session has no working folder' - if (args?.file === undefined || args.file === '') { - const listedFiles = await listProjectState(dir) - return listedFiles.ok ? listed(listedFiles) : listedFiles.reason - } - const read = await readProjectState(dir, args.file) - return read.ok ? read.text : read.reason - }, - })) -} diff --git a/core/src/host/store.js b/core/src/host/store.js index cdcd612f0..0484f4ea5 100644 --- a/core/src/host/store.js +++ b/core/src/host/store.js @@ -742,7 +742,7 @@ export function install(rt) { })), } } - // #1828: the role switch. The policy lives in project-board.js (`changeRole`), so the endpoint + // #1828: the role switch. The policy lives in the Project domain (`changeRole`), so the endpoint // and the menu cannot disagree; a refusal answers the reason and writes nothing. case 'project-role': { const project = readProject(projectText(body.id, 200)) @@ -860,7 +860,7 @@ export function install(rt) { return await rt.projectDecision?.(project.id, path, actor) ?? { ok: false, reason: `not listed: ${path}` } } // The read-only board (#1696): Project id in, its tasks out. The aggregate lives in - // project-board.js; it reads and returns, and writes nothing. The actor goes with the id + // the Project domain; it reads and returns, and writes nothing. The actor goes with the id // so the board applies the same visibility rule `tasks` does (#1723). case 'project-board': { const board = (await rt.projectBoard?.(projectText(body.id, 200), actor, diff --git a/core/src/host/turn-budget.js b/core/src/host/turn-budget.js index 5c1aae588..6422ab5c6 100644 --- a/core/src/host/turn-budget.js +++ b/core/src/host/turn-budget.js @@ -12,7 +12,7 @@ // Whose turn it is comes from turns.js's recorded verdict (turn-origin.js): `owner` is the user's // own message or a line they wrote in a group; everything else — `scheduled`, `other`, or a turn // whose messages carried no verdict — is system-started and waits for the counter to reset. -import { dayKey } from './project-metrics.js' +import { dayKey } from './day-key.js' import { OWNER } from './turn-origin.js' // The cap a fresh team starts with, and the day a team counted in before it had a zone of its own. diff --git a/core/src/host/work-dir.js b/core/src/host/work-dir.js new file mode 100644 index 000000000..655c3299d --- /dev/null +++ b/core/src/host/work-dir.js @@ -0,0 +1,15 @@ +// The Session's working folder (#2684): the cwd its Agent runs in, else the folder the Bot or room +// is set to work in, else the team workspace. Undefined only when none of them is known. +// +// This lived in `project-state.js` while that reader was the only caller; the meetings module +// needs the same answer (the folder a decision is archived in), so it is its own module now that +// the Project domain moved to `plugins/project` — the plugin gets it back through the host seam +// `flybot.projectHost()` (`core/src/host/project-host.js`). +export function workDirFor(rt, exec) { + const cwd = exec?.agent?.session?.header?.cwd + if (typeof cwd === 'string' && cwd !== '') return cwd + const botId = rt.selfOf?.(exec?.agent?.id) ?? exec?.agent?.id + const dir = rt.workDirOf?.({ botId, roomId: exec?.agent?.session?.header?.roomId }) + if (typeof dir === 'string' && dir !== '') return dir + return rt.teamFolder?.() +} diff --git a/core/test/agents.test.js b/core/test/agents.test.js index b8c5ad77e..1f1a48a61 100644 --- a/core/test/agents.test.js +++ b/core/test/agents.test.js @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' import { apply } from '../index.js' +import { apply as applyProject } from '../../plugins/project/index.js' import { TEAM_TOOLS } from '../src/host/constants.js' import { fakeContext } from './fake-ctx.js' @@ -90,6 +91,9 @@ function start(team, { config = {} } = {}) { return register(options) } apply(ctx, { home, ...config }) + // The Project domain is a peer plugin (#2684): a profile mounts it next to the core, and the + // team tools below include the two it registers. + applyProject({ flybot: ctx.flybot }) const call = async (endpoint, payload) => { const response = await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', body: JSON.stringify({ endpoint, payload }) })) return response.json() diff --git a/core/test/install-doctor.test.js b/core/test/install-doctor.test.js index 91bf55ab9..5513c73ac 100644 --- a/core/test/install-doctor.test.js +++ b/core/test/install-doctor.test.js @@ -97,18 +97,19 @@ test('checkPluginPackage: a numeric dsh.bundle.patch fails without throwing', () } }) -test('buildInstallCommands: core, social, tree and workflow; timeline only on request (#281)', () => { +test('buildInstallCommands: core, social, tree, workflow and project; timeline only on request (#281)', () => { const commands = buildInstallCommands() - assert.equal(commands.length, 4) + assert.equal(commands.length, 5) assert.ok(commands.every(command => command.includes('0.2.1-rc.2'))) assert.ok(commands[0].includes('path:/core')) assert.ok(commands[1].includes('path:/plugins/social')) assert.ok(commands[2].includes('path:/plugins/tree')) assert.ok(commands[3].includes('path:/plugins/workflow')) + assert.ok(commands[4].includes('path:/plugins/project')) assert.equal(commands.some(command => command.includes('timeline')), false) const all = buildInstallCommands({ timeline: true }) - assert.equal(all.length, 5) - assert.ok(all[4].includes('path:/plugins/timeline')) + assert.equal(all.length, 6) + assert.ok(all[5].includes('path:/plugins/timeline')) }) test('runDoctor: the real repository passes on a Node version at the floor', () => { @@ -129,7 +130,7 @@ test('runDoctor: an empty root fails the plugin checks', () => { const report = runDoctor({ root: dir, nodeVersion: 'v22.19.0' }) assert.equal(report.ok, false) const pluginChecks = report.checks.filter(check => check.label.includes('plugin package')) - assert.equal(pluginChecks.length, 5) + assert.equal(pluginChecks.length, 6) assert.ok(pluginChecks.every(check => !check.ok)) } finally { rmSync(dir, { recursive: true, force: true }) diff --git a/core/test/install.test.js b/core/test/install.test.js index ec073f32b..97fd6b66e 100644 --- a/core/test/install.test.js +++ b/core/test/install.test.js @@ -29,6 +29,9 @@ function start(state, { config = {}, services } = {}) { } // Listeners run, and tools are listed, in the order they are registered. +// The Project tools (`project_state`, `project_digest`) are registered by @flybot/project's own +// apply() since #2684, so they are not in the core's list; plugins/project/test/host.test.js holds +// that half. test('apply registers listeners, tools, and effects in a fixed order', (t) => { const { ctx, stop, applyResult } = start() t.after(stop) @@ -68,7 +71,6 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { 'tool attach_capability', 'tool detach_capability', 'tool track_tasks', - 'tool project_state', 'tool meeting', 'effect ds-bot: chat index', 'effect ds-bot: external room participants', @@ -117,7 +119,6 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { 'effect ds-bot: capabilities', 'effect ds-bot: missed scheduled runs', 'tool bot_schedules', - 'tool project_digest', 'effect ds-bot: automation tasks default', 'effect ds-bot: usage ledger', 'on llm/stream', diff --git a/core/test/meetings.test.js b/core/test/meetings.test.js index 2ea3d10ba..a5da1b0e5 100644 --- a/core/test/meetings.test.js +++ b/core/test/meetings.test.js @@ -10,6 +10,7 @@ import { join } from 'node:path' import { test } from 'node:test' import { createRuntime } from '../index.js' import { fakeContext } from './fake-ctx.js' +import { installProjectDomain } from './project-domain.js' const team = () => ({ version: 2, @@ -37,6 +38,7 @@ async function boot() { sessions: { flush: async () => {} }, }) const rt = createRuntime(ctx, { home }) + installProjectDomain(rt) await rt.load() const dir = mkdtempSync(join(tmpdir(), 'ds-bot-meetings-work-')) const exec = (id = 'a') => ({ agent: { id, session: { header: { cwd: dir } } } }) @@ -268,7 +270,7 @@ test('#1861: a severe decision is refused when its only agree position is a prox test('#1861: the decision card exposes the participants and their proxies', async (t) => { const { rt, open, meeting, stop } = await boot() t.after(stop) - const { decisionCardOf } = await import('../src/host/project-board.js') + const { decisionCardOf } = await import('../../plugins/project/src/project-board.js') const id = await open('Who holds the storage decision?', ['Chief', 'Owner']) await meeting({ action: 'position', meeting: id, as: 'Chief', stance: 'agree', text: 'ship it', speaksFor: 'Owner' }) const card = decisionCardOf(rt.state.meetings[id]) diff --git a/core/test/plugin-switches.test.js b/core/test/plugin-switches.test.js index 25e4be14d..04b554bdf 100644 --- a/core/test/plugin-switches.test.js +++ b/core/test/plugin-switches.test.js @@ -21,14 +21,14 @@ const runtime = (state) => { } test('every switch is on until state.json says otherwise', () => { - assert.deepEqual(FEATURE_PLUGIN_IDS, ['social', 'tree', 'timeline', 'workflow']) - assert.deepEqual(pluginSwitches({}).map(plugin => plugin.enabled), [true, true, true, true]) + assert.deepEqual(FEATURE_PLUGIN_IDS, ['social', 'tree', 'timeline', 'workflow', 'project']) + assert.deepEqual(pluginSwitches({}).map(plugin => plugin.enabled), [true, true, true, true, true]) assert.deepEqual(pluginSwitches({ flybot: { plugins: { workflow: { enabled: false } } } }).map(plugin => [plugin.id, plugin.enabled]), [ - ['social', true], ['tree', true], ['timeline', true], ['workflow', false], + ['social', true], ['tree', true], ['timeline', true], ['workflow', false], ['project', true], ]) }) -test('a switch only takes the four known ids, and true/false only', () => { +test('a switch only takes a known id, and true/false only', () => { const state = {} assert.equal(setPluginSwitch(state, 'social', false).find(plugin => plugin.id === 'social').enabled, false) assert.deepEqual(state.flybot.plugins.social, { enabled: false }) diff --git a/core/test/project-assistant.test.js b/core/test/project-assistant.test.js deleted file mode 100644 index 41d9250bb..000000000 --- a/core/test/project-assistant.test.js +++ /dev/null @@ -1,734 +0,0 @@ -// The Project Assistant (#1712): one hidden assistant per Project, memory that never crosses a -// Project id, a digest built only from the Project State reader and the board rows, and exactly one -// 09:00 Asia/Shanghai task on the assistant's own Session that goes away with the Project. -import assert from 'node:assert/strict' -import { existsSync, mkdtempSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { test } from 'node:test' -import { createRuntime } from '../index.js' -import { fakeContext } from './fake-ctx.js' -import { cleanHome } from './temp-home.js' -import { MAX_ACTIONABLE, actionableFor, buildDigest, dayIn } from '../src/host/project-assistant.js' - -const seed = () => ({ - version: 2, - revision: 1, - mainBotIds: ['a'], - members: { 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' } }, - bots: { a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1, ownerId: 'owner-1' } }, - rooms: {}, - agents: {}, - exchanges: [], - exchangeTurns: {}, - questions: {}, - prefs: {}, - grants: {}, -}) - -// DSH's schedule service, in memory: enough for the host's own create/delete path and its view. -function scheduleService() { - const tasks = new Map() - const created = [] - let next = 1 - return { - tasks, - created, - async create(sessionId, request) { - created.push({ sessionId, request }) - const id = `schedule-${next++}` - const kind = Object.keys(request).find(key => !['title', 'prompt'].includes(key)) - const record = { id, title: request.title, prompt: request.prompt, timing: request[kind], kind, scheduledAt: '2030-01-01T01:00:00.000Z' } - tasks.set(id, { sessionId, record, status: 'active', deliveryHistory: { records: [], earlierRecordsUnavailable: false } }) - return record - }, - async list({ sessionId }) { return [...tasks.values()].filter(task => task.sessionId === sessionId && task.status === 'active').map(task => task.record) }, - async catalog() { return [...tasks.values()].map(task => ({ ...task.record, sessionId: task.sessionId, status: task.status })) }, - async delete({ sessionId, id }) { - const task = tasks.get(id) - if (task === undefined || task.sessionId !== sessionId) return { deleted: false } - tasks.delete(id) - return { deleted: true } - }, - } -} - -async function start({ github = 'Birfy', schedule = true } = {}) { - const home = mkdtempSync(join(tmpdir(), 'ds-bot-assistant-')) - if (!existsSync(join(home, 'state.json'))) writeFileSync(join(home, 'state.json'), JSON.stringify(seed())) - const service = scheduleService() - const ctx = fakeContext({ services: schedule ? { schedule: service } : {} }) - // `schedule: false` leaves the service off at install and keeps the host's own attach callback, so - // a test can switch automation tasks on later exactly as the Plugins page does. - const pendingAttach = [] - if (!schedule) { - ctx.inject = (names, attach) => { pendingAttach.push(() => attach({ schedule: service, effect: (execute, label) => ctx.effect(execute, label) })) } - } - const attachSchedule = () => { for (const run of pendingAttach.splice(0)) run() } - const agents = new Map() - const agentOf = (sessionId) => { - if (!agents.has(sessionId)) agents.set(sessionId, { session: { id: sessionId }, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle() {}, inbox: { nextStep: [], nextTurn: [], remove() {} } }) - return agents.get(sessionId) - } - let sessions = 0 - Object.assign(ctx, { - llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, - workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, - sessionController: { create: async () => ({ sessionId: `s${++sessions}` }), resolveAgent: async id => ({ agent: agentOf(id) }), selectModel: async () => {} }, - sessionTitle: { rename: async () => {} }, - sessions: { flush: async () => {} }, - }) - const rt = createRuntime(ctx, { home }) - await rt.load() - rt.connectorsView = () => (github === null ? [] : [{ id: 'github', name: 'GitHub', state: 'ready', account: github }]) - const delivered = [] - rt.deliver = async message => { delivered.push(message) } - // #1983: the digest's warnings are still being appended to the diagnostics log when the test - // body ends; `dispose()` drains that log (and the state chain), so the home is removed once, - // after the writers are quiet. `cleanHome` only asserts the removal worked. - const stop = async () => { - await ctx.dispose() - cleanHome(home) - } - return { rt, service, delivered, attachSchedule, stop } -} - -const created = async (rt, body) => (await rt.projectEndpoint('project-create', body)).project - -test('memory is private to one Project and an unknown id is refused', async () => { - const { rt, stop } = await start() - try { - const one = await created(rt, { name: 'One', ownerUserId: 'owner-1' }) - const two = await created(rt, { name: 'Two', ownerUserId: 'owner-1' }) - await rt.projectEndpoint('project-memory-write', { id: one.id, kind: 'decision', text: 'Ship the memory store' }) - await rt.projectEndpoint('project-memory-write', { id: one.id, kind: 'question', text: 'Who reviews it?' }) - const mine = (await rt.projectEndpoint('project-memory', { id: one.id })).memory - assert.deepEqual(mine.decisions.map(entry => entry.text), ['Ship the memory store']) - assert.deepEqual(mine.questions.map(entry => entry.text), ['Who reviews it?']) - const theirs = (await rt.projectEndpoint('project-memory', { id: two.id })).memory - assert.deepEqual(theirs.decisions, []) - assert.deepEqual(theirs.questions, []) - await assert.rejects(() => rt.projectEndpoint('project-memory', { id: 'project-missing' }), /Unknown Project/) - assert.throws(() => rt.projectAssistant.read('project-missing'), error => error.code === 'bot/not-found') - } finally { - await stop() - } -}) - -// #1886: the assistant writer and the store's `appendProjectMemory` must count the bound on the -// same unit. Both now count code points, so the same emoji note is accepted by both and the same -// 601-code-point one refused by both — the old `String#length` made a 350-emoji note look like 700. -test('the assistant writer and the store count the memory bound in code points alike', async () => { - const { rt, stop } = await start() - try { - const project = await created(rt, { name: 'Points', ownerUserId: 'owner-1' }) - const atCap = '😀'.repeat(600) - rt.projectAssistant.write(project.id, { kind: 'decision', text: atCap }) - let memory = (await rt.projectEndpoint('project-memory', { id: project.id })).memory - assert.equal(memory.decisions.length, 1, 'the assistant writer accepts 600 code points') - assert.equal(memory.decisions[0].text, atCap, 'and the store keeps the entry whole') - - const over = '😀'.repeat(601) - assert.throws(() => rt.projectAssistant.write(project.id, { kind: 'decision', text: over }), - error => error.code === 'bot/invalid' && error.message === 'A Project memory entry is at most 600 characters') - await assert.rejects(() => rt.projectEndpoint('project-memory-write', { id: project.id, kind: 'decision', text: over }), - error => error.code === 'bot/invalid' && error.message === 'A Project memory entry is at most 600 characters') - memory = (await rt.projectEndpoint('project-memory', { id: project.id })).memory - assert.equal(memory.decisions.length, 1, 'neither refusal adds a row') - - const short = '😀'.repeat(350) - rt.projectAssistant.write(project.id, { kind: 'question', text: short }) - await rt.projectEndpoint('project-memory-write', { id: project.id, kind: 'decision', text: short }) - memory = (await rt.projectEndpoint('project-memory', { id: project.id })).memory - assert.deepEqual(memory.questions.map(entry => entry.text), [short], 'the assistant writer stores 350 emoji') - assert.deepEqual(memory.decisions.map(entry => entry.text), [atCap, short], 'and the store stores them too') - } finally { - await stop() - } -}) - -test('the assistant refuses an unknown Project and an archived one', async () => { - const { rt, stop } = await start() - try { - await assert.rejects(() => rt.projectAssistant.create('project-missing'), error => error.code === 'bot/not-found') - const project = await created(rt, { name: 'Archived', ownerUserId: 'owner-1' }) - rt.readProject(project.id).status = 'archived' - await assert.rejects(() => rt.projectAssistant.create(project.id), /is archived/) - } finally { - await stop() - } -}) - -test('the digest names only the facts the rows carry, in the four-section order', async () => { - const { rt, delivered, stop } = await start() - try { - const project = await created(rt, { name: 'Digest', ownerUserId: 'owner-1' }) - const at = Date.parse('2026-10-29T01:00:00Z') - rt.projectBoard = () => [ - { id: 't-merged', source: 'workflow', column: 'done', assignee: null, updatedAt: at - 60_000 }, - { id: 't-closed', source: 'bot', column: 'done', assignee: null, updatedAt: at - 30_000 }, - { id: 't-waiting', source: 'bot', column: 'in-progress', assignee: 'Writer', updatedAt: at - 10_000 }, - { id: 't-next', source: 'group', column: 'not-started', assignee: null, updatedAt: at - 5_000 }, - ] - const result = await rt.projectAssistant.digest(project.id, { at }) - assert.equal(result.ok, true) - assert.equal(result.skipped, false) - const text = result.text - assert.match(text, /^Written-by: Project Assistant \(Digest\)\nPosted-for: Birfy\n/) - for (const id of ['t-merged', 't-closed', 't-waiting', 't-next']) assert.match(text, new RegExp(id)) - assert.match(text, /waiting on:\n- t-waiting — Writer|In progress, waiting on:\n- t-waiting — Writer/) - assert.doesNotMatch(text, /t-absent/, 'a row that does not exist is never named') - const merged = text.indexOf('Merged since the previous digest:') - const closed = text.indexOf('Closed:') - const blocked = text.indexOf('In progress, waiting on:') - const next = text.indexOf('Next slice:') - assert.ok(merged > 0 && merged < closed && closed < blocked && blocked < next, text) - assert.equal(delivered.length, 1) - } finally { - await stop() - } -}) - -test('a day with no change is one line and the second build adds nothing', async () => { - const { rt, delivered, stop } = await start() - try { - const project = await created(rt, { name: 'Quiet', ownerUserId: 'owner-1' }) - const at = Date.parse('2026-10-29T01:00:00Z') - rt.projectBoard = () => [] - const first = await rt.projectAssistant.digest(project.id, { at }) - assert.equal(first.changed, false) - assert.match(first.text, /no change since the previous digest\.$/) - const second = await rt.projectAssistant.digest(project.id, { at: at + 60_000 }) - assert.equal(second.skipped, true) - assert.equal(second.reason, 'today already has a digest') - assert.equal(delivered.length, 1, 'one delivery for one day') - assert.equal(dayIn(at), dayIn(at + 60_000)) - const note = rt.projectMemoryOf(project.id).digest - assert.equal(note.day, dayIn(at)) - assert.match(note.text, /no change/) - } finally { - await stop() - } -}) - -// 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' }) - 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 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() - } -}) - -test('project-create makes exactly one 09:00 Asia/Shanghai task and project-delete takes it away', async () => { - const { rt, service, stop } = await start() - try { - const project = await created(rt, { name: 'Scheduled', ownerUserId: 'owner-1' }) - assert.equal(service.created.length, 1) - const request = service.created[0].request - assert.equal(request.title, '09:00 Project digest') - assert.deepEqual(request.daily, { time: '09:00:00', time_zone: 'Asia/Shanghai' }) - assert.equal(project.assistant.scheduleId, 'schedule-1') - const assistant = rt.botOf(project.assistant.botId) - assert.equal(assistant.hidden, true) - assert.equal(assistant.projectId, project.id) - assert.equal(service.tasks.size, 1) - - await rt.projectEndpoint('project-delete', { id: project.id }) - assert.equal(rt.readProject(project.id), null) - assert.equal(service.tasks.size, 0) - assert.equal(rt.projectMemoryOf(project.id), null) - } finally { - await stop() - } -}) - -// A host with a member signed in narrows every schedule answer to the caller's rows, and the -// digest is created by the host itself: the assistant's owner has to go down with that request, -// or the create succeeds and the row it just wrote is not in the answer the host reads back — -// the Project would then be re-scheduled on every reconcile. -test('with owner scoping on, project-create still records the digest it scheduled', async () => { - const { rt, service, stop } = await start() - try { - rt.state.authSessions = { 's-a': { memberId: 'owner-1', tokenHash: 'a' } } - assert.equal(rt.authzEnforced(), true, 'the guard is on in this case, or the assertion proves nothing') - const project = await created(rt, { name: 'Scoped', ownerUserId: 'owner-1' }) - assert.equal(service.created.length, 1, 'one digest was scheduled') - assert.equal(project.assistant.scheduleId, 'schedule-1', 'and the host knows the row it wrote') - await rt.projectEndpoint('project-delete', { id: project.id }) - assert.equal(service.tasks.size, 0) - } finally { - await stop() - } -}) - -test('a Project created while automation is off gets its 09:00 digest when the service appears (#1751)', async () => { - const { rt, service, attachSchedule, stop } = await start({ schedule: false }) - try { - const project = await created(rt, { name: 'Late', ownerUserId: 'owner-1' }) - assert.equal(project.assistant.scheduleId, undefined, 'no task is written while the service is off') - assert.match(project.assistant.error, /Automation tasks are off in DSH/) - assert.equal(service.tasks.size, 0) - - // The host's own attach path: the service appears on the ctx and schedules.attach runs, which - // asks the assistant to reconcile. The digest is the only task it may write. - attachSchedule() - await rt.projectAssistant.reconcile() - assert.equal(service.created.length, 1) - const request = service.created[0].request - assert.equal(request.title, '09:00 Project digest') - assert.deepEqual(request.daily, { time: '09:00:00', time_zone: 'Asia/Shanghai' }) - assert.equal(service.tasks.size, 1) - - const saved = rt.readProject(project.id).assistant - assert.equal(saved.scheduleId, 'schedule-1') - assert.equal(saved.error, undefined, 'the recorded error is cleared by the successful retry') - - // A second pass over the same Project is not a second task. - await rt.projectAssistant.reconcile() - assert.equal(service.created.length, 1) - assert.equal(service.tasks.size, 1) - - // `project-create` on a Project that already has its assistant answers `created:false` and adds - // nothing; with the schedule in place there is nothing to retry. - const again = await rt.projectAssistant.create(project.id) - assert.equal(again.created, false) - assert.equal(again.assistant.scheduleId, 'schedule-1') - assert.equal(service.tasks.size, 1) - } finally { - await stop() - } -}) - -test('project-create retries the digest for an assistant it did not create (#1751)', async () => { - const { rt, service, stop } = await start() - try { - // The service is mounted but its first write fails, which is how "automation tasks are off" - // reaches the assistant: the endpoint exists and the task is refused. - const realCreate = service.create - service.create = async () => { throw new Error('Automation tasks are off in DSH') } - const project = await created(rt, { name: 'Retry', ownerUserId: 'owner-1' }) - assert.equal(project.assistant.scheduleId, undefined) - assert.match(project.assistant.error, /Automation tasks are off in DSH/) - assert.equal(service.tasks.size, 0) - - service.create = realCreate - const again = await rt.projectAssistant.create(project.id) - assert.equal(again.created, false) - assert.equal(again.assistant.scheduleId, 'schedule-1') - assert.equal(again.assistant.error, undefined) - assert.equal(service.tasks.size, 1, 'exactly one 09:00 task, and it is the retried one') - - const third = await rt.projectAssistant.create(project.id) - assert.equal(third.created, false) - assert.equal(service.tasks.size, 1) - } finally { - await stop() - } -}) - -test('buildDigest is pure: the same rows answer the same four sections', () => { - const project = { id: 'project-1', name: 'Pure' } - const rows = [ - { id: 'b', source: 'workflow', column: 'done', assignee: null, updatedAt: 20 }, - { id: 'a', source: 'bot', column: 'in-progress', assignee: 'Writer', updatedAt: 10 }, - ] - const one = buildDigest({ project, rows, since: null, at: Date.parse('2026-10-29T01:00:00Z') }) - const two = buildDigest({ project, rows, since: null, at: Date.parse('2026-10-29T01:00:00Z') }) - assert.equal(one.text, two.text) - assert.match(one.text, /Project Assistant \(Pure\) — 2026-10-29/) - const after = buildDigest({ project, rows, since: 30, at: Date.parse('2026-10-29T01:00:00Z') }) - assert.doesNotMatch(after.text, /b \\(workflow\\)/, 'a finished card older than the previous digest is not reported again') - assert.match(after.text, /waiting on/) - const quiet = buildDigest({ project, rows: [], since: 30, at: Date.parse('2026-10-29T01:00:00Z') }) - assert.equal(quiet.changed, false) - assert.match(quiet.text, /no change since the previous digest\.$/) -}) - -// #2618: the header and the day the digest is filed under are one calendar day — the digest's own -// zone, Asia/Shanghai, not UTC. A fixed instant with an explicit offset keeps the machine's TZ out -// of the assertion: 00:30 Shanghai is still the previous day in UTC. -test('the digest header names the Shanghai day the digest is filed under', () => { - const at = Date.parse('2026-11-08T00:30:00+08:00') - assert.equal(dayIn(at, 'Asia/Shanghai'), '2026-11-08') - const project = { id: 'project-1', name: 'Edge' } - const quiet = buildDigest({ project, rows: [], at }) - assert.equal(quiet.day, '2026-11-08') - assert.equal(quiet.text.split('\n')[0], 'Project Assistant (Edge) — 2026-11-08: no change since the previous digest.') - const rows = [{ id: 'a', source: 'bot', column: 'in-progress', assignee: 'Writer', updatedAt: 10 }] - const busy = buildDigest({ project, rows, at }) - assert.equal(busy.day, '2026-11-08') - assert.equal(busy.text.split('\n')[0], 'Project Assistant (Edge) — 2026-11-08') -}) - -// --- #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 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 }, - ] - } - 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 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 - 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 08: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() - } -}) - -// P-194: the due instant is 09:00 Asia/Shanghai; a quiet window is wall clock in the owner's own -// zone. Two different zones' wall clocks are not comparable, so the window is tested against the -// due instant read in the owner's zone. Red first drives this through setOwner directly: the -// `project-assistant-owner` endpoint has no channel case, so no product-level path exists today -// and the trigger arrives with P-198. Until then this is a latent defect: the digest would be -// dropped silently once a UTC owner's window is set. -test('#P-194: a quiet window in the owner\'s zone is compared with the due instant, not with 09:00', async () => { - const { rt, stop } = await start() - try { - const project = await created(rt, { name: 'UtcWindow', ownerUserId: 'owner-1' }) - rt.projectBoard = () => [{ id: 't-utc', source: 'bot', column: 'in-progress', assignee: 'Owner One Bot', ownerId: 'owner-1', updatedAt: 1 }] - // 08:00–10:00 UTC does not contain the due instant (01:00Z = 09:00 Asia/Shanghai). - await rt.projectAssistant.setOwner(project.id, 'owner-1', { quiet: { on: true, from: '08:00', to: '10:00' }, zone: 'UTC' }) - const result = await rt.projectAssistant.digest(project.id, { at: AT }) // 01:00Z - const owner = result.owners[0] - assert.equal(result.delivered, 1, 'the window does not cover the due instant, so the digest goes out') - assert.notEqual(owner.dropped, true, 'nothing was dropped') - assert.doesNotMatch(String(owner.reason ?? ''), /cover/, 'no drop reason is recorded') - assert.notEqual(owner.held, true, 'nothing was held either') - } finally { - await stop() - } -}) - -test('#P-194: a window that really covers the due instant still drops the digest', async () => { - const { rt, delivered, stop } = await start() - try { - const project = await created(rt, { name: 'UtcCovered', ownerUserId: 'owner-1' }) - rt.projectBoard = () => [] - await rt.projectAssistant.setOwner(project.id, 'owner-1', { quiet: { on: true, from: '00:30', to: '01:30' }, zone: 'UTC' }) - const result = await rt.projectAssistant.digest(project.id, { at: AT }) // 01:00Z is inside it - const owner = result.owners[0] - assert.equal(result.delivered, 0, 'a window covering the due instant delivers nothing') - assert.equal(owner.dropped, true) - assert.match(owner.reason, /cover 01:00, so nothing is delivered — the expected outcome/) - assert.equal(delivered.length, 0) - } finally { - await stop() - } -}) - -// P-197: the personal block stays five cards, and says how many are left over; five or fewer -// print exactly as they did before. -test('#P-197: more than five decisions say how many are still waiting', () => { - const rows = Array.from({ length: MAX_ACTIONABLE + 2 }, (_, index) => ({ id: `t-${index}`, source: 'bot', column: 'in-progress', assignee: 'Worker', ownerId: 'owner-1', updatedAt: index, title: `card ${index}`, status: 'blocked' })) - const items = actionableFor(rows, 'owner-1') - assert.equal(items.length, MAX_ACTIONABLE + 2, 'the helper keeps them all') - const built = buildDigest({ project: { id: 'p', name: 'More' }, 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(line => /^- t-\d/.test(line)) - assert.equal(listed.length, MAX_ACTIONABLE, 'the digest still lists at most MAX_ACTIONABLE cards') - assert.match(built.text, /^- … and 2 more waiting on you$/m) -}) - -test('#P-197: five or fewer decisions print exactly as before', () => { - const rows = Array.from({ length: MAX_ACTIONABLE }, (_, index) => ({ id: `t-${index}`, source: 'bot', column: 'in-progress', assignee: 'Worker', ownerId: 'owner-1', updatedAt: index, title: `card ${index}`, status: 'blocked' })) - const built = buildDigest({ project: { id: 'p', name: 'Five' }, rows, owner: 'owner-1', actionable: actionableFor(rows, 'owner-1'), at: AT }) - assert.doesNotMatch(built.text, /more waiting on you/) - const personal = built.text.slice(built.text.indexOf("Needs owner-1's decision:")) - const listed = personal.split('\n').filter(line => /^- t-\d/.test(line)) - assert.equal(listed.length, MAX_ACTIONABLE) -}) - -// P-195: the personal block is what waits on the reader, not the whole board: a card still being -// worked on is somebody's move, a blocked card needs this reader's call, and a finished result -// needs their acceptance. -test('#P-195: only the cards that wait on the reader reach the personal block', () => { - const rows = [ - { id: 't-working', source: 'bot', column: 'in-progress', assignee: 'Worker', ownerId: 'owner-1', updatedAt: 1, title: 'Still moving', status: 'working' }, - { id: 't-blocked', source: 'bot', column: 'in-progress', assignee: 'Worker', ownerId: 'owner-1', updatedAt: 2, title: 'Stopped on a question', status: 'blocked' }, - { id: 't-ready', source: 'bot', column: 'in-progress', assignee: 'Worker', ownerId: 'owner-1', updatedAt: 3, title: 'Back for acceptance', status: 'result-ready' }, - ] - const items = actionableFor(rows, 'owner-1') - assert.deepEqual(items.map(item => item.id), ['t-blocked', 't-ready'], 'a card that is still moving is nobody else\'s decision to make') - const built = buildDigest({ project: { id: 'p', name: 'Waiting' }, rows, owner: 'owner-1', actionable: items, at: AT }) - const personal = built.text.slice(built.text.indexOf("Needs owner-1's decision:")) - assert.match(personal, /Stopped on a question/) - assert.match(personal, /needs your call/) - assert.match(personal, /Back for acceptance/) - assert.match(personal, /ready for your acceptance/) - assert.doesNotMatch(personal, /Still moving/, 'a card still in progress is not a decision waiting on the reader') - const shared = built.text.slice(0, built.text.indexOf("Needs owner-1's decision:")) - assert.match(shared, /In progress, waiting on:/) - assert.match(shared, /t-working — Worker/, 'the shared section still lists it') -}) - -// #2005: the personal block shortens a long card title to 77 units to keep the line readable, and -// that bound must land on a code point. A title whose 78th UTF-16 unit is the first half of an -// emoji used to carry that half into the digest text, which a reader then sees as a replacement -// character. -test('#2005: a long card title in the digest is shortened at a code point, never inside an emoji', () => { - const row = { id: 't-cut', source: 'bot', column: 'in-progress', assignee: 'Worker', ownerId: 'owner-1', updatedAt: 1, title: `${'a'.repeat(76)}😀${'b'.repeat(10)}`, status: 'blocked' } - const built = buildDigest({ project: { id: 'p', name: 'Cut' }, rows: [row], owner: 'owner-1', actionable: actionableFor([row], 'owner-1'), at: AT }) - const personal = built.text.slice(built.text.indexOf("Needs owner-1's decision:")) - const line = personal.split('\n').find(entry => entry.startsWith('- t-cut')) - assert.equal(line.isWellFormed(), true, 'the digest line is well formed, never a lone high surrogate') - assert.equal(line, `- t-cut — needs your call — ${'a'.repeat(76)}… (still in-progress)`, 'the 78th unit was the first half of the pair, so the pair is dropped whole') -}) - -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: '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') - 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/) -}) - -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 }) - const pick = (await rt.taskEndpoint('task-create', { botId: 'worker', title: 'Pick the release date' }, null)).id - const rotate = (await rt.taskEndpoint('task-create', { botId: 'a', title: 'Rotate the deploy key' }, null)).id - const moving = (await rt.taskEndpoint('task-create', { botId: 'a', title: 'Still moving' }, null)).id - // A card waits on its reader only once it has stopped: drive both to `blocked`, which the - // digest reads from the task store itself, and leave one of the owner's cards `working`. - for (const id of [pick, rotate]) { - await rt.taskEndpoint('task-accept', { id }, null) - await rt.taskEndpoint('task-start', { id }, null) - await rt.taskEndpoint('task-wait-input', { id, question: 'Which one?' }, null) - } - await rt.taskEndpoint('task-accept', { id: moving }, null) - await rt.taskEndpoint('task-start', { id: moving }, null) - // #1767: the same call now answers the record beside the rows, so the rows are read off it. - const rows = (await rt.projectBoard(project.id)).rows - assert.equal(rows.length, 3) - 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.doesNotMatch(one.text, /Still moving/, 'a card the owner\'s Bot is still working on waits on nobody') - 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/core/test/project-board-levels.test.js b/core/test/project-board-levels.test.js index 8f820077d..e51c32a02 100644 --- a/core/test/project-board-levels.test.js +++ b/core/test/project-board-levels.test.js @@ -8,7 +8,8 @@ import { fileURLToPath } from 'node:url' import { runInNewContext } from 'node:vm' import { build } from 'esbuild' import { advanceRefusal, decisionLevel } from '../src/host/meetings.js' -import { decisionCardOf, install } from '../src/host/project-board.js' +import { decisionCardOf, install } from '../../plugins/project/src/project-board.js' +import '../../plugins/project/test/host-bind.js' const bundle = await build({ entryPoints: [fileURLToPath(new URL('../src/client/project-board.js', import.meta.url))], diff --git a/core/test/project-board-meeting-date.test.js b/core/test/project-board-meeting-date.test.js deleted file mode 100644 index d03786282..000000000 --- a/core/test/project-board-meeting-date.test.js +++ /dev/null @@ -1,92 +0,0 @@ -// #2587: a `meetings` record whose `decidedAt` / `createdAt` is a non-date string is state an -// older version or a hand edit can leave behind. `text()` only proves the value is a non-empty -// string, so the date line fed `new Date(value).toISOString()` and threw -// `RangeError: Invalid time value` for the whole board read. The meeting now lists with an empty -// date, like the other unreadable fields, and the rest of the board still renders. -import assert from 'node:assert/strict' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { test } from 'node:test' -import { createRuntime, provideFlyBot } from '../index.js' -import { fakeContext } from './fake-ctx.js' - -const seed = () => ({ - version: 2, - revision: 1, - mainBotIds: ['a'], - members: { 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' } }, - bots: { - a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1, ownerId: 'owner-1' }, - }, - rooms: { r1: { id: 'r1', name: 'Ops', members: ['a'], mode: 'admin', admin: 'a', named: true, sessions: {}, log: [], seq: 0, seen: {}, projectId: 'project-p1' } }, - agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, grants: {}, - projects: { - 'project-p1': { id: 'project-p1', name: 'P1', repository: 'ThinkFlowLab/FlyBot', defaultBranch: 'main', ownerUserId: 'owner-1', createdAt: 1, updatedAt: 1, status: 'active' }, - }, - projectRuns: {}, - tasks: { - 'task-group': { id: 'task-group', kind: 'group', status: 'working', ownerBotId: 'a', origin: { roomId: 'r1' }, assignee: 'Writer', title: 'Ship it', createdAt: 5, updatedAt: 10 }, - }, - meetings: { - // The decided meeting carries the unparseable timestamp the old record may hold. - m1: { id: 'm1', project: 'project-p1', status: 'decided', question: 'Adopt the record?', decidedAt: 'not-a-date', updatedAt: 10 }, - // The pending list reads `createdAt` on its own line; it must guard the same way. - m2: { id: 'm2', project: 'project-p1', status: 'open', question: 'Still open?', createdAt: 'not-a-time', updatedAt: 11 }, - // A readable timestamp still formats, so the guard does not blank every date. - m3: { id: 'm3', project: 'project-p1', status: 'decided', question: 'Keep the date?', decidedAt: '2026-11-01T09:00:00.000Z', updatedAt: 12 }, - }, -}) - -async function start() { - const home = mkdtempSync(join(tmpdir(), 'ds-bot-board-')) - writeFileSync(join(home, 'state.json'), JSON.stringify(seed())) - const ctx = fakeContext() - let route - const agents = new Map() - const agentOf = (sessionId) => { - if (!agents.has(sessionId)) agents.set(sessionId, { session: { id: sessionId }, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle() {}, inbox: { nextStep: [], nextTurn: [], remove() {} } }) - return agents.get(sessionId) - } - Object.assign(ctx, { - llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, - workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, - sessionController: { create: async () => ({ sessionId: 's1' }), resolveAgent: async id => ({ agent: agentOf(id) }), selectModel: async () => {} }, - sessionTitle: { rename: async () => {} }, - sessions: { flush: async () => {} }, - }) - const register = ctx.connection.fetch.register - ctx.connection.fetch.register = (options) => { route = options; return register(options) } - const rt = createRuntime(ctx, { home }) - provideFlyBot(ctx, rt.flybot) - await rt.load() - const call = async (endpoint, payload) => (await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', body: JSON.stringify({ endpoint, payload }) }))).json() - const stop = async () => { - await ctx.dispose() - rmSync(home, { recursive: true, force: true, maxRetries: 5 }) - } - return { call, rt, stop } -} - -test('#2587: a meetings record with a non-date decidedAt does not break the board read', async () => { - const { rt, call, stop } = await start() - try { - const board = await rt.projectBoard('project-p1') - const decided = board.meetings.decided.find(meeting => meeting.id === 'm1') - assert.ok(decided, 'the decided meeting is still listed') - assert.equal(decided.date, '', 'an unreadable decidedAt reads as no date') - const pending = board.meetings.pending.find(meeting => meeting.id === 'm2') - assert.ok(pending, 'the pending meeting is still listed') - assert.equal(pending.date, '', 'an unreadable createdAt reads as no date') - const dated = board.meetings.decided.find(meeting => meeting.id === 'm3') - assert.equal(dated.date, '2026-11-01', 'a readable timestamp still formats') - assert.deepEqual(board.rows.map(row => row.id), ['task-group'], 'the rest of the board still renders') - // The endpoint the page uses answers the same board, not an error. - const served = await call('project-board', { id: 'project-p1' }) - assert.equal(served.ok, true, JSON.stringify(served)) - assert.deepEqual(served.value.tasks.map(row => row.id), ['task-group']) - assert.equal(served.value.meetings.decided.find(meeting => meeting.id === 'm1').date, '') - } finally { - await stop() - } -}) diff --git a/core/test/project-board-panels.test.js b/core/test/project-board-panels.test.js index 668dc8bbf..5aa6d6ee2 100644 --- a/core/test/project-board-panels.test.js +++ b/core/test/project-board-panels.test.js @@ -15,6 +15,7 @@ import { runInNewContext } from 'node:vm' import { build } from 'esbuild' import { createRuntime } from '../index.js' import { fakeContext } from './fake-ctx.js' +import { installProjectDomain } from './project-domain.js' // The entry carries the dictionary beside the panels: `t` and `hostText` reach the panel only // through the same copy of `i18n.js`, and that is true inside one bundle and nowhere else. @@ -128,6 +129,7 @@ async function startHost(t, state = seed()) { sessions: { flush: async () => {} }, }) const rt = createRuntime(ctx, { home }) + installProjectDomain(rt) await rt.load() t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) return rt diff --git a/core/test/project-digest-authz.test.js b/core/test/project-digest-authz.test.js index 783280134..598913933 100644 --- a/core/test/project-digest-authz.test.js +++ b/core/test/project-digest-authz.test.js @@ -9,6 +9,7 @@ import { join } from 'node:path' import { test } from 'node:test' import { createRuntime } from '../index.js' import { fakeContext } from './fake-ctx.js' +import { installProjectDomain } from './project-domain.js' // One member has signed in (#1846): that single line is what turns `authzEnforced()` on, and it is // the only difference from `project-assistant.test.js`'s seed. `owner-1` and `owner-2` own the two @@ -70,6 +71,7 @@ async function start() { sessions: { flush: async () => {} }, }) const rt = createRuntime(ctx, { home }) + installProjectDomain(rt) await rt.load() rt.connectorsView = () => [{ id: 'github', name: 'GitHub', state: 'ready', account: 'Birfy' }] rt.deliver = async () => {} diff --git a/core/test/project-domain.js b/core/test/project-domain.js new file mode 100644 index 000000000..11ea44109 --- /dev/null +++ b/core/test/project-domain.js @@ -0,0 +1,12 @@ +// The Project domain, applied to a runtime the way a profile has it (#2684). +// +// The domain — Project State, the board, the Project's own Chief, the team dossier, the metrics +// and the assistant — lives in `plugins/project` now, and it installs its seams in its own +// `apply()`. A core test that reads a Project through the store, the channel or the board needs +// the plugin applied to the runtime it built, exactly as a profile mounts it; the plugin's own +// suite covers the domain itself and lives in `plugins/project/test/`. +import { apply as applyProject } from '../../plugins/project/index.js' + +export function installProjectDomain(rt) { + return applyProject({ flybot: rt.flybot }) +} diff --git a/core/test/project-role-authz.test.js b/core/test/project-role-authz.test.js index 7919236e0..eb2943c28 100644 --- a/core/test/project-role-authz.test.js +++ b/core/test/project-role-authz.test.js @@ -11,6 +11,7 @@ import { test } from 'node:test' import { createRuntime, provideFlyBot } from '../index.js' import { install as installAuthz } from '../src/host/authz.js' import { fakeContext } from './fake-ctx.js' +import { installProjectDomain } from './project-domain.js' const seed = () => ({ version: 2, @@ -59,6 +60,7 @@ async function start() { sessions: { flush: async () => {} }, }) const rt = createRuntime(ctx, { home }) + installProjectDomain(rt) provideFlyBot(ctx, rt.flybot) await rt.load() // One member has signed in, so the gate is enforced: the actor comes from the session cookie only. diff --git a/core/test/project-state-unreadable.test.js b/core/test/project-state-unreadable.test.js deleted file mode 100644 index 82c9a4382..000000000 --- a/core/test/project-state-unreadable.test.js +++ /dev/null @@ -1,114 +0,0 @@ -// A Project State file that is there but cannot be read must not be dropped on the floor. The -// reader already answers `not a repository`, `absent` and `too large` with the reason instead of -// an empty document; a listed name that exists and fails to read is the same kind of answer. -// Today `listProjectState` catches every failure and lists nothing, so a folder whose PROJECT.md -// is a folder, or is unreadable, reads as `no Project State files in this folder` — the one -// answer that says there is nothing to read. The name and the reason belong in the answer: -// `unreadable: PROJECT.md ()`, with the name written the way the other rows write it -// (relative to the working folder, because the answer can be pasted into a public issue). -// -// The control is a folder with no Project State file at all, which must keep answering exactly -// `no Project State files` — an answer that says "nothing here" is wrong only when something is. -import assert from 'node:assert/strict' -import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { test } from 'node:test' -import { apply } from '../index.js' -import { fakeContext } from './fake-ctx.js' - -const TEAM = { - version: 1, mainBotIds: ['a'], - bots: { a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1 } }, - rooms: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0, -} - -const NO_FILES = 'project state: no Project State files in this folder' -// The rows this reader answers with one per line; `unreadable` is the one this test is about. -const rows = listing => listing.split('\n').slice(1) -const unreadable = listing => rows(listing).filter(line => line.startsWith('unreadable: ')) - -// A folder the reader calls a repository, with nothing in it that this reader lists. The test -// writes the one state file it wants, so the red case is the only thing in the folder. -function emptyRepository() { - const dir = mkdtempSync(join(tmpdir(), 'ds-bot-project-state-unreadable-')) - mkdirSync(join(dir, '.git')) - return dir -} - -async function boot({ dir } = {}) { - const home = mkdtempSync(join(tmpdir(), 'ds-bot-project-state-unreadable-home-')) - writeFileSync(join(home, 'state.json'), JSON.stringify(TEAM)) - const ctx = fakeContext() - Object.assign(ctx, { - llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, - workspaceRegistry: { get: () => (dir === undefined ? undefined : { path: dir }), resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, - sessionController: { create: async () => ({ sessionId: 's1' }), resolveAgent: async id => ({ agent: { session: { id }, id, status: 'idle', cancel() {} } }), selectModel: async () => {}, page: async () => ({ records: [], hasMore: false }) }, - sessionTitle: { rename: async () => {} }, - sessions: { flush: async () => {} }, - }) - const rt = apply(ctx, { home }) - await ctx.flybot.ready() - const exec = { agent: { id: 'a', session: { header: { cwd: dir } } } } - const tool = args => ctx.registeredTools.get('project_state').execute(args, exec) - const stop = async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) } - return { ctx, rt, home, tool, stop } -} - -test('#2461: a Project State name that is a folder is answered as unreadable, never as no files', async (t) => { - const dir = emptyRepository() - mkdirSync(join(dir, 'PROJECT.md')) - const { tool, stop } = await boot({ dir }) - t.after(async () => { await stop(); rmSync(dir, { recursive: true, force: true, maxRetries: 5 }) }) - - const listing = await tool({}) - assert.notEqual(listing, NO_FILES, 'the folder is not empty: one listed name is there and could not be read') - assert.equal(listing.split('\n')[0], 'project state:', 'the answer still opens as the listing it is') - assert.deepEqual(unreadable(listing).map(line => line.replace(/ \([^)]+\)$/, '')), ['unreadable: PROJECT.md'], - 'the name is written the way the rows write it, relative to the working folder') - for (const line of unreadable(listing)) assert.match(line, /^unreadable: PROJECT\.md \([^)\n]+\)$/, - 'the reason travels with the name, so a reader knows it is not an absent file') -}) - -test('#2461: a Project State file that cannot be read keeps its name and reason beside the rows that can', async (t) => { - const dir = emptyRepository() - mkdirSync(join(dir, 'PROJECT.md')) - writeFileSync(join(dir, 'ROADMAP.md'), '# Roadmap\n') - const { tool, stop } = await boot({ dir }) - t.after(async () => { await stop(); rmSync(dir, { recursive: true, force: true, maxRetries: 5 }) }) - - const listing = await tool({}) - const roadmap = rows(listing).find(line => line.startsWith('ROADMAP.md\t')) - assert.ok(roadmap, 'the file that can be read is still listed, with its size, hash and mtime') - assert.equal(roadmap.split('\t').length, 4) - assert.deepEqual(unreadable(listing).length, 1, 'and the one that cannot be read is named once, not swallowed') -}) - -// A root process reads a chmod 000 file, so this case is declared skipped rather than run there — -// the shape `core/test/diagnostics.test.js` already uses for its own root-only cases. -test('#2461: a state file the process may not read answers its code, not an empty folder', { - skip: process.getuid?.() === 0 ? 'root reads a chmod 000 file' : undefined, -}, async (t) => { - const dir = emptyRepository() - writeFileSync(join(dir, 'PROJECT.md'), '# Project\n') - chmodSync(join(dir, 'PROJECT.md'), 0o000) - const { tool, stop } = await boot({ dir }) - t.after(async () => { - await stop() - chmodSync(join(dir, 'PROJECT.md'), 0o600) - rmSync(dir, { recursive: true, force: true, maxRetries: 5 }) - }) - - const listing = await tool({}) - assert.notEqual(listing, NO_FILES, 'an unreadable file is not an absent one') - assert.match(listing, /(^|\n)unreadable: PROJECT\.md \(EACCES\)($|\n)/, - 'the errno is what tells a reader to fix permissions rather than look for another file') -}) - -test('#2461: a folder with no Project State file still answers exactly no files', async (t) => { - const dir = emptyRepository() - const { tool, stop } = await boot({ dir }) - t.after(async () => { await stop(); rmSync(dir, { recursive: true, force: true, maxRetries: 5 }) }) - - assert.equal(await tool({}), NO_FILES) -}) diff --git a/core/test/project-state.test.js b/core/test/project-state.test.js deleted file mode 100644 index b60b9b8f2..000000000 --- a/core/test/project-state.test.js +++ /dev/null @@ -1,229 +0,0 @@ -import assert from 'node:assert/strict' -import { createHash } from 'node:crypto' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { test } from 'node:test' -import { apply } from '../index.js' -import { fakeContext } from './fake-ctx.js' - -const TEAM = { - version: 1, mainBotIds: ['a'], - bots: { a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1 } }, - rooms: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0, -} - -// A Project State folder: `.git` so it reads as a repository, the files the reader answers -// for, and one decision file. -function projectStateFolder() { - const dir = mkdtempSync(join(tmpdir(), 'ds-bot-project-state-')) - mkdirSync(join(dir, '.git')) - mkdirSync(join(dir, 'docs')) - mkdirSync(join(dir, 'decisions')) - writeFileSync(join(dir, 'PROJECT.md'), '# Project\none\n') - writeFileSync(join(dir, 'ROADMAP.md'), '# Roadmap\n') - writeFileSync(join(dir, 'LESSONS.md'), '# Lessons\n') - writeFileSync(join(dir, 'docs/governance.md'), '# Governance\n') - writeFileSync(join(dir, 'decisions/0001-first.md'), '# First decision\n') - writeFileSync(join(dir, 'README.md'), 'not project state\n') - return dir -} - -async function boot({ dir } = {}) { - const home = mkdtempSync(join(tmpdir(), 'ds-bot-project-state-home-')) - writeFileSync(join(home, 'state.json'), JSON.stringify(TEAM)) - const ctx = fakeContext() - Object.assign(ctx, { - llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, - workspaceRegistry: { get: () => (dir === undefined ? undefined : { path: dir }), resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, - sessionController: { create: async () => ({ sessionId: 's1' }), resolveAgent: async id => ({ agent: { session: { id }, id, status: 'idle', cancel() {} } }), selectModel: async () => {}, page: async () => ({ records: [], hasMore: false }) }, - sessionTitle: { rename: async () => {} }, - sessions: { flush: async () => {} }, - }) - const rt = apply(ctx, { home }) - await ctx.flybot.ready() - const exec = { agent: { id: 'a', session: { header: { cwd: dir } } } } - const tool = (args, agentExec = exec) => ctx.registeredTools.get('project_state').execute(args, agentExec) - const stop = async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) } - return { ctx, rt, home, tool, stop } -} - -test('#1702: list returns each Project State file with bytes, a stable sha256 and its mtime', async (t) => { - const dir = projectStateFolder() - const { tool, stop } = await boot({ dir }) - t.after(async () => { await stop(); rmSync(dir, { recursive: true, force: true, maxRetries: 5 }) }) - - const first = await tool({}) - const rows = first.split('\n').slice(1).map(line => line.split('\t')) - const paths = rows.map(row => row[0]).sort() - assert.deepEqual(paths, ['LESSONS.md', 'PROJECT.md', 'ROADMAP.md', 'decisions/0001-first.md', 'docs/governance.md']) - for (const row of rows) { - assert.match(row[1], /^\d+ bytes$/) - assert.match(row[2], /^sha256:[0-9a-f]{64}$/) - assert.match(row[3], /^\d{4}-\d{2}-\d{2}T/) - } - const project = rows.find(row => row[0] === 'PROJECT.md') - assert.equal(project[1], `${readFileSync(join(dir, 'PROJECT.md')).length} bytes`) - // Same bytes, same hash — across calls and across a fresh listing. - assert.equal(await tool({}), first) - - // One byte changes the content hash. - writeFileSync(join(dir, 'PROJECT.md'), '# Project\nONE\n') - const changed = await tool({}) - const changedRow = changed.split('\n').slice(1).map(line => line.split('\t')).find(row => row[0] === 'PROJECT.md') - assert.notEqual(changedRow[2], project[2]) - assert.equal(changedRow[1], project[1], 'the byte size stayed the same; only the hash moved') -}) - -test('#1702: read one returns its bytes, bounded, and reports a missing file', async (t) => { - const dir = projectStateFolder() - const { tool, stop } = await boot({ dir }) - t.after(async () => { await stop(); rmSync(dir, { recursive: true, force: true, maxRetries: 5 }) }) - - assert.equal(await tool({ file: 'decisions/0001-first.md' }), '# First decision\n') - assert.equal(await tool({ file: 'PROJECT.md' }), '# Project\none\n') - assert.match(await tool({ file: 'decisions/missing.md' }), /^absent: decisions\/missing\.md$/) - assert.match(await tool({ file: 'README.md' }), /^not listed: README\.md is not a Project State file$/) - assert.match(await tool({ file: '../PROJECT.md' }), /^not listed:/) - - const large = 'x'.repeat(200_001) - writeFileSync(join(dir, 'decisions/big.md'), large) - assert.match(await tool({ file: 'decisions/big.md' }), /^too large: decisions\/big\.md is 200001 bytes \(bound 200000\)$/) - const listed = await tool({}) - const bigRow = listed.split('\n').find(line => line.startsWith('decisions/big.md\t')) - assert.match(bigRow, /\t200001 bytes\t/, 'a file over the read bound still lists with its size') -}) - -// A symlink is a name for a file somewhere else, and this reader answers for one folder. A name -// that stands for a link is therefore absent — the same answer as a file that is not there — and -// never the target's text. `readdir` already leaves a link out of the listing, and the read has to -// agree with it; a name from a record can reach the same file without ever being listed. -test('#2037: a symlinked Project State file is not followed, it answers as absent', async (t) => { - const dir = projectStateFolder() - const outside = mkdtempSync(join(tmpdir(), 'ds-bot-project-state-outside-')) - writeFileSync(join(outside, 'secret.md'), '# Outside secret\n') - const { tool, stop } = await boot({ dir }) - t.after(async () => { - await stop() - rmSync(dir, { recursive: true, force: true, maxRetries: 5 }) - rmSync(outside, { recursive: true, force: true, maxRetries: 5 }) - }) - - symlinkSync(join(outside, 'secret.md'), join(dir, 'decisions/0002-link.md')) - assert.equal(await tool({ file: 'decisions/0002-link.md' }), 'absent: decisions/0002-link.md', 'a link answers like a missing file, never with the text it points at') - - rmSync(join(dir, 'PROJECT.md')) - symlinkSync(join(outside, 'secret.md'), join(dir, 'PROJECT.md')) - assert.equal(await tool({ file: 'PROJECT.md' }), 'absent: PROJECT.md', 'the same answer for a named Project State file') - const lines = (await tool({})).split('\n') - assert.equal(lines.some(line => line.startsWith('PROJECT.md\t')), false, 'a link is not listed either') - assert.equal(lines.some(line => line.startsWith('decisions/0002-link.md\t')), false, 'and neither is a linked decision') -}) - -test('#1702: a folder with no repository answers not a repository', async (t) => { - const dir = mkdtempSync(join(tmpdir(), 'ds-bot-project-state-nogit-')) - writeFileSync(join(dir, 'PROJECT.md'), '# Project\n') - const { tool, stop } = await boot({ dir }) - t.after(async () => { await stop(); rmSync(dir, { recursive: true, force: true, maxRetries: 5 }) }) - - assert.match(await tool({}), /^not a repository: /) - assert.match(await tool({ file: 'PROJECT.md' }), /^not a repository: /) -}) - -test('#1702: calling the reader changes no prompt, section or tool registration', async (t) => { - const dir = projectStateFolder() - const { ctx, tool, stop } = await boot({ dir }) - t.after(async () => { await stop(); rmSync(dir, { recursive: true, force: true, maxRetries: 5 }) }) - - const calls = [...ctx.calls] - const sections = new Map(ctx.sections) - const tools = [...ctx.registeredTools.keys()].sort() - await tool({}) - await tool({ file: 'PROJECT.md' }) - assert.deepEqual(ctx.calls, calls, 'a read registers nothing and fires no effect') - assert.deepEqual([...ctx.sections.keys()].sort(), [...sections.keys()].sort(), 'no prompt section is added') - assert.deepEqual([...ctx.registeredTools.keys()].sort(), tools, 'no tool is added or replaced') -}) - -// #2055: the `decisions` entry itself must be a real directory. `readdir` follows a link, and so -// does the leaf `lstat` of `decisions/.md`, so a Project whose `decisions` points at another -// folder listed and read that folder's files as its own. A link — at a real folder or at a missing -// path — and a plain file answer exactly like a folder that has no `decisions` directory: no row, -// no hash, and `absent` for a read. The control is a real folder, which must keep working. -function replaceDecisions(dir, target) { - rmSync(join(dir, 'decisions'), { recursive: true, force: true }) - symlinkSync(target, join(dir, 'decisions')) -} - -test('#2055: a decisions folder that is a symlink to a real folder outside the Project lists and opens nothing', async (t) => { - const dir = projectStateFolder() - const outside = mkdtempSync(join(tmpdir(), 'ds-bot-project-state-outside-')) - writeFileSync(join(outside, 'outside.md'), '# Outside\n') - const { tool, stop } = await boot({ dir }) - t.after(async () => { - await stop() - rmSync(dir, { recursive: true, force: true, maxRetries: 5 }) - rmSync(outside, { recursive: true, force: true, maxRetries: 5 }) - }) - - replaceDecisions(dir, outside) - const listing = await tool({}) - assert.equal(listing.split('\n').some(line => line.startsWith('decisions/')), false, 'another folder\'s files are not this Project\'s listing') - assert.equal(listing.includes('outside.md'), false, 'not even the name, so no title and no sha256 reach the answer') - assert.equal(await tool({ file: 'decisions/outside.md' }), 'absent: decisions/outside.md', 'a read answers as a folder with no decisions, never with the text it points at') - assert.equal(listing.includes('PROJECT.md\t'), true, 'the named Project State files are unaffected') -}) - -test('#2055: a decisions folder that is a symlink to a path that does not exist reads as no decisions', async (t) => { - const dir = projectStateFolder() - const { tool, stop } = await boot({ dir }) - t.after(async () => { await stop(); rmSync(dir, { recursive: true, force: true, maxRetries: 5 }) }) - - replaceDecisions(dir, join(dir, 'nowhere-decisions')) - const listing = await tool({}) - assert.equal(listing.split('\n').some(line => line.startsWith('decisions/')), false, 'a dangling link lists nothing') - assert.equal(await tool({ file: 'decisions/0001-first.md' }), 'absent: decisions/0001-first.md', 'and a read is absent, with no error naming the target') -}) - -// The listing is not the only door: `project_state` with a `file` goes `readProjectState` → -// `readListedFile` → `fileAt`, which never calls `readdir`. So the same link is asked here without -// a listing first, and the answer is the one an absent folder gives. -test('#2055: a decision read by name under a linked folder answers absent, with no listing first', async (t) => { - const dir = projectStateFolder() - const outside = mkdtempSync(join(tmpdir(), 'ds-bot-project-state-outside-read-')) - writeFileSync(join(outside, 'outside.md'), '# Outside\n') - const { tool, stop } = await boot({ dir }) - t.after(async () => { - await stop() - rmSync(dir, { recursive: true, force: true, maxRetries: 5 }) - rmSync(outside, { recursive: true, force: true, maxRetries: 5 }) - }) - - replaceDecisions(dir, outside) - assert.equal(await tool({ file: 'decisions/outside.md' }), 'absent: decisions/outside.md', 'the leaf lstat would see a plain file, so the folder above it decides') - assert.equal(await tool({ file: 'decisions/0001-first.md' }), 'absent: decisions/0001-first.md', 'a name from the old real folder is absent too, never read through the link') -}) - -test('#2055: a decisions folder that is a plain file, not a directory, reads as no decisions', async (t) => { - const dir = projectStateFolder() - const { tool, stop } = await boot({ dir }) - t.after(async () => { await stop(); rmSync(dir, { recursive: true, force: true, maxRetries: 5 }) }) - - rmSync(join(dir, 'decisions'), { recursive: true, force: true }) - writeFileSync(join(dir, 'decisions'), 'not a folder\n') - const listing = await tool({}) - assert.equal(listing.split('\n').some(line => line.startsWith('decisions/')), false, 'a plain file where the folder should be lists nothing') - assert.equal(await tool({ file: 'decisions/0001-first.md' }), 'absent: decisions/0001-first.md', 'and nothing throws') -}) - -test('#2055: a real decisions folder keeps listing, hashing and reading its files', async (t) => { - const dir = projectStateFolder() - const { tool, stop } = await boot({ dir }) - t.after(async () => { await stop(); rmSync(dir, { recursive: true, force: true, maxRetries: 5 }) }) - - const row = (await tool({})).split('\n').find(line => line.startsWith('decisions/0001-first.md\t')) - assert.ok(row, 'the control still lists its decision') - const sha = /sha256:([0-9a-f]{64})/.exec(row)[1] - assert.equal(sha, createHash('sha256').update(readFileSync(join(dir, 'decisions/0001-first.md'))).digest('hex'), 'with the hash of the bytes it read') - assert.equal(await tool({ file: 'decisions/0001-first.md' }), '# First decision\n', 'and the read returns the file itself') -}) diff --git a/core/test/project-team.test.js b/core/test/project-team.test.js deleted file mode 100644 index 38f9c3a6a..000000000 --- a/core/test/project-team.test.js +++ /dev/null @@ -1,234 +0,0 @@ -// #1864: the Project board's team dossier. One read-only `project-team` endpoint answers, for the -// Project's Chief, its assistant and the pool members the Project names, the persona (`instructions`), -// the memory head and topic names, and the attached skills — capped, and with a missing Bot as a -// `null` row instead of an error for the whole answer. -// -// Before the slice the host answered `Unknown endpoint project-team` before any handler ran, so the -// first two tests are the red half: they call the endpoint through the same route the browser uses. -import assert from 'node:assert/strict' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { test } from 'node:test' -import { createRuntime, provideFlyBot } from '../index.js' -import { install } from '../src/host/project-team.js' -import { fakeContext } from './fake-ctx.js' - -const LONG = 'x'.repeat(5000) - -const seed = () => ({ - version: 2, - revision: 1, - mainBotIds: ['bot-chief'], - members: { - 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'bot-chief' }, - 'peer-1': { id: 'peer-1', createdAt: 2 }, - }, - bots: { - 'bot-chief': { - id: 'bot-chief', name: 'Project Chief (FlyBot)', role: 'Project Chief', sessionId: 'bot-chief', - createdAt: 11, ownerId: 'owner-1', model: 'deepseek-chat', color: '#112233', - instructions: 'You are the Project Chief of FlyBot.', capabilities: ['ship-it'], - }, - 'bot-assistant': { - id: 'bot-assistant', name: 'Project Assistant (FlyBot)', role: 'Project Assistant', sessionId: 'bot-assistant', - createdAt: 12, ownerId: 'owner-1', model: 'deepseek-chat', color: '#445566', - instructions: 'You are the Project Assistant of FlyBot.', capabilities: ['ship-it', 'not-a-skill'], - }, - 'bot-dev': { - id: 'bot-dev', name: 'Dev', role: 'Dev', sessionId: 'bot-dev', - createdAt: 13, ownerId: 'owner-1', model: 'deepseek-reasoner', color: '#778899', - instructions: 'You implement issues.', capabilities: [], - }, - }, - rooms: { - r1: { id: 'r1', name: 'FlyBot dev', members: ['bot-chief', 'bot-dev'], mode: 'admin', admin: 'bot-chief', named: true, sessions: {}, log: [], seq: 0, seen: {}, projectId: 'project-1' }, - r2: { id: 'r2', name: 'Far Project room', members: ['bot-dev'], mode: 'admin', admin: 'bot-dev', named: true, sessions: {}, log: [], seq: 0, seen: {}, projectId: 'project-2' }, - }, - agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, grants: {}, - capabilities: { - items: { - 'ship-it': { name: 'ship-it', kind: 'skill', description: 'Ship things' }, - 'not-a-skill': { name: 'not-a-skill', kind: 'mcp', description: 'Not a skill' }, - }, - }, - projects: { - 'project-1': { - id: 'project-1', name: 'FlyBot', repository: 'ThinkFlowLab/FlyBot', defaultBranch: 'main', - ownerUserId: 'owner-1', createdAt: 1, updatedAt: 1, status: 'active', - chief: { botId: 'bot-chief', sessionId: 'bot-chief', createdAt: 1 }, - assistant: { botId: 'bot-assistant', sessionId: 'bot-assistant', createdAt: 1 }, - roles: { maintainers: ['bot-dev'], members: ['bot-gone'] }, - }, - 'project-2': { - id: 'project-2', name: 'Far', repository: null, defaultBranch: 'main', - ownerUserId: 'owner-1', createdAt: 2, updatedAt: 2, status: 'active', - }, - }, -}) - -const memory = (home, botId, head, topics = []) => { - const dir = join(home, 'memory', 'bots', botId) - mkdirSync(dir, { recursive: true }) - writeFileSync(join(dir, 'MEMORY.md'), head) - for (const topic of topics) writeFileSync(join(dir, `${topic}.md`), `- a fact about ${topic}\n`) -} - -async function start({ signedIn = false } = {}) { - const home = mkdtempSync(join(tmpdir(), 'ds-bot-project-team-')) - writeFileSync(join(home, 'state.json'), JSON.stringify(seed())) - memory(home, 'bot-chief', 'The Chief remembers the Project.\n', ['travel', 'work']) - memory(home, 'bot-assistant', 'The assistant remembers the board.\n', ['board']) - memory(home, 'bot-dev', 'Dev memory.\n') - const ctx = fakeContext() - let route - const register = ctx.connection.fetch.register - ctx.connection.fetch.register = (options) => { route = options; return register(options) } - const agents = new Map() - const agentOf = (sessionId) => { - if (!agents.has(sessionId)) agents.set(sessionId, { session: { id: sessionId }, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle() {}, inbox: { nextStep: [], nextTurn: [], remove() {} } }) - return agents.get(sessionId) - } - Object.assign(ctx, { - llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, - workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, - sessionController: { create: async () => ({ sessionId: 's1' }), resolveAgent: async id => ({ agent: agentOf(id) }), selectModel: async () => {} }, - sessionTitle: { rename: async () => {} }, - sessions: { flush: async () => {} }, - }) - const rt = createRuntime(ctx, { home }) - provideFlyBot(ctx, rt.flybot) - await rt.load() - const callAs = async (endpoint, payload, token) => (await route.fetch(new Request('http://localhost/api/bot', { - method: 'POST', - headers: { 'content-type': 'application/json', ...(token === undefined ? {} : { 'x-flybot-member-session': token }) }, - body: JSON.stringify({ endpoint, payload }), - }))).json() - if (signedIn) { - rt.authenticate = async token => (token === 'tok-owner' ? { memberId: 'owner-1' } : null) - rt.state.authSessions = { 's-owner': { memberId: 'owner-1', tokenHash: 'o' } } - } - const stop = async () => { - await ctx.dispose() - rmSync(home, { recursive: true, force: true, maxRetries: 5 }) - } - return { callAs, home, rt, stop } -} - -const ROW_KEYS = ['botId', 'color', 'createdAt', 'memory', 'model', 'name', 'persona', 'role', 'rooms', 'skills'] - -test('project-team answers persona, memory and skills for the Chief, the assistant and the pool', async (t) => { - const { callAs, stop } = await start() - t.after(stop) - const answer = await callAs('project-team', { id: 'project-1' }) - assert.equal(answer.ok, true, 'the endpoint is dispatched through the channel, not Unknown endpoint') - const rows = answer.value.rows - assert.equal(rows.length, 4, 'Chief, assistant and the two pool members, in that order') - assert.deepEqual(rows.slice(0, 3).map(row => row.botId), ['bot-chief', 'bot-assistant', 'bot-dev']) - assert.equal(rows[3], null, 'a pool member whose Bot is gone is a null row, not an error') - - const chief = rows[0] - assert.deepEqual(Object.keys(chief).sort(), ROW_KEYS, 'exactly the documented keys, no secret-bearing field') - assert.equal(chief.name, 'Project Chief (FlyBot)') - assert.equal(chief.role, 'Project Chief') - assert.equal(chief.model, 'deepseek-chat') - assert.equal(chief.color, '#112233') - assert.equal(chief.createdAt, 11) - assert.equal(chief.persona, 'You are the Project Chief of FlyBot.', 'persona is the Bot instructions') - assert.deepEqual(chief.memory, { head: 'The Chief remembers the Project.\n', topics: ['travel', 'work'] }) - assert.deepEqual(chief.skills, ['ship-it'], 'only attached skills, not every attached capability') - assert.deepEqual(chief.rooms, [{ id: 'r1', name: 'FlyBot dev' }], 'only this Project\'s rooms') - - assert.deepEqual(rows[1].memory, { head: 'The assistant remembers the board.\n', topics: ['board'] }) - assert.deepEqual(rows[1].skills, ['ship-it']) - assert.deepEqual(rows[2].rooms, [{ id: 'r1', name: 'FlyBot dev' }], 'the far Project\'s room is not listed') -}) - -test('project-team caps persona, memory head and topics', async (t) => { - const { callAs, home, rt, stop } = await start() - t.after(stop) - rt.state.bots['bot-chief'].instructions = LONG - memory(home, 'bot-chief', LONG, Array.from({ length: 70 }, (_, at) => `topic-${String(at).padStart(2, '0')}`)) - const rows = (await callAs('project-team', { id: 'project-1' })).value.rows - assert.equal(rows[0].persona.length, 4000, 'persona is capped at 4000 characters') - assert.equal(rows[0].memory.head.length, 4000, 'the memory head is capped at 4000 characters') - assert.equal(rows[0].memory.topics.length, 64, 'at most 64 topic names') -}) - -test('a deleted Chief is a null row while the rest of the team still answers', async (t) => { - const { callAs, rt, stop } = await start() - t.after(stop) - delete rt.state.bots['bot-chief'] - const rows = (await callAs('project-team', { id: 'project-1' })).value.rows - assert.equal(rows[0], null, 'the missing Chief is null') - assert.equal(rows[1].botId, 'bot-assistant', 'the assistant still answers') - assert.equal(rows[2].botId, 'bot-dev', 'the pool still answers') -}) - -test('project-team reads with the project-read class of caller, including a signed-in member', async (t) => { - const { callAs, stop } = await start({ signedIn: true }) - t.after(stop) - const answer = await callAs('project-team', { id: 'project-1' }, 'tok-owner') - assert.equal(answer.ok, true, 'the endpoint belongs beside project-read in authz, not behind the owner gate') - assert.equal(answer.value.rows[0].botId, 'bot-chief') - const unknown = await callAs('project-team', { id: 'project-none' }, 'tok-owner') - assert.deepEqual(unknown.value.rows, [], 'an unknown Project answers no rows instead of an error') -}) - -// #1979: a group name is text another home supplied, and its 200-unit bound must not end on the -// first half of a surrogate pair. -test('project-team cuts a room name from another home at 200 without splitting an emoji', async (t) => { - const { callAs, rt, stop } = await start() - t.after(stop) - const name = 'a'.repeat(199) + '😀' + 'tail' - rt.state.rooms.r1.name = name - const rows = (await callAs('project-team', { id: 'project-1' })).value.rows - const kept = rows[0].rooms.find(room => room.id === 'r1').name - assert.equal(kept.isWellFormed(), true, 'the group name is well formed, never a lone high surrogate') - assert.equal(kept, 'a'.repeat(199), 'the 200th unit was the first half of the pair, so the pair is dropped, not split') - assert.ok(name.startsWith(kept), 'a code-point prefix of the group name') - assert.ok(kept.length <= 200, 'the budget of 200 UTF-16 units is unchanged') -}) - -// #2005: `capped()` bounds the name, role, persona, model and color of a dossier row, and every -// one of those bounds must land on a code point. A bot name whose 200th unit is the first half of -// an emoji used to reach the panel as an unpaired surrogate. -test('#2005: project-team caps a bot name at a code point, never inside an emoji', async (t) => { - const { callAs, rt, stop } = await start() - t.after(stop) - rt.state.bots['bot-chief'].name = `${'a'.repeat(199)}😀` - const rows = (await callAs('project-team', { id: 'project-1' })).value.rows - assert.equal(rows[0].name.isWellFormed(), true, 'the name is well formed, never a lone high surrogate') - assert.equal(rows[0].name, 'a'.repeat(199), 'the 200th unit was the first half of the pair, so the pair is dropped whole') -}) - -// #2005: the dossier's memory head is bounded at 4,000 units, and that bound must land on a code -// point too; a memory file whose 4,000th unit is the first half of an emoji used to reach the -// panel as half a character. -test('#2005: project-team cuts the memory head at a code point, never inside an emoji', async (t) => { - const { callAs, home, stop } = await start() - t.after(stop) - memory(home, 'bot-chief', `${'a'.repeat(3999)}😀`) - const rows = (await callAs('project-team', { id: 'project-1' })).value.rows - assert.equal(rows[0].memory.head.isWellFormed(), true, 'the head is well formed, never a lone high surrogate') - assert.equal(rows[0].memory.head, 'a'.repeat(3999), 'the 4,000th unit was the first half of the pair, so the pair is dropped whole') -}) - -// #2005: the head is bounded twice on purpose — once where `memoryDigest` reads it, and once in the -// dossier row itself, which is where the module promises that every string it answers is capped. The -// second bound cannot be reached through the reader (a 4,000-unit head is already well formed), so it -// is pinned by a direct call that hands the module a 4,001-unit head itself. -test('#2005: project-team cuts a head handed to it directly at a code point', () => { - const head = `${'a'.repeat(3999)}😀` - const rt = { - state: { rooms: {} }, - readProject: () => ({ id: 'project-1', chief: { botId: 'bot-chief' }, assistant: { botId: '' }, maintainers: [], members: [] }), - botOf: id => (id === 'bot-chief' ? { id, name: 'Chief', role: 'Chief', instructions: '', model: 'deepseek-chat', color: '#112233', capabilities: [], createdAt: 1 } : null), - memoryDigest: () => ({ head, topics: [] }), - } - install(rt) - const row = rt.projectTeam('project-1').rows[0] - assert.equal(head.length, 4001, 'the caller handed the dossier 4,001 units') - assert.equal(row.memory.head.isWellFormed(), true, 'the head is well formed, never a lone high surrogate') - assert.equal(row.memory.head, 'a'.repeat(3999), 'the pair at the bound is dropped whole instead of keeping its first half') -}) diff --git a/core/test/projects.test.js b/core/test/projects.test.js index b26bcaa9a..cc05d0468 100644 --- a/core/test/projects.test.js +++ b/core/test/projects.test.js @@ -9,6 +9,7 @@ import { createHash } from 'node:crypto' import { test } from 'node:test' import { createRuntime, provideFlyBot } from '../index.js' import { fakeContext } from './fake-ctx.js' +import { installProjectDomain } from './project-domain.js' const hashToken = token => createHash('sha256').update(token).digest('hex') @@ -62,6 +63,7 @@ async function start(home = mkdtempSync(join(tmpdir(), 'ds-bot-projects-')), { s // `createRuntime` + `provideFlyBot` is exactly what `apply` does, with the runtime kept so a // test can remove one seam and prove the host still answers (#1859). const rt = createRuntime(ctx, { home }) + installProjectDomain(rt) provideFlyBot(ctx, rt.flybot) const call = async (endpoint, payload, token = 'tok-a') => (await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', diff --git a/core/test/readonly-window-reads.test.js b/core/test/readonly-window-reads.test.js index bbd891dcc..7f8cfc422 100644 --- a/core/test/readonly-window-reads.test.js +++ b/core/test/readonly-window-reads.test.js @@ -17,6 +17,7 @@ import { join } from 'node:path' import { test } from 'node:test' import { createRuntime, provideFlyBot } from '../index.js' import { fakeContext } from './fake-ctx.js' +import { installProjectDomain } from './project-domain.js' // The Project's own working folder is where its `decisions/*.md` rows come from; a read-only window // has to be able to read the folder rows and the file the board listed, so the seed names one. @@ -170,6 +171,7 @@ async function start() { sessions: { flush: async () => {} }, }) const rt = createRuntime(ctx, { home }) + installProjectDomain(rt) provideFlyBot(ctx, rt.flybot) await rt.load() const call = async (endpoint, payload = {}) => (await route.fetch(new Request('http://localhost/api/bot', { diff --git a/core/test/run-bot-tools.test.js b/core/test/run-bot-tools.test.js index 9c1d8a238..f5d0c2f31 100644 --- a/core/test/run-bot-tools.test.js +++ b/core/test/run-bot-tools.test.js @@ -5,6 +5,7 @@ import { join } from 'node:path' import { test } from 'node:test' import { setTimeout as delay } from 'node:timers/promises' import { apply } from '../index.js' +import { apply as applyProject } from '../../plugins/project/index.js' import { TEAM_TOOLS } from '../src/host/constants.js' // A run Bot (ephemeral) is not a team member (#1445). Its node's `tools` decides connectors and // the shell only, so the host refuses every tool whose effect reaches past its own Session — in the @@ -55,6 +56,9 @@ async function start(config = {}) { sessions: { flush: async () => {} }, }) apply(ctx, { home, ...config }) + // The Project domain is a peer plugin (#2684): a profile mounts it next to the core, and the + // team tools below include the two it registers. + applyProject({ flybot: ctx.flybot }) // A fresh Session per run Bot, the way workflows.js gets one. let sessions = 0 ctx.sessionController.create = async () => ({ sessionId: `run${++sessions}` }) diff --git a/core/test/task-contract.test.js b/core/test/task-contract.test.js index 366278335..d72a7dfb4 100644 --- a/core/test/task-contract.test.js +++ b/core/test/task-contract.test.js @@ -9,9 +9,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' import { createRuntime } from '../index.js' -import { install as installProjectBoard } from '../src/host/project-board.js' +import { install as installProjectBoard } from '../../plugins/project/src/project-board.js' import { formatRoomBoard } from '../src/host/room-context.js' import { fakeContext } from './fake-ctx.js' +import { installProjectDomain } from './project-domain.js' const team = () => ({ version: 2, @@ -66,6 +67,7 @@ async function boot(home) { sessions: { flush: async () => {} }, }) const rt = createRuntime(ctx, { home }) + installProjectDomain(rt) await rt.load() const track = (sessionId, args) => ctx.registeredTools.get('track_tasks').execute(args ?? {}, { agent: { id: sessionId } }) const idOf = (body, title) => { diff --git a/core/types/flybot.d.ts b/core/types/flybot.d.ts index 6c8bdda12..bfea43ee4 100644 --- a/core/types/flybot.d.ts +++ b/core/types/flybot.d.ts @@ -380,6 +380,12 @@ export interface ModelAsk { } export type ModelAnswer = { ok: true; text: string } | { ok: false; code: string } +/** What `flybot.projectHost()` answers: the core runtime and the shared pure helpers (#2684). */ +export interface ProjectHost { + runtime: Record + helpers: Record +} + export interface FlyBot { version: 1 /** Plugin this handle is scoped to; 'shared' for the unscoped service. */ @@ -390,6 +396,14 @@ export interface FlyBot { ready(): Promise /** True while another dsh holds the team: no polling, no writes. */ readOnly(): boolean + /** + * The Project domain host seam (#2684), for `plugins/project` only: the runtime the domain + * installs its seams on (`rt.projectBoard`, `rt.projectChief`, `rt.projectTeam`, + * `rt.projectAssistant`) and the pure helpers the two packages share. `undefined` on a host + * without it. Not a stable surface for third-party plugins, and absent is not an error: the + * core's Project endpoints keep their fallbacks. + */ + projectHost?(): ProjectHost /** * A Bot's own memory, for a plugin the host allows (#450 item 1). Off unless the host names the * plugin in config.flybot.memoryPlugins: `read` returns `[]` otherwise, and `append` resolves diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 3beba3f70..8af1dbcdd 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -53,10 +53,9 @@ 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/real-dir.js` | The one "what is this directory entry, here" check (#2055), shared by the readers that must not follow a folder link. `dirKind(path)` (async) and `dirKindSync(path)` (sync) answer `real` — the entry itself is a directory and not a symlink; `absent` — nothing at that path (`ENOENT`); or `other` — a symlink, a plain file, a non-directory parent, or any other read error (fail closed). Three states rather than a boolean because a writer has to tell "nothing there, create it" from "a link or a file, refuse"; a reader only tests `=== 'real'`. Both shells share one predicate (`lstat` + `isDirectory()` + `!isSymbolicLink()`), so the rule is written once; `#2056` and `#2057` consume it instead of a fourth hand-rolled copy. The leaf door is the same question asked of a file a writer appends to or truncates ([#2079](https://github.com/ThinkFlowLab/FlyBot/issues/2079)): `fileKind(path)` / `fileKindSync(path)` answer `real` (a plain file carrying one name), `absent` (nothing at that path) or `other` (a link, a hard link whose bytes are another name's, a directory, a non-directory parent, or a check that could not run). Since [#2159](https://github.com/ThinkFlowLab/FlyBot/issues/2159) the leaf door and the temporary name a write lands through share one predicate, so the two answers for the same file cannot disagree (`nlink === 1` in both); the five callers today are all writers — the readers ask `dirKind` — so a hard link refuses the write and never turns a snapshot's file into an unreadable one. Tests: `core/test/real-dir.test.js`. | -| `core/src/host/project-state.js` | The `project_state` tool (#1702, #1550 Stage 2.4): the Project State files of the Session working folder (`PROJECT.md`, `ROADMAP.md`, `docs/governance.md`, `decisions/*.md`, `LESSONS.md`), listed with byte size, sha256 content hash and last-modified time, or one file handed over whole up to 200,000 bytes. A caller compares the hash with the copy it already had, so a stale copy is visible instead of assumed. Answers `absent`, `too large` (with the size) or `not a repository` rather than an empty document, refuses any path outside that list, and reads nothing at install time, no prompt, system message or Session context is written by a call. Since #2037 a decision name has exactly one shape — one path segment under `decisions/`, ending in `.md` — and `isDecisionPath` is the one rule for it: the listing, this reader and the board take a name only when it matches, and both this reader and the board open a file through `describeFile`, which `lstat`s the name and refuses anything that is not a plain file, so a symlink answers `absent` — the shape a missing file already has — instead of the text it points at; since #2041 the bounded byte read is its own export, `readListedFile(dir, path)`, because the board's opened decision reads through it too, so the 200,000-byte bound and the `lstat` rule cannot drift between the two readers ([#2037](https://github.com/ThinkFlowLab/FlyBot/issues/2037), [#2041](https://github.com/ThinkFlowLab/FlyBot/issues/2041)). Since #2055 the `decisions` entry itself must be a directory this reader sees — `dirKind` in `real-dir.js` answers `real` — because `readdir` follows a folder link and the leaf `lstat` sees only the final component: a link at a real folder outside the Project, a link at a missing path and a plain file all read exactly like a folder with no `decisions`, so the listing contributes no row and a read of any `decisions/.md` answers `absent`, never the text it points at ([#2055](https://github.com/ThinkFlowLab/FlyBot/issues/2055)). Since #2461 a listed name that is there but cannot be read is a row of its own: the listing adds `unreadable: ()` after the readable rows (`` is the errno the read failed with, or `not a plain file` for a folder or other non-file entry), instead of catching every failure into the same silence that answers `no Project State files in this folder`; an absent name, and a symlink, which this reader already answers as absent (#2055), still add nothing, and the readable rows keep their order and their bytes ([#2461](https://github.com/ThinkFlowLab/FlyBot/issues/2461)). Tests: `core/test/project-state.test.js`, `core/test/project-state-unreadable.test.js`. | -| `core/src/host/project-assistant.js` | The Project Assistant (#1712, #1550 Stage 2.5): one hidden Bot and Session per Project, created by `project-create` through the roster and refusing an unknown or archived id, with exactly one 09:00 Asia/Shanghai task on that Session through the host's own `schedule-create` path and removed with the Project by `project-delete` — never a cron inside the plugin, and no backfill for a Project made before this slice. Memory lives in `state.projectMemory[id]` (`{decisions, questions, digest}`, behind `rt.projectMemoryOf` / `appendProjectMemory` / `setProjectDigest` and the `project-memory` / `project-memory-write` endpoints) keyed by Project id, so one Project cannot read another's entries. Since #1847 that bound is enforced twice, from one value: `MAX_ENTRY` (600, in `constants.js`) is checked by the assistant writer and again by `appendProjectMemory`, the one write door, and since #1886 both count code points (`codePoints` in `src/host/text.js`, one helper since #1895), not UTF-16 units, so a 600-character emoji note is stored rather than refused as 1,200 units; the endpoint refuses an empty entry (`A Project memory entry needs text`) and one over the cap (`A Project memory entry is at most 600 characters`) instead of storing a blank row or cutting the text, and a refusal leaves the table and `project.updatedAt` untouched; the panel quotes either sentence, in Chinese under a zh locale (`core/src/client/i18n.js`). `buildDigest` answers, in order, what merged since the previous digest, what closed, what is still in progress and on whom, and the next slice, using only board rows (`source`/`column`/`assignee`/`updatedAt`) and the Project State file list; a day with no change is one line, one day has one note and one `deliver()`, and the second build answers `skipped`. Since #2618 the header names the calendar day the digest is filed under — `dayIn(at)`, the same function behind `day` and the idempotency check, in the digest's own Asia/Shanghai zone — rather than a UTC date, so a digest built between 00:00 and 08:00 no longer prints the previous day, and one held by quiet hours and replayed later carries the right date. 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. Its own `schedule-create` sends the assistant's owner as the actor, because a host with a member signed in answers the create with the caller's rows only and the host reads that answer back for the row id. Since #1846 the digest reads the Project's own board, not one member's page: `rt.projectBoard(id, null, { internal: true })` is the one opt-in that skips the per-reader `taskVisible` rule, and the channel builds its options from the zone alone, so no request body can ask for it. Tests: `core/test/project-assistant.test.js`, `core/test/project-digest-authz.test.js`. | -| `core/src/host/project-chief.js` | The Project Chief (#1859): every Project gets its own Chief, created with the Project by `project-create` — never the user's main Chief acting part-time. `rt.projectChief` has the same shape as `rt.projectAssistant` (`create(projectId)`, `chiefOf(projectId)`, `remove(projectId)`), the record carries `chief: { botId, sessionId, createdAt }` beside `assistant`, and the spawned Bot is named `Project Chief ()`, bound with `bindProject` so it carries `projectId`, and given instructions that name that one Project and forbid answering for any other (another Project's name is never quoted). A host without the seam logs once and answers `undefined`, so `project-create` still creates the Project with `chief: { error }`; `project-delete` retires the Chief through `deleteBot` and clears its binding. Tests: `core/test/projects.test.js`. | -| `core/src/host/project-team.js` | The Project's team dossier (#1864), read-only. `rt.projectTeam(projectId)` answers `{ rows }` in the board's order — the Project's Chief, its assistant, then `roles.maintainers` / `roles.members` — with each row `{ botId, name, role, persona, model, color, createdAt, memory: { head, topics }, skills, rooms }`. `persona` is the Bot's `instructions`; `memory.head` is its own `MEMORY.md` (read through `rt.memoryDigest` in `memory.js`, which also supplies the topic names and never a topic body); `skills` is the attached capabilities the library records as `skill` (a pack contributes its skill children); `rooms` is only the rooms this Project owns. Every string and list is capped (`DOSSIER_LIMITS`: persona and memory head 4000, 64 topics, 64 skills, 32 rooms), a deleted Bot is a `null` row instead of an error for the whole answer, and an unknown Project answers `{ rows: [] }`. The endpoint sits beside `project-read` in `authz.js`'s `TASK_ENDPOINTS`, per the owner decision that persona and memory are shared by the Project's members. Tests: `core/test/project-team.test.js`. | +| `core/src/host/project-host.js` | The Project domain's host seam (#2684): `projectHost(rt)` answers `{ runtime, helpers }`, which is what `plugins/project` asks for in its own `apply()`. The domain — Project State, the board, the Project's own Chief, the team dossier, the metrics and the assistant — moved out of `core/src/host/project-*.js` into [project](project.md); the core must not import a peer plugin, so the plugin takes the runtime it installs its seams on (`rt.projectBoard`, `rt.projectChief`, `rt.projectTeam`, `rt.projectAssistant`) and the pure helpers the two packages share. `HELPER_NAMES` is the list `test/host.test.js` holds the plugin's own list against, and a core with no plugin installed never calls it: the store's Project endpoints then keep the fallbacks they already have (a Project is created without a Chief or an assistant, the board and the dossier answer empty). | +| `core/src/host/work-dir.js` | The Session's working folder (#2684): the cwd its Agent runs in, else the folder the Bot or room is set to work in, else the team workspace. It lived in `project-state.js` while that reader was its only caller; `meetings.js` needs the same answer (the folder a decision is archived in) and the Project domain moved to `plugins/project`, so it is its own module and the plugin reads it back through the host seam. | +| `core/src/host/day-key.js` | The natural day a timestamp falls in, in the caller's time zone (#2684): `en-CA` prints ISO order, `Intl.DateTimeFormat` refuses an unknown zone (a caller that takes one from outside resolves it first), and a non-finite timestamp answers `''`. It was exported by `project-metrics.js`; the core's turn budget needs the same key and the metrics moved to `plugins/project`, so it lives here and the plugin reads it back through the host seam. | | `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`. | @@ -691,7 +690,7 @@ An unreadable store still answers `counts` of `null`. Tests: `core/test/project- The team dossier (#1864) is the same class of read: `project-team` takes `{ id }`, is dispatched by `channel.js` beside `project-board`, and sits in `authz.js`'s `TASK_ENDPOINTS` next to `project-read` — the owner decided that a Project's persona and memory are shared by its members. -`core/src/host/project-team.js` builds the rows from the Project record and the Bot store, reads the +`plugins/project/src/project-team.js` builds the rows from the Project record and the Bot store, reads the memory head and topic names through `rt.memoryDigest`, and writes nothing; a Bot that was deleted is a `null` row, so one dead Chief does not take the rest of the answer down. Tests: `core/test/project-team.test.js`. @@ -717,7 +716,7 @@ default `ordinary`, an unknown value refused with `meeting/invalid`) and `recipi at most 32). `severe` is the one level the host refuses to advance on its own: `advanceRefusal(meeting)` in `core/src/host/meetings.js` answers the reason (how many participants dissented, else that the Owner confirms it) and nothing for `ordinary`/`major`. The board projects that record through -`decisionCardOf` in `core/src/host/project-board.js`, so every pending and decided row carries `level`, +`decisionCardOf` in `plugins/project/src/project-board.js`, so every pending and decided row carries `level`, `recipients` and `advance: { allowed, reason }`; a record written before the slice reads as an ordinary card with no recipients. The same read separates the three identities the record already holds: a row carries `holder` (the claim, never the assignee label) and `speaksFor` (the user a card is written for), @@ -741,7 +740,7 @@ Projects carry three roles since #1828, and only two of them are stored. `roles: members }` on the record holds the two assignable ranks; the Owner is derived every time from `ownerUserIds` (plus `ownerUserId`), so a record written before this slice reads owner-only with no migration and the stored lists can never disagree with the owner. `rolesOf` / `roleOf` / -`roleChoices` / `changeRole` in `project-board.js` are the whole policy: nobody promotes themselves, +`roleChoices` / `changeRole` in `plugins/project/src/project-board.js` are the whole policy: nobody promotes themselves, only an Owner re-roles someone else, the Owner rank is never assignable here, and the last owner cannot be demoted. `changeRole` answers `{ ok: true, roles }` or a machine-readable reason (`not-owner`, `cannot-self-promote`, `last-owner`, `unknown-role`, `owner-not-assignable`) and the @@ -877,7 +876,7 @@ the host side only, so the tier stays T2 and `core/src/shared/` is untouched. ## Project metrics (#1829) -`core/src/host/project-metrics.js` answers the Project board's seven numbers: PRs opened and merged today, issues opened and closed today, and the seven-day medians of PR merge, issue close and task completion durations. The four day counts compare `created_at` / `merged_at` / `closed_at` against the reader's own time zone (`Intl.DateTimeFormat`), not the host clock — the zone arrives in the request body, so `resolveTimeZone` validates it once and an unknown name answers the named `UTC` instead of throwing out of the whole board read, rows, queues and decisions with it (#1849) — and the local task half reads `createdAt` and the last terminal `actors[].at` (falling back to `completedAt`, then `updatedAt`) and counts only `accepted` cards. The running table and the task-done median read one board's own cards, not the deployment's task store: `project-board.js` hands `readMetrics` the same `mine` list its rows were built from (#1848), so a card of another Project, or one this reader is refused, appears in neither, and every branch (no credential, no fetch, a failed read, a cache hit, a success) reproduces both from that same list. The five-minute cache is shared by every Project on the repository, so an entry holds only the repository-level numbers (`ok`, the time zone, the stamp and the six GitHub counts and medians); `running` and `taskDoneMedianMs` are re-projected from the caller's cards on a hit exactly as on a miss (#1880). The numbers ride on the existing `project-board` endpoint (`store.js` carries the `metrics` section beside the rows), which passes the host secret store's `GITHUB_TOKEN` and the reader's time zone from the request, requires the Project, and answers `ok: false` with `null` values when the credential is missing or a read fails — the client paints `—`, never `0`. One five-minute in-process cache keys on repository, time zone and window, so a hit spends no source call; a failed read is not cached. `runningOf` projects a claimed card into the running table's row (who, claim age, source, last activity). Since #2650 both GitHub lists are read page by page through the response's `Link: rel="next"` header instead of the first 100 rows (and `/pulls` no longer sends a `since` the endpoint ignores), with a 30-page cap that keeps the rows read and answers `truncated: true` rather than claiming a complete window. +`plugins/project/src/project-metrics.js` answers the Project board's seven numbers: PRs opened and merged today, issues opened and closed today, and the seven-day medians of PR merge, issue close and task completion durations. The four day counts compare `created_at` / `merged_at` / `closed_at` against the reader's own time zone (`Intl.DateTimeFormat`), not the host clock — the zone arrives in the request body, so `resolveTimeZone` validates it once and an unknown name answers the named `UTC` instead of throwing out of the whole board read, rows, queues and decisions with it (#1849) — and the local task half reads `createdAt` and the last terminal `actors[].at` (falling back to `completedAt`, then `updatedAt`) and counts only `accepted` cards. The running table and the task-done median read one board's own cards, not the deployment's task store: `project-board.js` hands `readMetrics` the same `mine` list its rows were built from (#1848), so a card of another Project, or one this reader is refused, appears in neither, and every branch (no credential, no fetch, a failed read, a cache hit, a success) reproduces both from that same list. The five-minute cache is shared by every Project on the repository, so an entry holds only the repository-level numbers (`ok`, the time zone, the stamp and the six GitHub counts and medians); `running` and `taskDoneMedianMs` are re-projected from the caller's cards on a hit exactly as on a miss (#1880). The numbers ride on the existing `project-board` endpoint (`store.js` carries the `metrics` section beside the rows), which passes the host secret store's `GITHUB_TOKEN` and the reader's time zone from the request, requires the Project, and answers `ok: false` with `null` values when the credential is missing or a read fails — the client paints `—`, never `0`. One five-minute in-process cache keys on repository, time zone and window, so a hit spends no source call; a failed read is not cached. `runningOf` projects a claimed card into the running table's row (who, claim age, source, last activity). Since #2650 both GitHub lists are read page by page through the response's `Link: rel="next"` header instead of the first 100 rows (and `/pulls` no longer sends a `since` the endpoint ignores), with a 30-page cap that keeps the rows read and answers `truncated: true` rather than claiming a complete window. ## The Project run seam (#1856) diff --git a/docs/subsystems/install.md b/docs/subsystems/install.md index 8871e39c6..145843026 100644 --- a/docs/subsystems/install.md +++ b/docs/subsystems/install.md @@ -35,10 +35,10 @@ Tooling for setup checks, the architecture-docs gate and the container image (#3 | `core/test/check-arch-docs.test.js` | Tests for the gate. | | `core/test/install-doctor.test.js` | Install doctor tests. | | `plugins/README.md` | Plugin list. | -| `core/scripts/install-doctor.mjs` | Install doctor: Node floor, the packages (`core`, `plugins/social`, `plugins/tree`, `plugins/workflow`, `plugins/timeline`) and the install commands (core, social, tree, workflow; `@flybot/timeline` is opt-in and not in the default commands, #281). | +| `core/scripts/install-doctor.mjs` | Install doctor: Node floor, the packages (`core`, `plugins/social`, `plugins/tree`, `plugins/workflow`, `plugins/timeline`, `plugins/project`) and the install commands (core, social, tree, workflow, project; `@flybot/timeline` is opt-in and not in the default commands, #281). | | `core/scripts/local-ci.sh` | Local run of CI's steps, including each plugin's suite. | | `core/scripts/replay.mjs` | Dev-only state-journal replay (#511): rebuilds `state.json` up to an entry from `/journal/state.jsonl`; no network or process calls, output is a new 0600 file or stdout, a bad journal is refused whole. See [core-host](core-host.md). | -| `Dockerfile` | Packs `core` and the plugins (social, tree, workflow, timeline) with `check-pack.mjs` (the release tarballs), then a runtime with DSH (pinned like the README), pnpm and tini, as user `node`, one volume `/data`. | +| `Dockerfile` | Packs `core` and the plugins (social, tree, workflow, timeline, project) with `check-pack.mjs` (the release tarballs), then a runtime with DSH (pinned like the README), pnpm and tini, as user `node`, one volume `/data`. | | `compose.yaml` | One instance: host networking (`network_mode: host`), so the server's bind on the host's `127.0.0.1` is the same loopback-only exposure a published port gave before; volume `data`, `FLYBOT_PORT` and `DEEPSEEK_API_KEY` passed through only when set. | | `docker/entrypoint.mjs`, `docker/profile.mjs` | Each start links the image's packages into `/data/profiles/web` (other plugins and `cordis.patch.yml` kept), `pnpm install`, then `dsh web` in `/data/workspace` on `FLYBOT_PORT` (default 3080). | | `docker/web.patch.yml` | Container-only overlay: `webserver.host: 127.0.0.1`. DSH 0.2.1 refuses a wildcard host — `host "0.0.0.0" is an unspecified (wildcard) address, which is not supported` — so the container shares the host network instead; the CLI refuses `--host 0.0.0.0` for the same reason. | @@ -52,7 +52,7 @@ Tooling for setup checks, the architecture-docs gate and the container image (#3 1. Owned-file changes require a matching subsystem doc in the same PR (or a `Docs-not-needed:` trailer). 2. Empty diff vs base passes. -3. A new peer plugin is added to the install doctor (and its test), `check-pack.mjs`, the CI plugin matrix, `local-ci.sh` and `plugins/README.md` in the same PR. `@flybot/workflow` is one of the default commands. `@flybot/timeline` stays opt-in. +3. A new peer plugin is added to the install doctor (and its test), `check-pack.mjs`, the CI plugin matrix, `local-ci.sh` and `plugins/README.md` in the same PR. `@flybot/workflow` and `@flybot/project` are default commands. `@flybot/timeline` stays opt-in. 4. The container image holds no key or token, runs as a normal user, and `compose.yaml` uses the host's network (`network_mode: host`) so the web server's own `127.0.0.1` bind is the only exposure — no port is published. CI (`.github/workflows/docker.yml`) builds and start-checks it; nothing is pushed to a registry (owner decision on #352). ## Tests diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index 668019330..85055efdb 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -25,6 +25,8 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots - `core/types/flybot.d.ts` — public types. - Flybot unit tests (`flybot.test.js`, `flybot-client.test.js`, `flybot-feed.test.js`); `flybot-client.test.js` also pins the one name a mention wears on every surface — menu insert, bubble chip, sidebar preview, at-source row — and that two friends' same-named Bots stay apart while every alias still routes to its own handle (#967), the sidebar footer button (Connect apps opens Connectors), the account menu's Plugins row, the account menu's labels (Connect apps, Scheduled tasks opening Settings → Scheduled tasks, Settings, and the only classic Agent mode switch; #392-#394), and a face-room row that keeps its full title on hover (#304) while the number-first title wraps (#1102). +- The Project domain's host seam (#2684): `flybot.projectHost()` answers `{ runtime, helpers }` for `plugins/project`, whose code ships as its own package and therefore cannot import `core/src/*`. The core never imports that package; a core without it installed never calls the seam and the store keeps its Project fallbacks. The helpers it hands over are the pure functions the two packages share (`cut`, `codePoints`, `dirKind`, `defineTool`, `output`, `columnOf`, `priorityOf`, `advanceRefusal`, `workDirFor`, `dayKey`, `MAX_ENTRY`, `ASSISTANT_ROLE`, the quiet-hours reads), listed in `core/src/host/project-host.js` and read once in the plugin's own `apply()`. The concrete domain is [project](project.md). + ## Does not own - Generic host/client code under `core/src/` (→ [ds-bot-core](ds-bot-core.md)). diff --git a/docs/subsystems/project.md b/docs/subsystems/project.md new file mode 100644 index 000000000..70953dcd4 --- /dev/null +++ b/docs/subsystems/project.md @@ -0,0 +1,92 @@ +--- +subsystem: project +owned_paths: + - plugins/project/ +--- + +# project + +The Project domain as a peer plugin: `plugins/project` (`@flybot/project`). The core keeps the +runtime and the Project *record* (`state.projects`, the `bot.projectId` / `room.projectId` +bindings, `state.projectRuns`, the memory table and the `project-*` endpoints in `store.js`); this +package owns everything that reads or acts on that record. + +## Owns + +- `plugins/project/index.js` — the Cordis plugin (`name = 'flybot-project'`, `inject = ['flybot']`). + `apply()` returns before registering anything when `flybot.pluginEnabled('project')` is `false`, + and otherwise binds the host helpers and installs the domain on the runtime the host hands over. +- `plugins/project/src/` — the domain: Project State (`project-state.js`), the board + (`project-board.js`), the Project's own Chief (`project-chief.js`), the team dossier + (`project-team.js`), the metrics (`project-metrics.js`) and the assistant (`project-assistant.js`), + plus `host.js` (the helper binding) and `install.js` (which seam goes on the runtime, in the + order the core used to install these modules). +- `plugins/project/test/` — the domain's tests, moved here from `core/test/project-*.test.js` when + the code moved, plus `host.test.js` (the plugin's own apply(), the switch, and the seam). +- The two Project tools: `project_state` (#1702) and `project_digest` (#1712). They are + registered by this plugin and nowhere else, so the plugin's switch turns them off with the rest. + +## Does not own + +- The Project record, its bindings and the `project-*` endpoints → [core-host](core-host.md) + (`store.js`). +- The `project_state` / `project_digest` permission entries and endpoint dispatch → `authz.js`, + `channel.js` ([core-host](core-host.md)). +- The browser half. The board overlay, the Project header row, the projects nav and the settings + panels are still `core/src/client/project-board.js` and its callers: they read the core's own + client context (the roster feed, the overlay stack, the api client) and are bundled by + `core/scripts/build-client.mjs`, which cannot import a plugin bundle. `plugins/project/client.js` + is therefore an empty module in this slice; turning those surfaces into flybot slots is the + follow-up that moves `src/client/` here. +- `core/src/host/hooks.js` — the plugin API surface the seam hangs off → [plugin-runtime](plugin-runtime.md). + +## Key files + +| File | Role | +| --- | --- | +| `plugins/project/index.js` | Plugin entry: the owner switch (`flybot.pluginEnabled('project')`), `bindHost(seam.helpers)`, `installProjectDomain(seam.runtime)`. A core with no `flybot.projectHost()` is told once and nothing is registered; the host's own fallbacks answer instead. | +| `plugins/project/src/install.js` | Puts the seams on the runtime in the core's old `MODULES` order — state reader, Chief, board, dossier, assistant — and marks the runtime so a second `apply()` is a no-op (a second `ctx.tools.register` of the same name would fail the whole plugin). | +| `plugins/project/src/host.js` | The helpers this package takes from its host, read at call time so a module-scope arrow (`oneLine`, `capped`) sees the binding: `cut`, `codePoints`, `dirKind`, `defineTool`, `output`, `columnOf`, `priorityOf`, `advanceRefusal`, `workDirFor`, `dayKey`, `MAX_ENTRY`, `ASSISTANT_ROLE` and the quiet-hours reads. `REQUIRED` is the whole list; a host that binds a partial bag fails at `apply()` naming the missing helper. | +| `plugins/project/src/project-state.js` | The `project_state` tool: the Project State files of the Session working folder (`PROJECT.md`, `ROADMAP.md`, `docs/governance.md`, `decisions/*.md`, `LESSONS.md`), listed with byte size, a sha256 content hash and mtime, or one file whole up to 200,000 bytes. Answers `absent`, `too large`, `not a repository` or `unreadable: ()` with the reason instead of an empty document; a decision name is one segment under `decisions/` ending in `.md` (`isDecisionPath`), read through `lstat` so a symlink answers absent, and `decisions/` itself must be a real directory (#2055, #2037, #2041, #2461). | +| `plugins/project/src/project-board.js` | The board read model (#1696): `state.tasks`, `state.meetings`, the `decisions/*.md` files and the Project bindings, with the workflow run refs read through the core's provider seam (`rt.onProjectProvider`). Read-only by construction; it writes nothing. Carries the role policy (`rolesOf`, `roleOf`, `roleChoices`, `changeRole` — one Owner, nobody promotes themselves, a Project always keeps its Owner), the queue/lease/blocked groups and the decision card (`decisionCardOf`, `advanceRefusal`). | +| `plugins/project/src/project-chief.js` | The Project Chief (#1859): one hidden Bot per Project, named `Project Chief ()`, bound with `bindProject` and given instructions that name that one Project and forbid answering for any other; retired through the host's own `deleteBot` with the Project. `rt.projectChief` is `{ create, chiefOf, remove }`. | +| `plugins/project/src/project-team.js` | The team dossier (#1864), read-only: `rt.projectTeam(projectId)` answers `{ rows }` in the board's order — the Chief, the assistant, then `roles.maintainers` / `roles.members` — with persona (the Bot's instructions), the head and topic names of its own memory, its skills and only this Project's rooms. Every string and list is capped and a deleted Bot is a `null` row, never an error for the whole answer. | +| `plugins/project/src/project-metrics.js` | The board's seven numbers (#1829) read from GitHub REST and the local task store, with a five-minute cache and one honest failure answer: a missing credential or an unreadable source is `unavailable` and every value reads `null`, never `0`. The lists are followed page by page under a cap (#2650), a foreign host is never followed with the token, and the day boundary is the caller's zone (`resolveTimeZone`) rather than the host's UTC clock. Its day key is `core/src/host/day-key.js` since #2684. | +| `plugins/project/src/project-assistant.js` | The Project Assistant (#1712): one hidden Bot and Session per Project with exactly one 09:00 Asia/Shanghai `project_digest` task on that Session through the host's schedule path (never a cron in the plugin), per-owner memory in `state.projectMemory` behind the store's write door (600 code points), and a digest built only from board rows and Project State (what merged, what closed, what is in progress and on whom, the next slice; a day with no change is one line, a second build answers `skipped`). Its writes carry `Written-by: Project Assistant ()` / `Posted-for: `. | +| `plugins/project/client.js` | Empty in this slice: the browser half is still in the core, see **Does not own**. | + +## Entry points + +- `apply(ctx)` when a profile mounts `@flybot/project` (the owner switch is read there, so a flip + applies at the next start). +- The seams it leaves on the runtime: `rt.projectBoard`, `rt.projectChief`, `rt.projectTeam`, + `rt.projectAssistant` — the same names `store.js`, `meetings.js` and `channel.js` already call. + +## Invariants + +1. **Behavior parity.** Same input, same output as the core modules this moved out of: the same + read shapes, the same refusals, the same strings. The tests moved with the code, so a change in + behavior fails an existing case rather than passing in a new file. +2. **The switch is all-or-nothing.** `pluginEnabled('project') === false` registers no tool, no + seam and no background work; a core with the plugin disabled keeps the store's documented + fallbacks (a Project is created without a Chief or an assistant; the board and the dossier + answer empty). The plugin reads the switch at `apply()`, like tree and social, so a flip lands + at the next start. +3. **No copy of the core.** The plugin does not import `core/src/*`; the helpers it shares with the + core come through `flybot.projectHost()` and are read at call time. `test/host.test.js` holds + `src/host.js`'s `REQUIRED` list and the core's `HELPER_NAMES` to the same names. +4. **One install.** The domain installs once per runtime (`install.js` marks it), so a host that + applies the plugin twice — or a test that applies it after the core — registers one set of + tools. +5. **No timer.** The only background beat is the assistant's own DSH schedule; the plugin polls + nothing. + +## Tests + +`cd plugins/project && npm test` (the suite applies the plugin to a real core runtime through +`ds-bot`, the way a profile mounts both). The core's own suite keeps the integration cases that +read a Project through the store, the channel and the board (`core/test/projects.test.js`, +`project-digest-authz`, `project-role-authz`, `project-owner-gate`, `project-run-seam`, +`project-board-panels`, `project-board-view`, `project-settings-page`, `projects-nav`, +`project-board-readonly`, `project-board-entry`, `project-board-levels`, `readonly-window-reads`); +they apply the domain with `core/test/project-domain.js`. diff --git a/docs/testing-layers.json b/docs/testing-layers.json index 38008d238..beabaac0b 100644 --- a/docs/testing-layers.json +++ b/docs/testing-layers.json @@ -55,6 +55,15 @@ "command": "npm --prefix plugins/tree test", "note": "Peer plugin; its CI job also runs the plugin lint and fails a skipped test." }, + { + "id": "plugins/project", + "layer": "L1", + "paths": [ + "plugins/project/test/**/*.test.js" + ], + "command": "npm --prefix plugins/project test", + "note": "Peer plugin; its CI job also runs the plugin lint and fails a skipped test." + }, { "id": "plugins/timeline", "layer": "L1", diff --git a/docs/testing-layers.md b/docs/testing-layers.md index 3fe6f493b..a1d2ae1fd 100644 --- a/docs/testing-layers.md +++ b/docs/testing-layers.md @@ -80,6 +80,7 @@ The tables below are generated: edit `docs/testing-layers.json`, then run | `core` | L1 | `core/test/**/*.test.js` | `npm --prefix core test` | | `plugins/social` | L1 | `plugins/social/test/**/*.test.js` | `npm --prefix plugins/social test` | | `plugins/tree` | L1 | `plugins/tree/test/**/*.test.js` | `npm --prefix plugins/tree test` | +| `plugins/project` | L1 | `plugins/project/test/**/*.test.js` | `npm --prefix plugins/project test` | | `plugins/timeline` | L1 | `plugins/timeline/test/**/*.test.js` | `npm --prefix plugins/timeline test` | | `plugins/workflow` | L1 | `plugins/workflow/test/**/*.test.js` | `npm --prefix plugins/workflow test` | | `scripts` | L1 | `.github/scripts/*.test.mjs`
`policy/*.test.mjs` | `node --test ".github/scripts/*.test.mjs" "policy/*.test.mjs"` | diff --git a/package.json b/package.json index 4b4e29b8f..ef76d9eeb 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "node": ">=22" }, "scripts": { - "verify": "npm --prefix core run lint && node scripts/lint-plugins.mjs && node core/scripts/build-client.mjs --check && node scripts/ds-bot-patches.mjs check && node scripts/ds-bot-patches.mjs canonical && npm --prefix core test && npm --prefix plugins/social test && npm --prefix plugins/tree test && npm --prefix plugins/timeline test && npm --prefix plugins/workflow test && node --test \".github/scripts/*.test.mjs\" \"policy/*.test.mjs\" && npm run lint:interop && npm run test:interop && npm run lint:live-e2e && npm run test:live-e2e", + "verify": "npm --prefix core run lint && node scripts/lint-plugins.mjs && node core/scripts/build-client.mjs --check && node scripts/ds-bot-patches.mjs check && node scripts/ds-bot-patches.mjs canonical && npm --prefix core test && npm --prefix plugins/social test && npm --prefix plugins/tree test && npm --prefix plugins/timeline test && npm --prefix plugins/workflow test && npm --prefix plugins/project test && node --test \".github/scripts/*.test.mjs\" \"policy/*.test.mjs\" && npm run lint:interop && npm run test:interop && npm run lint:live-e2e && npm run test:live-e2e", "lint:interop": "node scripts/lint-interop.mjs", "test:interop": "node --import ./dev/interop/no-network.mjs --test \"dev/interop/test/*.test.mjs\"", "demo:interop": "node --import ./dev/interop/no-network.mjs dev/interop/demo.mjs", diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index f1bb2e0bf..a1b38740a 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -9512,7 +9512,13 @@ diff --git a/src/shared/mentions.js b/src/shared/mentions.js diff --git a/test/agents.test.js b/test/agents.test.js --- a/test/agents.test.js +++ b/test/agents.test.js -@@ -9,6 +9,12 @@ import { fakeContext } from './fake-ctx.js' +@@ -4,11 +4,18 @@ import { tmpdir } from 'node:os' + import { join } from 'node:path' + import { test } from 'node:test' + import { apply } from '../index.js' ++import { apply as applyProject } from '../../plugins/project/index.js' + import { TEAM_TOOLS } from '../src/host/constants.js' + import { fakeContext } from './fake-ctx.js' const flush = async () => { for (let index = 0; index < 8; index += 1) await Promise.resolve() } @@ -9560,6 +9566,16 @@ diff --git a/test/agents.test.js b/test/agents.test.js archived.push(sessionId) }, }, +@@ -65,6 +65,9 @@ function start(team, { config = {} } = {}) { + return register(options) + } + apply(ctx, { home, ...config }) ++ // The Project domain is a peer plugin (#2684): a profile mounts it next to the core, and the ++ // team tools below include the two it registers. ++ applyProject({ flybot: ctx.flybot }) + const call = async (endpoint, payload) => { + const response = await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', body: JSON.stringify({ endpoint, payload }) })) + return response.json() @@ -74,7 +74,7 @@ function start(team, { config = {} } = {}) { await ctx.dispose() rmSync(home, { recursive: true, force: true, maxRetries: 5 }) @@ -12850,7 +12866,15 @@ diff --git a/test/images.test.js b/test/images.test.js diff --git a/test/install.test.js b/test/install.test.js --- a/test/install.test.js +++ b/test/install.test.js -@@ -34,9 +34,15 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { +@@ -29,14 +29,23 @@ function start(state, { config = {}, services } = {}) { + } + + // Listeners run, and tools are listed, in the order they are registered. ++// The Project tools (`project_state`, `project_digest`) are registered by @flybot/project's own ++// apply() since #2684, so they are not in the core's list; plugins/project/test/host.test.js holds ++// that half. + test('apply registers listeners, tools, and effects in a fixed order', (t) => { + const { ctx, stop, applyResult } = start() t.after(stop) assert.equal(applyResult, undefined) assert.deepEqual(ctx.calls, [ @@ -12874,12 +12898,11 @@ diff --git a/test/install.test.js b/test/install.test.js 'tool list_models', 'tool create_group', 'tool update_group', -@@ -60,11 +60,18 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { +@@ -60,11 +60,17 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { 'tool delete_capability', 'tool attach_capability', 'tool detach_capability', + 'tool track_tasks', -+ 'tool project_state', + 'tool meeting', 'effect ds-bot: chat index', + 'effect ds-bot: external room participants', @@ -12907,7 +12930,7 @@ diff --git a/test/install.test.js b/test/install.test.js 'on llm/stream', 'on agent/status', 'tool request_secret', -@@ -91,12 +91,24 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { +@@ -91,12 +91,23 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { 'on llm/stream', 'effect ds-bot: load secrets', 'effect ds-bot: connectors', @@ -12918,7 +12941,6 @@ diff --git a/test/install.test.js b/test/install.test.js 'effect ds-bot: capabilities', + 'effect ds-bot: missed scheduled runs', + 'tool bot_schedules', -+ 'tool project_digest', + 'effect ds-bot: automation tasks default', 'effect ds-bot: usage ledger', 'on llm/stream', diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index 80639b44b..24e2b2aec 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -3,7 +3,7 @@ FlyBot patch 0002-flybot-api-host on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Gen diff --git a/index.js b/index.js --- a/index.js +++ b/index.js -@@ -4,9 +4,19 @@ +@@ -4,9 +4,18 @@ import { join } from 'node:path' import * as diagnostics from './src/host/diagnostics.js' import * as store from './src/host/store.js' @@ -13,7 +13,6 @@ diff --git a/index.js b/index.js +import * as roomMembership from './src/host/room-membership.js' +import * as reach from './src/host/reach.js' +import * as tasks from './src/host/tasks.js' -+import * as projectBoard from './src/host/project-board.js' +import * as taskDrive from './src/host/task-drive.js' import * as models from './src/host/models.js' import * as permissions from './src/host/permissions.js' @@ -23,12 +22,11 @@ diff --git a/index.js b/index.js import * as delivery from './src/host/delivery.js' import * as roster from './src/host/roster.js' import * as folders from './src/host/folders.js' -@@ -14,22 +14,35 @@ import * as agents from './src/host/agents.js' +@@ -14,22 +14,31 @@ import * as agents from './src/host/agents.js' import * as prompt from './src/host/prompt.js' import * as timeContext from './src/host/time-context.js' import * as tools from './src/host/tools.js' +import * as decisions from './src/host/decisions.js' -+import * as projectState from './src/host/project-state.js' +import * as meetings from './src/host/meetings.js' import * as chatIndex from './src/host/chat-index.js' import * as groups from './src/host/groups.js' @@ -44,9 +42,6 @@ diff --git a/index.js b/index.js +import * as connectorAccess from './src/host/connector-access.js' import * as capabilities from './src/host/capabilities.js' import * as schedules from './src/host/schedules.js' -+import * as projectAssistant from './src/host/project-assistant.js' -+import * as projectChief from './src/host/project-chief.js' -+import * as projectTeam from './src/host/project-team.js' +import * as automationDefault from './src/host/automation-default.js' import * as usage from './src/host/usage.js' import * as update from './src/host/update.js' @@ -64,7 +59,7 @@ diff --git a/index.js b/index.js // install. Listeners run and tools are listed in registration order, so this order holds: // usage meters model requests after every listener that rewrites them. -const MODULES = [diagnostics, store, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, approvals, resume, memory, parts, images, secrets, connectors, capabilities, schedules, browser, usage, update, dshUpdate, channel] -+const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, projectState, meetings, chatIndex, groups, roomMembership, reach, tasks, projectBoard, taskDrive, approvals, resume, memory, parts, turnBudget, images, secrets, connectors, connectorAccess, capabilities, schedules, projectAssistant, projectChief, projectTeam, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, health, channel] ++const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, meetings, chatIndex, groups, roomMembership, reach, tasks, taskDrive, approvals, resume, memory, parts, turnBudget, images, secrets, connectors, connectorAccess, capabilities, schedules, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, health, channel] export function apply(ctx, config = {}) { - createRuntime(ctx, config) @@ -95,7 +90,7 @@ diff --git a/index.js b/index.js diff --git a/src/host/constants.js b/src/host/constants.js --- a/src/host/constants.js +++ b/src/host/constants.js -@@ -21,6 +21,12 @@ export const OCR_CACHE_MAX = 500 +@@ -21,6 +21,16 @@ export const OCR_CACHE_MAX = 500 export const MAX_AVATAR_IMAGE = 160_000 export const ROUTE = '/api/bot' export const MAX_HOPS = 6 @@ -105,6 +100,10 @@ diff --git a/src/host/constants.js b/src/host/constants.js +// character note was refused as "over 600 characters". Count with `[...text].length`, as +// `chat-index.js` and `busy-ack.js` already do, and keep both writers on that same unit. +export const MAX_ENTRY = 600 ++// The role label a Project Assistant carries (#2684). The meetings module refuses a close from ++// this role, and the Project assistant (now in plugins/project) creates the Bot with it; the seam ++// hands the plugin this same constant, so the two cannot spell it differently. ++export const ASSISTANT_ROLE = 'Project Assistant' // Bot requests a Bot's own chat may hold unread; past this the Bot is most likely // stuck, and more messages only pile up behind it. export const MAX_UNREAD_REQUESTS = 10 @@ -1530,7 +1529,7 @@ diff --git a/src/host/store.js b/src/host/store.js + })), + } + } -+ // #1828: the role switch. The policy lives in project-board.js (`changeRole`), so the endpoint ++ // #1828: the role switch. The policy lives in the Project domain (`changeRole`), so the endpoint + // and the menu cannot disagree; a refusal answers the reason and writes nothing. + case 'project-role': { + const project = readProject(projectText(body.id, 200)) @@ -1648,7 +1647,7 @@ diff --git a/src/host/store.js b/src/host/store.js + return await rt.projectDecision?.(project.id, path, actor) ?? { ok: false, reason: `not listed: ${path}` } + } + // The read-only board (#1696): Project id in, its tasks out. The aggregate lives in -+ // project-board.js; it reads and returns, and writes nothing. The actor goes with the id ++ // the Project domain; it reads and returns, and writes nothing. The actor goes with the id + // so the board applies the same visibility rule `tasks` does (#1723). + case 'project-board': { + const board = (await rt.projectBoard?.(projectText(body.id, 200), actor, diff --git a/patches/ds-bot/files/scripts/install-doctor.mjs b/patches/ds-bot/files/scripts/install-doctor.mjs index e8b2e4227..8109fb059 100644 --- a/patches/ds-bot/files/scripts/install-doctor.mjs +++ b/patches/ds-bot/files/scripts/install-doctor.mjs @@ -18,6 +18,7 @@ const PACKAGES = [ ['plugins/tree', '@flybot/tree'], ['plugins/workflow', '@flybot/workflow'], ['plugins/timeline', '@flybot/timeline'], + ['plugins/project', '@flybot/project'], ] export function parseNodeVersion(versionString) { @@ -76,7 +77,7 @@ export function buildInstallCommands({ dshPackage = '@deepseek-ai/dsh@0.2.1-rc.2', timeline = false, // @flybot/timeline is opt-in (#281): its install command is printed only on request } = {}) { - return ['/core', '/plugins/social', '/plugins/tree', '/plugins/workflow', ...(timeline ? ['/plugins/timeline'] : [])] + return ['/core', '/plugins/social', '/plugins/tree', '/plugins/workflow', '/plugins/project', ...(timeline ? ['/plugins/timeline'] : [])] .map(path => `npx -y ${dshPackage} plugin --profile ${profile} add "github:${repo}#${ref}&path:${path}"`) } diff --git a/patches/ds-bot/files/scripts/local-ci.sh b/patches/ds-bot/files/scripts/local-ci.sh index 4ac58bdbc..a74a657fe 100644 --- a/patches/ds-bot/files/scripts/local-ci.sh +++ b/patches/ds-bot/files/scripts/local-ci.sh @@ -65,3 +65,7 @@ echo "=== $step ===" step="plugin tests (workflow)" echo "=== $step ===" (cd ../plugins/workflow && npm test) + +step="plugin tests (project)" +echo "=== $step ===" +(cd ../plugins/project && npm test) diff --git a/patches/ds-bot/files/src/host/authz.js b/patches/ds-bot/files/src/host/authz.js index d2cb8c9be..b62eba237 100644 --- a/patches/ds-bot/files/src/host/authz.js +++ b/patches/ds-bot/files/src/host/authz.js @@ -83,7 +83,7 @@ const TASK_ENDPOINTS = new Set([ 'project-memory', 'project-memory-write', 'project-delete', 'project-assistant-owner', // The role switch (#1836) is the third of the same family after `project-board` (#1713) and // `project-assistant-owner` (#1759): `channel.js` dispatches it and `store.js` implements it, and - // the policy in front of the write is `changeRole` (`project-board.js`), which refuses a + // the policy in front of the write is `changeRole` (plugins/project/src/project-board.js), which refuses a // non-owner itself. Leaving it out only answered `bot/not-found: Unknown endpoint project-role` // before the handler ran, so the role menu failed on every host with a member session. 'project-role', diff --git a/patches/ds-bot/files/src/host/day-key.js b/patches/ds-bot/files/src/host/day-key.js new file mode 100644 index 000000000..45ce6cdd8 --- /dev/null +++ b/patches/ds-bot/files/src/host/day-key.js @@ -0,0 +1,12 @@ +// The natural day a timestamp falls in, in the caller's time zone (#2684). `en-CA` prints ISO +// order, which is what the comparison needs; an unknown zone throws here, so a caller that takes +// one from outside (a board read) resolves it with `resolveTimeZone` first. +// +// This was exported by `project-metrics.js`; the core's turn budget needs the same key, and the +// metrics moved to `plugins/project`, so the function lives here and the plugin takes it back +// through the host seam `flybot.projectHost()` (`core/src/host/project-host.js`). +export function dayKey(ms, timeZone = 'UTC') { + if (!Number.isFinite(ms)) return '' + const parts = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }) + return parts.format(new Date(ms)) +} diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index 072120ccb..6ddd70338 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -25,6 +25,7 @@ import { createPluginModel } from './plugin-model.js' import { defineTool } from './tool-schema.js' import { retiredBot } from './store.js' import { pluginEnabledAtStart, pluginSwitches, setPluginSwitch } from './plugins.js' +import { projectHost as createProjectHost } from './project-host.js' import { codePoints } from './text.js' import { cut } from '../shared/text-boundary.js' import { EPHEMERAL_DENIED_TOOLS } from './tools.js' @@ -1160,6 +1161,11 @@ export function install(rt) { // apply(), and registers nothing when this answers false. Read from the state file // the last run wrote, because plugins load before the team is loaded. pluginEnabled: id => pluginEnabledAtStart(rt.statePath, id), + // The Project domain's host (#2684): plugins/project implements Project State, the board, the + // Chief, the dossier, the metrics and the assistant, and cannot import core/src/*. It asks for + // the runtime and the shared pure helpers once, in its own apply(); a core without the plugin + // never calls this and keeps the store's existing fallbacks. + projectHost: () => createProjectHost(rt), plugin, scope: build, bot: { diff --git a/patches/ds-bot/files/src/host/meetings.js b/patches/ds-bot/files/src/host/meetings.js index 89c656450..f55b3e78e 100644 --- a/patches/ds-bot/files/src/host/meetings.js +++ b/patches/ds-bot/files/src/host/meetings.js @@ -11,8 +11,8 @@ import { join } from 'node:path' import { cut } from './text.js' import { defineTool } from './tool-schema.js' import { output } from './tools.js' -import { workDirFor } from './project-state.js' -import { ASSISTANT_ROLE } from './project-assistant.js' +import { workDirFor } from './work-dir.js' +import { ASSISTANT_ROLE } from './constants.js' // The Project Assistant takes a position like anyone else, but it does not write the decision // file and does not close the meeting (the Chief or the owner does). diff --git a/patches/ds-bot/files/src/host/plugins.js b/patches/ds-bot/files/src/host/plugins.js index 7d2a249d4..5421e9aa6 100644 --- a/patches/ds-bot/files/src/host/plugins.js +++ b/patches/ds-bot/files/src/host/plugins.js @@ -1,4 +1,6 @@ -// The four FlyBot feature plugins and the owner's switch for each (#2564). +// The FlyBot feature plugins and the owner's switch for each (#2564). The Project domain is one +// of them since #2684: its code lives in plugins/project and this switch is how an owner turns +// the whole domain (tools, board, Chief, assistant) off without uninstalling anything. // // The switch does not install or uninstall anything: the packages stay in the DSH // profile as they are, and the answer lives in FlyBot's own state.json. A plugin reads @@ -13,20 +15,21 @@ export const FEATURE_PLUGINS = Object.freeze([ { id: 'tree', name: 'Tree', package: '@flybot/tree', purpose: 'Run Bots as a tree under a coordinator.' }, { id: 'timeline', name: 'Timeline', package: '@flybot/timeline', purpose: 'Read the team activity as a timeline.' }, { id: 'workflow', name: 'Workflow', package: '@flybot/workflow', purpose: 'Run workflow graphs across several Bots.' }, + { id: 'project', name: 'Project', package: '@flybot/project', purpose: 'Run a Project: Project State, its board, its own Chief, its team and its assistant.' }, ]) export const FEATURE_PLUGIN_IDS = Object.freeze(FEATURE_PLUGINS.map(plugin => plugin.id)) const known = id => FEATURE_PLUGIN_IDS.includes(id) -// The four switches as the Settings page shows them: on unless state.json says off. +// The switches as the Settings page shows them: on unless state.json says off. export function pluginSwitches(state) { const saved = state?.flybot?.plugins ?? {} return FEATURE_PLUGINS.map(plugin => ({ ...plugin, enabled: saved[plugin.id]?.enabled !== false })) } -// Writes one switch into the state the next save persists. Only these four ids are -// accepted, so a typo cannot grow a fifth switch nobody reads. +// Writes one switch into the state the next save persists. Only these ids are accepted, +// so a typo cannot grow a switch nobody reads. export function setPluginSwitch(state, id, enabled) { const name = String(id ?? '') if (!known(name)) throw new Error(`flybot: unknown plugin ${JSON.stringify(name)}`) diff --git a/patches/ds-bot/files/src/host/project-assistant.js b/patches/ds-bot/files/src/host/project-assistant.js deleted file mode 100644 index 557a4377b..000000000 --- a/patches/ds-bot/files/src/host/project-assistant.js +++ /dev/null @@ -1,557 +0,0 @@ -// The Project Assistant (#1712): one hidden Bot and Session per Project, memory private to that -// Project, and a 09:00 Asia/Shanghai digest built only from what the Project already knows — the -// Project State reader (2.4) and the board rows (2.3). Nothing here restates Project State, mints -// a second task identity or invents a number: every line the digest prints comes from a row or a -// file it was handed, and a day with nothing to say prints one line saying so. -// -// Identity is the install's own connector account (owner decision 2026-10-29: on a single-person -// install the Project owner is the person signed in, so `GET /user` is that person's own login). -// The shared Project App identity never stands in for a person: with no connector signed in, or on -// a host with more than one member where the Project's `ownerUserId` is not the host's own user, -// the assistant refuses and asks the owner instead. Every write it makes carries the name the -// account is posting for, because one shared GitHub login makes attribution text, not identity. -// -// The digest is not scheduled from inside the plugin: `project-create` puts exactly one 09:00 task -// on the assistant Session through the host's schedule path, and `project-delete` removes it with -// the Project. A missed beat is DSH's, and it posts nothing. -import { defineTool } from './tool-schema.js' -import { MAX_ENTRY } from './constants.js' -import { listProjectState } from './project-state.js' -import { codePoints, cut } from './text.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' -export const DIGEST_TIME = '09:00' -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.' -// The bound itself is in `constants.js` (#1847), beside the other limits host modules share, -// because the store's write door enforces it too; this re-export keeps the assistant's own name. -export { MAX_ENTRY } -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})` - -export const assistantInstructions = project => [ - `You are the Project Assistant of "${project.name}". You speak for that Project and for no other.`, - 'Your memory is private to this Project: record decisions and open questions with project_memory_write, and never carry another Project\'s entries here.', - 'You may speak with the GitHub identity the host has signed in, and only while that identity is the Project owner\'s own. A write you make states the name you write as and the login you post for; the shared Project App identity is never used.', - 'Once a day, when the 09:00 task fires, call project_digest exactly once. It answers the four questions in order from the board rows and the Project State files, and prints one line on a day with no change.', -].join('\n\n') - -const refusal = (message, code = 'bot/invalid') => Object.assign(new Error(message), { code }) -const text = value => (typeof value === 'string' ? value.trim() : '') - -// The calendar day in the digest's own zone. A digest is idempotent per *day* rather than per -// 24 hours, so a retry after 09:00 still sees that today already has one. -export function dayIn(at, zone = DIGEST_ZONE) { - return new Intl.DateTimeFormat('en-CA', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' }) - .format(new Date(at)) -} - -// The four answers, in the order the issue names them, from rows the read model already carries: -// `source`, `column`, `assignee`, `updatedAt`. "Merged" is finished work a workflow run carried -// in (the run's binding is what put the row on this Project's board); "closed" is finished work a -// Bot or group chat owned. A board row has no blocked flag, so the third answer lists the cards -// 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(), 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') - const closed = finished.filter(row => row.source !== 'workflow') - const blocked = rows.filter(row => row.column === 'in-progress' && text(row.assignee) !== '') - const next = rows - .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 shown = owner === null ? [] : actionable.slice(0, MAX_ACTIONABLE).map(item => `${item.id} — ${item.ask}`) - // The block stays five cards: a person with seven waiting is told how many are left over instead - // of being handed the whole board (#P-197). Five or fewer print exactly as they did before. - const rest = owner === null ? 0 : actionable.length - MAX_ACTIONABLE - if (rest > 0) shown.push(`… and ${rest} more waiting on you`) - const personal = shown.length === 0 ? [] : line(`Needs ${owner}'s decision:`, shown) - 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)} — ${dayIn(at)}` - if (sections.length === 0) return { changed: false, day: dayIn(at), text: `${head}: no change since the previous digest.` } - const stateLine = files.length === 0 - ? 'Project State: no Project State files read.' - : `Project State: ${files.map(file => file.path).join(', ')}` - return { changed: true, day: dayIn(at), text: [head, stateLine, ...sections].join('\n') } -} - -// The cards that need *this* owner's decision: owned by that owner, and stopped where only the -// reader can move them. 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. -const WAITING_STATUS = new Set(['blocked', 'result-ready']) -export function actionableFor(rows, ownerId, ownerOf = row => text(row?.ownerId)) { - const owner = text(ownerId) - if (owner === '') return [] - return (rows ?? []) - .filter(row => row?.column !== 'done' && ownerOf(row) === owner) - // A row that carries no status keeps the older answer: absence is not evidence that the card - // is moving, and reading it as "not waiting" would silently delete a card from the digest. - .filter((row) => { - const status = text(row?.status) - return status === '' || WAITING_STATUS.has(status) - }) - .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. What the -// card is waiting for is its own status, not its column: a blocked card wants a call, a finished -// result wants acceptance, and anything else the caller chose to list is still a decision to make. -const waitOf = (row) => { - const status = text(row?.status) - if (status === 'blocked') return 'needs your call' - if (status === 'result-ready') return 'ready for your acceptance' - return 'decide what happens next' -} -const askOf = (row) => { - const title = text(row.title).replace(/\s+/g, ' ') - const where = text(row.column) === '' ? 'open' : `still ${text(row.column)}` - if (title === '') return `${waitOf(row)} — ${where}` - // One line still, and the 77-unit bound lands on a code point (#2005): a cut inside a pair - // would send the digest half an emoji. - const short = title.length > 80 ? `${cut(title, 77).text}…` : title - return `${waitOf(row)} — ${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}` - -export function install(rt) { - const projectOf = id => rt.readProject?.(id) ?? null - - const activeProject = (id) => { - const project = projectOf(text(id)) - if (project === null) throw refusal(`Unknown Project ${text(id) || '(none)'}`, 'bot/not-found') - if (project.status === 'archived') throw refusal(`Project ${project.name} is archived`, 'bot/refused') - return project - } - - const assistantOf = (project) => { - const botId = project.assistant?.botId - return typeof botId === 'string' && rt.botOf(botId) !== undefined ? project.assistant : null - } - - // 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 : [], - digest: memory?.digest ?? null, - } - } - - // 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) - const kind = text(body.kind) || 'decision' - if (!MEMORY_KINDS.includes(kind)) throw refusal(`Unknown Project memory kind ${kind}`) - const entry = text(body.text) - if (entry === '') throw refusal('A Project memory entry needs text') - // #1886: count code points, the same unit `appendProjectMemory` counts, so the two writers - // cannot disagree about which note fits (`String#length` charged an emoji two units). - // #1895: both count through the one `codePoints` helper. - if (codePoints(entry) > MAX_ENTRY) throw refusal(`A Project memory entry is at most ${MAX_ENTRY} characters`) - 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 - // is the owner's own. Two cases refuse and escalate instead of falling back to the app. - const identity = (projectId) => { - const project = activeProject(projectId) - const github = (rt.connectorsView?.() ?? []).find(row => row.id === 'github') - const login = text(github?.account) - if (login === '') { - return { ok: false, project, escalate: true, reason: 'no GitHub connector is signed in, so the assistant has no owner identity to post for' } - } - const members = Object.keys(rt.state?.members ?? {}) - // One member is the person signed in, so the Project owner must be that member. More than one - // member is exactly the case this slice has no answer for — there is no per-user GitHub linkage - // yet — so it refuses rather than letting one person's account post for another. - if (members.length > 1) { - return { ok: false, project, escalate: true, reason: `this host has more than one member and no per-user GitHub linkage, so Project ${project.name} cannot be posted for by any one owner` } - } - const own = members.length === 1 ? members[0] : '' - const ownerId = text(project.ownerUserId) || own - if (own !== '' && ownerId !== own) { - return { ok: false, project, escalate: true, reason: `Project ${project.name} is owned by ${ownerId}, not by this host's own user; there is no per-user GitHub linkage to post for them` } - } - return { ok: true, project, login } - } - - // One task on the assistant's Session, through the host's schedule path — never a cron of the - // plugin's own. An install with DSH's automation tasks off keeps the Project and records why the - // digest is not scheduled, because creating a Project must not depend on the schedule service. - const scheduleDigest = async (project, botId) => { - const sessionId = rt.chatOf?.(botId) ?? botId - if (typeof rt.scheduleEndpoint !== 'function') return { error: 'the schedule service is off in DSH' } - // The Bot exists but the Session index has not been rebuilt yet; a task on it must resolve as - // that Bot's own Session, or the schedule path refuses it as somebody else's. - rt.chatOwners?.set(sessionId, botId) - // The host asks on the assistant's own behalf, and a host with a member signed in narrows the - // list it answers with to the caller's rows: the digest belongs to that Bot's owner, so the - // owner goes down with the request the way the channel sends the caller for an edit (#1321). - const owner = rt.botOf?.(botId)?.ownerId - const actor = owner === undefined ? undefined : { memberId: owner } - try { - const view = await rt.scheduleEndpoint('schedule-create', { - target: botId, - title: DIGEST_TITLE, - prompt: DIGEST_PROMPT, - when: { kind: 'daily', time: DIGEST_TIME, timeZone: DIGEST_ZONE }, - }, actor) - const row = (view?.tasks ?? []).find(task => task.sessionId === sessionId && task.title === DIGEST_TITLE) - return { scheduleId: row?.id ?? null } - } catch (error) { - return { error: error?.message ?? String(error) } - } - } - - // A Project whose assistant exists without a `scheduleId` — created while DSH's automation tasks - // were off, or whose task was never written — gets its 09:00 digest on the first moment the - // service exists (#1751). A success clears the recorded error, so a later `project-create` - // answers `created:false` with nothing left to retry. One run at a time: a caller that arrives - // while one is in flight joins it instead of putting a second task on the Session. - let reconciling = null - const reconcile = () => { - if (reconciling !== null) return reconciling - reconciling = (async () => { - if (typeof rt.scheduleEndpoint !== 'function') return { scheduled: [] } - const scheduled = [] - for (const project of rt.listProjects?.() ?? []) { - const assistant = assistantOf(project) - // No assistant, or one whose digest is already scheduled: nothing to retry. - if (assistant === null || typeof assistant.scheduleId === 'string') continue - const schedule = await scheduleDigest(project, assistant.botId) - if (typeof schedule.scheduleId !== 'string') { - if (typeof schedule.error === 'string') assistant.error = schedule.error - continue - } - assistant.scheduleId = schedule.scheduleId - delete assistant.error - scheduled.push(project.id) - } - if (scheduled.length > 0) await rt.save?.() - return { scheduled } - })().finally(() => { reconciling = null }) - return reconciling - } - - const create = async (projectId) => { - const project = activeProject(projectId) - const existing = assistantOf(project) - if (existing !== null) { - // An assistant that exists but never got its digest is offered the retry here, and the - // answer still says `created:false`: this call did not create anything. - if (typeof existing.scheduleId !== 'string') await reconcile() - return { project, assistant: assistantOf(project) ?? existing, created: false } - } - const bot = await rt.createBot({ - name: assistantName(project), - role: ASSISTANT_ROLE, - instructions: assistantInstructions(project), - hidden: true, - ownerId: text(project.ownerUserId) || rt.initialOwnerId?.(), - }) - // The assistant and its binding are plumbing, not a change of the Project: a Project - // created with one still answers `createdAt === updatedAt`. - const stamped = project.updatedAt - rt.bindProject?.({ kind: 'bot', id: bot.id, projectId: project.id }) - project.updatedAt = stamped - const schedule = await scheduleDigest(project, bot.id) - const assistant = { botId: bot.id, sessionId: rt.chatOf?.(bot.id) ?? bot.id, createdAt: Date.now(), ...schedule } - project.assistant = assistant - await rt.save?.() - return { project, assistant, created: true } - } - - const remove = async (projectId) => { - const project = projectOf(text(projectId)) - if (project === null) return null - const assistant = assistantOf(project) - if (assistant === null) return null - if (typeof rt.scheduleEndpoint === 'function' && typeof assistant.scheduleId === 'string') { - await rt.scheduleEndpoint('schedule-delete', { sessionId: assistant.sessionId, id: assistant.scheduleId }) - .catch(error => rt.warn?.('ds-bot: removing the Project Assistant schedule failed: %s', error?.message ?? error)) - } - await rt.deleteBot?.(assistant.botId) - .catch(error => rt.warn?.('ds-bot: removing the Project Assistant Bot failed: %s', error?.message ?? error)) - delete project.assistant - await rt.save?.() - return assistant - } - - // 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) - } - const nextOff = settings.off === undefined ? wasOff : settings.off === true - if (settings.off !== undefined && nextOff !== wasOff) record.off = nextOff - // #1845: a Save that writes the value already stored is not a change. The panel sends the box - // it drew, so without this guard a read-back bug (or a Save on an untouched row) would call - // `schedule-resume` on a digest the owner paused, which is the silent rewrite itself. - const changed = nextOff !== wasOff - let schedule = null - if (changed && 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) - } - } - if (changed || settings.quiet !== undefined || settings.zone !== undefined) await rt.save?.() - return { owner, off: record.off === true, quiet: quietOf(assistant, owner), schedule, changed } - } - - // The caller's own controls, read from the same record `setOwner` writes (#1845): the panel used - // to read `project.assistant.off`, which the host never writes, so a paused digest always looked - // paused-off and one Save turned it back on. `project-read` carries this beside the Project. - const mine = (projectId, ownerId = '') => { - const project = activeProject(projectId) - const owner = text(ownerId) - const record = owner === '' ? null : (project?.assistant?.owners?.[owner] ?? null) - return { off: record?.off === true, quiet: quietOf(project?.assistant ?? null, owner) } - } - - // 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 task = rt.state?.tasks?.[String(row?.id ?? '')] - const title = text(task?.title) - // The card's status comes from its own record here, not from a second read further down: a - // card that is still moving waits on nobody, and the digest decides that from this row (#P-195). - const status = text(task?.status) - const shaped = title === '' ? row : { ...row, title } - return status === '' ? shaped : { ...shaped, status } - }) - - // 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 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, - 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 the due instant delivers nothing that day at all, and that is the expected outcome, not a - // failure. - 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) - const day = dayIn(at) - // 09:00 in the digest's own zone is an instant, and a window is wall clock in the owner's own - // zone: two zones' wall clocks are not comparable (#P-194). Asia/Shanghai has no DST, so the - // due instant is today's date at 09:00+08:00, read back as a clock in the owner's zone. - const due = clockIn(quiet.zone, Date.parse(`${day}T${DIGEST_TIME}:00+08:00`)) - const clock = clockIn(quiet.zone, at) - const coversDue = inQuietHours(quiet, due) - const memory = memoryOf(project, owner) - 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 from the - // instant it was held at — not from 09:00, which is another zone's wall clock. - 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 ${clockIn(quiet.zone, held.at ?? at)} 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 { 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(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) { - 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 ${due}, 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) - 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 - // 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) - // The board read is the same one the page uses (#1767): the digest wants its cards, not the - // decisions and meetings that answer beside them. It asks for the Project's own facts (#1846): - // with no actor every card is invisible once a member has signed in, and a digest built from one - // reader's page reported "no change" on a moving board. The flag is this call's alone. - const board = (await rt.projectBoard?.(project.id, null, { internal: true })) ?? [] - const rows = Array.isArray(board) ? board : (board.rows ?? []) - const state = project.workDir === undefined ? null : await listProjectState(project.workDir).catch(() => null) - const files = state?.ok === true ? state.files : [] - const results = [] - for (const owner of owners) { - try { - 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) - 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 }, - } - } - - rt.projectAssistant = { create, reconcile, remove, read, write, mine, 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. - rt.ctx.tools.register(defineTool({ - name: 'project_digest', - description: 'Build the Project digest for the Project your Session is bound to: what merged since the previous digest, what closed, which cards are still in progress and on whom, and the next slice. It stores one Project note and delivers one message to the owner, at most once per day; the second call in a day adds nothing and says so.', - parameters: {}, - output, - async execute(_args, exec) { - await rt.load?.() - const botId = rt.selfOf?.(exec?.agent?.id) ?? exec?.agent?.id - const bot = botId === undefined ? undefined : rt.botOf(botId) - const projectId = text(bot?.projectId) - if (projectId === '') return 'This Session is not a Project Assistant, so it has no Project digest to build.' - const result = await digest(projectId) - if (result.ok !== true) return `The digest was not posted: ${result.reason}` - return result.skipped === true ? `Today already has a digest (${result.day}); this call added nothing.` : result.text - }, - })) -} diff --git a/patches/ds-bot/files/src/host/project-board.js b/patches/ds-bot/files/src/host/project-board.js deleted file mode 100644 index 1fe297757..000000000 --- a/patches/ds-bot/files/src/host/project-board.js +++ /dev/null @@ -1,652 +0,0 @@ -// One Project, one board read model (#1696). The core task record is the identity: a workflow -// run names the same id through `run.task.id` and never mints a second task, so a card a run -// consumed is one row that carries the run, not two views of the same work. -// -// #1767 adds the other half of the record beside the cards: the `decisions/*.md` files of the -// Project's working folder and the meetings that reached `decided`. It is the same read — the -// folder goes through `listProjectState`, which already hashes every file, and a decision file -// is listed with a name, a date and one title line, never its body. #1809 adds the second source: -// `decide` archives in the Session's working folder, so each decided meeting names the folder it -// wrote to (`decisionDir`) and the board reads that one file beside the Project folder. -// -// #1768 adds the queue beside those: the waiting cards of a role queue, the cards a claim holds -// (with its lease live or stalled) and the blocked cards, each with the line it already stores. -// They are derived from the same `state.tasks` pass as the rows, never a second store. -// -// Read-only by construction: it reads `state.tasks`, `state.meetings` and the Project bindings, -// asks each registered provider for the workflow refs of tasks that still exist, and returns the -// sections. It writes nothing, starts nothing and is not called unless someone asks for the board. -import { open } from 'node:fs/promises' -import { join } from 'node:path' -import { columnOf, priorityOf } from '../shared/task-board.js' -import { DECISIONS_DIR, describeFile, isDecisionPath, listProjectState, readListedFile } from './project-state.js' -import { cut } from './text.js' -import { advanceRefusal } from './meetings.js' -import { readMetrics } from './project-metrics.js' - -// #1828: a Project's roles. The record stores only `roles: { maintainers, members }`; the owner -// is read from `ownerUserIds`, never stored twice, so the two cannot disagree. A record written -// before this slice carries no `roles` and derives to owner-only — no migration pass. -export const PROJECT_ROLE_RANKS = { member: 1, maintainer: 2, owner: 3 } -const roleList = value => (Array.isArray(value) ? [...new Set(value.map(id => text(id)).filter(id => id !== ''))] : []) -export function rolesOf(project) { - const owner = roleList(project?.ownerUserIds) - const primary = text(project?.ownerUserId) - if (primary !== '' && !owner.includes(primary)) owner.unshift(primary) - const stored = project?.roles !== null && typeof project?.roles === 'object' ? project.roles : {} - const maintainers = roleList(stored.maintainers).filter(id => !owner.includes(id)) - const members = roleList(stored.members).filter(id => !owner.includes(id) && !maintainers.includes(id)) - return { owner, maintainers, members } -} -export function roleOf(project, memberId) { - const id = text(memberId) - if (id === '') return null - const roles = rolesOf(project) - if (roles.owner.includes(id)) return 'owner' - if (roles.maintainers.includes(id)) return 'maintainer' - if (roles.members.includes(id)) return 'member' - return null -} -// The roles a caller may switch a target to: the current one is not repeated, and the Owner rank -// is never an assignment (transfer is a separate slice). A Member sees nothing, a Maintainer -// sees only Member, and an Owner acting on anyone else sees the two assignable ranks. -export function roleChoices(project, actorId, targetId = actorId) { - const actor = roleOf(project, actorId) - const self = text(targetId) === text(actorId) - const target = self ? actor : roleOf(project, targetId) - const choices = [] - // The Owner of a Project never drops its own rank here: the menu shows only the current item. - if (self && actor === 'owner') return choices - for (const role of ['maintainer', 'member']) { - if (role === target) continue - if (actor === 'owner') choices.push(role) - else if (self && actor !== null && PROJECT_ROLE_RANKS[role] < PROJECT_ROLE_RANKS[actor]) choices.push(role) - } - return choices -} -// The policy Chief's matrix (2026-10-30) fixes, in one place the endpoint and the menu both read: -// only an Owner re-roles someone else, nobody promotes themselves, and a Project always keeps its -// Owner. Returns the next stored roles on success and a machine-readable reason on refusal; the -// caller's record is untouched either way. -export function changeRole(project, { actorId, targetId, role } = {}) { - const actor = roleOf(project, actorId) - const self = text(targetId) === '' || text(targetId) === text(actorId) - const target = self ? text(actorId) : text(targetId) - const wanted = text(role) - const current = rolesOf(project) - // #1838: a plain object answers for names it inherits, so `toString` used to read as a rank and - // fall through to the demote path. Only an own key of the table is a role. - if (!Object.hasOwn(PROJECT_ROLE_RANKS, wanted)) return { ok: false, reason: 'unknown-role' } - if (wanted === 'owner') return { ok: false, reason: 'owner-not-assignable' } - if (actor === null) return { ok: false, reason: 'not-owner' } - if (self) { - if (actor === 'owner') return { ok: false, reason: 'last-owner' } - if (PROJECT_ROLE_RANKS[wanted] > PROJECT_ROLE_RANKS[actor]) return { ok: false, reason: 'cannot-self-promote' } - } else if (actor !== 'owner') { - return { ok: false, reason: 'not-owner' } - } else if (current.owner.includes(target)) { - return { ok: false, reason: 'last-owner' } - } - const maintainers = current.maintainers.filter(id => id !== target) - const members = current.members.filter(id => id !== target) - if (wanted === 'maintainer') maintainers.push(target) - else members.push(target) - return { ok: true, roles: { maintainers, members } } -} - -const text = value => (typeof value === 'string' ? value.trim() : '') - -// The queue, the leases and the blocked cards are host truth the board never copied (#1768): -// a member's role queue is `queueRole` on an open card with no `claimedBy` (`tasks.js`), a lease is -// the one `tasks.js` writes, and a blocked card already stores the question it waits on. This read -// derives those three groups from those fields, oldest wait first, and writes nothing. -// The column a finished card reads (#1855): the archive key, never a row. -const DONE_COLUMN = 'done' -const ONE_LINE = 200 -// #2005: the bound lands on a code point. A `slice` here put the first half of an emoji into the -// queue reason the panel draws, which reads as a replacement character. -const oneLine = value => (typeof value === 'string' ? cut(value.replace(/\s+/g, ' ').trim(), ONE_LINE).text : '') -const stampOf = value => (Number.isFinite(value) ? value : null) -// #2587: `text()` proves only that a stored timestamp is a non-empty string, so a hand-edited -// `decidedAt` / `createdAt` reached `toISOString()` and threw for the whole board read. A value -// that does not parse reads as no date, like the other unreadable fields. -const dayOf = value => { - const parsed = Date.parse(text(value)) - return Number.isFinite(parsed) ? new Date(parsed).toISOString().slice(0, 10) : '' -} - -// Waiting: an open card a role may claim and no member has. `waitedMs` is measured from the card's -// own `createdAt`, so the board shows a duration a reader can check, not a guess. -export function waitingOf(task, now) { - if (task === null || typeof task !== 'object') return null - if (task.status !== 'open') return null - const role = oneLine(task.queueRole) - if (role === '' || text(task.claimedBy) !== '') return null - const since = stampOf(task.createdAt) - return { id: String(task.id), role, since, waitedMs: since === null ? null : Math.max(0, now - since) } -} - -// In flight: the card a member holds. A live lease reads running; an expired one reads stalled — -// the card record is untouched either way, recovery stays `tasks.js`'s own path (#1720). -export function inflightOf(task) { - if (task === null || typeof task !== 'object') return null - if (task.status !== 'claimed' && task.status !== 'working') return null - // The holder is the claim, not the assignee label: a card nobody claimed is waiting work, and - // only the queue's own claim (or the lease that claim wrote) says a member holds it. - const holder = oneLine(task.claimedBy) || oneLine(task.lease?.holder) - if (holder === '') return null - return { id: String(task.id), holder } -} - -// Blocked: the reason is the card's own line, never a second copy. #2029 adds the age the ask -// already stamped (`inputRequest.requestedAt`, `tasks.js`) — a card blocked for three days and one -// blocked for three minutes must not read alike. A card with no stamp has no age: the card's own -// `createdAt`/`updatedAt` are not the question's clock, and an invented wait is worse than none. -export function blockedOf(task, now) { - if (task === null || typeof task !== 'object' || task.status !== 'blocked') return null - const reason = oneLine(task.inputRequest?.question) || oneLine(task.queueReason) || oneLine(task.title) || '' - const since = stampOf(task.inputRequest?.requestedAt) - return { id: String(task.id), reason, since, waitedMs: since === null ? null : Math.max(0, now - since) } -} - -// Oldest wait first: the card that has waited longest is the one a person wants next. Shared by the -// Waiting and the Blocked groups so the two read in the same order; a card the host cannot date -// sorts last rather than first. -const oldestFirst = (a, b) => (a.since ?? Infinity) - (b.since ?? Infinity) || a.id.localeCompare(b.id) - -// A decision is read for its title line only: the head of the file is enough for a heading, and -// the body stays where it is. The bound also keeps a megabyte file from being read to answer one -// line; the hash the reader needs comes from `listProjectState`, not from this read. -const TITLE_BYTES = 4096 -const TITLE_MAX = 200 -const DECISIONS_MAX = 200 -const MEETINGS_MAX = 100 - -const fileName = path => path.slice(path.lastIndexOf('/') + 1) -// The day a decision file records is the one in its name (`decisions/2026-10-30-x.md`), so two -// files of one day read as one day even when the folder was copied and their mtimes differ. -const dateOfName = (name) => { - const match = /^(\d{4}-\d{2}-\d{2})/.exec(name) - return match === null ? '' : match[1] -} - -async function titleOf(dir, path) { - let handle - try { - handle = await open(join(dir, path), 'r') - const buffer = Buffer.alloc(TITLE_BYTES) - const { bytesRead } = await handle.read(buffer, 0, TITLE_BYTES, 0) - for (const raw of buffer.subarray(0, bytesRead).toString('utf8').split(/\r?\n/)) { - const line = raw.trim() - if (line === '') continue - // The same code-point rule as `oneLine` (#2005): a decision heading may end in an emoji. - return cut(line.replace(/^#+\s*/, ''), TITLE_MAX).text - } - return '' - } catch { - // A file that vanished between the listing and this read answers no title rather than a - // failed board: the row itself is the other read's. - return '' - } finally { - // Whichever way the read ended, the handle is closed and nothing is left to close. - if (handle !== undefined) await handle.close().catch(() => { /* nothing left to close */ }) - } -} - -// The board a card belongs to. A group card names its room; a Bot card names its owner. A card -// with neither (a run-only card) has no board and resolves through the run's own binding. -const boardOf = (task) => { - const roomId = text(task?.origin?.roomId) - if (task?.kind === 'group' && roomId !== '') return { kind: 'group', id: roomId } - const botId = text(task?.ownerBotId) - if (botId !== '') return { kind: 'bot', id: botId } - return null -} - -// #1831: the level, the recipients and whether the card may advance, read off a meeting record. -// Exported so the read model and its unit test share one projection: a card cannot mean one -// thing on the board and another in the test. -export function decisionCardOf(meeting) { - const level = ['ordinary', 'major', 'severe'].includes(text(meeting?.level)) ? text(meeting.level) : 'ordinary' - const recipients = Array.isArray(meeting?.recipients) - ? meeting.recipients.map(item => text(typeof item === 'string' ? item : item?.name)).filter(name => name !== '') - : [] - // #1861: the board says who spoke for whom, so a proxy position reads as one. A participant with - // no speaksFor is the person themselves; nothing is inferred. - const participants = Array.isArray(meeting?.participants) - ? meeting.participants - .map(participant => ({ - name: text(participant?.name), - speaksFor: text(participant?.speaksFor ?? participant?.position?.speaksFor) || null, - stance: text(participant?.position?.stance) || null, - silent: participant?.silent === true, - })) - .filter(participant => participant.name !== '') - : [] - const refusal = advanceRefusal(meeting) - return { level, recipients, participants, advance: refusal === undefined ? { allowed: true } : { allowed: false, reason: refusal } } -} - -export function install(rt) { - // #2041: the decision names the board listed, per Project, with the folder each name was read - // from. A render rewrites its Project's entry; nothing else writes here, and a name outside it - // is refused unread. - const readings = new Map() - // The folder a Project's decisions live in. A Project names one itself, and when it names none - // the folder is the one its members work in — the same folder a meeting resolves through - // `workDirFor` before it archives a decision, so the reader and the writer look in one place. - const folderOf = (project) => { - const own = text(project?.workDir) - if (own !== '') return own - for (const room of Object.values(rt.state?.rooms ?? {})) { - if (room === null || typeof room !== 'object') continue - const dir = text(room.workDir) - if (dir !== '' && rt.projectOfMember?.('group', room.id)?.id === project?.id) return dir - } - for (const bot of Object.values(rt.state?.bots ?? {})) { - if (bot === null || typeof bot !== 'object') continue - const dir = text(bot.workDir) - if (dir !== '' && rt.projectOfMember?.('bot', bot.id)?.id === project?.id) return dir - } - return '' - } - - // The file a decided meeting archived (#1809): read from the folder the record names, and only - // that one file — two folder-less Projects share the team folder, so a folder read whole would - // show each Project the other's decisions. Three answers, never two: a record written before this - // slice names no folder (`none`) and resolves through the Project folder exactly as it did; one - // that named a folder but whose file this read could not take is `unreadable`, which is not that - // shape; one that was read carries its row. The middle case is why this is not `undefined` plus a - // second look at `decisionDir` in the caller. - const archivedOf = async (meeting) => { - const dir = text(meeting?.decisionDir) - const path = text(meeting?.decisionFile) - if (dir === '') return { state: 'none' } - // The listing accepts one shape only, and this read is part of it: a name that is not - // `decisions/.md`, or a symlink standing in for that file, is refused here too. Reading - // it would put a file outside the folder in the rows, and the past-cut fallback reads this set. - if (!isDecisionPath(path)) return { state: 'unreadable' } - try { - return { state: 'read', row: { dir, ...await describeFile(dir, path) } } - } catch { - // Absent or unreadable is not a row: the meeting keeps its own record, with no path. - return { state: 'unreadable' } - } - } - - const decisionsOf = async (project) => { - const dir = folderOf(project) - const folders = [] - const found = [] - if (dir !== '') { - folders.push(dir) - let state - try { - // The board's own read takes no repository gate (#1809): a decision file is the board's - // artifact, and a Project folder is not a repository in a default install. - state = await listProjectState(dir, { repository: false }) - } catch (error) { - return { dir, files: [], total: 0, reason: `unreadable: ${error?.message ?? String(error)}` } - } - if (state.ok !== true) return { dir, files: [], total: 0, reason: state.reason } - found.push(...state.files - .filter(file => isDecisionPath(file.path)) - .map(file => ({ ...file, dir }))) - } - // The other source: the folder each decided meeting of this Project recorded, one file each. - // #2041: a name that came from a meeting keeps that meeting's own cards, because opening the - // file is checked against the same rule the board paints those cards with. - const cardsOf = new Map() - const named = new Map() - for (const meeting of Object.values(rt.state?.meetings ?? {})) { - if (meeting === null || typeof meeting !== 'object' || meeting.status !== 'decided') continue - if (!inProject(meeting, project)) continue - const archivedDir = text(meeting.decisionDir) - if (archivedDir !== '' && !folders.includes(archivedDir)) folders.push(archivedDir) - const archived = await archivedOf(meeting) - if (archived.state === 'none') { - // A record written before the archive slice names no folder: its file is one of the rows of - // the Project's own listing, and the meeting still names the cards it decided. Kept aside - // until that listing is deduped, so only a name this read really listed carries the cards. - const path = text(meeting.decisionFile) - if (isDecisionPath(path) && !named.has(path)) { - named.set(path, Array.isArray(meeting.taskIds) ? meeting.taskIds.map(id => text(id)).filter(id => id !== '') : []) - } - continue - } - // A record that named a folder is not the shape above even when its file could not be read: - // that file belongs to another folder, and the same relative name here is another file. - if (archived.state === 'unreadable') continue - found.push(archived.row) - if (!cardsOf.has(archived.row.path)) { - cardsOf.set(archived.row.path, Array.isArray(meeting.taskIds) ? meeting.taskIds.map(id => text(id)).filter(id => id !== '') : []) - } - } - // One row per path — the client keys a row by path, and the Project folder was read first. - const seen = new Set() - const deduped = found - .filter((file) => { - if (seen.has(file.path)) return false - seen.add(file.path) - return true - }) - .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) - // A record that saved no folder keeps the cards it decided, on the row this read listed for it - // and only that row: a name the folder did not list stays a ghost and opens nothing. A record - // that saved a folder wins over one that saved none for the same name, and an empty list still - // answers through the role list in `decisionVisible`. - for (const [path, ids] of named) { - if (!cardsOf.has(path) && deduped.some(file => file.path === path)) cardsOf.set(path, ids) - } - // #2021: a `decisions/YYYY-MM-DD-….md` name sorts oldest first, so the cut has to take the tail. - // Taking the head kept the 200 oldest files and dropped the ones just written — the wrong end of - // the list. The order on screen stays ascending; only which rows survive at the cap changes. - const chosen = deduped.slice(-DECISIONS_MAX) - const files = [] - for (const file of chosen) { - const name = fileName(file.path) - files.push({ - path: file.path, - name, - date: dateOfName(name), - title: await titleOf(file.dir, file.path), - bytes: file.bytes, - sha256: file.sha256, - mtime: file.mtime, - }) - } - const reason = files.length > 0 - ? '' - : folders.length === 0 - ? 'no working folder: this Project names none' - : folders.length === 1 - ? `no decision file in ${join(folders[0], DECISIONS_DIR)}` - : 'no decision file in any folder the record names' - // #2021: the count is taken after the dedup and before the cut, so a reader can tell a complete - // list from a truncated one instead of reading the cap as the answer. - // #2033: the un-truncated dedupe of this read, paths only — the meeting lookup below has to - // tell "fell off the 200-row cut" from "was never here" without asking the disk again. - // #2041: each name keeps the folder it was read from, because a decided meeting archives to a - // folder of its own; opening a row reads through this map and nothing else. - return { dir: dir === '' ? null : dir, files, total: deduped.length, reason, known: new Map(deduped.map(file => [file.path, file.dir])), cards: cardsOf } - } - - // #1831: the card's level, who it is addressed to and whether it may advance ride on every - // meeting row; the projection is the exported one, so the board and its test cannot disagree. - const cardOf = decisionCardOf - - // A meeting names the Project it belongs to by id or by name (the tool's `project` argument is - // free text), so both forms resolve here rather than one being silently dropped. - const inProject = (meeting, project) => { - const named = text(meeting?.project).toLowerCase() - if (named === '') return false - return [text(project?.id), text(project?.name)] - .filter(value => value !== '') - .some(value => value.toLowerCase() === named) - } - - const meetingsOf = (project, decisions) => { - const known = new Map(decisions.files.map(file => [file.path, file])) - const mine = Object.values(rt.state?.meetings ?? {}) - .filter(meeting => meeting !== null && typeof meeting === 'object' && inProject(meeting, project)) - .sort((a, b) => (b.decidedAt ?? b.updatedAt ?? 0) - (a.decidedAt ?? a.updatedAt ?? 0) || String(a.id).localeCompare(String(b.id))) - const decided = [] - const pending = [] - for (const meeting of mine) { - const id = text(meeting.id) - const question = text(meeting.question ?? meeting.title) - if (meeting.status === 'decided') { - // A decided meeting is the decision's record, not the file's: the file may have been - // deleted or copied away, and the meeting still says what was decided. `path` is empty - // exactly then, and the reader can see the file the record names is gone. - const named = text(meeting.decisionFile) - const file = known.get(named) - // #2033: `files` keeps the newest 200 rows (#2021), so a name can miss there while this read - // still saw the file in the folder. That fallback asks this read's own dedupe set — never - // the filesystem — so a name like `../outside.md` cannot become a path. - const pastCut = file === undefined && decisions.known?.has(named) === true - decided.push({ - id, - date: dayOf(meeting.decidedAt), - question, - owner: text(meeting.owner) || null, - ...cardOf(meeting), - path: file !== undefined ? file.path : pastCut ? named : '', - ...(file === undefined ? {} : { sha256: file.sha256 }), - }) - continue - } - // Only a decided meeting is a decision. A meeting still open, deliberating or blocked is - // listed beside those, with the state it is in, so nobody reads a question as an outcome. - if (meeting.status === 'open' || meeting.status === 'deliberating' || meeting.status === 'blocked') { - pending.push({ - id, - date: dayOf(meeting.createdAt), - question, - status: meeting.status, - ...cardOf(meeting), - ...(meeting.status === 'blocked' && text(meeting.blockingQuestion) !== '' ? { blockingQuestion: text(meeting.blockingQuestion) } : {}), - }) - } - } - return { - decided: decided.slice(0, MEETINGS_MAX), - pending: pending.slice(0, MEETINGS_MAX), - // #2021: the meetings kept the newest already; what was missing was the same count the - // decisions read now carries, per list, taken before the cut. - decidedTotal: decided.length, - pendingTotal: pending.length, - reason: mine.length === 0 ? 'no meeting of this Project is recorded' : '', - } - } - - // One board: the task rows, then the decisions and meetings that read beside them. An unknown - // Project and a Project with nothing under it both answer empty sections: reading a board is - // not a reason to throw. - const projectBoard = async (projectId, actor, options = {}) => { - const id = text(projectId) - const empty = { - rows: [], - // #1855: the finished cards are the archive beside the rows, not rows of their own. A Project - // that months of work already closed reads as the work in flight plus one count. - done: [], - waiting: [], - inflight: [], - blocked: [], - counts: { waiting: 0, inflight: 0, blocked: 0, done: 0 }, - decisions: { dir: null, files: [], total: 0, reason: 'no Project: nothing to read' }, - meetings: { decided: [], pending: [], decidedTotal: 0, pendingTotal: 0, reason: 'no Project: nothing to read' }, - metrics: { ok: false, reason: 'no Project: nothing to read', metrics: {}, running: [] }, - } - if (id === '') return empty - const project = rt.readProject?.(id) - if (!project) return empty - // A store that cannot be read is not an empty store (#1768): `null` keeps the two apart. - const tasks = rt.state?.tasks - const readable = tasks !== null && typeof tasks === 'object' - const refs = new Map() - for (const ref of rt.projectRunRefs?.({ hasTask: taskId => Object.hasOwn(tasks ?? {}, taskId) }) ?? []) { - refs.set(ref.taskId, ref) - } - // #1723: a row is painted only when the caller could open that card through `tasks`. The - // predicate lives in tasks.js (`rt.taskVisible`); a host that has not installed it shows - // nothing, because an unreadable rule is not a reason to widen a board. - const visible = typeof rt.taskVisible === 'function' ? rt.taskVisible : () => false - // #1846: the Project's own daily digest reports the facts of the Project, not one member's page, - // and it is the one caller that says so explicitly. The flag is read from this argument and - // nowhere else: the channel builds `options` from the zone alone, so no request body reaches it. - const internal = options.internal === true - const rows = [] - const mine = [] - for (const task of readable ? Object.values(tasks) : []) { - if (task === null || typeof task !== 'object') continue - if (!internal && !visible(task, actor)) continue - const board = boardOf(task) - // The Project of the card's own board. A card that has a board already belongs somewhere; a - // board whose member is unbound names no Project, and only then may a run carry the card. - const ownProject = board === null ? null : rt.projectOfMember?.(board.kind, board.id) - const ref = refs.get(task.id) - const runProject = ref === undefined ? null : rt.projectOfMember?.('run', ref.runId) - // The card's own board wins, and it is read whether or not it names this Project: a card - // bound elsewhere is not borrowed through the run that happened to consume it. The run - // carries only a card with no Project of its own, and it lends its id only to this board - // — #1713: both halves let the far Project's run put its card here and its id on our row. - const source = ownProject !== null - ? ownProject.id === id ? board.kind : null - : runProject?.id === id ? 'workflow' : null - if (source === null) continue - mine.push(task) - const fromRun = ref !== undefined && (runProject === null || runProject.id === id) - const audit = typeof rt.taskAudit === 'function' ? rt.taskAudit(task.id) : '' - const title = text(task.title) - rows.push({ - id: task.id, - source, - // The card's own text and priority, read here so a row is enough to draw a card. A finished - // card is not in the open `tasks` read, and a join by id would leave the Done column with - // `#` and no priority; a record stored before this field reads `null` / `'normal'`. - title: title === '' ? null : title, - priority: priorityOf(task), - // The board this row opens (#2198), so a finished card — which the open `tasks` read no - // longer lists — still opens from the Done column instead of drawing as a dead card. It - // names what `source` names: a run-carried row (`source: 'workflow'`) has no board of its - // own to open, which is the same `null` the client derives for it today. - board: source === 'workflow' || board === null ? null : { kind: board.kind, id: board.id }, - column: columnOf(task), - assignee: task.assignee ?? null, - // #1831: "who is doing" is the claim, not the assignee label, and "who is speaking for - // whom" is `speaksFor` — the Assistant writing for a user says so. Both are host truth - // the client only prints; a card carrying neither reads exactly as it did. - ...(text(task.claimedBy) !== '' ? { holder: text(task.claimedBy) } : {}), - ...(text(task.speaksFor) !== '' ? { speaksFor: text(task.speaksFor) } : {}), - updatedAt: task.updatedAt ?? task.createdAt ?? null, - // The typed contract (#1720) is read from the same row, not a second copy: a board that - // shows a rework or a held lease shows what the task store wrote. A record stored before - // the slice carries none of the keys and reads back exactly as it did. - ...(task.taskClass ? { taskClass: task.taskClass } : {}), - ...(task.reworkOf ? { reworkOf: task.reworkOf } : {}), - ...(task.handoff && typeof task.handoff === 'object' ? { handoff: task.handoff } : {}), - ...(Array.isArray(task.evidence) ? { evidence: task.evidence } : {}), - ...(task.lease && typeof task.lease === 'object' ? { lease: task.lease } : {}), - // The same projection the task store renders (#1730): one text block a card detail can - // show, with nothing written back. A pre-slice card answers the empty string and the row - // carries no `audit` key at all. - ...(audit !== '' ? { audit } : {}), - ...(fromRun ? { runId: ref.runId, ...(ref.nodeId ? { nodeId: ref.nodeId } : {}) } : {}), - }) - } - // #1855: a card in the `done` column is a finished record, not work in flight — the archive - // read keeps it (in the same row shape and order) under `done`, and `rows` carries the rest. - // Only `done` leaves here: a rework, a blocked card and a waiting card are still rows, because - // the board is where a reader looks for the work that is still somebody's. - const done = rows.filter(row => row.column === DONE_COLUMN) - const open = rows.filter(row => row.column !== DONE_COLUMN) - // An unreadable store answers unknown (#1768): `0` would claim the queue is empty, and the - // board must not turn "cannot read" into "nothing waiting". - const queues = !readable - ? { waiting: [], inflight: [], blocked: [], counts: { waiting: null, inflight: null, blocked: null, done: null } } - : buildQueues(mine, done.length) - const decisions = await decisionsOf(project) - // #2041: the set an opened row is checked against is this read's own listing, kept per Project - // because a page polls this read every few seconds. Opening a decision never lists the folder - // again: it reads one of these names, or answers `not listed` and touches nothing. - // #2041: names and folders only — never an actor's view of them. Visibility is computed per - // call in `projectDecision`, so one member reading the board cannot hand its own reach to the - // next caller, and revoking a card takes effect on the next open. - readings.set(id, { dirs: decisions.known, cards: decisions.cards }) - // `known` is this read's own path set for the meeting lookup (#2033); the page renders - // `dir`/`files`/`total`/`reason`, so it does not travel with the board. - const servedDecisions = { dir: decisions.dir, files: decisions.files, total: decisions.total, reason: decisions.reason } - // #1829: the seven numbers ride on the same read as the rows, so the board and its header - // cannot disagree, and a credential-less host answers `ok: false` (the client paints a dash). - // #1848: the running table and the done-task median read `mine` — exactly the cards this - // reader was allowed above — never the deployment's task table. - const metrics = await readMetrics({ - repo: text(rt.githubRepo) || 'ThinkFlowLab/FlyBot', - token: text(rt.secretValue?.('GITHUB_TOKEN')), - fetchImpl: globalThis.fetch, - timeZone: text(options.timeZone) || 'UTC', - now: typeof rt.now === 'function' ? rt.now() : Date.now(), - tasks: mine, - }) - return { rows: open, done, ...queues, decisions: servedDecisions, meetings: meetingsOf(project, decisions), metrics } - } - const buildQueues = (mine, done) => { - const now = typeof rt.now === 'function' ? rt.now() : Date.now() - const waiting = [] - const inflight = [] - const blocked = [] - for (const task of mine) { - const wait = waitingOf(task, now) - if (wait !== null) waiting.push(wait) - const flight = inflightOf(task) - if (flight !== null) { - const lease = task.lease !== null && typeof task.lease === 'object' ? task.lease : null - const live = lease !== null && Number.isFinite(lease.expiresAt) && lease.expiresAt > now - inflight.push({ ...flight, lease: lease === null ? 'none' : live ? 'live' : 'expired', stalled: lease !== null && !live }) - } - const block = blockedOf(task, now) - if (block !== null) blocked.push(block) - } - // Oldest wait first, in both groups: the card that has waited longest is the one a person wants - // next, and a blocked card that has been stuck for days belongs above one stuck for minutes. - waiting.sort(oldestFirst) - blocked.sort(oldestFirst) - return { waiting, inflight, blocked, counts: { waiting: waiting.length, inflight: inflight.length, blocked: blocked.length, done } } - } - // #1828: the endpoint the host calls for a role switch. It answers the policy's own decision; - // the store is what persists the two assignable lists. - rt.projectRole = (project, { actorId, targetId, role } = {}) => changeRole(project, { actorId, targetId, role }) - // The listing's own read (#1828): the caller's rank and the ranks they may switch to. Both are - // the functions the endpoint uses, so the sidebar menu offers exactly what the host accepts. - rt.projectRoleOf = (project, memberId) => roleOf(project, memberId) - rt.projectRoleChoices = (project, memberId) => roleChoices(project, memberId) - rt.projectBoard = projectBoard - // #2041: the text behind one decision row. The accepted set is the listing this host produced for - // that Project — the names the 200-row cut dropped included — so a name outside it answers - // `not listed` without a stat, an open or a byte of content: opening a row is never a second, - // wider read path beside `readProjectState`. The name shape and the `lstat` read are #2038's own; - // this adds the membership check and nothing else. - // #2041: who may open a listed name. The text is behind the same door as the cards (#1723), and - // the two kinds of name have two doors: one a decided meeting named follows that meeting's own - // cards through `rt.taskVisible`, the rule the board paints them with, while a plain record has - // no card and follows the Project's role list (`roleOf`). Neither branch widens when it cannot - // judge: a missing predicate refuses rather than allows. - const decisionVisible = (project, actor, taskIds) => { - // One human on this host: the rule every task read already uses hides nothing from them. - if (rt.authzEnforced?.() !== true) return true - const role = roleOf(project, actor?.memberId) - if (taskIds === undefined) return role !== null - const cards = taskIds.map(id => rt.state?.tasks?.[id]).filter(task => task !== null && typeof task === 'object') - // A decided meeting that produced no card has no card to judge, so the role list decides. - if (cards.length === 0) return role !== null - const visible = typeof rt.taskVisible === 'function' ? rt.taskVisible : null - if (visible === null) return false - return cards.some(task => visible(task, actor)) - } - rt.projectDecision = async (projectId, file, actor) => { - const name = text(file) - if (!isDecisionPath(name)) return { ok: false, reason: `not listed: ${name} is not a decision file` } - const id = text(projectId) - const project = rt.readProject?.(id) - if (project === null || project === undefined) return { ok: false, reason: `no Project: ${id}` } - // A name this caller may not open answers exactly what a name the listing never had answers: - // one sentence for both, so the refusal cannot be used as a list of what exists. - const listed = { ok: false, reason: `not listed: ${name} is not in this board's decision list` } - const entry = readings.get(id) - if (entry === undefined) return listed - // #2041: visibility is read from the caller and the state as they are now, in memory, before - // any `stat` or `open` — a board read never froze one member's reach into this cache. - if (!decisionVisible(project, actor, entry.cards.get(name))) return listed - const dir = entry.dirs.get(name) - if (dir === undefined) return listed - // A file that left the folder since the listing answers `absent`; one over the bound answers - // `too large` with the size and no text. - return await readListedFile(dir, name) - } - // The three groups are their own read over the same pass, for a caller that wants only the queue. - rt.projectBoardQueues = async (projectId, actor) => { - const board = await projectBoard(projectId, actor) - return { waiting: board.waiting, inflight: board.inflight, blocked: board.blocked, counts: board.counts } - } -} diff --git a/patches/ds-bot/files/src/host/project-chief.js b/patches/ds-bot/files/src/host/project-chief.js deleted file mode 100644 index e956afec6..000000000 --- a/patches/ds-bot/files/src/host/project-chief.js +++ /dev/null @@ -1,82 +0,0 @@ -// The Project Chief (#1859): every Project gets its own Chief, created with the Project. It is not -// the user's main Chief acting part-time for a Project — the main Chief stays outside, and this one -// speaks for exactly one Project. The record carries `chief: { botId, sessionId, createdAt }`, the -// same place and shape the assistant uses, and `project-delete` retires it with the Project. -// -// Nothing here invents a schedule or a memory of its own: the Chief is one hidden Bot with a Session -// and instructions, bound to the Project through `bindProject`, so a host that has no Chief seam -// still creates the Project and records why (the store owns that fallback, not this module). -const CHIEF_ROLE = 'Project Chief' - -export const chiefName = project => `${CHIEF_ROLE} (${project.name})` - -// The one boundary that matters: the instructions name this Project and forbid answering for any -// other. Another Project's name is never quoted here, so two Chiefs cannot learn each other's scope -// from their own prompt. -export const chiefInstructions = project => [ - `You are the Project Chief of "${project.name}" (Project id ${project.id}). You are that Project's own Chief, not the user's main Chief acting part-time.`, - 'You speak for that Project and for no other: never answer for, decide for, or report on another Project, and never let another Project\'s work be attributed to this one.', - 'The user\'s main Chief stays outside this Project. Report to it when asked, but keep this Project\'s work and its record here.', -].join('\n\n') - -const text = value => (typeof value === 'string' ? value.trim() : '') -const refusal = (message, code = 'bot/invalid') => Object.assign(new Error(message), { code }) - -export function install(rt) { - const projectOf = id => rt.readProject?.(text(id)) ?? null - - const activeProject = (id) => { - const project = projectOf(id) - if (project === null) throw refusal(`Unknown Project ${text(id) || '(none)'}`, 'bot/not-found') - if (project.status === 'archived') throw refusal(`Project ${project.name} is archived`, 'bot/refused') - return project - } - - // The record is the answer; a Bot that has since been deleted is not a Chief, so the read - // resolves `null` instead of handing back a dead id. - const chiefOf = (projectId) => { - const project = projectOf(projectId) - if (project === null) return null - const chief = project.chief - if (typeof chief?.botId !== 'string' || chief.botId === '') return null - return rt.botOf?.(chief.botId) === undefined ? null : chief - } - - const create = async (projectId) => { - const project = activeProject(projectId) - const existing = chiefOf(project.id) - if (existing !== null) return { project, chief: existing, created: false } - const bot = await rt.createBot({ - name: chiefName(project), - role: CHIEF_ROLE, - instructions: chiefInstructions(project), - hidden: true, - ownerId: text(project.ownerUserId) || rt.initialOwnerId?.(), - }) - // The Chief and its binding are plumbing, not a change of the Project: a Project created with - // one still answers `createdAt === updatedAt`. - const stamped = project.updatedAt - rt.bindProject?.({ kind: 'bot', id: bot.id, projectId: project.id }) - project.updatedAt = stamped - const chief = { botId: bot.id, sessionId: rt.chatOf?.(bot.id) ?? bot.id, createdAt: Date.now() } - project.chief = chief - await rt.save?.() - return { project, chief, created: true } - } - - // Retirement goes through the host's own Bot deletion, so the Session, the memory archive and the - // board cards are handled by the same path every other Bot deletion takes. - const remove = async (projectId) => { - const project = projectOf(projectId) - if (project === null) return null - const chief = chiefOf(project.id) ?? (typeof project.chief?.botId === 'string' ? project.chief : null) - if (chief === null) return null - await rt.deleteBot?.(chief.botId) - .catch(error => rt.warn?.('ds-bot: removing the Project Chief Bot failed: %s', error?.message ?? error)) - delete project.chief - await rt.save?.() - return chief - } - - rt.projectChief = { create, chiefOf, remove, chiefName, chiefInstructions, CHIEF_ROLE } -} diff --git a/patches/ds-bot/files/src/host/project-host.js b/patches/ds-bot/files/src/host/project-host.js new file mode 100644 index 000000000..90cf84fae --- /dev/null +++ b/patches/ds-bot/files/src/host/project-host.js @@ -0,0 +1,56 @@ +// The Project domain's host seam (#2684). The domain — Project State, the board, the Project's own +// Chief, the team dossier, the metrics and the assistant — moved to `plugins/project`, which ships +// as its own package and therefore cannot import `core/src/*`. The core hands it the two things it +// cannot get by itself: the runtime it puts its seams on, and the pure helpers the two packages +// share (one function each, never a copy: a second implementation of `cut` or `dayKey` would drift). +// +// The seam is the *core's* side of the contract, not a third-party plugin surface: only +// `plugins/project` asks for it, and only in its `apply()`. A core without the plugin installed +// never calls it — the store's Project endpoints then keep the fallbacks they already have (a +// Project is created without a Chief or an assistant, the board and the dossier answer empty). +// +// Every helper is a pure function or a constant; nothing here reads state or writes anything when +// the object is built, so a caller can build it once at startup. +import { ASSISTANT_ROLE, MAX_ENTRY } from './constants.js' +import { dayKey } from './day-key.js' +import { advanceRefusal } from './meetings.js' +import { dirKind } from './real-dir.js' +import { codePoints, cut } from './text.js' +import { defineTool } from './tool-schema.js' +import { output } from './tools.js' +import { workDirFor } from './work-dir.js' +import { clockIn, inQuietHours, quietHoursOf, readQuietHours } from '../shared/quiet-hours.js' +import { columnOf, priorityOf } from '../shared/task-board.js' + +export const HELPER_NAMES = Object.freeze([ + 'cut', 'codePoints', 'dirKind', 'defineTool', 'output', 'columnOf', 'priorityOf', 'advanceRefusal', + 'workDirFor', 'dayKey', 'MAX_ENTRY', 'ASSISTANT_ROLE', + 'inQuietHours', 'clockIn', 'quietHoursOf', 'readQuietHours', +]) + +export function projectHost(rt) { + return { + // The live runtime: the seams the domain installs (`rt.projectBoard`, `rt.projectChief`, + // `rt.projectTeam`, `rt.projectAssistant`) are the ones the core's store, meetings and channel + // already call by those names. + runtime: rt, + helpers: { + cut, + codePoints, + dirKind, + defineTool, + output, + columnOf, + priorityOf, + advanceRefusal, + workDirFor, + dayKey, + MAX_ENTRY, + ASSISTANT_ROLE, + inQuietHours, + clockIn, + quietHoursOf, + readQuietHours, + }, + } +} diff --git a/patches/ds-bot/files/src/host/project-team.js b/patches/ds-bot/files/src/host/project-team.js deleted file mode 100644 index c97459d24..000000000 --- a/patches/ds-bot/files/src/host/project-team.js +++ /dev/null @@ -1,89 +0,0 @@ -// The Project board's team dossier (#1864). One read-only answer for the people who serve a -// Project: its own Chief (#1859), the assistant that speaks for the caller (#1712) and the role -// pool the Project names (#1828). Nothing here writes persona, instructions or memory; editing stays -// in the Project settings panel, and a later slice that wants an editor opens its own issue. -// -// Persona is the Bot's `instructions`, memory is the head of its own MEMORY.md plus the topic names -// (never a topic body, never another Project's memory), and skills are the attached capabilities the -// library records as `skill` — a pack contributes the skills it contains. The owner decided on -// 2026-10-30 that persona and memory are shared by the Project's members and kept as files in the -// repository; this slice reads what the Bot carries today, and adds no writer. -// -// Every string and list is capped, and a Bot that was deleted is a `null` row: one dead Chief must -// not take the assistant and the pool down with it. The row shape is the contract the client's -// dossier panel draws, so it carries exactly those keys and nothing that names an owner, a working -// folder or a stored secret. -import { rolesOf } from './project-board.js' -import { cut } from './text.js' - -export const DOSSIER_LIMITS = { persona: 4000, memoryHead: 4000, topics: 64, skills: 64, rooms: 32, name: 200, role: 120, model: 200, color: 64 } - -const text = value => (typeof value === 'string' ? value.trim() : '') -// #2005: every capped field lands on a code point — a `slice` bound could end on the first half -// of an emoji, which the panel draws as a replacement character. -const capped = (value, limit) => cut(text(value), limit).text - -export function install(rt) { - // The attached names that are skills. A pack is expanded to its skill children; a capability the - // library no longer holds is kept by name so a moved library does not erase what the Bot has. - const skillNames = (bot) => { - const library = typeof rt.capabilityLibraryView === 'function' ? rt.capabilityLibraryView() : [] - const byName = new Map((Array.isArray(library) ? library : []).map(item => [text(item?.name), item])) - const names = [] - const add = (name) => { - const clean = text(name) - if (clean !== '' && !names.includes(clean)) names.push(clean) - } - for (const attached of Array.isArray(bot.capabilities) ? bot.capabilities : []) { - const item = byName.get(text(attached)) - if (item === undefined) add(attached) - else if (item.kind === 'skill') add(attached) - else if (item.kind === 'pack') for (const child of item.items ?? []) if (byName.get(text(child))?.kind === 'skill') add(child) - } - return names.slice(0, DOSSIER_LIMITS.skills) - } - - // Only this Project's rooms: a dossier is the Project's own read, so a Bot that also works in - // another Project's room does not carry that room's name into this answer. - const roomsOf = (bot, project) => Object.values(rt.state?.rooms ?? {}) - .filter(room => room !== null && typeof room === 'object' && Array.isArray(room.members) && room.members.includes(bot.id)) - .filter(room => rt.projectOfMember?.('group', room.id)?.id === project.id) - .map(room => ({ id: text(room.id), name: cut(text(room.name), 200).text })) - .slice(0, DOSSIER_LIMITS.rooms) - - const rowOf = (botId, project) => { - const id = text(botId) - if (id === '') return null - const bot = rt.botOf?.(id) - if (bot === undefined || bot === null) return null - const digest = rt.memoryDigest?.(id) ?? { head: '', topics: [] } - return { - botId: id, - name: capped(bot.name, DOSSIER_LIMITS.name), - role: capped(bot.role, DOSSIER_LIMITS.role), - persona: capped(bot.instructions, DOSSIER_LIMITS.persona), - model: capped(bot.model, DOSSIER_LIMITS.model), - color: capped(bot.color, DOSSIER_LIMITS.color), - createdAt: Number.isFinite(bot.createdAt) ? bot.createdAt : null, - memory: { - head: cut(typeof digest.head === 'string' ? digest.head : '', DOSSIER_LIMITS.memoryHead).text, - topics: (Array.isArray(digest.topics) ? digest.topics : []).map(name => text(name)).filter(name => name !== '').slice(0, DOSSIER_LIMITS.topics), - }, - skills: skillNames(bot), - rooms: roomsOf(bot, project), - } - } - - // The order is the board's own: Chief, assistant, then the pool. A Project with no Chief keeps - // the first slot as `null`, so the client always reads the same three groups. - const projectTeam = (projectId) => { - const project = rt.readProject?.(text(projectId)) ?? null - if (project === null) return { rows: [] } - const roles = rolesOf(project) - const pool = [...roles.maintainers, ...roles.members].filter((id, at, all) => all.indexOf(id) === at) - const ids = [text(project.chief?.botId), text(project.assistant?.botId), ...pool] - return { rows: ids.map(id => rowOf(id, project)) } - } - - rt.projectTeam = projectTeam -} diff --git a/patches/ds-bot/files/src/host/turn-budget.js b/patches/ds-bot/files/src/host/turn-budget.js index 5c1aae588..6422ab5c6 100644 --- a/patches/ds-bot/files/src/host/turn-budget.js +++ b/patches/ds-bot/files/src/host/turn-budget.js @@ -12,7 +12,7 @@ // Whose turn it is comes from turns.js's recorded verdict (turn-origin.js): `owner` is the user's // own message or a line they wrote in a group; everything else — `scheduled`, `other`, or a turn // whose messages carried no verdict — is system-started and waits for the counter to reset. -import { dayKey } from './project-metrics.js' +import { dayKey } from './day-key.js' import { OWNER } from './turn-origin.js' // The cap a fresh team starts with, and the day a team counted in before it had a zone of its own. diff --git a/patches/ds-bot/files/src/host/work-dir.js b/patches/ds-bot/files/src/host/work-dir.js new file mode 100644 index 000000000..655c3299d --- /dev/null +++ b/patches/ds-bot/files/src/host/work-dir.js @@ -0,0 +1,15 @@ +// The Session's working folder (#2684): the cwd its Agent runs in, else the folder the Bot or room +// is set to work in, else the team workspace. Undefined only when none of them is known. +// +// This lived in `project-state.js` while that reader was the only caller; the meetings module +// needs the same answer (the folder a decision is archived in), so it is its own module now that +// the Project domain moved to `plugins/project` — the plugin gets it back through the host seam +// `flybot.projectHost()` (`core/src/host/project-host.js`). +export function workDirFor(rt, exec) { + const cwd = exec?.agent?.session?.header?.cwd + if (typeof cwd === 'string' && cwd !== '') return cwd + const botId = rt.selfOf?.(exec?.agent?.id) ?? exec?.agent?.id + const dir = rt.workDirOf?.({ botId, roomId: exec?.agent?.session?.header?.roomId }) + if (typeof dir === 'string' && dir !== '') return dir + return rt.teamFolder?.() +} diff --git a/patches/ds-bot/files/test/install-doctor.test.js b/patches/ds-bot/files/test/install-doctor.test.js index 91bf55ab9..5513c73ac 100644 --- a/patches/ds-bot/files/test/install-doctor.test.js +++ b/patches/ds-bot/files/test/install-doctor.test.js @@ -97,18 +97,19 @@ test('checkPluginPackage: a numeric dsh.bundle.patch fails without throwing', () } }) -test('buildInstallCommands: core, social, tree and workflow; timeline only on request (#281)', () => { +test('buildInstallCommands: core, social, tree, workflow and project; timeline only on request (#281)', () => { const commands = buildInstallCommands() - assert.equal(commands.length, 4) + assert.equal(commands.length, 5) assert.ok(commands.every(command => command.includes('0.2.1-rc.2'))) assert.ok(commands[0].includes('path:/core')) assert.ok(commands[1].includes('path:/plugins/social')) assert.ok(commands[2].includes('path:/plugins/tree')) assert.ok(commands[3].includes('path:/plugins/workflow')) + assert.ok(commands[4].includes('path:/plugins/project')) assert.equal(commands.some(command => command.includes('timeline')), false) const all = buildInstallCommands({ timeline: true }) - assert.equal(all.length, 5) - assert.ok(all[4].includes('path:/plugins/timeline')) + assert.equal(all.length, 6) + assert.ok(all[5].includes('path:/plugins/timeline')) }) test('runDoctor: the real repository passes on a Node version at the floor', () => { @@ -129,7 +130,7 @@ test('runDoctor: an empty root fails the plugin checks', () => { const report = runDoctor({ root: dir, nodeVersion: 'v22.19.0' }) assert.equal(report.ok, false) const pluginChecks = report.checks.filter(check => check.label.includes('plugin package')) - assert.equal(pluginChecks.length, 5) + assert.equal(pluginChecks.length, 6) assert.ok(pluginChecks.every(check => !check.ok)) } finally { rmSync(dir, { recursive: true, force: true }) diff --git a/patches/ds-bot/files/test/meetings.test.js b/patches/ds-bot/files/test/meetings.test.js index 2ea3d10ba..a5da1b0e5 100644 --- a/patches/ds-bot/files/test/meetings.test.js +++ b/patches/ds-bot/files/test/meetings.test.js @@ -10,6 +10,7 @@ import { join } from 'node:path' import { test } from 'node:test' import { createRuntime } from '../index.js' import { fakeContext } from './fake-ctx.js' +import { installProjectDomain } from './project-domain.js' const team = () => ({ version: 2, @@ -37,6 +38,7 @@ async function boot() { sessions: { flush: async () => {} }, }) const rt = createRuntime(ctx, { home }) + installProjectDomain(rt) await rt.load() const dir = mkdtempSync(join(tmpdir(), 'ds-bot-meetings-work-')) const exec = (id = 'a') => ({ agent: { id, session: { header: { cwd: dir } } } }) @@ -268,7 +270,7 @@ test('#1861: a severe decision is refused when its only agree position is a prox test('#1861: the decision card exposes the participants and their proxies', async (t) => { const { rt, open, meeting, stop } = await boot() t.after(stop) - const { decisionCardOf } = await import('../src/host/project-board.js') + const { decisionCardOf } = await import('../../plugins/project/src/project-board.js') const id = await open('Who holds the storage decision?', ['Chief', 'Owner']) await meeting({ action: 'position', meeting: id, as: 'Chief', stance: 'agree', text: 'ship it', speaksFor: 'Owner' }) const card = decisionCardOf(rt.state.meetings[id]) diff --git a/patches/ds-bot/files/test/plugin-switches.test.js b/patches/ds-bot/files/test/plugin-switches.test.js index 25e4be14d..04b554bdf 100644 --- a/patches/ds-bot/files/test/plugin-switches.test.js +++ b/patches/ds-bot/files/test/plugin-switches.test.js @@ -21,14 +21,14 @@ const runtime = (state) => { } test('every switch is on until state.json says otherwise', () => { - assert.deepEqual(FEATURE_PLUGIN_IDS, ['social', 'tree', 'timeline', 'workflow']) - assert.deepEqual(pluginSwitches({}).map(plugin => plugin.enabled), [true, true, true, true]) + assert.deepEqual(FEATURE_PLUGIN_IDS, ['social', 'tree', 'timeline', 'workflow', 'project']) + assert.deepEqual(pluginSwitches({}).map(plugin => plugin.enabled), [true, true, true, true, true]) assert.deepEqual(pluginSwitches({ flybot: { plugins: { workflow: { enabled: false } } } }).map(plugin => [plugin.id, plugin.enabled]), [ - ['social', true], ['tree', true], ['timeline', true], ['workflow', false], + ['social', true], ['tree', true], ['timeline', true], ['workflow', false], ['project', true], ]) }) -test('a switch only takes the four known ids, and true/false only', () => { +test('a switch only takes a known id, and true/false only', () => { const state = {} assert.equal(setPluginSwitch(state, 'social', false).find(plugin => plugin.id === 'social').enabled, false) assert.deepEqual(state.flybot.plugins.social, { enabled: false }) diff --git a/patches/ds-bot/files/test/project-board-levels.test.js b/patches/ds-bot/files/test/project-board-levels.test.js index 8f820077d..e51c32a02 100644 --- a/patches/ds-bot/files/test/project-board-levels.test.js +++ b/patches/ds-bot/files/test/project-board-levels.test.js @@ -8,7 +8,8 @@ import { fileURLToPath } from 'node:url' import { runInNewContext } from 'node:vm' import { build } from 'esbuild' import { advanceRefusal, decisionLevel } from '../src/host/meetings.js' -import { decisionCardOf, install } from '../src/host/project-board.js' +import { decisionCardOf, install } from '../../plugins/project/src/project-board.js' +import '../../plugins/project/test/host-bind.js' const bundle = await build({ entryPoints: [fileURLToPath(new URL('../src/client/project-board.js', import.meta.url))], diff --git a/patches/ds-bot/files/test/project-board-panels.test.js b/patches/ds-bot/files/test/project-board-panels.test.js index 668dc8bbf..5aa6d6ee2 100644 --- a/patches/ds-bot/files/test/project-board-panels.test.js +++ b/patches/ds-bot/files/test/project-board-panels.test.js @@ -15,6 +15,7 @@ import { runInNewContext } from 'node:vm' import { build } from 'esbuild' import { createRuntime } from '../index.js' import { fakeContext } from './fake-ctx.js' +import { installProjectDomain } from './project-domain.js' // The entry carries the dictionary beside the panels: `t` and `hostText` reach the panel only // through the same copy of `i18n.js`, and that is true inside one bundle and nowhere else. @@ -128,6 +129,7 @@ async function startHost(t, state = seed()) { sessions: { flush: async () => {} }, }) const rt = createRuntime(ctx, { home }) + installProjectDomain(rt) await rt.load() t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) return rt diff --git a/patches/ds-bot/files/test/project-board.test.js b/patches/ds-bot/files/test/project-board.test.js deleted file mode 100644 index a8e71d800..000000000 --- a/patches/ds-bot/files/test/project-board.test.js +++ /dev/null @@ -1,1720 +0,0 @@ -// One Project, one board (#1696): a core task record is the identity, and a workflow run names -// that same id through `run.task.id`. The aggregate reads both views as one row, never twice, -// and writes nothing back. -import assert from 'node:assert/strict' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' -import { test } from 'node:test' -import { createRuntime, provideFlyBot } from '../index.js' -import { blockedOf } from '../src/host/project-board.js' -import { MAX_BYTES } from '../src/host/project-state.js' -import { fakeContext } from './fake-ctx.js' - -const seed = () => ({ - version: 2, - revision: 1, - mainBotIds: ['a'], - members: { 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' } }, - bots: { - a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1, ownerId: 'owner-1' }, - runner: { id: 'runner', name: 'Runner', role: 'Run', sessionId: 'runner', createdAt: 2, ownerId: 'owner-1' }, - }, - rooms: { r1: { id: 'r1', name: 'Ops', members: ['a'], mode: 'admin', admin: 'a', named: true, sessions: {}, log: [], seq: 0, seen: {}, projectId: 'project-p1' } }, - agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, grants: {}, - projects: { - 'project-p1': { id: 'project-p1', name: 'P1', repository: 'ThinkFlowLab/FlyBot', defaultBranch: 'main', ownerUserId: 'owner-1', createdAt: 1, updatedAt: 1, status: 'active' }, - 'project-empty': { id: 'project-empty', name: 'Empty', repository: null, defaultBranch: 'main', ownerUserId: 'owner-1', createdAt: 2, updatedAt: 2, status: 'active' }, - }, - projectRuns: { 'run-1': 'project-p1' }, - tasks: { - // A group card a run consumed: its board carries the Project. - 'task-group': { id: 'task-group', kind: 'group', status: 'working', ownerBotId: 'a', origin: { roomId: 'r1' }, assignee: 'Writer', title: 'Ship it', createdAt: 5, updatedAt: 10 }, - // A card the run created: no board Project, the run's binding carries it. - 'task-run': { id: 'task-run', status: 'working', ownerBotId: 'runner', title: 'Run card', createdAt: 7, updatedAt: 20 }, - }, -}) - -// #1713: a second Project whose run holds the first Project's card. Nothing here belongs in -// `seed()`: the leak needs a run binding of the far Project on a card that has a board of its own. -const splitSeed = () => { - const team = seed() - team.rooms.r2 = { id: 'r2', name: 'Garden', members: ['a'], mode: 'admin', admin: 'a', named: true, sessions: {}, log: [], seq: 0, seen: {}, projectId: 'project-p2' } - team.projects['project-p2'] = { id: 'project-p2', name: 'P2', repository: 'ThinkFlowLab/FlyBot', defaultBranch: 'main', ownerUserId: 'owner-1', createdAt: 1, updatedAt: 1, status: 'active' } - team.projectRuns = { 'run-1': 'project-p1', 'run-2': 'project-p2' } - return team -} - -async function start(home = mkdtempSync(join(tmpdir(), 'ds-bot-board-')), team = seed()) { - if (!existsSync(join(home, 'state.json'))) writeFileSync(join(home, 'state.json'), JSON.stringify(team)) - const ctx = fakeContext() - let route - const agents = new Map() - const agentOf = (sessionId) => { - if (!agents.has(sessionId)) agents.set(sessionId, { session: { id: sessionId }, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle() {}, inbox: { nextStep: [], nextTurn: [], remove() {} } }) - return agents.get(sessionId) - } - Object.assign(ctx, { - llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, - workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, - sessionController: { create: async () => ({ sessionId: 's1' }), resolveAgent: async id => ({ agent: agentOf(id) }), selectModel: async () => {} }, - sessionTitle: { rename: async () => {} }, - sessions: { flush: async () => {} }, - }) - const register = ctx.connection.fetch.register - ctx.connection.fetch.register = (options) => { route = options; return register(options) } - const rt = createRuntime(ctx, { home }) - provideFlyBot(ctx, rt.flybot) - await rt.load() - const call = async (endpoint, payload) => (await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', body: JSON.stringify({ endpoint, payload }) }))).json() - // The same route with the member session cookie the browser sends, for the authz-on case. - const callAs = async (endpoint, payload, token) => (await route.fetch(new Request('http://localhost/api/bot', { - method: 'POST', - headers: { 'content-type': 'application/json', 'x-flybot-member-session': token }, - body: JSON.stringify({ endpoint, payload }), - }))).json() - const stop = async ({ keepHome = false } = {}) => { - await ctx.dispose() - if (!keepHome) rmSync(home, { recursive: true, force: true, maxRetries: 5 }) - } - return { call, callAs, stop, rt } -} - -const value = (response) => { - assert.equal(response.ok, true, JSON.stringify(response)) - return response.value -} - -// The group board's card and the run's card are two tasks, and each one is one row. -test('a Project board reads a group card and a run-created card without repeating a task', async () => { - const { rt, stop } = await start() - const off = rt.onProjectProvider(({ hasTask }) => [ - // A run consumed the group card: same id, so it joins that row instead of adding one. - { taskId: 'task-group', runId: 'run-2', nodeId: 'planner' }, - // The run created this card and its own Project binding carries it. - { taskId: 'task-run', runId: 'run-1', nodeId: 'writer' }, - // A run whose card is gone is not a row. - { taskId: 'task-missing', runId: 'run-3' }, - ].filter(row => hasTask(row.taskId))) - try { - const rows = (await rt.projectBoard('project-p1')).rows - assert.equal(rows.length, 2) - const group = rows.find(row => row.id === 'task-group') - assert.deepEqual(group, { id: 'task-group', source: 'group', title: 'Ship it', priority: 'normal', board: { kind: 'group', id: 'r1' }, column: 'in-progress', assignee: 'Writer', updatedAt: 10, runId: 'run-2', nodeId: 'planner' }) - const run = rows.find(row => row.id === 'task-run') - assert.deepEqual(run, { id: 'task-run', source: 'workflow', title: 'Run card', priority: 'normal', board: null, column: 'in-progress', assignee: null, updatedAt: 20, runId: 'run-1', nodeId: 'writer' }) - // Never the same task twice. - assert.equal(new Set(rows.map(row => row.id)).size, rows.length) - } finally { - off() - await stop() - } -}) - -// P-167: a finished card is still a card. The `tasks` read lists open cards only, so the Done -// column has no task record to join against — the row itself has to carry the text and the -// priority, or the card draws as `#` with no priority at all. -test('a finished card carries its title and priority on the row the Done column draws', async () => { - const team = seed() - team.tasks['task-done'] = { id: 'task-done', kind: 'group', status: 'accepted', ownerBotId: 'a', origin: { roomId: 'r1' }, assignee: 'Writer', title: 'Ship the release', priority: 'high', createdAt: 3, updatedAt: 4 } - const { rt, stop } = await start(undefined, team) - try { - const board = await rt.projectBoard('project-p1') - const card = board.done.find(row => row.id === 'task-done') - assert.equal(card.title, 'Ship the release') - assert.equal(card.priority, 'high') - // The read the page joins against keeps open cards only, so a finished card is not in it. - const listed = await rt.taskEndpoint('tasks', {}, null) - assert.equal(listed.some(task => task.id === 'task-done'), false) - } finally { - await stop() - } -}) - -// #2198: a row names the board it opens, so the Done column is not a shelf of dead cards. With no -// `tasks` record to join, the client had no way to derive the board and the card drew without a -// click at all; the row carries it, and a run-carried row keeps naming none of its own. -test('a finished card names the board it opens on the row the Done column draws', async () => { - const team = seed() - team.tasks['task-done'] = { id: 'task-done', kind: 'group', status: 'accepted', ownerBotId: 'a', origin: { roomId: 'r1' }, assignee: 'Writer', title: 'Ship the release', priority: 'high', createdAt: 3, updatedAt: 4 } - const { rt, stop } = await start(undefined, team) - const off = rt.onProjectProvider(({ hasTask }) => [ - { taskId: 'task-run', runId: 'run-1' }, - ].filter(row => hasTask(row.taskId))) - try { - const board = await rt.projectBoard('project-p1') - const card = board.done.find(row => row.id === 'task-done') - assert.deepEqual(card.board, { kind: 'group', id: 'r1' }) - // The run carried this card in, and `source: 'workflow'` is the run's binding, not a board. - const runCard = board.rows.find(row => row.id === 'task-run') - assert.equal(runCard.source, 'workflow') - assert.equal(runCard.board, null) - } finally { - off() - await stop() - } -}) - - -test('the board takes its token from the host secret store and its zone from the call', async () => { - const { rt, stop } = await start() - const realSecret = rt.secretValue - const realFetch = globalThis.fetch - const asked = [] - const calls = [] - rt.secretValue = name => { asked.push(name); return name === 'GITHUB_TOKEN' ? 'tok' : undefined } - globalThis.fetch = async (url, init) => { calls.push([String(url), init.headers.Authorization]); return { ok: true, json: async () => [] } } - try { - const board = await rt.projectBoard('project-p1', null, { timeZone: 'Asia/Shanghai' }) - // #1829: the numbers can only render when the token comes from the same store connectors.js - // reads, and the natural day is the reader's zone, which the request carries. - assert.equal(asked.includes('GITHUB_TOKEN'), true) - assert.equal(calls.length, 2) - assert.equal(calls[0][1], 'Bearer tok') - assert.equal(board.metrics.ok, true) - assert.equal(board.metrics.timeZone, 'Asia/Shanghai') - // No zone in the call falls back to UTC instead of the host's own idea of a day. - calls.length = 0 - const plain = await rt.projectBoard('project-p1') - assert.equal(plain.metrics.timeZone, 'UTC') - } finally { - globalThis.fetch = realFetch - rt.secretValue = realSecret - await stop() - } -}) - -// #1849: the request body's `timeZone` reaches `Intl` unvalidated, so one unknown name used to -// throw out of the whole board read — rows, queues and decisions with it — instead of costing the -// seven numbers. The board still returns its cards, and the metrics answer says which zone it used. -test('#1849: an unknown zone does not take the board down with the metrics', async () => { - const dir = mkdtempSync(join(tmpdir(), 'ds-bot-board-tz-')) - const { rt, stop } = await start(dir) - const realSecret = rt.secretValue - const realFetch = globalThis.fetch - rt.secretValue = name => (name === 'GITHUB_TOKEN' ? 'tok' : undefined) - globalThis.fetch = async () => ({ ok: true, json: async () => [] }) - try { - const before = readFileSync(join(dir, 'state.json')) - const board = await rt.projectBoard('project-p1', null, { timeZone: 'Not/AZone' }) - assert.deepEqual(board.rows.map(row => row.id), ['task-group'], 'the cards are still there') - assert.equal(board.counts.waiting, 0) - assert.equal(board.metrics.ok, true) - // The answer says which zone it used instead of inheriting the bad name. - assert.equal(board.metrics.timeZone, 'UTC') - // The fallback is decided in this read only: nothing about the request's zone is written back. - assert.deepEqual(readFileSync(join(dir, 'state.json')), before, 'the fallback is not saved to state') - assert.equal(before.includes('Not/AZone'), false, 'the bad name is not written to state') - } finally { - globalThis.fetch = realFetch - rt.secretValue = realSecret - await stop() - rmSync(dir, { recursive: true, force: true }) - } -}) - -test('an empty Project answers [] and a missing one is not an error', async () => { - const { rt, stop } = await start() - try { - // Without a provider the group board still reads on its own; the run-created card has no - // board of its own and no run binding to give, so it is not a row. - assert.deepEqual((await rt.projectBoard('project-p1')).rows.map(row => [row.id, row.source]), [['task-group', 'group']]) - assert.deepEqual((await rt.projectBoard('project-empty')).rows, []) - assert.deepEqual((await rt.projectBoard('project-nothing')).rows, []) - assert.deepEqual((await rt.projectBoard('')).rows, []) - } finally { await stop() } -}) - -test('reading the board leaves state.tasks and the run refs byte-identical', async () => { - const { rt, stop } = await start() - const refs = () => [{ taskId: 'task-group', runId: 'run-2' }, { taskId: 'task-run', runId: 'run-1' }] - const off = rt.onProjectProvider(() => refs()) - try { - const before = JSON.stringify({ tasks: rt.state.tasks, projects: rt.state.projects, projectRuns: rt.state.projectRuns }) - const refsBefore = JSON.stringify(rt.projectRunRefs()) - await rt.projectBoard('project-p1') - assert.equal(JSON.stringify({ tasks: rt.state.tasks, projects: rt.state.projects, projectRuns: rt.state.projectRuns }), before) - assert.equal(JSON.stringify(rt.projectRunRefs()), refsBefore) - } finally { - off() - await stop() - } -}) - -test('the project-board endpoint serves the same rows', async () => { - const { rt, call, stop } = await start() - const off = rt.onProjectProvider(() => [{ taskId: 'task-group', runId: 'run-2' }]) - try { - const rows = value(await call('project-board', { id: 'project-p1' })).tasks - assert.deepEqual(rows.map(row => row.id), ['task-group']) - assert.equal(rows[0].runId, 'run-2') - assert.deepEqual(value(await call('project-board', { id: 'project-nothing' })).tasks, []) - } finally { - off() - await stop() - } -}) - -// #1713 A: the endpoint is wired, the store is wired, only the allowlist line was missing, so a -// signed-in deployment answered `bot/not-found: Unknown endpoint project-board` while every -// sibling Project read answered ALLOW under the same actor. -test('#1713: a signed-in actor may read the Project board, like the Project record', async () => { - const { rt, callAs, stop } = await start() - try { - // Enforced means someone has signed in on this host; before that every endpoint answers. - rt.authenticate = async (token) => (token === 'tok-a' ? { memberId: 'owner-1' } : null) - rt.state.authSessions = { 's-a': { memberId: 'owner-1', tokenHash: 'a' } } - const actor = { memberId: 'owner-1' } - for (const endpoint of ['projects', 'project-read', 'project-of', 'project-bind']) { - assert.equal(rt.authorize(actor, endpoint, { id: 'project-p1' }).ok, true, `${endpoint} answers ALLOW`) - } - const gate = rt.authorize(actor, 'project-board', { id: 'project-p1' }) - assert.equal(gate.ok, true, `project-board is refused as ${gate.code}: ${gate.message}`) - // The same answer through the channel with the member cookie, not just the allowlist table. - const served = await callAs('project-board', { id: 'project-p1' }, 'tok-a') - assert.equal(served.ok, true, JSON.stringify(served)) - assert.deepEqual(served.value.tasks.map(row => row.id), ['task-group']) - } finally { await stop() } -}) - -// #1713 B: a run of the far Project holds this Project's card; the card belongs to the Project of -// its own board, and the far board must not inherit it (nor lend its run id to this one). -test('#1713: a run binding never carries another Project’s card onto a board', async () => { - const { rt, stop } = await start(undefined, splitSeed()) - const off = rt.onProjectProvider(() => [{ taskId: 'task-group', runId: 'run-2' }, { taskId: 'task-run', runId: 'run-1' }]) - try { - const p1 = (await rt.projectBoard('project-p1')).rows - // Rows come newest first, so the identity of a row is its id and source, not its position. - assert.deepEqual(p1.map(row => `${row.id}:${row.source}`).sort(), ['task-group:group', 'task-run:workflow']) - const group = p1.find(row => row.id === 'task-group') - assert.equal(group.runId, undefined, 'a row of this Project never carries another Project’s run id') - // Project 2 does not inherit a card whose own board already names Project 1. - assert.deepEqual((await rt.projectBoard('project-p2')).rows, [], 'the far Project lists no card of this one') - } finally { - off() - await stop() - } -}) - -// #1723: the board is the one Project read that never carried the caller, so it painted a card -// that `tasks` refuses to the same member. A second owner with its own Bot, room and Project is -// what the leak needs; nothing here belongs in `seed()`. -const crossSeed = () => { - const team = seed() - team.members['owner-2'] = { id: 'owner-2', createdAt: 2, designatedChiefId: null } - team.bots.b = { id: 'b', name: 'Writer', role: 'Write', sessionId: 'b', createdAt: 3, ownerId: 'owner-2' } - team.rooms.r2 = { id: 'r2', name: 'Garden', members: ['b'], mode: 'admin', admin: 'b', named: true, sessions: {}, log: [], seq: 0, seen: {}, projectId: 'project-p2' } - team.projects['project-p2'] = { id: 'project-p2', name: 'P2', repository: 'ThinkFlowLab/FlyBot', defaultBranch: 'main', ownerUserId: 'owner-2', createdAt: 1, updatedAt: 1, status: 'active' } - team.tasks['task-b1'] = { id: 'task-b1', kind: 'group', status: 'working', ownerBotId: 'b', origin: { roomId: 'r2' }, assignee: 'Writer', title: 'Write it', createdAt: 6, updatedAt: 10 } - return team -} - -test('#1723: a Project board paints only the cards `tasks` would let the member open', async () => { - const { rt, callAs, stop } = await start(undefined, crossSeed()) - try { - rt.authenticate = async token => (token === 'tok-1' ? { memberId: 'owner-1' } : token === 'tok-2' ? { memberId: 'owner-2' } : null) - rt.state.authSessions = { 's-1': { memberId: 'owner-1', tokenHash: 'a' }, 's-2': { memberId: 'owner-2', tokenHash: 'b' } } - // The card `tasks` refuses to member A is the one the board must not paint for it. - const refused = await callAs('tasks', { botId: 'b' }, 'tok-1') - assert.equal(refused.ok, false, JSON.stringify(refused)) - assert.equal(refused.error.code, 'bot/forbidden', JSON.stringify(refused)) - const theirs = await callAs('project-board', { id: 'project-p2' }, 'tok-1') - assert.equal(theirs.ok, true, JSON.stringify(theirs)) - assert.deepEqual(theirs.value.tasks ?? [], [], 'member A reads nothing of member B’s board') - // Member B reads the same board and still gets its card. - const own = await callAs('project-board', { id: 'project-p2' }, 'tok-2') - assert.equal(own.ok, true, JSON.stringify(own)) - assert.deepEqual(own.value.tasks.map(row => row.id), ['task-b1']) - // #1716's same-owner ALLOW and #1715's empty board stay as they were. - const p1 = await callAs('project-board', { id: 'project-p1' }, 'tok-1') - assert.deepEqual(p1.value.tasks.map(row => row.id), ['task-group']) - const empty = await callAs('project-board', { id: 'project-empty' }, 'tok-1') - assert.equal(empty.ok, true, JSON.stringify(empty)) - assert.deepEqual(empty.value.tasks, []) - } finally { await stop() } -}) - -// #1767: the record beside the cards. The Project's working folder holds two decision files and -// one meeting reached `decided`; the board lists exactly those, with the hash it computed, and a -// meeting still deliberating is never listed as a decision. -const recordSeed = () => { - const team = seed() - team.projects['project-p1'].workDir = '__WORKDIR__' - team.meetings = { - 'meeting-decided': { - id: 'meeting-decided', status: 'decided', question: 'Which board carries the record?', - owner: 'Chief', project: 'project-p1', decisionFile: 'decisions/2026-10-30-adopt.md', - createdAt: 1, updatedAt: 5, decidedAt: 6, - }, - 'meeting-open': { - id: 'meeting-open', status: 'deliberating', question: 'Should the board page?', - project: 'P1', createdAt: 2, updatedAt: 3, - }, - 'meeting-blocked': { - id: 'meeting-blocked', status: 'blocked', question: 'Who owns the archive?', - project: 'project-p1', blockingQuestion: 'The maintainer has not named an owner.', - createdAt: 2, updatedAt: 4, - }, - 'meeting-other': { - id: 'meeting-other', status: 'decided', question: 'Another Project’s question', - owner: 'Chief', project: 'project-p2', decisionFile: 'decisions/2026-10-30-elsewhere.md', - createdAt: 1, updatedAt: 2, decidedAt: 3, - }, - } - return team -} - -// The State reader answers for a repository, so the fixture is one; the decisions folder only -// exists once this test makes it, which is the read the board performs. -const folderWithDecisions = () => { - const dir = mkdtempSync(join(tmpdir(), 'ds-bot-decisions-')) - mkdirSync(join(dir, '.git')) - mkdirSync(join(dir, 'decisions')) - writeFileSync(join(dir, 'decisions', '2026-10-30-adopt.md'), '# Adopt the board record\n\nThe board lists this file.\n') - writeFileSync(join(dir, 'decisions', '2026-10-30-second.md'), '# Keep decisions small\n') - return dir -} - -async function startWithRecord() { - const home = mkdtempSync(join(tmpdir(), 'ds-bot-board-')) - const dir = folderWithDecisions() - const team = recordSeed() - team.projects['project-p1'].workDir = dir - const started = await start(home, team) - return { ...started, home, dir } -} - -test('#1767: the board lists two decisions with their hash and one decided meeting', async () => { - const { rt, call, home, dir, stop } = await startWithRecord() - try { - const board = await rt.projectBoard('project-p1') - assert.equal(board.decisions.dir, dir) - assert.deepEqual(board.decisions.files.map(file => file.path), ['decisions/2026-10-30-adopt.md', 'decisions/2026-10-30-second.md']) - assert.deepEqual(board.decisions.files.map(file => file.date), ['2026-10-30', '2026-10-30']) - assert.deepEqual(board.decisions.files.map(file => file.title), ['Adopt the board record', 'Keep decisions small']) - for (const file of board.decisions.files) { - assert.match(file.sha256, /^[0-9a-f]{64}$/, 'every decision carries the hash the reader computed') - assert.equal(file.bytes, statSync(join(dir, file.path)).size) - assert.equal(file.mtime, new Date(statSync(join(dir, file.path)).mtimeMs).toISOString()) - } - // One decided meeting, linked to its file; the deliberating and blocked ones are apart, and a - // meeting of another Project is not here at all. - assert.deepEqual(board.meetings.decided.map(meeting => meeting.id), ['meeting-decided']) - assert.equal(board.meetings.decided[0].path, 'decisions/2026-10-30-adopt.md') - assert.equal(board.meetings.decided[0].owner, 'Chief') - assert.equal(board.meetings.decided[0].question, 'Which board carries the record?') - assert.deepEqual(board.meetings.pending.map(meeting => meeting.status).sort(), ['blocked', 'deliberating']) - assert.equal(board.meetings.pending.some(meeting => meeting.id === 'meeting-decided'), false, 'a decided meeting is never pending') - assert.equal(board.meetings.decided.some(meeting => meeting.id === 'meeting-open'), false, 'a deliberating meeting is never a decision') - // The same read through the endpoint the page uses: `tasks` unchanged, two new keys. - const served = value(await call('project-board', { id: 'project-p1' })) - assert.deepEqual(served.tasks.map(row => row.id), ['task-group']) - assert.equal(served.decisions.files.length, 2) - assert.deepEqual(served.meetings.decided.map(meeting => meeting.id), ['meeting-decided']) - // Nothing wrote: the team file is the bytes it was when the read began. - const before = readFileSync(join(home, 'state.json')) - const reread = value(await call('project-board', { id: 'project-p1' })) - assert.equal(reread.decisions.files.length, 2) - assert.deepEqual(readFileSync(join(home, 'state.json')), before, 'reading the board writes nothing') - } finally { - await stop() - rmSync(dir, { recursive: true, force: true }) - } -}) - -test('#1767: a deleted decision file leaves the next read, and a decided meeting still reads decided', async () => { - const { rt, dir, stop } = await startWithRecord() - try { - rmSync(join(dir, 'decisions', '2026-10-30-second.md')) - const afterDelete = await rt.projectBoard('project-p1') - assert.deepEqual(afterDelete.decisions.files.map(file => file.path), ['decisions/2026-10-30-adopt.md']) - // The archived file the decided meeting names is removed too: the meeting is the record and - // stays listed, with no path — the file is gone, not the decision. - rmSync(join(dir, 'decisions', '2026-10-30-adopt.md')) - const gone = await rt.projectBoard('project-p1') - assert.equal(gone.decisions.files.length, 0) - assert.match(gone.decisions.reason, /no decision file in .*decisions/) - assert.deepEqual(gone.meetings.decided.map(meeting => [meeting.id, meeting.path]), [['meeting-decided', '']]) - } finally { - await stop() - rmSync(dir, { recursive: true, force: true }) - } -}) - -test('#1767: a Project without a decisions folder answers an empty list with the reason, never a throw', async () => { - const home = mkdtempSync(join(tmpdir(), 'ds-bot-board-')) - const dir = mkdtempSync(join(tmpdir(), 'ds-bot-nodecisions-')) - mkdirSync(join(dir, '.git')) - const team = recordSeed() - team.projects['project-p1'].workDir = dir - const { rt, call, stop } = await start(home, team) - try { - const board = await rt.projectBoard('project-p1') - assert.deepEqual(board.decisions.files, []) - assert.match(board.decisions.reason, /no decision file in .*decisions/, 'the empty state names the folder it read') - assert.deepEqual(board.meetings.decided.map(meeting => [meeting.id, meeting.path]), [['meeting-decided', '']]) - // A Project naming no folder is an answer too. - const noFolder = await rt.projectBoard('project-empty') - assert.deepEqual(noFolder.decisions.files, []) - assert.match(noFolder.decisions.reason, /no working folder/) - const served = value(await call('project-board', { id: 'project-p1' })) - assert.deepEqual(served.decisions.files, []) - assert.match(served.decisions.reason, /no decision file/) - } finally { - await stop() - rmSync(dir, { recursive: true, force: true }) - } -}) - -// #2033: the Decisions row list is cut at the newest 200 files (#2021), but a decided meeting -// still finds its own file by name. A file that fell off the oldest end of that cut is on disk and -// was seen by this read, so its meeting must name it rather than report it gone. -const folderPastCap = () => { - const base = mkdtempSync(join(tmpdir(), 'ds-bot-decisions-cap-')) - const dir = join(base, 'project') - mkdirSync(join(dir, '.git'), { recursive: true }) - mkdirSync(join(dir, 'decisions')) - for (let index = 0; index < 200; index += 1) { - writeFileSync(join(dir, 'decisions', `2026-01-02-${String(index).padStart(3, '0')}.md`), `# Old decision ${index}\n`) - } - for (let index = 0; index < 5; index += 1) { - writeFileSync(join(dir, 'decisions', `2026-11-01-${String(index).padStart(3, '0')}.md`), `# New decision ${index}\n`) - } - // `../outside.md` from the Project folder: present on disk, never in this read's dedupe set. A - // fallback that asks the filesystem instead of the read would hand its path out. - writeFileSync(join(base, 'outside.md'), '# Outside the folder\n') - return { base, dir } -} - -const capSeed = (dir) => { - const team = seed() - team.projects['project-p1'].workDir = dir - team.meetings = { - 'meeting-oldest': { - id: 'meeting-oldest', status: 'decided', question: 'The oldest decision?', - owner: 'Chief', project: 'project-p1', decisionFile: 'decisions/2026-01-02-000.md', - createdAt: 1, updatedAt: 5, decidedAt: 6, - }, - 'meeting-newest': { - id: 'meeting-newest', status: 'decided', question: 'The newest decision?', - owner: 'Chief', project: 'project-p1', decisionFile: 'decisions/2026-11-01-004.md', - createdAt: 1, updatedAt: 5, decidedAt: 7, - }, - 'meeting-absent': { - id: 'meeting-absent', status: 'decided', question: 'A file that never was?', - owner: 'Chief', project: 'project-p1', decisionFile: 'decisions/2026-12-31-missing.md', - createdAt: 1, updatedAt: 5, decidedAt: 8, - }, - 'meeting-escape': { - id: 'meeting-escape', status: 'decided', question: 'A name pointing outside?', - owner: 'Chief', project: 'project-p1', decisionFile: '../outside.md', - createdAt: 1, updatedAt: 5, decidedAt: 9, - }, - // Inside the cut, so this read lists it until the file is deleted; after that the meeting must - // read gone again. The fallback takes names from this read, so it cannot resurrect the file. - 'meeting-in-cap': { - id: 'meeting-in-cap', status: 'decided', question: 'A file inside the cut that is later deleted?', - owner: 'Chief', project: 'project-p1', decisionFile: 'decisions/2026-11-01-002.md', - createdAt: 1, updatedAt: 5, decidedAt: 10, - }, - } - return team -} - -async function startPastCap() { - const home = mkdtempSync(join(tmpdir(), 'ds-bot-board-')) - const { base, dir } = folderPastCap() - const { rt, stop } = await start(home, capSeed(dir)) - return { rt, home, base, dir, stop } -} - -test('#2033: a decided meeting whose file fell off the 200-row cut still names it', async () => { - const { rt, base, stop } = await startPastCap() - try { - const board = await rt.projectBoard('project-p1') - assert.equal(board.decisions.files.length, 200, 'the row list is still cut at the newest 200') - assert.equal(board.decisions.total, 205, 'the count still says how many files the read saw') - assert.equal(board.decisions.files.some(file => file.path === 'decisions/2026-01-02-000.md'), false, 'the oldest file is outside the cut, which is why the fallback is needed') - const oldest = board.meetings.decided.find(meeting => meeting.id === 'meeting-oldest') - assert.equal(oldest.path, 'decisions/2026-01-02-000.md', 'a file past the cut is named, not reported gone') - assert.equal('sha256' in oldest, false, 'only the cut rows carry a hash, so the fallback adds none') - } finally { - await stop() - rmSync(base, { recursive: true, force: true }) - } -}) - -test('#2033: a decided meeting inside the cut keeps its path and hash', async () => { - const { rt, base, stop } = await startPastCap() - try { - const board = await rt.projectBoard('project-p1') - const newest = board.meetings.decided.find(meeting => meeting.id === 'meeting-newest') - assert.equal(newest.path, 'decisions/2026-11-01-004.md') - assert.match(newest.sha256, /^[0-9a-f]{64}$/) - } finally { - await stop() - rmSync(base, { recursive: true, force: true }) - } -}) - -test('#2033: a decided meeting naming a file that is really absent still reads it gone', async () => { - const { rt, base, stop } = await startPastCap() - try { - const board = await rt.projectBoard('project-p1') - const absent = board.meetings.decided.find(meeting => meeting.id === 'meeting-absent') - assert.equal(absent.path, '', 'a name this read never saw is not a path') - assert.equal('sha256' in absent, false) - } finally { - await stop() - rmSync(base, { recursive: true, force: true }) - } -}) - -test('#2033: a decision file deleted from inside the cut still reads gone', async () => { - const { rt, dir, base, stop } = await startPastCap() - try { - const before = await rt.projectBoard('project-p1') - const kept = before.meetings.decided.find(meeting => meeting.id === 'meeting-in-cap') - assert.equal(kept.path, 'decisions/2026-11-01-002.md', 'the file is inside the cut while it is there') - assert.match(kept.sha256, /^[0-9a-f]{64}$/) - rmSync(join(dir, 'decisions', '2026-11-01-002.md')) - const after = await rt.projectBoard('project-p1') - assert.equal(after.decisions.total, 204, 'the read now sees one file fewer') - assert.equal(after.decisions.files.some(file => file.path === 'decisions/2026-11-01-002.md'), false, 'the deleted row is off the list') - const gone = after.meetings.decided.find(meeting => meeting.id === 'meeting-in-cap') - assert.equal(gone.path, '', 'a deleted file stays gone: the fallback names only what this read saw') - assert.equal('sha256' in gone, false) - } finally { - await stop() - rmSync(base, { recursive: true, force: true }) - } -}) - -test('#2033: a decision name that points outside the folder never becomes a path', async () => { - const { rt, base, stop } = await startPastCap() - try { - assert.equal(existsSync(join(base, 'outside.md')), true, 'the file it points at does exist on disk') - const board = await rt.projectBoard('project-p1') - const escape = board.meetings.decided.find(meeting => meeting.id === 'meeting-escape') - assert.equal(escape.path, '', 'the fallback takes names from this read, never from the filesystem') - } finally { - await stop() - rmSync(base, { recursive: true, force: true }) - } -}) - -// A hand-edited record, or a name crafted into a decisions folder, can point `decisionFile` -// anywhere. The board lists one shape only — a regular `decisions/.md` — and that same rule -// has to refuse every other name where the listing is produced, so neither a row nor the past-cut -// fallback reads outside the folder. Each class below is a refusal, never a silent skip. -const traversalFolder = () => { - const base = mkdtempSync(join(tmpdir(), 'ds-bot-decisions-escape-')) - const dir = join(base, 'project') - mkdirSync(join(dir, '.git'), { recursive: true }) - mkdirSync(join(dir, 'decisions')) - mkdirSync(join(dir, 'decisions', 'zz')) - mkdirSync(join(dir, 'decisions', 'sub')) - writeFileSync(join(dir, 'decisions', '2026-01-02-legit.md'), '# The real decision\n') - writeFileSync(join(dir, 'decisions', 'sub', '2026-01-02-deep.md'), '# Left out of the listing\n') - writeFileSync(join(dir, 'decisions', 'handwritten-notes.md'), '# Written by hand\n') - writeFileSync(join(base, 'outside.md'), '# Outside the folder\n') - symlinkSync(join(base, 'outside.md'), join(dir, 'decisions', '2026-01-02-link.md')) - return { base, dir } -} - -const traversalSeed = (dir, base, ids) => { - const team = seed() - team.projects['project-p1'].workDir = dir - const at = (id, decisionFile) => ({ - id, status: 'decided', question: `Q ${id}`, owner: 'Chief', project: 'project-p1', - decisionDir: dir, decisionFile, createdAt: 1, updatedAt: 5, decidedAt: 6, - }) - const all = { - 'm-dotdot': 'decisions/zz/../../../outside.md', - 'm-absolute-out': join(base, 'outside.md'), - 'm-absolute-root': '/etc/passwd', - 'm-symlink': 'decisions/2026-01-02-link.md', - 'm-deep': 'decisions/sub/2026-01-02-deep.md', - 'm-legit': 'decisions/2026-01-02-legit.md', - } - team.meetings = Object.fromEntries(ids.map(id => [id, at(id, all[id])])) - return team -} - -// Each case gets only its own meetings, so a leak in one class cannot make another class red. -async function startTraversal(ids) { - const home = mkdtempSync(join(tmpdir(), 'ds-bot-board-')) - const { base, dir } = traversalFolder() - const { rt, stop } = await start(home, traversalSeed(dir, base, ids)) - return { rt, base, dir, stop } -} - -const decidedRow = (board, id) => board.meetings.decided.find(meeting => meeting.id === id) - -test('a decision name with a .. segment never becomes a row or a past-cut path', async () => { - const { rt, base, stop } = await startTraversal(['m-dotdot']) - try { - const board = await rt.projectBoard('project-p1') - assert.equal(decidedRow(board, 'm-dotdot').path, '', 'a name with a .. segment is refused, not projected') - assert.equal(board.decisions.files.some(file => file.path.includes('..')), false, 'no row names a .. path') - assert.equal(board.decisions.files.some(file => file.title === 'Outside the folder'), false, 'no row carries the outside title') - } finally { - await stop() - rmSync(base, { recursive: true, force: true }) - } -}) - -test('an absolute decision name is refused, never read', async () => { - const { rt, base, stop } = await startTraversal(['m-absolute-out', 'm-absolute-root']) - try { - const board = await rt.projectBoard('project-p1') - assert.equal(decidedRow(board, 'm-absolute-out').path, '', 'an absolute path is refused') - assert.equal(decidedRow(board, 'm-absolute-root').path, '', 'and so is a root absolute path') - assert.equal('sha256' in decidedRow(board, 'm-absolute-out'), false) - assert.equal(board.decisions.files.some(file => file.path.startsWith('/')), false, 'no row names an absolute path') - } finally { - await stop() - rmSync(base, { recursive: true, force: true }) - } -}) - -test('a symlinked decision name is refused, never followed', async () => { - const { rt, base, stop } = await startTraversal(['m-symlink']) - try { - const board = await rt.projectBoard('project-p1') - const row = decidedRow(board, 'm-symlink') - assert.equal(row.path, '', 'a symlink is not a decision file this listing found') - assert.equal('sha256' in row, false, 'and it carries no hash of the file it points at') - assert.equal(board.decisions.files.some(file => file.path === 'decisions/2026-01-02-link.md'), false, 'the link is not a row') - assert.equal(board.decisions.files.some(file => file.title === 'Outside the folder'), false, 'and its target is never titled') - } finally { - await stop() - rmSync(base, { recursive: true, force: true }) - } -}) - -test('a decision name this listing did not read never becomes a past-cut path', async () => { - const { rt, base, stop } = await startTraversal(['m-deep']) - try { - const board = await rt.projectBoard('project-p1') - const row = decidedRow(board, 'm-deep') - assert.equal(row.path, '', 'the fallback names only what this read listed, so a name it never read is not a path') - assert.equal('sha256' in row, false) - assert.equal(board.decisions.files.some(file => file.path === 'decisions/sub/2026-01-02-deep.md'), false, 'the listing reads one level only') - } finally { - await stop() - rmSync(base, { recursive: true, force: true }) - } -}) - -test('a regular decision name is still listed, titled and hashed', async () => { - const { rt, base, stop } = await startTraversal(['m-legit']) - try { - const board = await rt.projectBoard('project-p1') - const file = board.decisions.files.find(row => row.path === 'decisions/2026-01-02-legit.md') - assert.equal(file.title, 'The real decision', 'a legal name is still listed with its own title') - const handwritten = board.decisions.files.find(row => row.path === 'decisions/handwritten-notes.md') - assert.equal(handwritten.title, 'Written by hand', 'a legal name with no date is listed too: the rule is the shape, not the date') - const row = decidedRow(board, 'm-legit') - assert.equal(row.path, 'decisions/2026-01-02-legit.md', 'and the meeting still finds it') - assert.match(row.sha256, /^[0-9a-f]{64}$/) - } finally { - await stop() - rmSync(base, { recursive: true, force: true }) - } -}) - -// #2005: a decision file's title is its first heading, bounded at 200 units. A heading whose 200th -// unit is the first half of an emoji used to reach the board as an unpaired surrogate, so the -// Decisions section carried half a character. -test('#2005: a decision title is bounded at a code point, never inside an emoji', async () => { - const home = mkdtempSync(join(tmpdir(), 'ds-bot-board-')) - const dir = mkdtempSync(join(tmpdir(), 'ds-bot-title-cut-')) - mkdirSync(join(dir, '.git')) - mkdirSync(join(dir, 'decisions')) - writeFileSync(join(dir, 'decisions', '2026-10-30-cut.md'), `# ${'a'.repeat(199)}😀\n`) - const team = recordSeed() - team.projects['project-p1'].workDir = dir - const { rt, stop } = await start(home, team) - try { - const board = await rt.projectBoard('project-p1') - const [file] = board.decisions.files - assert.equal(file.title.isWellFormed(), true, 'the title is well formed, never a lone high surrogate') - assert.equal(file.title, 'a'.repeat(199), 'the 200th unit was the first half of the pair, so the pair is dropped whole') - } finally { - await stop() - rmSync(dir, { recursive: true, force: true }) - } -}) - -// #1768: the queue, the leases and the blocked cards beside the columns. The seed adds one card of -// each shape on Project 1 and one on Project 2, so every group can be asked what it must not show. -const queueSeed = () => { - const team = seed() - team.rooms.r2 = { id: 'r2', name: 'Garden', members: ['a'], mode: 'admin', admin: 'a', named: true, sessions: {}, log: [], seq: 0, seen: {}, projectId: 'project-p2' } - team.projects['project-p2'] = { id: 'project-p2', name: 'P2', repository: 'ThinkFlowLab/FlyBot', defaultBranch: 'main', ownerUserId: 'owner-1', createdAt: 1, updatedAt: 1, status: 'active' } - team.tasks['task-w1'] = { id: 'task-w1', kind: 'group', status: 'open', queueRole: 'Dev', queueBy: 'a', origin: { roomId: 'r1' }, title: 'First queued', createdAt: 10, updatedAt: 10 } - team.tasks['task-w2'] = { id: 'task-w2', kind: 'group', status: 'open', queueRole: 'Dev', queueBy: 'a', origin: { roomId: 'r1' }, title: 'Second queued', createdAt: 20, updatedAt: 20 } - team.tasks['task-hold'] = { id: 'task-hold', kind: 'group', status: 'claimed', queueRole: 'Dev', claimedBy: 'Dev1', origin: { roomId: 'r1' }, title: 'Claimed', lease: { holder: 'Dev1', takenAt: Date.now() - 1000, expiresAt: Date.now() + 600000 }, createdAt: 30, updatedAt: 30 } - team.tasks['task-blocked'] = { id: 'task-blocked', kind: 'group', status: 'blocked', waitingFor: 'input', origin: { roomId: 'r1' }, title: 'Blocked card', inputRequest: { id: 'q1', question: 'Which branch should this ship on?' }, createdAt: 40, updatedAt: 40 } - team.tasks['task-far'] = { id: 'task-far', kind: 'group', status: 'open', queueRole: 'Dev', queueBy: 'a', origin: { roomId: 'r2' }, title: 'Other Project', createdAt: 50, updatedAt: 50 } - return team -} - -test('#1768: two queued cards and one claimed read as 2 waiting / 1 in flight, holder named', async () => { - const { rt, stop } = await start(undefined, queueSeed()) - try { - const queues = await rt.projectBoardQueues('project-p1') - assert.equal(queues.counts.waiting, 2) - assert.equal(queues.counts.inflight, 1) - // Oldest wait first, with the role it is queued for. - assert.deepEqual(queues.waiting.map(item => item.id), ['task-w1', 'task-w2']) - assert.deepEqual(queues.waiting.map(item => item.role), ['Dev', 'Dev']) - assert.ok(Number.isFinite(queues.waiting[0].waitedMs) && queues.waiting[0].waitedMs >= queues.waiting[1].waitedMs) - // The holder is the member that claimed it, and a live lease reads running. - assert.deepEqual(queues.inflight.map(item => [item.id, item.holder, item.lease, item.stalled]), [['task-hold', 'Dev1', 'live', false]]) - } finally { await stop() } -}) - -test('#1768: an expired lease reads stalled and the card record does not move', async () => { - const { rt, stop } = await start(undefined, queueSeed()) - try { - const before = JSON.stringify(rt.state.tasks['task-hold']) - rt.state.tasks['task-hold'].lease.expiresAt = Date.now() - 1000 - const moved = JSON.stringify(rt.state.tasks['task-hold']) - assert.notEqual(moved, before, 'the test moved the lease itself') - const queues = await rt.projectBoardQueues('project-p1') - assert.deepEqual(queues.inflight.map(item => [item.id, item.stalled, item.lease]), [['task-hold', true, 'expired']]) - assert.equal(JSON.stringify(rt.state.tasks['task-hold']), moved, 'reading the board wrote nothing') - } finally { await stop() } -}) - -test('#1768: a blocked card carries its own reason and another Project is in none of the groups', async () => { - const { rt, stop } = await start(undefined, queueSeed()) - try { - const queues = await rt.projectBoardQueues('project-p1') - assert.deepEqual(queues.blocked.map(item => [item.id, item.reason]), [['task-blocked', 'Which branch should this ship on?']]) - // task-far is queued for the same role but lives on Project 2, and the counts are this Project's. - const ids = [...queues.waiting, ...queues.inflight, ...queues.blocked].map(item => item.id) - assert.ok(!ids.includes('task-far'), JSON.stringify(ids)) - assert.deepEqual([queues.counts.waiting, queues.counts.inflight, queues.counts.blocked], [2, 1, 1]) - } finally { await stop() } -}) - -// #2029: the Blocked group answered why and never how long. `inputRequest.requestedAt` is the stamp -// the ask itself wrote (`tasks.js`), so a card's age is read from it — and from nowhere else: a -// fallback to `createdAt` would read as a wait nobody measured. -test('#2029: a blocked card carries the age of its own question', () => { - const NOW = 1_800_000_000_000 - const since = NOW - 48 * 3600e3 - assert.deepEqual( - blockedOf({ id: 't1', status: 'blocked', inputRequest: { id: 'q', question: 'why', requestedAt: since } }, NOW), - { id: 't1', reason: 'why', since, waitedMs: 172800000 }, - ) -}) - -test('#2029: a blocked card with no requestedAt has no age, invented or otherwise', async () => { - const { rt, stop } = await start(undefined, queueSeed()) - try { - // The card's own timestamps are there and a fallback would find them; what is missing is the - // question's stamp, so the card says nothing about how long it has been blocked. - const now = Date.now() - rt.state.tasks['task-blocked'].createdAt = now - 48 * 3600e3 - rt.state.tasks['task-blocked'].updatedAt = now - 48 * 3600e3 - const queues = await rt.projectBoardQueues('project-p1') - const item = queues.blocked.find(entry => entry.id === 'task-blocked') - assert.equal(item.since, null, 'no stamp, no age') - assert.equal(item.waitedMs, null, 'and no invented duration') - } finally { await stop() } -}) - -test('#2029: the Blocked group lists the card stuck longest first', async () => { - const team = queueSeed() - const now = Date.now() - team.tasks['task-blocked'].inputRequest.requestedAt = now - 3600e3 - team.tasks['task-blocked-old'] = { id: 'task-blocked-old', kind: 'group', status: 'blocked', waitingFor: 'input', origin: { roomId: 'r1' }, title: 'Stuck since Friday', inputRequest: { id: 'q2', question: 'Still stuck?', requestedAt: now - 48 * 3600e3 }, createdAt: 41, updatedAt: 41 } - team.tasks['task-blocked-unstamped'] = { id: 'task-blocked-unstamped', kind: 'group', status: 'blocked', waitingFor: 'input', origin: { roomId: 'r1' }, title: 'No stamp', inputRequest: { id: 'q3', question: 'Since when?' }, createdAt: 42, updatedAt: 42 } - const { rt, stop } = await start(undefined, team) - try { - const queues = await rt.projectBoardQueues('project-p1') - // 48 h before 1 h, and the card the host cannot date sorts last rather than first. - assert.deepEqual(queues.blocked.map(item => item.id), ['task-blocked-old', 'task-blocked', 'task-blocked-unstamped']) - assert.deepEqual(queues.blocked.map(item => item.since), [now - 48 * 3600e3, now - 3600e3, null]) - assert.equal(queues.blocked[2].waitedMs, null) - } finally { await stop() } -}) - -// #2005: every one-line the board derives (`oneLine`) is bounded at 200 UTF-16 units, so a line -// whose 200th unit is the first half of an emoji used to reach the client as an unpaired surrogate -// — the queue reason is the case that reads a field another home supplied. -test('#2005: a blocked card reason is bounded at a code point, never inside an emoji', async () => { - const { rt, stop } = await start(undefined, queueSeed()) - try { - rt.state.tasks['task-blocked'].inputRequest.question = `${'a'.repeat(199)}😀tail` - const queues = await rt.projectBoardQueues('project-p1') - const reason = queues.blocked.find(item => item.id === 'task-blocked').reason - assert.equal(reason.isWellFormed(), true, 'the reason is well formed, never a lone high surrogate') - assert.equal(reason, 'a'.repeat(199), 'the 200th unit was the first half of the pair, so the pair is dropped whole') - } finally { await stop() } -}) - -test('#1768: an unreadable queue answers unknown, never zero', async () => { - const { rt, stop } = await start(undefined, queueSeed()) - try { - rt.state.tasks = null - const queues = await rt.projectBoardQueues('project-p1') - assert.deepEqual(queues.counts, { waiting: null, inflight: null, blocked: null, done: null }) - assert.deepEqual([queues.waiting, queues.inflight, queues.blocked], [[], [], []]) - } finally { await stop() } -}) - -// #1809: `meeting decide` archives its file in the Session's working folder, while the board read -// only the Project's own folder — so a default install showed an empty Decisions section and the -// decided meeting as `decision file gone` while the file sat on disk. These cases drive the real -// `meeting` tool (open → three positions → decide) and then read the board. -const meetingTeam = () => ({ - version: 2, - revision: 1, - mainBotIds: ['a'], - members: { 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' } }, - bots: { - a: { id: 'a', name: 'Chief', role: 'Chief', sessionId: 'a', createdAt: 1, ownerId: 'owner-1' }, - d1: { id: 'd1', name: 'Dev', role: 'Dev', sessionId: 'd1', createdAt: 2, ownerId: 'owner-1' }, - r1: { id: 'r1', name: 'Reviewer', role: 'Reviewer', sessionId: 'r1', createdAt: 3, ownerId: 'owner-1' }, - }, - rooms: { - 'room-1': { id: 'room-1', name: 'Ops', members: ['a'], mode: 'admin', admin: 'a', named: true, sessions: {}, log: [], seq: 0, seen: {}, projectId: 'project-p1' }, - 'room-2': { id: 'room-2', name: 'Garden', members: ['a'], mode: 'admin', admin: 'a', named: true, sessions: {}, log: [], seq: 0, seen: {}, projectId: 'project-p2' }, - }, - agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, grants: {}, - projects: { - 'project-p1': { id: 'project-p1', name: 'P1', repository: null, defaultBranch: 'main', ownerUserId: 'owner-1', createdAt: 1, updatedAt: 1, status: 'active' }, - 'project-p2': { id: 'project-p2', name: 'P2', repository: null, defaultBranch: 'main', ownerUserId: 'owner-1', createdAt: 2, updatedAt: 2, status: 'active' }, - }, - projectRuns: {}, tasks: {}, meetings: {}, -}) - -async function startWithMeetings({ project, room, session, team = meetingTeam() } = {}) { - const home = mkdtempSync(join(tmpdir(), 'ds-bot-board-')) - const teamDir = mkdtempSync(join(tmpdir(), 'ds-bot-team-')) - if (project !== undefined) team.projects['project-p1'].workDir = project - if (room !== undefined) team.rooms['room-1'].workDir = room - writeFileSync(join(home, 'state.json'), JSON.stringify(team)) - const ctx = fakeContext() - Object.assign(ctx, { - llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, - // The team folder a default install resolves to: the workspace the Session works in. - workspaceRegistry: { get: () => ({ id: 'w1', path: teamDir }), resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, - sessionController: { create: async () => ({ sessionId: 's1' }), resolveAgent: async id => ({ agent: { session: { id }, id, status: 'idle', cancel() {} } }), selectModel: async () => {}, page: async () => ({ records: [], hasMore: false }) }, - sessionTitle: { rename: async () => {} }, - sessions: { flush: async () => {} }, - }) - const rt = createRuntime(ctx, { home }) - await rt.load() - const exec = { agent: { id: 'a', session: { header: { cwd: session ?? teamDir } } } } - const meeting = args => ctx.registeredTools.get('meeting').execute(args, exec) - const decide = async (projectId, question) => { - const id = (await meeting({ action: 'open', question, participants: ['Chief', 'Dev', 'Reviewer'], project: projectId })).split(' ')[0] - for (const name of ['Chief', 'Dev', 'Reviewer']) await meeting({ action: 'position', meeting: id, as: name, stance: 'agree', text: `${name} agrees` }) - await meeting({ action: 'decide', meeting: id, owner: 'Chief', decision: 'Archive where the Session works.' }) - return id - } - const stop = async () => { - await ctx.dispose() - for (const dir of [home, teamDir]) rmSync(dir, { recursive: true, force: true, maxRetries: 5 }) - } - return { ctx, rt, home, teamDir, exec, meeting, decide, stop } -} - -test('#1809: a decision archived in the Session folder is listed by the board of a Project that names another folder', async () => { - const projectDir = mkdtempSync(join(tmpdir(), 'ds-p1809-project-')) - const sessionDir = mkdtempSync(join(tmpdir(), 'ds-p1809-session-')) - const { ctx, rt, exec, decide, stop } = await startWithMeetings({ project: projectDir, room: sessionDir, session: sessionDir }) - try { - const id = await decide('project-p1', 'Which folder carries the decision of a Project that names its own?') - const meeting = rt.state.meetings[id] - // Compare folders, not a pattern: a Windows path such as `C:\Users\me\U...` fed to - // `new RegExp` turns \U, \A, \L into escapes and stops matching its own string (#1987). - assert.equal(resolve(meeting.decisionDir ?? ''), resolve(sessionDir), 'the record names the folder it archived in') - const named = meeting.decisionFile - assert.equal(existsSync(join(sessionDir, named)), true, 'the file stays in the Session folder') - assert.equal(existsSync(join(projectDir, 'decisions')), false, 'nothing was written into the Project folder') - const board = await rt.projectBoard('project-p1') - assert.equal(board.decisions.dir, projectDir, 'the Project folder is still the folder the board reports') - assert.deepEqual(board.decisions.files.map(file => file.path), [named], 'the archived file is listed') - const listed = board.decisions.files[0] - assert.equal(listed.bytes, statSync(join(sessionDir, named)).size, 'bytes come from the folder the file is in') - assert.equal(listed.sha256, meeting.decisionSha256, 'the hash is the bytes `decide` archived') - assert.equal(listed.title, 'Which folder carries the decision of a Project that names its own?') - assert.deepEqual(board.meetings.decided.map(entry => [entry.id, entry.path]), [[id, named]], 'the meeting links to the file, no `decision file gone`') - assert.equal(board.decisions.reason, '') - // Acceptance 6: the `project_state` tool keeps its repository gate on a plain folder; only the - // board's own read is ungated, and that is why the case above can list the file at all. - const state = await ctx.registeredTools.get('project_state').execute({}, exec) - assert.match(state, /not a repository:/, 'project_state still refuses a plain folder') - } finally { - await stop() - rmSync(projectDir, { recursive: true, force: true }) - rmSync(sessionDir, { recursive: true, force: true }) - } -}) - -test('#1809: a default install lists the team-folder decision, and a real delete still reads gone', async () => { - const { rt, teamDir, decide, stop } = await startWithMeetings() - try { - const id = await decide('project-p1', 'Which folder holds the record of a default install?') - const named = rt.state.meetings[id].decisionFile - assert.equal(existsSync(join(teamDir, named)), true, 'the decision is archived in the team folder') - const board = await rt.projectBoard('project-p1') - assert.equal(board.decisions.dir, null, 'a Project that names no folder still reports no folder') - assert.deepEqual(board.decisions.files.map(file => file.path), [named], 'the team-folder decision is listed') - assert.equal(board.decisions.files[0].sha256, rt.state.meetings[id].decisionSha256) - assert.deepEqual(board.meetings.decided.map(entry => [entry.id, entry.path]), [[id, named]]) - assert.equal(board.decisions.reason, '') - // Acceptance 4: a file that really was deleted still reads `decision file gone`, not a listing. - rmSync(join(teamDir, named)) - const gone = await rt.projectBoard('project-p1') - assert.deepEqual(gone.decisions.files, []) - assert.deepEqual(gone.meetings.decided.map(entry => [entry.id, entry.path]), [[id, '']], 'a missing file keeps the meeting, with no path') - } finally { await stop() } -}) - -test('#1809: two folder-less Projects sharing the team folder list only their own decisions', async () => { - const { rt, teamDir, decide, stop } = await startWithMeetings() - try { - const first = await decide('project-p1', 'Where do the decisions of the first Project live?') - const second = await decide('project-p2', 'Where do the decisions of the second Project live?') - const one = rt.state.meetings[first].decisionFile - const two = rt.state.meetings[second].decisionFile - assert.equal(existsSync(join(teamDir, one)), true) - assert.equal(existsSync(join(teamDir, two)), true) - const p1 = await rt.projectBoard('project-p1') - assert.deepEqual(p1.decisions.files.map(file => file.path), [one], 'the other Project’s decision is never listed here') - assert.deepEqual(p1.meetings.decided.map(entry => [entry.id, entry.path]), [[first, one]]) - const p2 = await rt.projectBoard('project-p2') - assert.deepEqual(p2.decisions.files.map(file => file.path), [two]) - assert.deepEqual(p2.meetings.decided.map(entry => [entry.id, entry.path]), [[second, two]]) - } finally { await stop() } -}) - -// #1855: the owner read a Project with months of history as "a wall of 已完成" — the board painted -// 191 accepted records beside the 9 cards in flight. The rows are the work still open; the finished -// cards are the archive beside them, and the header count keeps counting every one of them. -const historySeed = (doneCount = 191, openCount = 9) => { - const team = seed() - // This read is about one card per row, so the seed keeps its Project and its bindings only. - team.tasks = {} - for (let at = 0; at < doneCount + openCount; at += 1) { - const finished = at < doneCount - team.tasks[`task-h${at}`] = { - id: `task-h${at}`, kind: 'group', status: finished ? 'accepted' : 'working', ownerBotId: 'a', - origin: { roomId: 'r1' }, title: finished ? `Finished ${at}` : `In flight ${at}`, - createdAt: at + 1, updatedAt: at + 1, - } - } - return team -} - -test('#1855: a board of 200 cards with 191 done answers 9 rows and counts.done 191', async () => { - const { rt, call, stop } = await start(undefined, historySeed()) - try { - const board = await rt.projectBoard('project-p1') - assert.equal(board.rows.length, 9, 'only the work in flight is a row') - assert.equal(board.rows.some(row => row.column === 'done'), false, 'a finished card is never a row') - assert.equal(board.done.length, 191, 'every finished card is in the archive') - assert.equal(board.done.every(row => row.column === 'done'), true) - assert.equal(board.counts.done, 191, 'the header still counts every finished card') - // The same read, another key: no card is lost, none is painted twice, and the rows keep their - // source order. - assert.equal(new Set([...board.rows, ...board.done].map(row => row.id)).size, 200) - assert.deepEqual(board.rows.map(row => row.id), Array.from({ length: 9 }, (_, at) => `task-h${191 + at}`)) - // The endpoint the page reads carries the archive beside the rows. - const served = value(await call('project-board', { id: 'project-p1' })) - assert.equal(served.tasks.length, 9) - assert.equal(served.done.length, 191) - assert.equal(served.counts.done, 191) - } finally { await stop() } -}) - -test('#1855: a card that turns done leaves the rows and raises counts.done by one', async () => { - const { rt, stop } = await start(undefined, historySeed(2, 2)) - try { - const before = await rt.projectBoard('project-p1') - assert.deepEqual(before.rows.map(row => row.id), ['task-h2', 'task-h3']) - assert.equal(before.counts.done, 2) - rt.state.tasks['task-h2'].status = 'accepted' - const after = await rt.projectBoard('project-p1') - assert.deepEqual(after.rows.map(row => row.id), ['task-h3'], 'the finished card stops being a row') - assert.equal(after.counts.done, 3, 'and the header counts it once more') - assert.deepEqual(after.done.map(row => row.id), ['task-h0', 'task-h1', 'task-h2']) - } finally { await stop() } -}) - -test('#1855: a rework, a blocked and a waiting card stay rows — only done is filtered', async () => { - const team = seed() - team.tasks['task-rework'] = { id: 'task-rework', kind: 'group', status: 'result-ready', taskClass: 'rework', reworkOf: 'task-group', origin: { roomId: 'r1' }, title: 'A rework', createdAt: 30, updatedAt: 30 } - team.tasks['task-blocked'] = { id: 'task-blocked', kind: 'group', status: 'blocked', waitingFor: 'input', origin: { roomId: 'r1' }, title: 'Blocked', inputRequest: { id: 'q1', question: 'Which branch?' }, createdAt: 31, updatedAt: 31 } - team.tasks['task-waiting'] = { id: 'task-waiting', kind: 'group', status: 'open', queueRole: 'Dev', queueBy: 'a', origin: { roomId: 'r1' }, title: 'Waiting', createdAt: 32, updatedAt: 32 } - team.tasks['task-finished'] = { id: 'task-finished', kind: 'group', status: 'accepted', origin: { roomId: 'r1' }, title: 'Finished', createdAt: 33, updatedAt: 33 } - const { rt, stop } = await start(undefined, team) - try { - const board = await rt.projectBoard('project-p1') - const ids = board.rows.map(row => row.id) - assert.deepEqual(ids, ['task-group', 'task-rework', 'task-blocked', 'task-waiting'], 'nothing but done leaves the rows') - assert.equal(ids.includes('task-finished'), false) - assert.deepEqual(board.done.map(row => row.id), ['task-finished']) - assert.equal(board.counts.done, 1) - // The queue groups still read the same cards: filtering rows never empties a queue. - assert.deepEqual(board.waiting.map(item => item.id), ['task-waiting']) - assert.deepEqual(board.blocked.map(item => item.id), ['task-blocked']) - } finally { await stop() } -}) - -// #2021: the decision list is capped, and the cut used to keep the oldest files — a Project past -// the cap lost the decisions it had just written and nothing said the list was short. The read now -// keeps the newest and reports how many there were, so the board can say what it hid. -const folderWithManyDecisions = (old, fresh) => { - const dir = mkdtempSync(join(tmpdir(), 'ds-bot-decisions-many-')) - mkdirSync(join(dir, '.git')) - mkdirSync(join(dir, 'decisions')) - for (let at = 0; at < old; at += 1) { - writeFileSync(join(dir, 'decisions', `2026-01-02-${String(at).padStart(3, '0')}.md`), `# Old decision ${at}\n`) - } - for (let at = 0; at < fresh; at += 1) { - writeFileSync(join(dir, 'decisions', `2026-11-01-${String(at).padStart(3, '0')}.md`), `# Fresh decision ${at}\n`) - } - return dir -} - -test('#2021: the board keeps the newest decisions and reports the total it found', async () => { - const home = mkdtempSync(join(tmpdir(), 'ds-bot-board-')) - const dir = folderWithManyDecisions(200, 5) - const team = recordSeed() - team.projects['project-p1'].workDir = dir - const { rt, stop } = await start(home, team) - try { - const board = await rt.projectBoard('project-p1') - assert.equal(board.decisions.files.length, 200, 'the list stays at the cap') - const paths = board.decisions.files.map(file => file.path) - assert.deepEqual(paths.slice(-5), [ - 'decisions/2026-11-01-000.md', - 'decisions/2026-11-01-001.md', - 'decisions/2026-11-01-002.md', - 'decisions/2026-11-01-003.md', - 'decisions/2026-11-01-004.md', - ], 'every decision just written is listed') - assert.equal(paths[0], 'decisions/2026-01-02-005.md', 'the five oldest are the ones dropped') - assert.deepEqual(paths, [...paths].sort(), 'the display order stays ascending, oldest first') - assert.equal(board.decisions.total, 205, 'the read says how many files it found') - } finally { - await stop() - rmSync(dir, { recursive: true, force: true }) - } -}) - -test('#2021: under the cap the decision list is exactly what it was, plus its total', async () => { - const { rt, stop } = await startWithRecord() - try { - const board = await rt.projectBoard('project-p1') - assert.equal(board.decisions.total, 2) - assert.deepEqual(board.decisions.files.map(file => file.path), ['decisions/2026-10-30-adopt.md', 'decisions/2026-10-30-second.md']) - } finally { await stop() } -}) - -// The meetings side already kept the newest; it was the silence about the total that hid a list. -test('#2021: the meetings read reports how many of each list it found', async () => { - const home = mkdtempSync(join(tmpdir(), 'ds-bot-board-')) - const team = seed() - team.projects['project-p1'].workDir = '' - team.meetings = {} - for (let at = 0; at < 105; at += 1) { - team.meetings[`decided-${at}`] = { - id: `decided-${at}`, status: 'decided', question: `Decided question ${at}`, owner: 'Chief', - project: 'project-p1', createdAt: at, updatedAt: at, decidedAt: at, - } - } - for (let at = 0; at < 103; at += 1) { - team.meetings[`open-${at}`] = { - id: `open-${at}`, status: 'deliberating', question: `Open question ${at}`, - project: 'project-p1', createdAt: 1000 + at, updatedAt: 1000 + at, - } - } - const { rt, stop } = await start(home, team) - try { - const board = await rt.projectBoard('project-p1') - assert.equal(board.meetings.decided.length, 100) - assert.equal(board.meetings.decidedTotal, 105, 'the decided list says how many it found') - assert.equal(board.meetings.decided[0].id, 'decided-104', 'the newest decided meeting is kept') - assert.equal(board.meetings.pending.length, 100) - assert.equal(board.meetings.pendingTotal, 103, 'the pending list says how many it found') - } finally { await stop() } -}) - -// #2041: a decision row can be opened. The board already listed the file; opening it asks the host -// for that one name, and the host reads only a name its own listing produced — `decisions/..`, -// an absolute path, a name one level down and a symlink are refused before any stat. -const folderOpenable = () => { - const base = mkdtempSync(join(tmpdir(), 'ds-bot-decisions-open-')) - const dir = join(base, 'project') - mkdirSync(join(dir, '.git'), { recursive: true }) - mkdirSync(join(dir, 'decisions')) - mkdirSync(join(dir, 'decisions', 'sub')) - writeFileSync(join(dir, 'decisions', '2026-10-30-adopt.md'), '# Adopt the board record\n\nThe board lists this file.\n') - writeFileSync(join(dir, 'decisions', '2026-10-30-big.md'), `# Big\n\n${'x'.repeat(MAX_BYTES + 1)}\n`) - writeFileSync(join(dir, 'decisions', 'sub', '2026-10-30-deep.md'), '# One level down\n') - // `decisions/../outside.md` resolves here. It is over the bound on purpose: a host that stat'd it - // would answer `too large`, so `not listed` is the proof the name was refused before the disk. - writeFileSync(join(dir, 'outside.md'), `# Outside the folder\n\n${'y'.repeat(MAX_BYTES + 1)}\n`) - // A legal name standing for a file outside the folder: the listing skips it, so opening answers - // as if the file were not there, never with the target's text. - symlinkSync(join(dir, 'outside.md'), join(dir, 'decisions', '2026-10-30-link.md')) - const absolute = join(base, 'secret.md') - writeFileSync(absolute, '# A file outside the Project folder\n') - return { base, dir, absolute } -} - -const openableSeed = (dir) => { - const team = seed() - team.projects['project-p1'].workDir = dir - return team -} - -async function startOpenable() { - const home = mkdtempSync(join(tmpdir(), 'ds-bot-board-')) - const { base, dir, absolute } = folderOpenable() - const started = await start(home, openableSeed(dir)) - return { ...started, home, base, dir, absolute } -} - -test('#2041: a decision the board listed opens with its text', async () => { - const { rt, call, base, stop } = await startOpenable() - try { - // The page reads the board first; the set opening is checked against is that read's own. - const board = await call('project-board', { id: 'project-p1' }) - assert.equal(board.ok, true, JSON.stringify(board)) - assert.equal(board.value.decisions.files.some(file => file.path === 'decisions/2026-10-30-adopt.md'), true) - const opened = await call('project-decision', { id: 'project-p1', path: 'decisions/2026-10-30-adopt.md' }) - assert.equal(opened.ok, true, JSON.stringify(opened)) - assert.equal(opened.value.text, '# Adopt the board record\n\nThe board lists this file.\n') - assert.match(opened.value.sha256, /^[0-9a-f]{64}$/) - assert.equal(opened.value.path, 'decisions/2026-10-30-adopt.md') - // The declared read is the same answer: one rule, one place. - const direct = await rt.projectDecision('project-p1', 'decisions/2026-10-30-adopt.md') - assert.equal(direct.text, opened.value.text) - } finally { - await stop() - rmSync(base, { recursive: true, force: true }) - } -}) - -test('#2041: a name the board did not list answers not listed and is never read', async () => { - const { rt, base, absolute, stop } = await startOpenable() - try { - await rt.projectBoard('project-p1') - const refused = [ - ['decisions/../outside.md', 'a name that leaves decisions/'], - [absolute, 'an absolute path'], - ['decisions/sub/2026-10-30-deep.md', 'a name one level down'], - ['decisions/2026-10-30-link.md', 'a symlink with a legal name'], - ] - for (const [path, why] of refused) { - const answer = await rt.projectDecision('project-p1', path) - assert.equal(answer.ok, false, `${why} must not open`) - assert.match(answer.reason, /^not listed: /, `${why} answers not listed`) - assert.equal('text' in answer, false, `${why} carries no text`) - } - // `decisions/../outside.md` is over the bound: `too large` would prove a stat happened. - const escape = await rt.projectDecision('project-p1', 'decisions/../outside.md') - assert.equal(/too large/.test(escape.reason), false, 'the size was never measured') - assert.equal(existsSync(join(base, 'project', 'outside.md')), true, 'the file is there to be read') - } finally { - await stop() - rmSync(base, { recursive: true, force: true }) - } -}) - -test('#2041: a listed decision over the bound answers too large and hands over no text', async () => { - const { rt, base, stop } = await startOpenable() - try { - const board = await rt.projectBoard('project-p1') - assert.equal(board.decisions.files.some(file => file.path === 'decisions/2026-10-30-big.md'), true, 'the row is listed') - const answer = await rt.projectDecision('project-p1', 'decisions/2026-10-30-big.md') - assert.equal(answer.ok, false) - assert.match(answer.reason, /^too large: /) - assert.match(answer.reason, new RegExp(`bound ${MAX_BYTES}`)) - assert.equal('text' in answer, false, 'no partial text is returned') - } finally { - await stop() - rmSync(base, { recursive: true, force: true }) - } -}) - -test('#2041: a decision past the 200-row cut still opens', async () => { - const { rt, base, stop } = await startPastCap() - try { - const board = await rt.projectBoard('project-p1') - assert.equal(board.decisions.files.some(file => file.path === 'decisions/2026-01-02-000.md'), false, 'the row is outside the cut') - const answer = await rt.projectDecision('project-p1', 'decisions/2026-01-02-000.md') - assert.equal(answer.ok, true, JSON.stringify(answer)) - assert.equal(answer.text, '# Old decision 0\n') - } finally { - await stop() - rmSync(base, { recursive: true, force: true }) - } -}) - -test('#2041: a decision deleted after the board listed it answers absent', async () => { - const { rt, base, dir, stop } = await startOpenable() - try { - const board = await rt.projectBoard('project-p1') - assert.equal(board.decisions.files.some(file => file.path === 'decisions/2026-10-30-adopt.md'), true) - rmSync(join(dir, 'decisions', '2026-10-30-adopt.md')) - const answer = await rt.projectDecision('project-p1', 'decisions/2026-10-30-adopt.md') - assert.equal(answer.ok, false) - assert.equal(answer.reason, 'absent: decisions/2026-10-30-adopt.md') - assert.equal('text' in answer, false) - } finally { - await stop() - rmSync(base, { recursive: true, force: true }) - } -}) - -test('#2041: a decision a meeting archived opens from the folder the listing read it in', async () => { - const home = mkdtempSync(join(tmpdir(), 'ds-bot-board-')) - const base = mkdtempSync(join(tmpdir(), 'ds-bot-decisions-archive-')) - const dir = join(base, 'project') - const archive = join(base, 'room') - mkdirSync(join(dir, '.git'), { recursive: true }) - mkdirSync(join(dir, 'decisions')) - mkdirSync(join(archive, 'decisions'), { recursive: true }) - // The file exists only in the folder the meeting recorded, never in the Project folder. - writeFileSync(join(archive, 'decisions', '2026-10-29-archived.md'), '# Archived by a meeting\n') - const team = seed() - team.projects['project-p1'].workDir = dir - team.meetings = { - 'meeting-archived': { - id: 'meeting-archived', status: 'decided', question: 'Where did this one land?', owner: 'Chief', - project: 'project-p1', decisionDir: archive, decisionFile: 'decisions/2026-10-29-archived.md', - createdAt: 1, updatedAt: 2, decidedAt: 3, - }, - } - const { rt, stop } = await start(home, team) - try { - const board = await rt.projectBoard('project-p1') - assert.equal(board.decisions.dir, dir, 'the board still names the Project folder as its own') - assert.equal(board.decisions.files.some(file => file.path === 'decisions/2026-10-29-archived.md'), true, 'the archived file is a row') - const answer = await rt.projectDecision('project-p1', 'decisions/2026-10-29-archived.md') - assert.equal(answer.ok, true, JSON.stringify(answer)) - assert.equal(answer.text, '# Archived by a meeting\n', 'read from the folder the listing read it in, not the Project folder') - } finally { - await stop() - rmSync(base, { recursive: true, force: true }) - } -}) - -// #2041: opening a decision is behind the same door as its cards (#1723). The board already answers -// `tasks: []` for a member who cannot see a Project's cards; the text of its decisions must not -// answer instead. A plain record follows the Project's role list; a name a decided meeting archived -// follows that meeting's own cards. Both refuse with the sentence a missing name answers, so the -// refusal cannot be read as a list of what exists. -const visibleSeed = (dir) => { - const team = crossSeed() - team.projects['project-p2'].workDir = dir - team.meetings = { - 'meeting-decided': { - id: 'meeting-decided', status: 'decided', question: 'What shipped?', owner: 'Writer', - project: 'project-p2', decisionDir: dir, decisionFile: 'decisions/2026-11-01-decided.md', - taskIds: ['task-b1'], createdAt: 1, updatedAt: 2, decidedAt: 3, - }, - } - return team -} - -test('#2041: a decision text is answered only to a caller who can see the Project', async () => { - const base = mkdtempSync(join(tmpdir(), 'ds-bot-decisions-visible-')) - const dir = join(base, 'project') - mkdirSync(join(dir, '.git'), { recursive: true }) - mkdirSync(join(dir, 'decisions')) - writeFileSync(join(dir, 'decisions', '2026-11-01-decided.md'), '# P2 decided this\n') - writeFileSync(join(dir, 'decisions', '2026-11-01-plain.md'), '# A plain record\n') - // Over the bound on purpose: a host that stat'd it would answer `too large`, so `not listed` is - // the proof that a caller who may not read it never reached the disk. - writeFileSync(join(dir, 'decisions', '2026-11-01-secret.md'), `# Secret\n\n${'s'.repeat(MAX_BYTES + 1)}\n`) - const { rt, callAs, stop } = await start(undefined, visibleSeed(dir)) - try { - rt.authenticate = async token => (token === 'tok-1' ? { memberId: 'owner-1' } : token === 'tok-2' ? { memberId: 'owner-2' } : null) - rt.state.authSessions = { 's-1': { memberId: 'owner-1', tokenHash: 'a' }, 's-2': { memberId: 'owner-2', tokenHash: 'b' } } - // The board the two members read: owner-1 sees no card of this Project at all. - const theirs = await callAs('project-board', { id: 'project-p2' }, 'tok-1') - assert.equal(theirs.ok, true, JSON.stringify(theirs)) - assert.deepEqual(theirs.value.tasks ?? [], [], 'owner-1 sees no card of this Project') - // Member B, whose Bot owns the card and the Project, opens the meeting's decision. - await callAs('project-board', { id: 'project-p2' }, 'tok-2') - const own = await callAs('project-decision', { id: 'project-p2', path: 'decisions/2026-11-01-decided.md' }, 'tok-2') - assert.equal(own.ok, true, JSON.stringify(own)) - assert.equal(own.value.text, '# P2 decided this\n', 'the meeting card is visible to its owner') - // The plain record has no card: the Project's own role list decides, and owner-2 owns it. - const plain = await callAs('project-decision', { id: 'project-p2', path: 'decisions/2026-11-01-plain.md' }, 'tok-2') - assert.equal(plain.ok, true, JSON.stringify(plain)) - assert.equal(plain.value.text, '# A plain record\n') - // Owner-1 is neither in the Project's role list nor the owner of a card: every name refuses, - // in the words a name the listing never had answers. - for (const path of ['decisions/2026-11-01-decided.md', 'decisions/2026-11-01-plain.md', 'decisions/2026-11-01-secret.md']) { - const answer = await callAs('project-decision', { id: 'project-p2', path }, 'tok-1') - assert.equal(answer.ok, true, JSON.stringify(answer)) - assert.equal(answer.value.ok, false, `${path} must not open for a caller who sees no card`) - assert.equal(answer.value.reason, `not listed: ${path} is not in this board's decision list`) - assert.equal('text' in answer.value, false, `${path} carries no text`) - assert.equal('sha256' in answer.value, false, `${path} carries no hash`) - } - // The bound file is the proof: the caller who may read it is told its size, the caller who may - // not is never told anything the disk would have said. - const sized = await callAs('project-decision', { id: 'project-p2', path: 'decisions/2026-11-01-secret.md' }, 'tok-2') - assert.match(sized.value.reason, /^too large: /, 'a visible caller does reach the file') - } finally { - await stop() - rmSync(base, { recursive: true, force: true }) - } -}) - -test('#2041: revoking a card closes the name a board read already cached', async () => { - const base = mkdtempSync(join(tmpdir(), 'ds-bot-decisions-revoke-')) - const dir = join(base, 'project') - mkdirSync(join(dir, '.git'), { recursive: true }) - mkdirSync(join(dir, 'decisions')) - writeFileSync(join(dir, 'decisions', '2026-11-02-team.md'), '# Team decision\n') - const team = crossSeed() - team.projects['project-p1'].workDir = dir - team.meetings = { - 'meeting-team': { - id: 'meeting-team', status: 'decided', question: 'Who ships it?', owner: 'Chief', - project: 'project-p1', decisionDir: dir, decisionFile: 'decisions/2026-11-02-team.md', - taskIds: ['task-group'], createdAt: 1, updatedAt: 2, decidedAt: 3, - }, - } - const { rt, callAs, stop } = await start(undefined, team) - try { - rt.authenticate = async token => (token === 'tok-1' ? { memberId: 'owner-1' } : null) - rt.state.authSessions = { 's-1': { memberId: 'owner-1', tokenHash: 'a' } } - await callAs('project-board', { id: 'project-p1' }, 'tok-1') - const first = await callAs('project-decision', { id: 'project-p1', path: 'decisions/2026-11-02-team.md' }, 'tok-1') - assert.equal(first.ok, true, JSON.stringify(first)) - assert.equal(first.value.text, '# Team decision\n') - // The same cached read, after the card it was judged against moved to another owner and another - // room: the name is still in the listing, and the next open is judged again rather than served - // from the cache the first read left behind. - Object.assign(rt.state.tasks['task-group'], { ownerBotId: 'b', origin: { roomId: 'r2' } }) - const again = await callAs('project-board', { id: 'project-p1' }, 'tok-1') - assert.equal(again.value.decisions.files.some(file => file.path === 'decisions/2026-11-02-team.md'), true, 'the row is still listed') - const revoked = await callAs('project-decision', { id: 'project-p1', path: 'decisions/2026-11-02-team.md' }, 'tok-1') - assert.equal(revoked.value.ok, false, JSON.stringify(revoked)) - assert.equal(revoked.value.reason, "not listed: decisions/2026-11-02-team.md is not in this board's decision list") - assert.equal('text' in revoked.value, false) - } finally { - await stop() - rmSync(base, { recursive: true, force: true }) - } -}) - - -// A decided meeting written before the archive slice saved no folder, so its file is a row of the -// Project's own listing rather than one a meeting recorded. It still names the cards it decided, -// and the name has to keep them: with no card the role list answers, and a member who sees none of -// those cards reads the text. The two shapes must read the same, name by name. -const decisionFolder = (prefix) => { - const base = mkdtempSync(join(tmpdir(), prefix)) - const dir = join(base, 'project') - mkdirSync(join(dir, '.git'), { recursive: true }) - mkdirSync(join(dir, 'decisions')) - return { base, dir } -} - -// The owner the Project names is not a member record here, so a Bot's owner is card-affine and -// carries no Project role: the card door and the role list cannot be mistaken for each other. -const legacyFolderSeed = (dir, meetings) => { - const team = crossSeed() - team.projects['project-p1'].ownerUserIds = ['owner-9'] - team.projects['project-p1'].ownerUserId = 'owner-9' - team.projects['project-p1'].workDir = dir - team.projects['project-p1'].roles = { maintainers: [], members: ['owner-3'] } - team.members['owner-3'] = { id: 'owner-3', createdAt: 4, designatedChiefId: null } - team.meetings = meetings - return team -} - -const legacyMeeting = (id, overrides = {}) => ({ - id, status: 'decided', question: 'What shipped?', owner: 'Chief', project: 'project-p1', - decisionDir: '', decisionFile: `decisions/2026-10-29-${id}.md`, taskIds: ['task-group'], - createdAt: 1, updatedAt: 2, decidedAt: 3, ...overrides, -}) - -const signIn = (rt) => { - const members = { 'tok-1': 'owner-1', 'tok-2': 'owner-2', 'tok-3': 'owner-3' } - rt.authenticate = async token => (members[token] ? { memberId: members[token] } : null) - rt.state.authSessions = Object.fromEntries(Object.keys(members) - .map((token, at) => [`s-${at + 1}`, { memberId: members[token], tokenHash: token }])) -} - -const refused = (answer) => { - assert.equal(answer.ok, true, JSON.stringify(answer)) - assert.equal(answer.value.ok, false, 'a caller who sees none of the cards reads no text') - assert.equal('text' in answer.value, false) -} - -test('#2052: a record that saved no folder keeps the cards it decided, like one that saved a folder', async () => { - const { base, dir } = decisionFolder('ds-bot-decisions-record-') - writeFileSync(join(dir, 'decisions', '2026-10-29-nofolder.md'), '# decided with no folder\n') - writeFileSync(join(dir, 'decisions', '2026-10-29-folder.md'), '# decided with a folder\n') - const team = legacyFolderSeed(dir, { - 'm-nofolder': legacyMeeting('nofolder'), - 'm-folder': legacyMeeting('folder', { decisionDir: dir }), - }) - const { rt, callAs, stop } = await start(undefined, team) - try { - signIn(rt) - const board = await callAs('project-board', { id: 'project-p1' }, 'tok-3') - assert.equal(board.ok, true, JSON.stringify(board)) - assert.deepEqual(board.value.tasks ?? [], [], 'the member sees none of the cards this Project decides on') - for (const name of ['2026-10-29-nofolder.md', '2026-10-29-folder.md']) { - const path = `decisions/${name}` - assert.equal(board.value.decisions.files.some(file => file.path === path), true, `${path} is listed`) - const member = await callAs('project-decision', { id: 'project-p1', path }, 'tok-3') - refused(member) - assert.equal(member.value.reason, `not listed: ${path} is not in this board's decision list`) - } - // Card-affine and holding no Project role: the room's own admin reads both through the card door. - const noFolder = await callAs('project-decision', { id: 'project-p1', path: 'decisions/2026-10-29-nofolder.md' }, 'tok-1') - assert.equal(noFolder.value.ok, true, JSON.stringify(noFolder)) - assert.equal(noFolder.value.text, '# decided with no folder\n') - const folder = await callAs('project-decision', { id: 'project-p1', path: 'decisions/2026-10-29-folder.md' }, 'tok-1') - assert.equal(folder.value.ok, true, JSON.stringify(folder)) - assert.equal(folder.value.text, '# decided with a folder\n') - } finally { - await stop() - rmSync(base, { recursive: true, force: true }) - } -}) - -test('#2052: a record that decided no card still answers through the role list', async () => { - const { base, dir } = decisionFolder('ds-bot-decisions-nocard-') - writeFileSync(join(dir, 'decisions', '2026-10-29-nocard.md'), '# a record with no card\n') - const team = legacyFolderSeed(dir, { 'm-nocard': legacyMeeting('nocard', { taskIds: [] }) }) - const { rt, callAs, stop } = await start(undefined, team) - try { - signIn(rt) - await callAs('project-board', { id: 'project-p1' }, 'tok-3') - const member = await callAs('project-decision', { id: 'project-p1', path: 'decisions/2026-10-29-nocard.md' }, 'tok-3') - assert.equal(member.value.ok, true, JSON.stringify(member)) - assert.equal(member.value.text, '# a record with no card\n', 'a name no card binds follows the role list') - // The same name is still a name, not a list: a caller outside both doors reads nothing. - await callAs('project-board', { id: 'project-p1' }, 'tok-2') - const outsider = await callAs('project-decision', { id: 'project-p1', path: 'decisions/2026-10-29-nocard.md' }, 'tok-2') - refused(outsider) - assert.equal(outsider.value.reason, "not listed: decisions/2026-10-29-nocard.md is not in this board's decision list") - } finally { - await stop() - rmSync(base, { recursive: true, force: true }) - } -}) - -test('#2052: a record naming a file the folder did not list opens nothing', async () => { - const { base, dir } = decisionFolder('ds-bot-decisions-ghost-') - const team = legacyFolderSeed(dir, { 'm-ghost': legacyMeeting('ghost') }) - const { rt, callAs, stop } = await start(undefined, team) - try { - signIn(rt) - await callAs('project-board', { id: 'project-p1' }, 'tok-1') - for (const token of ['tok-1', 'tok-3']) { - const answer = await callAs('project-decision', { id: 'project-p1', path: 'decisions/2026-10-29-ghost.md' }, token) - refused(answer) - assert.equal(answer.value.reason, "not listed: decisions/2026-10-29-ghost.md is not in this board's decision list", - 'a name the listing never had stays a ghost') - } - } finally { - await stop() - rmSync(base, { recursive: true, force: true }) - } -}) - -test('#2052: a record of one Project puts no cards on another Project name', async () => { - const { base, dir } = decisionFolder('ds-bot-decisions-cross-') - writeFileSync(join(dir, 'decisions', '2026-11-01-shared.md'), '# read through the other Project\n') - const team = legacyFolderSeed(dir, { 'm-shared': legacyMeeting('shared', { decisionFile: 'decisions/2026-11-01-shared.md' }) }) - // The second Project lists the same relative name in the same folder and has a role for owner-1. - team.projects['project-p2'].ownerUserId = 'owner-9' - team.projects['project-p2'].workDir = dir - team.projects['project-p2'].roles = { maintainers: [], members: ['owner-1'] } - const { rt, callAs, stop } = await start(undefined, team) - try { - signIn(rt) - // The first Project's record binds its own card to its own read: its member still reads nothing. - await callAs('project-board', { id: 'project-p1' }, 'tok-3') - const mine = await callAs('project-decision', { id: 'project-p1', path: 'decisions/2026-11-01-shared.md' }, 'tok-3') - refused(mine) - // The second Project has no record for the name: its role list decides, and owner-1 reaches it. - await callAs('project-board', { id: 'project-p2' }, 'tok-1') - const theirs = await callAs('project-decision', { id: 'project-p2', path: 'decisions/2026-11-01-shared.md' }, 'tok-1') - assert.equal(theirs.value.ok, true, JSON.stringify(theirs)) - assert.equal(theirs.value.text, '# read through the other Project\n', 'a record of one Project gates only its own names') - } finally { - await stop() - rmSync(base, { recursive: true, force: true }) - } -}) - -test('#2052: two records naming one file keep the cards the first one decided', async () => { - const { base, dir } = decisionFolder('ds-bot-decisions-twice-') - const archive = join(base, 'archive') - mkdirSync(join(archive, 'decisions'), { recursive: true }) - writeFileSync(join(archive, 'decisions', '2026-11-02-twice.md'), '# the first record decided it\n') - const team = legacyFolderSeed(dir, { - 'm-first': legacyMeeting('first', { decisionDir: archive, decisionFile: 'decisions/2026-11-02-twice.md' }), - 'm-second': legacyMeeting('second', { decisionDir: archive, decisionFile: 'decisions/2026-11-02-twice.md', taskIds: [] }), - }) - const { rt, callAs, stop } = await start(undefined, team) - try { - signIn(rt) - for (const round of ['first read', 'second read']) { - const board = await callAs('project-board', { id: 'project-p1' }, 'tok-3') - assert.equal(board.value.decisions.files.some(file => file.path === 'decisions/2026-11-02-twice.md'), true, - `${round}: one row per name`) - const member = await callAs('project-decision', { id: 'project-p1', path: 'decisions/2026-11-02-twice.md' }, 'tok-3') - refused(member) - assert.equal(member.value.reason, "not listed: decisions/2026-11-02-twice.md is not in this board's decision list", - `${round}: the first record supplies the cards`) - } - const own = await callAs('project-decision', { id: 'project-p1', path: 'decisions/2026-11-02-twice.md' }, 'tok-1') - assert.equal(own.value.ok, true, JSON.stringify(own)) - assert.equal(own.value.text, '# the first record decided it\n') - } finally { - await stop() - rmSync(base, { recursive: true, force: true }) - } -}) - -// `archivedOf` answers `undefined` for two records that are not the same shape: one written before -// the archive slice (`decisionDir === ''`), and one that named a folder whose file this read could -// not open. The `archived === undefined` branch of `decisionsOf` is where they part: the first -// falls through to the legacy `named` backfill, the second stops at the `continue` before it. The -// two reads below are one fixture with that one field changed, so the difference can only come -// from that branch. -test('#2053: only a record that named no folder reaches the legacy card backfill', async () => { - const read = async (shape) => { - const { base, dir } = decisionFolder('ds-bot-decisions-shape-') - const archive = join(base, 'archive') - mkdirSync(join(archive, 'decisions'), { recursive: true }) - writeFileSync(join(dir, 'decisions', '2026-11-03-shape.md'), '# the Project folder copy\n') - const decisionDir = shape === 'folder' ? archive : '' - const team = legacyFolderSeed(dir, { - 'm-shape': legacyMeeting('shape', { decisionDir, decisionFile: 'decisions/2026-11-03-shape.md' }), - }) - const { rt, callAs, stop } = await start(undefined, team) - try { - signIn(rt) - const board = await callAs('project-board', { id: 'project-p1' }, 'tok-3') - assert.equal(board.value.decisions.files.some(file => file.path === 'decisions/2026-11-03-shape.md'), true, - `${shape}: the Project folder lists the row`) - return { - member: (await callAs('project-decision', { id: 'project-p1', path: 'decisions/2026-11-03-shape.md' }, 'tok-3')).value, - own: (await callAs('project-decision', { id: 'project-p1', path: 'decisions/2026-11-03-shape.md' }, 'tok-1')).value, - } - } finally { - await stop() - rmSync(base, { recursive: true, force: true }) - } - } - // No folder: the legacy backfill binds `task-group`, so a Project member without the card reads - // nothing while the card's own party reads the text. - const noFolder = await read('no folder') - assert.equal(noFolder.member.ok, false, JSON.stringify(noFolder.member)) - assert.equal(noFolder.member.reason, "not listed: decisions/2026-11-03-shape.md is not in this board's decision list") - assert.equal(noFolder.own.ok, true) - assert.equal(noFolder.own.text, '# the Project folder copy\n') - // The folder's file was never opened, so nothing is bound: the row answers through the role list - // and the same member reads the text, exactly as a row no record mentions. - const folder = await read('folder') - assert.equal(folder.member.ok, true, JSON.stringify(folder.member)) - assert.equal(folder.member.text, '# the Project folder copy\n', - 'cards from a record whose own file never opened stay off this folder row') - // The card door opens nothing here either: no card of that record reached this row. - assert.equal(folder.own.ok, false, JSON.stringify(folder.own)) - assert.equal(folder.own.reason, "not listed: decisions/2026-11-03-shape.md is not in this board's decision list") -}) - -// One world, two runs: the same two records and the same folder, only the insertion order of -// `state.meetings` differs. `m-arch` named a folder that does not hold its file, so `archivedOf` -// cannot open it; `m-no` names no folder and is the record whose file the Project folder listed. -// Whichever of them the shared `undefined` branch lets into the backfill supplies the row's cards, -// so an order-dependent answer here is the bug itself. -test('#2053: the same two records answer word for word in either key order', async () => { - const { base, dir } = decisionFolder('ds-bot-decisions-order-') - const archive = join(base, 'archive') - mkdirSync(join(archive, 'decisions'), { recursive: true }) - writeFileSync(join(dir, 'decisions', '2026-11-03-x.md'), '# the Project-folder record\n') - const records = { - 'm-arch': legacyMeeting('arch', { decisionDir: archive, decisionFile: 'decisions/2026-11-03-x.md', taskIds: ['task-b1'] }), - 'm-no': legacyMeeting('no', { decisionDir: '', decisionFile: 'decisions/2026-11-03-x.md', taskIds: ['task-group'] }), - } - const read = async (order) => { - const team = legacyFolderSeed(dir, Object.fromEntries(order.map(id => [id, structuredClone(records[id])]))) - team.projects['project-p1'].roles = { maintainers: [], members: ['owner-1', 'owner-2'] } - team.members['owner-2'] = { id: 'owner-2', createdAt: 4, designatedChiefId: null } - const { rt, callAs, stop } = await start(undefined, team) - try { - signIn(rt) - const board = await callAs('project-board', { id: 'project-p1' }, 'tok-1') - assert.equal(board.value.decisions.files.some(file => file.path === 'decisions/2026-11-03-x.md'), true, - `${order.join(' then ')}: one row for the name`) - return { - // owner-1 holds `task-group`, the card of the folder-less record. - no: (await callAs('project-decision', { id: 'project-p1', path: 'decisions/2026-11-03-x.md' }, 'tok-1')).value, - // owner-2 holds `task-b1`, the card of the record whose own folder never opened that file. - arch: (await callAs('project-decision', { id: 'project-p1', path: 'decisions/2026-11-03-x.md' }, 'tok-2')).value, - } - } finally { - await stop() - } - } - const archFirst = await read(['m-arch', 'm-no']) - const noFirst = await read(['m-no', 'm-arch']) - // The folder-less record supplies the cards: its own party reads the text, in both runs. - assert.equal(archFirst.no.ok, true, JSON.stringify(archFirst.no)) - assert.equal(archFirst.no.text, '# the Project-folder record\n') - assert.equal(noFirst.no.ok, true, JSON.stringify(noFirst.no)) - assert.equal(noFirst.no.text, '# the Project-folder record\n') - // The other record's cards belong to no row here, so its party is refused in both runs — reading - // in either one would mean the folder it named was followed, or that it took the row's cards. - assert.equal(archFirst.arch.ok, false, JSON.stringify(archFirst.arch)) - assert.equal(noFirst.arch.ok, false, JSON.stringify(noFirst.arch)) - assert.deepEqual(noFirst, archFirst, 'the stored order of the two records changes no answer') - rmSync(base, { recursive: true, force: true }) -}) - -// #2055: the board reads the decisions folder through the same listing and the same file read, so -// a `decisions` that is a symlink is no decisions at all: no row, no hash, the empty reason the -// board already gives a folder without one, and a decided meeting keeps its cards with no path. -test('#2055: the board reads a linked decisions folder as no decisions', async () => { - const home = mkdtempSync(join(tmpdir(), 'ds-bot-board-')) - const outside = mkdtempSync(join(tmpdir(), 'ds-bot-outside-decisions-')) - writeFileSync(join(outside, 'outside.md'), '# Outside\n') - const dir = mkdtempSync(join(tmpdir(), 'ds-bot-linkdecisions-')) - mkdirSync(join(dir, '.git')) - symlinkSync(outside, join(dir, 'decisions')) - const team = recordSeed() - team.projects['project-p1'].workDir = dir - const { rt, call, stop } = await start(home, team) - try { - const board = await rt.projectBoard('project-p1') - assert.equal(board.decisions.dir, dir, 'the folder the board read is still named') - assert.deepEqual(board.decisions.files, [], 'a linked folder contributes no row') - assert.match(board.decisions.reason, /no decision file in .*decisions/, 'the empty state is the one a folder without decisions gives') - assert.equal(JSON.stringify(board).includes('outside.md'), false, 'the outside name and hash never reach the board') - assert.deepEqual(board.meetings.decided.map(meeting => [meeting.id, meeting.path]), [['meeting-decided', '']], 'a decided meeting stays decided, with no path it cannot read') - const served = value(await call('project-board', { id: 'project-p1' })) - assert.deepEqual(served.decisions.files, [], 'the endpoint the page uses answers the same') - } finally { - await stop() - rmSync(dir, { recursive: true, force: true }) - rmSync(outside, { recursive: true, force: true }) - } -}) diff --git a/patches/ds-bot/files/test/project-digest-authz.test.js b/patches/ds-bot/files/test/project-digest-authz.test.js index 783280134..598913933 100644 --- a/patches/ds-bot/files/test/project-digest-authz.test.js +++ b/patches/ds-bot/files/test/project-digest-authz.test.js @@ -9,6 +9,7 @@ import { join } from 'node:path' import { test } from 'node:test' import { createRuntime } from '../index.js' import { fakeContext } from './fake-ctx.js' +import { installProjectDomain } from './project-domain.js' // One member has signed in (#1846): that single line is what turns `authzEnforced()` on, and it is // the only difference from `project-assistant.test.js`'s seed. `owner-1` and `owner-2` own the two @@ -70,6 +71,7 @@ async function start() { sessions: { flush: async () => {} }, }) const rt = createRuntime(ctx, { home }) + installProjectDomain(rt) await rt.load() rt.connectorsView = () => [{ id: 'github', name: 'GitHub', state: 'ready', account: 'Birfy' }] rt.deliver = async () => {} diff --git a/patches/ds-bot/files/test/project-domain.js b/patches/ds-bot/files/test/project-domain.js new file mode 100644 index 000000000..11ea44109 --- /dev/null +++ b/patches/ds-bot/files/test/project-domain.js @@ -0,0 +1,12 @@ +// The Project domain, applied to a runtime the way a profile has it (#2684). +// +// The domain — Project State, the board, the Project's own Chief, the team dossier, the metrics +// and the assistant — lives in `plugins/project` now, and it installs its seams in its own +// `apply()`. A core test that reads a Project through the store, the channel or the board needs +// the plugin applied to the runtime it built, exactly as a profile mounts it; the plugin's own +// suite covers the domain itself and lives in `plugins/project/test/`. +import { apply as applyProject } from '../../plugins/project/index.js' + +export function installProjectDomain(rt) { + return applyProject({ flybot: rt.flybot }) +} diff --git a/patches/ds-bot/files/test/project-metrics-cache-bound.test.js b/patches/ds-bot/files/test/project-metrics-cache-bound.test.js deleted file mode 100644 index 02b033c7b..000000000 --- a/patches/ds-bot/files/test/project-metrics-cache-bound.test.js +++ /dev/null @@ -1,22 +0,0 @@ -// #2806: the shared metrics cache is keyed by a five-minute window, so an entry can never be -// answered again once its window is over — a bucket that is done is dead weight, and the host kept -// every one of them for the life of the process (288 a day per repository and zone). -import test from 'node:test' -import assert from 'node:assert/strict' -import { METRICS_TTL_MS, readMetrics } from '../src/host/project-metrics.js' - -const NOW = Date.parse('2026-10-30T12:00:00Z') -const fetcher = async () => ({ ok: true, json: async () => [] }) - -test('a five-minute bucket that is over is dropped, not kept for the life of the host', async () => { - const store = new Map() - const read = now => readMetrics({ repo: 'o/r', token: 'tok', fetchImpl: fetcher, now, cacheStore: store }) - // Bucket 0 fills the cache, then 200 more windows pass: each one is a new key, and the read that - // fills it is the only chance this table has to drop the windows it can no longer answer. - assert.equal((await read(NOW)).cache, 'miss') - for (let bucket = 1; bucket <= 200; bucket += 1) await read(NOW + bucket * METRICS_TTL_MS) - // A bucket that is over is a miss, not a hit served out of a dead entry. Before #2806 this read - // answered `hit` from bucket 0's entry, which had been carried across 200 later windows. - assert.equal((await read(NOW)).cache, 'miss') - assert.ok(store.size <= 2, `the store keeps the live window, not 201 dead ones (kept ${store.size})`) -}) diff --git a/patches/ds-bot/files/test/project-metrics-scope.test.js b/patches/ds-bot/files/test/project-metrics-scope.test.js deleted file mode 100644 index ec229aabf..000000000 --- a/patches/ds-bot/files/test/project-metrics-scope.test.js +++ /dev/null @@ -1,278 +0,0 @@ -// #1848: the board's running table is the board's own cards, not the deployment's. Every branch of -// the metrics read carries the same list the rows were built from, and a card the same actor is -// refused on a row never appears in `running` — the host must not send the titles at all. -import assert from 'node:assert/strict' -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { test } from 'node:test' -import { createRuntime, provideFlyBot } from '../index.js' -import { readMetrics } from '../src/host/project-metrics.js' -import { fakeContext } from './fake-ctx.js' - -const HOUR = 60 * 60 * 1000 -const NOW = Date.parse('2026-10-30T12:00:00Z') - -// Two Projects with two owners, and a card in flight on each one. The second Project's card is the -// leak: it is `claimed`, carries a title and a holder, and the first owner is refused on it. -const seed = () => ({ - version: 2, - revision: 1, - mainBotIds: ['a'], - members: { - 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' }, - 'owner-2': { id: 'owner-2', createdAt: 2, designatedChiefId: 'foreign' }, - }, - bots: { - a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1, ownerId: 'owner-1' }, - foreign: { id: 'foreign', name: 'Foreigner Bot', role: 'Bot', sessionId: 'foreign', createdAt: 2, ownerId: 'owner-2' }, - }, - rooms: { - r1: { id: 'r1', name: 'Ops', members: ['a'], mode: 'admin', admin: 'a', named: true, sessions: {}, log: [], seq: 0, seen: {}, projectId: 'project-p1' }, - r2: { id: 'r2', name: 'Garden', members: ['foreign'], mode: 'admin', admin: 'foreign', named: true, sessions: {}, log: [], seq: 0, seen: {}, projectId: 'project-p2' }, - }, - agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, grants: {}, - projects: { - 'project-p1': { id: 'project-p1', name: 'P1', repository: 'ThinkFlowLab/FlyBot', defaultBranch: 'main', ownerUserId: 'owner-1', createdAt: 1, updatedAt: 1, status: 'active' }, - 'project-p2': { id: 'project-p2', name: 'P2', repository: 'ThinkFlowLab/FlyBot', defaultBranch: 'main', ownerUserId: 'owner-2', createdAt: 1, updatedAt: 1, status: 'active' }, - }, - projectRuns: {}, - tasks: { - // This board: one card in flight, one finished an hour after it started. - 'task-group': { id: 'task-group', kind: 'group', status: 'claimed', claimedBy: 'Writer', claimedAt: NOW - 2 * HOUR, origin: { roomId: 'r1' }, ownerBotId: 'a', assignee: 'Writer', title: 'Ship it', createdAt: 5, updatedAt: NOW - HOUR }, - 'task-done-a': { id: 'task-done-a', kind: 'group', status: 'accepted', origin: { roomId: 'r1' }, ownerBotId: 'a', title: 'Closed on p1', createdAt: NOW - 2 * HOUR, completedAt: NOW - HOUR, updatedAt: NOW - HOUR }, - // The other Project: same shapes, and nine hours from start to done. - 'task-foreign': { id: 'task-foreign', kind: 'group', status: 'claimed', claimedBy: 'Foreigner', claimedAt: NOW - 2 * HOUR, origin: { roomId: 'r2' }, ownerBotId: 'foreign', title: 'Secret salary review', createdAt: 5, updatedAt: NOW - HOUR }, - 'task-foreign-done': { id: 'task-foreign-done', kind: 'group', status: 'accepted', origin: { roomId: 'r2' }, ownerBotId: 'foreign', title: 'Foreign closed', createdAt: NOW - 10 * HOUR, completedAt: NOW - HOUR, updatedAt: NOW - HOUR }, - }, -}) - -async function start(home = mkdtempSync(join(tmpdir(), 'ds-bot-scope-')), team = seed()) { - if (!existsSync(join(home, 'state.json'))) writeFileSync(join(home, 'state.json'), JSON.stringify(team)) - const ctx = fakeContext() - const agents = new Map() - const agentOf = (sessionId) => { - if (!agents.has(sessionId)) agents.set(sessionId, { session: { id: sessionId }, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle() {}, inbox: { nextStep: [], nextTurn: [], remove() {} } }) - return agents.get(sessionId) - } - Object.assign(ctx, { - llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, - workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, - sessionController: { create: async () => ({ sessionId: 's1' }), resolveAgent: async id => ({ agent: agentOf(id) }), selectModel: async () => {} }, - sessionTitle: { rename: async () => {} }, - sessions: { flush: async () => {} }, - }) - const rt = createRuntime(ctx, { home }) - provideFlyBot(ctx, rt.flybot) - await rt.load() - // Somebody has signed in: the row guard of #1723 is on, and the running table must obey it too. - rt.state.authSessions = { 's-1': { memberId: 'owner-1', tokenHash: 'h1' }, 's-2': { memberId: 'owner-2', tokenHash: 'h2' } } - rt.now = () => NOW - const stop = async ({ keepHome = false } = {}) => { - await ctx.dispose() - if (!keepHome) rmSync(home, { recursive: true, force: true, maxRetries: 5 }) - } - return { rt, stop } -} - -test('#1848: another Project\'s claimed card appears in neither the rows nor the running table', async () => { - const { rt, stop } = await start() - const realFetch = globalThis.fetch - const realSecret = rt.secretValue - rt.githubRepo = 'test/scope-1' - // A source that answers, so both boards get to their success branch. - rt.secretValue = name => (name === 'GITHUB_TOKEN' ? 'tok' : undefined) - globalThis.fetch = async () => ({ ok: true, json: async () => [] }) - try { - // Reading the other Project's board as a member who cannot see its cards. - const foreign = await rt.projectBoard('project-p2', { memberId: 'owner-1' }) - assert.deepEqual(foreign.rows, [], 'a card of the other Project is not a row here') - assert.deepEqual(foreign.metrics.running, [], 'and it is not a running row here either') - // This Project's own board: the one card in flight is one row, and the other Project's card is - // not beside it. - const board = await rt.projectBoard('project-p1', { memberId: 'owner-1' }) - const rows = board.rows.map(row => row.id) - assert.equal(rows.includes('task-group'), true) - assert.equal(rows.some(id => id.startsWith('task-foreign')), false, 'no card of the other Project is a row here') - assert.deepEqual(board.metrics.running.map(row => row.id), ['task-group']) - assert.equal(board.metrics.running[0].title, 'Ship it') - } finally { - globalThis.fetch = realFetch - rt.secretValue = realSecret - await stop() - } -}) - -test('#1848: the board\'s own card stays in the running table when the GitHub source is unavailable', async () => { - const { rt, stop } = await start() - const realFetch = globalThis.fetch - const realSecret = rt.secretValue - rt.githubRepo = 'test/scope-2' - try { - // No credential: the four numbers answer `ok: false`, but the running table is local data and - // must keep the card it can see. - rt.secretValue = () => undefined - const noToken = await rt.projectBoard('project-p1', { memberId: 'owner-1' }) - assert.equal(noToken.metrics.ok, false) - assert.equal(noToken.metrics.reason, 'no-credentials') - assert.deepEqual(noToken.metrics.running.map(row => row.id), ['task-group']) - // A source that fails answers the same way for the same reason. - rt.secretValue = name => (name === 'GITHUB_TOKEN' ? 'tok' : undefined) - globalThis.fetch = async () => { throw new Error('socket hang up') } - const failed = await rt.projectBoard('project-p1', { memberId: 'owner-1' }) - assert.equal(failed.metrics.ok, false) - assert.match(failed.metrics.reason, /^read-failed:/) - assert.deepEqual(failed.metrics.running.map(row => row.id), ['task-group']) - } finally { - globalThis.fetch = realFetch - rt.secretValue = realSecret - await stop() - } -}) - -test('#1848: a cache hit and a cache miss carry the same running rows, byte for byte', async () => { - const { rt, stop } = await start() - const realFetch = globalThis.fetch - const realSecret = rt.secretValue - rt.githubRepo = 'test/scope-3' - rt.secretValue = name => (name === 'GITHUB_TOKEN' ? 'tok' : undefined) - globalThis.fetch = async () => ({ ok: true, json: async () => [] }) - try { - const miss = await rt.projectBoard('project-p1', { memberId: 'owner-1' }) - assert.equal(miss.metrics.cache, 'miss') - // Same instant, so the second read is the cache hit and its numbers come from the cache. - const hit = await rt.projectBoard('project-p1', { memberId: 'owner-1' }) - assert.equal(hit.metrics.cache, 'hit') - assert.deepEqual(hit.metrics.running, miss.metrics.running) - assert.deepEqual(miss.metrics.running.map(row => row.id), ['task-group']) - assert.equal(JSON.stringify(hit.metrics.running), JSON.stringify(miss.metrics.running)) - } finally { - globalThis.fetch = realFetch - rt.secretValue = realSecret - await stop() - } -}) - -test('#1848: the done-task median is this board\'s cards, not the deployment\'s', async () => { - const { rt, stop } = await start() - const realFetch = globalThis.fetch - const realSecret = rt.secretValue - rt.githubRepo = 'test/scope-4' - rt.secretValue = name => (name === 'GITHUB_TOKEN' ? 'tok' : undefined) - globalThis.fetch = async () => ({ ok: true, json: async () => [] }) - try { - const board = await rt.projectBoard('project-p1', { memberId: 'owner-1' }) - // One card finished an hour after it started here; the other Project's nine-hour card is not - // this board's work. - assert.equal(board.metrics.metrics.taskDoneMedianMs, HOUR) - } finally { - globalThis.fetch = realFetch - rt.secretValue = realSecret - await stop() - } -}) - -// #1880: the metrics cache is keyed by repository, time zone and five-minute bucket only, so a hit -// is shared by every reader of that repo. The done-task median is computed from the reader's own -// cards, like the running table, so a hit must re-project it and never answer with the cards of -// whoever filled the entry first. -test('#1880: a cache hit projects the done-task median from the reader\'s own cards', async () => { - const { rt, stop } = await start() - const realFetch = globalThis.fetch - const realSecret = rt.secretValue - rt.githubRepo = 'test/scope-5' - rt.secretValue = name => (name === 'GITHUB_TOKEN' ? 'tok' : undefined) - globalThis.fetch = async () => ({ ok: true, json: async () => [] }) - try { - // The other board fills this bucket: one card, nine hours from start to done. - const other = await rt.projectBoard('project-p2', { memberId: 'owner-2' }) - assert.equal(other.metrics.cache, 'miss') - assert.equal(other.metrics.metrics.taskDoneMedianMs, 9 * HOUR) - // Same repo, same bucket, a different board: the read is a hit, and the median is this board's - // hour — not the nine hours the first reader's cards produced. - const mine = await rt.projectBoard('project-p1', { memberId: 'owner-1' }) - assert.equal(mine.metrics.cache, 'hit') - assert.equal(mine.metrics.metrics.taskDoneMedianMs, HOUR) - // A hit belongs to its reader, not to whoever filled the entry: back on the other board the - // nine hours return. - const back = await rt.projectBoard('project-p2', { memberId: 'owner-2' }) - assert.equal(back.metrics.cache, 'hit') - assert.equal(back.metrics.metrics.taskDoneMedianMs, 9 * HOUR) - } finally { - globalThis.fetch = realFetch - rt.secretValue = realSecret - await stop() - } -}) - -test('#1880: a cache hit\'s running rows and done median come from the same board', async () => { - const { rt, stop } = await start() - const realFetch = globalThis.fetch - const realSecret = rt.secretValue - rt.githubRepo = 'test/scope-6' - rt.secretValue = name => (name === 'GITHUB_TOKEN' ? 'tok' : undefined) - globalThis.fetch = async () => ({ ok: true, json: async () => [] }) - try { - const miss = await rt.projectBoard('project-p1', { memberId: 'owner-1' }) - assert.equal(miss.metrics.cache, 'miss') - const hit = await rt.projectBoard('project-p2', { memberId: 'owner-2' }) - assert.equal(hit.metrics.cache, 'hit') - // One projection, one source: the rows are this board's card and the median beside it is this - // board's nine hours, never a row from one reader with a number from the other. - assert.deepEqual(hit.metrics.running.map(row => row.id), ['task-foreign']) - assert.equal(hit.metrics.metrics.taskDoneMedianMs, 9 * HOUR) - assert.equal(miss.metrics.metrics.taskDoneMedianMs, HOUR) - } finally { - globalThis.fetch = realFetch - rt.secretValue = realSecret - await stop() - } -}) - -// The shape, not only this afternoon's behaviour: the entry is shared by every Project on the repo, -// so a caller-scoped field in it is the same leak waiting for the next writer. The whitelist is the -// repository-level numbers and the three stamps beside them, and nothing computed from `tasks`. -test('#1880: the metrics cache holds repository-level numbers only', async () => { - const entries = new Map() - const cacheStore = { get: key => entries.get(key), set: (key, value) => entries.set(key, value) } - const tasks = Object.values(seed().tasks) - await readMetrics({ - repo: 'test/scope-7', - token: 'tok', - fetchImpl: async () => ({ ok: true, json: async () => [] }), - timeZone: 'UTC', - now: NOW, - tasks, - cacheStore, - }) - const [[key, entry]] = [...entries] - assert.equal(key, `test/scope-7|UTC|${Math.floor(NOW / (5 * 60 * 1000))}`, 'the key stays repo, time zone and bucket') - assert.deepEqual(Object.keys(entry.value).sort(), ['generatedAt', 'metrics', 'ok', 'timeZone']) - assert.deepEqual(Object.keys(entry.value.metrics).sort(), [ - 'issueCloseMedianMs', 'issuesClosedToday', 'issuesOpenedToday', - 'prMergeMedianMs', 'prsMergedToday', 'prsOpenedToday', - ]) - assert.equal(JSON.stringify(entry.value).includes('running'), false, 'the caller\'s rows are never cached') - assert.equal(JSON.stringify(entry.value).includes('taskDoneMedianMs'), false, 'nor the median computed from them') -}) - -// #1880: the projection that re-reads the caller's numbers on every read must not reach the -// unavailable branches. "Read it, could not" is answered with nulls, never with a zero that reads -// as "no card ever finished here" — the one thing #1848 and #1849 both keep. -test('#1880: an unavailable source keeps the done median null, never zero', async () => { - const tasks = Object.values(seed().tasks).filter(task => task.origin.roomId === 'r1') - const cacheStore = { get: () => undefined, set: () => {} } - const base = { repo: 'test/scope-8', timeZone: 'UTC', now: NOW, tasks, cacheStore } - const cases = [ - ['no-credentials', { ...base, token: '' }], - ['no-fetch', { ...base, token: 'tok' }], - ['read-failed', { ...base, token: 'tok', fetchImpl: async () => { throw new Error('socket hang up') } }], - ] - for (const [label, args] of cases) { - const read = await readMetrics(args) - assert.equal(read.ok, false, `${label} answers not ok`) - assert.match(read.reason, label === 'read-failed' ? /^read-failed: / : new RegExp(`^${label}$`)) - assert.equal(read.metrics.taskDoneMedianMs, null, `${label}: an unreadable source is not a zero median`) - assert.deepEqual(read.running.map(row => row.id), ['task-group'], `${label}: the local rows are still the board's`) - } -}) diff --git a/patches/ds-bot/files/test/project-metrics.test.js b/patches/ds-bot/files/test/project-metrics.test.js deleted file mode 100644 index d7f0fde29..000000000 --- a/patches/ds-bot/files/test/project-metrics.test.js +++ /dev/null @@ -1,286 +0,0 @@ -// #1829: the board's seven metrics. The read is injected, so these cases spend no network and -// count their own source calls; the day boundary is the reader's zone, and a missing credential or -// a failed read answers `null` (painted `—`), never `0`. -import test from 'node:test' -import assert from 'node:assert/strict' -import { METRIC_KEYS, METRICS_TTL_MS, dayKey, median, readMetrics, runningOf, summarizeMetrics } from '../src/host/project-metrics.js' - -const HOUR = 60 * 60 * 1000 -const NOW = Date.parse('2026-10-30T12:00:00Z') - -const pr = (created, merged) => ({ created_at: created, merged_at: merged }) -const issue = (created, closed) => ({ created_at: created, closed_at: closed, pull_request: undefined }) - -test('the natural day is the reader zone, not UTC', () => { - // 2026-10-30T16:30Z is still 10-30 in UTC and already 10-31 in Shanghai. - const at = Date.parse('2026-10-30T16:30:00Z') - assert.equal(dayKey(at, 'UTC'), '2026-10-30') - assert.equal(dayKey(at, 'Asia/Shanghai'), '2026-10-31') - // 10:00Z on 10-30 is that same day in both zones, but 20:00Z is already 10-31 in Shanghai. - const source = { ok: true, pullRequests: [pr('2026-10-30T10:00:00Z', null)], issues: [] } - const utc = summarizeMetrics({ now: Date.parse('2026-10-30T20:00:00Z'), timeZone: 'UTC', source }) - const shanghai = summarizeMetrics({ now: Date.parse('2026-10-30T20:00:00Z'), timeZone: 'Asia/Shanghai', source }) - assert.equal(utc.metrics.prsOpenedToday, 1) - assert.equal(shanghai.metrics.prsOpenedToday, 0) -}) - -test('a day count compares the field it documents', () => { - const source = { - ok: true, - pullRequests: [pr('2026-10-29T10:00:00Z', '2026-10-30T09:00:00Z'), pr('2026-10-30T08:00:00Z', null)], - issues: [issue('2026-10-30T07:00:00Z', null), issue('2026-10-20T07:00:00Z', '2026-10-30T11:00:00Z')], - } - const out = summarizeMetrics({ now: NOW, timeZone: 'UTC', source }) - assert.equal(out.metrics.prsOpenedToday, 1) - assert.equal(out.metrics.prsMergedToday, 1) - assert.equal(out.metrics.issuesOpenedToday, 1) - assert.equal(out.metrics.issuesClosedToday, 1) -}) - -test('an empty window is null, never zero, and median halves round', () => { - assert.equal(median([]), null) - assert.equal(median([10]), 10) - assert.equal(median([10, 20]), 15) - assert.equal(median([30, 10, 20]), 20) - const out = summarizeMetrics({ now: NOW, timeZone: 'UTC', source: { ok: true, pullRequests: [], issues: [] } }) - assert.equal(out.metrics.prMergeMedianMs, null) - assert.equal(out.metrics.issueCloseMedianMs, null) - assert.equal(out.metrics.taskDoneMedianMs, null) -}) - -test('durations are the last seven days only', () => { - const source = { ok: true, pullRequests: [pr('2026-10-01T00:00:00Z', '2026-10-02T00:00:00Z'), pr('2026-10-30T06:00:00Z', '2026-10-30T08:00:00Z')], issues: [] } - const out = summarizeMetrics({ now: NOW, timeZone: 'UTC', source }) - assert.equal(out.metrics.prMergeMedianMs, 2 * HOUR) -}) - -test('no credential answers unavailable without a request', async () => { - let calls = 0 - const out = await readMetrics({ repo: 'a/b', token: '', fetchImpl: async () => { calls += 1; return { ok: true, json: async () => [] } }, now: NOW, cacheStore: new Map() }) - assert.equal(calls, 0) - assert.equal(out.ok, false) - assert.equal(out.reason, 'no-credentials') - assert.equal(out.metrics.prsOpenedToday, null) -}) - -test('a failed read degrades and is not cached', async () => { - const store = new Map() - const failing = async () => ({ ok: false, status: 503, json: async () => ({}) }) - const out = await readMetrics({ repo: 'a/b', token: 't', fetchImpl: failing, now: NOW, cacheStore: store }) - assert.equal(out.ok, false) - assert.match(out.reason, /read-failed/) - assert.equal(out.metrics.issuesClosedToday, null) - assert.equal(store.size, 0) -}) - -test('a cache hit spends the source once', async () => { - let calls = 0 - const fetchImpl = async () => { - calls += 1 - return { ok: true, json: async () => [{ created_at: '2026-10-30T06:00:00Z', merged_at: null }] } - } - const store = new Map() - const first = await readMetrics({ repo: 'a/b', token: 't', fetchImpl, now: NOW, cacheStore: store }) - const second = await readMetrics({ repo: 'a/b', token: 't', fetchImpl, now: NOW + 1000, cacheStore: store }) - assert.equal(first.cache, 'miss') - assert.equal(second.cache, 'hit') - assert.equal(calls, 2) // one pulls read and one issues read, once - assert.equal(second.metrics.prsOpenedToday, 1) - // The window rolls: past the TTL the source is read again. - await readMetrics({ repo: 'a/b', token: 't', fetchImpl, now: NOW + METRICS_TTL_MS + 1, cacheStore: store }) - assert.equal(calls, 4) -}) - -test('a card claimed after a cached read still shows as running', async () => { - const fetchImpl = async () => ({ ok: true, json: async () => [] }) - const store = new Map() - const before = { t1: { id: 't1', status: 'open', title: 'Wait' } } - const first = await readMetrics({ repo: 'a/b', token: 't', fetchImpl, now: NOW, tasks: before, cacheStore: store }) - assert.equal(first.running.length, 0) - // The claim lands a second later, still inside the five-minute window: the numbers are cached, - // the rows are not, so the running table is not five minutes stale (#1829). - const after = { ...before, t2: { id: 't2', status: 'claimed', claimedBy: 'Dev', claimedAt: NOW - HOUR, updatedAt: NOW, title: 'Ship it' } } - const second = await readMetrics({ repo: 'a/b', token: 't', fetchImpl, now: NOW + 1000, tasks: after, cacheStore: store }) - assert.equal(second.cache, 'hit') - assert.deepEqual(second.running.map(row => row.id), ['t2']) - assert.equal(store.size, 1) - assert.equal(Object.hasOwn([...store.values()][0].value, 'running'), false, 'the cache holds the numbers, not the rows') -}) - -test('a running row carries who, how long and where from', () => { - const row = runningOf({ id: 't1', title: 'Ship it', status: 'claimed', claimedBy: 'Dev', claimedAt: NOW - 2 * HOUR, updatedAt: NOW - HOUR, kind: 'group', origin: { roomId: 'r1' } }, NOW) - assert.deepEqual(row, { id: 't1', title: 'Ship it', status: 'claimed', who: 'Dev', sinceMs: 2 * HOUR, source: 'group', lastActivityAt: NOW - HOUR }) - assert.equal(runningOf({ id: 't2', status: 'open' }, NOW), null) - assert.equal(runningOf({ id: 't3', status: 'claimed' }, NOW), null) -}) - -test('the task median reads the store stamps only', () => { - const tasks = { - a: { status: 'accepted', createdAt: NOW - 10 * HOUR, completedAt: NOW - 8 * HOUR }, - b: { status: 'accepted', createdAt: NOW - 6 * HOUR, completedAt: NOW - 4 * HOUR }, - c: { status: 'accepted', createdAt: NOW - 6 * HOUR }, - d: { status: 'open', createdAt: NOW - 6 * HOUR }, - } - const out = summarizeMetrics({ now: NOW, timeZone: 'UTC', source: { ok: true, pullRequests: [], issues: [] }, tasks }) - assert.equal(out.metrics.taskDoneMedianMs, 2 * HOUR) -}) - -// #1849: a request body's `timeZone` reaches `Intl` unvalidated, so one unknown name used to throw -// out of the whole board read instead of costing the seven numbers. The zone is resolved once, and -// an unknown name answers the named `UTC` — on every path that answers, and in the cache key too. -const BAD_ZONE = 'Not/AZone' - -test('#1849: an unknown zone answers the named UTC fallback on every path', async () => { - const source = { ok: true, pullRequests: [pr('2026-10-30T10:00:00Z', null)], issues: [] } - const direct = summarizeMetrics({ now: NOW, timeZone: BAD_ZONE, source }) - assert.equal(direct.timeZone, 'UTC') - assert.equal(direct.metrics.prsOpenedToday, 1) - assert.equal(summarizeMetrics({ now: NOW, timeZone: BAD_ZONE }).timeZone, 'UTC') - assert.equal(summarizeMetrics({ now: NOW, timeZone: BAD_ZONE, source: { ok: false, reason: 'x' } }).timeZone, 'UTC') - - const noCredentials = await readMetrics({ repo: 'o/r', token: '', timeZone: BAD_ZONE, now: NOW }) - assert.equal(noCredentials.timeZone, 'UTC') - assert.equal(noCredentials.ok, false) - - const noFetch = await readMetrics({ repo: 'o/r', token: 'tok', timeZone: BAD_ZONE, now: NOW }) - assert.equal(noFetch.timeZone, 'UTC') - - const readFailure = await readMetrics({ - repo: 'o/r', token: 'tok', timeZone: BAD_ZONE, now: NOW, - fetchImpl: async () => ({ ok: false, status: 500, json: async () => ({}) }), - }) - assert.equal(readFailure.timeZone, 'UTC') - assert.equal(readFailure.ok, false) - - // The cache key carries the resolved zone: a bad name shares the UTC entry instead of making a - // second one, and the hit still answers `UTC`. - const cacheStore = new Map() - const fetchImpl = async () => ({ ok: true, json: async () => [] }) - const miss = await readMetrics({ repo: 'o/r', token: 'tok', timeZone: 'UTC', now: NOW, fetchImpl, cacheStore }) - assert.equal(miss.cache, 'miss') - const hit = await readMetrics({ repo: 'o/r', token: 'tok', timeZone: BAD_ZONE, now: NOW, fetchImpl, cacheStore }) - assert.equal(hit.cache, 'hit') - assert.equal(hit.timeZone, 'UTC') - assert.equal(cacheStore.size, 1) - - const success = await readMetrics({ repo: 'o/r', token: 'tok', timeZone: BAD_ZONE, now: NOW, fetchImpl, cacheStore: new Map() }) - assert.equal(success.timeZone, 'UTC') - assert.equal(success.ok, true) -}) - -test('#1849: a valid zone keeps the day boundary and the answer it had', () => { - const source = { - ok: true, - pullRequests: [pr('2026-10-30T10:00:00Z', '2026-10-30T11:00:00Z')], - issues: [issue('2026-10-30T09:00:00Z', null)], - } - const shanghai = summarizeMetrics({ now: NOW, timeZone: 'Asia/Shanghai', source }) - assert.equal(shanghai.timeZone, 'Asia/Shanghai') - assert.equal(dayKey(NOW, 'Asia/Shanghai'), '2026-10-30') - assert.deepEqual(shanghai.metrics, { - prsOpenedToday: 1, prsMergedToday: 1, issuesOpenedToday: 1, issuesClosedToday: 0, - prMergeMedianMs: HOUR, issueCloseMedianMs: null, taskDoneMedianMs: null, - }) - // A zone `Intl` accepts but the host would never guess stays as sent, and UTC still reads one day. - assert.equal(summarizeMetrics({ now: NOW, timeZone: 'UTC', source }).metrics.prsOpenedToday, 1) -}) - -// #1884: a card finishes by `transition(task, 'accepted')` (core/src/host/tasks.js:269-279), which -// writes `status`, `updatedAt` and one `actors[]` row — never `completedAt`. So the duration is -// `createdAt` → the last terminal `actors[].at` (9h here), not a field the store never writes. -const finished = (id, createdAt, acceptedAt) => ({ - id, - title: id, - status: 'accepted', - createdAt, - updatedAt: acceptedAt, - actors: [ - { status: 'open', at: createdAt }, - { status: 'claimed', at: createdAt + HOUR }, - { status: 'accepted', at: acceptedAt }, - ], -}) - -test('a card that finished the way the host finishes cards is a done duration', () => { - const tasks = [ - finished('t-9h', NOW - 10 * HOUR, NOW - 1 * HOUR), - { id: 't-cancelled', status: 'cancelled', createdAt: NOW - 10 * HOUR, updatedAt: NOW - 2 * HOUR, actors: [{ status: 'cancelled', at: NOW - 2 * HOUR }] }, - ] - const out = summarizeMetrics({ now: NOW, timeZone: 'UTC', source: { ok: true, pullRequests: [], issues: [] }, tasks }) - assert.equal(out.metrics.taskDoneMedianMs, 9 * HOUR) - assert.deepEqual(Object.keys(out.metrics).sort(), [...METRIC_KEYS].sort()) -}) - -// A board whose only finished cards were abandoned was never completed on: `—`, never `0`. This one -// passes on main today only because no card is counted at all, so it is the non-regression half of -// the pair — it starts guarding the `accepted`-only rule the moment the first test goes green. -test('only cancelled and failed cards is null, never zero', () => { - const tasks = [ - { id: 't-cancelled', status: 'cancelled', createdAt: NOW - 10 * HOUR, updatedAt: NOW - 2 * HOUR, actors: [{ status: 'cancelled', at: NOW - 2 * HOUR }] }, - { id: 't-failed', status: 'failed', createdAt: NOW - 8 * HOUR, updatedAt: NOW - 3 * HOUR, actors: [{ status: 'failed', at: NOW - 3 * HOUR }] }, - ] - const out = summarizeMetrics({ now: NOW, timeZone: 'UTC', source: { ok: true, pullRequests: [], issues: [] }, tasks }) - assert.equal(out.metrics.taskDoneMedianMs, null) -}) - -// #2650: seven days is longer than one page. The first page is full and carries the `Link` header; -// the rows that decide the counts and the medians sit on page two, where a one-page read never looks. -const pageRows = (count, make) => Array.from({ length: count }, (_, index) => make(index)) -const pageLink = (pathname, page) => new Map([['link', `; rel="next"`]]) - -test('#2650: the metric lists are read page by page, so a deciding row on page two counts', async () => { - const pageOnePulls = pageRows(100, index => ({ number: index + 1, created_at: '2026-10-29T11:00:00Z', updated_at: '2026-10-30T11:59:00Z', merged_at: null })) - const pageTwoPulls = pageRows(5, index => ({ number: 101 + index, created_at: '2026-10-28T06:00:00Z', updated_at: '2026-10-30T06:00:00Z', merged_at: '2026-10-30T06:00:00Z' })) - const pageOneIssues = pageRows(100, index => ({ number: index + 1, created_at: '2026-10-29T11:00:00Z', updated_at: '2026-10-30T11:59:00Z', closed_at: null })) - const pageTwoIssues = [{ number: 101, created_at: '2026-10-29T06:00:00Z', updated_at: '2026-10-30T10:00:00Z', closed_at: '2026-10-30T10:00:00Z' }] - const seen = [] - const fetchImpl = async (url) => { - const at = new URL(url) - seen.push(at) - const page = Number(at.searchParams.get('page') ?? '1') - const pulls = at.pathname.endsWith('/pulls') - const rows = pulls - ? (page === 1 ? pageOnePulls : page === 2 ? pageTwoPulls : []) - : (page === 1 ? pageOneIssues : page === 2 ? pageTwoIssues : []) - return { ok: true, headers: page === 1 ? pageLink(at.pathname, 2) : new Map(), json: async () => rows } - } - const out = await readMetrics({ repo: 'o/r', token: 'tok', now: NOW, timeZone: 'UTC', fetchImpl, cacheStore: new Map() }) - assert.equal(out.ok, true) - assert.equal(seen.length, 4, 'both lists were followed to their second page') - assert.equal(seen.filter(at => at.pathname.endsWith('/pulls')).every(at => at.searchParams.has('since') === false), true, - '`/pulls` does not accept `since`, so the request no longer pretends to filter by it') - assert.equal(out.metrics.prsMergedToday, 5) - assert.equal(out.metrics.prMergeMedianMs, 48 * HOUR) - assert.equal(out.metrics.issuesClosedToday, 1) - assert.equal(out.metrics.issueCloseMedianMs, 28 * HOUR) - assert.equal(out.metrics.prsOpenedToday, 0) - assert.equal(out.truncated, undefined) -}) - -test('#2650: a `next` that never ends stops at the page cap and answers truncated', async () => { - let calls = 0 - const fetchImpl = async () => { - calls += 1 - return { ok: true, headers: pageLink('/next', calls + 1), json: async () => [pr('2026-10-30T06:00:00Z', null)] } - } - const out = await readMetrics({ repo: 'o/r', token: 'tok', now: NOW, timeZone: 'UTC', fetchImpl, cacheStore: new Map() }) - assert.equal(out.ok, true) - assert.equal(out.truncated, true, 'the answer does not claim the whole window was read') - assert.equal(calls, 60, 'thirty pages for each of the two lists') - assert.equal(out.metrics.prsOpenedToday, 30, 'the rows read before the cap are kept') -}) - -test('#2650: a failed page is a failed read, never a silent partial list', async () => { - const store = new Map() - let calls = 0 - const fetchImpl = async () => { - calls += 1 - if (calls <= 2) return { ok: true, headers: pageLink('/next', 2), json: async () => [] } - return { ok: false, status: 502, json: async () => ({}) } - } - const out = await readMetrics({ repo: 'o/r', token: 'tok', now: NOW, timeZone: 'UTC', fetchImpl, cacheStore: store }) - assert.equal(out.ok, false) - assert.match(out.reason, /^read-failed: .*answered 502$/) - assert.equal(out.metrics.prsOpenedToday, null) - assert.equal(store.size, 0, 'a failed page is not cached as a complete window') -}) diff --git a/patches/ds-bot/files/test/project-role-authz.test.js b/patches/ds-bot/files/test/project-role-authz.test.js index 7919236e0..eb2943c28 100644 --- a/patches/ds-bot/files/test/project-role-authz.test.js +++ b/patches/ds-bot/files/test/project-role-authz.test.js @@ -11,6 +11,7 @@ import { test } from 'node:test' import { createRuntime, provideFlyBot } from '../index.js' import { install as installAuthz } from '../src/host/authz.js' import { fakeContext } from './fake-ctx.js' +import { installProjectDomain } from './project-domain.js' const seed = () => ({ version: 2, @@ -59,6 +60,7 @@ async function start() { sessions: { flush: async () => {} }, }) const rt = createRuntime(ctx, { home }) + installProjectDomain(rt) provideFlyBot(ctx, rt.flybot) await rt.load() // One member has signed in, so the gate is enforced: the actor comes from the session cookie only. diff --git a/patches/ds-bot/files/test/project-roles.test.js b/patches/ds-bot/files/test/project-roles.test.js deleted file mode 100644 index 44eb2b1bf..000000000 --- a/patches/ds-bot/files/test/project-roles.test.js +++ /dev/null @@ -1,71 +0,0 @@ -// #1828: a Project's roles. The record stores only maintainers and members; the Owner is derived -// from `ownerUserIds`, so a Project written before this slice is owner-only without a migration. -import test from 'node:test' -import assert from 'node:assert/strict' -import { changeRole, roleChoices, roleOf, rolesOf } from '../src/host/project-board.js' - -const project = (extra = {}) => ({ id: 'project-1', name: 'P', ownerUserIds: ['owner'], ...extra }) - -test('a Project written before roles derives to owner-only (#1828)', () => { - const old = project() - assert.deepEqual(rolesOf(old), { owner: ['owner'], maintainers: [], members: [] }) - assert.equal(roleOf(old, 'owner'), 'owner') - assert.equal(roleOf(old, 'someone'), null) - assert.deepEqual(roleChoices(old, 'owner'), []) -}) - -test('a member cannot promote themselves and the roles stay put (#1828)', () => { - const p = project({ roles: { maintainers: [], members: ['m'] } }) - const before = rolesOf(p) - assert.deepEqual(changeRole(p, { actorId: 'm', targetId: 'm', role: 'maintainer' }), { ok: false, reason: 'cannot-self-promote' }) - assert.deepEqual(changeRole(p, { actorId: 'm', targetId: 'm', role: 'owner' }), { ok: false, reason: 'owner-not-assignable' }) - assert.deepEqual(rolesOf(p), before) - assert.deepEqual(roleChoices(p, 'm'), []) -}) - -test('a member has no switchable role and cannot promote themselves (#1828)', () => { - const p = project({ roles: { maintainers: [], members: ['b'] } }) - assert.deepEqual(roleChoices(p, 'b'), []) - assert.deepEqual(changeRole(p, { actorId: 'b', targetId: 'b', role: 'maintainer' }), { ok: false, reason: 'cannot-self-promote' }) - assert.deepEqual(changeRole(p, { actorId: 'b', targetId: 'c', role: 'member' }), { ok: false, reason: 'not-owner' }) - assert.deepEqual(rolesOf(p), { owner: ['owner'], maintainers: [], members: ['b'] }) -}) - -test('an owner demotes another member to Member (#1828)', () => { - const p = project({ roles: { maintainers: ['m'], members: ['n'] } }) - const done = changeRole(p, { actorId: 'owner', targetId: 'm', role: 'member' }) - assert.deepEqual(done, { ok: true, roles: { maintainers: [], members: ['n', 'm'] } }) - assert.deepEqual(roleChoices(p, 'owner', 'm'), ['member'], 'the current role is not offered') - assert.deepEqual(roleChoices(p, 'owner', 'n'), ['maintainer']) -}) - -test('the last owner cannot be demoted, by themselves or by anyone else (#1828)', () => { - const p = project() - assert.deepEqual(changeRole(p, { actorId: 'owner', targetId: 'owner', role: 'member' }), { ok: false, reason: 'last-owner' }) - assert.deepEqual(changeRole(p, { actorId: 'owner', targetId: 'owner', role: 'owner' }), { ok: false, reason: 'owner-not-assignable' }) - assert.deepEqual(rolesOf(p), { owner: ['owner'], maintainers: [], members: [] }) -}) - -// #1838: `PROJECT_ROLE_RANKS` is a plain object, so a name inherited from Object.prototype used to -// read as a defined rank, skip every refusal, and land on the demote path. A role name is the rank -// table's own key or it is not a role. -test('#1838: role "toString" is refused and a Maintainer keeps that rank', () => { - const p = project({ roles: { maintainers: ['m'], members: ['n'] } }) - const before = rolesOf(p) - assert.deepEqual(changeRole(p, { actorId: 'owner', targetId: 'm', role: 'toString' }), { ok: false, reason: 'unknown-role' }) - assert.deepEqual(rolesOf(p), before) -}) - -test('#1838: role "__proto__" is refused and a Maintainer keeps that rank', () => { - const p = project({ roles: { maintainers: ['m'], members: ['n'] } }) - const before = rolesOf(p) - assert.deepEqual(changeRole(p, { actorId: 'owner', targetId: 'm', role: '__proto__' }), { ok: false, reason: 'unknown-role' }) - assert.deepEqual(rolesOf(p), before) -}) - -test('#1838: role "constructor" is refused and a Maintainer keeps that rank', () => { - const p = project({ roles: { maintainers: ['m'], members: ['n'] } }) - const before = rolesOf(p) - assert.deepEqual(changeRole(p, { actorId: 'owner', targetId: 'm', role: 'constructor' }), { ok: false, reason: 'unknown-role' }) - assert.deepEqual(rolesOf(p), before) -}) diff --git a/patches/ds-bot/files/test/projects.test.js b/patches/ds-bot/files/test/projects.test.js index b26bcaa9a..cc05d0468 100644 --- a/patches/ds-bot/files/test/projects.test.js +++ b/patches/ds-bot/files/test/projects.test.js @@ -9,6 +9,7 @@ import { createHash } from 'node:crypto' import { test } from 'node:test' import { createRuntime, provideFlyBot } from '../index.js' import { fakeContext } from './fake-ctx.js' +import { installProjectDomain } from './project-domain.js' const hashToken = token => createHash('sha256').update(token).digest('hex') @@ -62,6 +63,7 @@ async function start(home = mkdtempSync(join(tmpdir(), 'ds-bot-projects-')), { s // `createRuntime` + `provideFlyBot` is exactly what `apply` does, with the runtime kept so a // test can remove one seam and prove the host still answers (#1859). const rt = createRuntime(ctx, { home }) + installProjectDomain(rt) provideFlyBot(ctx, rt.flybot) const call = async (endpoint, payload, token = 'tok-a') => (await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', diff --git a/patches/ds-bot/files/test/readonly-window-reads.test.js b/patches/ds-bot/files/test/readonly-window-reads.test.js index bbd891dcc..7f8cfc422 100644 --- a/patches/ds-bot/files/test/readonly-window-reads.test.js +++ b/patches/ds-bot/files/test/readonly-window-reads.test.js @@ -17,6 +17,7 @@ import { join } from 'node:path' import { test } from 'node:test' import { createRuntime, provideFlyBot } from '../index.js' import { fakeContext } from './fake-ctx.js' +import { installProjectDomain } from './project-domain.js' // The Project's own working folder is where its `decisions/*.md` rows come from; a read-only window // has to be able to read the folder rows and the file the board listed, so the seed names one. @@ -170,6 +171,7 @@ async function start() { sessions: { flush: async () => {} }, }) const rt = createRuntime(ctx, { home }) + installProjectDomain(rt) provideFlyBot(ctx, rt.flybot) await rt.load() const call = async (endpoint, payload = {}) => (await route.fetch(new Request('http://localhost/api/bot', { diff --git a/patches/ds-bot/files/test/run-bot-tools.test.js b/patches/ds-bot/files/test/run-bot-tools.test.js index 9c1d8a238..f5d0c2f31 100644 --- a/patches/ds-bot/files/test/run-bot-tools.test.js +++ b/patches/ds-bot/files/test/run-bot-tools.test.js @@ -5,6 +5,7 @@ import { join } from 'node:path' import { test } from 'node:test' import { setTimeout as delay } from 'node:timers/promises' import { apply } from '../index.js' +import { apply as applyProject } from '../../plugins/project/index.js' import { TEAM_TOOLS } from '../src/host/constants.js' // A run Bot (ephemeral) is not a team member (#1445). Its node's `tools` decides connectors and // the shell only, so the host refuses every tool whose effect reaches past its own Session — in the @@ -55,6 +56,9 @@ async function start(config = {}) { sessions: { flush: async () => {} }, }) apply(ctx, { home, ...config }) + // The Project domain is a peer plugin (#2684): a profile mounts it next to the core, and the + // team tools below include the two it registers. + applyProject({ flybot: ctx.flybot }) // A fresh Session per run Bot, the way workflows.js gets one. let sessions = 0 ctx.sessionController.create = async () => ({ sessionId: `run${++sessions}` }) diff --git a/patches/ds-bot/files/test/task-contract.test.js b/patches/ds-bot/files/test/task-contract.test.js index 366278335..d72a7dfb4 100644 --- a/patches/ds-bot/files/test/task-contract.test.js +++ b/patches/ds-bot/files/test/task-contract.test.js @@ -9,9 +9,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' import { createRuntime } from '../index.js' -import { install as installProjectBoard } from '../src/host/project-board.js' +import { install as installProjectBoard } from '../../plugins/project/src/project-board.js' import { formatRoomBoard } from '../src/host/room-context.js' import { fakeContext } from './fake-ctx.js' +import { installProjectDomain } from './project-domain.js' const team = () => ({ version: 2, @@ -66,6 +67,7 @@ async function boot(home) { sessions: { flush: async () => {} }, }) const rt = createRuntime(ctx, { home }) + installProjectDomain(rt) await rt.load() const track = (sessionId, args) => ctx.registeredTools.get('track_tasks').execute(args ?? {}, { agent: { id: sessionId } }) const idOf = (body, title) => { diff --git a/patches/ds-bot/files/types/flybot.d.ts b/patches/ds-bot/files/types/flybot.d.ts index 6c8bdda12..bfea43ee4 100644 --- a/patches/ds-bot/files/types/flybot.d.ts +++ b/patches/ds-bot/files/types/flybot.d.ts @@ -380,6 +380,12 @@ export interface ModelAsk { } export type ModelAnswer = { ok: true; text: string } | { ok: false; code: string } +/** What `flybot.projectHost()` answers: the core runtime and the shared pure helpers (#2684). */ +export interface ProjectHost { + runtime: Record + helpers: Record +} + export interface FlyBot { version: 1 /** Plugin this handle is scoped to; 'shared' for the unscoped service. */ @@ -390,6 +396,14 @@ export interface FlyBot { ready(): Promise /** True while another dsh holds the team: no polling, no writes. */ readOnly(): boolean + /** + * The Project domain host seam (#2684), for `plugins/project` only: the runtime the domain + * installs its seams on (`rt.projectBoard`, `rt.projectChief`, `rt.projectTeam`, + * `rt.projectAssistant`) and the pure helpers the two packages share. `undefined` on a host + * without it. Not a stable surface for third-party plugins, and absent is not an error: the + * core's Project endpoints keep their fallbacks. + */ + projectHost?(): ProjectHost /** * A Bot's own memory, for a plugin the host allows (#450 item 1). Off unless the host names the * plugin in config.flybot.memoryPlugins: `read` returns `[]` otherwise, and `append` resolves diff --git a/plugins/README.md b/plugins/README.md index f74b11650..171a64ca5 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -1,8 +1,8 @@ # FlyBot plugins Peer plugin packages at the repository root: `social` (`@flybot/social`), `tree` -(`@flybot/tree`), `workflow` (`@flybot/workflow`) and `timeline` (`@flybot/timeline`). They are not part of `core/` and are not carried by any -`patches/ds-bot/` patch. +(`@flybot/tree`), `workflow` (`@flybot/workflow`), `timeline` (`@flybot/timeline`) and `project` +(`@flybot/project`). They are not part of `core/` and are not carried by any `patches/ds-bot/` patch. ## Install doctor @@ -14,7 +14,7 @@ npm run install:doctor ``` It checks the Node floor and the packages in the repository -(`core`, `plugins/social`, `plugins/tree`, `plugins/workflow`, `plugins/timeline`) and prints the `dsh plugin add` -commands for the clean-profile install: `path:/core`, `path:/plugins/social`, `path:/plugins/tree` and `path:/plugins/workflow`. +(`core`, `plugins/social`, `plugins/tree`, `plugins/workflow`, `plugins/timeline`, `plugins/project`) and prints the `dsh plugin add` +commands for the clean-profile install: `path:/core`, `path:/plugins/social`, `path:/plugins/tree`, `path:/plugins/workflow` and `path:/plugins/project`. `@flybot/timeline` is opt-in (off by default), so its command is not in that list; see the README. It reads the filesystem only: no network, no tokens, no `~/.dsh`. diff --git a/plugins/project/README.md b/plugins/project/README.md new file mode 100644 index 000000000..521d2eceb --- /dev/null +++ b/plugins/project/README.md @@ -0,0 +1,99 @@ +# @flybot/project + +The Project domain of FlyBot as a peer plugin (built on ds-bot / DeepSeek Harness). + +## English + +### What it does +- **The Project domain, out of the core (#2684).** Project State, the Project board, the Project's + own Chief, the team dossier, the Project metrics and the Project Assistant used to be + `core/src/host/project-*.js`. They are this package now: `apply()` installs them on the core + runtime through `ctx.flybot.projectHost()`, so `rt.projectBoard`, `rt.projectChief`, + `rt.projectTeam` and `rt.projectAssistant` are the same seams the core's store, meetings and + channel already call. +- **Project State (`project_state`).** The Project State files of the Session's working folder — + `PROJECT.md`, `ROADMAP.md`, `docs/governance.md`, `decisions/*.md`, `LESSONS.md` — listed with + byte size, a sha256 content hash and last-modified time, so a caller can compare the hash with + the copy it already has; or one file handed over whole, bounded at 200,000 bytes. Answers + `absent`, `too large`, `not a repository` or `unreadable: ()` with the reason + instead of an empty document. +- **The board.** One read model over the core's task records, the `decisions/*.md` files, the + decided meetings and the workflow run refs: rows, the role queues and leases, the blocked cards, + the decision cards and the Project roles (`owner` / `maintainer` / `member`, with the one policy + that keeps a Project's Owner). Read-only by construction — it writes nothing and starts nothing. +- **The Project's own Chief.** Every Project gets one hidden Bot, bound to that Project, with + instructions naming that one Project and forbidding an answer for any other; it is retired with + the Project. +- **The team dossier.** `rt.projectTeam(projectId)` answers the Project's Chief, its assistant and + its role pool with persona, the head and topic names of each Bot's own memory, the skills it + carries and only this Project's rooms. +- **Metrics.** The board's seven numbers read from GitHub REST and the local task store with a + five-minute cache; a source that cannot be read is `unavailable` and every value is `null`, + never `0`. +- **The Project Assistant (`project_digest`).** One hidden assistant per Project with exactly one + 09:00 Asia/Shanghai digest task on its own Session (a DSH schedule, never a cron in the plugin), + memory private to (Project, owner), and a digest built only from board rows and Project State: + what merged, what closed, what is in progress and on whom, the next slice — one line on a day + with no change. + +### The owner switch +Settings → Plugins → **Project**, stored as `flybot.plugins.project.enabled` in `state.json`. Off +means the plugin registers no tool, no seam and no background work at the next start; the core then +keeps its documented fallbacks — a Project is created without a Chief or an assistant, and the +board and the dossier answer empty. Without this plugin installed, the same fallbacks apply. + +### Config +```yaml +- id: project + name: "@flybot/project" + # no config keys in this slice: the domain reads the Project record, the board and the + # Session, and takes the metrics' credentials from the install's own connector +``` + +### Install +Installed next to the core in the same DSH profile (a default FlyBot install includes it): + +```sh +npx -y @deepseek-ai/dsh@0.2.1-rc.2 plugin --profile web add "github:ThinkFlowLab/FlyBot#main&path:/plugins/project" +``` + +### Tests +```sh +cd plugins/project +npm test # needs a FlyBot core checkout at ../../core (or FLYBOT_CORE) and DSH_HARNESS +``` + +The suite is the domain's own tests, moved here from `core/test/project-*.test.js` when the code +moved, plus `test/host.test.js` for the plugin boundary itself. The core's suite keeps the +integration cases that read a Project through the store, the channel and the client. + +## 中文 + +### 做什么 +- **Project 域整体搬出 core(#2684)**:Project State、项目看板、项目自己的 Chief、团队档案、 + 项目指标和项目助理原本是 `core/src/host/project-*.js`,现在是这个包。`apply()` 通过 + `ctx.flybot.projectHost()` 把它们装回 core 运行时,所以 `rt.projectBoard`、`rt.projectChief`、 + `rt.projectTeam`、`rt.projectAssistant` 仍是 core 的 store、meetings 与 channel 调用的那几个接缝。 +- **Project State(`project_state`)**:列出会话工作目录里的 Project State 文件(`PROJECT.md`、 + `ROADMAP.md`、`docs/governance.md`、`decisions/*.md`、`LESSONS.md`)的字节数、sha256 与修改时间, + 便于核对手里那份是否过期;也可整份读出一个文件(上限 200,000 字节)。读不到时回答原因 + (`absent` / `too large` / `not a repository` / `unreadable: ()`),不拿空文档冒充。 +- **看板**:任务记录、决策文件、已决定的会议与 workflow 运行引用合成一份只读视图:卡片、角色队列与租约、 + 被阻塞的卡片、决策卡与项目角色(owner / maintainer / member)。只读,不写、不启动任何东西。 +- **项目 Chief**:每个项目一个隐藏 Bot,绑定该项目的 `projectId`,指令里只认这一个项目,随项目一起退休。 +- **团队档案**:`rt.projectTeam(projectId)` 按看板顺序回答 Chief、助理与角色池,含人设、各自记忆的头部与 + 主题名、技能和只属于这个项目的房间。 +- **指标**:看板的七个数,五分钟缓存;读不到就 `unavailable`、值为 `null`,绝不用 0 冒充「今天没有」。 +- **项目助理(`project_digest`)**:每个项目一个隐藏助理,它自己的会话上有且只有一个 09:00(Asia/Shanghai) + 摘要任务(走 DSH 的定时,不是插件里的 cron);记忆按(项目, 所有者)隔离,摘要只用看板行和 Project State。 + +### 开关 +Settings → Plugins → **Project**(`state.json` 的 `flybot.plugins.project.enabled`)。关掉后下次启动 +不再注册任何工具、接缝或后台动作,core 保留既有兜底:项目照常创建,只是没有 Chief 和助理,看板和档案返回空。 +未安装本插件时同样是这套兜底。 + +### 测试 +```sh +cd plugins/project +npm test +``` diff --git a/plugins/project/client.js b/plugins/project/client.js new file mode 100644 index 000000000..daed96ccf --- /dev/null +++ b/plugins/project/client.js @@ -0,0 +1,14 @@ +// Browser half of @flybot/project, in the dsh client loader format ds-bot uses +// (window.__ModuleLoader__.load, CJS factory, `require` resolves shell-provided react). +// +// Empty on purpose in this slice (#2684): the Project pages the core draws — the board overlay, the +// Project row in the header, the projects nav, the settings panels — are still `core/src/client/ +// project-board.js` and its callers, because they read the core's own client context (the roster +// feed, the overlay stack, the api client) and are bundled by `core/scripts/build-client.mjs`. +// Moving them means turning those surfaces into flybot slots first; until that lands, the module +// exists so the package shape matches every other @flybot plugin and a profile that mounts it has +// a client entry to load. `src/client/` arrives with that follow-up. +window.__ModuleLoader__.load({ + id: '@flybot/project', + factory: () => ({}), +}) diff --git a/plugins/project/cordis.patch.yml b/plugins/project/cordis.patch.yml new file mode 100644 index 000000000..b242c5f9e --- /dev/null +++ b/plugins/project/cordis.patch.yml @@ -0,0 +1,4 @@ +# Mounts flybot-project (needs ds-bot, which provides the flybot service and the Project host seam). +- insert: + - id: flybot-project + name: '@flybot/project' diff --git a/plugins/project/index.js b/plugins/project/index.js new file mode 100644 index 000000000..336dc8fd7 --- /dev/null +++ b/plugins/project/index.js @@ -0,0 +1,36 @@ +// @flybot/project host plugin (dsh / cordis plugin shape, same as ds-bot: name + inject + apply). +// The Project domain — Project State, the board, the Project's own Chief, its team dossier, its +// metrics and its assistant — lives in src/, moved out of the core's `src/host/project-*.js` +// (#2684). The core owns the runtime and the few pure helpers the two packages share; this +// plugin asks for them through `flybot.projectHost()` and installs the same seams on that +// runtime the core used to install itself, so `rt.projectBoard`, `rt.projectChief`, +// `rt.projectTeam`, `rt.projectAssistant` and the `project_state` / `project_digest` tools are +// exactly what they were. +// +// Owner switch (#2564): off means this process registers none of the domain's tools, endpoints, +// prompt text or background work. Read at start, so it applies at the next launch. +import { bindHost } from './src/host.js' +import { installProjectDomain } from './src/install.js' + +export const name = 'flybot-project' +export const inject = ['flybot'] + +export function apply(ctx) { + if (ctx.flybot?.pluginEnabled?.('project') === false) return { project: undefined, dispose() {} } + const seam = ctx.flybot?.projectHost?.() + // A core older than this plugin has no Project host seam. Nothing is registered and the core's + // store keeps its own fallback (a Project is created without a Chief or an assistant), which is + // the behavior that core has when its domain modules are not installed at all. + if (seam === undefined || seam.runtime === undefined) { + ctx.flybot?.warn?.('flybot-project: this core has no Project host seam, so none of the Project domain is registered (#2684)') + return { project: undefined, dispose() {} } + } + bindHost(seam.helpers) + const project = installProjectDomain(seam.runtime) + // Nothing here polls or holds a timer: every answer is built when it is asked for, and the one + // background beat (the 09:00 digest) is a DSH schedule on the assistant's own Session. + const dispose = () => {} + if (typeof ctx.effect === 'function') ctx.effect(() => dispose, 'flybot-project') + else ctx.on?.('dispose', dispose) + return { project, dispose } +} diff --git a/plugins/project/package.json b/plugins/project/package.json new file mode 100644 index 000000000..cfa4074da --- /dev/null +++ b/plugins/project/package.json @@ -0,0 +1,41 @@ +{ + "name": "@flybot/project", + "version": "0.1.0", + "description": "FlyBot project: the Project domain (Project State, the board, the Project Chief, the team dossier, metrics and the assistant) as a peer plugin on ds-bot's flybot plugin API v1 (ctx.flybot)", + "keywords": [ + "dsh-plugin", + "deepseek-harness", + "dsh", + "ds-bot", + "flybot" + ], + "license": "Apache-2.0", + "type": "module", + "main": "index.js", + "exports": { + ".": "./index.js", + "./client": "./client.js", + "./package.json": "./package.json" + }, + "files": [ + "index.js", + "client.js", + "src/", + "README.md", + "cordis.patch.yml" + ], + "scripts": { + "test": "node --import ./test/resolve.mjs --test \"test/*.test.js\"" + }, + "peerDependencies": { + "ds-bot": ">=0.1.5-flybot.1" + }, + "dsh": { + "client": { + "platform": "web" + }, + "bundle": { + "patch": "./cordis.patch.yml" + } + } +} diff --git a/plugins/project/src/host.js b/plugins/project/src/host.js new file mode 100644 index 000000000..44b066a3d --- /dev/null +++ b/plugins/project/src/host.js @@ -0,0 +1,60 @@ +// The helpers this package takes from its host (#2684). The Project plugin ships as its own +// package, so its code cannot import `core/src/*`; the core hands the few pure helpers the two +// packages share over through `flybot.projectHost()` in the plugin's `apply()`. +// +// Every function here reads the binding at call time, so a module-scope arrow that closes over +// `cut` (the board's `oneLine`, the dossier's `capped`) keeps working after the bind. A package +// used without `apply()` — a unit test of a pure function — binds the core's own implementations +// in `test/host.js`, which is also the check that the seam carries the functions this package +// thinks it does. +let bound = null + +// The values below are only placeholders until the host binds: they are the numbers and labels +// the core owns (`core/src/host/constants.js`), and `test/host.test.js` holds the two together. +export let MAX_ENTRY = 600 +export let ASSISTANT_ROLE = 'Project Assistant' +export let output = { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] } + +export function bindHost(helpers) { + if (helpers === null || typeof helpers !== 'object') throw new Error('@flybot/project: the host seam must hand over an object of helpers') + for (const name of REQUIRED) { + if (helpers[name] === undefined) throw new Error(`@flybot/project: the host seam is missing "${name}"`) + } + bound = helpers + MAX_ENTRY = helpers.MAX_ENTRY + ASSISTANT_ROLE = helpers.ASSISTANT_ROLE + output = helpers.output + return bound +} + +export function boundHost() { + return bound +} + +// The helpers without which the domain cannot answer: a host that binds a partial bag fails at +// `apply()` with the name, not at the first board read with a TypeError. +export const REQUIRED = Object.freeze([ + 'cut', 'codePoints', 'dirKind', 'defineTool', 'output', 'columnOf', 'priorityOf', 'advanceRefusal', + 'workDirFor', 'dayKey', 'MAX_ENTRY', 'ASSISTANT_ROLE', + 'inQuietHours', 'clockIn', 'quietHoursOf', 'readQuietHours', +]) + +const need = (name) => { + const value = bound?.[name] + if (typeof value !== 'function') throw new Error(`@flybot/project: the host helper "${name}" is not bound; the plugin's apply() binds it`) + return value +} + +export const cut = (text, max) => need('cut')(text, max) +export const codePoints = text => need('codePoints')(text) +export const dirKind = path => need('dirKind')(path) +export const defineTool = def => need('defineTool')(def) +export const columnOf = task => need('columnOf')(task) +export const priorityOf = task => need('priorityOf')(task) +export const advanceRefusal = meeting => need('advanceRefusal')(meeting) +export const workDirFor = (runtime, exec) => need('workDirFor')(runtime, exec) +export const dayKey = (ms, timeZone) => need('dayKey')(ms, timeZone) +export const inQuietHours = (...args) => need('inQuietHours')(...args) +export const clockIn = (...args) => need('clockIn')(...args) +export const quietHoursOf = (...args) => need('quietHoursOf')(...args) +export const readQuietHours = (...args) => need('readQuietHours')(...args) diff --git a/plugins/project/src/install.js b/plugins/project/src/install.js new file mode 100644 index 000000000..0f7e7cbba --- /dev/null +++ b/plugins/project/src/install.js @@ -0,0 +1,33 @@ +// The Project domain on the host runtime (#2684). The core used to install these six modules +// from `core/src/host/project-*.js`; it now hands the plugin its runtime through +// `flybot.projectHost()` and this file puts the same seams on it in the same order, so the core's +// store, meetings and channel call `rt.projectBoard`, `rt.projectChief`, `rt.projectTeam` and +// `rt.projectAssistant` exactly as before. +// +// Installing twice is a no-op: the domain is one set of registrations (two tools, the board +// endpoints' readers, the Project Chief and the assistant), and a second `ctx.tools.register` of +// the same name would fail the whole plugin. The marker lives on the runtime because that is the +// object the seams belong to. +import { install as installState } from './project-state.js' +import { install as installBoard } from './project-board.js' +import { install as installChief } from './project-chief.js' +import { install as installTeam } from './project-team.js' +import { install as installAssistant } from './project-assistant.js' + +export const INSTALLED = 'flybotProjectDomain' + +export function installProjectDomain(rt) { + if (rt === null || typeof rt !== 'object') throw new Error('@flybot/project: installProjectDomain needs the host runtime') + if (rt[INSTALLED] !== undefined) return rt[INSTALLED] + // The order is the core's own `MODULES` order: the state reader is a leaf, the board reads it and + // the metrics, the Chief and the dossier read the board's roles, and the assistant reads the + // board and the state reader. The metrics module has no seam of its own — the board composes it. + installState(rt) + installChief(rt) + installBoard(rt) + installTeam(rt) + installAssistant(rt) + const domain = { chief: rt.projectChief, board: rt.projectBoard, team: rt.projectTeam, assistant: rt.projectAssistant } + Object.defineProperty(rt, INSTALLED, { value: domain, configurable: true }) + return domain +} diff --git a/core/src/host/project-assistant.js b/plugins/project/src/project-assistant.js similarity index 99% rename from core/src/host/project-assistant.js rename to plugins/project/src/project-assistant.js index 557a4377b..628ab1558 100644 --- a/core/src/host/project-assistant.js +++ b/plugins/project/src/project-assistant.js @@ -14,14 +14,9 @@ // The digest is not scheduled from inside the plugin: `project-create` puts exactly one 09:00 task // on the assistant Session through the host's schedule path, and `project-delete` removes it with // the Project. A missed beat is DSH's, and it posts nothing. -import { defineTool } from './tool-schema.js' -import { MAX_ENTRY } from './constants.js' +import { ASSISTANT_ROLE, MAX_ENTRY, clockIn, codePoints, cut, defineTool, inQuietHours, output, quietHoursOf, readQuietHours } from './host.js' import { listProjectState } from './project-state.js' -import { codePoints, cut } from './text.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' export const DIGEST_TIME = '09:00' export const DIGEST_TITLE = '09:00 Project digest' diff --git a/core/src/host/project-board.js b/plugins/project/src/project-board.js similarity index 99% rename from core/src/host/project-board.js rename to plugins/project/src/project-board.js index 1fe297757..94a63893a 100644 --- a/core/src/host/project-board.js +++ b/plugins/project/src/project-board.js @@ -18,10 +18,10 @@ // sections. It writes nothing, starts nothing and is not called unless someone asks for the board. import { open } from 'node:fs/promises' import { join } from 'node:path' -import { columnOf, priorityOf } from '../shared/task-board.js' +import { columnOf, priorityOf } from './host.js' import { DECISIONS_DIR, describeFile, isDecisionPath, listProjectState, readListedFile } from './project-state.js' -import { cut } from './text.js' -import { advanceRefusal } from './meetings.js' +import { cut } from './host.js' +import { advanceRefusal } from './host.js' import { readMetrics } from './project-metrics.js' // #1828: a Project's roles. The record stores only `roles: { maintainers, members }`; the owner diff --git a/core/src/host/project-chief.js b/plugins/project/src/project-chief.js similarity index 100% rename from core/src/host/project-chief.js rename to plugins/project/src/project-chief.js diff --git a/patches/ds-bot/files/src/host/project-metrics.js b/plugins/project/src/project-metrics.js similarity index 94% rename from patches/ds-bot/files/src/host/project-metrics.js rename to plugins/project/src/project-metrics.js index 1d7b1b5e9..973a3cbdf 100644 --- a/patches/ds-bot/files/src/host/project-metrics.js +++ b/plugins/project/src/project-metrics.js @@ -3,7 +3,7 @@ // `unavailable` and every value reads `null`, never `0`: a zero claims "none today" and the board // must not turn "cannot read" into a number. The reads are injected, so a test counts calls without // a network, and the day boundary is the reader's own time zone, not the host's UTC clock. -import { columnOf } from '../shared/task-board.js' +import { columnOf, dayKey } from './host.js' export const METRICS_TTL_MS = 5 * 60 * 1000 export const METRIC_KEYS = [ @@ -51,17 +51,11 @@ export function resolveTimeZone(timeZone = 'UTC') { } } -// The natural day a timestamp falls in, in the caller's time zone. `en-CA` prints ISO order, which -// is what the comparison below needs; an unknown zone throws here, so a caller that takes one from -// outside (the board read) resolves it with `resolveTimeZone` first. -export function dayKey(ms, timeZone = 'UTC') { - if (!Number.isFinite(ms)) return '' - const parts = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }) - return parts.format(new Date(ms)) -} +// The day boundary this module compares in moved to `core/src/host/day-key.js` (#2684): the +// core's turn budget needs the same key, and the plugin takes it back through the host seam. +// Re-exported so a test of the board's numbers reads it from the same module. +export { dayKey } -// A median over the durations of records in the window: empty is `null`, which the client paints -// as `—`. Half a millisecond of rounding is not worth carrying into the view. export function median(values) { const clean = values.filter(value => Number.isFinite(value)).sort((a, b) => a - b) if (clean.length === 0) return null diff --git a/patches/ds-bot/files/src/host/project-state.js b/plugins/project/src/project-state.js similarity index 94% rename from patches/ds-bot/files/src/host/project-state.js rename to plugins/project/src/project-state.js index e3d45d117..22db2a587 100644 --- a/patches/ds-bot/files/src/host/project-state.js +++ b/plugins/project/src/project-state.js @@ -11,9 +11,7 @@ import { createHash } from 'node:crypto' import { lstat, readdir, readFile, stat } from 'node:fs/promises' import { dirname, join } from 'node:path' -import { defineTool } from './tool-schema.js' -import { output } from './tools.js' -import { dirKind } from './real-dir.js' +import { defineTool, dirKind, output, workDirFor } from './host.js' // The files this reader answers for, and nothing else: a named file outside this list is // refused rather than read, so the tool never becomes a way to walk the working folder. @@ -161,16 +159,10 @@ export async function readListedFile(dir, path) { return { ok: true, path, bytes: bytes.length, sha256: sha256(bytes), mtime: new Date(info.mtimeMs).toISOString(), text: bytes.toString('utf8') } } -// The Session's working folder: the cwd its Agent runs in, else the folder the Bot or room -// is set to work in, else the team workspace. Undefined only when none of them is known. -export function workDirFor(rt, exec) { - const cwd = exec?.agent?.session?.header?.cwd - if (typeof cwd === 'string' && cwd !== '') return cwd - const botId = rt.selfOf?.(exec?.agent?.id) ?? exec?.agent?.id - const dir = rt.workDirOf?.({ botId, roomId: exec?.agent?.session?.header?.roomId }) - if (typeof dir === 'string' && dir !== '') return dir - return rt.teamFolder?.() -} +// The Session's working folder moved to `core/src/host/work-dir.js` (#2684): the meetings module +// needs it too, and the plugin takes it back through the host seam. Re-exported so a caller of +// this reader (`project_state`, the board) keeps importing it from the same module. +export { workDirFor } // The answer the tool prints: the rows a caller compares hashes with, and — after them — the names // that are there but could not be read. `none` is the answer for a folder with nothing to read, diff --git a/core/src/host/project-team.js b/plugins/project/src/project-team.js similarity index 99% rename from core/src/host/project-team.js rename to plugins/project/src/project-team.js index c97459d24..cf27470af 100644 --- a/core/src/host/project-team.js +++ b/plugins/project/src/project-team.js @@ -14,7 +14,7 @@ // dossier panel draws, so it carries exactly those keys and nothing that names an owner, a working // folder or a stored secret. import { rolesOf } from './project-board.js' -import { cut } from './text.js' +import { cut } from './host.js' export const DOSSIER_LIMITS = { persona: 4000, memoryHead: 4000, topics: 64, skills: 64, rooms: 32, name: 200, role: 120, model: 200, color: 64 } diff --git a/plugins/project/test/host-bind.js b/plugins/project/test/host-bind.js new file mode 100644 index 000000000..7c0f077a5 --- /dev/null +++ b/plugins/project/test/host-bind.js @@ -0,0 +1,40 @@ +// The host helpers the plugin needs, bound from the core's own implementations (test-only). +// +// At runtime `apply()` binds them from `flybot.projectHost()`; a unit test of a pure function +// (rolesOf, buildDigest, blockedOf) needs them without a whole runtime, and binding the core's +// modules by their repo path is the same set the seam carries. `test/host.test.js` checks that the +// seam's list and this one are the same names, so a helper added to one cannot be missing from the +// other. The path is the repo checkout, the way plugins/tree/test/identity.test.js reads the core's +// identity module. +import { bindHost } from '../src/host.js' +import { ASSISTANT_ROLE, MAX_ENTRY } from '../../../core/src/host/constants.js' +import { dayKey } from '../../../core/src/host/day-key.js' +import { advanceRefusal } from '../../../core/src/host/meetings.js' +import { dirKind } from '../../../core/src/host/real-dir.js' +import { codePoints, cut } from '../../../core/src/host/text.js' +import { defineTool } from '../../../core/src/host/tool-schema.js' +import { output } from '../../../core/src/host/tools.js' +import { workDirFor } from '../../../core/src/host/work-dir.js' +import { clockIn, inQuietHours, quietHoursOf, readQuietHours } from '../../../core/src/shared/quiet-hours.js' +import { columnOf, priorityOf } from '../../../core/src/shared/task-board.js' + +export const HELPERS = { + cut, + codePoints, + dirKind, + defineTool, + output, + columnOf, + priorityOf, + advanceRefusal, + workDirFor, + dayKey, + MAX_ENTRY, + ASSISTANT_ROLE, + inQuietHours, + clockIn, + quietHoursOf, + readQuietHours, +} + +bindHost(HELPERS) diff --git a/plugins/project/test/host.test.js b/plugins/project/test/host.test.js new file mode 100644 index 000000000..8c40aae49 --- /dev/null +++ b/plugins/project/test/host.test.js @@ -0,0 +1,109 @@ +// The plugin's own half (#2684): what `apply()` puts on a core runtime, and what the owner switch +// takes away. The domain's behavior is covered by the tests next to this file, which moved here +// from `core/test/project-*.test.js`; this one pins the plugin boundary itself: +// +// - the two Project tools are registered here and nowhere else, with the same names the core's +// TEAM_TOOLS lists; +// - the four seams the store, meetings and channel already call are installed; +// - `pluginEnabled('project') === false` registers none of it (read from the state file the host +// points at, the way the core reads every switch); +// - the helpers the plugin binds are the names the core's own seam carries. +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createRuntime, provideFlyBot } from 'ds-bot' +import { apply } from '../index.js' +import { bindHost, REQUIRED } from '../src/host.js' +import { HELPER_NAMES } from '../../../core/src/host/project-host.js' +import { fakeContext } from '../../../core/test/fake-ctx.js' + +const TEAM = { + version: 1, mainBotIds: ['a'], + bots: { a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1 } }, + rooms: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0, +} + +function coreContext() { + const ctx = fakeContext() + Object.assign(ctx, { + llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionController: { create: async () => ({ sessionId: 's1' }), resolveAgent: async id => ({ agent: { session: { id }, id, status: 'idle', cancel() {} } }), selectModel: async () => {}, page: async () => ({ records: [], hasMore: false }) }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + return ctx +} + +// `projectEnabled: false` writes the owner switch into the state file the core reads at startup. +function boot({ projectEnabled = true, dir } = {}) { + const home = mkdtempSync(join(tmpdir(), 'flybot-project-plugin-')) + const team = projectEnabled ? TEAM : { ...TEAM, flybot: { plugins: { project: { enabled: false } } } } + writeFileSync(join(home, 'state.json'), JSON.stringify(team)) + const ctx = coreContext() + const rt = createRuntime(ctx, { home }) + provideFlyBot(ctx, rt.flybot) + const result = apply({ flybot: rt.flybot, effect: exec => exec() }) + const stop = () => rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + return { ctx, rt, result, home, stop, dir } +} + +const tools = ctx => [...ctx.registeredTools.keys()].sort() +// The core's own modules register their tools on the same runtime; this test is about the two the +// Project domain adds and the seams it installs. +const projectTools = ctx => tools(ctx).filter(name => name.startsWith('project_')) + +test('#2684 apply() installs the Project domain on the core runtime and registers its tools', () => { + const { ctx, rt, result, stop } = boot() + try { + assert.deepEqual(projectTools(ctx), ['project_digest', 'project_state']) + assert.equal(typeof rt.projectBoard, 'function') + assert.equal(typeof rt.projectChief.create, 'function') + assert.equal(typeof rt.projectTeam, 'function') + assert.equal(typeof rt.projectAssistant.create, 'function') + assert.equal(typeof result.dispose, 'function') + // A second apply must not register the tools again (the fake context would keep one entry, but + // a real ctx.tools.register of the same name throws) and must not install a second domain. + const again = apply({ flybot: rt.flybot, effect: exec => exec() }) + assert.deepEqual(projectTools(ctx), ['project_digest', 'project_state']) + assert.equal(again.project, result.project) + } finally { stop() } +}) + +test('#2684 the owner switch off registers no tool and no seam', () => { + const { ctx, rt, result, stop } = boot({ projectEnabled: false }) + try { + assert.deepEqual(projectTools(ctx), []) + assert.equal(rt.projectBoard, undefined) + assert.equal(rt.projectChief, undefined) + assert.equal(rt.projectTeam, undefined) + assert.equal(rt.projectAssistant, undefined) + assert.equal(result.project, undefined) + // The store keeps its documented fallback: a Project is still created, just without a Chief. + assert.equal(typeof rt.flybot.projects.read, 'function') + } finally { stop() } +}) + +test('#2684 project_state answers the Project State listing it always did', async () => { + const dir = mkdtempSync(join(tmpdir(), 'flybot-project-state-')) + mkdirSync(join(dir, '.git')) + writeFileSync(join(dir, 'PROJECT.md'), '# Project\n') + const { ctx, stop } = boot() + try { + const tool = ctx.registeredTools.get('project_state') + const exec = { agent: { id: 'a', session: { header: { cwd: dir } } } } + const listing = await tool.execute({}, exec) + assert.match(listing, /^project state:\nPROJECT\.md\t\d+ bytes\tsha256:[0-9a-f]{64}\t\d{4}-\d{2}-\d{2}T/) + assert.equal(await tool.execute({ file: 'PROJECT.md' }, exec), '# Project\n') + assert.equal(await tool.execute({ file: '../etc/passwd' }, exec), 'not listed: ../etc/passwd is not a Project State file') + } finally { stop(); rmSync(dir, { recursive: true, force: true, maxRetries: 5 }) } +}) + +test('#2684 the plugin binds exactly the helpers the core seam names', () => { + // `test/host-bind.js` bound the core's own implementations for the pure-function tests; the names + // the plugin insists on and the names the core hands over are one list. + assert.deepEqual([...REQUIRED].sort(), [...HELPER_NAMES].sort()) + assert.doesNotThrow(() => bindHost(new Proxy({}, { get: (_, name) => () => name }))) +}) diff --git a/patches/ds-bot/files/test/project-assistant.test.js b/plugins/project/test/project-assistant.test.js similarity index 99% rename from patches/ds-bot/files/test/project-assistant.test.js rename to plugins/project/test/project-assistant.test.js index 41d9250bb..364e92754 100644 --- a/patches/ds-bot/files/test/project-assistant.test.js +++ b/plugins/project/test/project-assistant.test.js @@ -6,10 +6,12 @@ import { existsSync, mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' -import { createRuntime } from '../index.js' -import { fakeContext } from './fake-ctx.js' -import { cleanHome } from './temp-home.js' -import { MAX_ACTIONABLE, actionableFor, buildDigest, dayIn } from '../src/host/project-assistant.js' +import { createRuntime } from 'ds-bot' +import { apply as applyProject } from '../index.js' +import './host-bind.js' +import { fakeContext } from '../../../core/test/fake-ctx.js' +import { cleanHome } from '../../../core/test/temp-home.js' +import { MAX_ACTIONABLE, actionableFor, buildDigest, dayIn } from '../src/project-assistant.js' const seed = () => ({ version: 2, @@ -79,6 +81,7 @@ async function start({ github = 'Birfy', schedule = true } = {}) { sessions: { flush: async () => {} }, }) const rt = createRuntime(ctx, { home }) + applyProject({ flybot: rt.flybot }) await rt.load() rt.connectorsView = () => (github === null ? [] : [{ id: 'github', name: 'GitHub', state: 'ready', account: github }]) const delivered = [] diff --git a/patches/ds-bot/files/test/project-board-meeting-date.test.js b/plugins/project/test/project-board-meeting-date.test.js similarity index 95% rename from patches/ds-bot/files/test/project-board-meeting-date.test.js rename to plugins/project/test/project-board-meeting-date.test.js index d03786282..a99d53f6d 100644 --- a/patches/ds-bot/files/test/project-board-meeting-date.test.js +++ b/plugins/project/test/project-board-meeting-date.test.js @@ -8,8 +8,10 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' -import { createRuntime, provideFlyBot } from '../index.js' -import { fakeContext } from './fake-ctx.js' +import { createRuntime, provideFlyBot } from 'ds-bot' +import { apply as applyProject } from '../index.js' +import './host-bind.js' +import { fakeContext } from '../../../core/test/fake-ctx.js' const seed = () => ({ version: 2, @@ -58,6 +60,7 @@ async function start() { const register = ctx.connection.fetch.register ctx.connection.fetch.register = (options) => { route = options; return register(options) } const rt = createRuntime(ctx, { home }) + applyProject({ flybot: rt.flybot }) provideFlyBot(ctx, rt.flybot) await rt.load() const call = async (endpoint, payload) => (await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', body: JSON.stringify({ endpoint, payload }) }))).json() diff --git a/core/test/project-board.test.js b/plugins/project/test/project-board.test.js similarity index 99% rename from core/test/project-board.test.js rename to plugins/project/test/project-board.test.js index a8e71d800..74092f08e 100644 --- a/core/test/project-board.test.js +++ b/plugins/project/test/project-board.test.js @@ -6,10 +6,12 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, sym import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { test } from 'node:test' -import { createRuntime, provideFlyBot } from '../index.js' -import { blockedOf } from '../src/host/project-board.js' -import { MAX_BYTES } from '../src/host/project-state.js' -import { fakeContext } from './fake-ctx.js' +import { createRuntime, provideFlyBot } from 'ds-bot' +import { apply as applyProject } from '../index.js' +import './host-bind.js' +import { blockedOf } from '../src/project-board.js' +import { MAX_BYTES } from '../src/project-state.js' +import { fakeContext } from '../../../core/test/fake-ctx.js' const seed = () => ({ version: 2, @@ -64,6 +66,7 @@ async function start(home = mkdtempSync(join(tmpdir(), 'ds-bot-board-')), team = const register = ctx.connection.fetch.register ctx.connection.fetch.register = (options) => { route = options; return register(options) } const rt = createRuntime(ctx, { home }) + applyProject({ flybot: rt.flybot }) provideFlyBot(ctx, rt.flybot) await rt.load() const call = async (endpoint, payload) => (await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', body: JSON.stringify({ endpoint, payload }) }))).json() @@ -910,6 +913,7 @@ async function startWithMeetings({ project, room, session, team = meetingTeam() sessions: { flush: async () => {} }, }) const rt = createRuntime(ctx, { home }) + applyProject({ flybot: rt.flybot }) await rt.load() const exec = { agent: { id: 'a', session: { header: { cwd: session ?? teamDir } } } } const meeting = args => ctx.registeredTools.get('meeting').execute(args, exec) diff --git a/core/test/project-metrics-cache-bound.test.js b/plugins/project/test/project-metrics-cache-bound.test.js similarity index 93% rename from core/test/project-metrics-cache-bound.test.js rename to plugins/project/test/project-metrics-cache-bound.test.js index 02b033c7b..864d4db71 100644 --- a/core/test/project-metrics-cache-bound.test.js +++ b/plugins/project/test/project-metrics-cache-bound.test.js @@ -1,9 +1,10 @@ // #2806: the shared metrics cache is keyed by a five-minute window, so an entry can never be // answered again once its window is over — a bucket that is done is dead weight, and the host kept // every one of them for the life of the process (288 a day per repository and zone). +import './host-bind.js' import test from 'node:test' import assert from 'node:assert/strict' -import { METRICS_TTL_MS, readMetrics } from '../src/host/project-metrics.js' +import { METRICS_TTL_MS, readMetrics } from '../src/project-metrics.js' const NOW = Date.parse('2026-10-30T12:00:00Z') const fetcher = async () => ({ ok: true, json: async () => [] }) diff --git a/core/test/project-metrics-scope.test.js b/plugins/project/test/project-metrics-scope.test.js similarity index 98% rename from core/test/project-metrics-scope.test.js rename to plugins/project/test/project-metrics-scope.test.js index ec229aabf..be2fca29a 100644 --- a/core/test/project-metrics-scope.test.js +++ b/plugins/project/test/project-metrics-scope.test.js @@ -6,9 +6,11 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' -import { createRuntime, provideFlyBot } from '../index.js' -import { readMetrics } from '../src/host/project-metrics.js' -import { fakeContext } from './fake-ctx.js' +import { createRuntime, provideFlyBot } from 'ds-bot' +import { apply as applyProject } from '../index.js' +import './host-bind.js' +import { readMetrics } from '../src/project-metrics.js' +import { fakeContext } from '../../../core/test/fake-ctx.js' const HOUR = 60 * 60 * 1000 const NOW = Date.parse('2026-10-30T12:00:00Z') @@ -63,6 +65,7 @@ async function start(home = mkdtempSync(join(tmpdir(), 'ds-bot-scope-')), team = sessions: { flush: async () => {} }, }) const rt = createRuntime(ctx, { home }) + applyProject({ flybot: rt.flybot }) provideFlyBot(ctx, rt.flybot) await rt.load() // Somebody has signed in: the row guard of #1723 is on, and the running table must obey it too. diff --git a/core/test/project-metrics.test.js b/plugins/project/test/project-metrics.test.js similarity index 99% rename from core/test/project-metrics.test.js rename to plugins/project/test/project-metrics.test.js index d7f0fde29..01247da23 100644 --- a/core/test/project-metrics.test.js +++ b/plugins/project/test/project-metrics.test.js @@ -1,9 +1,10 @@ // #1829: the board's seven metrics. The read is injected, so these cases spend no network and // count their own source calls; the day boundary is the reader's zone, and a missing credential or // a failed read answers `null` (painted `—`), never `0`. +import './host-bind.js' import test from 'node:test' import assert from 'node:assert/strict' -import { METRIC_KEYS, METRICS_TTL_MS, dayKey, median, readMetrics, runningOf, summarizeMetrics } from '../src/host/project-metrics.js' +import { METRIC_KEYS, METRICS_TTL_MS, dayKey, median, readMetrics, runningOf, summarizeMetrics } from '../src/project-metrics.js' const HOUR = 60 * 60 * 1000 const NOW = Date.parse('2026-10-30T12:00:00Z') diff --git a/core/test/project-roles.test.js b/plugins/project/test/project-roles.test.js similarity index 99% rename from core/test/project-roles.test.js rename to plugins/project/test/project-roles.test.js index 44eb2b1bf..c78c1bfa0 100644 --- a/core/test/project-roles.test.js +++ b/plugins/project/test/project-roles.test.js @@ -2,7 +2,7 @@ // from `ownerUserIds`, so a Project written before this slice is owner-only without a migration. import test from 'node:test' import assert from 'node:assert/strict' -import { changeRole, roleChoices, roleOf, rolesOf } from '../src/host/project-board.js' +import { changeRole, roleChoices, roleOf, rolesOf } from '../src/project-board.js' const project = (extra = {}) => ({ id: 'project-1', name: 'P', ownerUserIds: ['owner'], ...extra }) diff --git a/patches/ds-bot/files/test/project-state-unreadable.test.js b/plugins/project/test/project-state-unreadable.test.js similarity index 95% rename from patches/ds-bot/files/test/project-state-unreadable.test.js rename to plugins/project/test/project-state-unreadable.test.js index 82c9a4382..f1260cf6b 100644 --- a/patches/ds-bot/files/test/project-state-unreadable.test.js +++ b/plugins/project/test/project-state-unreadable.test.js @@ -14,8 +14,9 @@ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:f import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' -import { apply } from '../index.js' -import { fakeContext } from './fake-ctx.js' +import { createRuntime, provideFlyBot } from 'ds-bot' +import { apply as applyProject } from '../index.js' +import { fakeContext } from '../../../core/test/fake-ctx.js' const TEAM = { version: 1, mainBotIds: ['a'], @@ -47,7 +48,9 @@ async function boot({ dir } = {}) { sessionTitle: { rename: async () => {} }, sessions: { flush: async () => {} }, }) - const rt = apply(ctx, { home }) + const rt = createRuntime(ctx, { home }) + provideFlyBot(ctx, rt.flybot) + applyProject({ flybot: rt.flybot }) await ctx.flybot.ready() const exec = { agent: { id: 'a', session: { header: { cwd: dir } } } } const tool = args => ctx.registeredTools.get('project_state').execute(args, exec) diff --git a/patches/ds-bot/files/test/project-state.test.js b/plugins/project/test/project-state.test.js similarity index 97% rename from patches/ds-bot/files/test/project-state.test.js rename to plugins/project/test/project-state.test.js index b60b9b8f2..1cab71bef 100644 --- a/patches/ds-bot/files/test/project-state.test.js +++ b/plugins/project/test/project-state.test.js @@ -4,8 +4,9 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSyn import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' -import { apply } from '../index.js' -import { fakeContext } from './fake-ctx.js' +import { createRuntime, provideFlyBot } from 'ds-bot' +import { apply as applyProject } from '../index.js' +import { fakeContext } from '../../../core/test/fake-ctx.js' const TEAM = { version: 1, mainBotIds: ['a'], @@ -40,7 +41,9 @@ async function boot({ dir } = {}) { sessionTitle: { rename: async () => {} }, sessions: { flush: async () => {} }, }) - const rt = apply(ctx, { home }) + const rt = createRuntime(ctx, { home }) + provideFlyBot(ctx, rt.flybot) + applyProject({ flybot: rt.flybot }) await ctx.flybot.ready() const exec = { agent: { id: 'a', session: { header: { cwd: dir } } } } const tool = (args, agentExec = exec) => ctx.registeredTools.get('project_state').execute(args, agentExec) diff --git a/patches/ds-bot/files/test/project-team.test.js b/plugins/project/test/project-team.test.js similarity index 97% rename from patches/ds-bot/files/test/project-team.test.js rename to plugins/project/test/project-team.test.js index 38f9c3a6a..c82d62698 100644 --- a/patches/ds-bot/files/test/project-team.test.js +++ b/plugins/project/test/project-team.test.js @@ -10,9 +10,11 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' -import { createRuntime, provideFlyBot } from '../index.js' -import { install } from '../src/host/project-team.js' -import { fakeContext } from './fake-ctx.js' +import { createRuntime, provideFlyBot } from 'ds-bot' +import { apply as applyProject } from '../index.js' +import './host-bind.js' +import { install } from '../src/project-team.js' +import { fakeContext } from '../../../core/test/fake-ctx.js' const LONG = 'x'.repeat(5000) @@ -97,6 +99,7 @@ async function start({ signedIn = false } = {}) { sessions: { flush: async () => {} }, }) const rt = createRuntime(ctx, { home }) + applyProject({ flybot: rt.flybot }) provideFlyBot(ctx, rt.flybot) await rt.load() const callAs = async (endpoint, payload, token) => (await route.fetch(new Request('http://localhost/api/bot', { diff --git a/plugins/project/test/resolve.mjs b/plugins/project/test/resolve.mjs new file mode 100644 index 000000000..9a473138e --- /dev/null +++ b/plugins/project/test/resolve.mjs @@ -0,0 +1,34 @@ +// Test-only module resolution for @flybot/project (ThinkFlowLab/FlyBot#34). +// - `ds-bot` resolves to the FlyBot core checkout, the way a DSH profile supplies the peer: +// FLYBOT_CORE, else the repo-root `core/` (pristine ds-bot/ + patches/ds-bot/, generated by +// scripts/ds-bot-patches.mjs). Never `ds-bot/`: that is unpatched upstream, with no flybot API. +// - `@deepseek-ai/*` imported from inside that core resolves from a DeepSeek Harness: +// DSH_HARNESS, else /dev/harness (`npm run harness` in the core). +// Project's own code never imports ds-bot; its tests do, and they apply the plugin to a core runtime. +import { existsSync } from 'node:fs' +import { registerHooks } from 'node:module' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const repo = resolve(import.meta.dirname, '../../..') +const core = process.env.FLYBOT_CORE + ? resolve(process.env.FLYBOT_CORE) + : join(repo, 'core') +if (!existsSync(join(core, 'package.json'))) throw new Error(`@flybot/project tests: no FlyBot core at ${core} (set FLYBOT_CORE)`) +const harness = process.env.DSH_HARNESS ?? join(core, 'dev', 'harness') +process.env.DSH_HARNESS = harness +process.env.FLYBOT_CORE = core // tests read interface-layer files (flybot-registry.js) from here +const base = resolve(harness, 'packages/bundle/base/package.json') +const anchor = pathToFileURL(existsSync(base) ? base : resolve(harness, 'package.json')).href +const coreUrl = `${pathToFileURL(core).href}/` +const entry = pathToFileURL(join(core, 'index.js')).href + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === 'ds-bot') return { url: entry, format: 'module', shortCircuit: true } + if (specifier.startsWith('@deepseek-ai/') && context.parentURL?.startsWith(coreUrl)) { + return nextResolve(specifier, { ...context, parentURL: anchor }) + } + return nextResolve(specifier, context) + }, +})