@ngockhoale/ukit 3.0.12 → 3.1.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 +12 -0
- package/README.md +1 -0
- package/manifests/documentation.yaml +12 -0
- package/package.json +1 -1
- package/scripts/bench/data-foundation.mjs +368 -50
- package/src/cli/commands/doctor.js +232 -3
- package/src/cli/commands/feedback.js +64 -1
- package/src/cli/commands/install.js +18 -0
- package/src/cli/commands/memory.js +42 -37
- package/src/cli/commands/telemetry.js +460 -0
- package/src/cli/index.js +7 -0
- package/src/core/agentRuntime/adapters.js +83 -2
- package/src/core/agentRuntime/diagnostics.js +104 -0
- package/src/core/agentRuntime/supervisor.js +137 -0
- package/src/core/agentRuntime/telemetry.js +204 -0
- package/src/core/memory/memoryEmit.js +131 -0
- package/src/core/memory/memoryHit.js +1 -1
- package/src/core/memory/migrate.js +18 -11
- package/src/core/memory/migrateMapping.js +15 -7
- package/src/core/memory/mutateMemory.js +22 -4
- package/src/core/memory/recordIndex.js +10 -3
- package/src/core/memory/recordStore.js +28 -3
- package/src/core/memory/retrieval.js +79 -38
- package/src/core/memory/store.js +37 -37
- package/src/core/memory/storeV2.js +28 -25
- package/src/core/memory/storeV2Loader.js +2 -2
- package/src/core/observability/adapters/ingest.js +576 -0
- package/src/core/observability/analytics/anomalies.js +415 -0
- package/src/core/observability/analytics/summary.js +16 -1
- package/src/core/observability/emit/config.js +69 -1
- package/src/core/observability/emit/crash.js +434 -0
- package/src/core/observability/emit/lifecycle.js +349 -0
- package/src/core/observability/emit/recorder.js +135 -9
- package/src/core/observability/evaluation/aiPacket.js +52 -10
- package/src/core/observability/evaluation/outcomes.js +95 -0
- package/src/core/observability/evaluation/runner.js +225 -0
- package/src/core/observability/privacy/allowlist.js +23 -3
- package/src/core/observability/schema/compatibility.js +48 -3
- package/src/core/observability/schema/constants.js +5 -0
- package/src/core/observability/schema/registry.js +57 -0
- package/src/core/observability/schema/validate.js +68 -6
- package/src/core/observability/segments/internal.js +42 -8
- package/src/core/observability/segments/readSegments.js +35 -1
- package/src/core/observability/segments/recovery.js +3 -2
- package/src/core/observability/segments/retention.js +137 -33
- package/src/core/observability/support/projector.js +88 -18
- package/src/core/observability/support/provision.js +160 -0
- package/src/core/observability/support/renderer.js +2 -2
- package/src/core/observability/support/schedule.js +174 -0
- package/template_project/.claude/hooks/auto-allow-bash.sh +7 -1
- package/template_project/.claude/hooks/auto-prune-bash.sh +16 -7
- package/template_project/.claude/hooks/verification-guard.sh +13 -4
- package/template_project/.claude/ukit/runtime/async-lock.mjs +26 -0
|
@@ -29,8 +29,10 @@
|
|
|
29
29
|
* Pipeline: stage gate → Documents resolution → collect → double-gate
|
|
30
30
|
* (validateSemanticRecord + sanitizeForSupport — default-deny, unknown
|
|
31
31
|
* fields rejected) → age cap → bundle-local pseudonymization → per-trace
|
|
32
|
-
* summarize + digest (failure-promoted ordering) → byte cap →
|
|
33
|
-
*
|
|
32
|
+
* summarize + digest (failure-promoted ordering) → byte cap →
|
|
33
|
+
* detectAnomalies over the final digest set + readCrashReports (DF2-FR11:
|
|
34
|
+
* anomalies section + crash_count in SUMMARY.md and manifest coverage) →
|
|
35
|
+
* atomic per-file temp-rename write (manifest.json last = commit point) →
|
|
34
36
|
* stale owned-file cleanup → support-view prune.
|
|
35
37
|
*
|
|
36
38
|
* Safety invariants:
|
|
@@ -60,6 +62,8 @@ import { validateSemanticRecord } from '../schema/validate.js';
|
|
|
60
62
|
import { summarizeTrace, METRIC_VERSION } from '../analytics/summary.js';
|
|
61
63
|
import { renderTraceDigest } from '../analytics/digest.js';
|
|
62
64
|
import { resolveStage } from '../emit/config.js';
|
|
65
|
+
import { detectAnomalies } from '../analytics/anomalies.js';
|
|
66
|
+
import { readCrashReports } from '../emit/crash.js';
|
|
63
67
|
import { resolveSupportDir } from './paths.js';
|
|
64
68
|
import { pruneSupportView, isOwnedName } from './retention.js';
|
|
65
69
|
|
|
@@ -167,7 +171,7 @@ function renderReadme() {
|
|
|
167
171
|
].join('\n');
|
|
168
172
|
}
|
|
169
173
|
|
|
170
|
-
function renderSummary({ generatedAt, lagMs, coverage, caps, traces }) {
|
|
174
|
+
function renderSummary({ generatedAt, lagMs, coverage, caps, traces, anomalies }) {
|
|
171
175
|
const lines = [
|
|
172
176
|
'# UKit Support — projection summary',
|
|
173
177
|
'',
|
|
@@ -185,6 +189,8 @@ function renderSummary({ generatedAt, lagMs, coverage, caps, traces }) {
|
|
|
185
189
|
`traces: ${traces.length}`,
|
|
186
190
|
`digests_written: ${coverage.digests_written}`,
|
|
187
191
|
`digests_dropped_by_cap: ${coverage.digests_dropped_by_cap}`,
|
|
192
|
+
`anomalies_detected: ${coverage.anomalies_detected}`,
|
|
193
|
+
`crash_count: ${coverage.crash_count === null ? 'unknown' : coverage.crash_count}`,
|
|
188
194
|
];
|
|
189
195
|
if (coverage.store) {
|
|
190
196
|
lines.push(
|
|
@@ -195,6 +201,24 @@ function renderSummary({ generatedAt, lagMs, coverage, caps, traces }) {
|
|
|
195
201
|
lines.push(`skipped_files: ${coverage.skipped_files.join(', ')}`);
|
|
196
202
|
}
|
|
197
203
|
lines.push('', '## caps', `max_bytes: ${caps.maxBytes}`, `max_age_ms: ${caps.maxAgeMs}`, `max_digests: ${caps.maxDigests}`, `max_records: ${caps.maxRecords}`, '');
|
|
204
|
+
|
|
205
|
+
// Anomalies: failed digests first (existing promotion), then detector
|
|
206
|
+
// hits (`- anomaly:<kind> <trace-ref>`). Deterministic — detectAnomalies
|
|
207
|
+
// returns sorted records; anomaly counts are declared in coverage above.
|
|
208
|
+
lines.push('## anomalies');
|
|
209
|
+
const anomalyLines = [];
|
|
210
|
+
for (const t of traces) {
|
|
211
|
+
if (t.failed) anomalyLines.push(`- failed:${t.digestName}`);
|
|
212
|
+
}
|
|
213
|
+
for (const a of anomalies) {
|
|
214
|
+
const owner = traces.find((t) => t.traceId === a.trace_id);
|
|
215
|
+
const traceRef = a.trace_id === null ? 'untraced' : a.trace_id;
|
|
216
|
+
anomalyLines.push(`- anomaly:${a.payload.kind} trace=${traceRef}${owner ? ` digest=${owner.digestName}` : ''}`);
|
|
217
|
+
}
|
|
218
|
+
if (anomalyLines.length === 0) anomalyLines.push('- none');
|
|
219
|
+
lines.push(...anomalyLines);
|
|
220
|
+
lines.push('');
|
|
221
|
+
|
|
198
222
|
lines.push('## traces');
|
|
199
223
|
for (const t of traces) {
|
|
200
224
|
lines.push(`- ${t.digestName} failed=${t.failed} spans=${t.summary.coverage.spans} telemetry_complete=${t.summary.telemetry_complete}`);
|
|
@@ -254,8 +278,8 @@ function projectKeyFor(snapshot, options) {
|
|
|
254
278
|
return { key: 'unknown', source: 'unknown' };
|
|
255
279
|
}
|
|
256
280
|
|
|
257
|
-
function projectDirName(key) {
|
|
258
|
-
return `proj-${sha256Hex(key).slice(0, 12)}`;
|
|
281
|
+
export function projectDirName(key) {
|
|
282
|
+
return `proj-${sha256Hex(String(key)).slice(0, 12)}`;
|
|
259
283
|
}
|
|
260
284
|
|
|
261
285
|
/**
|
|
@@ -408,10 +432,20 @@ export async function projectSupport(snapshot = {}, options = {}) {
|
|
|
408
432
|
truncated_records: 0,
|
|
409
433
|
digests_written: 0,
|
|
410
434
|
digests_dropped_by_cap: 0,
|
|
435
|
+
anomalies_detected: 0,
|
|
436
|
+
// null until readCrashReports resolves — unknown is declared, never 0.
|
|
437
|
+
crash_count: null,
|
|
411
438
|
skipped_files: [],
|
|
412
439
|
store: null,
|
|
413
440
|
};
|
|
414
441
|
|
|
442
|
+
// Crash reports live beside the canonical segment root; when the
|
|
443
|
+
// snapshot is record-array-only the crash state is honestly unknown.
|
|
444
|
+
if (typeof snap.root === 'string' && snap.root.length > 0) {
|
|
445
|
+
const crashRead = readCrashReports(snap.root);
|
|
446
|
+
if (crashRead.ok) coverage.crash_count = crashRead.crashes.length;
|
|
447
|
+
}
|
|
448
|
+
|
|
415
449
|
// --- collect + double-gate ---
|
|
416
450
|
const raw = await collectRecords(snap, coverage);
|
|
417
451
|
coverage.records_in = raw.length;
|
|
@@ -489,32 +523,66 @@ export async function projectSupport(snapshot = {}, options = {}) {
|
|
|
489
523
|
const lagMs = latestRecordMs > 0 ? Math.max(0, now - latestRecordMs) : null;
|
|
490
524
|
|
|
491
525
|
// --- assemble file set under the byte cap ---
|
|
526
|
+
// The fixed set is README + SUMMARY.md + manifest.json: all three are
|
|
527
|
+
// mandatory outputs, so their EXACT bytes are charged against maxBytes
|
|
528
|
+
// before any digest or record line claims budget. records.jsonl is
|
|
529
|
+
// trimmed into whatever remains, with a small wobble reserve covering
|
|
530
|
+
// the byte-count digits the final SUMMARY/manifest render gains.
|
|
492
531
|
const files = {};
|
|
493
532
|
files['README.md'] = renderReadme();
|
|
494
|
-
|
|
533
|
+
// Pre-seed so the in-loop manifest already lists a records.jsonl
|
|
534
|
+
// file-table entry — otherwise fixedBytes() under-sizes the manifest by
|
|
535
|
+
// a whole entry (~80B) and the written bundle can exceed maxBytes.
|
|
536
|
+
files['records.jsonl'] = '';
|
|
495
537
|
const recordLines = projected.map((r) => JSON.stringify(r));
|
|
496
|
-
files['records.jsonl'] = recordLines.length ? `${recordLines.join('\n')}\n` : '';
|
|
497
538
|
for (const t of shown) {
|
|
498
539
|
files[t.digestName] = renderTraceDigest(t.summary, { records: t.records, coverage: coverage.store });
|
|
499
540
|
}
|
|
500
541
|
|
|
501
|
-
|
|
502
|
-
const
|
|
542
|
+
const digestBytes = () => shown.reduce((s, t) => s + Buffer.byteLength(files[t.digestName], 'utf8'), 0);
|
|
543
|
+
const manifestBytes = (manifest) => Buffer.byteLength(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
503
544
|
const fixedBytes = () =>
|
|
504
545
|
Buffer.byteLength(files['README.md'], 'utf8') +
|
|
505
546
|
Buffer.byteLength(files['SUMMARY.md'], 'utf8') +
|
|
506
|
-
|
|
507
|
-
|
|
547
|
+
manifestBytes(files['manifest.json']);
|
|
548
|
+
|
|
549
|
+
// Anomaly detection runs on the digest set it renders — every cited
|
|
550
|
+
// trace has a digest the reader can open (no dangling citation).
|
|
551
|
+
// Records are the projected/pseudonymized set, so evidence_refs stay
|
|
552
|
+
// bundle-local. Span-less event-only traces are valid TraceSummaries
|
|
553
|
+
// here — detectAnomalies still runs its event-level detectors on them.
|
|
554
|
+
const renderSummaryCurrent = () => {
|
|
555
|
+
const evidenceRecords = new Map(shown.map((t) => [t.traceId, t.records]));
|
|
556
|
+
const anomalies = detectAnomalies(shown.map((t) => t.summary), { evidenceRecords });
|
|
557
|
+
coverage.anomalies_detected = anomalies.length;
|
|
558
|
+
return renderSummary({ generatedAt, lagMs, coverage, caps, traces: shown, anomalies });
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
const renderFixed = () => {
|
|
562
|
+
coverage.digests_written = shown.length;
|
|
563
|
+
files['SUMMARY.md'] = renderSummaryCurrent();
|
|
564
|
+
files['manifest.json'] = buildManifest({ generatedAt, pseudonym: projectPseudonym, coverage, caps, files, projectIdSource });
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
// Drop lowest-ranked digests until the whole fixed set fits the cap.
|
|
568
|
+
// Each drop shrinks SUMMARY.md and the manifest, so they are re-rendered
|
|
569
|
+
// inside the loop rather than sized once against a reserve estimate.
|
|
570
|
+
renderFixed();
|
|
571
|
+
while (shown.length > 0 && fixedBytes() + digestBytes() > caps.maxBytes) {
|
|
508
572
|
const dropped = shown.pop();
|
|
509
573
|
delete files[dropped.digestName];
|
|
510
574
|
coverage.digests_dropped_by_cap += 1;
|
|
575
|
+
renderFixed();
|
|
511
576
|
}
|
|
512
|
-
coverage.digests_written = shown.length;
|
|
513
577
|
|
|
514
|
-
// records.jsonl takes the
|
|
515
|
-
// drop first.
|
|
516
|
-
|
|
517
|
-
|
|
578
|
+
// records.jsonl takes whatever budget the fixed set + digests leave;
|
|
579
|
+
// tail (lowest-ranked) lines drop first. The final SUMMARY/manifest
|
|
580
|
+
// re-render below can grow by a few bytes (records.jsonl byte count +
|
|
581
|
+
// truncated_records digit width inside their own contents) — the wobble
|
|
582
|
+
// is bounded by RECORDS_WOBBLE_RESERVE and charged up front, so the
|
|
583
|
+
// written bundle never exceeds maxBytes.
|
|
584
|
+
const RECORDS_WOBBLE_RESERVE = 64;
|
|
585
|
+
const recordsBudget = Math.max(0, caps.maxBytes - fixedBytes() - digestBytes() - RECORDS_WOBBLE_RESERVE);
|
|
518
586
|
let used = 0;
|
|
519
587
|
let keptLines = 0;
|
|
520
588
|
for (const line of recordLines) {
|
|
@@ -526,8 +594,10 @@ export async function projectSupport(snapshot = {}, options = {}) {
|
|
|
526
594
|
coverage.truncated_records += recordLines.length - keptLines;
|
|
527
595
|
files['records.jsonl'] = keptLines > 0 ? `${recordLines.slice(0, keptLines).join('\n')}\n` : '';
|
|
528
596
|
|
|
529
|
-
|
|
530
|
-
|
|
597
|
+
// Final render at true coverage: SUMMARY declares the real truncated
|
|
598
|
+
// count, manifest carries checksums/sizes at written size.
|
|
599
|
+
files['SUMMARY.md'] = renderSummaryCurrent();
|
|
600
|
+
files['manifest.json'] = `${JSON.stringify(buildManifest({ generatedAt, pseudonym: projectPseudonym, coverage, caps, files, projectIdSource }), null, 2)}\n`;
|
|
531
601
|
|
|
532
602
|
// --- write phase: per-file atomic temp-rename, manifest last ---
|
|
533
603
|
await fs.promises.mkdir(dir, { recursive: true });
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* provision.js (TASK-010, SPEC §5 DF2-FR10, §8) — install-time
|
|
3
|
+
* `Documents/UKit Support` provisioning.
|
|
4
|
+
*
|
|
5
|
+
* provisionSupportDir({ homeDir?, env?, platform?, now? })
|
|
6
|
+
* → { status: 'created'|'exists'|'degraded', dir?, reason? }
|
|
7
|
+
*
|
|
8
|
+
* Called once per `ukit install` (see src/cli/commands/install.js). It
|
|
9
|
+
* resolves the OS-native Documents folder via paths.js, creates the
|
|
10
|
+
* `UKit Support` directory when absent, and lays down the two skeleton
|
|
11
|
+
* files the support view needs before any projection has ever run:
|
|
12
|
+
*
|
|
13
|
+
* README.md — human stub: what the folder is, how to zip+send it.
|
|
14
|
+
* manifest.json — minimal skeleton `{format, provisioned: true,
|
|
15
|
+
* redaction_version, created_at, files:{}}` marking the
|
|
16
|
+
* dir as UKit-owned. Written last — the manifest is the
|
|
17
|
+
* commit point, same convention as the projector.
|
|
18
|
+
*
|
|
19
|
+
* Safety invariants (shared with projector.js):
|
|
20
|
+
* - Never overwrites: existing regular files are left byte-for-byte
|
|
21
|
+
* intact — foreign files AND earlier skeleton files alike. A second
|
|
22
|
+
* provision therefore reports `exists` with zero writes.
|
|
23
|
+
* - Never follows symlinks: `UKit Support` or a skeleton file being a
|
|
24
|
+
* symlink degrades with a typed reason (`blocked_symlink` /
|
|
25
|
+
* `required_file_blocked`); the link is not traversed or replaced.
|
|
26
|
+
* - No raw fallback: an unresolvable Documents folder degrades with the
|
|
27
|
+
* resolver's `documents_*` reason — nothing is written anywhere else.
|
|
28
|
+
* - Never throws: every io failure maps to {status:'degraded',reason}.
|
|
29
|
+
*
|
|
30
|
+
* `ukit uninstall` is deliberately untouched: user-facing data stays.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import fs from 'node:fs';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
|
|
36
|
+
import { writeFileAtomic } from '../../fileOps.js';
|
|
37
|
+
import { ioReason } from '../segments/internal.js';
|
|
38
|
+
import { REDACTION_VERSION } from '../privacy/allowlist.js';
|
|
39
|
+
import { SUPPORT_FORMAT } from './manifest.js';
|
|
40
|
+
import { resolveSupportDir } from './paths.js';
|
|
41
|
+
|
|
42
|
+
/** Skeleton names provision may fill in; manifest is the commit point. */
|
|
43
|
+
const SKELETON_ORDER = ['README.md', 'manifest.json'];
|
|
44
|
+
|
|
45
|
+
function isPlainObject(value) {
|
|
46
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function renderReadmeStub() {
|
|
50
|
+
return `# UKit Support
|
|
51
|
+
|
|
52
|
+
This folder is created by UKit (\`ukit install\`) for support diagnostics.
|
|
53
|
+
It is safe to share: every bundle is sanitized (IDs are bundle-local
|
|
54
|
+
pseudonyms) and each project keeps its own proj-* subdirectory.
|
|
55
|
+
|
|
56
|
+
## What lives here
|
|
57
|
+
- \`proj-*\` directories — one per project, holding README/SUMMARY,
|
|
58
|
+
records, and per-trace digests when telemetry support projection runs.
|
|
59
|
+
|
|
60
|
+
## How to send it for support
|
|
61
|
+
|
|
62
|
+
1. Zip this whole folder (or just the proj-* subdirectory for the
|
|
63
|
+
project in question).
|
|
64
|
+
2. Send the zip to whoever is helping you debug.
|
|
65
|
+
3. They can verify and inspect it with \`ukit telemetry import <path>\`.
|
|
66
|
+
|
|
67
|
+
You may delete this folder at any time — nothing depends on it
|
|
68
|
+
unconditionally. \`ukit uninstall\` deliberately leaves it in place.
|
|
69
|
+
`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function skeletonManifest(createdAt) {
|
|
73
|
+
return {
|
|
74
|
+
format: SUPPORT_FORMAT,
|
|
75
|
+
provisioned: true,
|
|
76
|
+
redaction_version: REDACTION_VERSION,
|
|
77
|
+
created_at: createdAt,
|
|
78
|
+
files: {},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {{ homeDir?: string|null, env?: object, platform?: string, now?: number }} [opts]
|
|
84
|
+
* @returns {Promise<{ status: 'created'|'exists'|'degraded', dir?: string, reason?: string }>}
|
|
85
|
+
*/
|
|
86
|
+
export async function provisionSupportDir(opts = {}) {
|
|
87
|
+
const resolved = await resolveSupportDir({
|
|
88
|
+
homeDir: opts.homeDir,
|
|
89
|
+
env: opts.env,
|
|
90
|
+
platform: opts.platform,
|
|
91
|
+
});
|
|
92
|
+
if (!resolved.ok) {
|
|
93
|
+
return { status: 'degraded', reason: resolved.reason };
|
|
94
|
+
}
|
|
95
|
+
const dir = resolved.dir;
|
|
96
|
+
|
|
97
|
+
// The support path itself must be a real directory — never a symlink
|
|
98
|
+
// (nothing is followed) and never a foreign file (nothing replaced).
|
|
99
|
+
let status = 'created';
|
|
100
|
+
let existed = false;
|
|
101
|
+
try {
|
|
102
|
+
const st = await fs.promises.lstat(dir);
|
|
103
|
+
if (st.isSymbolicLink()) {
|
|
104
|
+
return { status: 'degraded', dir, reason: 'blocked_symlink' };
|
|
105
|
+
}
|
|
106
|
+
if (!st.isDirectory()) {
|
|
107
|
+
return { status: 'degraded', dir, reason: 'support_path_not_directory' };
|
|
108
|
+
}
|
|
109
|
+
existed = true;
|
|
110
|
+
} catch (err) {
|
|
111
|
+
if (!err || err.code !== 'ENOENT') {
|
|
112
|
+
return { status: 'degraded', dir, reason: ioReason(err) };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (existed) status = 'exists';
|
|
116
|
+
else {
|
|
117
|
+
try {
|
|
118
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
119
|
+
} catch (err) {
|
|
120
|
+
return { status: 'degraded', dir, reason: ioReason(err) };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const now = Number.isFinite(opts.now) ? opts.now : Date.now();
|
|
125
|
+
const files = {
|
|
126
|
+
'README.md': renderReadmeStub(),
|
|
127
|
+
'manifest.json': `${JSON.stringify(skeletonManifest(new Date(now).toISOString()), null, 2)}\n`,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Fill in only missing skeleton files — an existing regular file wins,
|
|
131
|
+
// whether it is foreign or one of ours from a previous install.
|
|
132
|
+
let requiredBlocked = false;
|
|
133
|
+
for (const name of SKELETON_ORDER) {
|
|
134
|
+
const target = path.join(dir, name);
|
|
135
|
+
let st;
|
|
136
|
+
try {
|
|
137
|
+
st = await fs.promises.lstat(target);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
if (err && err.code === 'ENOENT') {
|
|
140
|
+
st = null;
|
|
141
|
+
} else {
|
|
142
|
+
return { status: 'degraded', dir, reason: ioReason(err) };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (st) {
|
|
146
|
+
if (!st.isFile() || st.isSymbolicLink()) requiredBlocked = true;
|
|
147
|
+
continue; // foreign or owned: never overwritten either way
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
await writeFileAtomic(target, files[name]);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
return { status: 'degraded', dir, reason: ioReason(err) };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (requiredBlocked) {
|
|
157
|
+
return { status: 'degraded', dir, reason: 'required_file_blocked' };
|
|
158
|
+
}
|
|
159
|
+
return { status, dir };
|
|
160
|
+
}
|
|
@@ -27,8 +27,8 @@ const README_ORDER = [
|
|
|
27
27
|
' before trusting anything else.',
|
|
28
28
|
'2. `SUMMARY.md` — projection summary: counts, freshness lag, declared',
|
|
29
29
|
' coverage gaps. Start here.',
|
|
30
|
-
'3. Anomalies —
|
|
31
|
-
'
|
|
30
|
+
'3. Anomalies — SUMMARY.md `## anomalies` lists failed digests plus',
|
|
31
|
+
' deterministic detector hits (anomaly:<kind>) with their trace refs.',
|
|
32
32
|
'4. `digest-*.md` — per-trace digests, failure-promoted. Read the',
|
|
33
33
|
' failing traces before the clean ones.',
|
|
34
34
|
'5. `records.jsonl` — the raw sanitized evidence behind every claim',
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* schedule.js (TASK-012, SPEC §5 DF2-FR12, §10) — automatic support-projection
|
|
3
|
+
* scheduling behind the projector seam.
|
|
4
|
+
*
|
|
5
|
+
* maybeRefreshSupport({ projectRoot, config, recorder?, homeDir?, env?,
|
|
6
|
+
* platform?, now? })
|
|
7
|
+
* → { status: 'refreshed', projected_at, stamped, bytes }
|
|
8
|
+
* | { status: 'skipped', reason }
|
|
9
|
+
*
|
|
10
|
+
* MIN_PROJECTION_INTERVAL_MS = 15 * 60 * 1000
|
|
11
|
+
* STAMP_FORMAT = 'ukit-last-projection/1'
|
|
12
|
+
*
|
|
13
|
+
* Gate order (each gate reports its own typed reason):
|
|
14
|
+
* 1. no projectRoot → skipped / no_project_root
|
|
15
|
+
* 2. projector seam off → skipped / stage_off — resolveSeamStages
|
|
16
|
+
* clamps global 'off'/'shadow' (and restrictive-only seam overrides)
|
|
17
|
+
* below the projector minimum ('canary'); the call then delegates the
|
|
18
|
+
* kill-switch obligation to projectSupport's existing stage-off
|
|
19
|
+
* invalidation path instead of returning early, so disabling the seam
|
|
20
|
+
* still scrubs previously-owned material.
|
|
21
|
+
* 3. interval gate → skipped / min_interval — the persisted
|
|
22
|
+
* stamp `.ukit/storage/observability/last-projection.json` carries the
|
|
23
|
+
* last SUCCESSFUL projection; `observability.projector.min_interval_ms`
|
|
24
|
+
* can lengthen the gap but never shrink it below the 15min floor
|
|
25
|
+
* (write-spam bound — SPEC §10 wants no write storm on repeat runs).
|
|
26
|
+
* A missing/corrupt stamp is treated as absent: refresh once, never
|
|
27
|
+
* crash-loop on a poisoned state file.
|
|
28
|
+
* 4. projectSupport({ root }) → a 'written' result re-stamps the file;
|
|
29
|
+
* degraded/skipped results map to { status:'skipped', reason } — the
|
|
30
|
+
* scheduled path reports the degrade, it does not promote it to an
|
|
31
|
+
* error (collect still exits on its own lane's verdict).
|
|
32
|
+
*
|
|
33
|
+
* `recorder?` is the in-process collect path's recorder (DF2-FR12 interfaces):
|
|
34
|
+
* before projecting, queued records are flushed with a bounded deadline so
|
|
35
|
+
* the projection sees what collect just ingested; flush failures degrade to
|
|
36
|
+
* whatever was already durable, never to an exception.
|
|
37
|
+
*
|
|
38
|
+
* Freshness lag is derived by the doctor/status surface from this stamp and
|
|
39
|
+
* from manifest.generated_at — the stamp is the scheduler's own memory, not
|
|
40
|
+
* the projector's.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import fs from 'node:fs/promises';
|
|
44
|
+
import path from 'node:path';
|
|
45
|
+
|
|
46
|
+
import { writeFileAtomic } from '../../fileOps.js';
|
|
47
|
+
import { resolveStage } from '../emit/config.js';
|
|
48
|
+
import { resolveSeamConfig } from '../rollout.js';
|
|
49
|
+
import { projectSupport } from './projector.js';
|
|
50
|
+
|
|
51
|
+
export const MIN_PROJECTION_INTERVAL_MS = 15 * 60 * 1000;
|
|
52
|
+
export const STAMP_FORMAT = 'ukit-last-projection/1';
|
|
53
|
+
const STAMP_FILE_NAME = 'last-projection.json';
|
|
54
|
+
const PRE_PROJECTION_FLUSH_DEADLINE_MS = 10_000;
|
|
55
|
+
|
|
56
|
+
function isPlainObject(value) {
|
|
57
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `<projectRoot>/.ukit/storage/observability/last-projection.json`. */
|
|
61
|
+
export function lastProjectionStampPath(projectRoot) {
|
|
62
|
+
return path.join(projectRoot, '.ukit', 'storage', 'observability', STAMP_FILE_NAME);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Effective minimum gap between scheduled writes: the configured override
|
|
67
|
+
* lengthens the interval but never beats the 15min floor; malformed values
|
|
68
|
+
* degrade to the floor itself.
|
|
69
|
+
*/
|
|
70
|
+
export function projectionMinIntervalMs(config) {
|
|
71
|
+
const node = isPlainObject(config) ? config.observability : undefined;
|
|
72
|
+
const raw = isPlainObject(node) && isPlainObject(node.projector)
|
|
73
|
+
? node.projector.min_interval_ms
|
|
74
|
+
: undefined;
|
|
75
|
+
const interval = Number.isFinite(raw) && raw > 0 ? raw : MIN_PROJECTION_INTERVAL_MS;
|
|
76
|
+
return Math.max(interval, MIN_PROJECTION_INTERVAL_MS);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function readStamp(stampPath) {
|
|
80
|
+
let parsed;
|
|
81
|
+
try {
|
|
82
|
+
parsed = JSON.parse(await fs.readFile(stampPath, 'utf8'));
|
|
83
|
+
} catch {
|
|
84
|
+
return null; // missing, corrupt, unreadable — all mean "no stamp"
|
|
85
|
+
}
|
|
86
|
+
const at = isPlainObject(parsed) ? parsed.projected_at : undefined;
|
|
87
|
+
return Number.isFinite(at) ? at : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function writeStamp(stampPath, at) {
|
|
91
|
+
try {
|
|
92
|
+
await fs.mkdir(path.dirname(stampPath), { recursive: true });
|
|
93
|
+
await writeFileAtomic(
|
|
94
|
+
stampPath,
|
|
95
|
+
JSON.stringify({ format: STAMP_FORMAT, projected_at: at }) + '\n',
|
|
96
|
+
);
|
|
97
|
+
return true;
|
|
98
|
+
} catch {
|
|
99
|
+
return false; // the stamp is scheduler memory — a write failure skips, never throws
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {{ projectRoot?: string, config?: object, recorder?: object,
|
|
105
|
+
* homeDir?: string|null, env?: object, platform?: string,
|
|
106
|
+
* now?: number }} opts
|
|
107
|
+
* @returns {Promise<{status:'refreshed'|'skipped', reason?:string}>}
|
|
108
|
+
*/
|
|
109
|
+
export async function maybeRefreshSupport(opts = {}) {
|
|
110
|
+
const { projectRoot, config } = isPlainObject(opts) ? opts : {};
|
|
111
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
112
|
+
return { status: 'skipped', reason: 'no_project_root' };
|
|
113
|
+
}
|
|
114
|
+
const root = path.resolve(projectRoot);
|
|
115
|
+
const now = Number.isFinite(opts.now) ? opts.now : Date.now();
|
|
116
|
+
|
|
117
|
+
// Seam config view: resolveStage(view) is the projector seam's effective
|
|
118
|
+
// stage — below the seam's 'canary' minimum it resolves 'off', which is
|
|
119
|
+
// the value projectSupport already treats as its kill switch.
|
|
120
|
+
const seamConfig = resolveSeamConfig(config, 'projector');
|
|
121
|
+
const seamStage = resolveStage(seamConfig);
|
|
122
|
+
if (seamStage === 'off') {
|
|
123
|
+
try {
|
|
124
|
+
await projectSupport(
|
|
125
|
+
{ root: path.join(root, '.ukit', 'storage', 'observability', 'segments') },
|
|
126
|
+
{ config: seamConfig, projectRoot: root, homeDir: opts.homeDir, env: opts.env, platform: opts.platform },
|
|
127
|
+
);
|
|
128
|
+
} catch {
|
|
129
|
+
// invalidation is best-effort even on a dead projector path
|
|
130
|
+
}
|
|
131
|
+
return { status: 'skipped', reason: 'stage_off' };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const stampPath = lastProjectionStampPath(root);
|
|
135
|
+
const lastAt = await readStamp(stampPath);
|
|
136
|
+
if (lastAt !== null && now - lastAt < projectionMinIntervalMs(config)) {
|
|
137
|
+
return { status: 'skipped', reason: 'min_interval' };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// In-process collect lane: drain queued records before the snapshot read
|
|
141
|
+
// so the projection reflects this collect. Bounded — a stuck writer eats
|
|
142
|
+
// only the deadline, never the caller's lifetime.
|
|
143
|
+
const recorder = opts.recorder;
|
|
144
|
+
if (recorder && typeof recorder.flush === 'function') {
|
|
145
|
+
try {
|
|
146
|
+
await recorder.flush({ deadlineMs: PRE_PROJECTION_FLUSH_DEADLINE_MS });
|
|
147
|
+
} catch {
|
|
148
|
+
// degrade: project whatever is already durable
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let result;
|
|
153
|
+
try {
|
|
154
|
+
result = await projectSupport(
|
|
155
|
+
{ root: path.join(root, '.ukit', 'storage', 'observability', 'segments') },
|
|
156
|
+
{
|
|
157
|
+
config: seamConfig,
|
|
158
|
+
projectRoot: root,
|
|
159
|
+
homeDir: opts.homeDir,
|
|
160
|
+
env: opts.env,
|
|
161
|
+
platform: opts.platform,
|
|
162
|
+
now,
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
} catch {
|
|
166
|
+
return { status: 'skipped', reason: 'projector_error' };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (result && result.status === 'written') {
|
|
170
|
+
const stamped = await writeStamp(stampPath, now);
|
|
171
|
+
return { status: 'refreshed', projected_at: now, stamped, bytes: result.bytes ?? 0 };
|
|
172
|
+
}
|
|
173
|
+
return { status: 'skipped', reason: (result && result.reason) || 'projection_skipped' };
|
|
174
|
+
}
|
|
@@ -149,7 +149,13 @@ UKIT_RUNTIME_DIR="$SCRIPT_DIR/../ukit/runtime" node -e '
|
|
|
149
149
|
// is still legally waiting for the lock.
|
|
150
150
|
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 8000;
|
|
151
151
|
const LOCK_STARTED_AT = Date.now();
|
|
152
|
-
setTimeout(() =>
|
|
152
|
+
setTimeout(() => {
|
|
153
|
+
// A wedged fs op (fifo/NFS as a state file) parks a libuv threadpool thread and
|
|
154
|
+
// the atexit teardown of process.exit()/reallyExit() waits for it forever — the
|
|
155
|
+
// deadline can only escape by killing itself. The bash call is `|| true`-masked,
|
|
156
|
+
// so the observable contract stays "exit 0".
|
|
157
|
+
try { process.kill(process.pid, "SIGKILL"); } catch {}
|
|
158
|
+
}, HOOK_DEADLINE_MS).unref();
|
|
153
159
|
const fsp = require("fs").promises;
|
|
154
160
|
const path = require("path");
|
|
155
161
|
const { pathToFileURL } = require("url");
|
|
@@ -54,18 +54,27 @@ const LOCK_STARTED_AT = Date.now();
|
|
|
54
54
|
// SPEC §8: a failed prune abandons configured work — announce it (success stays silent).
|
|
55
55
|
// Unpruned rules are harmless (stale entries simply never match again), so degrade is
|
|
56
56
|
// advisory-only and still exits 0.
|
|
57
|
-
function emitDegrade(reason) {
|
|
57
|
+
function emitDegrade(reason, onDone) {
|
|
58
58
|
try {
|
|
59
|
-
process.stdout.write(
|
|
60
|
-
systemMessage: `UKit auto-prune-bash: ${reason}
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
process.stdout.write(
|
|
60
|
+
JSON.stringify({ systemMessage: `UKit auto-prune-bash: ${reason}` }) + "\n",
|
|
61
|
+
() => { try { onDone?.(); } catch {} },
|
|
62
|
+
);
|
|
63
|
+
} catch {
|
|
64
|
+
try { onDone?.(); } catch {}
|
|
65
|
+
}
|
|
63
66
|
}
|
|
64
67
|
|
|
65
68
|
setTimeout(() => {
|
|
66
|
-
emitDegrade("prune exceeded its deadline; stale Bash auto-allow rules were not pruned this session — the next session retries.")
|
|
67
|
-
|
|
69
|
+
emitDegrade("prune exceeded its deadline; stale Bash auto-allow rules were not pruned this session — the next session retries.", () => {
|
|
70
|
+
// The degrade line must be flushed BEFORE the kill, and process.exit() cannot
|
|
71
|
+
// be used here: a wedged fs op parks a libuv threadpool thread and atexit
|
|
72
|
+
// teardown waits for it forever. SIGKILL is the only guaranteed escape; the
|
|
73
|
+
// bash call is `|| true`-masked, so the observable contract stays "exit 0".
|
|
74
|
+
try { process.kill(process.pid, "SIGKILL"); } catch {}
|
|
75
|
+
});
|
|
68
76
|
}, HOOK_DEADLINE_MS).unref();
|
|
77
|
+
|
|
69
78
|
const fsp = require("fs").promises;
|
|
70
79
|
const path = require("path");
|
|
71
80
|
const { pathToFileURL } = require("url");
|
|
@@ -71,9 +71,16 @@ setTimeout(() => {
|
|
|
71
71
|
try {
|
|
72
72
|
process.stdout.write(JSON.stringify({
|
|
73
73
|
systemMessage: `UKit verification-guard: evaluation exceeded its ${HOOK_DEADLINE_MS}ms deadline; this command was allowed without verification tracking.`,
|
|
74
|
-
}) + '\n')
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
}) + '\n', () => {
|
|
75
|
+
// The degrade line must be flushed BEFORE the kill, and process.exit() cannot
|
|
76
|
+
// be used here: a wedged fs op parks a libuv threadpool thread and atexit
|
|
77
|
+
// teardown waits for it forever. SIGKILL is the only guaranteed escape; the
|
|
78
|
+
// wrapper exits 0 regardless of node status (advisory contract).
|
|
79
|
+
try { process.kill(process.pid, 'SIGKILL'); } catch {}
|
|
80
|
+
});
|
|
81
|
+
} catch {
|
|
82
|
+
try { process.kill(process.pid, 'SIGKILL'); } catch {}
|
|
83
|
+
}
|
|
77
84
|
}, HOOK_DEADLINE_MS).unref();
|
|
78
85
|
// TASK-015 fix round 1: the awaitable fs surface. Every state/progress read and
|
|
79
86
|
// the atomic progress mutation is awaited so the unref'd self-deadline above can
|
|
@@ -570,4 +577,6 @@ process.exit(0);
|
|
|
570
577
|
});
|
|
571
578
|
NODE
|
|
572
579
|
|
|
573
|
-
|
|
580
|
+
# Advisory hook: the deadline may SIGKILL node past a wedged fs op — the hook
|
|
581
|
+
# contract is still always-exit-0 regardless of the child status.
|
|
582
|
+
exit 0
|
|
@@ -452,6 +452,15 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
|
|
|
452
452
|
const releaseRetry = (op) => withTransientFsRetry(op, { deadlineMs: LOCK_RESERVE_MS });
|
|
453
453
|
|
|
454
454
|
while (true) {
|
|
455
|
+
// Abort wins over every other condition, checked once per iteration: an abort
|
|
456
|
+
// landing mid-sweep/mid-op must still surface the typed aborted outcome — the
|
|
457
|
+
// ops below can throw the raw signal.reason (withTransientFsRetry rethrows
|
|
458
|
+
// AbortError when the signal fires during a transient retry), and a non-EEXIST
|
|
459
|
+
// throw would otherwise escape as an unhandled rejection instead of the
|
|
460
|
+
// contract's { ok: false, reason: 'aborted' } envelope.
|
|
461
|
+
if (signal?.aborted) {
|
|
462
|
+
return { ok: false, reason: 'aborted', waitedMs: Date.now() - startedAt };
|
|
463
|
+
}
|
|
455
464
|
// Reap stale `*.reclaim-*` quarantine dirs stranded by dead reapers. Cheap:
|
|
456
465
|
// one readdir per attempt, all failures swallowed (C79-21).
|
|
457
466
|
await sweepStaleReclaims(lockPath, stale);
|
|
@@ -489,6 +498,11 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
|
|
|
489
498
|
owned = true;
|
|
490
499
|
break;
|
|
491
500
|
} catch (error) {
|
|
501
|
+
// An abort surfacing as the raw AbortError out of a transient retry is the
|
|
502
|
+
// typed aborted outcome, never an untyped throw.
|
|
503
|
+
if (signal?.aborted) {
|
|
504
|
+
return { ok: false, reason: 'aborted', waitedMs: Date.now() - startedAt };
|
|
505
|
+
}
|
|
492
506
|
if (error?.code !== 'EEXIST') throw error;
|
|
493
507
|
}
|
|
494
508
|
|
|
@@ -533,6 +547,12 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
|
|
|
533
547
|
}
|
|
534
548
|
if (reclaimed) continue;
|
|
535
549
|
|
|
550
|
+
// Abort after the wait phase still returns the typed aborted outcome — the
|
|
551
|
+
// budget check must not claim 'busy' for a caller that was actually cancelled.
|
|
552
|
+
if (signal?.aborted) {
|
|
553
|
+
return { ok: false, reason: 'aborted', waitedMs: Date.now() - startedAt };
|
|
554
|
+
}
|
|
555
|
+
|
|
536
556
|
const waitedMs = Date.now() - startedAt;
|
|
537
557
|
if (waitedMs >= budget) {
|
|
538
558
|
// Fail closed: the caller's policy decides what a busy lock means. The callback
|
|
@@ -548,6 +568,12 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
|
|
|
548
568
|
}
|
|
549
569
|
}
|
|
550
570
|
|
|
571
|
+
// Abort between acquire and the critical section releases the owned lock through
|
|
572
|
+
// the normal finally path and reports 'aborted' instead of running fn after the
|
|
573
|
+
// caller was cancelled.
|
|
574
|
+
if (signal?.aborted) {
|
|
575
|
+
return { ok: false, reason: 'aborted', waitedMs: Date.now() - startedAt };
|
|
576
|
+
}
|
|
551
577
|
try {
|
|
552
578
|
return { ok: true, value: await fn() };
|
|
553
579
|
} finally {
|