@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
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Bounded runtime-context compiler (G5 / TASK-002).
|
|
2
|
+
// Pure module: no I/O, no runtimeConfig reads, no adapter/gate imports.
|
|
3
|
+
// Compiles goal / verified state / evidence / memory records into a frozen
|
|
4
|
+
// BoundedContext under a hard byte budget that includes framing and
|
|
5
|
+
// provenance labels. Every included item carries {source, ref, bytes, age?};
|
|
6
|
+
// every dropped item is listed in omitted[] with {source, ref, reason} —
|
|
7
|
+
// omission is always visible, never silent.
|
|
8
|
+
//
|
|
9
|
+
// Memory records pass through src/core/memory/policy.js eligible();
|
|
10
|
+
// ineligible records are excluded (omitted with the policy reason), and
|
|
11
|
+
// eligible-but-unverified records are labeled 'unverified' and quoted —
|
|
12
|
+
// never injected as instruction.
|
|
13
|
+
|
|
14
|
+
import { eligible } from '../memory/policy.js';
|
|
15
|
+
|
|
16
|
+
const STALE_MS = 15 * 60 * 1000; // default freshness window for evidence
|
|
17
|
+
|
|
18
|
+
const HEADER = '# UKit runtime context';
|
|
19
|
+
const FOOTER = '# end context';
|
|
20
|
+
|
|
21
|
+
function byteLen(s) {
|
|
22
|
+
return Buffer.byteLength(s, 'utf8');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function oneLine(text) {
|
|
26
|
+
return String(text).split('\n').join(' ');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Each candidate: { source, ref, label|null, age?, line, required }
|
|
30
|
+
function renderLine(c) {
|
|
31
|
+
const label = c.label ? ` ${c.label}` : '';
|
|
32
|
+
const age = c.age != null ? ` age=${c.age}ms` : '';
|
|
33
|
+
const prefix = c.label === 'unverified' ? '> ' : '';
|
|
34
|
+
return `${prefix}[${c.source}${label} ref=${c.ref}${age}] ${oneLine(c.text)}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function compileRuntimeContext({
|
|
38
|
+
task,
|
|
39
|
+
verifiedState,
|
|
40
|
+
evidence,
|
|
41
|
+
memoryRecords,
|
|
42
|
+
budget,
|
|
43
|
+
now,
|
|
44
|
+
} = {}) {
|
|
45
|
+
const nowMs = typeof now === 'number' ? now : Date.now();
|
|
46
|
+
|
|
47
|
+
if (typeof budget !== 'number' || !Number.isFinite(budget) || budget <= 0) {
|
|
48
|
+
return { ok: false, code: 'malformed_input' };
|
|
49
|
+
}
|
|
50
|
+
if (task == null || typeof task !== 'object' || typeof task.goal !== 'string' || task.goal.length === 0) {
|
|
51
|
+
return { ok: false, code: 'malformed_input' };
|
|
52
|
+
}
|
|
53
|
+
if (evidence != null && !Array.isArray(evidence)) {
|
|
54
|
+
return { ok: false, code: 'malformed_input' };
|
|
55
|
+
}
|
|
56
|
+
if (memoryRecords != null && !Array.isArray(memoryRecords)) {
|
|
57
|
+
return { ok: false, code: 'malformed_input' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const candidates = [];
|
|
61
|
+
const omitted = [];
|
|
62
|
+
const push = (c, required = false) => candidates.push({ ...c, required });
|
|
63
|
+
const omit = (source, ref, reason) =>
|
|
64
|
+
omitted.push(Object.freeze({ source, ref, reason }));
|
|
65
|
+
|
|
66
|
+
// Goal is always required — a context with no goal is not a context.
|
|
67
|
+
push({ source: 'task', ref: task.id != null ? String(task.id) : 'task', text: `goal: ${task.goal}` }, true);
|
|
68
|
+
|
|
69
|
+
if (verifiedState != null) {
|
|
70
|
+
const text = typeof verifiedState === 'string' ? verifiedState : verifiedState.summary;
|
|
71
|
+
if (typeof text === 'string' && text.length > 0) {
|
|
72
|
+
push({ source: 'verified-state', ref: verifiedState.ref != null ? String(verifiedState.ref) : 'state', text }, true);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const ev of evidence || []) {
|
|
77
|
+
if (ev == null || typeof ev !== 'object' || typeof ev.content !== 'string') {
|
|
78
|
+
omit('evidence', ev && ev.ref != null ? String(ev.ref) : 'unknown', 'malformed_input');
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const source = typeof ev.source === 'string' ? ev.source : 'evidence';
|
|
82
|
+
const ref = ev.ref != null ? String(ev.ref) : 'unknown';
|
|
83
|
+
const ttl = typeof ev.ttlMs === 'number' ? ev.ttlMs : STALE_MS;
|
|
84
|
+
const age = typeof ev.observedAt === 'number' ? nowMs - ev.observedAt : null;
|
|
85
|
+
if (age != null && age > ttl) {
|
|
86
|
+
omit(source, ref, 'stale');
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
push({ source, ref, text: ev.content, age });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const memContext = {
|
|
93
|
+
projectId:
|
|
94
|
+
(verifiedState && typeof verifiedState.projectId === 'string' ? verifiedState.projectId : null)
|
|
95
|
+
?? (typeof task.projectId === 'string' ? task.projectId : null),
|
|
96
|
+
now: nowMs,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
for (const rec of memoryRecords || []) {
|
|
100
|
+
const ref = rec && rec.id != null ? String(rec.id) : 'memory';
|
|
101
|
+
const verdict = eligible(rec, memContext);
|
|
102
|
+
if (!verdict.ok) {
|
|
103
|
+
omit('memory', ref, verdict.reason);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const text = typeof rec.content === 'string' ? rec.content
|
|
107
|
+
: typeof rec.text === 'string' ? rec.text
|
|
108
|
+
: null;
|
|
109
|
+
if (text == null) {
|
|
110
|
+
omit('memory', ref, 'malformed_input');
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const unverified = rec.verified !== true && rec.trust_tier !== 'verified';
|
|
114
|
+
push({ source: 'memory', ref, text, label: unverified ? 'unverified' : null });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const framingBytes = byteLen(`${HEADER}\n\n${FOOTER}\n`);
|
|
118
|
+
let used = framingBytes;
|
|
119
|
+
const includedLines = [];
|
|
120
|
+
const included = [];
|
|
121
|
+
|
|
122
|
+
for (const c of candidates) {
|
|
123
|
+
const line = renderLine(c);
|
|
124
|
+
const lineBytes = byteLen(line) + 1; // trailing newline
|
|
125
|
+
if (used + lineBytes <= budget) {
|
|
126
|
+
used += lineBytes;
|
|
127
|
+
includedLines.push(line);
|
|
128
|
+
const item = { source: c.source, ref: c.ref, bytes: lineBytes };
|
|
129
|
+
if (c.age != null) item.age = c.age;
|
|
130
|
+
if (c.label) item.label = c.label;
|
|
131
|
+
included.push(Object.freeze(item));
|
|
132
|
+
} else if (c.required) {
|
|
133
|
+
// A required element cannot fit: nothing useful can be emitted.
|
|
134
|
+
return { ok: false, code: 'over_budget' };
|
|
135
|
+
} else {
|
|
136
|
+
omit(c.source, c.ref, 'over_budget');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const packet = `${HEADER}\n${includedLines.join('\n')}\n${FOOTER}\n`;
|
|
141
|
+
return {
|
|
142
|
+
ok: true,
|
|
143
|
+
context: Object.freeze({
|
|
144
|
+
packet,
|
|
145
|
+
included: Object.freeze(included),
|
|
146
|
+
omitted: Object.freeze(omitted),
|
|
147
|
+
}),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agentRuntime/contract.js — decision-first-runtime G1 (DR-02), contract v1.
|
|
3
|
+
*
|
|
4
|
+
* Pure validators for the versioned operation state machine, the SemanticEvent
|
|
5
|
+
* envelope, and side-effect/retry permission rules. No I/O, no host calls.
|
|
6
|
+
* Every rejection is a typed result `{ ok:false, code }` — never a throw.
|
|
7
|
+
*
|
|
8
|
+
* Spec: docs/AI_HANDOFF/SPEC.md §2 (G1-FR01/02/04), §4, §5.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const CONTRACT_VERSION = 1;
|
|
12
|
+
|
|
13
|
+
export const OPERATION_STATES = Object.freeze([
|
|
14
|
+
'queued',
|
|
15
|
+
'starting',
|
|
16
|
+
'running',
|
|
17
|
+
'cancel_pending',
|
|
18
|
+
'retry_pending',
|
|
19
|
+
'recovery_required',
|
|
20
|
+
'completed',
|
|
21
|
+
'failed',
|
|
22
|
+
'cancelled',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
export const TERMINAL_STATES = Object.freeze(['completed', 'failed', 'cancelled']);
|
|
26
|
+
|
|
27
|
+
const TERMINAL_SET = new Set(TERMINAL_STATES);
|
|
28
|
+
const STATE_SET = new Set(OPERATION_STATES);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Legal edges. `recovery_required` is reachable from every non-terminal state
|
|
32
|
+
* and resolves to exactly one terminal state — it never re-enters `running`.
|
|
33
|
+
*/
|
|
34
|
+
const TRANSITIONS = Object.freeze({
|
|
35
|
+
queued: new Set(['starting', 'recovery_required']),
|
|
36
|
+
starting: new Set(['running', 'cancel_pending', 'recovery_required']),
|
|
37
|
+
running: new Set(['completed', 'failed', 'cancel_pending', 'retry_pending', 'recovery_required']),
|
|
38
|
+
cancel_pending: new Set(['cancelled', 'recovery_required']),
|
|
39
|
+
retry_pending: new Set(['running', 'cancel_pending', 'recovery_required']),
|
|
40
|
+
recovery_required: new Set(TERMINAL_STATES),
|
|
41
|
+
completed: new Set(),
|
|
42
|
+
failed: new Set(),
|
|
43
|
+
cancelled: new Set(),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export const SIDE_EFFECT_CLASSES = Object.freeze([
|
|
47
|
+
'pure',
|
|
48
|
+
'read_only',
|
|
49
|
+
'idempotent_write',
|
|
50
|
+
'write',
|
|
51
|
+
'network',
|
|
52
|
+
'destructive',
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
/** Classes that MAY auto-retry under bounded attempts + backoff (SPEC §5). */
|
|
56
|
+
const AUTO_RETRYABLE = new Set(['pure', 'read_only', 'idempotent_write']);
|
|
57
|
+
|
|
58
|
+
const DEFAULT_MAX_ATTEMPTS = 1;
|
|
59
|
+
export const MAX_SAFE_PAYLOAD_BYTES = 32 * 1024;
|
|
60
|
+
|
|
61
|
+
const ok = () => ({ ok: true });
|
|
62
|
+
const reject = (code) => ({ ok: false, code });
|
|
63
|
+
|
|
64
|
+
export function isTerminal(state) {
|
|
65
|
+
return TERMINAL_SET.has(state);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Validate an operation state transition.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} prev current state
|
|
72
|
+
* @param {string} next requested next state
|
|
73
|
+
* @param {object} ctx transition context; MUST carry `fencingEpoch` (monotonic
|
|
74
|
+
* owner epoch). When `ctx.ownerEpoch` is provided, `fencingEpoch` must be
|
|
75
|
+
* >= ownerEpoch; a lower or absent epoch is a stale-owner reject.
|
|
76
|
+
* @returns {{ok:true}|{ok:false, code:string}}
|
|
77
|
+
*/
|
|
78
|
+
export function validateTransition(prev, next, ctx = {}) {
|
|
79
|
+
if (!STATE_SET.has(prev) || !STATE_SET.has(next)) {
|
|
80
|
+
return reject('unknown_state');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const { fencingEpoch, ownerEpoch } = ctx ?? {};
|
|
84
|
+
if (
|
|
85
|
+
!Number.isInteger(fencingEpoch)
|
|
86
|
+
|| fencingEpoch < 0
|
|
87
|
+
|| (ownerEpoch !== undefined && fencingEpoch < ownerEpoch)
|
|
88
|
+
) {
|
|
89
|
+
return reject('stale_owner');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (isTerminal(prev)) {
|
|
93
|
+
return reject('terminal_reentry');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!TRANSITIONS[prev].has(next)) {
|
|
97
|
+
return reject('invalid_transition');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return ok();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const REQUIRED_EVENT_FIELDS = Object.freeze([
|
|
104
|
+
'eventId',
|
|
105
|
+
'operationId',
|
|
106
|
+
'seq',
|
|
107
|
+
'eventType',
|
|
108
|
+
'observedAt',
|
|
109
|
+
'producerVersion',
|
|
110
|
+
'contractVersion',
|
|
111
|
+
'privacyClass',
|
|
112
|
+
'artifactRefs',
|
|
113
|
+
'safePayload',
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Validate a versioned SemanticEvent envelope (SPEC §2 G1-FR02).
|
|
118
|
+
* Ordering (`seq` cursor, duplicate/gap handling) is enforced by the journal
|
|
119
|
+
* reader — this validator checks envelope shape only.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} event
|
|
122
|
+
* @returns {{ok:true}|{ok:false, code:string}}
|
|
123
|
+
*/
|
|
124
|
+
export function validateSemanticEvent(event) {
|
|
125
|
+
if (event === null || typeof event !== 'object' || Array.isArray(event)) {
|
|
126
|
+
return reject('malformed_event');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
for (const field of REQUIRED_EVENT_FIELDS) {
|
|
130
|
+
if (!(field in event)) {
|
|
131
|
+
return reject('malformed_event');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (typeof event.eventId !== 'string' || event.eventId.length === 0) {
|
|
136
|
+
return reject('malformed_event');
|
|
137
|
+
}
|
|
138
|
+
if (typeof event.operationId !== 'string' || event.operationId.length === 0) {
|
|
139
|
+
return reject('malformed_event');
|
|
140
|
+
}
|
|
141
|
+
if (!Number.isInteger(event.seq) || event.seq < 0) {
|
|
142
|
+
return reject('malformed_event');
|
|
143
|
+
}
|
|
144
|
+
if (typeof event.eventType !== 'string' || event.eventType.length === 0) {
|
|
145
|
+
return reject('malformed_event');
|
|
146
|
+
}
|
|
147
|
+
if (typeof event.observedAt !== 'string' || Number.isNaN(Date.parse(event.observedAt))) {
|
|
148
|
+
return reject('malformed_event');
|
|
149
|
+
}
|
|
150
|
+
if (typeof event.producerVersion !== 'string' || event.producerVersion.length === 0) {
|
|
151
|
+
return reject('malformed_event');
|
|
152
|
+
}
|
|
153
|
+
if (event.contractVersion !== CONTRACT_VERSION) {
|
|
154
|
+
return reject('unsupported_contract_version');
|
|
155
|
+
}
|
|
156
|
+
if (typeof event.privacyClass !== 'string' || event.privacyClass.length === 0) {
|
|
157
|
+
return reject('malformed_event');
|
|
158
|
+
}
|
|
159
|
+
if (!Array.isArray(event.artifactRefs)) {
|
|
160
|
+
return reject('malformed_event');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// safePayload is bounded and must never carry raw output (SPEC §2).
|
|
164
|
+
let payloadBytes;
|
|
165
|
+
try {
|
|
166
|
+
payloadBytes = Buffer.byteLength(JSON.stringify(event.safePayload ?? null), 'utf8');
|
|
167
|
+
} catch {
|
|
168
|
+
return reject('malformed_event');
|
|
169
|
+
}
|
|
170
|
+
if (payloadBytes > MAX_SAFE_PAYLOAD_BYTES) {
|
|
171
|
+
return reject('payload_too_large');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return ok();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Validate a SemanticEvent against the per-operation ordering cursor
|
|
179
|
+
* (SPEC §2): `seq` is the causal cursor — strictly monotonic, gap-free.
|
|
180
|
+
* A duplicate `eventId` or already-seen `seq` is an idempotent no-op
|
|
181
|
+
* (`{ok:true, duplicate:true}`); a future `seq` is a typed reject
|
|
182
|
+
* `out_of_order` — never a silent reorder.
|
|
183
|
+
*
|
|
184
|
+
* `cursor` is the caller-owned per-operation state:
|
|
185
|
+
* `{ lastSeq: number, seenEventIds?: Set<string> }`. It is mutated only on a
|
|
186
|
+
* non-duplicate accept (`lastSeq` advances, `eventId` recorded).
|
|
187
|
+
*
|
|
188
|
+
* @param {object} event event already accepted by validateSemanticEvent
|
|
189
|
+
* @param {object} cursor
|
|
190
|
+
* @returns {{ok:true, duplicate?:boolean}|{ok:false, code:string}}
|
|
191
|
+
*/
|
|
192
|
+
export function validateEventOrder(event, cursor) {
|
|
193
|
+
if (cursor === null || typeof cursor !== 'object' || !Number.isInteger(cursor.lastSeq) || cursor.lastSeq < 0) {
|
|
194
|
+
return reject('malformed_cursor');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const seen = cursor.seenEventIds instanceof Set ? cursor.seenEventIds : null;
|
|
198
|
+
if (seen?.has(event.eventId) || event.seq <= cursor.lastSeq) {
|
|
199
|
+
return { ok: true, duplicate: true };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (event.seq !== cursor.lastSeq + 1) {
|
|
203
|
+
return reject('out_of_order');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
cursor.lastSeq = event.seq;
|
|
207
|
+
seen?.add(event.eventId);
|
|
208
|
+
return ok();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Validate whether an attempt is permitted for a side-effect class.
|
|
213
|
+
*
|
|
214
|
+
* `attempt` is 1-based: attempt 1 is the initial execution and is always
|
|
215
|
+
* allowed; attempt >= 2 is a replay and is only permitted for auto-retryable
|
|
216
|
+
* classes within `policy.maxAttempts`. `write`/`network`/`destructive` and any
|
|
217
|
+
* unrecognized class fall into the most conservative bucket: no auto-replay —
|
|
218
|
+
* post-crash they resolve to `recovery_required` (SPEC §5).
|
|
219
|
+
*
|
|
220
|
+
* @param {string} sideEffectClass
|
|
221
|
+
* @param {number} attempt 1-based attempt number
|
|
222
|
+
* @param {object} [policy] `{ maxAttempts }` — total attempts allowed
|
|
223
|
+
* @returns {{ok:true}|{ok:false, code:string}}
|
|
224
|
+
*/
|
|
225
|
+
export function validateRetry(sideEffectClass, attempt, policy = {}) {
|
|
226
|
+
if (!Number.isInteger(attempt) || attempt < 1) {
|
|
227
|
+
return reject('invalid_attempt');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const maxAttempts = Number.isInteger(policy?.maxAttempts) && policy.maxAttempts >= 1
|
|
231
|
+
? policy.maxAttempts
|
|
232
|
+
: DEFAULT_MAX_ATTEMPTS;
|
|
233
|
+
|
|
234
|
+
if (attempt === 1) {
|
|
235
|
+
return ok();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!AUTO_RETRYABLE.has(sideEffectClass)) {
|
|
239
|
+
return reject('retry_unsafe');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (attempt > maxAttempts) {
|
|
243
|
+
return reject('retry_exhausted');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return ok();
|
|
247
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agentRuntime/diagnostics.js — decision-first-runtime G7 (DR-10),
|
|
3
|
+
* explainable trace/replay readers (SPEC §2 G7-FR01/02, §4, §5, §7).
|
|
4
|
+
*
|
|
5
|
+
* Two pure readers over the operation journal:
|
|
6
|
+
* renderOperationTimeline → deterministic frozen SanitizedTimeline
|
|
7
|
+
* replayOperation → read-only frozen ReplayResult
|
|
8
|
+
*
|
|
9
|
+
* Privacy contract (SPEC §5): `summary` strings are assembled from the
|
|
10
|
+
* eventType plus a whitelist of enumerated code/state fields — raw
|
|
11
|
+
* `safePayload` text is NEVER copied into output. Values must match a
|
|
12
|
+
* bounded code pattern; anything else is dropped silently.
|
|
13
|
+
*
|
|
14
|
+
* Read-only guarantee: this module performs no writes, spawns, signals,
|
|
15
|
+
* or enqueues. Journal + contract are the only inputs. It performs no
|
|
16
|
+
* config reads (same convention as eventStore.js).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { promises as fs } from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
CONTRACT_VERSION,
|
|
24
|
+
OPERATION_STATES,
|
|
25
|
+
isTerminal,
|
|
26
|
+
validateTransition,
|
|
27
|
+
} from './contract.js';
|
|
28
|
+
import { readJournal, EventStoreError } from './eventStore.js';
|
|
29
|
+
|
|
30
|
+
export class DiagnosticsError extends Error {
|
|
31
|
+
constructor(code, message) {
|
|
32
|
+
super(message ?? code);
|
|
33
|
+
this.name = 'DiagnosticsError';
|
|
34
|
+
this.code = code;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const JOURNAL_DIR = 'events';
|
|
39
|
+
const STATE_SET = new Set(OPERATION_STATES);
|
|
40
|
+
|
|
41
|
+
const journalPath = (dir, operationId) =>
|
|
42
|
+
path.join(dir, JOURNAL_DIR, `${operationId}.jsonl`);
|
|
43
|
+
|
|
44
|
+
async function pathExists(p) {
|
|
45
|
+
try {
|
|
46
|
+
await fs.access(p);
|
|
47
|
+
return true;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Bounded code pattern — states, codes, digests only; never free text. */
|
|
54
|
+
const CODE_RE = /^[a-z0-9_.:-]{1,64}$/;
|
|
55
|
+
|
|
56
|
+
/** Whitelisted safePayload fields whose values are enumerated codes. */
|
|
57
|
+
const CODE_FIELDS = Object.freeze(['code', 'reason', 'status', 'kind', 'attempt']);
|
|
58
|
+
const NUMERIC_FIELDS = Object.freeze(['seq', 'step', 'attempt', 'count', 'durationMs']);
|
|
59
|
+
|
|
60
|
+
function codeValue(v) {
|
|
61
|
+
return typeof v === 'string' && CODE_RE.test(v) ? v : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build a sanitized summary from eventType + enumerated code fields only.
|
|
66
|
+
* `from`/`to` are emitted only when they are known contract states.
|
|
67
|
+
*/
|
|
68
|
+
function summarize(event) {
|
|
69
|
+
const parts = [event.eventType];
|
|
70
|
+
const p = event.safePayload;
|
|
71
|
+
if (p !== null && typeof p === 'object' && !Array.isArray(p)) {
|
|
72
|
+
for (const f of ['from', 'to']) {
|
|
73
|
+
if (STATE_SET.has(p[f])) parts.push(`${f}=${p[f]}`);
|
|
74
|
+
}
|
|
75
|
+
for (const f of CODE_FIELDS) {
|
|
76
|
+
const v = codeValue(p[f]);
|
|
77
|
+
if (v !== null) parts.push(`${f}=${v}`);
|
|
78
|
+
}
|
|
79
|
+
for (const f of NUMERIC_FIELDS) {
|
|
80
|
+
if (Number.isFinite(p[f]) && !['from', 'to'].includes(f) && !CODE_FIELDS.includes(f)) {
|
|
81
|
+
parts.push(`${f}=${p[f]}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return parts.join(' ');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read + validate the journal; throws DiagnosticsError('journal_missing')
|
|
90
|
+
* when absent and detects the truncated-tail flag the reader discards.
|
|
91
|
+
*/
|
|
92
|
+
async function loadJournal(dir, operationId) {
|
|
93
|
+
const file = journalPath(dir, operationId);
|
|
94
|
+
if (!(await pathExists(file))) {
|
|
95
|
+
throw new DiagnosticsError('journal_missing', `no journal for ${operationId}`);
|
|
96
|
+
}
|
|
97
|
+
let raw;
|
|
98
|
+
try {
|
|
99
|
+
raw = await fs.readFile(file, 'utf8');
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (err && err.code === 'ENOENT') {
|
|
102
|
+
throw new DiagnosticsError('journal_missing', `no journal for ${operationId}`);
|
|
103
|
+
}
|
|
104
|
+
throw new DiagnosticsError('journal_unreadable', err && err.code ? err.code : 'read_failed');
|
|
105
|
+
}
|
|
106
|
+
const truncatedTail = !raw.endsWith('\n');
|
|
107
|
+
const events = [];
|
|
108
|
+
try {
|
|
109
|
+
for await (const e of readJournal(dir, operationId)) events.push(e);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
if (err instanceof EventStoreError) {
|
|
112
|
+
throw new DiagnosticsError('malformed_event', err.code);
|
|
113
|
+
}
|
|
114
|
+
if (err && err.code === 'ENOENT') {
|
|
115
|
+
// file raced away after the guarded pre-read — same contract as :102
|
|
116
|
+
throw new DiagnosticsError('journal_missing', `no journal for ${operationId}`);
|
|
117
|
+
}
|
|
118
|
+
if (err && typeof err.code === 'string' && err.code !== '') {
|
|
119
|
+
// fs failure inside readJournal's own readFile (e.g. EACCES/EIO) —
|
|
120
|
+
// the DiagnosticsError contract covers the whole loadJournal surface
|
|
121
|
+
throw new DiagnosticsError('journal_unreadable', err.code);
|
|
122
|
+
}
|
|
123
|
+
throw err;
|
|
124
|
+
}
|
|
125
|
+
for (const e of events) {
|
|
126
|
+
if (e.contractVersion !== CONTRACT_VERSION) {
|
|
127
|
+
throw new DiagnosticsError(
|
|
128
|
+
'unsupported_contract_version',
|
|
129
|
+
`event seq ${e.seq} contractVersion ${e.contractVersion}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { events, truncatedTail };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const gap = (seq, code, detail) => Object.freeze({ seq, code, detail });
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Fold transition events through validateTransition. Returns
|
|
140
|
+
* { derivedState, lastSeq, gaps, boundarySeq } — folding stops at the
|
|
141
|
+
* first replay boundary; events after it are recorded as gaps, never
|
|
142
|
+
* used to infer state (SPEC §7).
|
|
143
|
+
*/
|
|
144
|
+
function foldTransitions(events) {
|
|
145
|
+
const gaps = [];
|
|
146
|
+
let state = null;
|
|
147
|
+
let lastSeq = 0;
|
|
148
|
+
let boundarySeq = null;
|
|
149
|
+
|
|
150
|
+
for (const e of events) {
|
|
151
|
+
lastSeq = e.seq;
|
|
152
|
+
if (/^(model\.|tool\.)/.test(e.eventType)) {
|
|
153
|
+
gaps.push(gap(e.seq, 'external_input', `${e.eventType} output is nondeterministic; not re-derived`));
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (boundarySeq !== null) {
|
|
157
|
+
// everything past the boundary is display-only, never inferred
|
|
158
|
+
if (e.eventType === 'operation.transition') {
|
|
159
|
+
gaps.push(gap(e.seq, 'replay_boundary', 'transition after recovery boundary ignored'));
|
|
160
|
+
}
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (e.eventType !== 'operation.transition') continue;
|
|
164
|
+
const to = e.safePayload?.to;
|
|
165
|
+
if (!STATE_SET.has(to)) {
|
|
166
|
+
gaps.push(gap(e.seq, 'replay_boundary', 'transition missing target state'));
|
|
167
|
+
boundarySeq = e.seq;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const prev = state === null ? (STATE_SET.has(e.safePayload?.from) ? e.safePayload.from : 'queued') : state;
|
|
171
|
+
const ctx = { fencingEpoch: Number.isInteger(e.safePayload?.fencingEpoch) ? e.safePayload.fencingEpoch : e.seq };
|
|
172
|
+
const v = validateTransition(prev, to, ctx);
|
|
173
|
+
if (!v.ok) {
|
|
174
|
+
gaps.push(gap(e.seq, 'replay_boundary', `transition ${prev}→${to} rejected: ${v.code}`));
|
|
175
|
+
boundarySeq = e.seq;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
state = to;
|
|
179
|
+
if (to === 'recovery_required') {
|
|
180
|
+
gaps.push(gap(e.seq, 'recovery_boundary', 'recovery_required is never resolved by replay alone'));
|
|
181
|
+
boundarySeq = e.seq;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { derivedState: state, lastSeq, gaps, boundarySeq };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function classifyReplayable(derivedState, gaps, events) {
|
|
188
|
+
if (derivedState === null) {
|
|
189
|
+
return events.length === 0 ? 'none' : 'partial'; // start_unconfirmed window
|
|
190
|
+
}
|
|
191
|
+
if (isTerminal(derivedState) && gaps.length === 0) return 'full';
|
|
192
|
+
return 'partial';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* G7-FR01 — deterministic frozen SanitizedTimeline.
|
|
197
|
+
*
|
|
198
|
+
* @param {string} dir store root
|
|
199
|
+
* @param {string} operationId
|
|
200
|
+
* @returns {Promise<object>} SanitizedTimeline (SPEC §4)
|
|
201
|
+
*/
|
|
202
|
+
export async function renderOperationTimeline(dir, operationId, _opts = {}) {
|
|
203
|
+
const { events, truncatedTail } = await loadJournal(dir, operationId);
|
|
204
|
+
const steps = events.map((e) => Object.freeze({
|
|
205
|
+
seq: e.seq,
|
|
206
|
+
eventType: e.eventType,
|
|
207
|
+
observedAt: e.observedAt,
|
|
208
|
+
summary: summarize(e),
|
|
209
|
+
artifactRefs: Object.freeze(
|
|
210
|
+
(Array.isArray(e.artifactRefs) ? e.artifactRefs : []).filter((r) => typeof r === 'string'),
|
|
211
|
+
),
|
|
212
|
+
}));
|
|
213
|
+
const { derivedState, gaps } = foldTransitions(events);
|
|
214
|
+
return Object.freeze({
|
|
215
|
+
version: 1,
|
|
216
|
+
operationId,
|
|
217
|
+
steps: Object.freeze(steps),
|
|
218
|
+
terminalState: isTerminal(derivedState) ? derivedState : null,
|
|
219
|
+
journalTruncated: truncatedTail,
|
|
220
|
+
gaps: Object.freeze(gaps),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* G7-FR02 — read-only logical replay. MUST NOT spawn, signal, write, or
|
|
226
|
+
* enqueue; the returned frozen object is the only output (SPEC §7).
|
|
227
|
+
*
|
|
228
|
+
* @param {string} dir store root
|
|
229
|
+
* @param {string} operationId
|
|
230
|
+
* @returns {Promise<object>} ReplayResult (SPEC §4)
|
|
231
|
+
*/
|
|
232
|
+
export async function replayOperation(dir, operationId, _opts = {}) {
|
|
233
|
+
const { events } = await loadJournal(dir, operationId);
|
|
234
|
+
const { derivedState, lastSeq, gaps } = foldTransitions(events);
|
|
235
|
+
const replayable = classifyReplayable(derivedState, gaps, events);
|
|
236
|
+
return Object.freeze({
|
|
237
|
+
version: 1,
|
|
238
|
+
operationId,
|
|
239
|
+
derivedState: derivedState ?? 'unknown',
|
|
240
|
+
lastSeq,
|
|
241
|
+
replayable,
|
|
242
|
+
gaps: Object.freeze(gaps),
|
|
243
|
+
});
|
|
244
|
+
}
|