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>
94 lines
6.7 KiB
TypeScript
94 lines
6.7 KiB
TypeScript
import { mkdtempSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { afterEach, expect, it, vi } from 'vitest';
|
|
import { AutomaticDiagnostics } from '../src/services/automatic-diagnostics.js';
|
|
import { DiagnosticOutbox } from '../src/storage/diagnostic-outbox.js';
|
|
import { createDiagnosticRunObserver, diagnosticFaultFromRun } from '../src/services/diagnostic-faults.js';
|
|
|
|
const cleanup: Array<() => Promise<void>> = [];
|
|
afterEach(async () => { for (const fn of cleanup.splice(0)) await fn(); });
|
|
function fixture() {
|
|
const root = mkdtempSync(join(tmpdir(), 'od-auto-'));
|
|
let consent = true; let failCompletion = false; const calls: Array<{ path: string; body: any }> = [];
|
|
const id = '12345678-1234-1234-1234-123456789abc';
|
|
let prefix = ''; let manifest: any;
|
|
const fetcher = vi.fn(async (url: string | URL | Request, options?: RequestInit) => {
|
|
const path = new URL(String(url)).pathname; const body = JSON.parse(String(options?.body)); calls.push({ path, body });
|
|
if (path.endsWith('/register')) return Response.json({ device_id: id, device_token: `${id}.${'a'.repeat(64)}` });
|
|
if (path.endsWith('/authorize')) {
|
|
manifest = body.manifest; prefix = `diagnostics/v1/devices/${id}/incidents/${manifest.incidentId}/bundles/${'a'.repeat(64)}`;
|
|
return Response.json({ upload_token: 'signed', object_prefix: prefix });
|
|
}
|
|
if (body.complete) {
|
|
if (failCompletion) { failCompletion = false; throw new Error('receipt lost'); }
|
|
return Response.json({ status: 'available', object_key: `${prefix}/manifest.json`, storage_ref: `od://objects/${prefix}/manifest.json` });
|
|
}
|
|
const chunk = manifest.chunks[body.chunk_index];
|
|
return Response.json({ object_key: `${prefix}/chunks/${body.chunk_index}`, sha256: `sha256:${chunk.sha256}`, size_bytes: chunk.sizeBytes });
|
|
}) as unknown as typeof fetch;
|
|
const options = { dataRoot: root, relayOrigin: 'https://relay.test', consent: () => consent,
|
|
sources: async () => [], fetcher };
|
|
const service = new AutomaticDiagnostics(options);
|
|
cleanup.push(async () => { await service.stop(); rmSync(root, { recursive: true, force: true }); });
|
|
return { root, service, options, calls, setConsent: (v: boolean) => { consent = v; service.consentChanged(); },
|
|
loseReceipt: () => { failCompletion = true; } };
|
|
}
|
|
it('delivers no-run incidents without account/trace and records a completed receipt', async () => {
|
|
const f = fixture(); const id = f.service.record({ sourceId: 'pre-run:1', at: Date.now(), kind: 'admission_failure' })!;
|
|
await f.service.tick();
|
|
expect(f.service.outbox.get(id)?.state).toBe('delivered');
|
|
expect(f.calls.map((c) => c.path)).toEqual(['/api/objects/devices/register', '/api/objects/authorize', '/api/objects/batch', '/api/objects/batch']);
|
|
});
|
|
it('retains failed delivery through shutdown and retries the same incident after a lost receipt', async () => {
|
|
const f = fixture(); f.loseReceipt();
|
|
const id = f.service.record({ sourceId: 'run:1', at: Date.now(), kind: 'run_error' })!;
|
|
await f.service.tick(); expect(f.service.outbox.get(id)?.state).toBe('pending');
|
|
// Reset only the test clock's retry deadline via the version-fenced store API.
|
|
const item = f.service.outbox.get(id)!; f.service.outbox.defer(item, 0, 'test_due');
|
|
await f.service.stop();
|
|
const second = new AutomaticDiagnostics(f.options);
|
|
await second.tick(); expect(second.outbox.get(id)?.state).toBe('delivered'); await second.stop();
|
|
expect(f.calls.filter((c) => c.path.endsWith('/register'))).toHaveLength(1);
|
|
expect(f.calls.filter((c) => c.path.endsWith('/authorize')).map((c) => c.body.manifest.incidentId)).toEqual([id, id]);
|
|
});
|
|
it('does not capture while disabled and scrubs pending content when consent is revoked', async () => {
|
|
const f = fixture(); f.loseReceipt();
|
|
const id = f.service.record({ sourceId: 'a', at: Date.now(), kind: 'run_error', detail: 'private text' })!;
|
|
await f.service.tick(); f.setConsent(false); await f.service.tick();
|
|
expect(f.service.outbox.get(id)?.state).toBe('discarded');
|
|
expect(f.service.outbox.get(id)?.summary).toBe('{}');
|
|
expect(f.service.record({ sourceId: 'b', at: Date.now(), kind: 'run_error' })).toBeNull();
|
|
f.setConsent(true); await f.service.tick(); expect(f.calls.filter((c) => c.body.complete)).toHaveLength(1);
|
|
});
|
|
it('maps failed attempts and recovered retries but excludes healthy success and unattributed cancellation', () => {
|
|
const run = { id: 'r' }; const event = { id: 1, timestamp: 1, data: {} };
|
|
expect(diagnosticFaultFromRun(run, { ...event, event: 'run_retry_attempted' })?.kind).toBe('retry');
|
|
expect(diagnosticFaultFromRun(run, { ...event, event: 'end', data: { status: 'canceled' } })).toBeNull();
|
|
expect(diagnosticFaultFromRun(run, { ...event, event: 'end', data: { status: 'completed' } })).toBeNull();
|
|
expect(diagnosticFaultFromRun(run, { ...event, event: 'end', data: { status: 'failed' } })?.kind).toBe('terminal_failure');
|
|
expect(diagnosticFaultFromRun({ ...run, strategyTask: { outcome: 'blocked' } }, { ...event, event: 'end', data: { status: 'succeeded' } })?.kind).toBe('logical_blocked');
|
|
});
|
|
it('does not create a second fault for the terminal callback following an observed error', () => {
|
|
const observe = createDiagnosticRunObserver(); const run = { id: 'r', retryAttemptCount: 0 };
|
|
expect(observe(run, { id: 1, timestamp: 1, event: 'error', data: {} })).not.toBeNull();
|
|
expect(observe(run, { id: 2, timestamp: 2, event: 'end', data: { status: 'failed' } })).toBeNull();
|
|
run.retryAttemptCount = 1;
|
|
expect(observe(run, { id: 3, timestamp: 3, event: 'run_retry_attempted', data: {} })).toBeNull();
|
|
expect(observe(run, { id: 4, timestamp: 4, event: 'end', data: { status: 'failed' } })).not.toBeNull();
|
|
});
|
|
it('keeps the reason of a source known to be absent instead of a consent verdict', async () => {
|
|
const f = fixture();
|
|
const service = new AutomaticDiagnostics({ ...f.options,
|
|
sources: async () => [{ name: 'agent-cli-logs/amr/opencode', absolutePath: '', kind: 'text' as const, omitReason: 'source_not_located' }] });
|
|
cleanup.push(async () => { await service.stop(); });
|
|
const id = service.record({ sourceId: 'run:absent', at: Date.now(), kind: 'run_error' })!;
|
|
await service.tick();
|
|
const { readdirSync, readFileSync } = await import('node:fs');
|
|
const { gunzipSync } = await import('node:zlib');
|
|
const dir = join(service.outbox.directory, id);
|
|
const archive = Buffer.concat(readdirSync(dir).filter((n) => /^\d+$/.test(n)).sort((a, b) => +a - +b).map((n) => readFileSync(join(dir, n))));
|
|
const collection = gunzipSync(archive).toString().trim().split('\n').map((l) => JSON.parse(l)).at(-1);
|
|
expect(collection.notes).toEqual([{ name: 'agent-cli-logs/amr/opencode', reason: 'source_not_located' }]);
|
|
});
|