@ngockhoale/ukit 3.0.8 → 3.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -1
- package/manifests/documentation.yaml +11 -0
- package/package.json +1 -1
- package/scripts/audit/decision-coverage.mjs +29 -2
- package/scripts/bench/data-foundation.mjs +52 -3
- package/scripts/bench/decision-runtime-baseline.mjs +427 -0
- package/scripts/bench/decision-runtime-metrics.mjs +67 -0
- package/scripts/bench/decision-runtime-variant.mjs +626 -0
- package/scripts/bench/memory-ablation.mjs +495 -0
- package/scripts/bench/memory-baseline.mjs +596 -0
- package/scripts/bench/memory-bench.mjs +661 -0
- package/scripts/bench/memory-canary.mjs +321 -0
- package/scripts/bench/memory-corpus.mjs +354 -0
- package/scripts/bench/memory-gate.mjs +389 -0
- package/scripts/bench/memory-metrics.mjs +179 -0
- package/scripts/bench/parallel-agents.mjs +33 -11
- package/scripts/bench/recorder-overhead.mjs +204 -0
- package/scripts/bench/sqlite-spike.mjs +451 -0
- package/scripts/measure-decision-gateway.mjs +306 -0
- package/scripts/perf/audit-perf.mjs +35 -17
- package/src/bug/triageBug.js +4 -3
- package/src/cli/commands/memory.js +357 -63
- package/src/context/detectProjectContext.js +11 -1
- package/src/core/agentRuntime/adapters.js +254 -0
- package/src/core/agentRuntime/artifacts.js +192 -0
- package/src/core/agentRuntime/completionGate.js +176 -0
- package/src/core/agentRuntime/context.js +149 -0
- package/src/core/agentRuntime/contract.js +247 -0
- package/src/core/agentRuntime/diagnostics.js +244 -0
- package/src/core/agentRuntime/evaluation.js +163 -0
- package/src/core/agentRuntime/eventStore.js +404 -0
- package/src/core/agentRuntime/liveness.js +60 -0
- package/src/core/agentRuntime/planCompiler.js +322 -0
- package/src/core/agentRuntime/promotion.js +53 -0
- package/src/core/agentRuntime/qualityComparison.js +112 -0
- package/src/core/agentRuntime/recovery.js +266 -0
- package/src/core/agentRuntime/resourcePolicy.js +78 -0
- package/src/core/agentRuntime/runtimeSupport.js +237 -0
- package/src/core/agentRuntime/supervisor.js +565 -0
- package/src/core/agentRuntime/vmEngine.js +621 -0
- package/src/core/codeintel/analogy.js +3 -2
- package/src/core/experiments/dynamicWorkflow.js +17 -2
- package/src/core/fileOps.js +21 -3
- package/src/core/memory/deltaOverlays.js +75 -30
- package/src/core/memory/learningCandidates.js +93 -48
- package/src/core/memory/memoryFlags.js +83 -0
- package/src/core/memory/memoryFreshness.js +190 -0
- package/src/core/memory/memoryHit.js +144 -0
- package/src/core/memory/migrate.js +69 -189
- package/src/core/memory/migrateMapping.js +232 -0
- package/src/core/memory/mutateMemory.js +323 -0
- package/src/core/memory/policy.js +96 -0
- package/src/core/memory/projectIdentity.js +266 -0
- package/src/core/memory/recordIndex.js +178 -0
- package/src/core/memory/recordStore.js +133 -20
- package/src/core/memory/records.js +144 -6
- package/src/core/memory/retrieval.js +259 -125
- package/src/core/memory/store.js +16 -5
- package/src/core/memory/storeBackup.js +226 -0
- package/src/core/memory/storeV2.js +63 -26
- package/src/core/memory/storeV2Loader.js +30 -12
- package/src/core/memory/userMemory.js +38 -20
- package/src/core/memory/writeClassification.js +161 -0
- package/src/core/memory/writeGuard.js +129 -0
- package/src/core/observability/adapters/hookTelemetryAdapter.js +90 -0
- package/src/core/observability/analytics/cohorts.js +148 -0
- package/src/core/observability/analytics/storeDigest.js +163 -0
- package/src/core/observability/evaluation/experimentPlan.js +95 -0
- package/src/core/observability/evaluation/findings.js +99 -0
- package/src/core/observability/evaluation/optimizationKnowledge.js +10 -1
- package/src/core/observability/evaluation/perturbation.js +273 -0
- package/src/core/observability/evaluation/replay.js +7 -1
- package/src/core/observability/evaluation/scorecard.js +23 -3
- package/src/core/observability/rollout.js +11 -7
- package/src/core/observability/schema/compatibility.js +135 -0
- package/src/core/observability/schema/registry.js +99 -0
- package/src/core/observability/schema/validate.js +7 -0
- package/src/core/observability/support/import.js +53 -9
- package/src/core/observability/support/paths.js +13 -3
- package/src/core/observability/support/projector.js +148 -12
- package/src/core/output/index.js +12 -2
- package/src/core/runtimeConfig.js +83 -0
- package/src/core/runtimePaths.js +3 -0
- package/src/core/sensitiveValueScanner.js +40 -0
- package/src/core/token/index.js +40 -3
- package/src/decision/client.js +37 -13
- package/src/decision/protocol.js +1 -1
- package/src/decision/registry.js +5 -3
- package/src/decision/runtimeDecide.js +242 -0
- package/src/decision/runtimeFilter.js +150 -0
- package/src/decision/runtimeScheduler.js +239 -0
- package/src/index/buildIndex.js +13 -12
- package/src/index/queryIndex.js +35 -14
- package/src/index/relatedTests.js +50 -8
- package/src/index/resolveContext.js +9 -4
- package/src/manifest/selectItems.js +7 -3
- package/src/render/instructionRenderer.js +17 -5
- package/template_project/.claude/ukit/index/lib/index-core.mjs +94 -39
- package/template_project/.claude/ukit/index/route-task.mjs +121 -19
- package/template_project/.claude/ukit/index/unic-decision.mjs +28 -13
- package/template_project/.claude/ukit/runtime/memory-flags.mjs +51 -0
- package/template_project/.claude/ukit/runtime/memory-freshness.mjs +155 -0
- package/template_project/.claude/ukit/runtime/memory-policy.mjs +286 -0
- package/template_project/.claude/ukit/runtime/output-compression.mjs +3 -0
- package/template_project/.claude/ukit/runtime/reinject-context.mjs +145 -14
package/src/core/fileOps.js
CHANGED
|
@@ -103,7 +103,7 @@ export async function ensureDir(dirPath) {
|
|
|
103
103
|
await fs.mkdir(dirPath, { recursive: true });
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
export async function writeFileAtomic(filePath, content) {
|
|
106
|
+
export async function writeFileAtomic(filePath, content, { fsync = false } = {}) {
|
|
107
107
|
const dir = path.dirname(filePath);
|
|
108
108
|
await ensureDir(dir);
|
|
109
109
|
|
|
@@ -119,6 +119,24 @@ export async function writeFileAtomic(filePath, content) {
|
|
|
119
119
|
} else {
|
|
120
120
|
await withTransientFsRetry(() => fs.writeFile(tempPath, content, 'utf8'));
|
|
121
121
|
}
|
|
122
|
+
if (fsync) {
|
|
123
|
+
// Best-effort durability: flush the tmp file to stable storage before
|
|
124
|
+
// the rename publishes it. Filesystems without fsync support report
|
|
125
|
+
// ENOTSUP/EINVAL/EBADF — those are ignored so writes still land.
|
|
126
|
+
let handle;
|
|
127
|
+
try {
|
|
128
|
+
handle = await fs.open(tempPath, 'r');
|
|
129
|
+
await handle.sync();
|
|
130
|
+
} catch (syncError) {
|
|
131
|
+
if (!['ENOTSUP', 'EINVAL', 'EBADF'].includes(syncError?.code)) throw syncError;
|
|
132
|
+
} finally {
|
|
133
|
+
if (handle) await handle.close().catch(() => {});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Test-only crash-injection hook (TASK-002): UKIT_TEST_CRASH_AT=
|
|
137
|
+
// 'pre-rename' kills the process after the tmp write but before the
|
|
138
|
+
// rename publishes it — proving a mid-write crash is all-or-nothing.
|
|
139
|
+
if (process.env.UKIT_TEST_CRASH_AT === 'pre-rename') process.exit(1);
|
|
122
140
|
try {
|
|
123
141
|
await withTransientFsRetry(() => fs.rename(tempPath, filePath));
|
|
124
142
|
} catch (renameError) {
|
|
@@ -179,8 +197,8 @@ export async function copyFileRawExclusive(fromPath, toPath) {
|
|
|
179
197
|
await fs.copyFile(fromPath, toPath, fs.constants.COPYFILE_EXCL);
|
|
180
198
|
}
|
|
181
199
|
|
|
182
|
-
export async function writeJson(filePath, data) {
|
|
183
|
-
await writeFileAtomic(filePath, `${JSON.stringify(data, null, 2)}\n
|
|
200
|
+
export async function writeJson(filePath, data, opts) {
|
|
201
|
+
await writeFileAtomic(filePath, `${JSON.stringify(data, null, 2)}\n`, opts);
|
|
184
202
|
}
|
|
185
203
|
|
|
186
204
|
const LOCK_STALE_MS = 10_000;
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
|
|
31
31
|
import crypto from 'node:crypto';
|
|
32
32
|
import { loadRuntimeConfig, resolveConfigStage } from '../runtimeConfig.js';
|
|
33
|
-
import {
|
|
33
|
+
import { queryRecords } from './storeV2.js';
|
|
34
|
+
import { mutateMemory } from './mutateMemory.js';
|
|
34
35
|
|
|
35
36
|
export const OVERLAY_PROVENANCE = 'delta-overlay';
|
|
36
37
|
export const OVERLAY_OPS = Object.freeze(['insert', 'suppress', 'replace-field']);
|
|
@@ -261,13 +262,29 @@ async function findOverlayRecord(projectRoot, overlayId) {
|
|
|
261
262
|
) ?? null;
|
|
262
263
|
}
|
|
263
264
|
|
|
264
|
-
async function persistOverlayStatus(projectRoot, overlayId, status, extraMeta = {}) {
|
|
265
|
+
export async function persistOverlayStatus(projectRoot, overlayId, status, extraMeta = {}, { expectedRevision } = {}) {
|
|
265
266
|
try {
|
|
266
267
|
const record = await findOverlayRecord(projectRoot, overlayId);
|
|
267
268
|
if (!record) return null;
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
269
|
+
const res = await mutateMemory(
|
|
270
|
+
{ kind: 'project', projectRoot },
|
|
271
|
+
{
|
|
272
|
+
op: 'update',
|
|
273
|
+
payload: {
|
|
274
|
+
id: record.id,
|
|
275
|
+
patch: {
|
|
276
|
+
meta: { ...record.meta, status, ...extraMeta },
|
|
277
|
+
// Approval is a trust transition — the overlay becomes
|
|
278
|
+
// authoritative (FR-018); other statuses keep the tier.
|
|
279
|
+
...(status === 'approved' ? { trust_tier: 'verified' } : {}),
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
expectedRevision: expectedRevision ?? record.revision ?? 1,
|
|
283
|
+
actor: 'learning',
|
|
284
|
+
},
|
|
285
|
+
);
|
|
286
|
+
if (res.status === 'ok') return res.record;
|
|
287
|
+
return null;
|
|
271
288
|
} catch {
|
|
272
289
|
return null;
|
|
273
290
|
}
|
|
@@ -357,22 +374,33 @@ export async function registerOverlay(projectRoot, overlay) {
|
|
|
357
374
|
};
|
|
358
375
|
|
|
359
376
|
const existing = await findOverlayRecord(projectRoot, overlay.id);
|
|
360
|
-
if (existing)
|
|
361
|
-
return updateRecord(projectRoot, existing.id, {
|
|
362
|
-
meta: { ...existing.meta, ...meta, registeredAt: existing.meta?.registeredAt ?? meta.registeredAt },
|
|
363
|
-
});
|
|
364
|
-
}
|
|
377
|
+
if (existing) return existing;
|
|
365
378
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
379
|
+
try {
|
|
380
|
+
const res = await mutateMemory(
|
|
381
|
+
{ kind: 'project', projectRoot },
|
|
382
|
+
{
|
|
383
|
+
op: 'add',
|
|
384
|
+
payload: {
|
|
385
|
+
type: 'derived_fact',
|
|
386
|
+
scope: OVERLAY_TO_RECORD_SCOPE[overlay.scope] ?? 'session',
|
|
387
|
+
text: `Delta overlay ${overlay.id} on ${overlay.baseContract}@${overlay.baseVersion} (${overlay.operations.length} op${overlay.operations.length === 1 ? '' : 's'})`,
|
|
388
|
+
provenance: OVERLAY_PROVENANCE,
|
|
389
|
+
confidence: 0.8,
|
|
390
|
+
createdBy: 'delta-overlay',
|
|
391
|
+
projectId: overlay.projectId ?? null,
|
|
392
|
+
trustTier: meta.status === 'approved' ? 'verified' : 'candidate',
|
|
393
|
+
meta,
|
|
394
|
+
},
|
|
395
|
+
idempotencyKey: `ov:${overlay.id}`,
|
|
396
|
+
actor: 'learning',
|
|
397
|
+
},
|
|
398
|
+
);
|
|
399
|
+
if (res.status === 'ok' || res.status === 'duplicate') return res.record ?? null;
|
|
400
|
+
return null;
|
|
401
|
+
} catch {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
376
404
|
}
|
|
377
405
|
|
|
378
406
|
/**
|
|
@@ -382,16 +410,33 @@ export async function registerOverlay(projectRoot, overlay) {
|
|
|
382
410
|
*/
|
|
383
411
|
export async function rollbackOverlay(projectRoot, overlayId) {
|
|
384
412
|
if (!isNonEmptyString(overlayId)) return null;
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
413
|
+
try {
|
|
414
|
+
const record = await findOverlayRecord(projectRoot, overlayId);
|
|
415
|
+
if (!record) return null;
|
|
416
|
+
const res = await mutateMemory(
|
|
417
|
+
{ kind: 'project', projectRoot },
|
|
418
|
+
{
|
|
419
|
+
op: 'update',
|
|
420
|
+
payload: {
|
|
421
|
+
id: record.id,
|
|
422
|
+
patch: {
|
|
423
|
+
meta: {
|
|
424
|
+
...record.meta,
|
|
425
|
+
status: 'expired',
|
|
426
|
+
enabled: false,
|
|
427
|
+
rolledBackAt: Date.now(),
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
expectedRevision: record.revision ?? 1,
|
|
432
|
+
actor: 'learning',
|
|
433
|
+
},
|
|
434
|
+
);
|
|
435
|
+
if (res.status === 'ok') return res.record;
|
|
436
|
+
return null;
|
|
437
|
+
} catch {
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
395
440
|
}
|
|
396
441
|
|
|
397
442
|
/**
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
|
|
23
23
|
import crypto from 'node:crypto';
|
|
24
24
|
import { loadRuntimeConfig, resolveConfigStage } from '../runtimeConfig.js';
|
|
25
|
-
import {
|
|
25
|
+
import { queryRecords } from './storeV2.js';
|
|
26
|
+
import { mutateMemory } from './mutateMemory.js';
|
|
26
27
|
|
|
27
28
|
export const OBSERVATION_PROVENANCE = 'learning-observation';
|
|
28
29
|
export const CANDIDATE_PROVENANCE = 'learning-candidate';
|
|
@@ -175,27 +176,52 @@ export async function observeLearningSignal(projectRoot, signal = {}) {
|
|
|
175
176
|
const text = boundText(signal.text, MAX_SIGNAL_TEXT);
|
|
176
177
|
const target = boundText(signal.target);
|
|
177
178
|
const signature = `${signal.kind}:${normalizeText(target ?? text)}`.slice(0, MAX_META_FIELD);
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
179
|
+
// Idempotency is per (signature, session): the same signal re-observed in
|
|
180
|
+
// the same session is one record, while distinct sessions still count as
|
|
181
|
+
// separate occurrences for the minSessions promotion gate.
|
|
182
|
+
const signalHash = crypto.createHash('sha1')
|
|
183
|
+
.update(`${signature}|${boundText(signal.sessionId) ?? ''}`)
|
|
184
|
+
.digest('hex');
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const res = await mutateMemory(
|
|
188
|
+
{ kind: 'project', projectRoot },
|
|
189
|
+
{
|
|
190
|
+
op: 'add',
|
|
191
|
+
payload: {
|
|
192
|
+
type: 'derived_fact',
|
|
193
|
+
scope: CANDIDATE_TO_RECORD_SCOPE[signalScopeHint(signal)],
|
|
194
|
+
text: `learning-signal ${signal.kind}: ${String(text).trim()}`,
|
|
195
|
+
provenance: OBSERVATION_PROVENANCE,
|
|
196
|
+
confidence: 0.3,
|
|
197
|
+
createdBy: 'learning-observe',
|
|
198
|
+
projectId: boundText(signal.projectId),
|
|
199
|
+
trustTier: 'observation',
|
|
200
|
+
meta: {
|
|
201
|
+
kind: signal.kind,
|
|
202
|
+
signature,
|
|
203
|
+
sessionId: boundText(signal.sessionId),
|
|
204
|
+
scopeHint: signalScopeHint(signal),
|
|
205
|
+
target,
|
|
206
|
+
condition: boundText(signal.condition),
|
|
207
|
+
proposedDelta: boundDelta(signal.proposedDelta),
|
|
208
|
+
evidenceRef: boundText(signal.evidenceRef),
|
|
209
|
+
observedAt: Date.now(),
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
idempotencyKey: `obs:${signalHash}`,
|
|
213
|
+
actor: 'learning',
|
|
214
|
+
},
|
|
215
|
+
// Recurring signals share meta.signature with different text — generic
|
|
216
|
+
// classification would REVIEW-archive them as 'contradiction'. Dedupe
|
|
217
|
+
// here is the idempotency journal, not text classification.
|
|
218
|
+
{ classifyAdd: () => ({ action: 'ADD' }) },
|
|
219
|
+
);
|
|
220
|
+
if (res.status === 'ok' || res.status === 'duplicate') return res.record ?? null;
|
|
221
|
+
return null;
|
|
222
|
+
} catch {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
199
225
|
}
|
|
200
226
|
|
|
201
227
|
function candidateText(kind, signalText, occurrences, sessions) {
|
|
@@ -270,32 +296,51 @@ export async function maybeCreateCandidate(projectRoot) {
|
|
|
270
296
|
const conflicts = detectCandidateConflicts(draft, records);
|
|
271
297
|
|
|
272
298
|
const candidateId = `lc_${crypto.randomBytes(6).toString('hex')}`;
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
+
try {
|
|
300
|
+
const res = await mutateMemory(
|
|
301
|
+
{ kind: 'project', projectRoot },
|
|
302
|
+
{
|
|
303
|
+
op: 'add',
|
|
304
|
+
payload: {
|
|
305
|
+
type: 'derived_fact',
|
|
306
|
+
scope: CANDIDATE_TO_RECORD_SCOPE[scope],
|
|
307
|
+
text: candidateText(first.meta?.kind, first.text, group.length, sessions.size),
|
|
308
|
+
provenance: CANDIDATE_PROVENANCE,
|
|
309
|
+
confidence: Math.min(0.9, 0.4 + group.length * 0.1),
|
|
310
|
+
createdBy: 'learning-candidate',
|
|
311
|
+
projectId,
|
|
312
|
+
trustTier: 'candidate',
|
|
313
|
+
meta: {
|
|
314
|
+
id: candidateId,
|
|
315
|
+
candidateId,
|
|
316
|
+
schemaVersion: 1,
|
|
317
|
+
status: 'proposed',
|
|
318
|
+
signature,
|
|
319
|
+
kind: first.meta?.kind ?? null,
|
|
320
|
+
scope,
|
|
321
|
+
proposalType: PROPOSAL_TYPE_BY_KIND[first.meta?.kind] ?? 'knowledge',
|
|
322
|
+
condition: first.meta?.condition ?? `when ${first.meta?.kind ?? 'signal'} recurs`,
|
|
323
|
+
proposedDelta: draft.proposedDelta,
|
|
324
|
+
target: draft.target,
|
|
325
|
+
evidenceRefs,
|
|
326
|
+
occurrences: group.length,
|
|
327
|
+
sessions: sessions.size,
|
|
328
|
+
conflicts,
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
idempotencyKey: `cand:${projectId ?? ''}|${signature}`,
|
|
332
|
+
actor: 'learning',
|
|
333
|
+
},
|
|
334
|
+
// Candidate text differs from its source observations under the same
|
|
335
|
+
// meta.signature — generic classification would REVIEW-archive it as
|
|
336
|
+
// 'contradiction'. Dedupe is the signature scan above + journal.
|
|
337
|
+
{ classifyAdd: () => ({ action: 'ADD' }) },
|
|
338
|
+
);
|
|
339
|
+
if (res.status === 'ok' || res.status === 'duplicate') return res.record ?? null;
|
|
340
|
+
return null;
|
|
341
|
+
} catch {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
299
344
|
}
|
|
300
345
|
|
|
301
346
|
return firstExisting;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// memoryFlags — unified memoryV2 rollout control (SPEC §5 FR-002/FR-004).
|
|
2
|
+
//
|
|
3
|
+
// Four planes (eligibility/writer/index/decision) share the generic
|
|
4
|
+
// resolveConfigStage machinery: off → shadow → canary → default. Flags only
|
|
5
|
+
// ever REDUCE capability — malformed/missing resolves 'off', killSwitch is
|
|
6
|
+
// absolute, and 'canary' without an opted-in projectId resolves 'off'.
|
|
7
|
+
//
|
|
8
|
+
// Shadow receipts are the only telemetry: bounded JSONL of counts/codes/
|
|
9
|
+
// latency bands — never record text, prompt bodies, or secrets.
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs/promises';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { resolveConfigStage } from '../runtimeConfig.js';
|
|
15
|
+
|
|
16
|
+
export const MEMORY_PLANES = Object.freeze(['eligibility', 'writer', 'index', 'decision']);
|
|
17
|
+
|
|
18
|
+
const RECEIPT_MAX_BYTES = 64 * 1024;
|
|
19
|
+
const RECEIPT_KEEP_LINES = 200;
|
|
20
|
+
const RECEIPT_FIELDS = ['plane', 'stage', 'outcome', 'code', 'latencyBand'];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* resolveMemoryStage(config, plane, { projectId } = {})
|
|
24
|
+
* → 'off'|'shadow'|'canary'|'default'
|
|
25
|
+
* Pure, no I/O. Resolution order: killSwitch → 'off'; malformed/missing →
|
|
26
|
+
* 'off'; 'canary' + projectId not in canaryProjects → 'off'.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveMemoryStage(config, plane, { projectId } = {}) {
|
|
29
|
+
const memoryV2 = config?.memoryV2;
|
|
30
|
+
if (memoryV2 === null || typeof memoryV2 !== 'object' || Array.isArray(memoryV2)) {
|
|
31
|
+
return 'off';
|
|
32
|
+
}
|
|
33
|
+
if (memoryV2.killSwitch === true) {
|
|
34
|
+
return 'off';
|
|
35
|
+
}
|
|
36
|
+
const stage = resolveConfigStage(config, `memoryV2.${plane}.stage`);
|
|
37
|
+
if (stage === 'canary') {
|
|
38
|
+
const list = Array.isArray(memoryV2.canaryProjects) ? memoryV2.canaryProjects : [];
|
|
39
|
+
return projectId != null && list.includes(projectId) ? 'canary' : 'off';
|
|
40
|
+
}
|
|
41
|
+
return stage;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 'canary'|'default' → the new path is live for this project. */
|
|
45
|
+
export function isMemoryPlaneLive(stage) {
|
|
46
|
+
return stage === 'canary' || stage === 'default';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Latency band for receipt telemetry — bands only, never raw timings. */
|
|
50
|
+
export function latencyBandForMs(ms) {
|
|
51
|
+
if (!Number.isFinite(ms) || ms < 0) return 'over';
|
|
52
|
+
if (ms < 50) return 'p50';
|
|
53
|
+
if (ms < 200) return 'p95';
|
|
54
|
+
if (ms < 1000) return 'p99';
|
|
55
|
+
return 'over';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* appendRolloutReceipt(root, receipt) → Promise<void>
|
|
60
|
+
* Bounded JSONL at .ukit/storage/memory/rollout-receipts.jsonl; rotates to
|
|
61
|
+
* the last 200 lines once the file exceeds 64KB. Only the whitelisted fields
|
|
62
|
+
* are written — content fields are stripped, never persisted. All failures
|
|
63
|
+
* are swallowed: telemetry must never break the operation it measures.
|
|
64
|
+
*/
|
|
65
|
+
export async function appendRolloutReceipt(root, receipt = {}) {
|
|
66
|
+
try {
|
|
67
|
+
const line = { ts: Date.now() };
|
|
68
|
+
for (const field of RECEIPT_FIELDS) {
|
|
69
|
+
if (receipt[field] !== undefined) line[field] = receipt[field];
|
|
70
|
+
}
|
|
71
|
+
const dir = path.join(root, '.ukit', 'storage', 'memory');
|
|
72
|
+
const file = path.join(dir, 'rollout-receipts.jsonl');
|
|
73
|
+
await fs.mkdir(dir, { recursive: true });
|
|
74
|
+
await fs.appendFile(file, `${JSON.stringify(line)}\n`, 'utf8');
|
|
75
|
+
const stat = await fs.stat(file);
|
|
76
|
+
if (stat.size > RECEIPT_MAX_BYTES) {
|
|
77
|
+
const lines = (await fs.readFile(file, 'utf8')).split('\n').filter(Boolean);
|
|
78
|
+
await fs.writeFile(file, `${lines.slice(-RECEIPT_KEEP_LINES).join('\n')}\n`, 'utf8');
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// EACCES / ENOSPC / vanished dir — swallowed by contract.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// memoryFreshness.js — bounded freshness resolver for v2 memory records.
|
|
2
|
+
//
|
|
3
|
+
// Maps a record's evidence to { state: 'fresh'|'stale'|'unknown', detail }
|
|
4
|
+
// using file fingerprints and caller-supplied watermarks. `unknown` is a
|
|
5
|
+
// label, never an upgrade. I/O is capped at MAX_EVIDENCE (8) stat calls per
|
|
6
|
+
// record — evidence locators only, no directory walks, no git spawns.
|
|
7
|
+
//
|
|
8
|
+
// Contract (SPEC §5 FR-003/FR-004, §8):
|
|
9
|
+
// resolveRecordFreshness(record, { projectRoot, watermarks?, now? })
|
|
10
|
+
// -> Promise<{ state, detail }> // never rejects
|
|
11
|
+
// fileFingerprint(absPath)
|
|
12
|
+
// -> Promise<'fs:<mtimeMs>:<size>'|null>
|
|
13
|
+
|
|
14
|
+
import crypto from 'node:crypto';
|
|
15
|
+
import fsp from 'node:fs/promises';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
import { MAX_EVIDENCE } from './records.js';
|
|
19
|
+
|
|
20
|
+
const FRESH = 'fresh';
|
|
21
|
+
const STALE = 'stale';
|
|
22
|
+
const UNKNOWN = 'unknown';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* fileFingerprint(absPath) → 'fs:<mtimeMs>:<size>' or null on any fs error.
|
|
26
|
+
*/
|
|
27
|
+
export async function fileFingerprint(absPath) {
|
|
28
|
+
try {
|
|
29
|
+
const st = await fsp.stat(absPath);
|
|
30
|
+
if (!st.isFile()) return null;
|
|
31
|
+
return `fs:${st.mtimeMs}:${st.size}`;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function sha1Fingerprint(absPath) {
|
|
38
|
+
try {
|
|
39
|
+
const buf = await fsp.readFile(absPath);
|
|
40
|
+
return `sha1:${crypto.createHash('sha1').update(buf).digest('hex')}`;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Resolve a file-evidence locator against projectRoot. Returns the absolute
|
|
47
|
+
// path when it stays inside projectRoot, else null (unverifiable — never
|
|
48
|
+
// followed). Absolute locators are used as-is but must still land inside
|
|
49
|
+
// projectRoot.
|
|
50
|
+
function resolveLocator(projectRoot, locator) {
|
|
51
|
+
if (typeof locator !== 'string' || locator.length === 0) return null;
|
|
52
|
+
const root = path.resolve(projectRoot);
|
|
53
|
+
const abs = path.resolve(root, locator);
|
|
54
|
+
if (abs !== root && !abs.startsWith(root + path.sep)) return null;
|
|
55
|
+
return abs;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function checkFileEvidence(projectRoot, entry) {
|
|
59
|
+
const abs = resolveLocator(projectRoot, entry.locator);
|
|
60
|
+
if (abs == null) return { state: UNKNOWN, detail: 'locator-unverifiable' };
|
|
61
|
+
|
|
62
|
+
// Symlink defense (SPEC §14): a locator that resolves inside projectRoot
|
|
63
|
+
// but lands on a symlink pointing outside must never be followed — the
|
|
64
|
+
// realpath containment check makes escapes unverifiable → 'unknown'.
|
|
65
|
+
try {
|
|
66
|
+
const lst = await fsp.lstat(abs);
|
|
67
|
+
if (lst.isSymbolicLink()) {
|
|
68
|
+
const root = path.resolve(projectRoot);
|
|
69
|
+
const real = await fsp.realpath(abs).catch(() => null);
|
|
70
|
+
if (real == null || (real !== root && !real.startsWith(root + path.sep))) {
|
|
71
|
+
return { state: UNKNOWN, detail: 'locator-unverifiable' };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// lstat failure → fall through to the normal stat path, which maps
|
|
76
|
+
// ENOENT to 'file-missing' and other errors to 'file-unverified'.
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const fp = entry.fingerprint;
|
|
80
|
+
if (typeof fp === 'string' && fp.startsWith('sha1:')) {
|
|
81
|
+
const actual = await sha1Fingerprint(abs);
|
|
82
|
+
if (actual == null) {
|
|
83
|
+
// Distinguish missing file from unreadable file.
|
|
84
|
+
const probe = await fileFingerprint(abs);
|
|
85
|
+
if (probe == null) {
|
|
86
|
+
try {
|
|
87
|
+
await fsp.stat(abs);
|
|
88
|
+
return { state: UNKNOWN, detail: 'file-unverified' }; // exists, unreadable
|
|
89
|
+
} catch {
|
|
90
|
+
return { state: STALE, detail: 'file-missing' };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { state: UNKNOWN, detail: 'file-unverified' };
|
|
94
|
+
}
|
|
95
|
+
return actual === fp
|
|
96
|
+
? { state: FRESH, detail: 'file-match' }
|
|
97
|
+
: { state: STALE, detail: 'file-changed' };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let st;
|
|
101
|
+
try {
|
|
102
|
+
st = await fsp.stat(abs);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if (err && err.code === 'ENOENT') return { state: STALE, detail: 'file-missing' };
|
|
105
|
+
return { state: UNKNOWN, detail: 'file-unverified' };
|
|
106
|
+
}
|
|
107
|
+
if (!st.isFile()) return { state: STALE, detail: 'file-missing' };
|
|
108
|
+
|
|
109
|
+
if (typeof fp !== 'string' || fp.length === 0) {
|
|
110
|
+
return { state: UNKNOWN, detail: 'file-unverified' };
|
|
111
|
+
}
|
|
112
|
+
if (fp.startsWith('fs:')) {
|
|
113
|
+
return `fs:${st.mtimeMs}:${st.size}` === fp
|
|
114
|
+
? { state: FRESH, detail: 'file-match' }
|
|
115
|
+
: { state: STALE, detail: 'file-changed' };
|
|
116
|
+
}
|
|
117
|
+
// Unrecognized fingerprint scheme — cannot verify.
|
|
118
|
+
return { state: UNKNOWN, detail: 'file-unverified' };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Non-file evidence (command|ledger|manual|import): compare the caller-
|
|
122
|
+
// supplied watermarks[kind] against the observed watermark. String
|
|
123
|
+
// watermarks (e.g. command → head sha) compare against entry.locator;
|
|
124
|
+
// numeric watermarks (e.g. ledger → generation/epoch) compare against
|
|
125
|
+
// entry.observed_at, falling back to record.revision.
|
|
126
|
+
function checkWatermarkEvidence(entry, record, watermarks) {
|
|
127
|
+
const wm = watermarks != null ? watermarks[entry.kind] : undefined;
|
|
128
|
+
if (wm == null) return { state: UNKNOWN, detail: 'watermark-missing' };
|
|
129
|
+
if (typeof wm === 'string') {
|
|
130
|
+
return wm === entry.locator
|
|
131
|
+
? { state: FRESH, detail: 'watermark-match' }
|
|
132
|
+
: { state: STALE, detail: 'watermark-stale' };
|
|
133
|
+
}
|
|
134
|
+
if (typeof wm === 'number' && Number.isFinite(wm)) {
|
|
135
|
+
const observed = Number.isFinite(entry.observed_at)
|
|
136
|
+
? entry.observed_at
|
|
137
|
+
: record.revision;
|
|
138
|
+
return observed === wm
|
|
139
|
+
? { state: FRESH, detail: 'watermark-match' }
|
|
140
|
+
: { state: STALE, detail: 'watermark-stale' };
|
|
141
|
+
}
|
|
142
|
+
return { state: UNKNOWN, detail: 'watermark-missing' };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* resolveRecordFreshness(record, { projectRoot, watermarks = {}, now } = {})
|
|
147
|
+
* → Promise<{ state: 'fresh'|'stale'|'unknown', detail: string }>
|
|
148
|
+
*
|
|
149
|
+
* Aggregate precedence: any stale → stale; else any unknown → unknown;
|
|
150
|
+
* else fresh. Never rejects — every per-entry error degrades to `unknown`.
|
|
151
|
+
*/
|
|
152
|
+
export async function resolveRecordFreshness(record, { projectRoot, watermarks = {}, now } = {}) {
|
|
153
|
+
void now;
|
|
154
|
+
try {
|
|
155
|
+
const evidence = Array.isArray(record?.evidence) ? record.evidence : [];
|
|
156
|
+
if (evidence.length === 0) return { state: UNKNOWN, detail: 'no-evidence' };
|
|
157
|
+
|
|
158
|
+
let sawStale = false;
|
|
159
|
+
let sawUnknown = false;
|
|
160
|
+
let firstDetail = 'no-evidence';
|
|
161
|
+
|
|
162
|
+
for (const entry of evidence.slice(0, MAX_EVIDENCE)) {
|
|
163
|
+
let verdict;
|
|
164
|
+
try {
|
|
165
|
+
if (entry != null && entry.kind === 'file') {
|
|
166
|
+
verdict = await checkFileEvidence(projectRoot, entry);
|
|
167
|
+
} else {
|
|
168
|
+
verdict = checkWatermarkEvidence(entry ?? {}, record ?? {}, watermarks);
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
verdict = { state: UNKNOWN, detail: 'file-unverified' };
|
|
172
|
+
}
|
|
173
|
+
if (verdict.state === STALE) {
|
|
174
|
+
if (!sawStale) firstDetail = verdict.detail;
|
|
175
|
+
sawStale = true;
|
|
176
|
+
} else if (verdict.state === UNKNOWN) {
|
|
177
|
+
if (!sawStale && !sawUnknown) firstDetail = verdict.detail;
|
|
178
|
+
sawUnknown = true;
|
|
179
|
+
} else if (!sawStale && !sawUnknown) {
|
|
180
|
+
firstDetail = verdict.detail;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (sawStale) return { state: STALE, detail: firstDetail };
|
|
185
|
+
if (sawUnknown) return { state: UNKNOWN, detail: firstDetail };
|
|
186
|
+
return { state: FRESH, detail: firstDetail };
|
|
187
|
+
} catch {
|
|
188
|
+
return { state: UNKNOWN, detail: 'resolver-error' };
|
|
189
|
+
}
|
|
190
|
+
}
|