@ngockhoale/ukit 3.1.9 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/src/cli/commands/telemetry.js +45 -3
- package/src/core/agentRuntime/adapters.js +177 -0
- package/src/core/agentRuntime/contract.js +77 -0
- package/src/core/agentRuntime/diagnostics.js +343 -42
- package/src/core/agentRuntime/eventStore.js +139 -0
- package/src/core/agentRuntime/planCompiler.js +45 -6
- package/src/core/agentRuntime/planLibrary.js +43 -7
- package/src/core/agentRuntime/plans/bugfix-loop.json +1 -0
- package/src/core/agentRuntime/plans/flag-promotion.json +138 -0
- package/src/core/agentRuntime/plans/handoff-review-batch.json +1 -0
- package/src/core/agentRuntime/plans/release-check.json +2 -1
- package/src/core/agentRuntime/promotion.js +147 -9
- package/src/core/agentRuntime/runtimeSupport.js +18 -0
- package/src/core/agentRuntime/supervisor.js +44 -2
- package/src/core/agentRuntime/telemetry.js +123 -0
- package/src/core/agentRuntime/vmEngine.js +51 -10
- package/src/core/runtimeConfig.js +12 -0
- package/src/decision/client.js +4 -5
- package/src/decision/runtimeDecide.js +183 -5
- package/template_project/.claude/ukit/index/unic-decision.mjs +1 -2
|
@@ -5,15 +5,13 @@
|
|
|
5
5
|
* Two pure readers over the operation journal:
|
|
6
6
|
* renderOperationTimeline → deterministic frozen SanitizedTimeline
|
|
7
7
|
* replayOperation → read-only frozen ReplayResult
|
|
8
|
-
*
|
|
9
8
|
* Privacy contract (SPEC §5): `summary` strings are assembled from the
|
|
10
9
|
* eventType plus a whitelist of enumerated code/state fields — raw
|
|
11
10
|
* `safePayload` text is NEVER copied into output. Values must match a
|
|
12
11
|
* bounded code pattern; anything else is dropped silently.
|
|
13
12
|
*
|
|
14
|
-
* Read-only guarantee:
|
|
15
|
-
* or enqueues. Journal + contract are the only inputs.
|
|
16
|
-
* config reads (same convention as eventStore.js).
|
|
13
|
+
* Read-only guarantee (readers): the two readers perform no writes,
|
|
14
|
+
* spawns, signals, or enqueues. Journal + contract are the only inputs.
|
|
17
15
|
*
|
|
18
16
|
* DF2-FR07 (TASK-008): this module is ALSO the diagnostics-side emit
|
|
19
17
|
* surface — `emitModelAttempt` and `emitVerificationCompleted` turn a
|
|
@@ -21,10 +19,16 @@
|
|
|
21
19
|
* recorder records parented onto the caller's run span ctx. Emit helpers
|
|
22
20
|
* are synchronous, never throw, and never write to journals — the
|
|
23
21
|
* read-only contract above still holds for the two readers.
|
|
22
|
+
*
|
|
23
|
+
* V-06: `exportSupportBundle` is the one WRITER in this module — a
|
|
24
|
+
* stage-gated (`decisionRuntime.diagnostics`) sanitized support-bundle
|
|
25
|
+
* assembler; see the section comment above its definition. It resolves
|
|
26
|
+
* the injected config's stage rather than reading config itself.
|
|
24
27
|
*/
|
|
25
28
|
|
|
26
29
|
import { promises as fs } from 'node:fs';
|
|
27
30
|
import path from 'node:path';
|
|
31
|
+
import crypto from 'node:crypto';
|
|
28
32
|
|
|
29
33
|
import {
|
|
30
34
|
CONTRACT_VERSION,
|
|
@@ -32,8 +36,28 @@ import {
|
|
|
32
36
|
isTerminal,
|
|
33
37
|
validateTransition,
|
|
34
38
|
} from './contract.js';
|
|
35
|
-
import {
|
|
36
|
-
|
|
39
|
+
import {
|
|
40
|
+
readJournal,
|
|
41
|
+
readJournalExcerpt,
|
|
42
|
+
summarizeEvent,
|
|
43
|
+
EventStoreError,
|
|
44
|
+
} from './eventStore.js';
|
|
45
|
+
import {
|
|
46
|
+
makeSpanContext,
|
|
47
|
+
emitSpanRecord,
|
|
48
|
+
typedResource,
|
|
49
|
+
registryCode,
|
|
50
|
+
buildTraceExcerpt,
|
|
51
|
+
projectRootFromRuntimeDir,
|
|
52
|
+
} from './telemetry.js';
|
|
53
|
+
import { resolveDecisionRuntimeStage } from '../runtimeConfig.js';
|
|
54
|
+
import { scanText } from '../sensitiveValueScanner.js';
|
|
55
|
+
import { writeFileAtomic } from '../fileOps.js';
|
|
56
|
+
import { ioReason } from '../observability/segments/internal.js';
|
|
57
|
+
import { resolveSupportDir } from '../observability/support/paths.js';
|
|
58
|
+
import { buildManifest } from '../observability/support/manifest.js';
|
|
59
|
+
import { projectDirName } from '../observability/support/projector.js';
|
|
60
|
+
import { redactString } from '../observability/privacy/redaction.js';
|
|
37
61
|
|
|
38
62
|
export class DiagnosticsError extends Error {
|
|
39
63
|
constructor(code, message) {
|
|
@@ -58,41 +82,9 @@ async function pathExists(p) {
|
|
|
58
82
|
}
|
|
59
83
|
}
|
|
60
84
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
/** Whitelisted safePayload fields whose values are enumerated codes. */
|
|
65
|
-
const CODE_FIELDS = Object.freeze(['code', 'reason', 'status', 'kind', 'attempt']);
|
|
66
|
-
const NUMERIC_FIELDS = Object.freeze(['seq', 'step', 'attempt', 'count', 'durationMs']);
|
|
67
|
-
|
|
68
|
-
function codeValue(v) {
|
|
69
|
-
return typeof v === 'string' && CODE_RE.test(v) ? v : null;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Build a sanitized summary from eventType + enumerated code fields only.
|
|
74
|
-
* `from`/`to` are emitted only when they are known contract states.
|
|
75
|
-
*/
|
|
76
|
-
function summarize(event) {
|
|
77
|
-
const parts = [event.eventType];
|
|
78
|
-
const p = event.safePayload;
|
|
79
|
-
if (p !== null && typeof p === 'object' && !Array.isArray(p)) {
|
|
80
|
-
for (const f of ['from', 'to']) {
|
|
81
|
-
if (STATE_SET.has(p[f])) parts.push(`${f}=${p[f]}`);
|
|
82
|
-
}
|
|
83
|
-
for (const f of CODE_FIELDS) {
|
|
84
|
-
const v = codeValue(p[f]);
|
|
85
|
-
if (v !== null) parts.push(`${f}=${v}`);
|
|
86
|
-
}
|
|
87
|
-
for (const f of NUMERIC_FIELDS) {
|
|
88
|
-
if (Number.isFinite(p[f]) && !['from', 'to'].includes(f) && !CODE_FIELDS.includes(f)) {
|
|
89
|
-
parts.push(`${f}=${p[f]}`);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
return parts.join(' ');
|
|
94
|
-
}
|
|
95
|
-
|
|
85
|
+
// The privacy whitelist (CODE_RE/code fields) and summarizeEvent live in
|
|
86
|
+
// eventStore.js — one convention shared by the timeline reader and the
|
|
87
|
+
// support-bundle journal excerpt.
|
|
96
88
|
/**
|
|
97
89
|
* Read + validate the journal; throws DiagnosticsError('journal_missing')
|
|
98
90
|
* when absent and detects the truncated-tail flag the reader discards.
|
|
@@ -213,7 +205,7 @@ export async function renderOperationTimeline(dir, operationId, _opts = {}) {
|
|
|
213
205
|
seq: e.seq,
|
|
214
206
|
eventType: e.eventType,
|
|
215
207
|
observedAt: e.observedAt,
|
|
216
|
-
summary:
|
|
208
|
+
summary: summarizeEvent(e),
|
|
217
209
|
artifactRefs: Object.freeze(
|
|
218
210
|
(Array.isArray(e.artifactRefs) ? e.artifactRefs : []).filter((r) => typeof r === 'string'),
|
|
219
211
|
),
|
|
@@ -346,3 +338,312 @@ export function emitVerificationCompleted(recorder, {
|
|
|
346
338
|
emitSpanRecord(telemetry, span, 'verification.completed', payload);
|
|
347
339
|
return span;
|
|
348
340
|
}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
// --- V-06: support-bundle export -------------------------------------------
|
|
344
|
+
// One sanitized bundle per operation: a privacy-gated trace excerpt plus a
|
|
345
|
+
// sanitized journal excerpt, materialized inside the Data Foundation
|
|
346
|
+
// support layout — `Documents/UKit Support/proj-<sha256>/run-<sha256>/`
|
|
347
|
+
// (or an explicit outDir). Same ownership rules as the projector: flat
|
|
348
|
+
// `ukit-support/1` file set, manifest.json written LAST as the commit
|
|
349
|
+
// point, sha256 checksums + byte sizes in the manifest so
|
|
350
|
+
// `ukit telemetry import` can verify a received bundle.
|
|
351
|
+
//
|
|
352
|
+
// Privacy contract (SPEC §5, never loosened):
|
|
353
|
+
// - stage gate: `decisionRuntime.diagnostics` 'off' → zero writes.
|
|
354
|
+
// - redaction BEFORE persist: every string passes redactString
|
|
355
|
+
// (secrets, absolute paths, PII, control chars) with the secret
|
|
356
|
+
// scanner as a second signal; trace records additionally pass the
|
|
357
|
+
// independent sanitizeForSupport gate.
|
|
358
|
+
// - no raw content: payloads are allowlist-pruned — prompts, diffs,
|
|
359
|
+
// argv, output, paths can never appear (DENIED fields never even
|
|
360
|
+
// reach the excerpt).
|
|
361
|
+
// - final belt: the assembled file set is re-scanned; any residual
|
|
362
|
+
// secret blocks ALL writes (degraded, redaction_incomplete).
|
|
363
|
+
// - bounded: a hard maxBytes cap trims excerpt lines deterministically
|
|
364
|
+
// (journal tail first, then trace tail) and declares truncation in
|
|
365
|
+
// the manifest coverage — never a silent overflow.
|
|
366
|
+
|
|
367
|
+
const BUNDLE_CAPS = Object.freeze({ maxBytes: 256 * 1024 });
|
|
368
|
+
const BUNDLE_FILES = Object.freeze(['README.md', 'journal.jsonl', 'trace.jsonl', 'SUMMARY.md', 'manifest.json']);
|
|
369
|
+
|
|
370
|
+
function sha256Hex(input) {
|
|
371
|
+
return crypto.createHash('sha256').update(input).digest('hex');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Deterministic within-bundle id pseudonym — deterministic for tests. */
|
|
375
|
+
function bundlePseudonym(value, kind) {
|
|
376
|
+
return `${kind.slice(0, 8)}-${sha256Hex(`v06:${String(value)}`).slice(0, 12)}`;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Deep string redaction over plain JSON-shaped structures. */
|
|
380
|
+
function redactDeep(value, ctx) {
|
|
381
|
+
if (value === null) return null;
|
|
382
|
+
const t = typeof value;
|
|
383
|
+
if (t === 'boolean') return value;
|
|
384
|
+
if (t === 'number') return Number.isFinite(value) ? value : null;
|
|
385
|
+
if (t === 'string') return redactString(value, ctx);
|
|
386
|
+
if (Array.isArray(value)) return value.map((v) => redactDeep(v, ctx));
|
|
387
|
+
if (isPlainObject(value)) {
|
|
388
|
+
const out = {};
|
|
389
|
+
for (const [k, v] of Object.entries(value)) out[k] = redactDeep(v, ctx);
|
|
390
|
+
return out;
|
|
391
|
+
}
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function renderBundleReadme() {
|
|
396
|
+
return [
|
|
397
|
+
'# UKit run support bundle',
|
|
398
|
+
'',
|
|
399
|
+
'This directory was written by `ukit telemetry export-run` (V-06).',
|
|
400
|
+
'It contains a privacy-sanitized trace excerpt and journal excerpt',
|
|
401
|
+
'for one agent-runtime operation: codes, states, counts and timings',
|
|
402
|
+
'only — never prompts, diffs, paths, output or secrets.',
|
|
403
|
+
'',
|
|
404
|
+
'Share it by zipping the whole run-* directory. A maintainer can',
|
|
405
|
+
'validate it with `ukit telemetry import <path>`.',
|
|
406
|
+
'',
|
|
407
|
+
].join('\n');
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function renderBundleSummary({ generatedAt, opPseudonym, journal, trace, caps }) {
|
|
411
|
+
return [
|
|
412
|
+
'# VM run bundle summary',
|
|
413
|
+
'',
|
|
414
|
+
`generated_at: ${generatedAt}`,
|
|
415
|
+
`operation: ${opPseudonym}`,
|
|
416
|
+
`journal_status: ${journal.status}`,
|
|
417
|
+
`journal_entries: ${journal.entries.length}`,
|
|
418
|
+
`journal_truncated: ${journal.truncated}`,
|
|
419
|
+
`trace_status: ${trace.status}`,
|
|
420
|
+
`trace_kept: ${trace.stats.kept}`,
|
|
421
|
+
`trace_rejected: ${trace.stats.rejected}`,
|
|
422
|
+
`caps.maxBytes: ${caps.maxBytes}`,
|
|
423
|
+
'',
|
|
424
|
+
'Gaps are declared in manifest.json coverage; a missing or corrupt',
|
|
425
|
+
'journal still produces a bundle — the hole is marked, never hidden.',
|
|
426
|
+
'',
|
|
427
|
+
].join('\n');
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Export one operation's sanitized support bundle (V-06).
|
|
432
|
+
*
|
|
433
|
+
* @param {string} dir agent-runtime store root (events/, state/)
|
|
434
|
+
* @param {string} operationId
|
|
435
|
+
* @param {object} [opts]
|
|
436
|
+
* @param {object} [opts.config] runtime config — `decisionRuntime.diagnostics`
|
|
437
|
+
* stage 'off' (or absent/malformed) returns `{status:'skipped',
|
|
438
|
+
* reason:'stage_off'}` with zero writes.
|
|
439
|
+
* @param {string} [opts.outDir] bundle target directory; absent →
|
|
440
|
+
* `Documents/UKit Support/proj-<sha256(projectKey)>/run-<sha256(op)>/`
|
|
441
|
+
* via resolveSupportDir + projectDirName.
|
|
442
|
+
* @param {string} [opts.projectKey] project-key input for the proj dir
|
|
443
|
+
* name (defaults to projectRootFromRuntimeDir(dir), then operationId).
|
|
444
|
+
* @param {Array} [opts.trace] SemanticRecords for the trace excerpt.
|
|
445
|
+
* @param {string} [opts.traceRoot] canonical segment root; when given and
|
|
446
|
+
* `trace` is absent, records are read from segments.
|
|
447
|
+
* @param {string} [opts.traceId] restrict a traceRoot read to one trace.
|
|
448
|
+
* @param {number} [opts.maxBytes] bundle byte cap (default 256 KiB).
|
|
449
|
+
* @param {number} [opts.now] injectable clock for deterministic output.
|
|
450
|
+
* @returns {Promise<{status:string, reason?:string, dir?:string,
|
|
451
|
+
* files?:string[], bytes?:number, coverage?:object}>}
|
|
452
|
+
*/
|
|
453
|
+
export async function exportSupportBundle(dir, operationId, opts = {}) {
|
|
454
|
+
const none = { status: 'skipped', bytes: 0 };
|
|
455
|
+
|
|
456
|
+
// Stage gate first — 'off' must produce zero writes.
|
|
457
|
+
if (resolveDecisionRuntimeStage(opts.config ?? null, 'diagnostics') === 'off') {
|
|
458
|
+
return { ...none, reason: 'stage_off' };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const opId = typeof operationId === 'string' && operationId.length > 0
|
|
462
|
+
? operationId
|
|
463
|
+
: 'unknown';
|
|
464
|
+
const caps = {
|
|
465
|
+
maxBytes: Number.isInteger(opts.maxBytes) && opts.maxBytes > 0
|
|
466
|
+
? opts.maxBytes
|
|
467
|
+
: BUNDLE_CAPS.maxBytes,
|
|
468
|
+
};
|
|
469
|
+
const now = Number.isFinite(opts.now) ? opts.now : Date.now();
|
|
470
|
+
const generatedAt = new Date(now).toISOString();
|
|
471
|
+
const opPseudonym = `op-${sha256Hex(`v06:op:${opId}`).slice(0, 16)}`;
|
|
472
|
+
|
|
473
|
+
// --- resolve bundle dir -------------------------------------------------
|
|
474
|
+
let outDir = typeof opts.outDir === 'string' && opts.outDir.length > 0
|
|
475
|
+
? opts.outDir
|
|
476
|
+
: null;
|
|
477
|
+
if (outDir === null) {
|
|
478
|
+
const resolved = await resolveSupportDir({
|
|
479
|
+
homeDir: opts.homeDir,
|
|
480
|
+
env: opts.env,
|
|
481
|
+
platform: opts.platform,
|
|
482
|
+
});
|
|
483
|
+
if (!resolved.ok) {
|
|
484
|
+
return { status: 'degraded', reason: resolved.reason, bytes: 0 };
|
|
485
|
+
}
|
|
486
|
+
const projectKey = typeof opts.projectKey === 'string' && opts.projectKey.length > 0
|
|
487
|
+
? opts.projectKey
|
|
488
|
+
: (projectRootFromRuntimeDir(dir) ?? `standalone:${opId}`);
|
|
489
|
+
outDir = path.join(
|
|
490
|
+
resolved.dir,
|
|
491
|
+
projectDirName(projectKey),
|
|
492
|
+
`run-${sha256Hex(`v06:run:${opId}`).slice(0, 12)}`,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// --- journal excerpt (sanitized at the store boundary) -------------------
|
|
497
|
+
const journal = await readJournalExcerpt(dir, opId, { maxEntries: opts.maxJournalEntries });
|
|
498
|
+
|
|
499
|
+
// --- trace excerpt --------------------------------------------------------
|
|
500
|
+
let traceRecords = Array.isArray(opts.trace) ? opts.trace : null;
|
|
501
|
+
let traceReadFailed = false;
|
|
502
|
+
if (traceRecords === null && typeof opts.traceRoot === 'string' && opts.traceRoot.length > 0) {
|
|
503
|
+
try {
|
|
504
|
+
const { readSegments } = await import('../observability/segments/readSegments.js');
|
|
505
|
+
traceRecords = [];
|
|
506
|
+
for await (const record of readSegments(opts.traceRoot, { limit: 4096 })) {
|
|
507
|
+
const match = typeof opts.traceId === 'string' && opts.traceId.length > 0
|
|
508
|
+
? record?.trace_id === opts.traceId
|
|
509
|
+
: record?.payload?.operation === opId;
|
|
510
|
+
if (match) traceRecords.push(record);
|
|
511
|
+
}
|
|
512
|
+
} catch {
|
|
513
|
+
traceReadFailed = true;
|
|
514
|
+
traceRecords = null;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
const trace = buildTraceExcerpt(traceRecords, {
|
|
518
|
+
maxRecords: opts.maxTraceRecords,
|
|
519
|
+
pseudonymize: bundlePseudonym,
|
|
520
|
+
});
|
|
521
|
+
const traceStatus = traceReadFailed ? 'unreadable'
|
|
522
|
+
: traceRecords === null ? 'absent'
|
|
523
|
+
: trace.stats.kept === 0 && trace.stats.in === 0 ? 'empty' : 'ok';
|
|
524
|
+
trace.status = traceStatus;
|
|
525
|
+
|
|
526
|
+
// --- redact journal excerpt strings (artifact refs are op filenames) ------
|
|
527
|
+
const redactCtx = { scanner: scanText, maxChars: 1024 };
|
|
528
|
+
const journalEntries = journal.entries.map((e) => redactDeep(e, redactCtx));
|
|
529
|
+
|
|
530
|
+
// --- assemble the file set ------------------------------------------------
|
|
531
|
+
const files = {};
|
|
532
|
+
files['README.md'] = renderBundleReadme();
|
|
533
|
+
|
|
534
|
+
const journalLines = journalEntries.map((e) => JSON.stringify(e));
|
|
535
|
+
for (const g of journal.gaps) {
|
|
536
|
+
journalLines.push(JSON.stringify({ gap: g.code, seq: g.seq }));
|
|
537
|
+
}
|
|
538
|
+
if (journal.truncated) {
|
|
539
|
+
journalLines.push(JSON.stringify({ gap: 'journal_truncated', seq: 0 }));
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const traceLines = [
|
|
543
|
+
...trace.entries.map((e) => JSON.stringify(e)),
|
|
544
|
+
...trace.gaps.map((g) => JSON.stringify({ gap: g.code, index: g.index })),
|
|
545
|
+
];
|
|
546
|
+
|
|
547
|
+
const coverage = () => ({
|
|
548
|
+
journal: {
|
|
549
|
+
status: journal.status,
|
|
550
|
+
entries: journalEntries.length,
|
|
551
|
+
truncated: journal.truncated,
|
|
552
|
+
gaps: journal.gaps,
|
|
553
|
+
},
|
|
554
|
+
trace: {
|
|
555
|
+
status: traceStatus,
|
|
556
|
+
in: trace.stats.in,
|
|
557
|
+
kept: trace.stats.kept,
|
|
558
|
+
rejected: trace.stats.rejected,
|
|
559
|
+
truncated: trace.stats.truncated,
|
|
560
|
+
gaps: trace.gaps.length,
|
|
561
|
+
},
|
|
562
|
+
dropped_lines: 0,
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
// Byte budget: drop excerpt lines deterministically (journal tail first —
|
|
566
|
+
// the oldest-marked data loses before the self-description does), then
|
|
567
|
+
// trace tail, and finally declare the bundle unwritable rather than
|
|
568
|
+
// exceed the cap.
|
|
569
|
+
let dropped = 0;
|
|
570
|
+
const assemble = () => {
|
|
571
|
+
files['journal.jsonl'] = `${journalLines.join('\n')}\n`;
|
|
572
|
+
if (traceLines.length > 0) files['trace.jsonl'] = `${traceLines.join('\n')}\n`;
|
|
573
|
+
else delete files['trace.jsonl'];
|
|
574
|
+
files['SUMMARY.md'] = renderBundleSummary({
|
|
575
|
+
generatedAt, opPseudonym, journal: { ...journal, entries: journalEntries }, trace, caps,
|
|
576
|
+
});
|
|
577
|
+
const cov = coverage();
|
|
578
|
+
cov.dropped_lines = dropped;
|
|
579
|
+
files['manifest.json'] = `${JSON.stringify(buildManifest({
|
|
580
|
+
files,
|
|
581
|
+
generated_at: generatedAt,
|
|
582
|
+
operation_ref_pseudonym: opPseudonym,
|
|
583
|
+
coverage: cov,
|
|
584
|
+
caps,
|
|
585
|
+
}), null, 2)}\n`;
|
|
586
|
+
return Object.entries(files).reduce((s, [, c]) => s + Buffer.byteLength(c, 'utf8'), 0);
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
let total = assemble();
|
|
590
|
+
while (total > caps.maxBytes && (journalLines.length > 0 || traceLines.length > 0)) {
|
|
591
|
+
if (journalLines.length > 0) journalLines.pop();
|
|
592
|
+
else traceLines.pop();
|
|
593
|
+
dropped += 1;
|
|
594
|
+
total = assemble();
|
|
595
|
+
}
|
|
596
|
+
if (total > caps.maxBytes) {
|
|
597
|
+
return { status: 'degraded', reason: 'bundle_over_cap', bytes: 0 };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// --- final belt: re-scan assembled bytes; any residual secret blocks all --
|
|
601
|
+
for (const content of Object.values(files)) {
|
|
602
|
+
const scan = scanText(content);
|
|
603
|
+
if (scan.hasSecret) {
|
|
604
|
+
return { status: 'degraded', reason: 'redaction_incomplete', bytes: 0 };
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// --- fs safety: refuse foreign files, symlinks and non-regular targets ----
|
|
609
|
+
try {
|
|
610
|
+
const st = await fs.lstat(outDir).catch((err) => {
|
|
611
|
+
if (err && err.code === 'ENOENT') return null;
|
|
612
|
+
throw err;
|
|
613
|
+
});
|
|
614
|
+
if (st !== null) {
|
|
615
|
+
if (st.isSymbolicLink()) return { ...none, reason: 'support_path_symlink' };
|
|
616
|
+
if (!st.isDirectory()) return { ...none, reason: 'support_path_not_directory' };
|
|
617
|
+
const listing = await fs.readdir(outDir);
|
|
618
|
+
for (const name of listing) {
|
|
619
|
+
if (!BUNDLE_FILES.includes(name)) return { ...none, reason: 'bundle_dir_foreign' };
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
for (const name of Object.keys(files)) {
|
|
623
|
+
const target = path.join(outDir, name);
|
|
624
|
+
const t = await fs.lstat(target).catch((err) => {
|
|
625
|
+
if (err && err.code === 'ENOENT') return null;
|
|
626
|
+
throw err;
|
|
627
|
+
});
|
|
628
|
+
if (t !== null && (t.isSymbolicLink() || !t.isFile())) {
|
|
629
|
+
return { status: 'skipped', reason: 'bundle_file_blocked', bytes: 0 };
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
633
|
+
// manifest.json is written last — it is the commit point.
|
|
634
|
+
for (const name of BUNDLE_FILES) {
|
|
635
|
+
if (files[name] === undefined) continue;
|
|
636
|
+
await writeFileAtomic(path.join(outDir, name), files[name]);
|
|
637
|
+
}
|
|
638
|
+
} catch (err) {
|
|
639
|
+
return { status: 'degraded', reason: ioReason(err), bytes: 0 };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
return {
|
|
643
|
+
status: 'written',
|
|
644
|
+
dir: outDir,
|
|
645
|
+
files: Object.keys(files),
|
|
646
|
+
bytes: total,
|
|
647
|
+
coverage: JSON.parse(files['manifest.json']).coverage,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
@@ -33,6 +33,7 @@ import { withFileLock } from '../fileOps.js';
|
|
|
33
33
|
|
|
34
34
|
import {
|
|
35
35
|
CONTRACT_VERSION,
|
|
36
|
+
OPERATION_STATES,
|
|
36
37
|
validateSemanticEvent,
|
|
37
38
|
validateEventOrder,
|
|
38
39
|
} from './contract.js';
|
|
@@ -230,6 +231,144 @@ export async function* readJournal(dir, operationId) {
|
|
|
230
231
|
}
|
|
231
232
|
}
|
|
232
233
|
|
|
234
|
+
// --- V-06 support-bundle excerpt surface -----------------------------------
|
|
235
|
+
// The privacy whitelist lives on the event itself (single convention —
|
|
236
|
+
// diagnostics.js and the support-bundle assembler share these constants).
|
|
237
|
+
// Values must match CODE_RE to surface; anything else is dropped silently,
|
|
238
|
+
// so free text can never ride a summary into a bundle.
|
|
239
|
+
|
|
240
|
+
const EXCERPT_STATE_SET = new Set(OPERATION_STATES);
|
|
241
|
+
|
|
242
|
+
/** Bounded code pattern — states, codes, digests only; never free text. */
|
|
243
|
+
const CODE_RE = /^[a-z0-9_.:-]{1,64}$/;
|
|
244
|
+
|
|
245
|
+
/** Whitelisted safePayload fields whose values are enumerated codes. */
|
|
246
|
+
const CODE_FIELDS = Object.freeze(['code', 'reason', 'status', 'kind', 'attempt']);
|
|
247
|
+
const NUMERIC_FIELDS = Object.freeze(['seq', 'step', 'attempt', 'count', 'durationMs']);
|
|
248
|
+
|
|
249
|
+
function codeValue(v) {
|
|
250
|
+
return typeof v === 'string' && CODE_RE.test(v) ? v : null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Build a sanitized one-line summary from eventType + enumerated code
|
|
255
|
+
* fields only. `from`/`to` are emitted only when they are known contract
|
|
256
|
+
* states; raw `safePayload` text is NEVER copied.
|
|
257
|
+
*/
|
|
258
|
+
export function summarizeEvent(event) {
|
|
259
|
+
const parts = [event.eventType];
|
|
260
|
+
const p = event.safePayload;
|
|
261
|
+
if (p !== null && typeof p === 'object' && !Array.isArray(p)) {
|
|
262
|
+
for (const f of ['from', 'to']) {
|
|
263
|
+
if (EXCERPT_STATE_SET.has(p[f])) parts.push(`${f}=${p[f]}`);
|
|
264
|
+
}
|
|
265
|
+
for (const f of CODE_FIELDS) {
|
|
266
|
+
const v = codeValue(p[f]);
|
|
267
|
+
if (v !== null) parts.push(`${f}=${v}`);
|
|
268
|
+
}
|
|
269
|
+
for (const f of NUMERIC_FIELDS) {
|
|
270
|
+
if (Number.isFinite(p[f]) && !['from', 'to'].includes(f) && !CODE_FIELDS.includes(f)) {
|
|
271
|
+
parts.push(`${f}=${p[f]}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return parts.join(' ');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Sanitized journal excerpt (V-06): a bounded, content-free view of one
|
|
280
|
+
* operation journal for support-bundle assembly. Unlike `readJournal`
|
|
281
|
+
* this API NEVER throws — every failure mode becomes a typed status plus
|
|
282
|
+
* a `gaps` marker so the bundle can declare the hole instead of failing:
|
|
283
|
+
*
|
|
284
|
+
* status 'ok' — journal parsed; `entries` may still be empty
|
|
285
|
+
* status 'missing' — no journal file for this operation
|
|
286
|
+
* status 'corrupt' — mid-file invalid line or malformed event;
|
|
287
|
+
* entries before the bad line are kept
|
|
288
|
+
* status 'unreadable' — io failure opening/reading the file
|
|
289
|
+
*
|
|
290
|
+
* `truncated` flags a crash-torn final line (same tail rule as
|
|
291
|
+
* `readJournal`). `gaps` entries are `{seq, code}` with bounded codes —
|
|
292
|
+
* free-text failure details never reach the caller.
|
|
293
|
+
*
|
|
294
|
+
* @param {string} dir store root
|
|
295
|
+
* @param {string} operationId
|
|
296
|
+
* @param {object} [opts] `{ maxEntries }` (default 512, clamped ≥1)
|
|
297
|
+
* @returns {Promise<{ operationId: string, status: string, truncated: boolean,
|
|
298
|
+
* entries: Array, gaps: Array }>}
|
|
299
|
+
*/
|
|
300
|
+
export async function readJournalExcerpt(dir, operationId, opts = {}) {
|
|
301
|
+
const maxEntries = Number.isInteger(opts.maxEntries) ? Math.max(1, opts.maxEntries) : 512;
|
|
302
|
+
const file = journalPath(dir, operationId);
|
|
303
|
+
const gaps = [];
|
|
304
|
+
const entries = [];
|
|
305
|
+
let truncated = false;
|
|
306
|
+
|
|
307
|
+
const excerpt = (e) => ({
|
|
308
|
+
seq: e.seq,
|
|
309
|
+
eventType: e.eventType,
|
|
310
|
+
observedAt: e.observedAt,
|
|
311
|
+
summary: summarizeEvent(e),
|
|
312
|
+
artifactRefs: (Array.isArray(e.artifactRefs) ? e.artifactRefs : [])
|
|
313
|
+
.filter((r) => typeof r === 'string'),
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
if (!(await pathExists(file))) {
|
|
317
|
+
gaps.push({ seq: 0, code: 'journal_missing' });
|
|
318
|
+
return { operationId, status: 'missing', truncated, entries, gaps };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
let raw;
|
|
322
|
+
try {
|
|
323
|
+
raw = await fs.readFile(file, 'utf8');
|
|
324
|
+
} catch (err) {
|
|
325
|
+
if (err && err.code === 'ENOENT') {
|
|
326
|
+
gaps.push({ seq: 0, code: 'journal_missing' });
|
|
327
|
+
return { operationId, status: 'missing', truncated, entries, gaps };
|
|
328
|
+
}
|
|
329
|
+
gaps.push({ seq: 0, code: 'journal_unreadable' });
|
|
330
|
+
return { operationId, status: 'unreadable', truncated, entries, gaps };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Line-by-line tolerant parse: unlike readJournal's all-or-nothing
|
|
334
|
+
// parseJournalFile, the excerpt keeps the good prefix of a corrupt
|
|
335
|
+
// journal and marks the hole — same torn-tail rule (unterminated or
|
|
336
|
+
// unparseable final line is a crash artifact, never corruption).
|
|
337
|
+
truncated = !raw.endsWith('\n');
|
|
338
|
+
const lines = raw.split('\n');
|
|
339
|
+
if (!truncated) lines.pop();
|
|
340
|
+
let lastSeq = 0;
|
|
341
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
342
|
+
const line = lines[i];
|
|
343
|
+
if (line === '') continue;
|
|
344
|
+
const isLast = i === lines.length - 1;
|
|
345
|
+
let event;
|
|
346
|
+
try {
|
|
347
|
+
event = JSON.parse(line);
|
|
348
|
+
} catch {
|
|
349
|
+
if (isLast && truncated) break; // torn tail — discarded silently
|
|
350
|
+
gaps.push({ seq: lastSeq, code: 'journal_corrupt' });
|
|
351
|
+
return { operationId, status: 'corrupt', truncated, entries, gaps };
|
|
352
|
+
}
|
|
353
|
+
const shape = validateSemanticEvent(event);
|
|
354
|
+
if (!shape.ok) {
|
|
355
|
+
gaps.push({
|
|
356
|
+
seq: Number.isInteger(event?.seq) ? event.seq : lastSeq,
|
|
357
|
+
code: codeValue(shape.code) ?? 'journal_corrupt',
|
|
358
|
+
});
|
|
359
|
+
return { operationId, status: 'corrupt', truncated, entries, gaps };
|
|
360
|
+
}
|
|
361
|
+
if (entries.length >= maxEntries) {
|
|
362
|
+
gaps.push({ seq: event.seq ?? lastSeq, code: 'excerpt_truncated' });
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
entries.push(excerpt(event));
|
|
366
|
+
lastSeq = Number.isInteger(event.seq) ? event.seq : lastSeq;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return { operationId, status: 'ok', truncated, entries, gaps };
|
|
370
|
+
}
|
|
371
|
+
|
|
233
372
|
/**
|
|
234
373
|
* Write the guarded operation state file (cache only — the journal is truth).
|
|
235
374
|
* Atomic temp+rename+fsync.
|