diff --git a/changelog.d/2604.fixed.md b/changelog.d/2604.fixed.md new file mode 100644 index 000000000..d0410e923 --- /dev/null +++ b/changelog.d/2604.fixed.md @@ -0,0 +1 @@ +The Diagnostics report and its downloaded log now say when `logs/diagnostics.jsonl` could not be read at start-up, instead of reporting `No host errors recorded.` for a log that exists and holds errors: the report names the reason (`not read (EACCES)`), says that the errors the log holds are not shown, and the entry list and the download carry that reason rather than an empty answer, while the errors this host recorded since start-up stay listed ([#2604](https://github.com/ThinkFlowLab/FlyBot/issues/2604)). diff --git a/core/src/host/diagnostics.js b/core/src/host/diagnostics.js index 71a31cbb7..16970d742 100644 --- a/core/src/host/diagnostics.js +++ b/core/src/host/diagnostics.js @@ -103,8 +103,13 @@ 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, ...stateBackups, ...journal, ...log, ''] - out.push(recent.length === 0 ? 'No host errors recorded.' : `Recent host errors (${recent.length} of ${entries.length}, oldest first):`) + // A log this host could not read is not a log with no errors (#2604): the sentence stands where + // `No host errors recorded.` would claim otherwise, and the entries below it stay listed. + const unread = facts.diagnosticsLogUnread === undefined ? [] + : ['', `The diagnostics log could not be read (${facts.diagnosticsLogUnread}); the errors it holds are not shown.`] + const out = [...lines, ...stateFile, ...stateBackups, ...journal, ...log, ...unread, ''] + if (recent.length > 0) out.push(`Recent host errors (${recent.length} of ${entries.length}, oldest first):`) + else if (unread.length === 0) out.push('No host errors recorded.') if (recent.length > 0) out.push('```', ...recent.map(entryLine), '```') const stacks = recent.filter(entry => entry.stack).slice(-3) for (const entry of stacks) out.push('', `
Stack: ${cut(entry.text, 80).text.replace(/`, '', '```', entry.stack, '```', '
') @@ -149,7 +154,10 @@ export function install(rt) { const clean = text => cleanText(text, { redact: rt.redactSecrets }) - // Entries from earlier runs, read once; new ones are kept apart until then. + // Entries from earlier runs, read once; new ones are kept apart until then. A read that failed + // (`EACCES`, `EISDIR`, a name this host may not open) is not a log without errors (#2604): the + // rejection is kept, the report names it, and the entries this host records still count. + let earlierFailure const earlier = toFile ? readFile(logPath, 'utf8').then(text => text.split('\n').filter(Boolean).slice(-KEEP).flatMap((line) => { try { @@ -158,7 +166,10 @@ export function install(rt) { } catch { return [] } - }), () => []) + }), (error) => { + earlierFailure = error?.code ?? clean(error?.message ?? String(error)) + return [] + }) : Promise.resolve([]) let cleared = 0 @@ -188,6 +199,9 @@ export function install(rt) { if (size > LOG_MAX_BYTES) await rename(logPath, `${logPath}.1`) await appendFile(logPath, `${JSON.stringify(entry)}\n`, 'utf8') fileFailure = undefined + // A line that landed describes a log this host writes to again, so the read failure clears + // the way the write one does; the start-up read is not repeated either way. + earlierFailure = undefined }).catch((error) => { fileFailure = error?.code ?? clean(error?.message ?? String(error)) if (fileFailed) return @@ -243,11 +257,16 @@ export function install(rt) { const entries = async () => { const old = (await earlier).filter(entry => entry.time > cleared) - return [...old, ...fresh].slice(-KEEP).map(entry => ({ + const list = [...old, ...fresh].slice(-KEEP).map(entry => ({ ...entry, text: clean(entry.text), ...(entry.stack ? { stack: clean(entry.stack) } : {}), })) + // A log this host could not read is not an empty one (#2604): the answer carries the reason + // beside the entries, so a caller that would show "no entries" can say why instead. It is a + // property of the answer, so every caller that reads the array still gets the array it knew. + if (earlierFailure !== undefined) list.unread = earlierFailure + return list } // A file named without opening it (#2388): `lstat` answers what the name is — a missing file is @@ -355,6 +374,17 @@ export function install(rt) { return named.length === 0 ? 'none' : named.join(', ') } + // The write side and the read side of the log are different facts (#2383, #2604): an append that + // lands says nothing about the lines this host never read, so the one line names both when both + // apply, and `recording` only when neither does. + const logState = () => { + const written = leafRefusedNow ? 'not written (not a plain file of its own)' + : fileFailure ? `not written (${fileFailure})` + : undefined + const read = earlierFailure === undefined ? undefined : `not read (${earlierFailure})` + return [written, read].filter(Boolean).join(', ') || 'recording' + } + const facts = async () => { const bots = Object.values(rt.state.bots) const models = [...new Set(bots.map(bot => bot.model ?? bot.appliedModel).filter(Boolean))] @@ -398,11 +428,13 @@ export function install(rt) { // with the log off says nothing (#2134), a host whose appends land says so, and a refused // leaf or a failed append names its own reason — the words stderr already used, never the // folder, and never a claim about a file behind a link this host did not write (#2089). Both - // reasons are read as they stand now, not as the stderr latches left them (#2383). - diagnosticsLog: !toFile ? undefined - : leafRefusedNow ? 'not written (not a plain file of its own)' - : fileFailure ? `not written (${fileFailure})` - : 'recording', + // reasons are read as they stand now, not as the stderr latches left them (#2383). The + // start-up read is the third (#2604): a log this host could not open leaves every earlier + // line out of the list, so `recording` alone would claim a read that never happened. + diagnosticsLog: !toFile ? undefined : logState(), + // The read's own reason, so the report's text can say the earlier lines are missing without + // reading it back out of the sentence above (#2604). + diagnosticsLogUnread: !toFile ? undefined : earlierFailure, } } @@ -429,6 +461,10 @@ export function install(rt) { facts: hostFacts, entries: list.slice(-50).reverse(), total: list.length, + // The read failure on its own (#2604): additive, so a window that knows no such field keeps + // reading `entries` and `total` as it did, and one that does can tell an empty log from one + // this host never opened. + ...(list.unread === undefined ? {} : { unread: list.unread }), markdown: reportMarkdown({ facts: hostFacts, client, entries: list, clientEntries }), issuesUrl: ISSUES_URL, } @@ -437,7 +473,11 @@ export function install(rt) { async function logText() { await rt.loadSecrets?.().catch(() => { /* without stored secrets the log is still built, redacting what it can */ }) const list = await entries() - return list.map(entry => (entry.stack ? `${entryLine(entry)}\n${entry.stack.replace(/^/gm, ' ')}` : entryLine(entry))).join('\n') + const lines = list.map(entry => (entry.stack ? `${entryLine(entry)}\n${entry.stack.replace(/^/gm, ' ')}` : entryLine(entry))) + // The download is what a user hands over with a fault report, so it must not read as a log with + // no entries when the reason it is empty is that this host could not read it (#2604). + if (list.unread !== undefined) lines.push(`the diagnostics log could not be read (${list.unread}); the errors it holds are not shown`) + return lines.join('\n') } async function clear() { diff --git a/core/test/diagnostics.test.js b/core/test/diagnostics.test.js index d808df579..e36f0d134 100644 --- a/core/test/diagnostics.test.js +++ b/core/test/diagnostics.test.js @@ -607,6 +607,79 @@ test('the report says nothing about the log while it is off, and recording while assert.match(live.markdown, /^diagnostics log: recording$/m) }) +// #2604: the log is read once at start-up and the rejection was discarded (`() => []`), so a log +// that exists and holds recorded errors but cannot be opened answered `No host errors recorded.` — +// the read side of #2383. Root reads a file without its read bit, so the case is skipped there. +test('the report says the diagnostics log could not be read (#2604)', async (t) => { + if (process.getuid?.() === 0) { + t.skip('root reads a file without its read bit') + return + } + const home = mkdtempSync(join(tmpdir(), 'ds-bot-diag-')) + mkdirSync(join(home, 'logs'), { recursive: true }) + const logPath = join(home, 'logs', 'diagnostics.jsonl') + writeFileSync(logPath, `${JSON.stringify({ time: Date.now() - 60_000, level: 'error', source: 'host', text: 'state write failed' })}\n`) + chmodSync(logPath, 0o000) + t.after(() => { + try { chmodSync(logPath, 0o644) } catch { /* the home may already be gone */ } + rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + }) + assert.throws(() => readFileSync(logPath), /EACCES/, 'this file cannot be read at all') + + const rt = host(home) + const report = await rt.diagnostics.report({}) + + assert.equal(report.facts.diagnosticsLog, 'not read (EACCES)', 'the report names the read it could not make') + assert.match(report.markdown, /^diagnostics log: not read \(EACCES\)$/m) + assert.match(report.markdown, /The diagnostics log could not be read \(EACCES\); the errors it holds are not shown\./) + assert.doesNotMatch(report.markdown, /No host errors recorded\./, 'an unread log is not a log with no errors') + assert.equal(report.total, 0, 'the lines it could not read are counted as nothing rather than guessed at') + const listed = await rt.diagnostics.entries() + assert.equal(listed.length, 0, 'the list does not invent the entries it could not read') + assert.equal(listed.unread, 'EACCES', 'and says the log was not read rather than being empty') + assert.equal(report.unread, 'EACCES', 'the report answers the same way') + assert.match(await rt.diagnostics.logText(), /could not be read \(EACCES\); the errors it holds are not shown/, 'the Download says so instead of handing over an empty file') + assert.equal(report.markdown.includes(home), false, 'the sentence never carries the home folder') +}) + +// The other half of #2604: the read failure is about the lines an earlier run wrote, so the errors +// this host recorded since start-up still reach the report — and the line names both faults when the +// same leaf refuses the append too. +test('a failed read of the log keeps the errors this host recorded since start-up (#2604)', async (t) => { + if (process.getuid?.() === 0) { + t.skip('root reads a file without its read bit') + return + } + const home = mkdtempSync(join(tmpdir(), 'ds-bot-diag-')) + mkdirSync(join(home, 'logs'), { recursive: true }) + const logPath = join(home, 'logs', 'diagnostics.jsonl') + writeFileSync(logPath, `${JSON.stringify({ time: Date.now() - 60_000, level: 'error', source: 'host', text: 'an earlier run line' })}\n`) + chmodSync(logPath, 0o000) + t.after(() => { + try { chmodSync(logPath, 0o644) } catch { /* the home may already be gone */ } + rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + }) + + const stderr = [] + const quiet = t.mock.method(console, 'error', (...args) => stderr.push(args.join(' '))) + const rt = host(home) + rt.warn('ds-bot: a line written since start-up') + await rt.dispose() + const report = await rt.diagnostics.report({}) + quiet.mock.restore() + + assert.equal(report.total, 1, 'the line this host recorded is still counted') + assert.equal(report.entries[0].text, 'a line written since start-up') + assert.match(report.markdown, /a line written since start-up/) + assert.match(report.markdown, /The diagnostics log could not be read \(EACCES\); the errors it holds are not shown\./) + assert.doesNotMatch(report.markdown, /No host errors recorded\./) + assert.equal(report.facts.diagnosticsLog, 'not written (EACCES), not read (EACCES)', 'the one line names both faults') + const download = await rt.diagnostics.logText() + assert.match(download, /a line written since start-up/, 'the Download still carries the live line') + assert.match(download, /could not be read \(EACCES\)/, 'and says the earlier lines were not read') + assert.equal(stderr.filter(line => /not written/.test(line)).length, 1, 'stderr keeps its one line') +}) + // The Read-only row answered one constant for every window that may not write, but the store // answers three ways (#2153): `stuck` (the team file cannot be read) is not a live lock at all, // and a report that calls a newer or unreadable team file "another dsh holds the team" sends the diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 1e9dd0897..d5f99f0cb 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -5131,11 +5131,12 @@ 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,26 @@ export function reportMarkdown({ facts, client = {}, entries = [], clientEntries +@@ -78,11 +78,31 @@ export function reportMarkdown({ facts, client = {}, entries = [], clientEntries row('Read-only', facts.readOnly), ].filter(Boolean) const recent = entries.slice(-REPORT_ENTRIES) - const out = [...lines, ''] +- out.push(recent.length === 0 ? 'No host errors recorded.' : `Recent host errors (${recent.length} of ${entries.length}, oldest first):`) + // 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}`] @@ -5151,8 +5152,13 @@ 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, ...stateBackups, ...journal, ...log, ''] - out.push(recent.length === 0 ? 'No host errors recorded.' : `Recent host errors (${recent.length} of ${entries.length}, oldest first):`) ++ // A log this host could not read is not a log with no errors (#2604): the sentence stands where ++ // `No host errors recorded.` would claim otherwise, and the entries below it stay listed. ++ const unread = facts.diagnosticsLogUnread === undefined ? [] ++ : ['', `The diagnostics log could not be read (${facts.diagnosticsLogUnread}); the errors it holds are not shown.`] ++ const out = [...lines, ...stateFile, ...stateBackups, ...journal, ...log, ...unread, ''] ++ if (recent.length > 0) out.push(`Recent host errors (${recent.length} of ${entries.length}, oldest first):`) ++ else if (unread.length === 0) out.push('No host errors recorded.') if (recent.length > 0) out.push('```', ...recent.map(entryLine), '```') const stacks = recent.filter(entry => entry.stack).slice(-3) - for (const entry of stacks) out.push('', `
Stack: ${entry.text.slice(0, 80).replace(/`, '', '```', entry.stack, '```', '
') @@ -5160,7 +5166,7 @@ diff --git a/src/host/diagnostics.js b/src/host/diagnostics.js if (clientEntries.length > 0) { out.push('', `Recent browser errors (${Math.min(clientEntries.length, 10)}):`, '```', ...clientEntries.slice(-10).map(entryLine), '```') } -@@ -105,19 +105,22 @@ const dshVersion = () => { +@@ -105,23 +105,29 @@ const dshVersion = () => { try { const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) if (manifest.name === '@deepseek-ai/dsh-llm') return manifest.version @@ -5186,7 +5192,23 @@ diff --git a/src/host/diagnostics.js b/src/host/diagnostics.js const clean = text => cleanText(text, { redact: rt.redactSecrets }) -@@ -134,20 +134,53 @@ export function install(rt) { +- // Entries from earlier runs, read once; new ones are kept apart until then. ++ // Entries from earlier runs, read once; new ones are kept apart until then. A read that failed ++ // (`EACCES`, `EISDIR`, a name this host may not open) is not a log without errors (#2604): the ++ // rejection is kept, the report names it, and the entries this host records still count. ++ let earlierFailure + const earlier = toFile + ? readFile(logPath, 'utf8').then(text => text.split('\n').filter(Boolean).slice(-KEEP).flatMap((line) => { + try { +@@ -130,24 +130,63 @@ export function install(rt) { + } catch { + return [] + } +- }), () => []) ++ }), (error) => { ++ earlierFailure = error?.code ?? clean(error?.message ?? String(error)) ++ return [] ++ }) : Promise.resolve([]) let cleared = 0 @@ -5216,6 +5238,9 @@ diff --git a/src/host/diagnostics.js b/src/host/diagnostics.js if (size > LOG_MAX_BYTES) await rename(logPath, `${logPath}.1`) await appendFile(logPath, `${JSON.stringify(entry)}\n`, 'utf8') + fileFailure = undefined ++ // A line that landed describes a log this host writes to again, so the read failure clears ++ // the way the write one does; the start-up read is not repeated either way. ++ earlierFailure = undefined }).catch((error) => { + fileFailure = error?.code ?? clean(error?.message ?? String(error)) if (fileFailed) return @@ -5252,11 +5277,23 @@ 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,164 @@ export function install(rt) { +@@ -180,31 +180,189 @@ export function install(rt) { + + const entries = async () => { + const old = (await earlier).filter(entry => entry.time > cleared) +- return [...old, ...fresh].slice(-KEEP).map(entry => ({ ++ const list = [...old, ...fresh].slice(-KEEP).map(entry => ({ + ...entry, + text: clean(entry.text), + ...(entry.stack ? { stack: clean(entry.stack) } : {}), })) - } - -- const facts = () => { ++ // A log this host could not read is not an empty one (#2604): the answer carries the reason ++ // beside the entries, so a caller that would show "no entries" can say why instead. It is a ++ // property of the answer, so every caller that reads the array still gets the array it knew. ++ if (earlierFailure !== undefined) list.unread = earlierFailure ++ return list ++ } ++ + // A file named without opening it (#2388): `lstat` answers what the name is — a missing file is + // the writer's to create, a plain file of this host's own has a size and a last write time worth + // reporting, and a link, a second name or a check that cannot run is bytes this report may not @@ -5362,6 +5399,18 @@ diff --git a/src/host/diagnostics.js b/src/host/diagnostics.js + return named.length === 0 ? 'none' : named.join(', ') + } + ++ // The write side and the read side of the log are different facts (#2383, #2604): an append that ++ // lands says nothing about the lines this host never read, so the one line names both when both ++ // apply, and `recording` only when neither does. ++ const logState = () => { ++ const written = leafRefusedNow ? 'not written (not a plain file of its own)' ++ : fileFailure ? `not written (${fileFailure})` ++ : undefined ++ const read = earlierFailure === undefined ? undefined : `not read (${earlierFailure})` ++ return [written, read].filter(Boolean).join(', ') || 'recording' + } + +- const facts = () => { + const facts = async () => { const bots = Object.values(rt.state.bots) const models = [...new Set(bots.map(bot => bot.model ?? bot.appliedModel).filter(Boolean))] @@ -5406,11 +5455,13 @@ diff --git a/src/host/diagnostics.js b/src/host/diagnostics.js + // with the log off says nothing (#2134), a host whose appends land says so, and a refused + // leaf or a failed append names its own reason — the words stderr already used, never the + // folder, and never a claim about a file behind a link this host did not write (#2089). Both -+ // reasons are read as they stand now, not as the stderr latches left them (#2383). -+ diagnosticsLog: !toFile ? undefined -+ : leafRefusedNow ? 'not written (not a plain file of its own)' -+ : fileFailure ? `not written (${fileFailure})` -+ : 'recording', ++ // reasons are read as they stand now, not as the stderr latches left them (#2383). The ++ // start-up read is the third (#2604): a log this host could not open leaves every earlier ++ // line out of the list, so `recording` alone would claim a read that never happened. ++ diagnosticsLog: !toFile ? undefined : logState(), ++ // The read's own reason, so the report's text can say the earlier lines are missing without ++ // reading it back out of the sentence above (#2604). ++ diagnosticsLogUnread: !toFile ? undefined : earlierFailure, } } @@ -5420,7 +5471,7 @@ diff --git a/src/host/diagnostics.js b/src/host/diagnostics.js return { surface: pick(client?.surface, 40), language: pick(client?.language, 20), -@@ -213,15 +213,15 @@ export function install(rt) { +@@ -213,36 +213,105 @@ export function install(rt) { } const sanitizeClientEntries = list => (Array.isArray(list) ? list : []).slice(-20).flatMap((item) => { if (typeof item?.text !== 'string' || typeof item.time !== 'number') return [] @@ -5439,14 +5490,26 @@ diff --git a/src/host/diagnostics.js b/src/host/diagnostics.js return { facts: hostFacts, entries: list.slice(-50).reverse(), -@@ -232,17 +232,78 @@ export function install(rt) { + total: list.length, ++ // The read failure on its own (#2604): additive, so a window that knows no such field keeps ++ // reading `entries` and `total` as it did, and one that does can tell an empty log from one ++ // this host never opened. ++ ...(list.unread === undefined ? {} : { unread: list.unread }), + markdown: reportMarkdown({ facts: hostFacts, client, entries: list, clientEntries }), + issuesUrl: ISSUES_URL, + } } async function logText() { - await rt.loadSecrets?.().catch(() => {}) + await rt.loadSecrets?.().catch(() => { /* without stored secrets the log is still built, redacting what it can */ }) const list = await entries() - return list.map(entry => (entry.stack ? `${entryLine(entry)}\n${entry.stack.replace(/^/gm, ' ')}` : entryLine(entry))).join('\n') +- return list.map(entry => (entry.stack ? `${entryLine(entry)}\n${entry.stack.replace(/^/gm, ' ')}` : entryLine(entry))).join('\n') ++ const lines = list.map(entry => (entry.stack ? `${entryLine(entry)}\n${entry.stack.replace(/^/gm, ' ')}` : entryLine(entry))) ++ // The download is what a user hands over with a fault report, so it must not read as a log with ++ // no entries when the reason it is empty is that this host could not read it (#2604). ++ if (list.unread !== undefined) lines.push(`the diagnostics log could not be read (${list.unread}); the errors it holds are not shown`) ++ return lines.join('\n') } async function clear() { diff --git a/patches/ds-bot/0007-version-and-tooling.patch b/patches/ds-bot/0007-version-and-tooling.patch index c2d16133e..8d250aafc 100644 --- a/patches/ds-bot/0007-version-and-tooling.patch +++ b/patches/ds-bot/0007-version-and-tooling.patch @@ -1032,7 +1032,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,678 @@ test('diagnostics append fallback redacts an error message', async (t) => { +@@ -133,6 +133,751 @@ test('diagnostics append fallback redacts an error message', async (t) => { assert.match(stderr.join('\n'), /api_key=\[hidden\]/) }) @@ -1368,6 +1368,79 @@ diff --git a/test/diagnostics.test.js b/test/diagnostics.test.js + assert.match(live.markdown, /^diagnostics log: recording$/m) +}) + ++// #2604: the log is read once at start-up and the rejection was discarded (`() => []`), so a log ++// that exists and holds recorded errors but cannot be opened answered `No host errors recorded.` — ++// the read side of #2383. Root reads a file without its read bit, so the case is skipped there. ++test('the report says the diagnostics log could not be read (#2604)', async (t) => { ++ if (process.getuid?.() === 0) { ++ t.skip('root reads a file without its read bit') ++ return ++ } ++ const home = mkdtempSync(join(tmpdir(), 'ds-bot-diag-')) ++ mkdirSync(join(home, 'logs'), { recursive: true }) ++ const logPath = join(home, 'logs', 'diagnostics.jsonl') ++ writeFileSync(logPath, `${JSON.stringify({ time: Date.now() - 60_000, level: 'error', source: 'host', text: 'state write failed' })}\n`) ++ chmodSync(logPath, 0o000) ++ t.after(() => { ++ try { chmodSync(logPath, 0o644) } catch { /* the home may already be gone */ } ++ rmSync(home, { recursive: true, force: true, maxRetries: 5 }) ++ }) ++ assert.throws(() => readFileSync(logPath), /EACCES/, 'this file cannot be read at all') ++ ++ const rt = host(home) ++ const report = await rt.diagnostics.report({}) ++ ++ assert.equal(report.facts.diagnosticsLog, 'not read (EACCES)', 'the report names the read it could not make') ++ assert.match(report.markdown, /^diagnostics log: not read \(EACCES\)$/m) ++ assert.match(report.markdown, /The diagnostics log could not be read \(EACCES\); the errors it holds are not shown\./) ++ assert.doesNotMatch(report.markdown, /No host errors recorded\./, 'an unread log is not a log with no errors') ++ assert.equal(report.total, 0, 'the lines it could not read are counted as nothing rather than guessed at') ++ const listed = await rt.diagnostics.entries() ++ assert.equal(listed.length, 0, 'the list does not invent the entries it could not read') ++ assert.equal(listed.unread, 'EACCES', 'and says the log was not read rather than being empty') ++ assert.equal(report.unread, 'EACCES', 'the report answers the same way') ++ assert.match(await rt.diagnostics.logText(), /could not be read \(EACCES\); the errors it holds are not shown/, 'the Download says so instead of handing over an empty file') ++ assert.equal(report.markdown.includes(home), false, 'the sentence never carries the home folder') ++}) ++ ++// The other half of #2604: the read failure is about the lines an earlier run wrote, so the errors ++// this host recorded since start-up still reach the report — and the line names both faults when the ++// same leaf refuses the append too. ++test('a failed read of the log keeps the errors this host recorded since start-up (#2604)', async (t) => { ++ if (process.getuid?.() === 0) { ++ t.skip('root reads a file without its read bit') ++ return ++ } ++ const home = mkdtempSync(join(tmpdir(), 'ds-bot-diag-')) ++ mkdirSync(join(home, 'logs'), { recursive: true }) ++ const logPath = join(home, 'logs', 'diagnostics.jsonl') ++ writeFileSync(logPath, `${JSON.stringify({ time: Date.now() - 60_000, level: 'error', source: 'host', text: 'an earlier run line' })}\n`) ++ chmodSync(logPath, 0o000) ++ t.after(() => { ++ try { chmodSync(logPath, 0o644) } catch { /* the home may already be gone */ } ++ rmSync(home, { recursive: true, force: true, maxRetries: 5 }) ++ }) ++ ++ const stderr = [] ++ const quiet = t.mock.method(console, 'error', (...args) => stderr.push(args.join(' '))) ++ const rt = host(home) ++ rt.warn('ds-bot: a line written since start-up') ++ await rt.dispose() ++ const report = await rt.diagnostics.report({}) ++ quiet.mock.restore() ++ ++ assert.equal(report.total, 1, 'the line this host recorded is still counted') ++ assert.equal(report.entries[0].text, 'a line written since start-up') ++ assert.match(report.markdown, /a line written since start-up/) ++ assert.match(report.markdown, /The diagnostics log could not be read \(EACCES\); the errors it holds are not shown\./) ++ assert.doesNotMatch(report.markdown, /No host errors recorded\./) ++ assert.equal(report.facts.diagnosticsLog, 'not written (EACCES), not read (EACCES)', 'the one line names both faults') ++ const download = await rt.diagnostics.logText() ++ assert.match(download, /a line written since start-up/, 'the Download still carries the live line') ++ assert.match(download, /could not be read \(EACCES\)/, 'and says the earlier lines were not read') ++ assert.equal(stderr.filter(line => /not written/.test(line)).length, 1, 'stderr keeps its one line') ++}) ++ +// The Read-only row answered one constant for every window that may not write, but the store +// answers three ways (#2153): `stuck` (the team file cannot be read) is not a live lock at all, +// and a report that calls a newer or unreadable team file "another dsh holds the team" sends the