@ngockhoale/ukit 3.0.8 → 3.0.10
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 +18 -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,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agentRuntime/recovery.js — decision-first-runtime G2 (DR-04), SPEC §5.
|
|
3
|
+
*
|
|
4
|
+
* Restart reconciliation: replay journals + guarded state on supervisor start
|
|
5
|
+
* and resolve every recorded operation exactly once. The journal is truth;
|
|
6
|
+
* the state file is a cache consulted only for launch metadata the journal
|
|
7
|
+
* cannot carry yet (PID, processStartTime, exitStatus, fencing/lease epochs).
|
|
8
|
+
*
|
|
9
|
+
* Hard invariants (SPEC §5, G2-FR05/06):
|
|
10
|
+
* - never spawns: a crash cannot produce a second side-effecting action;
|
|
11
|
+
* - never kills: a PID not provably ours (start-time/lease match) is foreign;
|
|
12
|
+
* - non-terminal ops with no live owned process → `recovery_required`
|
|
13
|
+
* (typed code per crash window) — never silently re-spawned or replayed;
|
|
14
|
+
* - a provably exited process whose terminal event was never committed gets
|
|
15
|
+
* exactly one terminal append — idempotent on seq (double reconcile = 0);
|
|
16
|
+
* - stale fencingEpoch → `stale_owner` reject before any durable write.
|
|
17
|
+
*
|
|
18
|
+
* Resolution codes:
|
|
19
|
+
* 'start_unconfirmed' journal has no start proof — never re-spawn
|
|
20
|
+
* 'orphan_possible' spawn committed but PID record lost — do not kill
|
|
21
|
+
* 'live_mismatch' recorded PID live but (pid,startTime) mismatched —
|
|
22
|
+
* PID reuse: foreign process, never signal it
|
|
23
|
+
* 'exited_unconfirmed' provably gone/exited without outcome → terminal append
|
|
24
|
+
* when exitStatus is known, else recovery_required
|
|
25
|
+
* 'launch_disabled' kill switch active at restart — zero launches
|
|
26
|
+
* 'stale_owner' fencingEpoch below recorded owner epoch
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { promises as fs } from 'node:fs';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
CONTRACT_VERSION,
|
|
34
|
+
isTerminal,
|
|
35
|
+
validateTransition,
|
|
36
|
+
} from './contract.js';
|
|
37
|
+
import {
|
|
38
|
+
appendEvent,
|
|
39
|
+
deriveOperationState,
|
|
40
|
+
writeOperationState,
|
|
41
|
+
} from './eventStore.js';
|
|
42
|
+
|
|
43
|
+
const JOURNAL_DIR = 'events';
|
|
44
|
+
const STATE_DIR = 'state';
|
|
45
|
+
const PRODUCER_VERSION = '3.0.8';
|
|
46
|
+
|
|
47
|
+
async function pathExists(p) {
|
|
48
|
+
try {
|
|
49
|
+
await fs.access(p);
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function readJsonSafe(file) {
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(await fs.readFile(file, 'utf8'));
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Enumerate operation ids from both journal and guarded-state dirs. */
|
|
65
|
+
async function listOperationIds(runtimeDir) {
|
|
66
|
+
const ids = new Set();
|
|
67
|
+
for (const [sub, ext] of [[JOURNAL_DIR, '.jsonl'], [STATE_DIR, '.json']]) {
|
|
68
|
+
const dir = path.join(runtimeDir, sub);
|
|
69
|
+
let entries;
|
|
70
|
+
try {
|
|
71
|
+
entries = await fs.readdir(dir);
|
|
72
|
+
} catch {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
for (const name of entries) {
|
|
76
|
+
if (name.endsWith(ext)) ids.add(name.slice(0, -ext.length));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return [...ids].sort();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function ownerEpochFor(stateRec, ctx) {
|
|
83
|
+
const epochs = [ctx?.ownerEpoch, stateRec?.fencingEpoch, stateRec?.leaseEpoch]
|
|
84
|
+
.filter((n) => Number.isInteger(n) && n >= 0);
|
|
85
|
+
return epochs.length ? Math.max(...epochs) : 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function makeEvent(operationId, seq, eventType, safePayload, now) {
|
|
89
|
+
return {
|
|
90
|
+
eventId: `recovery-${operationId}-${seq}`,
|
|
91
|
+
operationId,
|
|
92
|
+
seq,
|
|
93
|
+
eventType,
|
|
94
|
+
observedAt: now(),
|
|
95
|
+
producerVersion: PRODUCER_VERSION,
|
|
96
|
+
contractVersion: CONTRACT_VERSION,
|
|
97
|
+
privacyClass: 'internal',
|
|
98
|
+
artifactRefs: [],
|
|
99
|
+
safePayload,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Reconcile all recorded operations once on supervisor start.
|
|
105
|
+
*
|
|
106
|
+
* @param {object} ctx
|
|
107
|
+
* @param {string} ctx.runtimeDir eventStore root (events/, state/)
|
|
108
|
+
* @param {object} [ctx.processTable] `{ pid: { alive, startTime, exitStatus? } }`
|
|
109
|
+
* injected snapshot — reconcile never probes or signals processes itself
|
|
110
|
+
* @param {number} ctx.fencingEpoch this owner's monotonic epoch
|
|
111
|
+
* @param {number} [ctx.ownerEpoch] epoch floor when state records lack one
|
|
112
|
+
* @param {boolean} [ctx.launchDisabled] kill switch active at restart
|
|
113
|
+
* @param {() => string} [ctx.now] ISO timestamp source (injectable clock)
|
|
114
|
+
* @returns {Promise<{results: Array<{operationId, resolution, code}>}>}
|
|
115
|
+
*/
|
|
116
|
+
export async function reconcileOwnedOperations(ctx) {
|
|
117
|
+
const runtimeDir = ctx?.runtimeDir;
|
|
118
|
+
if (typeof runtimeDir !== 'string' || runtimeDir === '') {
|
|
119
|
+
throw new TypeError('reconcileOwnedOperations: runtimeDir is required');
|
|
120
|
+
}
|
|
121
|
+
const processTable = ctx?.processTable && typeof ctx.processTable === 'object'
|
|
122
|
+
? ctx.processTable
|
|
123
|
+
: {};
|
|
124
|
+
const fencingEpoch = Number.isInteger(ctx?.fencingEpoch) ? ctx.fencingEpoch : 0;
|
|
125
|
+
const launchDisabled = ctx?.launchDisabled === true;
|
|
126
|
+
const now = typeof ctx?.now === 'function' ? ctx.now : () => new Date().toISOString();
|
|
127
|
+
|
|
128
|
+
const results = [];
|
|
129
|
+
for (const operationId of await listOperationIds(runtimeDir)) {
|
|
130
|
+
results.push(await reconcileOne({
|
|
131
|
+
runtimeDir,
|
|
132
|
+
operationId,
|
|
133
|
+
processTable,
|
|
134
|
+
fencingEpoch,
|
|
135
|
+
ownerEpoch: undefined,
|
|
136
|
+
launchDisabled,
|
|
137
|
+
ctxOwnerEpoch: ctx?.ownerEpoch,
|
|
138
|
+
now,
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
return { results };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function reconcileOne({
|
|
145
|
+
runtimeDir,
|
|
146
|
+
operationId,
|
|
147
|
+
processTable,
|
|
148
|
+
fencingEpoch,
|
|
149
|
+
launchDisabled,
|
|
150
|
+
ctxOwnerEpoch,
|
|
151
|
+
now,
|
|
152
|
+
}) {
|
|
153
|
+
const stateRec = await readJsonSafe(path.join(runtimeDir, STATE_DIR, `${operationId}.json`));
|
|
154
|
+
const derived = await deriveOperationState(runtimeDir, operationId);
|
|
155
|
+
const journalState = derived.state; // truth
|
|
156
|
+
const journalHasEvents = derived.lastSeq > 0;
|
|
157
|
+
const stateFileState = typeof stateRec?.state === 'string' ? stateRec.state : null;
|
|
158
|
+
const effectiveState = journalState ?? stateFileState;
|
|
159
|
+
const lastSeq = derived.lastSeq;
|
|
160
|
+
const ownerEpoch = ownerEpochFor(stateRec, { ownerEpoch: ctxOwnerEpoch });
|
|
161
|
+
const fenced = { fencingEpoch, ownerEpoch };
|
|
162
|
+
|
|
163
|
+
const result = (resolution, code) => ({ operationId, resolution, code });
|
|
164
|
+
|
|
165
|
+
const persistRecovery = async (from, code) => {
|
|
166
|
+
const t = validateTransition(from, 'recovery_required', fenced);
|
|
167
|
+
if (!t.ok) return result('stale_owner', t.code);
|
|
168
|
+
const event = makeEvent(operationId, lastSeq + 1, 'operation.transition', {
|
|
169
|
+
from,
|
|
170
|
+
to: 'recovery_required',
|
|
171
|
+
resolution: 'recovery_required',
|
|
172
|
+
code,
|
|
173
|
+
}, now);
|
|
174
|
+
await appendEvent(runtimeDir, event);
|
|
175
|
+
await writeOperationState(runtimeDir, operationId, {
|
|
176
|
+
...(stateRec ?? {}),
|
|
177
|
+
state: 'recovery_required',
|
|
178
|
+
resolution: code,
|
|
179
|
+
});
|
|
180
|
+
return result('recovery_required', code);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const persistTerminal = async (from, to, code) => {
|
|
184
|
+
const t = validateTransition(from, to, fenced);
|
|
185
|
+
if (!t.ok) return result('stale_owner', t.code);
|
|
186
|
+
const event = makeEvent(operationId, lastSeq + 1, 'operation.transition', {
|
|
187
|
+
from,
|
|
188
|
+
to,
|
|
189
|
+
resolution: 'terminal_appended',
|
|
190
|
+
code,
|
|
191
|
+
}, now);
|
|
192
|
+
await appendEvent(runtimeDir, event);
|
|
193
|
+
await writeOperationState(runtimeDir, operationId, {
|
|
194
|
+
...(stateRec ?? {}),
|
|
195
|
+
state: to,
|
|
196
|
+
resolution: code,
|
|
197
|
+
});
|
|
198
|
+
return result('terminal_appended', code);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// --- resolved already -----------------------------------------------------
|
|
202
|
+
if (isTerminal(journalState)) return result('already_terminal', journalState);
|
|
203
|
+
if (journalState === 'recovery_required') {
|
|
204
|
+
return result('already_recovery_required', 'recovery_required');
|
|
205
|
+
}
|
|
206
|
+
if (isTerminal(effectiveState)) {
|
|
207
|
+
// state cache recorded a terminal outcome but the journal never got the
|
|
208
|
+
// commit — backfill exactly one terminal event (idempotent on seq).
|
|
209
|
+
return persistTerminal(journalState, stateFileState, 'exited_unconfirmed');
|
|
210
|
+
}
|
|
211
|
+
// --- fencing: never mutate under a stale owner ----------------------------
|
|
212
|
+
// Any durable resolution below re-validates via validateTransition; check
|
|
213
|
+
// eagerly so a stale owner rejects before ANY write attempt.
|
|
214
|
+
if (fencingEpoch < ownerEpoch) {
|
|
215
|
+
return result('stale_owner', 'stale_owner');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// --- crash window: journal has no start proof -----------------------------
|
|
219
|
+
if (!journalHasEvents) {
|
|
220
|
+
if (stateRec?.spawned === true || Number.isInteger(stateRec?.pid)) {
|
|
221
|
+
// spawn committed but the PID/handle record was lost — a live foreign
|
|
222
|
+
// PID cannot be proven ours; never kill, never re-spawn.
|
|
223
|
+
return persistRecovery('starting', 'orphan_possible');
|
|
224
|
+
}
|
|
225
|
+
return persistRecovery('starting', 'start_unconfirmed');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// --- kill switch at restart: zero launches --------------------------------
|
|
229
|
+
if (launchDisabled) {
|
|
230
|
+
return persistRecovery(effectiveState ?? 'starting', 'launch_disabled');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// --- liveness fence: (pid, processStartTime) must both match --------------
|
|
234
|
+
const pid = Number.isInteger(stateRec?.pid) ? stateRec.pid : null;
|
|
235
|
+
const entry = pid != null ? processTable[pid] ?? processTable[String(pid)] : null;
|
|
236
|
+
const alive = entry?.alive === true;
|
|
237
|
+
|
|
238
|
+
if (pid != null && alive) {
|
|
239
|
+
const recordedStart = stateRec?.processStartTime;
|
|
240
|
+
const matches = recordedStart != null && entry.startTime === recordedStart;
|
|
241
|
+
if (!matches) {
|
|
242
|
+
// PID reuse: the live process is foreign — never signal it.
|
|
243
|
+
return persistRecovery(effectiveState, 'live_mismatch');
|
|
244
|
+
}
|
|
245
|
+
// Provably ours and alive → resume observation only; durable state stays
|
|
246
|
+
// non-terminal so a second reconcile keeps resuming (idempotent).
|
|
247
|
+
return result('resumed', 'alive_owned');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// --- no live owned process ------------------------------------------------
|
|
251
|
+
if (Number.isInteger(stateRec?.exitStatus)) {
|
|
252
|
+
const to = stateRec.exitStatus === 'cancelled'
|
|
253
|
+
? 'cancelled'
|
|
254
|
+
: stateRec.exitStatus === 0
|
|
255
|
+
? 'completed'
|
|
256
|
+
: 'failed';
|
|
257
|
+
return persistTerminal(effectiveState, to, 'exited_unconfirmed');
|
|
258
|
+
}
|
|
259
|
+
// Gone without outcome (or still starting): ambiguous — recovery_required,
|
|
260
|
+
// never a guessed terminal state, never a re-spawn. A `starting` op whose
|
|
261
|
+
// PID record never landed may have left an orphan we cannot prove is ours.
|
|
262
|
+
const tailCode = effectiveState === 'starting' && pid == null
|
|
263
|
+
? 'orphan_possible'
|
|
264
|
+
: 'exited_unconfirmed';
|
|
265
|
+
return persistRecovery(effectiveState ?? 'starting', tailCode);
|
|
266
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Deterministic resource-pressure policy for runtime event emission (G6 / TASK-002).
|
|
2
|
+
// Pure module: no I/O, no runtimeConfig reads, no adapter/context imports.
|
|
3
|
+
// Maps {cpuPressure, eventClass, queueDepth} to a frozen ResourceDecision —
|
|
4
|
+
// {action:'emit'|'coalesce'|'defer', maxDeferMs?, reason:string}.
|
|
5
|
+
//
|
|
6
|
+
// DR-FR09 invariant: terminal, failure, permission and user-input events are
|
|
7
|
+
// NEVER dropped or deferred — they always `emit` under any pressure or queue
|
|
8
|
+
// depth. Only `progress`-class observations may be coalesced/deferred, and
|
|
9
|
+
// deferral is always bounded (maxDeferMs) — there is no 'drop' action.
|
|
10
|
+
// Unknown event class or malformed input resolves `emit` (fail open for
|
|
11
|
+
// correctness, not throughput); this function never throws.
|
|
12
|
+
|
|
13
|
+
export const RESOURCE_ACTIONS = Object.freeze(['emit', 'coalesce', 'defer']);
|
|
14
|
+
|
|
15
|
+
export const EVENT_CLASSES = Object.freeze([
|
|
16
|
+
'terminal',
|
|
17
|
+
'failure',
|
|
18
|
+
'permission',
|
|
19
|
+
'user-input',
|
|
20
|
+
'progress',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
export const PRESSURE_LEVELS = Object.freeze(['normal', 'elevated', 'critical']);
|
|
24
|
+
|
|
25
|
+
// Bounded deferral ceilings (literal config values, ms). Coalescing may batch
|
|
26
|
+
// for up to this window; deferral may postpone emission by up to this window.
|
|
27
|
+
const MAX_DEFER_MS = Object.freeze({ elevated: 500, critical: 2000 });
|
|
28
|
+
|
|
29
|
+
// Queue depth below which pressure alone does not justify deferral — coalesce
|
|
30
|
+
// is the cheaper backpressure signal and is preferred first.
|
|
31
|
+
const DEFER_QUEUE_DEPTH = 8;
|
|
32
|
+
|
|
33
|
+
const CRITICAL_CLASSES = new Set(['terminal', 'failure', 'permission', 'user-input']);
|
|
34
|
+
const PRESSURE_SET = new Set(PRESSURE_LEVELS);
|
|
35
|
+
|
|
36
|
+
const freeze = (action, reason, extra) =>
|
|
37
|
+
Object.freeze({ action, reason, ...extra });
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* selectResourcePolicy({cpuPressure, eventClass, queueDepth}) → frozen
|
|
41
|
+
* ResourceDecision. Never throws; never returns `drop`.
|
|
42
|
+
*/
|
|
43
|
+
export function selectResourcePolicy(input) {
|
|
44
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
45
|
+
return freeze('emit', 'malformed');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { cpuPressure, eventClass, queueDepth } = input;
|
|
49
|
+
|
|
50
|
+
if (typeof eventClass !== 'string' || !EVENT_CLASSES.includes(eventClass)) {
|
|
51
|
+
// Any non-string or unrecognized class — including missing fields on a
|
|
52
|
+
// structurally valid object — fails open.
|
|
53
|
+
if (typeof eventClass === 'string' && eventClass.length > 0) {
|
|
54
|
+
return freeze('emit', 'class:unknown');
|
|
55
|
+
}
|
|
56
|
+
return freeze('emit', 'malformed');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// DR-FR09: critical classes always emit, regardless of pressure/depth.
|
|
60
|
+
if (CRITICAL_CLASSES.has(eventClass)) {
|
|
61
|
+
return freeze('emit', 'class:critical');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// eventClass === 'progress' from here.
|
|
65
|
+
if (!PRESSURE_SET.has(cpuPressure) || typeof queueDepth !== 'number' || !Number.isFinite(queueDepth) || queueDepth < 0) {
|
|
66
|
+
return freeze('emit', 'malformed');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (cpuPressure === 'normal') {
|
|
70
|
+
return freeze('emit', 'pressure:normal');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const maxDeferMs = MAX_DEFER_MS[cpuPressure];
|
|
74
|
+
if (cpuPressure === 'critical' || queueDepth >= DEFER_QUEUE_DEPTH) {
|
|
75
|
+
return freeze('defer', 'pressure:defer', { maxDeferMs });
|
|
76
|
+
}
|
|
77
|
+
return freeze('coalesce', 'pressure:coalesce', { maxDeferMs });
|
|
78
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agentRuntime/runtimeSupport.js — decision-first-runtime G7 (DR-10),
|
|
3
|
+
* SPEC §2 G7-FR03, §4, §5.
|
|
4
|
+
*
|
|
5
|
+
* buildRuntimeSupportRecords(dir, opts?)
|
|
6
|
+
* → { version, records[], coverage, redaction_version }
|
|
7
|
+
*
|
|
8
|
+
* Pure reader over the eventStore journals: each operation becomes one
|
|
9
|
+
* bounded support record carrying counts/codes/timings/artifact digests
|
|
10
|
+
* ONLY. `safePayload` strings are never copied into records — the only
|
|
11
|
+
* payload-derived values are state names, typed codes (safePayload.code /
|
|
12
|
+
* resolution / exit_code), and artifact sha256 digests. Every record is
|
|
13
|
+
* passed through sanitizeForSupport (default-deny, second privacy
|
|
14
|
+
* boundary): rejections are drop-and-count, never repaired, never
|
|
15
|
+
* bypassed.
|
|
16
|
+
*
|
|
17
|
+
* Invariants:
|
|
18
|
+
* - operationIds are pseudonymized per call (bundle-local salted
|
|
19
|
+
* sha256, projector.js convention) — raw IDs never appear in output.
|
|
20
|
+
* - no writes to journals/runtime storage; no config reads.
|
|
21
|
+
* - corrupt journals and unreadable dirs degrade to typed coverage —
|
|
22
|
+
* never throw raw, never fabricate records.
|
|
23
|
+
* - bounds: maxRecords / maxBytes caps are drop-and-count.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { promises as fs } from 'node:fs';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import crypto from 'node:crypto';
|
|
29
|
+
|
|
30
|
+
import { readJournal } from './eventStore.js';
|
|
31
|
+
import { sanitizeForSupport } from '../observability/privacy/sanitizeForSupport.js';
|
|
32
|
+
import { REDACTION_VERSION } from '../observability/privacy/allowlist.js';
|
|
33
|
+
|
|
34
|
+
export const SUPPORT_RECORDS_VERSION = 'ukit-runtime-support/1';
|
|
35
|
+
|
|
36
|
+
const JOURNAL_DIR = 'events';
|
|
37
|
+
|
|
38
|
+
export const DEFAULT_CAPS = Object.freeze({
|
|
39
|
+
maxRecords: 2000,
|
|
40
|
+
maxBytes: 8 * 1024 * 1024,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
function sha256Hex(input) {
|
|
44
|
+
return crypto.createHash('sha256').update(input).digest('hex');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Bundle-local pseudonym: deterministic within one call, salted per call. */
|
|
48
|
+
function pseudonymizer(salt) {
|
|
49
|
+
const cache = new Map();
|
|
50
|
+
return (value) => {
|
|
51
|
+
let pseudo = cache.get(value);
|
|
52
|
+
if (!pseudo) {
|
|
53
|
+
pseudo = `ref-${sha256Hex(`${salt}:${value}`).slice(0, 16)}`;
|
|
54
|
+
cache.set(value, pseudo);
|
|
55
|
+
}
|
|
56
|
+
return pseudo;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function listOperationIds(dir) {
|
|
61
|
+
let names;
|
|
62
|
+
try {
|
|
63
|
+
names = await fs.readdir(path.join(dir, JOURNAL_DIR));
|
|
64
|
+
} catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
return names
|
|
68
|
+
.filter((n) => n.endsWith('.jsonl'))
|
|
69
|
+
.map((n) => n.slice(0, -'.jsonl'.length))
|
|
70
|
+
.sort();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Artifact digest refs only — `path` (a raw filesystem path) is never
|
|
75
|
+
* emitted. Object refs contribute `{name: stream, fingerprint: sha256}`;
|
|
76
|
+
* opaque string refs contribute a salted digest pseudonym.
|
|
77
|
+
*/
|
|
78
|
+
function artifactDigests(events, pseudo) {
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const event of events) {
|
|
81
|
+
for (const ref of event.artifactRefs ?? []) {
|
|
82
|
+
if (ref && typeof ref === 'object' && typeof ref.sha256 === 'string') {
|
|
83
|
+
out.push({
|
|
84
|
+
name: typeof ref.stream === 'string' ? ref.stream : 'artifact',
|
|
85
|
+
fingerprint: ref.sha256,
|
|
86
|
+
});
|
|
87
|
+
} else if (typeof ref === 'string' && ref.length > 0) {
|
|
88
|
+
out.push({ name: 'artifact', fingerprint: sha256Hex(`ref:${ref}`).slice(0, 32) });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out.slice(0, 32);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Summarize one operation journal into a support-view payload.
|
|
97
|
+
* Counts/codes/timings/digests only — no safePayload strings are copied
|
|
98
|
+
* except state names and typed code fields, which the support gate then
|
|
99
|
+
* re-verifies (default-deny).
|
|
100
|
+
*/
|
|
101
|
+
function summarize(events, pseudo) {
|
|
102
|
+
let status = 'unknown';
|
|
103
|
+
let firstAt = NaN;
|
|
104
|
+
let lastAt = NaN;
|
|
105
|
+
const typeCounts = new Map();
|
|
106
|
+
const errorCodes = new Set();
|
|
107
|
+
const reasonCodes = new Set();
|
|
108
|
+
|
|
109
|
+
for (const event of events) {
|
|
110
|
+
typeCounts.set(event.eventType, (typeCounts.get(event.eventType) ?? 0) + 1);
|
|
111
|
+
const t = Date.parse(event.observedAt);
|
|
112
|
+
if (!Number.isNaN(t)) {
|
|
113
|
+
if (Number.isNaN(firstAt) || t < firstAt) firstAt = t;
|
|
114
|
+
if (Number.isNaN(lastAt) || t > lastAt) lastAt = t;
|
|
115
|
+
}
|
|
116
|
+
const sp = event.safePayload;
|
|
117
|
+
if (sp && typeof sp === 'object') {
|
|
118
|
+
if (event.eventType === 'operation.transition' && typeof sp.to === 'string') {
|
|
119
|
+
status = sp.to;
|
|
120
|
+
}
|
|
121
|
+
if (typeof sp.code === 'string') errorCodes.add(sp.code);
|
|
122
|
+
if (typeof sp.resolution === 'string') reasonCodes.add(sp.resolution);
|
|
123
|
+
if (Number.isFinite(sp.exit_code) && sp.exit_code !== 0) {
|
|
124
|
+
errorCodes.add(`exit_${sp.exit_code}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const payload = {
|
|
130
|
+
status,
|
|
131
|
+
count: events.length,
|
|
132
|
+
duration_ms: Number.isNaN(firstAt) ? 0 : Math.max(0, lastAt - firstAt),
|
|
133
|
+
type: [...typeCounts.entries()]
|
|
134
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
135
|
+
.map(([name, count]) => ({ name, count })),
|
|
136
|
+
error_code: [...errorCodes].sort(),
|
|
137
|
+
reason_code: [...reasonCodes].sort(),
|
|
138
|
+
fingerprint: artifactDigests(events, pseudo),
|
|
139
|
+
};
|
|
140
|
+
return payload;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Build privacy-sanitized support records for every operation journal
|
|
145
|
+
* under `dir/events/`.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} dir eventStore root
|
|
148
|
+
* @param {object} [opts]
|
|
149
|
+
* @param {string} [opts.salt] pseudonym salt (default: random per call)
|
|
150
|
+
* @param {number} [opts.maxRecords] cap on emitted records (drop-and-count)
|
|
151
|
+
* @param {number} [opts.maxBytes] cap on total serialized record bytes
|
|
152
|
+
* @param {(text:string)=>{hasSecret:boolean}} [opts.scanner] gate scanner
|
|
153
|
+
* @returns {Promise<{version:string, records:object[],
|
|
154
|
+
* coverage:{read:number, rejected_records:number,
|
|
155
|
+
* rejection_reasons:object, dropped_records:number},
|
|
156
|
+
* redaction_version:string}>}
|
|
157
|
+
*/
|
|
158
|
+
export async function buildRuntimeSupportRecords(dir, opts = {}) {
|
|
159
|
+
const salt = typeof opts.salt === 'string' && opts.salt.length > 0
|
|
160
|
+
? opts.salt
|
|
161
|
+
: crypto.randomBytes(16).toString('hex');
|
|
162
|
+
const maxRecords = Number.isInteger(opts.maxRecords) && opts.maxRecords >= 0
|
|
163
|
+
? opts.maxRecords
|
|
164
|
+
: DEFAULT_CAPS.maxRecords;
|
|
165
|
+
const maxBytes = Number.isInteger(opts.maxBytes) && opts.maxBytes > 0
|
|
166
|
+
? opts.maxBytes
|
|
167
|
+
: DEFAULT_CAPS.maxBytes;
|
|
168
|
+
const gate = typeof opts.scanner === 'function'
|
|
169
|
+
? (rec) => sanitizeForSupport(rec, { scanner: opts.scanner })
|
|
170
|
+
: (rec) => sanitizeForSupport(rec);
|
|
171
|
+
const pseudo = pseudonymizer(salt);
|
|
172
|
+
|
|
173
|
+
const coverage = {
|
|
174
|
+
read: 0,
|
|
175
|
+
rejected_records: 0,
|
|
176
|
+
rejection_reasons: {},
|
|
177
|
+
dropped_records: 0,
|
|
178
|
+
};
|
|
179
|
+
const reject = (reason) => {
|
|
180
|
+
coverage.rejected_records += 1;
|
|
181
|
+
coverage.rejection_reasons[reason] =
|
|
182
|
+
(coverage.rejection_reasons[reason] ?? 0) + 1;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const records = [];
|
|
186
|
+
let bytes = 0;
|
|
187
|
+
let capped = false;
|
|
188
|
+
|
|
189
|
+
for (const operationId of await listOperationIds(dir)) {
|
|
190
|
+
coverage.read += 1;
|
|
191
|
+
|
|
192
|
+
const events = [];
|
|
193
|
+
try {
|
|
194
|
+
for await (const event of readJournal(dir, operationId)) {
|
|
195
|
+
events.push(event);
|
|
196
|
+
}
|
|
197
|
+
} catch (err) {
|
|
198
|
+
// typed store errors only; never propagate raw
|
|
199
|
+
reject(typeof err?.code === 'string' ? err.code : 'journal_unreadable');
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const record = {
|
|
204
|
+
record_type: 'operation_summary',
|
|
205
|
+
semantic_name: 'agentRuntime.operation.summary',
|
|
206
|
+
schema_version: '1',
|
|
207
|
+
record_id: pseudo(operationId),
|
|
208
|
+
sequence: events.length > 0 ? events[events.length - 1].seq : 0,
|
|
209
|
+
privacy_class: 'internal',
|
|
210
|
+
payload: summarize(events, pseudo),
|
|
211
|
+
origin: 'agentRuntime.eventStore',
|
|
212
|
+
redaction_version: REDACTION_VERSION,
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const verdict = gate(record);
|
|
216
|
+
if (!verdict.ok) {
|
|
217
|
+
reject(verdict.reason);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const size = Buffer.byteLength(JSON.stringify(verdict.record), 'utf8');
|
|
222
|
+
if (capped || records.length >= maxRecords || bytes + size > maxBytes) {
|
|
223
|
+
capped = true;
|
|
224
|
+
coverage.dropped_records += 1;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
records.push(verdict.record);
|
|
228
|
+
bytes += size;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
version: SUPPORT_RECORDS_VERSION,
|
|
233
|
+
records,
|
|
234
|
+
coverage,
|
|
235
|
+
redaction_version: REDACTION_VERSION,
|
|
236
|
+
};
|
|
237
|
+
}
|