From e1411d8788ca55819c86569ada12e1781de7a8f8 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:35:29 +0800 Subject: [PATCH] host: the diagnostics report names the team file's backups (#2434) --- changelog.d/2434-state-backups.added.md | 1 + core/src/host/diagnostics.js | 69 ++++++++++++++- core/test/diagnostics.test.js | 81 +++++++++++++++++- docs/subsystems/core-host.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 74 +++++++++++++++-- patches/ds-bot/0007-version-and-tooling.patch | 83 ++++++++++++++++++- 6 files changed, 298 insertions(+), 12 deletions(-) create mode 100644 changelog.d/2434-state-backups.added.md diff --git a/changelog.d/2434-state-backups.added.md b/changelog.d/2434-state-backups.added.md new file mode 100644 index 000000000..b208ee664 --- /dev/null +++ b/changelog.d/2434-state-backups.added.md @@ -0,0 +1 @@ +The diagnostics report now lists the backups the store leaves beside the team file — `state.json.v1` and the `state.json.broken-` copies — with their size and age, newest three broken copies plus a count of the rest, or `state backups: none`; a backup this host cannot read is named with its reason, and no backup is ever opened ([#2434](https://github.com/ThinkFlowLab/FlyBot/issues/2434)). diff --git a/core/src/host/diagnostics.js b/core/src/host/diagnostics.js index 350bc1e8e..39b8f6282 100644 --- a/core/src/host/diagnostics.js +++ b/core/src/host/diagnostics.js @@ -5,9 +5,9 @@ // credential-like strings and the home folder before it is kept and again before it is // shown, because the report is meant for a public issue. import { constants, readFileSync } from 'node:fs' -import { access, appendFile, lstat, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' +import { access, appendFile, lstat, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' import { arch, homedir, platform, release } from 'node:os' -import { dirname, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { format } from 'node:util' import { fileKind } from './real-dir.js' @@ -84,6 +84,9 @@ export function reportMarkdown({ facts, client = {}, entries = [], clientEntries // The team file, right after the table that counts the team (#2394): a report about a team has to // say what that team is saved in, in the shape the two file lines below already have. const stateFile = facts.stateFile === undefined ? [] : ['', `state file: ${facts.stateFile}`] + // The backups beside that file, in the same block: what a person reading a report about a team + // that cannot be read needs next is whether an earlier copy is still on disk and readable. + const stateBackups = facts.stateBackups === undefined ? [] : [`state backups: ${facts.stateBackups}`] // The journal is only mentioned while the host runs one: an off journal is not a fact about // this host, and a stopped one is exactly what a report about a bug it was meant to catch misses. // Its file follows in the same shape (#2388): the sentence says whether the host records, and @@ -93,7 +96,7 @@ export function reportMarkdown({ facts, client = {}, entries = [], clientEntries // The log's own line, in the journal's shape (#2134): a host whose log is off says nothing, and // every other bundle gets the one fact the recent errors cannot show — whether they were written. const log = facts.diagnosticsLog === undefined ? [] : ['', `diagnostics log: ${facts.diagnosticsLog}`] - const out = [...lines, ...stateFile, ...journal, ...log, ''] + const out = [...lines, ...stateFile, ...stateBackups, ...journal, ...log, ''] out.push(recent.length === 0 ? 'No host errors recorded.' : `Recent host errors (${recent.length} of ${entries.length}, oldest first):`) if (recent.length > 0) out.push('```', ...recent.map(entryLine), '```') const stacks = recent.filter(entry => entry.stack).slice(-3) @@ -288,6 +291,63 @@ export function install(rt) { } } + // What the store leaves beside the team file, named without opening it: one `state.json.v1` + // taken before a version-1 migration, and the `state.json.broken-` copies of a file that + // could not be read. A report about a team that cannot be read has to say whether those are + // there and whether this host can describe them, so the line lists name, size and age — `lstat` + // for each name and one `readdir` for the broken ones, never an open: a backup holds the same + // members, messages and saved keys as the team file itself. + const BACKUPS_SHOWN = 3 + const sizeText = bytes => bytes < 1024 ? `${bytes} B` + : bytes < 1024 * 1024 ? `${Math.round(bytes / 1024)} KB` + : `${Math.round(bytes / (1024 * 1024))} MB` + const ageText = mtimeMs => { + const ms = Date.now() - mtimeMs + if (ms < 60_000) return 'just now' + if (ms < 3_600_000) return `${Math.round(ms / 60_000)} min ago` + if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)} h ago` + return `${Math.round(ms / 86_400_000)} d ago` + } + // One name this host may describe, always from the metadata `lstat` gave: size and age. A name + // `lstat` itself cannot answer for is named with the code it failed with, never guessed at; a + // folder or a link is a name like any other here, drawn from its own metadata and never opened. + const backupFact = (name, info) => `${name} (${sizeText(info.size)}, ${ageText(info.mtimeMs)})` + const backupProblem = (name, error) => `${name} (unreadable: ${typeof error?.code === 'string' ? error.code : 'cannot be read'})` + const stateBackups = async () => { + if (statePath === undefined) return undefined + const named = [] + const v1 = basename(`${statePath}.v1`) + try { + named.push(backupFact(v1, await lstat(`${statePath}.v1`))) + } catch (error) { + if (error?.code !== 'ENOENT') named.push(backupProblem(v1, error)) + } + // The store names its unreadable-team copies with this one fixed prefix (#199 and #143), so a + // listing filtered by it names only files the store itself wrote; the rest of the folder is + // never guessed at. + const prefix = `${basename(statePath)}.broken-` + const broken = [] + try { + for (const name of (await readdir(home)).filter(name => name.startsWith(prefix))) { + try { + broken.push({ name, info: await lstat(join(home, name)) }) + } catch (error) { + broken.push({ name, error }) + } + } + } catch { + // a home this host cannot list adds no broken copies and no sentence of its own + } + // Newest first: the copy a person restoring a team wants is the last one taken, and the rest + // are only counted — a home that failed to read the team repeatedly has one per attempt. + broken.sort((a, b) => (b.info?.mtimeMs ?? 0) - (a.info?.mtimeMs ?? 0)) + for (const item of broken.slice(0, BACKUPS_SHOWN)) { + named.push(item.info === undefined ? backupProblem(item.name, item.error) : backupFact(item.name, item.info)) + } + if (broken.length > BACKUPS_SHOWN) named.push(`and ${broken.length - BACKUPS_SHOWN} more`) + return named.length === 0 ? 'none' : named.join(', ') + } + const facts = async () => { const bots = Object.values(rt.state.bots) const models = [...new Set(bots.map(bot => bot.model ?? bot.appliedModel).filter(Boolean))] @@ -306,6 +366,9 @@ export function install(rt) { // open it. `none yet` is a computer that has not saved this team — naming it creates nothing. // A link is described by the store's own shape (#2404), not by the journal leaf's sentence. stateFile: await fileFact(statePath, { missing: 'none yet (this computer has not saved the team)', notPlain: stateNotPlain }), + // The backups beside it, in the same reading: a report about a team that cannot be read says + // whether an earlier copy is still there, and never opens one. + stateBackups: await stateBackups(), models: models.length > 0 ? models.join(', ') : undefined, // The store's own sentence (#2153): `stuck` (a team file that could not be read) and a // live state.lock are different faults, and the sidebar shows this same sentence, so the diff --git a/core/test/diagnostics.test.js b/core/test/diagnostics.test.js index 297efe811..d808df579 100644 --- a/core/test/diagnostics.test.js +++ b/core/test/diagnostics.test.js @@ -1,5 +1,5 @@ import assert from 'node:assert/strict' -import { chmodSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { chmodSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs' import fsp from 'node:fs/promises' import { syncBuiltinESMExports } from 'node:module' import { homedir, hostname, tmpdir } from 'node:os' @@ -823,6 +823,85 @@ test('a team file linking to a file it cannot read keeps the leaf sentence, not assert.equal(readFileSync(target, 'utf8'), kept, 'the target keeps every byte while it is unreadable') }) +// The store keeps backups beside the team file, and a report about a team that cannot be read +// must say whether they are there and whether they can be read: one `state.json.v1` taken before +// a version-1 migration, and `state.json.broken-` copies of a file that could not be read. +// The line lists names only — `lstat` for each and one `readdir` for the broken names, never an +// open: a backup holds the same members, messages and saved keys as the file itself. +test('a host with no state backups says none', async (t) => { + const { rt } = await realHost(t) + await rt.save() + const report = await rt.diagnostics.report({}) + assert.equal(report.facts.stateBackups, 'none') + assert.match(report.markdown, /^state backups: none$/m) +}) + +test('one readable backup is named with its size and age, and no other line moves', async (t) => { + const { home, rt } = await realHost(t) + await rt.save() + const before = (await rt.diagnostics.report({})).markdown.split('\n') + const v1 = join(home, 'state.json.v1') + writeFileSync(v1, 'x'.repeat(12 * 1024)) + const twoHoursAgo = new Date(Date.now() - 2 * 3_600_000) + utimesSync(v1, twoHoursAgo, twoHoursAgo) + + const report = await rt.diagnostics.report({}) + assert.equal(report.facts.stateBackups, 'state.json.v1 (12 KB, 2 h ago)') + const shown = report.markdown.split('\n') + const at = shown.indexOf('state backups: state.json.v1 (12 KB, 2 h ago)') + assert.ok(at > 0, 'the report carries the line') + assert.match(shown[at - 1], /^state file: /, 'it follows the team file line inside the same block') + const withoutBackups = text => text.filter(line => !line.startsWith('state backups: ')) + assert.deepEqual(withoutBackups(shown), withoutBackups(before), 'the backups line is the only line this change adds') +}) + +test('a backup whose lstat fails is named with its code and the report still prints', async (t) => { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-diag-')) + t.after(() => { + try { chmodSync(home, 0o700) } catch { /* the home is already gone */ } + rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + }) + writeFileSync(join(home, 'state.json'), '{"version":2}\n') + writeFileSync(join(home, 'state.json.v1'), '{"version":1}\n') + chmodSync(home, 0o000) + const report = await host(home, { config: { diagnosticsFile: false }, statePath: join(home, 'state.json') }).diagnostics.report({}) + assert.equal(report.facts.stateBackups, 'state.json.v1 (unreadable: EACCES)') + assert.match(report.markdown, /^state backups: state\.json\.v1 \(unreadable: EACCES\)$/m) + assert.match(report.markdown, /^state file: /m, 'the team file line still prints') +}) + +test('a backup that is a directory or a link to nothing is drawn from its own lstat', async (t) => { + const asDir = await realHost(t) + mkdirSync(join(asDir.home, 'state.json.v1')) + const dirReport = await asDir.rt.diagnostics.report({}) + assert.match(dirReport.facts.stateBackups, /^state\.json\.v1 \(\d+ (?:B|KB|MB), .+\)$/, + 'a name lstat answers for keeps the size-and-age shape, whatever it points at') + assert.doesNotMatch(dirReport.markdown, /unreadable: not a plain file/, 'no sentence is invented for a name lstat could describe') + + const dangling = await realHost(t) + symlinkSync(join(dangling.home, 'missing-backup'), join(dangling.home, 'state.json.v1')) + const linkReport = await dangling.rt.diagnostics.report({}) + assert.match(linkReport.facts.stateBackups, /^state\.json\.v1 \(\d+ (?:B|KB|MB), .+\)$/) + assert.equal(existsSync(join(dangling.home, 'missing-backup')), false, 'asking the link creates nothing') +}) + +test('the broken copies are listed newest first, three at most, with the rest counted', async (t) => { + const { home, rt } = await realHost(t, { locked: true }) + const readOnly = (await rt.diagnostics.report({})).markdown.match(/^\| Read-only \| (.*)$/m)?.[1] + for (let i = 1; i <= 5; i += 1) { + const name = join(home, `state.json.broken-${1000 + i}`) + writeFileSync(name, 'x'.repeat(12 * 1024)) + const when = new Date(Date.now() - i * 3_600_000) + utimesSync(name, when, when) + } + writeFileSync(join(home, 'other.txt'), 'not a backup') + const report = await rt.diagnostics.report({}) + assert.equal(report.facts.stateBackups, 'state.json.broken-1001 (12 KB, 1 h ago), state.json.broken-1002 (12 KB, 2 h ago), state.json.broken-1003 (12 KB, 3 h ago), and 2 more') + assert.match(report.markdown, /^state backups: state\.json\.broken-1001 /m) + assert.doesNotMatch(report.markdown, /other\.txt/, "only the store's own backup prefix is listed") + assert.equal(report.markdown.match(/^\| Read-only \| (.*)$/m)?.[1], readOnly, 'the Read-only row is unchanged') +}) + // The line is read information, not a write: a window another dsh keeps read-only still names the // team file, the way it still counts the team it shows. test('a read-only window names the team file too (#2394)', async (t) => { diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index ad8076c22..8e69775a8 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -407,7 +407,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi - Host diagnostics never depend on `ctx.logger`: DSH may export no logger, and a test or plugin-local host may carry one without `warn`. `rt.warn` still writes stderr and the diagnostics log in that case (`src/host/diagnostics.js`). Plugins reach the same log through `flybot.warn` (#68); see [plugin-runtime](plugin-runtime.md). - The `Read-only` row of a report repeats this host's own answer rather than one constant: `rt.readOnly()` is `undefined` while the window writes, a sentence about a live `state.lock` when another dsh holds the team, and a sentence about the team file when it is `stuck` (a newer version, a file that will not parse, an unreadable path). The report shows that sentence — `clean(cut(answer, 200))`, so saved keys, the home folder and `|` are handled like any other report text — the same one the sidebar shows, so a newer team file reads `update FlyBot` and an unreadable one reads `could not be read`, never `another dsh holds the team`; a writable window still adds no row ([#2167](https://github.com/ThinkFlowLab/FlyBot/issues/2167)). - The log itself is one leaf file under the home, and a write asks it before it appends ([#2089](https://github.com/ThinkFlowLab/FlyBot/issues/2089)): when `logs/diagnostics.jsonl` is a link, a hard link or anything other than a plain file of its own — the line is dropped with one line on stderr that carries no home folder, the entries stay readable in memory, and `clear` leaves a file behind such a link untouched rather than truncating it. Past 1 MB the leaf moves aside to `diagnostics.jsonl.1`, and `clear` removes that name as well — in the same write chain, behind the same leaf guard — so a cleared log leaves no rotated lines behind. The truncate runs first and the removal only after it: a leaf that cannot be emptied keeps the rotated copy where it is, because those lines are then the only copy left of them. The removal keeps a `try` of its own: a copy the host cannot remove (a directory under that name, a folder without its write bit) keeps its bytes, is named on one stderr line that carries no folder, and never turns a Clear that emptied the leaf into one that leaves every line on disk. The truncate keeps a `try` of its own for the same reason ([#2364](https://github.com/ThinkFlowLab/FlyBot/issues/2364)): the leaf guard asks the file's kind, not its mode, so a plain `0444` leaf passes it and the write fails with `EACCES` — that failure is named on one stderr line, `[ds-bot] diagnostics log not truncated: `, which carries no folder, the same way `append` names its own; the answer it leaves behind is `leaf: 'kept'`. `clear` answers with what it did — `{ leaf: 'truncated' | 'kept' | 'absent', rotated: 'removed' | 'kept' | 'none' }` — and `diagnostics-clear` passes that result through instead of a constant, so the Feedback page's sentence matches it: the unchanged one, one that names the leftover `logs/diagnostics.jsonl.1`, or one that does not claim a cleared log ([#2360](https://github.com/ThinkFlowLab/FlyBot/issues/2360)). `rotated` says what this call did to that name: `removed` when it deleted one, `kept` when one was there and still is — the removal failed, or the truncate failed first and nothing was removed — and `none` when there was none; a leaf the guard refuses never reaches the probe, so it answers `none`. What the window reads moves only with an emptied leaf: `entries()` answers the old lines through `cleared` plus the live ones in `fresh`, and `logText()` behind the Download reads the same, so a Clear that leaves the leaf full changes neither of them — the page can no longer show "not cleared" next to "0 recent errors" while every byte is still on disk. Only a leaf that really was truncated clears both — plus a host that keeps no log file at all (`diagnosticsFile: false`), which has no file and no list to keep; an `ENOENT` write leaves both alone like every other refusal ([#2364](https://github.com/ThinkFlowLab/FlyBot/issues/2364)). That is the answer, one stderr line and nothing else that moves: the truncate, the `fileKind` guard, the removal rule and the `try` keep the behaviour they had, and a leaf that is not there yet still gets the empty file the write always landed there. `absent` therefore means nothing was on disk to clear — this host keeps no log file (`diagnosticsFile: false`), or the folder is not there so the write found nothing to truncate — not a leaf the call refused. The report says whether that same file is being written, in the shape the journal line already has: `diagnostics log: recording` while the appends land, `not written ()` after an append the host could not make, and `not written (not a plain file of its own)` when the leaf guard refused the write, so a bundle that lists recent errors also says when the file holding them is missing or short and why. The value follows the last append attempt, not the first refusal: a host that puts a plain file of its own back writes the next entry into it and says `recording` again, and a leaf that has become a plain `0444` file says `not written (EACCES)` on the next attempt, while the guard's own stderr line stays one per host ([#2089](https://github.com/ThinkFlowLab/FlyBot/issues/2089)); a host with the log off (`diagnosticsFile: false`) adds no line at all, the rule the journal line keeps ([#2383](https://github.com/ThinkFlowLab/FlyBot/issues/2383)). -- The report also names the team file itself, right after the table that counts the team ([#2394](https://github.com/ThinkFlowLab/FlyBot/issues/2394)): `state file: ( bytes, last written )` for a plain file with this one name, `state file: none yet (this computer has not saved the team)` before this computer has one, and `state file: not a plain file (described only; the store reads the team file through the link and the next save replaces the name)` when the path is a link to a plain file or a file with a second name (`nlink > 1`) — the store's own shape, which reads the team file through the name and renames a temp name over it on the next save, so the line must not claim nothing is read or written ([#2404](https://github.com/ThinkFlowLab/FlyBot/issues/2404)). A directory, a link to a directory, a link to nothing or a check that cannot run keeps the journal leaf's `state file: not a plain file (nothing is read or written)`, which is true of them: no read of those lands, and for a folder behind the link the store's read fails as one (`EISDIR`), so it lands in `stuck`, `writable()` stays false and no save ever renames over the name. A link to a plain file this host may not read keeps that leaf sentence too ([#2428](https://github.com/ThinkFlowLab/FlyBot/issues/2428)): `stat` alone answers `isFile()`, so the permission is asked as well with `access(path, R_OK)` — no open, no byte — because otherwise the line promised the read above the `Read-only` row that reports the same read failing with `EACCES`. Both sentences are metadata only — `lstat` for the name, plus `stat` on a link to ask what it points at, never open, read or stream the target; that is the point: `state.json` holds the members, the messages and the saved keys, so the report may describe the file and must never open it — a report can say how large the team file is and when it was last written without reading a byte of it. The path goes through the same home redaction, the line is read information rather than a write action so a read-only window draws it too, and the wording of `state journal:`, `journal file:` and the `Read-only` row is unchanged. +- The report also names the team file itself, right after the table that counts the team ([#2394](https://github.com/ThinkFlowLab/FlyBot/issues/2394)): `state file: ( bytes, last written )` for a plain file with this one name, `state file: none yet (this computer has not saved the team)` before this computer has one, and `state file: not a plain file (described only; the store reads the team file through the link and the next save replaces the name)` when the path is a link to a plain file or a file with a second name (`nlink > 1`) — the store's own shape, which reads the team file through the name and renames a temp name over it on the next save, so the line must not claim nothing is read or written ([#2404](https://github.com/ThinkFlowLab/FlyBot/issues/2404)). A directory, a link to a directory, a link to nothing or a check that cannot run keeps the journal leaf's `state file: not a plain file (nothing is read or written)`, which is true of them: no read of those lands, and for a folder behind the link the store's read fails as one (`EISDIR`), so it lands in `stuck`, `writable()` stays false and no save ever renames over the name. A link to a plain file this host may not read keeps that leaf sentence too ([#2428](https://github.com/ThinkFlowLab/FlyBot/issues/2428)): `stat` alone answers `isFile()`, so the permission is asked as well with `access(path, R_OK)` — no open, no byte — because otherwise the line promised the read above the `Read-only` row that reports the same read failing with `EACCES`. Both sentences are metadata only — `lstat` for the name, plus `stat` on a link to ask what it points at, never open, read or stream the target; that is the point: `state.json` holds the members, the messages and the saved keys, so the report may describe the file and must never open it — a report can say how large the team file is and when it was last written without reading a byte of it. Beside that line the report names the backups the store leaves next to the team file ([#2434](https://github.com/ThinkFlowLab/FlyBot/issues/2434)): `state backups: none`, or the `state.json.v1` migration backup and the `state.json.broken-` copies of a team file that could not be read, each as `name (12 KB, 2 h ago)` — the broken copies newest first, three at most, with `and k more` counting the rest — and `unreadable: ` only when `lstat` itself fails, since a name that `lstat` answers for is drawn from its own size and age however a read of it would end (a folder or a link is such a name, and neither is opened). The path goes through the same home redaction, the line is read information rather than a write action so a read-only window draws it too, and the wording of `state journal:`, `journal file:` and the `Read-only` row is unchanged. - The report names the file the journal records into, in the journal's own shape and without opening it ([#2388](https://github.com/ThinkFlowLab/FlyBot/issues/2388)): `journal file: ( bytes, last written )` for a plain file with this one name, `journal file: none yet` before the first save creates it, and `journal file: not a plain file (nothing is read or written)` when the path is a link, a file with a second name (`nlink > 1`) or the check cannot run. `lstat` is the only call — never `open`, `readFile` or `createReadStream` — so the line needs no read permission on the file and touches nothing another name holds; the path goes through the same home redaction the rest of the report uses, and a host without a journal adds no line, like the `state journal:` sentence it follows. The line says what the file is, not that a write happened: `state journal:` keeps answering whether the host is recording. ### Untrusted fences in every shared room diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 07cba9c59..319302b1d 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -5034,9 +5034,10 @@ diff --git a/src/host/diagnostics.js b/src/host/diagnostics.js -import { readFileSync } from 'node:fs' -import { appendFile, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises' +import { constants, readFileSync } from 'node:fs' -+import { access, appendFile, lstat, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' ++import { access, appendFile, lstat, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' import { arch, homedir, platform, release } from 'node:os' - import { dirname, join } from 'node:path' +-import { dirname, join } from 'node:path' ++import { basename, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { format } from 'node:util' +import { fileKind } from './real-dir.js' @@ -5071,7 +5072,7 @@ diff --git a/src/host/diagnostics.js b/src/host/diagnostics.js row('DSH', facts.dshVersion), row('Surface', client.surface), row('OS', facts.os), -@@ -78,11 +78,23 @@ export function reportMarkdown({ facts, client = {}, entries = [], clientEntries +@@ -78,11 +78,26 @@ export function reportMarkdown({ facts, client = {}, entries = [], clientEntries row('Read-only', facts.readOnly), ].filter(Boolean) const recent = entries.slice(-REPORT_ENTRIES) @@ -5079,6 +5080,9 @@ diff --git a/src/host/diagnostics.js b/src/host/diagnostics.js + // The team file, right after the table that counts the team (#2394): a report about a team has to + // say what that team is saved in, in the shape the two file lines below already have. + const stateFile = facts.stateFile === undefined ? [] : ['', `state file: ${facts.stateFile}`] ++ // The backups beside that file, in the same block: what a person reading a report about a team ++ // that cannot be read needs next is whether an earlier copy is still on disk and readable. ++ const stateBackups = facts.stateBackups === undefined ? [] : [`state backups: ${facts.stateBackups}`] + // The journal is only mentioned while the host runs one: an off journal is not a fact about + // this host, and a stopped one is exactly what a report about a bug it was meant to catch misses. + // Its file follows in the same shape (#2388): the sentence says whether the host records, and @@ -5088,7 +5092,7 @@ diff --git a/src/host/diagnostics.js b/src/host/diagnostics.js + // The log's own line, in the journal's shape (#2134): a host whose log is off says nothing, and + // every other bundle gets the one fact the recent errors cannot show — whether they were written. + const log = facts.diagnosticsLog === undefined ? [] : ['', `diagnostics log: ${facts.diagnosticsLog}`] -+ const out = [...lines, ...stateFile, ...journal, ...log, ''] ++ const out = [...lines, ...stateFile, ...stateBackups, ...journal, ...log, ''] out.push(recent.length === 0 ? 'No host errors recorded.' : `Recent host errors (${recent.length} of ${entries.length}, oldest first):`) if (recent.length > 0) out.push('```', ...recent.map(entryLine), '```') const stacks = recent.filter(entry => entry.stack).slice(-3) @@ -5189,7 +5193,7 @@ diff --git a/src/host/diagnostics.js b/src/host/diagnostics.js console.error(`[ds-bot] ${line}${frames.length > 0 ? `\n${clean(frames.join('\n'))}` : ''}`) note({ level: 'warn', -@@ -187,24 +187,104 @@ export function install(rt) { +@@ -187,24 +187,164 @@ export function install(rt) { })) } @@ -5242,6 +5246,63 @@ diff --git a/src/host/diagnostics.js b/src/host/diagnostics.js + } + } + ++ // What the store leaves beside the team file, named without opening it: one `state.json.v1` ++ // taken before a version-1 migration, and the `state.json.broken-` copies of a file that ++ // could not be read. A report about a team that cannot be read has to say whether those are ++ // there and whether this host can describe them, so the line lists name, size and age — `lstat` ++ // for each name and one `readdir` for the broken ones, never an open: a backup holds the same ++ // members, messages and saved keys as the team file itself. ++ const BACKUPS_SHOWN = 3 ++ const sizeText = bytes => bytes < 1024 ? `${bytes} B` ++ : bytes < 1024 * 1024 ? `${Math.round(bytes / 1024)} KB` ++ : `${Math.round(bytes / (1024 * 1024))} MB` ++ const ageText = mtimeMs => { ++ const ms = Date.now() - mtimeMs ++ if (ms < 60_000) return 'just now' ++ if (ms < 3_600_000) return `${Math.round(ms / 60_000)} min ago` ++ if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)} h ago` ++ return `${Math.round(ms / 86_400_000)} d ago` ++ } ++ // One name this host may describe, always from the metadata `lstat` gave: size and age. A name ++ // `lstat` itself cannot answer for is named with the code it failed with, never guessed at; a ++ // folder or a link is a name like any other here, drawn from its own metadata and never opened. ++ const backupFact = (name, info) => `${name} (${sizeText(info.size)}, ${ageText(info.mtimeMs)})` ++ const backupProblem = (name, error) => `${name} (unreadable: ${typeof error?.code === 'string' ? error.code : 'cannot be read'})` ++ const stateBackups = async () => { ++ if (statePath === undefined) return undefined ++ const named = [] ++ const v1 = basename(`${statePath}.v1`) ++ try { ++ named.push(backupFact(v1, await lstat(`${statePath}.v1`))) ++ } catch (error) { ++ if (error?.code !== 'ENOENT') named.push(backupProblem(v1, error)) ++ } ++ // The store names its unreadable-team copies with this one fixed prefix (#199 and #143), so a ++ // listing filtered by it names only files the store itself wrote; the rest of the folder is ++ // never guessed at. ++ const prefix = `${basename(statePath)}.broken-` ++ const broken = [] ++ try { ++ for (const name of (await readdir(home)).filter(name => name.startsWith(prefix))) { ++ try { ++ broken.push({ name, info: await lstat(join(home, name)) }) ++ } catch (error) { ++ broken.push({ name, error }) ++ } ++ } ++ } catch { ++ // a home this host cannot list adds no broken copies and no sentence of its own ++ } ++ // Newest first: the copy a person restoring a team wants is the last one taken, and the rest ++ // are only counted — a home that failed to read the team repeatedly has one per attempt. ++ broken.sort((a, b) => (b.info?.mtimeMs ?? 0) - (a.info?.mtimeMs ?? 0)) ++ for (const item of broken.slice(0, BACKUPS_SHOWN)) { ++ named.push(item.info === undefined ? backupProblem(item.name, item.error) : backupFact(item.name, item.info)) ++ } ++ if (broken.length > BACKUPS_SHOWN) named.push(`and ${broken.length - BACKUPS_SHOWN} more`) ++ return named.length === 0 ? 'none' : named.join(', ') ++ } ++ + const facts = async () => { const bots = Object.values(rt.state.bots) const models = [...new Set(bots.map(bot => bot.model ?? bot.appliedModel).filter(Boolean))] @@ -5260,6 +5321,9 @@ diff --git a/src/host/diagnostics.js b/src/host/diagnostics.js + // open it. `none yet` is a computer that has not saved this team — naming it creates nothing. + // A link is described by the store's own shape (#2404), not by the journal leaf's sentence. + stateFile: await fileFact(statePath, { missing: 'none yet (this computer has not saved the team)', notPlain: stateNotPlain }), ++ // The backups beside it, in the same reading: a report about a team that cannot be read says ++ // whether an earlier copy is still there, and never opens one. ++ stateBackups: await stateBackups(), models: models.length > 0 ? models.join(', ') : undefined, - readOnly: rt.readOnly?.() ? 'yes, another dsh holds the team' : undefined, + // The store's own sentence (#2153): `stuck` (a team file that could not be read) and a diff --git a/patches/ds-bot/0007-version-and-tooling.patch b/patches/ds-bot/0007-version-and-tooling.patch index e8ed23e95..585c13455 100644 --- a/patches/ds-bot/0007-version-and-tooling.patch +++ b/patches/ds-bot/0007-version-and-tooling.patch @@ -798,7 +798,7 @@ diff --git a/test/diagnostics.test.js b/test/diagnostics.test.js @@ -1,11 +1,13 @@ import assert from 'node:assert/strict' -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' -+import { chmodSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' ++import { chmodSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs' import fsp from 'node:fs/promises' import { syncBuiltinESMExports } from 'node:module' -import { homedir, tmpdir } from 'node:os' @@ -1005,7 +1005,7 @@ diff --git a/test/diagnostics.test.js b/test/diagnostics.test.js test('diagnostics append fallback redacts an error message', async (t) => { const home = mkdtempSync(join(tmpdir(), 'ds-bot-diag-')) t.after(() => rmSync(home, { recursive: true, force: true, maxRetries: 5 })) -@@ -133,6 +133,599 @@ test('diagnostics append fallback redacts an error message', async (t) => { +@@ -133,6 +133,678 @@ test('diagnostics append fallback redacts an error message', async (t) => { assert.match(stderr.join('\n'), /api_key=\[hidden\]/) }) @@ -1557,6 +1557,85 @@ diff --git a/test/diagnostics.test.js b/test/diagnostics.test.js + assert.equal(readFileSync(target, 'utf8'), kept, 'the target keeps every byte while it is unreadable') +}) + ++// The store keeps backups beside the team file, and a report about a team that cannot be read ++// must say whether they are there and whether they can be read: one `state.json.v1` taken before ++// a version-1 migration, and `state.json.broken-` copies of a file that could not be read. ++// The line lists names only — `lstat` for each and one `readdir` for the broken names, never an ++// open: a backup holds the same members, messages and saved keys as the file itself. ++test('a host with no state backups says none', async (t) => { ++ const { rt } = await realHost(t) ++ await rt.save() ++ const report = await rt.diagnostics.report({}) ++ assert.equal(report.facts.stateBackups, 'none') ++ assert.match(report.markdown, /^state backups: none$/m) ++}) ++ ++test('one readable backup is named with its size and age, and no other line moves', async (t) => { ++ const { home, rt } = await realHost(t) ++ await rt.save() ++ const before = (await rt.diagnostics.report({})).markdown.split('\n') ++ const v1 = join(home, 'state.json.v1') ++ writeFileSync(v1, 'x'.repeat(12 * 1024)) ++ const twoHoursAgo = new Date(Date.now() - 2 * 3_600_000) ++ utimesSync(v1, twoHoursAgo, twoHoursAgo) ++ ++ const report = await rt.diagnostics.report({}) ++ assert.equal(report.facts.stateBackups, 'state.json.v1 (12 KB, 2 h ago)') ++ const shown = report.markdown.split('\n') ++ const at = shown.indexOf('state backups: state.json.v1 (12 KB, 2 h ago)') ++ assert.ok(at > 0, 'the report carries the line') ++ assert.match(shown[at - 1], /^state file: /, 'it follows the team file line inside the same block') ++ const withoutBackups = text => text.filter(line => !line.startsWith('state backups: ')) ++ assert.deepEqual(withoutBackups(shown), withoutBackups(before), 'the backups line is the only line this change adds') ++}) ++ ++test('a backup whose lstat fails is named with its code and the report still prints', async (t) => { ++ const home = mkdtempSync(join(tmpdir(), 'ds-bot-diag-')) ++ t.after(() => { ++ try { chmodSync(home, 0o700) } catch { /* the home is already gone */ } ++ rmSync(home, { recursive: true, force: true, maxRetries: 5 }) ++ }) ++ writeFileSync(join(home, 'state.json'), '{"version":2}\n') ++ writeFileSync(join(home, 'state.json.v1'), '{"version":1}\n') ++ chmodSync(home, 0o000) ++ const report = await host(home, { config: { diagnosticsFile: false }, statePath: join(home, 'state.json') }).diagnostics.report({}) ++ assert.equal(report.facts.stateBackups, 'state.json.v1 (unreadable: EACCES)') ++ assert.match(report.markdown, /^state backups: state\.json\.v1 \(unreadable: EACCES\)$/m) ++ assert.match(report.markdown, /^state file: /m, 'the team file line still prints') ++}) ++ ++test('a backup that is a directory or a link to nothing is drawn from its own lstat', async (t) => { ++ const asDir = await realHost(t) ++ mkdirSync(join(asDir.home, 'state.json.v1')) ++ const dirReport = await asDir.rt.diagnostics.report({}) ++ assert.match(dirReport.facts.stateBackups, /^state\.json\.v1 \(\d+ (?:B|KB|MB), .+\)$/, ++ 'a name lstat answers for keeps the size-and-age shape, whatever it points at') ++ assert.doesNotMatch(dirReport.markdown, /unreadable: not a plain file/, 'no sentence is invented for a name lstat could describe') ++ ++ const dangling = await realHost(t) ++ symlinkSync(join(dangling.home, 'missing-backup'), join(dangling.home, 'state.json.v1')) ++ const linkReport = await dangling.rt.diagnostics.report({}) ++ assert.match(linkReport.facts.stateBackups, /^state\.json\.v1 \(\d+ (?:B|KB|MB), .+\)$/) ++ assert.equal(existsSync(join(dangling.home, 'missing-backup')), false, 'asking the link creates nothing') ++}) ++ ++test('the broken copies are listed newest first, three at most, with the rest counted', async (t) => { ++ const { home, rt } = await realHost(t, { locked: true }) ++ const readOnly = (await rt.diagnostics.report({})).markdown.match(/^\| Read-only \| (.*)$/m)?.[1] ++ for (let i = 1; i <= 5; i += 1) { ++ const name = join(home, `state.json.broken-${1000 + i}`) ++ writeFileSync(name, 'x'.repeat(12 * 1024)) ++ const when = new Date(Date.now() - i * 3_600_000) ++ utimesSync(name, when, when) ++ } ++ writeFileSync(join(home, 'other.txt'), 'not a backup') ++ const report = await rt.diagnostics.report({}) ++ assert.equal(report.facts.stateBackups, 'state.json.broken-1001 (12 KB, 1 h ago), state.json.broken-1002 (12 KB, 2 h ago), state.json.broken-1003 (12 KB, 3 h ago), and 2 more') ++ assert.match(report.markdown, /^state backups: state\.json\.broken-1001 /m) ++ assert.doesNotMatch(report.markdown, /other\.txt/, "only the store's own backup prefix is listed") ++ assert.equal(report.markdown.match(/^\| Read-only \| (.*)$/m)?.[1], readOnly, 'the Read-only row is unchanged') ++}) ++ +// The line is read information, not a write: a window another dsh keeps read-only still names the +// team file, the way it still counts the team it shows. +test('a read-only window names the team file too (#2394)', async (t) => {