mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* fix(diagnostics): collect the AMR run's runtime log records (OPEND-3498) AMR incidents never carried AMR-side evidence. The collector looked for OpenCode logs under <dataDir>/amr/opencode-home, but Vela drops the OPENCODE_TEST_HOME it receives and runs OpenCode under per-conversation homes in $AMR_HOME/opencode-sessions, so nothing was listed and no note explained the gap. $AMR_HOME/logs/agent-runtime.jsonl was never listed either. - Take the incident run's records from agent-runtime.jsonl, plus those of the OpenCode sessions it owns until another run takes a session over, so other runs' records never enter the bundle. - Report a missing runtime log, a missing run id and the not-yet-located session logs as explicit collection notes, and keep those reasons instead of a consent verdict. - Give sources that appear after opting in a boundary at their current size on start, so a newly listed shared log is not omitted forever. Locating the per-conversation OpenCode session logs is left for a follow-up. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(diagnostics): keep a rotated log's baseline when extending the fence On Windows the packaged launcher rotates latest.log to previous.log at every start. extend() saw previous.log as an unbaselined path and recorded a boundary at its current size, so the rotated log's bytes written after the real boundary were dropped as consent_boundary. Found in a packaged Windows run: the daemon log written after opting in was missing from the bundle. Skip files that share a baselined creation time; the fence decides their boundary from that baseline. Skipping only defers to apply() and never admits bytes by itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
89 lines
6.4 KiB
TypeScript
89 lines
6.4 KiB
TypeScript
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { gunzipSync } from 'node:zlib';
|
|
import { afterEach, expect, it } from 'vitest';
|
|
import { buildAutomaticDiagnostics, DIAGNOSTIC_CHUNK_BYTES, DIAGNOSTIC_DELIVERY_LOG_PREFIX } from '../src/automatic.js';
|
|
|
|
const dirs: string[] = [];
|
|
afterEach(async () => { await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); });
|
|
it('streams text only, redacts JSONL secrets, and reports omissions and truncation', async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), 'diagnostic-auto-')); dirs.push(dir);
|
|
await writeFile(join(dir, 'events'), '{"token":"hidden"}\n{"message":"failure"}\n[debug] {"apiKey":"prefixed-secret"}\n');
|
|
await writeFile(join(dir, 'dump'), 'NEVER UPLOAD');
|
|
const result = await buildAutomaticDiagnostics({ directory: join(dir, 'bundle'), incidentId: 'incident-a',
|
|
summary: { error: 'Authorization: Bearer credential123' }, sources: [
|
|
{ name: 'events.jsonl', absolutePath: join(dir, 'events'), kind: 'text' },
|
|
{ name: 'crash.dmp', absolutePath: join(dir, 'dump'), kind: 'binary' },
|
|
{ name: 'missing.log', absolutePath: join(dir, 'missing'), kind: 'text' },
|
|
] });
|
|
const chunks = await Promise.all(result.manifest.chunks.map((c) => readFile(join(dir, 'bundle', String(c.index)))));
|
|
const text = gunzipSync(Buffer.concat(chunks)).toString();
|
|
expect(text).not.toContain('hidden'); expect(text).not.toContain('credential123'); expect(text).not.toContain('NEVER UPLOAD');
|
|
expect(text).not.toContain('prefixed-secret');
|
|
expect(text).toContain('failure'); expect(text).toContain('missing.log');
|
|
expect(result.manifest.completeness).toBe('partial');
|
|
expect(result.manifest.compressedBytes).toBe(Buffer.concat(chunks).length);
|
|
});
|
|
|
|
it('keeps only selected lines of a shared log and notes when none belong to the incident', async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), 'diagnostic-auto-')); dirs.push(dir);
|
|
await writeFile(join(dir, 'shared'), '{"run":"a","message":"mine"}\n{"run":"b","message":"someone else"}\n');
|
|
const pick = (run: string) => (lines: string[]) => lines.filter((line) => line.includes(`"run":"${run}"`));
|
|
const result = await buildAutomaticDiagnostics({ directory: join(dir, 'bundle'), incidentId: 'incident-b', summary: {}, sources: [
|
|
{ name: 'shared.jsonl', absolutePath: join(dir, 'shared'), kind: 'text', selectLines: pick('a') },
|
|
{ name: 'shared-again.jsonl', absolutePath: join(dir, 'shared'), kind: 'text', selectLines: pick('c') },
|
|
] });
|
|
const chunks = await Promise.all(result.manifest.chunks.map((c) => readFile(join(dir, 'bundle', String(c.index)))));
|
|
const records = gunzipSync(Buffer.concat(chunks)).toString().trim().split('\n').map((line) => JSON.parse(line));
|
|
expect(records.find((r) => r.name === 'shared.jsonl').content).toContain('mine');
|
|
expect(JSON.stringify(records)).not.toContain('someone else');
|
|
expect(records.at(-1).notes).toEqual([{ name: 'shared-again.jsonl', reason: 'no_matching_records' }]);
|
|
});
|
|
|
|
it('drops earlier delivery receipts so they cannot crowd the real log out of the bundle', async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), 'diagnostic-auto-')); dirs.push(dir);
|
|
const receipt = `${DIAGNOSTIC_DELIVERY_LOG_PREFIX} incident-a diagnostics/v1/manifest.json\n`;
|
|
const receipts = receipt.repeat(Math.ceil(DIAGNOSTIC_CHUNK_BYTES / Buffer.byteLength(receipt)) + 1);
|
|
expect(Buffer.byteLength(receipts)).toBeGreaterThan(DIAGNOSTIC_CHUNK_BYTES);
|
|
await writeFile(join(dir, 'daemon'), '[od] hub events channel connected\n' + receipts + '[od] run failed: upstream closed');
|
|
const result = await buildAutomaticDiagnostics({ directory: join(dir, 'bundle'), incidentId: 'incident-b',
|
|
summary: {}, sources: [{ name: 'logs/daemon/latest.log', absolutePath: join(dir, 'daemon'), kind: 'text' }] });
|
|
const chunks = await Promise.all(result.manifest.chunks.map((c) => readFile(join(dir, 'bundle', String(c.index)))));
|
|
const text = gunzipSync(Buffer.concat(chunks)).toString();
|
|
expect(text).not.toContain(DIAGNOSTIC_DELIVERY_LOG_PREFIX);
|
|
expect(text).toContain('hub events channel connected');
|
|
expect(text).toContain('run failed: upstream closed');
|
|
});
|
|
|
|
it.each([false, true])('respects the consent boundary while scanning past receipts (mid-line: %s)', async (midLine) => {
|
|
const dir = await mkdtemp(join(tmpdir(), 'diagnostic-auto-')); dirs.push(dir);
|
|
const before = 'private before consent\n';
|
|
const partial = midLine ? 'private boundary fragment\n' : '';
|
|
const kept = '故障 after consent\n';
|
|
const receipt = `${DIAGNOSTIC_DELIVERY_LOG_PREFIX} ${'r'.repeat(128 * 1024)}\n`;
|
|
await writeFile(join(dir, 'daemon'), before + partial + kept + receipt);
|
|
const result = await buildAutomaticDiagnostics({ directory: join(dir, 'bundle'), incidentId: 'consent',
|
|
summary: {}, sources: [{ name: 'daemon.log', absolutePath: join(dir, 'daemon'), kind: 'text',
|
|
startOffset: Buffer.byteLength(before) + (midLine ? 3 : 0) }] });
|
|
const chunks = await Promise.all(result.manifest.chunks.map((c) => readFile(join(dir, 'bundle', String(c.index)))));
|
|
const records = gunzipSync(Buffer.concat(chunks)).toString().trim().split('\n').map((line) => JSON.parse(line));
|
|
expect(records.find((record) => record.type === 'file').content).toBe(kept);
|
|
});
|
|
|
|
it.each([0, 1])('applies the byte budget to complete retained lines (extra byte: %s)', async (extraByte) => {
|
|
const dir = await mkdtemp(join(tmpdir(), 'diagnostic-auto-')); dirs.push(dir);
|
|
const older = 'older failure\n';
|
|
const newest = '最新 failure\n';
|
|
const receipt = `${DIAGNOSTIC_DELIVERY_LOG_PREFIX} ${'r'.repeat(128 * 1024)}\n`;
|
|
const limit = Buffer.byteLength(older + newest) - extraByte;
|
|
await writeFile(join(dir, 'daemon'), 'outside budget\n' + older + receipt + newest);
|
|
const result = await buildAutomaticDiagnostics({ directory: join(dir, 'bundle'), incidentId: 'budget',
|
|
summary: {}, sources: [{ name: 'daemon.log', absolutePath: join(dir, 'daemon'), kind: 'text', tailBytes: limit }] });
|
|
const chunks = await Promise.all(result.manifest.chunks.map((c) => readFile(join(dir, 'bundle', String(c.index)))));
|
|
const records = gunzipSync(Buffer.concat(chunks)).toString().trim().split('\n').map((line) => JSON.parse(line));
|
|
const content = records.find((record) => record.type === 'file').content;
|
|
expect(content).toBe(extraByte ? newest : older + newest);
|
|
expect(Buffer.byteLength(content)).toBeLessThanOrEqual(limit);
|
|
});
|