From 8837b672527761c57f5670145c424f0e6e95862d Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:28:59 +0800 Subject: [PATCH] A symlinked memory or workflows folder reads another folder's files as this team's own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2057 The two readers that take a fixed directory name under a folder the host owns — `memory/team`, `memory/bots/` (memory.js) and a workspace's `workflows/` (workflow-store.js) — listed and read whatever the directory yielded, and `readdir`/`readFile` follow a directory symlink, so a link to a folder outside the home fed foreign `MEMORY.md`/topic text into every prompt and foreign workflow definitions into the page. Both now ask `#2055`'s shared `dirKindSync` (`src/host/real-dir.js`, imported, not copied) before anything inside is listed or read. A reader means "nothing there" for anything but `real`, so `absent` and `other` answer exactly what a missing folder answers today: one empty `MEMORY.md` entry and no topics, an empty `own` list, `default: null`, `workflow/not-found`. `other` is the one case that is not silent — it warns once per folder and process, because an empty list and an unreadable folder look alike in the panel. The write paths are unchanged (a missing folder is still created). --- ...2057-memory-workflows-dir-symlink.fixed.md | 1 + core/src/host/memory.js | 50 +++++- core/src/host/workflow-store.js | 47 ++++-- core/test/memory.test.js | 65 +++++++- core/test/workflow-store.test.js | 85 +++++++++- docs/subsystems/core-host.md | 3 +- patches/ds-bot/0001-core-fixes.patch | 157 +++++++++++++++++- .../ds-bot/files/src/host/workflow-store.js | 47 ++++-- .../ds-bot/files/test/workflow-store.test.js | 85 +++++++++- 9 files changed, 498 insertions(+), 42 deletions(-) create mode 100644 changelog.d/2057-memory-workflows-dir-symlink.fixed.md diff --git a/changelog.d/2057-memory-workflows-dir-symlink.fixed.md b/changelog.d/2057-memory-workflows-dir-symlink.fixed.md new file mode 100644 index 000000000..0a8f9e89f --- /dev/null +++ b/changelog.d/2057-memory-workflows-dir-symlink.fixed.md @@ -0,0 +1 @@ +A memory or workflows folder that is a symlink is now read as empty instead of being followed ([#2057](https://github.com/ThinkFlowLab/FlyBot/issues/2057)): `/memory/team`, `/memory/bots/` and a workspace's `workflows/` must be real directories for their files to be listed and read, so a link to a folder outside the home no longer feeds another folder's entries into a prompt, a listing or a page. The check runs before anything is listed or read; a folder that does not exist answers exactly what it answered before, a real folder is unchanged, and a linked one says so once per folder with one warning, because "nothing saved" and "nothing readable" look alike otherwise. diff --git a/core/src/host/memory.js b/core/src/host/memory.js index f23717b4c..8d3f00a70 100644 --- a/core/src/host/memory.js +++ b/core/src/host/memory.js @@ -32,6 +32,7 @@ import { codePointStart, cut } from '../shared/text-boundary.js' import { output } from './tools.js' import { retiredBot } from './store.js' import { botNode, buildGraph, buildIndex, relatedOf, searchIndex, topicNode } from './memory-search.js' +import { dirKindSync } from './real-dir.js' // Fixed sizes, in characters. Both MEMORY.md files ride in every request of every // part, so together they stay near 8,000 characters, a few thousand tokens. A topic is @@ -430,6 +431,31 @@ export function install(rt) { const dirOf = scope => (scope.kind === 'team' ? join(root, 'team') : join(root, 'bots', safeId(scope.botId))) const scopeKey = scope => (scope.kind === 'team' ? 'team' : `bot:${scope.botId}`) + // The memory folders are the home's own bookkeeping (#2057): `readdir` and `readFile` follow a + // directory link, so a linked folder would read another folder's files as this team's own. A + // reader answers exactly what it answers for a missing folder; a link is what differs, and it is + // said once per folder and process, because "nothing saved" and "nothing readable" look alike. + const warnedFolders = new Set() + const warnFolder = (kind, dir) => { + if (kind !== 'other' || warnedFolders.has(dir)) return + warnedFolders.add(dir) + rt.warn('ds-bot: memory folder is not a plain directory %s; keeping it empty', dir) + } + const scopeDir = (scope) => { + const base = dirKindSync(root) + if (base !== 'real') { + warnFolder(base, root) + return undefined + } + const dir = dirOf(scope) + const kind = dirKindSync(dir) + if (kind !== 'real') { + warnFolder(kind, dir) + return undefined + } + return dir + } + const readText = (path) => { try { return readFileSync(path, 'utf8') @@ -438,22 +464,32 @@ export function install(rt) { return '' } } - const topicNames = (scope) => { + const topicNamesIn = (dir) => { try { - return readdirSync(dirOf(scope)).filter(name => name.endsWith('.md') && name !== MAIN).map(name => name.slice(0, -3)).sort() + return readdirSync(dir).filter(name => name.endsWith('.md') && name !== MAIN).map(name => name.slice(0, -3)).sort() } catch { return [] } } + const topicNames = (scope) => { + const dir = scopeDir(scope) + return dir === undefined ? [] : topicNamesIn(dir) + } // MEMORY.md first, then the topics by name. const loadScope = (scope) => { const dir = dirOf(scope) - return [undefined, ...topicNames(scope)].map((topic) => { + const open = scopeDir(scope) !== undefined + return [undefined, ...(open ? topicNamesIn(dir) : [])].map((topic) => { const file = topic === undefined ? MAIN : `${topic}.md` - const content = readText(join(dir, file)) + const content = open ? readText(join(dir, file)) : '' return { topic, file, path: join(dir, file), content, ...parseMemory(content) } }) } + // One scope's MEMORY.md, empty when its folder is not read. + const readMainOf = (scope) => { + const dir = scopeDir(scope) + return dir === undefined ? '' : readText(join(dir, MAIN)) + } const limitOf = file => (file.topic === undefined ? MEMORY_LIMITS.main : MEMORY_LIMITS.topic) const fileName = file => (file.topic === undefined ? MAIN : `topic [[${file.topic}]]`) @@ -683,8 +719,8 @@ export function install(rt) { } function memorySection(bot) { const own = ownScope(bot.id) - const main = parseMemory(readText(join(dirOf(own), MAIN))) - const team = parseMemory(readText(join(dirOf(TEAM), MAIN))) + const main = parseMemory(readMainOf(own)) + const team = parseMemory(readMainOf(TEAM)) return [ '# Your memory', memoryRules(reviewOn), @@ -1500,7 +1536,7 @@ export function install(rt) { return { // The head is the dossier's text, so its bound lands on a code point (#2005): a `slice` // here handed the panel the first half of an emoji, and no later bound can put it back. - head: cut(readText(join(dirOf(scope), MAIN)), head).text, + head: cut(readMainOf(scope), head).text, topics: topicNames(scope).slice(0, topics), } } diff --git a/core/src/host/workflow-store.js b/core/src/host/workflow-store.js index a50095b17..16014e67b 100644 --- a/core/src/host/workflow-store.js +++ b/core/src/host/workflow-store.js @@ -20,6 +20,7 @@ import { copyOfPreset, isPresetId, isWorkflowId, workflowBytes, WORKFLOW_DIR } f import { previewNode } from '../shared/workflow-preview.js' import { repoDevWorkflow } from '../shared/workflow-repo-dev.js' import { blankWorkflow } from '../shared/workflow-blank.js' +import { dirKindSync } from './real-dir.js' // The shipped presets, as definitions. `PRESET_IDS` in `workflow-doc.js` names the same two; a test // asserts they cannot drift apart. @@ -36,21 +37,32 @@ const summary = (definition, source) => ({ columns: normaliseWorkflow(definition).nodes.filter((node) => node.column !== false).length, }) -// A directory may not exist yet (a workspace nobody has customized anything in), and a file in it -// may be someone's hand-edit that does not parse: one broken file must not take the list down. -const ownFiles = (dir) => { - try { - return readdirSync(dir).filter((name) => name.endsWith('.json') && name !== PROJECT_FILE).sort() - } catch { - return [] - } -} - -export function createWorkflowStore({ dir, presets = PRESETS, team = () => null, factsFor = () => ({ ok: false, why: 'this host has no fact reader' }), warn = () => {} } = {}) { +export function createWorkflowStore({ dir, presets = PRESETS, team = () => null, factsFor = () => ({ ok: false, why: 'this host has no fact reader' }), warn = () => {}, warnFolder = () => {} } = {}) { if (typeof dir !== 'string' || dir === '') throw new Error('a workflow store needs a directory') const fileFor = (id) => join(dir, `${id}.json`) + // `readdir` and `readFile` follow a directory link (#2057), so a `workflows` that is a link would + // list and read another folder's definitions as this workspace's own. Only a real folder is read; + // a missing or linked one answers what a missing folder answers today. The warning is what tells + // the two apart in the page, and the caller says it once per folder rather than once per read. + const folderReal = () => { + const kind = dirKindSync(dir) + if (kind !== 'real') warnFolder(dir, kind) + return kind === 'real' + } + // A directory may not exist yet (a workspace nobody has customized anything in), and a file in it + // may be someone's hand-edit that does not parse: one broken file must not take the list down. + const ownFiles = () => { + if (!folderReal()) return [] + try { + return readdirSync(dir).filter((name) => name.endsWith('.json') && name !== PROJECT_FILE).sort() + } catch { + return [] + } + } + const readOwn = (id) => { + if (!folderReal()) return null try { return normaliseWorkflow(JSON.parse(readFileSync(fileFor(id), 'utf8'))) } catch { @@ -66,6 +78,7 @@ export function createWorkflowStore({ dir, presets = PRESETS, team = () => null, } const projectDefault = () => { + if (!folderReal()) return null try { const value = JSON.parse(readFileSync(join(dir, PROJECT_FILE), 'utf8')) return typeof value?.default === 'string' && value.default !== '' ? value.default : null @@ -75,7 +88,7 @@ export function createWorkflowStore({ dir, presets = PRESETS, team = () => null, } const list = () => { - const own = ownFiles(dir).flatMap((name) => { + const own = ownFiles().flatMap((name) => { const definition = readOwn(name.slice(0, -'.json'.length)) if (definition === null || definition.id === '') { warn('ds-bot: workflow store: %s in %s does not parse as a definition; skipped', name, dir) @@ -175,7 +188,7 @@ export function createWorkflowStore({ dir, presets = PRESETS, team = () => null, // The definitions in play, for the board's columns. Presets always; a workspace file is read on // every call so a save is visible to the next board read without a restart. definitions: () => { - const own = ownFiles(dir).map((name) => readOwn(name.slice(0, -'.json'.length))).filter((definition) => definition !== null && definition.id !== '') + const own = ownFiles().map((name) => readOwn(name.slice(0, -'.json'.length))).filter((definition) => definition !== null && definition.id !== '') return [...presets.map(normaliseWorkflow), ...own] }, bytesOf: (id) => { @@ -206,10 +219,16 @@ export function install(rt) { return null } } + const warnedFolders = new Set() + const warnFolder = (dir, kind) => { + if (kind !== 'other' || warnedFolders.has(dir)) return + warnedFolders.add(dir) + rt.warn?.('ds-bot: workflow folder is not a plain directory %s; keeping it empty', dir) + } const store = () => { const dir = dirFor() if (dir === null) return null - if (rt.workflowStore?.dir !== dir) rt.workflowStore = createWorkflowStore({ dir, team, factsFor: rt.workflowFactsFor ?? (() => ({ ok: false, why: 'this host has no fact reader' })) }) + if (rt.workflowStore?.dir !== dir) rt.workflowStore = createWorkflowStore({ dir, team, warn: rt.warn, warnFolder, factsFor: rt.workflowFactsFor ?? (() => ({ ok: false, why: 'this host has no fact reader' })) }) return rt.workflowStore } rt.workflowEndpoint = async (endpoint, body = {}) => { diff --git a/core/test/memory.test.js b/core/test/memory.test.js index ebb7b5519..768221a50 100644 --- a/core/test/memory.test.js +++ b/core/test/memory.test.js @@ -1,5 +1,5 @@ import assert from 'node:assert/strict' -import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { hostname, tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' @@ -1391,3 +1391,66 @@ test('#2006: an ask prompt keeps whole characters at its 2,000-unit bound', asyn assert.equal(sent.isWellFormed(), true, `the ask prompt keeps half an emoji: ${kept(sent)}`) assert.ok(sent.endsWith(`${'q'.repeat(1999)}`), 'the prompt keeps the bound and drops the emoji whole') }) + +// --------------------------------------------------------------------------- +// #2057: a linked folder is not this team's memory. + +const captureWarnings = (t) => { + const lines = [] + const original = console.error + console.error = (...args) => { lines.push(args.join(' ')) } + t.after(() => { console.error = original }) + return () => lines.filter(line => line.includes('memory folder is not a plain directory')) +} + +test('#2057: a team memory folder that is a symlink reads nothing, and says so once', async (t) => { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-memory-')) + const outside = mkdtempSync(join(tmpdir(), 'ds-bot-memory-outside-')) + writeFileSync(join(outside, 'x.md'), '# x\n\n- an entry from outside the home\n') + mkdirSync(join(home, 'memory'), { recursive: true }) + symlinkSync(outside, join(home, 'memory', 'team'), 'dir') + t.after(() => rmSync(outside, { recursive: true, force: true })) + const folderWarnings = captureWarnings(t) + const { section, call, stop } = start({ home }) + t.after(() => stop()) + // Three reads: the prompt section is what carries memory into every request. + for (let round = 0; round < 3; round++) { + const text = section('b2') + assert.doesNotMatch(text, /an entry from outside the home/) + assert.doesNotMatch(text, /\[\[x\]\]/) + const view = (await call('memory-view', { botId: 'b2' })).value + assert.deepEqual(view.entries.filter(entry => entry.scope === 'team'), []) + assert.equal(view.sizes.team, 0) + } + const warned = folderWarnings() + assert.equal(warned.length, 1, 'one warning per folder per process, not one per read') + assert.ok(warned[0].includes(join(home, 'memory', 'team')), `the warning names the folder: ${warned[0]}`) + assert.match(warned[0], /keeping it empty$/) +}) + +test('#2057: a memory folder that was never created reads as empty and says nothing', async (t) => { + const folderWarnings = captureWarnings(t) + const { section, call, stop, home } = start() + t.after(() => stop()) + assert.equal(existsSync(join(home, 'memory')), false) + assert.doesNotMatch(section('b2'), /an entry from outside/) + const view = (await call('memory-view', { botId: 'b2' })).value + assert.deepEqual(view.entries, []) + assert.deepEqual(folderWarnings(), []) +}) + +test('#2057: a real team memory folder keeps listing and reading, with no warning', async (t) => { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-memory-')) + mkdirSync(join(home, 'memory', 'team'), { recursive: true }) + writeFileSync(join(home, 'memory', 'team', 'MEMORY.md'), '# Memory\n\n- Ships on Friday [added: 2026-11-01]\n') + writeFileSync(join(home, 'memory', 'team', 'travel.md'), '# travel\n\n- Flies from Hangzhou\n') + const folderWarnings = captureWarnings(t) + const { section, call, stop } = start({ home }) + t.after(() => stop()) + const view = (await call('memory-view', { botId: 'b2' })).value + const text = section('b2') + assert.match(text, /- Ships on Friday/) + assert.match(text, /Topics: \[\[travel\]\]/) + assert.deepEqual(view.entries.filter(entry => entry.scope === 'team').map(entry => entry.text), ['Ships on Friday', 'Flies from Hangzhou']) + assert.deepEqual(folderWarnings(), []) +}) diff --git a/core/test/workflow-store.test.js b/core/test/workflow-store.test.js index 7669f3ae4..4e55d765a 100644 --- a/core/test/workflow-store.test.js +++ b/core/test/workflow-store.test.js @@ -3,7 +3,7 @@ // actor (acceptance 6 and 7). import assert from 'node:assert/strict' import { createHash } from 'node:crypto' -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' @@ -151,3 +151,86 @@ test('a workspace file that does not parse is skipped instead of breaking the li assert.deepEqual(listed.own.map((entry) => entry.id), ['blank-mine']) }) }) + +// --------------------------------------------------------------------------- +// #2057: a linked folder is not this workspace's workflows. + +const elsewhere = (t, files) => { + const root = mkdtempSync(join(tmpdir(), 'flybot-workflows-outside-')) + for (const [name, text] of Object.entries(files)) writeFileSync(join(root, name), text) + t.after(() => rmSync(root, { recursive: true, force: true })) + return root +} + +test('#2057: a workflow folder that is a symlink lists nothing and never reads the outside file', (t) => { + const root = mkdtempSync(join(tmpdir(), 'flybot-workflows-')) + t.after(() => rmSync(root, { recursive: true, force: true })) + const dir = join(root, 'workflows') + const outside = elsewhere(t, { 'other.json': JSON.stringify({ ...blankWorkflow, id: 'other', name: 'Other' }) }) + symlinkSync(outside, dir, 'dir') + const store = createWorkflowStore({ dir }) + const listed = store.list() + assert.deepEqual(listed.own, []) + assert.equal(listed.default, null) + const opened = store.open('other') + assert.equal(opened.ok, false) + assert.equal(opened.code, 'workflow/not-found') + assert.equal(JSON.stringify(opened).includes('Other'), false, 'the outside definition never comes back') + assert.deepEqual(store.definitions().map((definition) => definition.id).sort(), PRESETS.map((preset) => preset.id).sort()) +}) + +test('#2057: a workflow folder that is a plain file lists nothing and does not throw', (t) => { + const root = mkdtempSync(join(tmpdir(), 'flybot-workflows-')) + t.after(() => rmSync(root, { recursive: true, force: true })) + const dir = join(root, 'workflows') + writeFileSync(dir, 'not a folder') + const store = createWorkflowStore({ dir }) + assert.deepEqual(store.list().own, []) + assert.deepEqual(store.definitions().map((definition) => definition.id).sort(), PRESETS.map((preset) => preset.id).sort()) +}) + +test('#2057: a linked workflow folder warns once, a missing or real one never warns', async (t) => { + const warned = [] + const host = (root) => { + const rt = { + state: { bots: {} }, + ctx: { workspaceRegistry: { get: () => ({ path: root }) } }, + warn: (template, ...args) => warned.push(String(template).replace(/%s/g, () => String(args.shift() ?? ''))), + } + install(rt) + return rt + } + const folderWarnings = () => warned.filter((line) => line.includes('workflow folder is not a plain directory')) + + // A link to a folder outside the workspace: every read says "nothing", and the folder is named once. + const linkedRoot = mkdtempSync(join(tmpdir(), 'flybot-workflows-')) + t.after(() => rmSync(linkedRoot, { recursive: true, force: true })) + const outside = elsewhere(t, { 'other.json': JSON.stringify({ ...blankWorkflow, id: 'other', name: 'Other' }) }) + symlinkSync(outside, join(linkedRoot, 'workflows'), 'dir') + const linked = host(linkedRoot) + for (let round = 0; round < 3; round++) { + const listed = await linked.workflowEndpoint('workflows', { op: 'list' }) + assert.deepEqual(listed.own, []) + linked.workflowDefinitions() + } + const said = folderWarnings() + assert.equal(said.length, 1, 'one warning per folder per process, not one per read') + assert.ok(said[0].includes(join(linkedRoot, 'workflows')), `the warning names the folder: ${said[0]}`) + assert.match(said[0], /keeping it empty$/) + + // A workspace nobody has saved a workflow in: empty, silent. + const freshRoot = mkdtempSync(join(tmpdir(), 'flybot-workflows-')) + t.after(() => rmSync(freshRoot, { recursive: true, force: true })) + const fresh = host(freshRoot) + assert.deepEqual((await fresh.workflowEndpoint('workflows', { op: 'list' })).own, []) + assert.deepEqual(folderWarnings(), said) + + // A real folder: listed, silent. + const realRoot = mkdtempSync(join(tmpdir(), 'flybot-workflows-')) + t.after(() => rmSync(realRoot, { recursive: true, force: true })) + mkdirSync(join(realRoot, 'workflows'), { recursive: true }) + writeFileSync(join(realRoot, 'workflows', 'mine.json'), JSON.stringify({ ...blankWorkflow, id: 'mine', name: 'Mine' })) + const real = host(realRoot) + assert.deepEqual((await real.workflowEndpoint('workflows', { op: 'list' })).own.map((entry) => entry.id), ['mine']) + assert.deepEqual(folderWarnings(), said) +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index be650ca2f..ad8fd88ff 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -66,7 +66,7 @@ The server side of the patched `ds-bot` core (`core/`): the part of [ds-bot-core | `core/src/shared/workflow-doc.js` | The definition as a file (#1272 step 3): `workflowBytes(def)` is canonical — fixed key order, two spaces, a trailing newline, a field a definition does not state left out — so a load → save round trip is byte-identical, a definition diffs in git, and `column: false` is written while an absent column is not (they are two different decisions, #1265). `isWorkflowId` is the file-name rule (lowercase, digits, hyphens) and `PRESET_IDS` names the two ids a workspace file may not claim; `copyOfPreset(preset, { id, name })` is what *Customize* writes, and `differingFields(a, b)` is how the copy is held to "differs only in id/name" (acceptance 6). No I/O. Tests: `core/test/workflow-doc.test.js`. | | `core/src/shared/workflow-preview.js` | The guard preview the editor shows (#1272 step 3, acceptance 5): `previewNode(def, nodeId, answer)` asks `workflow.js`'s `guardRows` for one row per guard in the definition's order — `holds` (read and passing, no sentence), `refuse` (the producer's own sentence) or `unreadable` — and when the reader answered nothing (`{ ok: false, why }`) every row says it could not read the fact, with the reason named and no pass anywhere. It reads the same function `enterNode` reads, so the preview and a card's refusal cannot disagree. Tests: `core/test/workflow-preview.test.js`. | | `core/src/shared/workflow-run.js` | The running layer's pure half (#1316, #1272 step 4). `planJob({ definition, job, cards, facts, hold })` answers which node cards a job may create now: every decision is `enterNode`'s, so a node whose input is missing, stale or unreadable opens nothing and its refusal is that function's own sentence byte for byte, and a node that already has a card is in neither list — a card parented to another job is not this job's card, so `cardsOfJob` is what makes `(job, node)` one card across a replayed tick or a restarted host, even when a caller hands in the whole board. An open entry is the card the board already places (`parentId` the job card, `workflow`, `node`) plus the fields the pull queue reads (`role`, `queueRole`, `assignee: null`), so the hand-off is the claim and never an `@`. A held job opens nothing on any node, in the definition's own sentence: the planner renders that definition's `no-hold` guard (`pr` equal to `none`, refused for `hold`) through `renderRefusal`, which is the merge gate's wording byte for byte, and a definition guarding no held pull request gets `refusal: null` and the holder's name instead of a sentence this layer wrote. `jobDone` answers pin 6 — a job is done when its terminal node's outputs are present and current, never because a card moved — and `completions` is step (c)'s pure half: a done card whose declared outputs are current, append-only, an entry already in `history` never returned again. No fs, no clock, no timer, no network, no host import: `task-drive.js` `driveOnce` stays the only beat. Tests: `core/test/workflow-run.test.js`. | -| `core/src/host/workflow-store.js` | Where a definition lives (#1272 step 3): the presets read from their modules plus the owner's `workflows/.json` in the workspace, and the only writer. `save` validates with the engine's own `validateWorkflow` (the roster's roles are the team; an empty roster is no team to check), refuses a preset id (`workflow/preset-read-only` — *Customize* is the only way to start from one), writes `workflow-doc.js`'s bytes only when they changed, and answers `changed: false` for an unedited definition (acceptance 7). `customize`, `setDefault` (the project's `workflows/project.json`), `list`, `open` (definition + bytes + layout + preview) and `definitions()` (the presets plus the workspace files) round it out; `install` wires `rt.workflowEndpoint` for `workflows` (read) and `workflow-save` (write, in `authz.js`'s `TASK_ENDPOINTS`), so the client writes a file through the host and never to GitHub. A file that does not parse is skipped with one warning instead of breaking the list. Tests: `core/test/workflow-store.test.js`. | +| `core/src/host/workflow-store.js` | Where a definition lives (#1272 step 3): the presets read from their modules plus the owner's `workflows/.json` in the workspace, and the only writer. `save` validates with the engine's own `validateWorkflow` (the roster's roles are the team; an empty roster is no team to check), refuses a preset id (`workflow/preset-read-only` — *Customize* is the only way to start from one), writes `workflow-doc.js`'s bytes only when they changed, and answers `changed: false` for an unedited definition (acceptance 7). `customize`, `setDefault` (the project's `workflows/project.json`), `list`, `open` (definition + bytes + layout + preview) and `definitions()` (the presets plus the workspace files) round it out; `install` wires `rt.workflowEndpoint` for `workflows` (read) and `workflow-save` (write, in `authz.js`'s `TASK_ENDPOINTS`), so the client writes a file through the host and never to GitHub. A file that does not parse is skipped with one warning instead of breaking the list. The `workflows` folder itself must be a real directory too ([#2057](https://github.com/ThinkFlowLab/FlyBot/issues/2057)): `dirKindSync` from `src/host/real-dir.js` is asked before every listing and read, so a link, a plain file or a check that cannot run lists nothing and opens nothing — `own` is empty, `default` is `null`, `open` answers `workflow/not-found`, `definitions()` answers the presets — which is exactly the answer a missing folder gives, and the linked folder is named once per folder and process in one warning (`ds-bot: workflow folder is not a plain directory …; keeping it empty`) because "nothing saved" and "nothing readable" look alike in the page. Writes are unchanged: a save still creates a missing folder. Tests: `core/test/workflow-store.test.js`. | | `core/src/host/workflow-facts.js` | The fact providers (#1272 step 2, acceptance 10): one function per fact a guard asks about — `github.labels`, `github.checkAtSha`, `github.verdictAtTip`, `github.mergeable`, `github.prBodyTip`, `task.progress`, `manual.tick` — each taking the answer a reader already got and returning one artifact (spec, revision, value), never fetching. No `GITHUB_TOKEN`, no answer, or an answer missing the field is `{ ok: false, reason: 'unreadable', why }`, never a guess: a fact nobody could read refuses the node it feeds instead of passing it. `workflowFact(name, args)` is the one spelling for a caller that has the name as data, and an unknown name is unreadable with the name in the reason. Tests: `core/test/workflow-repo-dev.test.js`. | | `core/src/shared/remote-label.js` | Owner-badge labels for a friend's Bot and “Shared room from …” titles. `friendBotTitle(bot)` / `friendBotOwner(bot)` name a friend's chat from the roster (`Chief (SelfB1233)`) and its owner (`SelfB1233`, the person an approval card names) with one formatter for every client surface (#498); `friendRecord` reads a published roster record (`roster.remoteBots`, name/owner/home at the top level) as well as a chat's `remote`, so the name does not change when the chat appears (#967). `remoteMentionLabel(record, others)` is the one name a mention wears — that title, and on a title two different handles share the home too (`Chief (Bob) · bob2.home`), so the menu, the bubble, the preview and the host's match cannot drift apart (#967). `remoteBotName` / `isAutoOwnerName` / `seatOwnerName` keep group and face-room labels in that format too (#526): Bot Social's ` · ` member label is not a Bot name, and an owner name made up from an install id (`FlyBot 682b8a43`) gives way to the username in the home handle (`Reviewer (flybot682b8a43)`). `isListedRemoteChat` / `listedChatBots` keep gone, hidden, and home-level (`@something.home`) remotes out of the **发给** picker, mentions, and `list_bots` friends lines: a share that ended is not a place to send to. The sidebar asks a different question through `isShownRemoteChat` (#956): a chat whose Bot left the published list keeps its row, marked *no longer shared*, while only a hide the user set (`bot.hidden`) takes a row out. History stays on disk either way. Since #1916 `cleanPeerName` bounds a peer name through the shared `cut` (`shared/text-boundary.js`), so a 60-unit label never ends in half of an emoji. | @@ -229,6 +229,7 @@ An admin's `@` used to be the room's control plane: every `@Dev #1234 rebase and - `src/host/memory-search.js` derives two recall aids from the memory files that `src/host/memory.js` already reads; both are pure and rebuildable, and the Markdown files stay the only source of truth. - Hybrid ranking: `termsOf`/`buildIndex`/`searchIndex` build a sparse TF-IDF vector per entry over words, word character trigrams (4+ letters, so a shared stem matches across endings) and CJK uni/bi-grams, and rank by cosine. `recall` uses it only when called with `semantic: "true"`; the default exact-word path is unchanged, so an existing call scores and prints exactly as before. - Graph: `buildGraph`/`relatedOf` turn `[[topic]]` references into topic→topic edges and a `by:` metadata value into file→Bot edges; `recall` with `related` walks them (undirected, up to two hops) and lists what is near a topic or Bot. Tests: `core/test/memory-search.test.js` (pure) and the two `recall` cases in `core/test/memory.test.js`. +- The memory folders are entries of their own ([#2057](https://github.com/ThinkFlowLab/FlyBot/issues/2057)): `memory/team` and `memory/bots/` are asked through `dirKindSync` from `src/host/real-dir.js` (the `#2055` helper, not a second copy) before anything inside them is listed or read, so a `memory/team` that is a link can no longer feed another folder's `MEMORY.md` or topics into a prompt, a `recall`, the memory panel or the Project dossier. Only `real` is read; `absent` and `other` answer what a missing folder answers (the one empty `MEMORY.md` entry and no topics), and `other` says so once per folder and process in one warning (`ds-bot: memory folder is not a plain directory …; keeping it empty`), because a folder that reads as empty and a folder that cannot be read look alike in the panel. The writes that create a memory folder are unchanged. - `recall` shows at most 30 entries (`RECALL_MAX` in `src/host/memory.js`) and now says when it cut the list ([#2001](https://github.com/ThinkFlowLab/FlyBot/issues/2001)): the exact-word path prints `N more entries match; narrow the query or read a topic` with the real count it already holds, and `semantic: "true"` asks `searchIndex` for one past the cap and prints `more entries are close in meaning than are shown; narrow the query or read a topic` because that index returns the top k only. The cap stays 30; a reply that shows every match adds neither line. ### Plugin memory seam (#450 item 1) diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 35fb07aa6..3e450f41b 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -4891,7 +4891,7 @@ diff --git a/src/host/images.js b/src/host/images.js diff --git a/src/host/memory.js b/src/host/memory.js --- a/src/host/memory.js +++ b/src/host/memory.js -@@ -25,9 +25,13 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSyn +@@ -25,9 +25,14 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSyn import { appendFile, mkdir, rename, unlink, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { BlockAssembler } from '@deepseek-ai/dsh-llm' @@ -4904,6 +4904,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js import { output } from './tools.js' +import { retiredBot } from './store.js' +import { botNode, buildGraph, buildIndex, relatedOf, searchIndex, topicNode } from './memory-search.js' ++import { dirKindSync } from './real-dir.js' // Fixed sizes, in characters. Both MEMORY.md files ride in every request of every // part, so together they stay near 8,000 characters, a few thousand tokens. A topic is @@ -4942,6 +4943,75 @@ diff --git a/src/host/memory.js b/src/host/memory.js }) return } +@@ -425,6 +425,31 @@ export function install(rt) { + const dirOf = scope => (scope.kind === 'team' ? join(root, 'team') : join(root, 'bots', safeId(scope.botId))) + const scopeKey = scope => (scope.kind === 'team' ? 'team' : `bot:${scope.botId}`) + ++ // The memory folders are the home's own bookkeeping (#2057): `readdir` and `readFile` follow a ++ // directory link, so a linked folder would read another folder's files as this team's own. A ++ // reader answers exactly what it answers for a missing folder; a link is what differs, and it is ++ // said once per folder and process, because "nothing saved" and "nothing readable" look alike. ++ const warnedFolders = new Set() ++ const warnFolder = (kind, dir) => { ++ if (kind !== 'other' || warnedFolders.has(dir)) return ++ warnedFolders.add(dir) ++ rt.warn('ds-bot: memory folder is not a plain directory %s; keeping it empty', dir) ++ } ++ const scopeDir = (scope) => { ++ const base = dirKindSync(root) ++ if (base !== 'real') { ++ warnFolder(base, root) ++ return undefined ++ } ++ const dir = dirOf(scope) ++ const kind = dirKindSync(dir) ++ if (kind !== 'real') { ++ warnFolder(kind, dir) ++ return undefined ++ } ++ return dir ++ } ++ + const readText = (path) => { + try { + return readFileSync(path, 'utf8') +@@ -433,22 +433,32 @@ export function install(rt) { + return '' + } + } +- const topicNames = (scope) => { ++ const topicNamesIn = (dir) => { + try { +- return readdirSync(dirOf(scope)).filter(name => name.endsWith('.md') && name !== MAIN).map(name => name.slice(0, -3)).sort() ++ return readdirSync(dir).filter(name => name.endsWith('.md') && name !== MAIN).map(name => name.slice(0, -3)).sort() + } catch { + return [] + } + } ++ const topicNames = (scope) => { ++ const dir = scopeDir(scope) ++ return dir === undefined ? [] : topicNamesIn(dir) ++ } + // MEMORY.md first, then the topics by name. + const loadScope = (scope) => { + const dir = dirOf(scope) +- return [undefined, ...topicNames(scope)].map((topic) => { ++ const open = scopeDir(scope) !== undefined ++ return [undefined, ...(open ? topicNamesIn(dir) : [])].map((topic) => { + const file = topic === undefined ? MAIN : `${topic}.md` +- const content = readText(join(dir, file)) ++ const content = open ? readText(join(dir, file)) : '' + return { topic, file, path: join(dir, file), content, ...parseMemory(content) } + }) + } ++ // One scope's MEMORY.md, empty when its folder is not read. ++ const readMainOf = (scope) => { ++ const dir = scopeDir(scope) ++ return dir === undefined ? '' : readText(join(dir, MAIN)) ++ } + const limitOf = file => (file.topic === undefined ? MEMORY_LIMITS.main : MEMORY_LIMITS.topic) + const fileName = file => (file.topic === undefined ? MAIN : `topic [[${file.topic}]]`) + @@ -486,7 +486,24 @@ export function install(rt) { // Main Bots and the admins of a group also write the team's; Main Bots read and // write every Bot's. The user may do everything from the memory panel. @@ -5038,6 +5108,17 @@ diff --git a/src/host/memory.js b/src/host/memory.js const dir = dirOf(ownScope(botId)) if (!existsSync(dir)) return const name = `${safeId(botId)}-${new Date().toISOString().replace(/[:.]/g, '-')}` +@@ -655,8 +655,8 @@ export function install(rt) { + } + function memorySection(bot) { + const own = ownScope(bot.id) +- const main = parseMemory(readText(join(dirOf(own), MAIN))) +- const team = parseMemory(readText(join(dirOf(TEAM), MAIN))) ++ const main = parseMemory(readMainOf(own)) ++ const team = parseMemory(readMainOf(TEAM)) + return [ + '# Your memory', + memoryRules(reviewOn), @@ -837,11 +837,13 @@ export function install(rt) { ctx.tools.register(defineTool({ @@ -5313,7 +5394,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js + return { + // The head is the dossier's text, so its bound lands on a code point (#2005): a `slice` + // here handed the panel the first half of an emoji, and no later bound can put it back. -+ head: cut(readText(join(dirOf(scope), MAIN)), head).text, ++ head: cut(readMainOf(scope), head).text, + topics: topicNames(scope).slice(0, topics), + } + } @@ -10905,6 +10986,13 @@ diff --git a/test/member-status.test.js b/test/member-status.test.js diff --git a/test/memory.test.js b/test/memory.test.js --- a/test/memory.test.js +++ b/test/memory.test.js +@@ -1,5 +1,5 @@ + import assert from 'node:assert/strict' +-import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' ++import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' + import { hostname, tmpdir } from 'node:os' + import { join } from 'node:path' + import { test } from 'node:test' @@ -176,6 +176,15 @@ function start({ home = mkdtempSync(join(tmpdir(), 'ds-bot-memory-')), replies = } @@ -11137,7 +11225,7 @@ diff --git a/test/memory.test.js b/test/memory.test.js const view = (await call('memory-view', { botId: 'b2' })).value assert.equal(view.error, null) assert.equal(view.summary?.text, 'You like tea.') -@@ -1076,3 +1076,235 @@ test('a memory call names the current part of the Bot chat', async (t) => { +@@ -1076,3 +1076,298 @@ test('a memory call names the current part of the Bot chat', async (t) => { assert.equal(answer.ok, true) assert.equal(requests[0].sessionId, 'b2-part2') }) @@ -11373,6 +11461,69 @@ diff --git a/test/memory.test.js b/test/memory.test.js + assert.equal(sent.isWellFormed(), true, `the ask prompt keeps half an emoji: ${kept(sent)}`) + assert.ok(sent.endsWith(`${'q'.repeat(1999)}`), 'the prompt keeps the bound and drops the emoji whole') +}) ++ ++// --------------------------------------------------------------------------- ++// #2057: a linked folder is not this team's memory. ++ ++const captureWarnings = (t) => { ++ const lines = [] ++ const original = console.error ++ console.error = (...args) => { lines.push(args.join(' ')) } ++ t.after(() => { console.error = original }) ++ return () => lines.filter(line => line.includes('memory folder is not a plain directory')) ++} ++ ++test('#2057: a team memory folder that is a symlink reads nothing, and says so once', async (t) => { ++ const home = mkdtempSync(join(tmpdir(), 'ds-bot-memory-')) ++ const outside = mkdtempSync(join(tmpdir(), 'ds-bot-memory-outside-')) ++ writeFileSync(join(outside, 'x.md'), '# x\n\n- an entry from outside the home\n') ++ mkdirSync(join(home, 'memory'), { recursive: true }) ++ symlinkSync(outside, join(home, 'memory', 'team'), 'dir') ++ t.after(() => rmSync(outside, { recursive: true, force: true })) ++ const folderWarnings = captureWarnings(t) ++ const { section, call, stop } = start({ home }) ++ t.after(() => stop()) ++ // Three reads: the prompt section is what carries memory into every request. ++ for (let round = 0; round < 3; round++) { ++ const text = section('b2') ++ assert.doesNotMatch(text, /an entry from outside the home/) ++ assert.doesNotMatch(text, /\[\[x\]\]/) ++ const view = (await call('memory-view', { botId: 'b2' })).value ++ assert.deepEqual(view.entries.filter(entry => entry.scope === 'team'), []) ++ assert.equal(view.sizes.team, 0) ++ } ++ const warned = folderWarnings() ++ assert.equal(warned.length, 1, 'one warning per folder per process, not one per read') ++ assert.ok(warned[0].includes(join(home, 'memory', 'team')), `the warning names the folder: ${warned[0]}`) ++ assert.match(warned[0], /keeping it empty$/) ++}) ++ ++test('#2057: a memory folder that was never created reads as empty and says nothing', async (t) => { ++ const folderWarnings = captureWarnings(t) ++ const { section, call, stop, home } = start() ++ t.after(() => stop()) ++ assert.equal(existsSync(join(home, 'memory')), false) ++ assert.doesNotMatch(section('b2'), /an entry from outside/) ++ const view = (await call('memory-view', { botId: 'b2' })).value ++ assert.deepEqual(view.entries, []) ++ assert.deepEqual(folderWarnings(), []) ++}) ++ ++test('#2057: a real team memory folder keeps listing and reading, with no warning', async (t) => { ++ const home = mkdtempSync(join(tmpdir(), 'ds-bot-memory-')) ++ mkdirSync(join(home, 'memory', 'team'), { recursive: true }) ++ writeFileSync(join(home, 'memory', 'team', 'MEMORY.md'), '# Memory\n\n- Ships on Friday [added: 2026-11-01]\n') ++ writeFileSync(join(home, 'memory', 'team', 'travel.md'), '# travel\n\n- Flies from Hangzhou\n') ++ const folderWarnings = captureWarnings(t) ++ const { section, call, stop } = start({ home }) ++ t.after(() => stop()) ++ const view = (await call('memory-view', { botId: 'b2' })).value ++ const text = section('b2') ++ assert.match(text, /- Ships on Friday/) ++ assert.match(text, /Topics: \[\[travel\]\]/) ++ assert.deepEqual(view.entries.filter(entry => entry.scope === 'team').map(entry => entry.text), ['Ships on Friday', 'Flies from Hangzhou']) ++ assert.deepEqual(folderWarnings(), []) ++}) diff --git a/test/model-menu.test.js b/test/model-menu.test.js --- a/test/model-menu.test.js +++ b/test/model-menu.test.js diff --git a/patches/ds-bot/files/src/host/workflow-store.js b/patches/ds-bot/files/src/host/workflow-store.js index a50095b17..16014e67b 100644 --- a/patches/ds-bot/files/src/host/workflow-store.js +++ b/patches/ds-bot/files/src/host/workflow-store.js @@ -20,6 +20,7 @@ import { copyOfPreset, isPresetId, isWorkflowId, workflowBytes, WORKFLOW_DIR } f import { previewNode } from '../shared/workflow-preview.js' import { repoDevWorkflow } from '../shared/workflow-repo-dev.js' import { blankWorkflow } from '../shared/workflow-blank.js' +import { dirKindSync } from './real-dir.js' // The shipped presets, as definitions. `PRESET_IDS` in `workflow-doc.js` names the same two; a test // asserts they cannot drift apart. @@ -36,21 +37,32 @@ const summary = (definition, source) => ({ columns: normaliseWorkflow(definition).nodes.filter((node) => node.column !== false).length, }) -// A directory may not exist yet (a workspace nobody has customized anything in), and a file in it -// may be someone's hand-edit that does not parse: one broken file must not take the list down. -const ownFiles = (dir) => { - try { - return readdirSync(dir).filter((name) => name.endsWith('.json') && name !== PROJECT_FILE).sort() - } catch { - return [] - } -} - -export function createWorkflowStore({ dir, presets = PRESETS, team = () => null, factsFor = () => ({ ok: false, why: 'this host has no fact reader' }), warn = () => {} } = {}) { +export function createWorkflowStore({ dir, presets = PRESETS, team = () => null, factsFor = () => ({ ok: false, why: 'this host has no fact reader' }), warn = () => {}, warnFolder = () => {} } = {}) { if (typeof dir !== 'string' || dir === '') throw new Error('a workflow store needs a directory') const fileFor = (id) => join(dir, `${id}.json`) + // `readdir` and `readFile` follow a directory link (#2057), so a `workflows` that is a link would + // list and read another folder's definitions as this workspace's own. Only a real folder is read; + // a missing or linked one answers what a missing folder answers today. The warning is what tells + // the two apart in the page, and the caller says it once per folder rather than once per read. + const folderReal = () => { + const kind = dirKindSync(dir) + if (kind !== 'real') warnFolder(dir, kind) + return kind === 'real' + } + // A directory may not exist yet (a workspace nobody has customized anything in), and a file in it + // may be someone's hand-edit that does not parse: one broken file must not take the list down. + const ownFiles = () => { + if (!folderReal()) return [] + try { + return readdirSync(dir).filter((name) => name.endsWith('.json') && name !== PROJECT_FILE).sort() + } catch { + return [] + } + } + const readOwn = (id) => { + if (!folderReal()) return null try { return normaliseWorkflow(JSON.parse(readFileSync(fileFor(id), 'utf8'))) } catch { @@ -66,6 +78,7 @@ export function createWorkflowStore({ dir, presets = PRESETS, team = () => null, } const projectDefault = () => { + if (!folderReal()) return null try { const value = JSON.parse(readFileSync(join(dir, PROJECT_FILE), 'utf8')) return typeof value?.default === 'string' && value.default !== '' ? value.default : null @@ -75,7 +88,7 @@ export function createWorkflowStore({ dir, presets = PRESETS, team = () => null, } const list = () => { - const own = ownFiles(dir).flatMap((name) => { + const own = ownFiles().flatMap((name) => { const definition = readOwn(name.slice(0, -'.json'.length)) if (definition === null || definition.id === '') { warn('ds-bot: workflow store: %s in %s does not parse as a definition; skipped', name, dir) @@ -175,7 +188,7 @@ export function createWorkflowStore({ dir, presets = PRESETS, team = () => null, // The definitions in play, for the board's columns. Presets always; a workspace file is read on // every call so a save is visible to the next board read without a restart. definitions: () => { - const own = ownFiles(dir).map((name) => readOwn(name.slice(0, -'.json'.length))).filter((definition) => definition !== null && definition.id !== '') + const own = ownFiles().map((name) => readOwn(name.slice(0, -'.json'.length))).filter((definition) => definition !== null && definition.id !== '') return [...presets.map(normaliseWorkflow), ...own] }, bytesOf: (id) => { @@ -206,10 +219,16 @@ export function install(rt) { return null } } + const warnedFolders = new Set() + const warnFolder = (dir, kind) => { + if (kind !== 'other' || warnedFolders.has(dir)) return + warnedFolders.add(dir) + rt.warn?.('ds-bot: workflow folder is not a plain directory %s; keeping it empty', dir) + } const store = () => { const dir = dirFor() if (dir === null) return null - if (rt.workflowStore?.dir !== dir) rt.workflowStore = createWorkflowStore({ dir, team, factsFor: rt.workflowFactsFor ?? (() => ({ ok: false, why: 'this host has no fact reader' })) }) + if (rt.workflowStore?.dir !== dir) rt.workflowStore = createWorkflowStore({ dir, team, warn: rt.warn, warnFolder, factsFor: rt.workflowFactsFor ?? (() => ({ ok: false, why: 'this host has no fact reader' })) }) return rt.workflowStore } rt.workflowEndpoint = async (endpoint, body = {}) => { diff --git a/patches/ds-bot/files/test/workflow-store.test.js b/patches/ds-bot/files/test/workflow-store.test.js index 7669f3ae4..4e55d765a 100644 --- a/patches/ds-bot/files/test/workflow-store.test.js +++ b/patches/ds-bot/files/test/workflow-store.test.js @@ -3,7 +3,7 @@ // actor (acceptance 6 and 7). import assert from 'node:assert/strict' import { createHash } from 'node:crypto' -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' @@ -151,3 +151,86 @@ test('a workspace file that does not parse is skipped instead of breaking the li assert.deepEqual(listed.own.map((entry) => entry.id), ['blank-mine']) }) }) + +// --------------------------------------------------------------------------- +// #2057: a linked folder is not this workspace's workflows. + +const elsewhere = (t, files) => { + const root = mkdtempSync(join(tmpdir(), 'flybot-workflows-outside-')) + for (const [name, text] of Object.entries(files)) writeFileSync(join(root, name), text) + t.after(() => rmSync(root, { recursive: true, force: true })) + return root +} + +test('#2057: a workflow folder that is a symlink lists nothing and never reads the outside file', (t) => { + const root = mkdtempSync(join(tmpdir(), 'flybot-workflows-')) + t.after(() => rmSync(root, { recursive: true, force: true })) + const dir = join(root, 'workflows') + const outside = elsewhere(t, { 'other.json': JSON.stringify({ ...blankWorkflow, id: 'other', name: 'Other' }) }) + symlinkSync(outside, dir, 'dir') + const store = createWorkflowStore({ dir }) + const listed = store.list() + assert.deepEqual(listed.own, []) + assert.equal(listed.default, null) + const opened = store.open('other') + assert.equal(opened.ok, false) + assert.equal(opened.code, 'workflow/not-found') + assert.equal(JSON.stringify(opened).includes('Other'), false, 'the outside definition never comes back') + assert.deepEqual(store.definitions().map((definition) => definition.id).sort(), PRESETS.map((preset) => preset.id).sort()) +}) + +test('#2057: a workflow folder that is a plain file lists nothing and does not throw', (t) => { + const root = mkdtempSync(join(tmpdir(), 'flybot-workflows-')) + t.after(() => rmSync(root, { recursive: true, force: true })) + const dir = join(root, 'workflows') + writeFileSync(dir, 'not a folder') + const store = createWorkflowStore({ dir }) + assert.deepEqual(store.list().own, []) + assert.deepEqual(store.definitions().map((definition) => definition.id).sort(), PRESETS.map((preset) => preset.id).sort()) +}) + +test('#2057: a linked workflow folder warns once, a missing or real one never warns', async (t) => { + const warned = [] + const host = (root) => { + const rt = { + state: { bots: {} }, + ctx: { workspaceRegistry: { get: () => ({ path: root }) } }, + warn: (template, ...args) => warned.push(String(template).replace(/%s/g, () => String(args.shift() ?? ''))), + } + install(rt) + return rt + } + const folderWarnings = () => warned.filter((line) => line.includes('workflow folder is not a plain directory')) + + // A link to a folder outside the workspace: every read says "nothing", and the folder is named once. + const linkedRoot = mkdtempSync(join(tmpdir(), 'flybot-workflows-')) + t.after(() => rmSync(linkedRoot, { recursive: true, force: true })) + const outside = elsewhere(t, { 'other.json': JSON.stringify({ ...blankWorkflow, id: 'other', name: 'Other' }) }) + symlinkSync(outside, join(linkedRoot, 'workflows'), 'dir') + const linked = host(linkedRoot) + for (let round = 0; round < 3; round++) { + const listed = await linked.workflowEndpoint('workflows', { op: 'list' }) + assert.deepEqual(listed.own, []) + linked.workflowDefinitions() + } + const said = folderWarnings() + assert.equal(said.length, 1, 'one warning per folder per process, not one per read') + assert.ok(said[0].includes(join(linkedRoot, 'workflows')), `the warning names the folder: ${said[0]}`) + assert.match(said[0], /keeping it empty$/) + + // A workspace nobody has saved a workflow in: empty, silent. + const freshRoot = mkdtempSync(join(tmpdir(), 'flybot-workflows-')) + t.after(() => rmSync(freshRoot, { recursive: true, force: true })) + const fresh = host(freshRoot) + assert.deepEqual((await fresh.workflowEndpoint('workflows', { op: 'list' })).own, []) + assert.deepEqual(folderWarnings(), said) + + // A real folder: listed, silent. + const realRoot = mkdtempSync(join(tmpdir(), 'flybot-workflows-')) + t.after(() => rmSync(realRoot, { recursive: true, force: true })) + mkdirSync(join(realRoot, 'workflows'), { recursive: true }) + writeFileSync(join(realRoot, 'workflows', 'mine.json'), JSON.stringify({ ...blankWorkflow, id: 'mine', name: 'Mine' })) + const real = host(realRoot) + assert.deepEqual((await real.workflowEndpoint('workflows', { op: 'list' })).own.map((entry) => entry.id), ['mine']) + assert.deepEqual(folderWarnings(), said) +})