From cbf57160f59c76035ddbb66eb18b802be2bc575f Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Tue, 10 Nov 2026 08:28:06 +0800 Subject: [PATCH] host: repair a null saved record instead of failing every load A `state.json` record saved as null (a truncated write, a hand edit, an older build) threw inside adopt(): `room.members` on a null room, `'fields' in bot` on a null Bot. `loaded ??=` then cached the rejection, so the team never appeared and readOnly() had no answer for it (#2755). Copy the file as it was to `state.json.broken-` and name state.json in one warning, then fold the record: a room is kept with an empty member list, an unusable Bot record is dropped. A version-1 home still keeps its .v1 backup and migrates, and a read-only pass writes no state.json. --- changelog.d/2755-state-null-record.fixed.md | 1 + core/src/host/store.js | 39 +++++++++++- core/test/store.test.js | 66 ++++++++++++++++++++ docs/subsystems/core-host.md | 2 +- patches/ds-bot/0002-flybot-api-host.patch | 67 +++++++++++++++++--- patches/ds-bot/0014-flybot-branding.patch | 68 ++++++++++++++++++++- 6 files changed, 230 insertions(+), 13 deletions(-) create mode 100644 changelog.d/2755-state-null-record.fixed.md diff --git a/changelog.d/2755-state-null-record.fixed.md b/changelog.d/2755-state-null-record.fixed.md new file mode 100644 index 000000000..203c4cb2d --- /dev/null +++ b/changelog.d/2755-state-null-record.fixed.md @@ -0,0 +1 @@ +A `state.json` whose saved room or Bot record is `null` no longer hides the whole team: the file as it was is kept as a `state.json.broken-` copy, one warning names `state.json`, the room is folded to an empty member list (an unusable Bot record is dropped), and the repaired team loads and saves. A version-1 file still keeps its `.v1` backup, and a read-only window records the evidence but never writes `state.json`. See [#2755](https://github.com/ThinkFlowLab/FlyBot/issues/2755). diff --git a/core/src/host/store.js b/core/src/host/store.js index c93070bdb..8e9335ceb 100644 --- a/core/src/host/store.js +++ b/core/src/host/store.js @@ -84,6 +84,8 @@ export function install(rt) { let stopped = false // mtime and size of the state.json this host last read or wrote. let seen = '' + // A broken saved record was folded on the last read, so that read owes the disk a write. + let brokenRepaired = false const stamp = () => { try { const { mtimeMs, size } = statSync(statePath) @@ -221,6 +223,40 @@ export function install(rt) { } return undefined } + // A saved record that is null (or a scalar) is a broken record, not a crash: a truncated write, + // a hand edit, a restore from another machine or an older build leaves `rooms: { r1: null }` or + // `bots: { chief: null }`. The record used to throw inside adopt (`room.members` on null, + // `'fields' in bot`), `loaded ??=` then cached the rejection and the team never appeared (#2755). + // Keep the file exactly as it was, then fold the record: a room stays with an empty member list, + // a Bot record is dropped because no data is left to keep. Both are named in one warning that + // names state.json, so "the team is gone" has a line to find. + if (parsed?.version === 1 || parsed?.version === STATE_VERSION) { + const broken = [] + for (const key of ['bots', 'rooms']) { + if (!isRecord(parsed[key])) continue + for (const [id, record] of Object.entries(parsed[key])) if (!isRecord(record)) broken.push(`${key}.${id}`) + } + if (broken.length) { + const backup = `${statePath}.broken-${Date.now()}` + try { + await copyFile(statePath, backup) + } catch (copyError) { + stuck = `a broken record (${broken.join(', ')}) could not be preserved before repairing it (${copyError?.code ?? copyError?.message ?? copyError})` + warn('ds-bot: state %s has a broken record (%s) and the copy to %s failed: %s; the file is left untouched', statePath, broken.join(', '), backup, copyError) + return undefined + } + warn('ds-bot: state %s has a broken record (%s); the file as it was is kept as %s and the record is repaired', statePath, broken.join(', '), backup) + for (const key of ['bots', 'rooms']) { + if (!isRecord(parsed[key])) continue + for (const [id, record] of Object.entries(parsed[key])) { + if (isRecord(record)) continue + if (key === 'rooms') parsed[key][id] = {} + else delete parsed[key][id] + } + } + brokenRepaired = true + } + } if (parsed?.version === 1) { const problem = legacyOwnershipProblem(parsed) if (problem !== undefined) { @@ -266,7 +302,8 @@ export function install(rt) { state.prefs ??= {} // Teams saved before the DSH Agent existed carry no `agents`; corrupt values reset. if (state.agents === null || typeof state.agents !== 'object' || Array.isArray(state.agents)) state.agents = {} - let migrated = !hadCapabilities + let migrated = !hadCapabilities || brokenRepaired + brokenRepaired = false // Everything above is the file's value, not EMPTY's, and a hand edit, a restore from another // machine or a hand-merged file can bring `null` or a scalar where the schema holds a table. // Those are repaired to their empty shape before anything reads them, because the alternative diff --git a/core/test/store.test.js b/core/test/store.test.js index aca22f4a9..77e09594c 100644 --- a/core/test/store.test.js +++ b/core/test/store.test.js @@ -701,3 +701,69 @@ test('a zero-byte or truncated team file is backed up and loads fresh, never ref assert.equal(fs.readdirSync(home).filter(name => name.startsWith('state.json.broken-')).length, 1, label) } }) + +// #2755: a saved record that is null (a truncated write, a hand edit, an older build) used to throw +// out of adopt(), and `loaded ??=` cached the rejection: the team never appeared, `readOnly()` had no +// answer and nothing named state.json. The record is evidence-backed up and folded instead. +test('a room record that is null is backed up and folded, and the team still appears (#2755)', async (t) => { + const { rt, home, statePath, warnings } = runtime(t, team({ r1: null })) + await rt.load() + assert.equal(rt.readOnly(), undefined, 'the file loads as a writable team instead of a null answer') + assert.deepEqual(rt.state.rooms.r1.members, [], 'the room is kept and folded to an empty member list') + + const backups = fs.readdirSync(home).filter(name => name.startsWith('state.json.broken-')) + assert.equal(backups.length, 1, 'the pre-repair file is kept as one .broken copy') + const kept = JSON.parse(fs.readFileSync(join(home, backups[0]), 'utf8')) + assert.equal(kept.rooms.r1, null, 'the dropped value is preserved verbatim in the evidence copy') + + const written = JSON.parse(fs.readFileSync(statePath, 'utf8')) + assert.equal(written.version, 2, 'the repair is written back as version 2') + assert.deepEqual(written.rooms.r1.members, [], 'the saved room carries the repaired list') + assert.equal(warnings.some(line => line.join(' ').includes('state.json')), true, 'a warning names state.json') +}) + +test('a version-1 Bot record that is null migrates, keeps .v1 and drops only that record (#2755)', async (t) => { + const { rt, home, statePath, warnings } = runtime(t, { + version: 1, + revision: 1, + bots: { chief: null, kept: { id: 'kept', name: 'Keeper', createdAt: 1 } }, + }) + await rt.load() + assert.equal(rt.readOnly(), undefined, 'the team is not refused read-only') + assert.equal(rt.state.version, 2, 'the version-1 team is migrated') + assert.deepEqual(Object.keys(rt.state.bots), ['kept'], 'only the broken record is dropped') + + const v1 = JSON.parse(fs.readFileSync(`${statePath}.v1`, 'utf8')) + assert.equal(v1.version, 1, 'the version-1 backup is kept') + assert.equal(v1.bots.chief, null, 'the backup is the file as it was, broken record included') + const backups = fs.readdirSync(home).filter(name => name.startsWith('state.json.broken-')) + assert.equal(backups.length, 1) + assert.equal(JSON.parse(fs.readFileSync(join(home, backups[0]), 'utf8')).bots.chief, null) + + const written = JSON.parse(fs.readFileSync(statePath, 'utf8')) + assert.equal(written.version, 2) + assert.equal(Object.hasOwn(written.bots, 'chief'), false, 'the repaired file carries no null Bot record') + assert.equal(warnings.some(line => line.join(' ').includes('state.json')), true, 'a warning names state.json') +}) + +test('a version-1 team with a null record is not written back in a read-only pass (#2755)', async (t) => { + const home = fs.mkdtempSync(join(tmpdir(), 'ds-bot-store-')) + t.after(() => fs.rmSync(home, { recursive: true, force: true, maxRetries: 5 })) + const original = JSON.stringify({ version: 1, revision: 1, bots: { chief: null, kept: { id: 'kept', name: 'Keeper', createdAt: 1 } } }) + fs.writeFileSync(join(home, 'state.lock'), JSON.stringify({ pid: process.ppid, host: hostname(), token: 'other' })) + fs.writeFileSync(join(home, 'state.json'), original) + + const { rt } = runtime(t, undefined, { home }) + await rt.load() + assert.match(rt.readOnly(), /another dsh/, 'the window is told why it may not write') + assert.equal(fs.readFileSync(join(home, 'state.json'), 'utf8'), original, 'a read-only pass writes no state.json') + assert.equal(JSON.parse(fs.readFileSync(join(home, 'state.json.v1'), 'utf8')).version, 1, '.v1 is still kept') +}) + +test('a healthy team loads with no broken-record warning and no .broken copy (#2755)', async (t) => { + const { rt, home, warnings } = runtime(t, team({ r1: { id: 'r1', name: 'Ops', members: ['a'], mode: 'admin', admin: 'a', named: true, sessions: {}, log: [], seq: 0, seen: {} } })) + await rt.load() + assert.equal(rt.readOnly(), undefined) + assert.deepEqual(fs.readdirSync(home).filter(name => name.startsWith('state.json.broken-')), [], 'no evidence copy for a healthy file') + assert.equal(warnings.some(line => line.join(' ').includes('broken record')), false, 'no warning for a healthy file') +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 655bb1dde..16c56a982 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -238,7 +238,7 @@ The host's own files carry the user's content — a Bot's memory and the memory ### Team state and access -- The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; an existing backup counts only when it is a readable file holding a parseable version-1 state, and anything else (or any malformed ownership) fails closed (read-only). An older binary refuses a newer version read-only. A file that parses but carries no `version` — `{}`, `null`, `[]`, an empty string — is not a newer one: it is refused read-only as `the team file has no version — it is incomplete (an empty or half-written file)`, so the person is pointed at the empty or half-written file instead of at an update they do not need ([#2716](https://github.com/ThinkFlowLab/FlyBot/issues/2716)); the `saved by a newer FlyBot; update FlyBot` sentence stays for a `version` above `STATE_VERSION`, and a 0-byte or truncated file never reaches either branch — it fails to parse, is copied to `state.json.broken-` and loads fresh and writable, as before. This is schema foundation only — team mode stays disabled, see [team](team.md). A room record is repaired on load: a missing or non-array `members` becomes `[]` and the room itself is kept, so one hand-edited, restored or half-migrated record can no longer make `adopt()` throw — which used to reject `load()`, be cached by `loaded ??=`, and hide the whole team for the life of the process (#2714). Every other table `adopt()` copies in from the file is repaired the same way, because `Object.assign(state, JSON.parse(EMPTY), parsed ?? {})` takes the file's value and not EMPTY's: `exchanges` becomes `[]` and `exchangeTurns`, `questions`, `rooms`, `bots`, `prefs` and `agents` become `{}` when they arrive as `null` or a scalar. `exchanges` was the costly one (#2715): `save()` slices it on every write, so a `null` list loaded clean, `readOnly()` answered `writable`, and then creating a Bot, renaming a room, a group message, a memory write and a delivery (`delivery.js`'s `state.exchanges.push(…)`) all threw from inside `save()` with a stack that never named the file; the other tables threw at their own first write instead (`state.exchangeTurns[id] ??= []` in `turns.js`, a group question in `tools.js`). Each repair sets `migrated`, so the same `load()` writes the repaired file rather than leaving the next start to find it again. A version-2 file that carries `bots` as anything but a record is still refused read-only by `ownershipProblem()` before `adopt()` sees it — a roster is not repaired away, only a table that carried no record is. Tests: `core/test/store.test.js`. On top of it, a durable **Project** record (#1691): `state.projects` holds `{id, name, repository, defaultBranch, ownerUserId, createdAt, updatedAt, status}`, `bot.projectId` / `room.projectId` and `state.projectRuns[runId]` bind a member (a group, a Bot or a workflow run) to at most one Project, and the `api/bot` endpoints `projects`, `project-create`, `project-read`, `project-bind`, `project-of` and `project-board` sit in the task allow list. An unbound member resolves `null`, so an install with zero Projects behaves exactly as before. Since #2638 every text field a Project stores goes through `cut()` (`core/src/shared/text-boundary.js`) instead of `String#slice`: the bound counts UTF-16 units as before, but the kept prefix stops on a code-point boundary, so a name whose last character is an emoji at the 120 bound is stored whole or dropped whole rather than as half a surrogate pair that every UTF-8 write reads as U+FFFD. The read-only board over those bindings is `project-board` (#1696): Project id in, `{ tasks: [...] }` out, one row per core task id with `source` (`bot`/`group`/`workflow`), `column`, `assignee`, `updatedAt` and `runId`/`nodeId` when a run holds it, plus the card's own `title` (`null` when the record has none), `priority` (`priorityOf`, `'normal'` when the record stored none) and `board` (the `{ kind, id }` the row opens, `null` on a `workflow` row) so a row draws a card and opens the board it lives on: the open `tasks` read a page joins by id keeps open cards only, so a finished card in the `done` half has no record there and would otherwise draw as `#` with no priority and nowhere to go. `core/src/host/project-board.js` reads `state.tasks` and the bindings and asks the registered providers through `rt.projectRunRefs` (`flybot.tasks.onProjectProvider`); it never reads `workflows.json` and writes nothing. Since #2021 the board's record halves say what their caps left out and cut the right end of the list: the decision read answers `total` — the files left after the one-row-per-path dedup, before the cut — and takes `.slice(-DECISIONS_MAX)` over the still-ascending sort, so a Project past 200 decisions keeps the newest instead of dropping the ones just written and the order on screen does not change, while the meetings read answers `decidedTotal`/`pendingTotal` per list beside the 100 rows it already kept newest; a list at or under its cap answers the same rows as before, plus the count ([#2021](https://github.com/ThinkFlowLab/FlyBot/issues/2021)). Since #2033 the lookup a decided meeting uses for its own file falls back to the un-truncated dedupe set of that same read when the name is not among the kept rows, so a file that fell off the oldest end of the cut is still named by the meeting (`path` set, and no `sha256`, because only the kept rows carry one) while a name the folder no longer holds still answers `path: ''`; the fallback reads names from that dedupe set and never asks the filesystem, so a crafted `../outside.md` yields no path either ([#2033](https://github.com/ThinkFlowLab/FlyBot/issues/2033)). Since #2037 the name is also checked where the listing is produced, by the same `isDecisionPath` the reader uses: a name with a `..` segment, an absolute path, a symlink standing in for the file, or a name this read never listed yields no row and no meeting `path` (and no `sha256`), so the fallback past the cut cannot bring one back — `archivedOf` reads through `describeFile`, the reader's own `lstat`, never `stat` ([#2037](https://github.com/ThinkFlowLab/FlyBot/issues/2037)). Since #2041 a decision row can be opened: `rt.projectDecision(projectId, name)` reads one name through `readListedFile` and answers the reader's own three states (`text`, `absent`, `too large`), but only for a name the last `projectBoard` read of that Project produced — that read's un-truncated dedupe set is kept per Project, each name with the folder it was read from, so a name past the 200-row cut still opens while `decisions/../outside.md`, an absolute path, a name one level down or a symlink answers `not listed` without a `stat`, an open or a byte of content; the endpoint is `project-decision`, dispatched in `channel.js` beside `project-board` and listed in `authz.js`'s `TASK_ENDPOINTS` ([#2041](https://github.com/ThinkFlowLab/FlyBot/issues/2041)). A decided meeting that saved no folder keeps the cards it decided: its file is one of the Project folder's own rows, and binding those cards to that row is what keeps the name behind the same door as the cards, while a meeting that decided no card still answers through the role list ([#2052](https://github.com/ThinkFlowLab/FlyBot/issues/2052)). A record that named a folder whose file this read could not open is not that folder-less shape: it binds no cards, so the same relative name in the Project's own folder answers through the role list instead of inheriting the cards of a file in another folder. Since [#2029](https://github.com/ThinkFlowLab/FlyBot/issues/2029) a blocked card also carries the age of its own question: `blockedOf(task, now)` answers `since` (the `inputRequest.requestedAt` stamp `tasks.js` wrote, preserved across a re-ask) and `waitedMs` (`now - since`, the same shape `waitingOf` answers), both `null` when the ask wrote no stamp — never derived from `createdAt`/`updatedAt` — and `buildQueues` sorts `blocked` oldest-first with the comparator `waiting.sort` already uses, so an undated card sorts last; the Waiting group's own output is unchanged. Tests: `core/test/project-board.test.js`. On top of the record and the board sits the **Project Assistant** (#1712, Stage 2.5): `project-delete` removes the Project, its bindings and its memory; the assistant's digest is one stored note plus one `deliver()` to the owner, idempotent per Asia/Shanghai day, and its identity is the connector account and never the shared app — no connector signed in, or a multi-member host whose Project owner is not this host's own user, refuses and escalates with nothing written. Per-owner memory (#1733) keys those three fields by `(Project, owner)` under `projectMemory[id].owners`, mirrors the primary owner onto the row itself so a single-owner Project reads as before, and migrates a pre-#1733 record onto its primary owner on the first write instead of replacing it; the boundary a memory read is checked against is the Project's own owner list, so an owner asking for another owner's entries is refused naming that list, while the assistant's own render walks every owner. A card needs an owner's decision when the Bot that owns the card belongs to that owner, never from the board's `assignee` (a Bot name or null); the ask names the card's own title, at most `MAX_ACTIONABLE` (5) per owner. A quiet window on the assistant record holds a due digest and releases it once, labelled, when the window ends; a window covering 09:00 delivers nothing that day; an owner's opt-out stops generation and pauses the shared 09:00 task once every owner is off, and a re-enable resumes it; delivery goes to each owner's own Session and falls back to the main Bot only for the primary owner. A held digest is released by the next `digest()` call, not by the 09:00 task, which does not make one after a window ends. On top of it, member auth (#48) adds host module `src/host/member-auth.js`, installed after `store` in `MODULES` (`core/index.js`): `invitations` and `authSessions` maps live on state (no `STATE_VERSION` bump), the channel resolves the actor from the `x-flybot-member-session` header, and endpoints are `auth-invite` / `auth-redeem` / `auth-revoke` / `auth-whoami` / `auth-remove-member` / `auth-revoke-invite` with `body.actorId` ignored; `auth-invite` alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), `auth-revoke-invite` kills an unredeemed invitation (redeeming it afterwards fails with `revoked`), `removeMember` refuses a member that still owns a Bot (`has_bots`), and the auth mutations are serialized behind an in-process lock so concurrent `removeMember` calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in [team](team.md). On top of it, owner-scoped access (#49) adds host module `src/host/authz.js`, installed after member-auth: when any `authSessions` exist, the channel `authorize()` default-denies cross-owner Bot/room/session access, blocks shared non-isolatable resources (secrets, connectors, folder picks, browser bridge, update-install, diagnostics, set-prefs, `add-agent` — the DSH Agent is one per deployment and carries no owner) with `bot/blocked-shared` when more than one member exists, stamps `create-bot` with `actor.memberId`, and filters `view()`/`live()` to the actor; with no sessions the single-human behavior is unchanged. Session targets resolve through the store's owner maps — a Bot id via `botOf`, current chats and group Sessions and earlier parts via `selfOf`/`groupOwners`/`pastOwners` (a shared room Session also resolves for its current participants, #50) — and unresolvable ids deny. `approval-answer` gates on the approval's own Bot or room (resolved by approval id), never on caller-supplied session fields. `flybot/*` plugin endpoints (src/host/hooks.js) reach any signed-in actor for reads — except a Bot Social read naming a `body.botId` the actor does not own, which `bot/forbidden` refuses before the plugin handler registers that Bot's identity or syncs its grants (#1788, an owner-decided tightening); plugin writes follow the shared rule. `view()`/`live()` narrowing (`rt.ownedView`/`rt.ownedLive` in authz.js) keeps per-session entries (`questions`, `exchangeTurns`) the actor owns, and deployment-wide surfaces (agents, secrets, connectors, memory reviews) only while a single member exists — the same rule as the shared writes; rooms the actor belongs to and the Bots in them (#50) stay visible. `create-room` refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module `src/host/room-membership.js`, installed after groups: a room may carry typed `participants` (`human` / local `bot` / granted remote `bot` with `handle` + owner fields, roles `admin` / `member`, `joinedSeq` / `leftSeq`); log lines carry `authorId` + `authorKind`; history is the membership window (current members and the owner of an invited Bot see `seq >=` their interval; leave withdraws access; rejoin starts a new interval); local cross-owner rooms accept each side's *granted* Bots (#90: default deny, Chief included); a friend's Bot from `social/remote-bots` (`cleanRemote`) may be added and ungranted Bots stay private, and a room with a friend's Bot holds only granted local Bots (a Bot that joined by room code, `viaCode`, only for that room); `view()` labels remotes with an owner badge and shows an invited owner **Shared room from \**. Face-to-face rooms (#83) reuse the same `participants` / membership-window / owner-badge presentation with `origin: 'face-to-face'`: room-only remotes may sit in the room without a person-grant or a friendship, `presentRoom` treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and `fanoutRoomLine` goes to `rt.faceRoomFanout` instead of friend `flybotSend`. Still not team mode — see [team](team.md). Per-Bot grants (#90) add host module `src/host/reach.js`, installed after room-membership: `state.grants[botId]` is the list of independent grants (one Bot → one grantee, optionally room-scoped); default deny, Chief included; a Bot whose owner is not a member has none. Grants are set with `rt.setGrants`, exposed as `BotView.granted`, `flybot.bot.hasGrant` / `hasAnyGrant`, `flybot.grants` and the `bot.grants` event. `reach()` is the owner policy: a friend's Bot may reach only local Bots granted to that sender, only a granted Bot (or one code-joined in that room) may message a friend's Bot, another owner's Bot is reached only if both sides have matching grants or both are members of the room the message belongs to. It is not a middleware: hooks.js runs it before the `bot.send` chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or `bypass` can skip it and a queued message is judged by what is true when it is delivered. Revoking a grant, or replacing its Chief, removes it from rooms that have friends' Bots or several owners at once (`rt.evictUnshared`). Approval binding (#52) completes the held-send half of this: `state.flybot.approvals` records carry `requestId`, a sha256 `version` over the request content, server-derived `ownerId` and `origin` (`via`/`hop`/audience) — claims in the message (`meta`, text) never confer authority. `flybot/decide` is allowed through authz for any signed-in actor and the per-record check happens in hooks (`decide` requires the sender Bot's owner's session, `bot/forbidden` otherwise, `bot/unauthorized` without a session); `release()` revalidates before dispatch and marks the record `stale` when the request changed, the sender or target Bot is gone (also invalidated eagerly on `deleteBot`), or the Bot changed owners; a demoted Chief's sender-side holds are invalidated eagerly on `setMain` (not inside `staleReason`), and `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. `flybot/approvals` lists only the actor's own holds while enforcement is on; decided stale records keep their attribution in state but appear in no member's list. Which statuses still wait for that decision is one rule in `src/shared/approval-waiting.js` (#754): `decide()`, `heldOf()` in the chat view, the in-flight filter that invalidates holds, `PENDING_MAX` and the `APPROVALS_KEPT` cleanup all ask `isApprovalWaiting()`, and `channel.js` counts a row's waiting replies with it, so a `pending_approval` hold (Bot Social's own spelling) is decidable, drawn as waiting and never dropped mid-wait. +- The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; an existing backup counts only when it is a readable file holding a parseable version-1 state, and anything else (or any malformed ownership) fails closed (read-only). An older binary refuses a newer version read-only. A file that parses but carries no `version` — `{}`, `null`, `[]`, an empty string — is not a newer one: it is refused read-only as `the team file has no version — it is incomplete (an empty or half-written file)`, so the person is pointed at the empty or half-written file instead of at an update they do not need ([#2716](https://github.com/ThinkFlowLab/FlyBot/issues/2716)); the `saved by a newer FlyBot; update FlyBot` sentence stays for a `version` above `STATE_VERSION`, and a 0-byte or truncated file never reaches either branch — it fails to parse, is copied to `state.json.broken-` and loads fresh and writable, as before. This is schema foundation only — team mode stays disabled, see [team](team.md). A room record is repaired on load: a missing or non-array `members` becomes `[]` and the room itself is kept, so one hand-edited, restored or half-migrated record can no longer make `adopt()` throw — which used to reject `load()`, be cached by `loaded ??=`, and hide the whole team for the life of the process (#2714). Every other table `adopt()` copies in from the file is repaired the same way, because `Object.assign(state, JSON.parse(EMPTY), parsed ?? {})` takes the file's value and not EMPTY's: `exchanges` becomes `[]` and `exchangeTurns`, `questions`, `rooms`, `bots`, `prefs` and `agents` become `{}` when they arrive as `null` or a scalar. `exchanges` was the costly one (#2715): `save()` slices it on every write, so a `null` list loaded clean, `readOnly()` answered `writable`, and then creating a Bot, renaming a room, a group message, a memory write and a delivery (`delivery.js`'s `state.exchanges.push(…)`) all threw from inside `save()` with a stack that never named the file; the other tables threw at their own first write instead (`state.exchangeTurns[id] ??= []` in `turns.js`, a group question in `tools.js`). Each repair sets `migrated`, so the same `load()` writes the repaired file rather than leaving the next start to find it again. A saved record that is `null` (or a scalar) is repaired since [#2755](https://github.com/ThinkFlowLab/FlyBot/issues/2755): before the fold the file as it was is copied to `state.json.broken-` and one warning names `state.json`, then a room record becomes `{}` (kept, with an empty `members`) and a Bot record is dropped (there is no data left to keep); a version-1 home then migrates and writes version 2 keeping `state.json.v1`, while a read-only pass records the evidence but never writes `state.json`. A version-2 file that carries `bots` as anything but a record is still refused read-only by `ownershipProblem()` before `adopt()` sees it — a roster is not repaired away, only a table that carried no record is. Tests: `core/test/store.test.js`. On top of it, a durable **Project** record (#1691): `state.projects` holds `{id, name, repository, defaultBranch, ownerUserId, createdAt, updatedAt, status}`, `bot.projectId` / `room.projectId` and `state.projectRuns[runId]` bind a member (a group, a Bot or a workflow run) to at most one Project, and the `api/bot` endpoints `projects`, `project-create`, `project-read`, `project-bind`, `project-of` and `project-board` sit in the task allow list. An unbound member resolves `null`, so an install with zero Projects behaves exactly as before. Since #2638 every text field a Project stores goes through `cut()` (`core/src/shared/text-boundary.js`) instead of `String#slice`: the bound counts UTF-16 units as before, but the kept prefix stops on a code-point boundary, so a name whose last character is an emoji at the 120 bound is stored whole or dropped whole rather than as half a surrogate pair that every UTF-8 write reads as U+FFFD. The read-only board over those bindings is `project-board` (#1696): Project id in, `{ tasks: [...] }` out, one row per core task id with `source` (`bot`/`group`/`workflow`), `column`, `assignee`, `updatedAt` and `runId`/`nodeId` when a run holds it, plus the card's own `title` (`null` when the record has none), `priority` (`priorityOf`, `'normal'` when the record stored none) and `board` (the `{ kind, id }` the row opens, `null` on a `workflow` row) so a row draws a card and opens the board it lives on: the open `tasks` read a page joins by id keeps open cards only, so a finished card in the `done` half has no record there and would otherwise draw as `#` with no priority and nowhere to go. `core/src/host/project-board.js` reads `state.tasks` and the bindings and asks the registered providers through `rt.projectRunRefs` (`flybot.tasks.onProjectProvider`); it never reads `workflows.json` and writes nothing. Since #2021 the board's record halves say what their caps left out and cut the right end of the list: the decision read answers `total` — the files left after the one-row-per-path dedup, before the cut — and takes `.slice(-DECISIONS_MAX)` over the still-ascending sort, so a Project past 200 decisions keeps the newest instead of dropping the ones just written and the order on screen does not change, while the meetings read answers `decidedTotal`/`pendingTotal` per list beside the 100 rows it already kept newest; a list at or under its cap answers the same rows as before, plus the count ([#2021](https://github.com/ThinkFlowLab/FlyBot/issues/2021)). Since #2033 the lookup a decided meeting uses for its own file falls back to the un-truncated dedupe set of that same read when the name is not among the kept rows, so a file that fell off the oldest end of the cut is still named by the meeting (`path` set, and no `sha256`, because only the kept rows carry one) while a name the folder no longer holds still answers `path: ''`; the fallback reads names from that dedupe set and never asks the filesystem, so a crafted `../outside.md` yields no path either ([#2033](https://github.com/ThinkFlowLab/FlyBot/issues/2033)). Since #2037 the name is also checked where the listing is produced, by the same `isDecisionPath` the reader uses: a name with a `..` segment, an absolute path, a symlink standing in for the file, or a name this read never listed yields no row and no meeting `path` (and no `sha256`), so the fallback past the cut cannot bring one back — `archivedOf` reads through `describeFile`, the reader's own `lstat`, never `stat` ([#2037](https://github.com/ThinkFlowLab/FlyBot/issues/2037)). Since #2041 a decision row can be opened: `rt.projectDecision(projectId, name)` reads one name through `readListedFile` and answers the reader's own three states (`text`, `absent`, `too large`), but only for a name the last `projectBoard` read of that Project produced — that read's un-truncated dedupe set is kept per Project, each name with the folder it was read from, so a name past the 200-row cut still opens while `decisions/../outside.md`, an absolute path, a name one level down or a symlink answers `not listed` without a `stat`, an open or a byte of content; the endpoint is `project-decision`, dispatched in `channel.js` beside `project-board` and listed in `authz.js`'s `TASK_ENDPOINTS` ([#2041](https://github.com/ThinkFlowLab/FlyBot/issues/2041)). A decided meeting that saved no folder keeps the cards it decided: its file is one of the Project folder's own rows, and binding those cards to that row is what keeps the name behind the same door as the cards, while a meeting that decided no card still answers through the role list ([#2052](https://github.com/ThinkFlowLab/FlyBot/issues/2052)). A record that named a folder whose file this read could not open is not that folder-less shape: it binds no cards, so the same relative name in the Project's own folder answers through the role list instead of inheriting the cards of a file in another folder. Since [#2029](https://github.com/ThinkFlowLab/FlyBot/issues/2029) a blocked card also carries the age of its own question: `blockedOf(task, now)` answers `since` (the `inputRequest.requestedAt` stamp `tasks.js` wrote, preserved across a re-ask) and `waitedMs` (`now - since`, the same shape `waitingOf` answers), both `null` when the ask wrote no stamp — never derived from `createdAt`/`updatedAt` — and `buildQueues` sorts `blocked` oldest-first with the comparator `waiting.sort` already uses, so an undated card sorts last; the Waiting group's own output is unchanged. Tests: `core/test/project-board.test.js`. On top of the record and the board sits the **Project Assistant** (#1712, Stage 2.5): `project-delete` removes the Project, its bindings and its memory; the assistant's digest is one stored note plus one `deliver()` to the owner, idempotent per Asia/Shanghai day, and its identity is the connector account and never the shared app — no connector signed in, or a multi-member host whose Project owner is not this host's own user, refuses and escalates with nothing written. Per-owner memory (#1733) keys those three fields by `(Project, owner)` under `projectMemory[id].owners`, mirrors the primary owner onto the row itself so a single-owner Project reads as before, and migrates a pre-#1733 record onto its primary owner on the first write instead of replacing it; the boundary a memory read is checked against is the Project's own owner list, so an owner asking for another owner's entries is refused naming that list, while the assistant's own render walks every owner. A card needs an owner's decision when the Bot that owns the card belongs to that owner, never from the board's `assignee` (a Bot name or null); the ask names the card's own title, at most `MAX_ACTIONABLE` (5) per owner. A quiet window on the assistant record holds a due digest and releases it once, labelled, when the window ends; a window covering 09:00 delivers nothing that day; an owner's opt-out stops generation and pauses the shared 09:00 task once every owner is off, and a re-enable resumes it; delivery goes to each owner's own Session and falls back to the main Bot only for the primary owner. A held digest is released by the next `digest()` call, not by the 09:00 task, which does not make one after a window ends. On top of it, member auth (#48) adds host module `src/host/member-auth.js`, installed after `store` in `MODULES` (`core/index.js`): `invitations` and `authSessions` maps live on state (no `STATE_VERSION` bump), the channel resolves the actor from the `x-flybot-member-session` header, and endpoints are `auth-invite` / `auth-redeem` / `auth-revoke` / `auth-whoami` / `auth-remove-member` / `auth-revoke-invite` with `body.actorId` ignored; `auth-invite` alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), `auth-revoke-invite` kills an unredeemed invitation (redeeming it afterwards fails with `revoked`), `removeMember` refuses a member that still owns a Bot (`has_bots`), and the auth mutations are serialized behind an in-process lock so concurrent `removeMember` calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in [team](team.md). On top of it, owner-scoped access (#49) adds host module `src/host/authz.js`, installed after member-auth: when any `authSessions` exist, the channel `authorize()` default-denies cross-owner Bot/room/session access, blocks shared non-isolatable resources (secrets, connectors, folder picks, browser bridge, update-install, diagnostics, set-prefs, `add-agent` — the DSH Agent is one per deployment and carries no owner) with `bot/blocked-shared` when more than one member exists, stamps `create-bot` with `actor.memberId`, and filters `view()`/`live()` to the actor; with no sessions the single-human behavior is unchanged. Session targets resolve through the store's owner maps — a Bot id via `botOf`, current chats and group Sessions and earlier parts via `selfOf`/`groupOwners`/`pastOwners` (a shared room Session also resolves for its current participants, #50) — and unresolvable ids deny. `approval-answer` gates on the approval's own Bot or room (resolved by approval id), never on caller-supplied session fields. `flybot/*` plugin endpoints (src/host/hooks.js) reach any signed-in actor for reads — except a Bot Social read naming a `body.botId` the actor does not own, which `bot/forbidden` refuses before the plugin handler registers that Bot's identity or syncs its grants (#1788, an owner-decided tightening); plugin writes follow the shared rule. `view()`/`live()` narrowing (`rt.ownedView`/`rt.ownedLive` in authz.js) keeps per-session entries (`questions`, `exchangeTurns`) the actor owns, and deployment-wide surfaces (agents, secrets, connectors, memory reviews) only while a single member exists — the same rule as the shared writes; rooms the actor belongs to and the Bots in them (#50) stay visible. `create-room` refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module `src/host/room-membership.js`, installed after groups: a room may carry typed `participants` (`human` / local `bot` / granted remote `bot` with `handle` + owner fields, roles `admin` / `member`, `joinedSeq` / `leftSeq`); log lines carry `authorId` + `authorKind`; history is the membership window (current members and the owner of an invited Bot see `seq >=` their interval; leave withdraws access; rejoin starts a new interval); local cross-owner rooms accept each side's *granted* Bots (#90: default deny, Chief included); a friend's Bot from `social/remote-bots` (`cleanRemote`) may be added and ungranted Bots stay private, and a room with a friend's Bot holds only granted local Bots (a Bot that joined by room code, `viaCode`, only for that room); `view()` labels remotes with an owner badge and shows an invited owner **Shared room from \**. Face-to-face rooms (#83) reuse the same `participants` / membership-window / owner-badge presentation with `origin: 'face-to-face'`: room-only remotes may sit in the room without a person-grant or a friendship, `presentRoom` treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and `fanoutRoomLine` goes to `rt.faceRoomFanout` instead of friend `flybotSend`. Still not team mode — see [team](team.md). Per-Bot grants (#90) add host module `src/host/reach.js`, installed after room-membership: `state.grants[botId]` is the list of independent grants (one Bot → one grantee, optionally room-scoped); default deny, Chief included; a Bot whose owner is not a member has none. Grants are set with `rt.setGrants`, exposed as `BotView.granted`, `flybot.bot.hasGrant` / `hasAnyGrant`, `flybot.grants` and the `bot.grants` event. `reach()` is the owner policy: a friend's Bot may reach only local Bots granted to that sender, only a granted Bot (or one code-joined in that room) may message a friend's Bot, another owner's Bot is reached only if both sides have matching grants or both are members of the room the message belongs to. It is not a middleware: hooks.js runs it before the `bot.send` chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or `bypass` can skip it and a queued message is judged by what is true when it is delivered. Revoking a grant, or replacing its Chief, removes it from rooms that have friends' Bots or several owners at once (`rt.evictUnshared`). Approval binding (#52) completes the held-send half of this: `state.flybot.approvals` records carry `requestId`, a sha256 `version` over the request content, server-derived `ownerId` and `origin` (`via`/`hop`/audience) — claims in the message (`meta`, text) never confer authority. `flybot/decide` is allowed through authz for any signed-in actor and the per-record check happens in hooks (`decide` requires the sender Bot's owner's session, `bot/forbidden` otherwise, `bot/unauthorized` without a session); `release()` revalidates before dispatch and marks the record `stale` when the request changed, the sender or target Bot is gone (also invalidated eagerly on `deleteBot`), or the Bot changed owners; a demoted Chief's sender-side holds are invalidated eagerly on `setMain` (not inside `staleReason`), and `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. `flybot/approvals` lists only the actor's own holds while enforcement is on; decided stale records keep their attribution in state but appear in no member's list. Which statuses still wait for that decision is one rule in `src/shared/approval-waiting.js` (#754): `decide()`, `heldOf()` in the chat view, the in-flight filter that invalidates holds, `PENDING_MAX` and the `APPROVALS_KEPT` cleanup all ask `isApprovalWaiting()`, and `channel.js` counts a row's waiting replies with it, so a `pending_approval` hold (Bot Social's own spelling) is decidable, drawn as waiting and never dropped mid-wait. - One writer per home (`src/host/home-lock.js`): the first dsh to load `~/.dsh` takes `state.lock` (pid, host, a random token in the file, its mtime bumped on a timer) and a second window on the same data only reads, answering `bot/read-only` and naming the other host. The chat index follows that rule too (#2711): such a window opens `chat-index.sqlite` read-only, so it reads the rows the writer indexed and never creates the file, rebuilds it or chmods it — a missing, damaged or older-version file answers nothing and `read_own_chat` scans the logs instead. What such a window still answers is `channel.js`'s own `READS` whitelist: beside `state` it carries the reads a second window's screens draw — the Projects views (`projects`, `project-read`, `project-board`, `project-team`, `project-of`, `project-decision`, `project-memory`), the task boards (`tasks`), the folder rows and browser (`folders`, `folder-list`) and the connector row's app state (`connector-app-status`) — and the write beside each one (`project-create`, `project-bind`, `task-create`, `folder-pick`, `connector-connect`, ...) is still refused, so a missing read is an empty screen and never a change made through the reading window. `diagnostics-clear` is deliberately not on that list (#2302): it truncates `/logs/diagnostics.jsonl`, the file a bug report is supposed to carry, so a reading window is refused with `bot/read-only` instead of erasing the evidence, while `diagnostics` and `diagnostics-log` still read that same file. Takeover is **on the next write**, not on a poll (#1027): a window that was read-only claims the free lock inside `save()` too, and a change made outside a request (a plugin, a scheduled task, a delivery) belongs in `rt.mutate(fn)`, which takes over and adopts what the other host left *before* the change is made and written. The writers that changed `rt.state` inline and then called `save()` are gone (#1048): `delivery.js` (a friend's first line and its unread mark, a team exchange) and `agents.js` (add, adopt, archive and remove of the DSH Agent) take the free lock and adopt first, re-resolving their records inside the change, so a write from a window that was read-only keeps what the other host left instead of replacing it with its older copy. `remove-agent` follows the same rule against a change made meanwhile, not another host (#2616): it awaits one archive per Session, so it never writes back the Session list it read before them — the change drops exactly the ids it archived and keeps everything else, and when every archive succeeded the change keeps the Sessions that arrived during the removal and the removal answers a retryable error instead of deleting the record, so a Session added meanwhile is never orphaned and the retry archives it. `save()` itself never adopts: `adopt()` replaces `rt.state` whole, so adopting there would discard a change the caller has already made (`#1032`). Either takeover prints one line — `the other dsh has closed; this one now saves the Bot team (was pid on )` — from `refresh()` and from `save()` alike, so a window that writes over the file another host left leaves a trace (#1041). A lock file still naming a live holder on this host (its pid answers) keeps the window read-only, which is the #14 rule. A write that fails does not decide the delivery (#1096): the friend-line mark and the exchange record both land before the agent has the line, so a rejected `mutate()` (the lock was taken away, or the disk refused) prints one warning naming what was not recorded and the line is still handed over — a throw there would drop a message that exists nowhere else. The write chain is also readable: `rt.savesQueued()` is a monotonic watermark — every save this host has accepted since it started, never decremented — and `rt.savesSettled()` resolves once the chain is empty, so a caller that must read `state.json` after a save it did not await — a rollback's own write, in the tests — waits on the store instead of on a delay, where a wait long enough on an idle machine reads a half-written team on a busy one (#1368). A save never writes through a link at the team file's own name (P-556): the write lands on the temp name and the `rename` replaces the name, link included, so the file the link pointed at keeps every byte and its last write time, and the path the person opened is a regular file of this home's own from that save on. A hard link is that same save for the person who opened the name: `lstat` reports `isSymbolicLink()` `false` while the inode carries a second name, so the name counts as linked only while a regular file's `nlink > 1` (the rule `plainLeaf` in `real-dir.js` already holds), and a file of this host's own (`nlink === 1`) is never counted. The store counts that save — `lstat` on the name before the `rename`, once per save, never for a save over the regular file that followed — and answers `rt.stateLinkReplaced()` as `{ gen, count }`: the count, plus the random generation stamp of the process that made it, because a restarted host starts counting at 0 again and a window holding the previous process's larger count would read the restart's first save as old news (Chief, on review). The pair rides the live part of every `state` answer (`stateFileLinkReplaced`), the `unchanged` one included, because a replaced link moves no revision; `authz.js`'s `ownedLive` names it in its whitelist, so a deployment where anyone has signed in receives it too — a field left out of that whitelist reaches no signed-in client at all (Security, on review). diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index 6d2af4b64..8e4278d55 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -910,6 +910,15 @@ diff --git a/src/host/store.js b/src/host/store.js const EMPTY = JSON.stringify(state) // The other dsh host that holds state.lock while this one only reads (see home-lock.js); +@@ -27,6 +27,8 @@ export function install(rt) { + let stopped = false + // mtime and size of the state.json this host last read or wrote. + let seen = '' ++ // A broken saved record was folded on the last read, so that read owes the disk a write. ++ let brokenRepaired = false + const stamp = () => { + try { + const { mtimeMs, size } = statSync(statePath) @@ -39,7 +39,9 @@ export function install(rt) { const claimLock = () => { if (lockBroken) return @@ -1011,9 +1020,44 @@ diff --git a/src/host/store.js b/src/host/store.js const readFileState = async () => { let contents try { -@@ -87,10 +87,38 @@ export function install(rt) { +@@ -86,11 +86,73 @@ export function install(rt) { + } return undefined } ++ // A saved record that is null (or a scalar) is a broken record, not a crash: a truncated write, ++ // a hand edit, a restore from another machine or an older build leaves `rooms: { r1: null }` or ++ // `bots: { chief: null }`. The record used to throw inside adopt (`room.members` on null, ++ // `'fields' in bot`), `loaded ??=` then cached the rejection and the team never appeared (#2755). ++ // Keep the file exactly as it was, then fold the record: a room stays with an empty member list, ++ // a Bot record is dropped because no data is left to keep. Both are named in one warning that ++ // names state.json, so "the team is gone" has a line to find. ++ if (parsed?.version === 1 || parsed?.version === STATE_VERSION) { ++ const broken = [] ++ for (const key of ['bots', 'rooms']) { ++ if (!isRecord(parsed[key])) continue ++ for (const [id, record] of Object.entries(parsed[key])) if (!isRecord(record)) broken.push(`${key}.${id}`) ++ } ++ if (broken.length) { ++ const backup = `${statePath}.broken-${Date.now()}` ++ try { ++ await copyFile(statePath, backup) ++ } catch (copyError) { ++ stuck = `a broken record (${broken.join(', ')}) could not be preserved before repairing it (${copyError?.code ?? copyError?.message ?? copyError})` ++ warn('ds-bot: state %s has a broken record (%s) and the copy to %s failed: %s; the file is left untouched', statePath, broken.join(', '), backup, copyError) ++ return undefined ++ } ++ warn('ds-bot: state %s has a broken record (%s); the file as it was is kept as %s and the record is repaired', statePath, broken.join(', '), backup) ++ for (const key of ['bots', 'rooms']) { ++ if (!isRecord(parsed[key])) continue ++ for (const [id, record] of Object.entries(parsed[key])) { ++ if (isRecord(record)) continue ++ if (key === 'rooms') parsed[key][id] = {} ++ else delete parsed[key][id] ++ } ++ } ++ brokenRepaired = true ++ } ++ } if (parsed?.version === 1) { + const problem = legacyOwnershipProblem(parsed) + if (problem !== undefined) { @@ -1027,10 +1071,9 @@ diff --git a/src/host/store.js b/src/host/store.js + warn('ds-bot: unreadable state %s: %s', statePath, backupProblem) + return undefined + } - stuck = undefined - return parsed - } -- stuck = 'saved by a newer DS Bot; update DS Bot' ++ stuck = undefined ++ return parsed ++ } + if (parsed?.version === STATE_VERSION) { + const problem = ownershipProblem(parsed) + if (problem !== undefined) { @@ -1038,9 +1081,10 @@ diff --git a/src/host/store.js b/src/host/store.js + warn('ds-bot: state %s was refused: %s', statePath, problem) + return undefined + } -+ stuck = undefined -+ return parsed -+ } + stuck = undefined + return parsed + } +- stuck = 'saved by a newer DS Bot; update DS Bot' + // A file that parses but carries no version is not a newer one: it is empty or was + // cut short, and "update FlyBot" would send the person to the wrong repair (#2716). + if (parsed?.version === undefined) { @@ -1051,10 +1095,13 @@ diff --git a/src/host/store.js b/src/host/store.js return undefined } -@@ -104,6 +104,51 @@ export function install(rt) { +@@ -103,7 +103,53 @@ export function install(rt) { + state.prefs ??= {} // Teams saved before the DSH Agent existed carry no `agents`; corrupt values reset. if (state.agents === null || typeof state.agents !== 'object' || Array.isArray(state.agents)) state.agents = {} - let migrated = !hadCapabilities +- let migrated = !hadCapabilities ++ let migrated = !hadCapabilities || brokenRepaired ++ brokenRepaired = false + // Everything above is the file's value, not EMPTY's, and a hand edit, a restore from another + // machine or a hand-merged file can bring `null` or a scalar where the schema holds a table. + // Those are repaired to their empty shape before anything reads them, because the alternative diff --git a/patches/ds-bot/0014-flybot-branding.patch b/patches/ds-bot/0014-flybot-branding.patch index 8cc2f878d..2536f6e04 100644 --- a/patches/ds-bot/0014-flybot-branding.patch +++ b/patches/ds-bot/0014-flybot-branding.patch @@ -2448,7 +2448,7 @@ diff --git a/test/store.test.js b/test/store.test.js test('dispose waits for a pending save and prevents later writes', async (t) => { const { rt, home, statePath, dispose } = runtime(t) await rt.load() -@@ -285,3 +285,263 @@ test('dispose waits for a pending save and prevents later writes', async (t) => +@@ -285,3 +285,329 @@ test('dispose waits for a pending save and prevents later writes', async (t) => restore() } }) @@ -2712,6 +2712,72 @@ diff --git a/test/store.test.js b/test/store.test.js + assert.equal(fs.readdirSync(home).filter(name => name.startsWith('state.json.broken-')).length, 1, label) + } +}) ++ ++// #2755: a saved record that is null (a truncated write, a hand edit, an older build) used to throw ++// out of adopt(), and `loaded ??=` cached the rejection: the team never appeared, `readOnly()` had no ++// answer and nothing named state.json. The record is evidence-backed up and folded instead. ++test('a room record that is null is backed up and folded, and the team still appears (#2755)', async (t) => { ++ const { rt, home, statePath, warnings } = runtime(t, team({ r1: null })) ++ await rt.load() ++ assert.equal(rt.readOnly(), undefined, 'the file loads as a writable team instead of a null answer') ++ assert.deepEqual(rt.state.rooms.r1.members, [], 'the room is kept and folded to an empty member list') ++ ++ const backups = fs.readdirSync(home).filter(name => name.startsWith('state.json.broken-')) ++ assert.equal(backups.length, 1, 'the pre-repair file is kept as one .broken copy') ++ const kept = JSON.parse(fs.readFileSync(join(home, backups[0]), 'utf8')) ++ assert.equal(kept.rooms.r1, null, 'the dropped value is preserved verbatim in the evidence copy') ++ ++ const written = JSON.parse(fs.readFileSync(statePath, 'utf8')) ++ assert.equal(written.version, 2, 'the repair is written back as version 2') ++ assert.deepEqual(written.rooms.r1.members, [], 'the saved room carries the repaired list') ++ assert.equal(warnings.some(line => line.join(' ').includes('state.json')), true, 'a warning names state.json') ++}) ++ ++test('a version-1 Bot record that is null migrates, keeps .v1 and drops only that record (#2755)', async (t) => { ++ const { rt, home, statePath, warnings } = runtime(t, { ++ version: 1, ++ revision: 1, ++ bots: { chief: null, kept: { id: 'kept', name: 'Keeper', createdAt: 1 } }, ++ }) ++ await rt.load() ++ assert.equal(rt.readOnly(), undefined, 'the team is not refused read-only') ++ assert.equal(rt.state.version, 2, 'the version-1 team is migrated') ++ assert.deepEqual(Object.keys(rt.state.bots), ['kept'], 'only the broken record is dropped') ++ ++ const v1 = JSON.parse(fs.readFileSync(`${statePath}.v1`, 'utf8')) ++ assert.equal(v1.version, 1, 'the version-1 backup is kept') ++ assert.equal(v1.bots.chief, null, 'the backup is the file as it was, broken record included') ++ const backups = fs.readdirSync(home).filter(name => name.startsWith('state.json.broken-')) ++ assert.equal(backups.length, 1) ++ assert.equal(JSON.parse(fs.readFileSync(join(home, backups[0]), 'utf8')).bots.chief, null) ++ ++ const written = JSON.parse(fs.readFileSync(statePath, 'utf8')) ++ assert.equal(written.version, 2) ++ assert.equal(Object.hasOwn(written.bots, 'chief'), false, 'the repaired file carries no null Bot record') ++ assert.equal(warnings.some(line => line.join(' ').includes('state.json')), true, 'a warning names state.json') ++}) ++ ++test('a version-1 team with a null record is not written back in a read-only pass (#2755)', async (t) => { ++ const home = fs.mkdtempSync(join(tmpdir(), 'ds-bot-store-')) ++ t.after(() => fs.rmSync(home, { recursive: true, force: true, maxRetries: 5 })) ++ const original = JSON.stringify({ version: 1, revision: 1, bots: { chief: null, kept: { id: 'kept', name: 'Keeper', createdAt: 1 } } }) ++ fs.writeFileSync(join(home, 'state.lock'), JSON.stringify({ pid: process.ppid, host: hostname(), token: 'other' })) ++ fs.writeFileSync(join(home, 'state.json'), original) ++ ++ const { rt } = runtime(t, undefined, { home }) ++ await rt.load() ++ assert.match(rt.readOnly(), /another dsh/, 'the window is told why it may not write') ++ assert.equal(fs.readFileSync(join(home, 'state.json'), 'utf8'), original, 'a read-only pass writes no state.json') ++ assert.equal(JSON.parse(fs.readFileSync(join(home, 'state.json.v1'), 'utf8')).version, 1, '.v1 is still kept') ++}) ++ ++test('a healthy team loads with no broken-record warning and no .broken copy (#2755)', async (t) => { ++ const { rt, home, warnings } = runtime(t, team({ r1: { id: 'r1', name: 'Ops', members: ['a'], mode: 'admin', admin: 'a', named: true, sessions: {}, log: [], seq: 0, seen: {} } })) ++ await rt.load() ++ assert.equal(rt.readOnly(), undefined) ++ assert.deepEqual(fs.readdirSync(home).filter(name => name.startsWith('state.json.broken-')), [], 'no evidence copy for a healthy file') ++ assert.equal(warnings.some(line => line.join(' ').includes('broken record')), false, 'no warning for a healthy file') ++}) diff --git a/test/update.test.js b/test/update.test.js --- a/test/update.test.js +++ b/test/update.test.js