@ngockhoale/ukit 3.0.12 → 3.1.1

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 (66) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +1 -0
  3. package/manifests/documentation.yaml +12 -0
  4. package/manifests/platform.full.yaml +24 -0
  5. package/package.json +1 -1
  6. package/scripts/bench/data-foundation.mjs +368 -50
  7. package/src/cli/commands/doctor.js +232 -3
  8. package/src/cli/commands/feedback.js +64 -1
  9. package/src/cli/commands/install.js +18 -0
  10. package/src/cli/commands/memory.js +42 -37
  11. package/src/cli/commands/telemetry.js +460 -0
  12. package/src/cli/index.js +7 -0
  13. package/src/core/agentRuntime/adapters.js +83 -2
  14. package/src/core/agentRuntime/diagnostics.js +104 -0
  15. package/src/core/agentRuntime/supervisor.js +137 -0
  16. package/src/core/agentRuntime/telemetry.js +204 -0
  17. package/src/core/memory/memoryEmit.js +131 -0
  18. package/src/core/memory/memoryHit.js +1 -1
  19. package/src/core/memory/migrate.js +18 -11
  20. package/src/core/memory/migrateMapping.js +15 -7
  21. package/src/core/memory/mutateMemory.js +22 -4
  22. package/src/core/memory/recordIndex.js +10 -3
  23. package/src/core/memory/recordStore.js +28 -3
  24. package/src/core/memory/retrieval.js +79 -38
  25. package/src/core/memory/store.js +37 -37
  26. package/src/core/memory/storeV2.js +28 -25
  27. package/src/core/memory/storeV2Loader.js +2 -2
  28. package/src/core/observability/adapters/ingest.js +576 -0
  29. package/src/core/observability/analytics/anomalies.js +415 -0
  30. package/src/core/observability/analytics/summary.js +16 -1
  31. package/src/core/observability/emit/config.js +69 -1
  32. package/src/core/observability/emit/crash.js +434 -0
  33. package/src/core/observability/emit/lifecycle.js +349 -0
  34. package/src/core/observability/emit/recorder.js +135 -9
  35. package/src/core/observability/evaluation/aiPacket.js +52 -10
  36. package/src/core/observability/evaluation/outcomes.js +95 -0
  37. package/src/core/observability/evaluation/runner.js +225 -0
  38. package/src/core/observability/privacy/allowlist.js +23 -3
  39. package/src/core/observability/schema/compatibility.js +48 -3
  40. package/src/core/observability/schema/constants.js +5 -0
  41. package/src/core/observability/schema/registry.js +57 -0
  42. package/src/core/observability/schema/validate.js +68 -6
  43. package/src/core/observability/segments/internal.js +42 -8
  44. package/src/core/observability/segments/readSegments.js +35 -1
  45. package/src/core/observability/segments/recovery.js +3 -2
  46. package/src/core/observability/segments/retention.js +137 -33
  47. package/src/core/observability/support/projector.js +88 -18
  48. package/src/core/observability/support/provision.js +160 -0
  49. package/src/core/observability/support/renderer.js +2 -2
  50. package/src/core/observability/support/schedule.js +174 -0
  51. package/src/decision/registry.js +144 -0
  52. package/src/decision/reviewVerdict.js +309 -0
  53. package/template_project/.claude/agents/code-reviewer.md +25 -1
  54. package/template_project/.claude/agents/ukit-small-task-maintainer.md +16 -0
  55. package/template_project/.claude/commands/ukit/handoff-fullstack.md +2 -0
  56. package/template_project/.claude/commands/ukit/handoff-review.md +12 -0
  57. package/template_project/.claude/hooks/auto-allow-bash.sh +7 -1
  58. package/template_project/.claude/hooks/auto-prune-bash.sh +16 -7
  59. package/template_project/.claude/hooks/verification-guard.sh +13 -4
  60. package/template_project/.claude/ukit/index/review-verdict.mjs +592 -0
  61. package/template_project/.claude/ukit/index/sidecar-decision.mjs +595 -0
  62. package/template_project/.claude/ukit/index/unic-decision.mjs +10 -1
  63. package/template_project/.claude/ukit/runtime/async-lock.mjs +26 -0
  64. package/template_project/.codex/settings.json +3 -0
  65. package/template_project/.omp/agents/code-reviewer.md +25 -1
  66. package/template_project/.omp/agents/ukit-small-task-maintainer.md +16 -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
+ }