@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +1 -0
  3. package/manifests/documentation.yaml +12 -0
  4. package/package.json +1 -1
  5. package/scripts/bench/data-foundation.mjs +368 -50
  6. package/src/cli/commands/doctor.js +232 -3
  7. package/src/cli/commands/feedback.js +64 -1
  8. package/src/cli/commands/install.js +18 -0
  9. package/src/cli/commands/memory.js +42 -37
  10. package/src/cli/commands/telemetry.js +460 -0
  11. package/src/cli/index.js +7 -0
  12. package/src/core/agentRuntime/adapters.js +83 -2
  13. package/src/core/agentRuntime/diagnostics.js +104 -0
  14. package/src/core/agentRuntime/supervisor.js +137 -0
  15. package/src/core/agentRuntime/telemetry.js +204 -0
  16. package/src/core/memory/memoryEmit.js +131 -0
  17. package/src/core/memory/memoryHit.js +1 -1
  18. package/src/core/memory/migrate.js +18 -11
  19. package/src/core/memory/migrateMapping.js +15 -7
  20. package/src/core/memory/mutateMemory.js +22 -4
  21. package/src/core/memory/recordIndex.js +10 -3
  22. package/src/core/memory/recordStore.js +28 -3
  23. package/src/core/memory/retrieval.js +79 -38
  24. package/src/core/memory/store.js +37 -37
  25. package/src/core/memory/storeV2.js +28 -25
  26. package/src/core/memory/storeV2Loader.js +2 -2
  27. package/src/core/observability/adapters/ingest.js +576 -0
  28. package/src/core/observability/analytics/anomalies.js +415 -0
  29. package/src/core/observability/analytics/summary.js +16 -1
  30. package/src/core/observability/emit/config.js +69 -1
  31. package/src/core/observability/emit/crash.js +434 -0
  32. package/src/core/observability/emit/lifecycle.js +349 -0
  33. package/src/core/observability/emit/recorder.js +135 -9
  34. package/src/core/observability/evaluation/aiPacket.js +52 -10
  35. package/src/core/observability/evaluation/outcomes.js +95 -0
  36. package/src/core/observability/evaluation/runner.js +225 -0
  37. package/src/core/observability/privacy/allowlist.js +23 -3
  38. package/src/core/observability/schema/compatibility.js +48 -3
  39. package/src/core/observability/schema/constants.js +5 -0
  40. package/src/core/observability/schema/registry.js +57 -0
  41. package/src/core/observability/schema/validate.js +68 -6
  42. package/src/core/observability/segments/internal.js +42 -8
  43. package/src/core/observability/segments/readSegments.js +35 -1
  44. package/src/core/observability/segments/recovery.js +3 -2
  45. package/src/core/observability/segments/retention.js +137 -33
  46. package/src/core/observability/support/projector.js +88 -18
  47. package/src/core/observability/support/provision.js +160 -0
  48. package/src/core/observability/support/renderer.js +2 -2
  49. package/src/core/observability/support/schedule.js +174 -0
  50. package/template_project/.claude/hooks/auto-allow-bash.sh +7 -1
  51. package/template_project/.claude/hooks/auto-prune-bash.sh +16 -7
  52. package/template_project/.claude/hooks/verification-guard.sh +13 -4
  53. 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 → atomic
33
- * per-file temp-rename write (manifest.json last = commit point) →
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
- files['SUMMARY.md'] = renderSummary({ generatedAt, lagMs, coverage, caps, traces: shown });
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
- // Drop lowest-ranked digests until the fixed set fits under the cap.
502
- const manifestReserve = 4096;
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
- manifestReserve;
507
- while (shown.length > 0 && fixedBytes() + shown.reduce((s, t) => s + Buffer.byteLength(files[t.digestName], 'utf8'), 0) > caps.maxBytes) {
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 remaining budget; tail (lowest-ranked) lines
515
- // drop first. Prefix accumulation — O(n), no repeated joins.
516
- const digestBytes = shown.reduce((s, t) => s + Buffer.byteLength(files[t.digestName], 'utf8'), 0);
517
- const recordsBudget = Math.max(0, caps.maxBytes - fixedBytes() - digestBytes);
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
- const manifest = buildManifest({ generatedAt, pseudonym: projectPseudonym, coverage, caps, files, projectIdSource });
530
- files['manifest.json'] = `${JSON.stringify(manifest, null, 2)}\n`;
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 — traces flagged failed/blocked are promoted first in',
31
- ' SUMMARY.md and in the digest ordering.',
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(() => process.exit(0), HOOK_DEADLINE_MS).unref();
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(JSON.stringify({
60
- systemMessage: `UKit auto-prune-bash: ${reason}`,
61
- }) + "\n");
62
- } catch {}
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
- process.exit(0);
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
- } catch {}
76
- process.exit(0);
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
- exit $?
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 {