@ngockhoale/ukit 3.0.11 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -1
- package/README.md +1 -0
- package/manifests/documentation.yaml +12 -0
- package/package.json +1 -1
- package/scripts/bench/data-foundation.mjs +368 -50
- package/src/cli/commands/doctor.js +232 -3
- package/src/cli/commands/feedback.js +64 -1
- package/src/cli/commands/install.js +18 -0
- package/src/cli/commands/memory.js +42 -37
- package/src/cli/commands/telemetry.js +460 -0
- package/src/cli/index.js +7 -0
- package/src/core/agentRuntime/adapters.js +83 -2
- package/src/core/agentRuntime/diagnostics.js +104 -0
- package/src/core/agentRuntime/supervisor.js +137 -0
- package/src/core/agentRuntime/telemetry.js +204 -0
- package/src/core/memory/memoryEmit.js +131 -0
- package/src/core/memory/memoryHit.js +1 -1
- package/src/core/memory/migrate.js +18 -11
- package/src/core/memory/migrateMapping.js +15 -7
- package/src/core/memory/mutateMemory.js +22 -4
- package/src/core/memory/recordIndex.js +10 -3
- package/src/core/memory/recordStore.js +28 -3
- package/src/core/memory/retrieval.js +79 -38
- package/src/core/memory/store.js +37 -37
- package/src/core/memory/storeV2.js +28 -25
- package/src/core/memory/storeV2Loader.js +2 -2
- package/src/core/observability/adapters/ingest.js +576 -0
- package/src/core/observability/analytics/anomalies.js +415 -0
- package/src/core/observability/analytics/summary.js +16 -1
- package/src/core/observability/emit/config.js +69 -1
- package/src/core/observability/emit/crash.js +434 -0
- package/src/core/observability/emit/lifecycle.js +349 -0
- package/src/core/observability/emit/recorder.js +135 -9
- package/src/core/observability/evaluation/aiPacket.js +52 -10
- package/src/core/observability/evaluation/outcomes.js +95 -0
- package/src/core/observability/evaluation/runner.js +225 -0
- package/src/core/observability/privacy/allowlist.js +23 -3
- package/src/core/observability/schema/compatibility.js +48 -3
- package/src/core/observability/schema/constants.js +5 -0
- package/src/core/observability/schema/registry.js +57 -0
- package/src/core/observability/schema/validate.js +68 -6
- package/src/core/observability/segments/internal.js +42 -8
- package/src/core/observability/segments/readSegments.js +35 -1
- package/src/core/observability/segments/recovery.js +3 -2
- package/src/core/observability/segments/retention.js +137 -33
- package/src/core/observability/support/projector.js +88 -18
- package/src/core/observability/support/provision.js +160 -0
- package/src/core/observability/support/renderer.js +2 -2
- package/src/core/observability/support/schedule.js +174 -0
- package/template_project/.claude/hooks/auto-allow-bash.sh +7 -1
- package/template_project/.claude/hooks/auto-prune-bash.sh +16 -7
- package/template_project/.claude/hooks/verification-guard.sh +13 -4
- package/template_project/.claude/ukit/runtime/async-lock.mjs +26 -0
|
@@ -14,6 +14,13 @@
|
|
|
14
14
|
* Read-only guarantee: this module performs no writes, spawns, signals,
|
|
15
15
|
* or enqueues. Journal + contract are the only inputs. It performs no
|
|
16
16
|
* config reads (same convention as eventStore.js).
|
|
17
|
+
*
|
|
18
|
+
* DF2-FR07 (TASK-008): this module is ALSO the diagnostics-side emit
|
|
19
|
+
* surface — `emitModelAttempt` and `emitVerificationCompleted` turn a
|
|
20
|
+
* finished model attempt / verification gate outcome into canonical
|
|
21
|
+
* recorder records parented onto the caller's run span ctx. Emit helpers
|
|
22
|
+
* are synchronous, never throw, and never write to journals — the
|
|
23
|
+
* read-only contract above still holds for the two readers.
|
|
17
24
|
*/
|
|
18
25
|
|
|
19
26
|
import { promises as fs } from 'node:fs';
|
|
@@ -26,6 +33,7 @@ import {
|
|
|
26
33
|
validateTransition,
|
|
27
34
|
} from './contract.js';
|
|
28
35
|
import { readJournal, EventStoreError } from './eventStore.js';
|
|
36
|
+
import { makeSpanContext, emitSpanRecord, typedResource, registryCode } from './telemetry.js';
|
|
29
37
|
|
|
30
38
|
export class DiagnosticsError extends Error {
|
|
31
39
|
constructor(code, message) {
|
|
@@ -242,3 +250,99 @@ export async function replayOperation(dir, operationId, _opts = {}) {
|
|
|
242
250
|
gaps: Object.freeze(gaps),
|
|
243
251
|
});
|
|
244
252
|
}
|
|
253
|
+
|
|
254
|
+
// --- DF2-FR07: model.* / verification.completed emit helpers ---------------
|
|
255
|
+
// The emit surface lives here (not on the read path): callers that own a
|
|
256
|
+
// model attempt or a verification gate call these with the run's span ctx
|
|
257
|
+
// (from supervisor.start().telemetry or a parent's ctx) so every record
|
|
258
|
+
// lands on the run's trace_id with a resolvable parent_span_id.
|
|
259
|
+
// DF-FR08 resource honesty is enforced by typedResource — absent usage
|
|
260
|
+
// is {value:null, source:'UNKNOWN'} + telemetry_complete:false, never 0.
|
|
261
|
+
|
|
262
|
+
function isPlainObject(value) {
|
|
263
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function safeDurationMs(value) {
|
|
267
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
|
268
|
+
? Math.max(value, 1e-6)
|
|
269
|
+
: null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Emit a `model.started` + `model.completed`/`model.failed` span pair for
|
|
274
|
+
* one model attempt. `ctx` is the parent (run) span ctx — the attempt
|
|
275
|
+
* span is a child of it. Returns the child ctx; null when ctx missing.
|
|
276
|
+
*
|
|
277
|
+
* @param {object} recorder live recorder (emit only — never flushed here)
|
|
278
|
+
* @param {object} args
|
|
279
|
+
* @param {object} args.ctx parent span ctx {trace_id, span_id, execution_id, agent_id?, nowNs?}
|
|
280
|
+
* @param {boolean} [args.ok=true] whether the attempt succeeded
|
|
281
|
+
* @param {number} [args.durationMs] caller-measured attempt duration (ms)
|
|
282
|
+
* @param {number} [args.attemptIndex] 1-based attempt index (retries >1)
|
|
283
|
+
* @param {{value:number|null, source:string}} [args.resource] DF-FR08 usage
|
|
284
|
+
* @param {string} [args.errorCode] registry reason code for failures
|
|
285
|
+
*/
|
|
286
|
+
export function emitModelAttempt(recorder, {
|
|
287
|
+
ctx,
|
|
288
|
+
ok = true,
|
|
289
|
+
durationMs: duration,
|
|
290
|
+
attemptIndex,
|
|
291
|
+
resource,
|
|
292
|
+
errorCode,
|
|
293
|
+
} = {}) {
|
|
294
|
+
if (recorder == null || typeof recorder.emit !== 'function' || !isPlainObject(ctx)) {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
const telemetry = { recorder };
|
|
298
|
+
const span = makeSpanContext(ctx);
|
|
299
|
+
const payload = { operation: 'model' };
|
|
300
|
+
if (Number.isInteger(attemptIndex)) payload.attempt_index = attemptIndex;
|
|
301
|
+
emitSpanRecord(telemetry, span, 'model.started', payload);
|
|
302
|
+
const end = { ...payload };
|
|
303
|
+
const measured = safeDurationMs(duration);
|
|
304
|
+
if (measured !== null) end.duration_ms = measured;
|
|
305
|
+
const typed = typedResource(resource);
|
|
306
|
+
end.resource = typed.resource;
|
|
307
|
+
end.telemetry_complete = typed.complete;
|
|
308
|
+
if (ok !== true) end.error_code = registryCode(errorCode);
|
|
309
|
+
emitSpanRecord(telemetry, span, ok === true ? 'model.completed' : 'model.failed', end);
|
|
310
|
+
return span;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Emit `verification.completed` for a verification gate that ran to a
|
|
315
|
+
* verdict — outcome is the gate's typed verdict string (never raw
|
|
316
|
+
* output), reason_code only from the frozen registry.
|
|
317
|
+
*
|
|
318
|
+
* @param {object} recorder
|
|
319
|
+
* @param {object} args
|
|
320
|
+
* @param {object} args.ctx parent span ctx
|
|
321
|
+
* @param {string} args.outcome verdict code ('passed'|'failed'|'indeterminate'|gate verdict)
|
|
322
|
+
* @param {number} [args.durationMs] gate duration in ms when measured
|
|
323
|
+
* @param {string} [args.reasonCode] registry code explaining the outcome
|
|
324
|
+
* @param {number} [args.attempt] verification attempt count
|
|
325
|
+
*/
|
|
326
|
+
export function emitVerificationCompleted(recorder, {
|
|
327
|
+
ctx,
|
|
328
|
+
outcome,
|
|
329
|
+
durationMs: duration,
|
|
330
|
+
reasonCode,
|
|
331
|
+
attempt,
|
|
332
|
+
} = {}) {
|
|
333
|
+
if (recorder == null || typeof recorder.emit !== 'function' || !isPlainObject(ctx)) {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
const telemetry = { recorder };
|
|
337
|
+
const span = makeSpanContext(ctx);
|
|
338
|
+
const payload = {
|
|
339
|
+
operation: 'verification',
|
|
340
|
+
outcome: typeof outcome === 'string' && outcome.length > 0 ? outcome : 'unknown',
|
|
341
|
+
};
|
|
342
|
+
const measured = safeDurationMs(duration);
|
|
343
|
+
if (measured !== null) payload.duration_ms = measured;
|
|
344
|
+
if (reasonCode != null) payload.reason_code = registryCode(reasonCode);
|
|
345
|
+
if (Number.isInteger(attempt)) payload.attempt = attempt;
|
|
346
|
+
emitSpanRecord(telemetry, span, 'verification.completed', payload);
|
|
347
|
+
return span;
|
|
348
|
+
}
|
|
@@ -34,6 +34,15 @@ import {
|
|
|
34
34
|
import { classifyObservation } from './liveness.js';
|
|
35
35
|
import { createArtifactWriter } from './artifacts.js';
|
|
36
36
|
import { reconcileOwnedOperations } from './recovery.js';
|
|
37
|
+
import { beginToolSpan, endToolSpan } from './adapters.js';
|
|
38
|
+
import {
|
|
39
|
+
resolveTelemetry,
|
|
40
|
+
newRunContext,
|
|
41
|
+
emitSpanRecord,
|
|
42
|
+
durationMs,
|
|
43
|
+
reasonCodeForFailure,
|
|
44
|
+
projectRootFromRuntimeDir,
|
|
45
|
+
} from './telemetry.js';
|
|
37
46
|
|
|
38
47
|
const SUPERVISOR_FLAG = 'decisionRuntime.supervisor.stage';
|
|
39
48
|
// G6 (DR-09): `decisionRuntime.promotion.stage` — absent/malformed → 'off';
|
|
@@ -108,6 +117,66 @@ export function createSupervisor(opts = {}) {
|
|
|
108
117
|
const resourcesFn = typeof opts.resourcesFn === 'function' ? opts.resourcesFn : () => ({});
|
|
109
118
|
// Bounded receipt log — shadow-stage evidence only, never fs.
|
|
110
119
|
const WAIT_RECEIPT_CAP = 128;
|
|
120
|
+
|
|
121
|
+
// DF2-FR07 telemetry lane — resolved once at creation; null = emit path
|
|
122
|
+
// never runs (zero per-event cost when no recorder exists). Stage gating
|
|
123
|
+
// stays inside recorder.emit per record (kill switch is live-read).
|
|
124
|
+
const telemetry = resolveTelemetry({
|
|
125
|
+
recorder: opts.telemetry?.recorder,
|
|
126
|
+
projectRoot: opts.telemetry?.projectRoot ?? projectRootFromRuntimeDir(runtimeDir),
|
|
127
|
+
config: opts.config ?? null,
|
|
128
|
+
env: opts.env,
|
|
129
|
+
clock: opts.telemetry?.clock,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
/** Open the run's execution root span (emits execution.started). */
|
|
133
|
+
function beginExecutionSpan(spec) {
|
|
134
|
+
const ctx = newRunContext(telemetry, { traceId: spec?.traceId });
|
|
135
|
+
emitSpanRecord(telemetry, ctx, 'execution.started', {
|
|
136
|
+
operation: spec?.operationId ?? 'run',
|
|
137
|
+
duration_ms: null,
|
|
138
|
+
});
|
|
139
|
+
return ctx;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Close the run root span: completed | failed | blocked. */
|
|
143
|
+
function endExecutionSpan(ctx, status, payload = {}) {
|
|
144
|
+
if (!ctx) return;
|
|
145
|
+
emitSpanRecord(telemetry, ctx, `execution.${status}`, {
|
|
146
|
+
operation: payload.operation ?? ctx.operation ?? 'run',
|
|
147
|
+
duration_ms: durationMs(ctx.start_ns, telemetry.nowNs()),
|
|
148
|
+
...payload,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Close tool + execution spans exactly once per op. `status` is the
|
|
154
|
+
* terminal outcome ('completed'|'failed'|'blocked'); `info` carries
|
|
155
|
+
* exit/cancel detail mapped onto the frozen reason vocabulary.
|
|
156
|
+
*/
|
|
157
|
+
function closeOpSpans(op, status, { errorCode, reasonCode, exitCode } = {}) {
|
|
158
|
+
if (!telemetry || !op.telemetryCtx || op.spansClosed) return;
|
|
159
|
+
op.spansClosed = true;
|
|
160
|
+
// A blocked execution never spawned a tool — its tool span either
|
|
161
|
+
// does not exist or failed before the gate; 'blocked' only describes
|
|
162
|
+
// the execution end record.
|
|
163
|
+
const toolFailed = status === 'failed';
|
|
164
|
+
if (op.toolCtx) {
|
|
165
|
+
endToolSpan(telemetry, op.toolCtx, {
|
|
166
|
+
status: toolFailed ? 'failed' : 'completed',
|
|
167
|
+
errorCode: toolFailed ? errorCode : undefined,
|
|
168
|
+
toolName: op.spec?.toolName ?? op.spec?.sideEffectClass,
|
|
169
|
+
operationId: op.id,
|
|
170
|
+
exitCode,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
endExecutionSpan(op.telemetryCtx, status, {
|
|
174
|
+
operation: op.id,
|
|
175
|
+
...(toolFailed && errorCode != null ? { error_code: errorCode } : {}),
|
|
176
|
+
...(reasonCode != null ? { reason_code: reasonCode } : {}),
|
|
177
|
+
...(Number.isInteger(exitCode) ? { exit_code: exitCode } : {}),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
111
180
|
const waitReceipts = [];
|
|
112
181
|
|
|
113
182
|
function recordWaitReceipt(entry) {
|
|
@@ -281,6 +350,10 @@ export function createSupervisor(opts = {}) {
|
|
|
281
350
|
fencingEpoch,
|
|
282
351
|
ownerEpoch,
|
|
283
352
|
});
|
|
353
|
+
closeOpSpans(op, 'failed', {
|
|
354
|
+
errorCode: reasonCodeForFailure({ cancelReason: op.cancelReason ?? 'cancel_requested' }),
|
|
355
|
+
exitCode: op.exitInfo?.code,
|
|
356
|
+
});
|
|
284
357
|
});
|
|
285
358
|
}
|
|
286
359
|
}, limits.killGraceMs));
|
|
@@ -389,6 +462,23 @@ export function createSupervisor(opts = {}) {
|
|
|
389
462
|
const event = makeEvent(op, 'operation.transition', { from, to, ...extra }, op.artifactRefs);
|
|
390
463
|
await append(runtimeDir, event);
|
|
391
464
|
await writeOperationState(runtimeDir, op.id, { state: to, fencingEpoch, ownerEpoch });
|
|
465
|
+
// DF2-FR07: terminal transition committed — close tool + execution
|
|
466
|
+
// spans with the same outcome. Cancelled is a failed run with a
|
|
467
|
+
// USER_CANCELLED/TOOL_TIMEOUT code, never a silent open span.
|
|
468
|
+
if (to === 'completed') {
|
|
469
|
+
closeOpSpans(op, 'completed', { exitCode: op.exitInfo?.code });
|
|
470
|
+
} else {
|
|
471
|
+
closeOpSpans(op, 'failed', {
|
|
472
|
+
errorCode: reasonCodeForFailure({
|
|
473
|
+
cancelReason: to === 'cancelled' ? (op.cancelReason ?? 'cancel_requested') : undefined,
|
|
474
|
+
exitCode: op.exitInfo?.code,
|
|
475
|
+
signal: op.exitInfo?.signal,
|
|
476
|
+
errno: artErr,
|
|
477
|
+
code: artErr != null ? undefined : (op.cancelRequested ? 'cancelled' : 'exit_nonzero'),
|
|
478
|
+
}),
|
|
479
|
+
exitCode: op.exitInfo?.code,
|
|
480
|
+
});
|
|
481
|
+
}
|
|
392
482
|
}
|
|
393
483
|
|
|
394
484
|
function onExit(op, code, signal) {
|
|
@@ -411,10 +501,23 @@ export function createSupervisor(opts = {}) {
|
|
|
411
501
|
* whole tree via `-pgid`.
|
|
412
502
|
*/
|
|
413
503
|
async start(spec, policy = {}) {
|
|
504
|
+
// DF2-FR07: the run's execution root span opens at supervisor entry —
|
|
505
|
+
// BEFORE the preflight gates so a refused launch still lands an
|
|
506
|
+
// execution.blocked record on a real trace (flight recorder keeps
|
|
507
|
+
// refusals, not just runs).
|
|
508
|
+
const ctx = telemetry ? beginExecutionSpan(spec) : null;
|
|
414
509
|
if (closed || launchesDisabled || !enabledByConfig()) {
|
|
510
|
+
endExecutionSpan(ctx, 'blocked', {
|
|
511
|
+
operation: spec?.operationId ?? 'run',
|
|
512
|
+
reason_code: 'GATE_BLOCKED',
|
|
513
|
+
});
|
|
415
514
|
return unsupported('launch_disabled');
|
|
416
515
|
}
|
|
417
516
|
if (!specIsValid(spec)) {
|
|
517
|
+
endExecutionSpan(ctx, 'blocked', {
|
|
518
|
+
operation: 'run',
|
|
519
|
+
reason_code: 'SCHEMA_MISMATCH',
|
|
520
|
+
});
|
|
418
521
|
return unsupported('invalid_spec');
|
|
419
522
|
}
|
|
420
523
|
// G6 promotion consult — shadow evaluates+records a receipt; served
|
|
@@ -447,6 +550,7 @@ export function createSupervisor(opts = {}) {
|
|
|
447
550
|
artifacts: null,
|
|
448
551
|
timers: new Set(),
|
|
449
552
|
};
|
|
553
|
+
op.telemetryCtx = ctx;
|
|
450
554
|
operations.set(op.id, op);
|
|
451
555
|
|
|
452
556
|
await transition(op, 'starting', {
|
|
@@ -455,6 +559,16 @@ export function createSupervisor(opts = {}) {
|
|
|
455
559
|
});
|
|
456
560
|
|
|
457
561
|
let proc;
|
|
562
|
+
// DF2-FR07: the owned child process IS this run's tool invocation —
|
|
563
|
+
// tool.started opens right before spawn so a spawn throw still
|
|
564
|
+
// closes it as tool.failed (never an orphaned open span).
|
|
565
|
+
if (telemetry) {
|
|
566
|
+
op.toolCtx = beginToolSpan(telemetry, ctx, {
|
|
567
|
+
toolName: spec.toolName ?? spec.sideEffectClass,
|
|
568
|
+
operationId: spec.operationId,
|
|
569
|
+
attempt: spec.attempt,
|
|
570
|
+
});
|
|
571
|
+
}
|
|
458
572
|
try {
|
|
459
573
|
const argv = Array.isArray(spec.argv) && spec.argv.length > 0
|
|
460
574
|
? spec.argv
|
|
@@ -469,6 +583,19 @@ export function createSupervisor(opts = {}) {
|
|
|
469
583
|
await transition(op, 'failed', {
|
|
470
584
|
error: { code: 'spawn_failed', message: String(err?.message ?? err) },
|
|
471
585
|
});
|
|
586
|
+
if (telemetry) {
|
|
587
|
+
const code = reasonCodeForFailure({ code: 'spawn_failed', errno: err?.code });
|
|
588
|
+
endToolSpan(telemetry, op.toolCtx, {
|
|
589
|
+
status: 'failed',
|
|
590
|
+
errorCode: code,
|
|
591
|
+
toolName: spec.toolName ?? spec.sideEffectClass,
|
|
592
|
+
operationId: spec.operationId,
|
|
593
|
+
});
|
|
594
|
+
endExecutionSpan(ctx, 'failed', {
|
|
595
|
+
operation: spec.operationId,
|
|
596
|
+
error_code: code,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
472
599
|
return { ok: false, code: 'spawn_failed' };
|
|
473
600
|
}
|
|
474
601
|
|
|
@@ -511,6 +638,16 @@ export function createSupervisor(opts = {}) {
|
|
|
511
638
|
get currentState() {
|
|
512
639
|
return op.state;
|
|
513
640
|
},
|
|
641
|
+
// DF2-FR07: the run's span ctx propagates to callers so tool/model/
|
|
642
|
+
// verification emits parent onto this span — the span-context
|
|
643
|
+
// convention the task's Interfaces contract specifies.
|
|
644
|
+
telemetry: ctx ? {
|
|
645
|
+
trace_id: ctx.trace_id,
|
|
646
|
+
span_id: ctx.span_id,
|
|
647
|
+
execution_id: ctx.execution_id,
|
|
648
|
+
...(ctx.agent_id ? { agent_id: ctx.agent_id } : {}),
|
|
649
|
+
nowNs: ctx.nowNs,
|
|
650
|
+
} : null,
|
|
514
651
|
};
|
|
515
652
|
},
|
|
516
653
|
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agentRuntime/telemetry.js (TASK-008, SPEC §5 DF2-FR07, DF-FR08) —
|
|
3
|
+
* span-context plumbing for live agentRuntime telemetry.
|
|
4
|
+
*
|
|
5
|
+
* resolveTelemetry({ recorder, config, env }) → ctx | null
|
|
6
|
+
* makeSpanContext(ctx, parentSpan) → child ctx
|
|
7
|
+
* emitSpanRecord(telemetry, ctx, semanticName, payload) → EmitResult
|
|
8
|
+
* durationMs(startNs, nowNs) → positive ms | null
|
|
9
|
+
* projectRootFromRuntimeDir(runtimeDir) → projectRoot | null
|
|
10
|
+
*
|
|
11
|
+
* Contracts:
|
|
12
|
+
* - Emission is fire-and-forget: every helper is synchronous, wraps
|
|
13
|
+
* recorder.emit in try/catch, and NEVER throws into task execution —
|
|
14
|
+
* a throwing recorder is a telemetry loss, not a runtime failure.
|
|
15
|
+
* - Span context is the run's wiring convention: {trace_id, span_id,
|
|
16
|
+
* parent_span_id, execution_id, agent_id?, nowNs}. A child ctx is
|
|
17
|
+
* made only from a real parent span — parent_span_id always resolves
|
|
18
|
+
* to a span emitted in the same trace (validator causality rule).
|
|
19
|
+
* - DF-FR08: model/tool end records carry typed resource provenance
|
|
20
|
+
* {value, source}; absent usage stays {value:null, source:'UNKNOWN'}
|
|
21
|
+
* with telemetry_complete:false — never 0, never fabricated.
|
|
22
|
+
* - agent_id (SPEC §14): UKIT_AGENT_ID env, else config.agent.id, else
|
|
23
|
+
* the field is ABSENT — never an empty string.
|
|
24
|
+
* - Stage 'off' is enforced inside recorder.emit per record; helpers
|
|
25
|
+
* never pre-judge the stage.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import crypto from 'node:crypto';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
|
|
31
|
+
import { getRecorder } from '../observability/emit/lifecycle.js';
|
|
32
|
+
import { RESOURCE_SOURCES } from '../observability/schema/constants.js';
|
|
33
|
+
import { REASON_CODES } from '../observability/schema/registry.js';
|
|
34
|
+
|
|
35
|
+
/** Monotonic nanoseconds — BigInt where available (process.hrtime). */
|
|
36
|
+
export function nowNs() {
|
|
37
|
+
return typeof process.hrtime?.bigint === 'function'
|
|
38
|
+
? process.hrtime.bigint()
|
|
39
|
+
: BigInt(Math.round(performance.now() * 1e6));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Measured span duration in ms. Sub-millisecond spans still report a
|
|
44
|
+
* positive duration — a closed span always took real time, so 0 would
|
|
45
|
+
* read as "unmeasured" downstream (summary.js treats duration>0 as real).
|
|
46
|
+
*/
|
|
47
|
+
export function durationMs(startNs, endNs) {
|
|
48
|
+
if (typeof startNs !== 'bigint' || typeof endNs !== 'bigint') return null;
|
|
49
|
+
const delta = Number(endNs - startNs) / 1e6;
|
|
50
|
+
if (!Number.isFinite(delta) || delta < 0) return null;
|
|
51
|
+
return Math.max(delta, 1e-6);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isNonEmptyString(value) {
|
|
55
|
+
return typeof value === 'string' && value.length > 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* agent_id source (SPEC §14): UKIT_AGENT_ID env first, config.agent.id
|
|
60
|
+
* second, absent otherwise. The field is omitted entirely — never ''.
|
|
61
|
+
*/
|
|
62
|
+
export function resolveAgentId({ config, env } = {}) {
|
|
63
|
+
const source = env && typeof env === 'object' ? env : process.env;
|
|
64
|
+
if (isNonEmptyString(source?.UKIT_AGENT_ID)) return source.UKIT_AGENT_ID;
|
|
65
|
+
const fromConfig = config?.agent?.id;
|
|
66
|
+
return isNonEmptyString(fromConfig) ? fromConfig : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `.ukit/storage/agent-runtime[/...]` → project root. Returns null when
|
|
71
|
+
* the layout does not match (tests, foreign dirs) — the caller then
|
|
72
|
+
* relies on an explicit recorder instead of guessing a root.
|
|
73
|
+
*/
|
|
74
|
+
export function projectRootFromRuntimeDir(runtimeDir) {
|
|
75
|
+
if (typeof runtimeDir !== 'string' || runtimeDir === '') return null;
|
|
76
|
+
const resolved = path.resolve(runtimeDir);
|
|
77
|
+
let dir = resolved;
|
|
78
|
+
for (let i = 0; i < 8; i += 1) {
|
|
79
|
+
if (path.basename(dir) === '.ukit') return path.dirname(dir);
|
|
80
|
+
const parent = path.dirname(dir);
|
|
81
|
+
if (parent === dir) break;
|
|
82
|
+
dir = parent;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build the telemetry lane for one supervisor/adapter site. Returns null
|
|
89
|
+
* when there is no recorder — the lane is then a guaranteed no-op with
|
|
90
|
+
* zero per-event cost (DF2-FR07 stage-off = zero records AND no emit
|
|
91
|
+
* path work). `recorder` wins; otherwise getRecorder memoizes per root.
|
|
92
|
+
*/
|
|
93
|
+
export function resolveTelemetry({ recorder, projectRoot, config, env, clock } = {}) {
|
|
94
|
+
const rec = recorder ?? (projectRoot ? getRecorder({ projectRoot, config }) : null);
|
|
95
|
+
if (rec == null || typeof rec.emit !== 'function' || rec.disabled === true) return null;
|
|
96
|
+
const agent_id = resolveAgentId({ config, env });
|
|
97
|
+
return {
|
|
98
|
+
recorder: rec,
|
|
99
|
+
agent_id,
|
|
100
|
+
nowNs: clock && typeof clock.nowNs === 'function' ? clock.nowNs.bind(clock) : nowNs,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Root span context for one run — the trace_id owner. */
|
|
105
|
+
export function newRunContext(telemetry, { traceId } = {}) {
|
|
106
|
+
const trace_id = isNonEmptyString(traceId)
|
|
107
|
+
? traceId
|
|
108
|
+
: `trace-${crypto.randomUUID()}`;
|
|
109
|
+
return {
|
|
110
|
+
trace_id,
|
|
111
|
+
span_id: `span-${crypto.randomUUID()}`,
|
|
112
|
+
parent_span_id: null,
|
|
113
|
+
execution_id: `exec-${crypto.randomUUID()}`,
|
|
114
|
+
...(telemetry.agent_id ? { agent_id: telemetry.agent_id } : {}),
|
|
115
|
+
nowNs: telemetry.nowNs,
|
|
116
|
+
start_ns: telemetry.nowNs(),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Child ctx under a real parent span (tool/model/verification). */
|
|
121
|
+
export function makeSpanContext(parent) {
|
|
122
|
+
return {
|
|
123
|
+
trace_id: parent.trace_id,
|
|
124
|
+
span_id: `span-${crypto.randomUUID()}`,
|
|
125
|
+
parent_span_id: parent.span_id,
|
|
126
|
+
execution_id: parent.execution_id,
|
|
127
|
+
...(parent.agent_id ? { agent_id: parent.agent_id } : {}),
|
|
128
|
+
nowNs: parent.nowNs,
|
|
129
|
+
start_ns: parent.nowNs ? parent.nowNs() : undefined,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Emit one span/event record. Envelope ids come from ctx verbatim; the
|
|
135
|
+
* recorder fills record_id/sequence/timestamps and the registry privacy
|
|
136
|
+
* floor. Never throws — a throwing recorder returns a typed drop.
|
|
137
|
+
*/
|
|
138
|
+
export function emitSpanRecord(telemetry, ctx, semanticName, payload) {
|
|
139
|
+
try {
|
|
140
|
+
return telemetry.recorder.emit({
|
|
141
|
+
semantic_name: semanticName,
|
|
142
|
+
trace_id: ctx.trace_id,
|
|
143
|
+
span_id: ctx.span_id,
|
|
144
|
+
parent_span_id: ctx.parent_span_id ?? null,
|
|
145
|
+
execution_id: ctx.execution_id,
|
|
146
|
+
...(ctx.agent_id ? { agent_id: ctx.agent_id } : {}),
|
|
147
|
+
payload: payload ?? {},
|
|
148
|
+
});
|
|
149
|
+
} catch {
|
|
150
|
+
return { status: 'dropped', reason: 'emit-threw' };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Map a supervisor failure/cancel code onto the frozen REASON_CODES
|
|
156
|
+
* vocabulary (task test 3: error_code is UPPER_SNAKE from the registry,
|
|
157
|
+
* never free text). Unclassifiable causes stay HOST_BLIND — the honest
|
|
158
|
+
* "we do not have a code" code — rather than a fabricated reason.
|
|
159
|
+
*/
|
|
160
|
+
export function reasonCodeForFailure({ exitCode, signal, code, errno, cancelReason } = {}) {
|
|
161
|
+
if (errno === 'EACCES' || errno === 'EPERM') return 'PERMISSION_DENIED';
|
|
162
|
+
if (errno === 'ENOSPC') return 'DISK_FULL';
|
|
163
|
+
if (code === 'spawn_failed') return 'HOST_BLIND';
|
|
164
|
+
if (code === 'launch_disabled') return 'GATE_BLOCKED';
|
|
165
|
+
if (code === 'invalid_spec') return 'SCHEMA_MISMATCH';
|
|
166
|
+
if (cancelReason === 'wall_timeout' || cancelReason === 'stalled' || code === 'wall_timeout') {
|
|
167
|
+
return 'TOOL_TIMEOUT';
|
|
168
|
+
}
|
|
169
|
+
if (cancelReason != null || signal === 'SIGTERM' || signal === 'SIGKILL' || code === 'cancelled') {
|
|
170
|
+
return 'USER_CANCELLED';
|
|
171
|
+
}
|
|
172
|
+
if (code === 'ENOSPC') return 'DISK_FULL';
|
|
173
|
+
if (code === 'EACCES' || code === 'EPERM') return 'PERMISSION_DENIED';
|
|
174
|
+
if (typeof exitCode === 'number' && exitCode !== 0) return 'HOST_BLIND';
|
|
175
|
+
return 'HOST_BLIND';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Only registry-known codes may reach payload.error_code/reason_code. */
|
|
179
|
+
export function registryCode(code) {
|
|
180
|
+
return isNonEmptyString(code) && REASON_CODES[code] ? code : 'HOST_BLIND';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* DF-FR08 typed resource provenance (routeAdapter parity):
|
|
185
|
+
* - caller-typed {value, source} with a real source → kept verbatim;
|
|
186
|
+
* - numeric value without a trustworthy source → ESTIMATED (a number
|
|
187
|
+
* exists, so UNKNOWN would be a lie; PROVIDER would claim an
|
|
188
|
+
* unobserved measurement);
|
|
189
|
+
* - no value → {value:null, source:'UNKNOWN'} — host-blind stays
|
|
190
|
+
* explicitly unknown, never 0.
|
|
191
|
+
* Returns { resource, complete } — complete=false marks the record as
|
|
192
|
+
* telemetry-blind for summarizeTrace.
|
|
193
|
+
*/
|
|
194
|
+
export function typedResource(resource) {
|
|
195
|
+
const value = resource && typeof resource.value === 'number' && Number.isFinite(resource.value)
|
|
196
|
+
? resource.value
|
|
197
|
+
: null;
|
|
198
|
+
const source = resource && RESOURCE_SOURCES.includes(resource.source)
|
|
199
|
+
? resource.source
|
|
200
|
+
: null;
|
|
201
|
+
if (value === null) return { resource: { value: null, source: 'UNKNOWN' }, complete: false };
|
|
202
|
+
if (source && source !== 'UNKNOWN') return { resource: { value, source }, complete: true };
|
|
203
|
+
return { resource: { value, source: 'ESTIMATED' }, complete: true };
|
|
204
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// memoryEmit.js (TASK-007, SPEC §5 DF2-FR08) — the single telemetry seam for
|
|
2
|
+
// memory/retrieval call sites. Every record here is metadata-only: counts,
|
|
3
|
+
// kind buckets, opaque sha256 refs, durations. Record text, query text,
|
|
4
|
+
// paths and ids never enter a payload — the allowlist is the backstop, this
|
|
5
|
+
// module is the discipline.
|
|
6
|
+
//
|
|
7
|
+
// Contract: O(1) hot path and never-throw. resolveStage(config) !== 'off'
|
|
8
|
+
// short-circuits BEFORE any hashing/payload work so an off stage costs one
|
|
9
|
+
// property walk; emit() itself already never throws and the wrapper swallows
|
|
10
|
+
// anyway (a telemetry fault can never break the write/read path).
|
|
11
|
+
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
|
|
14
|
+
import { getRecorder } from '../observability/emit/lifecycle.js';
|
|
15
|
+
import { resolveStage } from '../observability/emit/config.js';
|
|
16
|
+
|
|
17
|
+
const REFS_MAX = 32;
|
|
18
|
+
|
|
19
|
+
function sha256(text) {
|
|
20
|
+
return crypto.createHash('sha256').update(String(text)).digest('hex');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function kindBuckets(records) {
|
|
24
|
+
const kinds = {};
|
|
25
|
+
for (const record of records) {
|
|
26
|
+
const kind = typeof record?.type === 'string' ? record.type : 'unknown';
|
|
27
|
+
kinds[kind] = (kinds[kind] ?? 0) + 1;
|
|
28
|
+
}
|
|
29
|
+
return kinds;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function safeEmit(recorder, record) {
|
|
33
|
+
try {
|
|
34
|
+
recorder.emit(record);
|
|
35
|
+
} catch {
|
|
36
|
+
// Never-throw: a recorder fault must not break the memory path.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* emitMemoryWrite({projectRoot, config, op, subject, recordCount, tombstoneCount})
|
|
42
|
+
* One `memory.write` record per persisted doc write. `subject` is the
|
|
43
|
+
* record(s) this write materially changed (or a raw id string) — hashed into
|
|
44
|
+
* opaque refs; `recordCount`/`tombstoneCount` describe the persisted doc.
|
|
45
|
+
*/
|
|
46
|
+
export function emitMemoryWrite({
|
|
47
|
+
projectRoot,
|
|
48
|
+
config,
|
|
49
|
+
op,
|
|
50
|
+
subject,
|
|
51
|
+
recordCount,
|
|
52
|
+
tombstoneCount,
|
|
53
|
+
} = {}) {
|
|
54
|
+
if (resolveStage(config) === 'off') return;
|
|
55
|
+
const recorder = getRecorder({ projectRoot, config });
|
|
56
|
+
const list = (Array.isArray(subject) ? subject : subject == null ? [] : [subject])
|
|
57
|
+
.slice(0, REFS_MAX);
|
|
58
|
+
const refs = [];
|
|
59
|
+
for (const entry of list) {
|
|
60
|
+
const id = typeof entry === 'string' ? entry : entry?.id;
|
|
61
|
+
if (typeof id === 'string' && id.length > 0) refs.push(sha256(id));
|
|
62
|
+
}
|
|
63
|
+
safeEmit(recorder, {
|
|
64
|
+
semantic_name: 'memory.write',
|
|
65
|
+
payload: {
|
|
66
|
+
op: typeof op === 'string' ? op : 'write',
|
|
67
|
+
record_count: Number.isInteger(recordCount) ? recordCount : 0,
|
|
68
|
+
changed_count: refs.length,
|
|
69
|
+
kinds: kindBuckets(list.filter((e) => e != null && typeof e === 'object')),
|
|
70
|
+
record_refs: refs,
|
|
71
|
+
tombstone_count: Number.isInteger(tombstoneCount) ? tombstoneCount : 0,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* emitRetrievalQuery({projectRoot, config, lane, scope, indexed,
|
|
78
|
+
* candidateCount, returnedCount, durationMs, freshness})
|
|
79
|
+
* One `retrieval.query` record per search() call — lanes 'v2'|'legacy',
|
|
80
|
+
* never the query text.
|
|
81
|
+
*/
|
|
82
|
+
export function emitRetrievalQuery({
|
|
83
|
+
projectRoot,
|
|
84
|
+
config,
|
|
85
|
+
lane,
|
|
86
|
+
scope,
|
|
87
|
+
indexed,
|
|
88
|
+
candidateCount,
|
|
89
|
+
returnedCount,
|
|
90
|
+
durationMs,
|
|
91
|
+
freshness,
|
|
92
|
+
} = {}) {
|
|
93
|
+
if (resolveStage(config) === 'off') return;
|
|
94
|
+
const recorder = getRecorder({ projectRoot, config });
|
|
95
|
+
const buckets = freshness && typeof freshness === 'object' ? freshness : {};
|
|
96
|
+
safeEmit(recorder, {
|
|
97
|
+
semantic_name: 'retrieval.query',
|
|
98
|
+
payload: {
|
|
99
|
+
lane: lane === 'legacy' ? 'legacy' : 'v2',
|
|
100
|
+
scope: typeof scope === 'string' ? scope : 'all',
|
|
101
|
+
indexed: indexed === true,
|
|
102
|
+
candidate_count: Number.isInteger(candidateCount) ? candidateCount : 0,
|
|
103
|
+
returned_count: Number.isInteger(returnedCount) ? returnedCount : 0,
|
|
104
|
+
duration_ms: Number.isFinite(durationMs) && durationMs >= 0 ? durationMs : 0,
|
|
105
|
+
freshness: buckets,
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* emitMemoryRetrieved({projectRoot, config, hits}) — one `memory.retrieved`
|
|
112
|
+
* record per returned hit: kind + opaque ref only.
|
|
113
|
+
*/
|
|
114
|
+
export function emitMemoryRetrieved({ projectRoot, config, hits } = {}) {
|
|
115
|
+
if (!Array.isArray(hits) || hits.length === 0) return;
|
|
116
|
+
if (resolveStage(config) === 'off') return;
|
|
117
|
+
const recorder = getRecorder({ projectRoot, config });
|
|
118
|
+
for (const hit of hits) {
|
|
119
|
+
const id = hit?.id;
|
|
120
|
+
if (typeof id !== 'string' || id.length === 0) continue;
|
|
121
|
+
safeEmit(recorder, {
|
|
122
|
+
semantic_name: 'memory.retrieved',
|
|
123
|
+
payload: {
|
|
124
|
+
kind: typeof hit.type === 'string' ? hit.type
|
|
125
|
+
: typeof hit.source === 'string' ? hit.source
|
|
126
|
+
: 'unknown',
|
|
127
|
+
record_ref: sha256(id),
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -111,7 +111,7 @@ export function rankHits(records, {
|
|
|
111
111
|
if (!eligible(record, ctx, policy).ok) continue;
|
|
112
112
|
if (!matchesRequestedScope(record, scope)) continue;
|
|
113
113
|
|
|
114
|
-
const lexical = scoreRecordTokens(record, queryTokens);
|
|
114
|
+
const lexical = scoreRecordTokens(record, queryTokens, nowMs);
|
|
115
115
|
const typeWeight = RECORD_TYPE_WEIGHTS[record.type] ?? 1;
|
|
116
116
|
const typeBonus = typeWeight / TYPE_WEIGHT_SCALE;
|
|
117
117
|
const evidenceBonus = hasLocatorEvidence(record) ? 1 : 0;
|