Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2434-state-backups.added.md
Original file line number Diff line number Diff line change
@@ -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-<stamp>` 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)).
69 changes: 66 additions & 3 deletions core/src/host/diagnostics.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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-<stamp>` 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))]
Expand All @@ -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
Expand Down
81 changes: 80 additions & 1 deletion core/test/diagnostics.test.js
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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-<stamp>` 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) => {
Expand Down
Loading
Loading