@unson/brainbase-mcp 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,934 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { hostname } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { planApply } from './import-extract.js';
6
+ import { initializePersonalOs, loadPersonalOs, mutatePersonalOsWithSidecar } from './ssot.js';
7
+ const LEDGER_SCHEMA_VERSION = 'connected_onboarding.v1';
8
+ const SHA256 = /^sha256:[a-f0-9]{64}$/;
9
+ const MAX_LEDGER_BYTES = 1024 * 1024;
10
+ const MAX_ITEMS = 50;
11
+ const MAX_PERMISSION_BYTES = 8192;
12
+ const SECRET_KEY = /(?:^|_)(?:tokens?|access_?tokens?|refresh_?tokens?|id_?tokens?|oauth_?tokens?|api_?keys?|private_?keys?|client_?secrets?|secrets?|passwords?|credentials?|authorization|cookies?)(?:$|_)/i;
13
+ const RAW_CONTENT_KEY = /^(?:raw(?:_body|_content|_text)?|body|content|document|message_?body|answer)$/i;
14
+ const SECRET_VALUE = /(?:^|\s)Bearer\s+[A-Za-z0-9._~+/-]+=*|\bsk-[A-Za-z0-9_-]{16,}\b/i;
15
+ const SECRET_ASSIGNMENT = /(?:access[_-]?token|refresh[_-]?token|api[_-]?key|client[_-]?secret|secret|password|credential|authorization|cookie)\s*(?:=|:)\s*[^\s&#;,]+/i;
16
+ const URI_USERINFO = /^[a-z][a-z0-9+.-]*:\/\/[^/?#\s]*:[^@/?#\s]+@/i;
17
+ const PERMISSION_KEYS = new Set([
18
+ 'visibility', 'collectedBy', 'provider', 'connectionId', 'grantId', 'accountId',
19
+ 'folderId', 'projectCode', 'roleMin', 'sensitivity', 'scope', 'scopes', 'roles',
20
+ 'clearance', 'authorizedAt', 'expiresAt'
21
+ ]);
22
+ const WORKFLOW_STATES = [
23
+ 'initialized',
24
+ 'source_ready',
25
+ 'candidates_ready',
26
+ 'promotion_reviewed',
27
+ 'first_value_ready',
28
+ 'first_value_answer_reviewed'
29
+ ];
30
+ export class ConnectedOnboardingError extends Error {
31
+ code;
32
+ constructor(code, message) {
33
+ super(message);
34
+ this.code = code;
35
+ this.name = 'ConnectedOnboardingError';
36
+ }
37
+ }
38
+ export class ConnectedOnboardingRuntime {
39
+ dataDir;
40
+ now;
41
+ idFactory;
42
+ constructor(dataDir, options = {}) {
43
+ this.dataDir = dataDir;
44
+ this.now = options.now ?? (() => new Date());
45
+ this.idFactory = options.idFactory ?? (() => `onb_${randomUUID()}`);
46
+ }
47
+ async start(input) {
48
+ assertSafePersistentValue(input);
49
+ const valueTarget = requireString(input.valueTarget, 'valueTarget', 500);
50
+ const sources = normalizeSourceInventory(input.sources);
51
+ await initializePersonalOs(this.dataDir);
52
+ const readyPrimary = sources.filter((source) => source.status === 'ready' && source.mode !== 'single_document');
53
+ const readyFallback = sources.filter((source) => source.status === 'ready' && source.mode === 'single_document');
54
+ const path = readyPrimary.length > 0 ? 'warm' : readyFallback.length > 0 ? 'fallback' : 'blocked';
55
+ const selectedSourceIds = path === 'warm'
56
+ ? [readyPrimary[0].id]
57
+ : path === 'fallback' ? [readyFallback[0].id] : [];
58
+ const timestamp = this.timestamp();
59
+ const run = {
60
+ id: this.idFactory(),
61
+ valueTarget,
62
+ path,
63
+ state: path === 'blocked' ? 'initialized' : 'source_ready',
64
+ startedAt: timestamp,
65
+ updatedAt: timestamp,
66
+ sourceReadyAt: path === 'blocked' ? undefined : timestamp,
67
+ sources,
68
+ selectedSourceIds,
69
+ receipts: [],
70
+ candidates: [],
71
+ promotedCanonicalIds: []
72
+ };
73
+ return this.mutateLedger((ledger) => {
74
+ if (ledger.runs.some((item) => item.id === run.id)) {
75
+ throw new ConnectedOnboardingError('run_conflict', `onboarding run already exists: ${run.id}`);
76
+ }
77
+ ledger.runs.push(run);
78
+ return clone(run);
79
+ });
80
+ }
81
+ async get(runId) {
82
+ await initializePersonalOs(this.dataDir);
83
+ return withLedgerLock(this.dataDir, async () => {
84
+ const ledger = await loadLedger(this.dataDir);
85
+ const os = await loadPersonalOs(this.dataDir);
86
+ assertLedgerCanonicalReferences(ledger, os);
87
+ const run = requireRun(ledger, runId);
88
+ return clone(run);
89
+ });
90
+ }
91
+ async ingest(runId, input) {
92
+ assertSafePersistentValue(input);
93
+ return this.mutateLedger((ledger) => {
94
+ const run = requireRun(ledger, runId);
95
+ if (run.path === 'blocked') {
96
+ throw new ConnectedOnboardingError('source_not_ready', 'onboarding run is not ready because no source is available');
97
+ }
98
+ const sourceId = requireString(input.source?.sourceId, 'source.sourceId', 1000);
99
+ const source = run.sources.find((item) => item.id === sourceId);
100
+ if (!source || source.status !== 'ready' || !run.selectedSourceIds.includes(sourceId)) {
101
+ throw new ConnectedOnboardingError('source_not_ready', `source '${sourceId}' is not ready or selected`);
102
+ }
103
+ const receipt = normalizeReceipt(source, input.source, this.timestamp());
104
+ const existingReceipt = run.receipts.find((item) => item.sourceId === sourceId);
105
+ if (existingReceipt && receiptIdentity(existingReceipt) !== receiptIdentity(receipt)) {
106
+ throw new ConnectedOnboardingError('source_receipt_conflict', 'source identity cannot be changed after ingestion');
107
+ }
108
+ const candidates = normalizeCandidates(run.id, sourceId, input.candidates);
109
+ const existingById = new Map(run.candidates.map((candidate) => [candidate.id, candidate]));
110
+ const existingByEvidence = new Map(run.candidates.map((candidate) => [`${candidate.sourceId}\0${candidate.evidenceId}`, candidate]));
111
+ for (const candidate of candidates) {
112
+ const existing = existingByEvidence.get(`${candidate.sourceId}\0${candidate.evidenceId}`);
113
+ if (existing && candidateIdentity(existing) !== candidateIdentity(candidate)) {
114
+ throw new ConnectedOnboardingError('candidate_retry_conflict', 'candidate evidence identity already has a different payload');
115
+ }
116
+ }
117
+ if (!existingReceipt)
118
+ run.receipts.push(receipt);
119
+ for (const candidate of candidates) {
120
+ if (!existingById.has(candidate.id))
121
+ run.candidates.push(candidate);
122
+ }
123
+ const storedReceipt = run.receipts.find((item) => item.sourceId === sourceId);
124
+ storedReceipt.candidateIds = unique([...storedReceipt.candidateIds, ...candidates.map((candidate) => candidate.id)]);
125
+ run.state = advanceState(run.state, run.candidates.length > 0 ? 'candidates_ready' : 'source_ready');
126
+ run.updatedAt = this.timestamp();
127
+ return clone(run);
128
+ });
129
+ }
130
+ async review(runId, actions) {
131
+ assertSafePersistentValue(actions);
132
+ if (!Array.isArray(actions) || actions.length === 0 || actions.length > MAX_ITEMS) {
133
+ throw new ConnectedOnboardingError('input_invalid', `review actions must contain 1..${MAX_ITEMS} items`);
134
+ }
135
+ await initializePersonalOs(this.dataDir);
136
+ return withLedgerLock(this.dataDir, async () => {
137
+ const ledger = await loadLedger(this.dataDir);
138
+ return mutatePersonalOsWithSidecar(this.dataDir, 'runs/connected-onboarding.json', (os) => {
139
+ assertLedgerCanonicalReferences(ledger, os);
140
+ const run = requireRun(ledger, runId);
141
+ const prepared = prepareReview(run, actions);
142
+ if (prepared.promotions.length === 0 && prepared.updates.length === 0) {
143
+ return { next: os, sidecarContent: serializeLedger(ledger), result: clone(run) };
144
+ }
145
+ const result = planApply(prepared.promotions, { ids: new Set(), all: true }, {
146
+ graphEntities: [...os.graph.entities],
147
+ relationships: [...os.relationships.relationships],
148
+ personalKg: os.personalKg,
149
+ decisions: os.decisions,
150
+ ownerName: os.graph.owner?.name
151
+ }, this.timestamp());
152
+ if (result.skipped.length > 0 || result.applied.length !== prepared.promotions.length) {
153
+ throw new ConnectedOnboardingError('candidate_not_promotable', result.skipped[0]?.reason ?? 'candidate was not promoted');
154
+ }
155
+ const applied = result.applied;
156
+ for (const item of applied) {
157
+ const candidate = run.candidates.find((entry) => entry.id === item.id);
158
+ candidate.promotedCanonicalIds = unique([...candidate.promotedCanonicalIds, ...item.canonicalIds]);
159
+ }
160
+ for (const update of prepared.updates) {
161
+ const candidate = run.candidates.find((item) => item.id === update.candidateId);
162
+ Object.assign(candidate, update.patch);
163
+ }
164
+ run.promotedCanonicalIds = unique(run.candidates.flatMap((candidate) => candidate.promotedCanonicalIds));
165
+ run.state = advanceState(run.state, 'promotion_reviewed');
166
+ run.updatedAt = this.timestamp();
167
+ const sidecarContent = serializeLedger(ledger);
168
+ return {
169
+ next: {
170
+ ...os,
171
+ graph: {
172
+ ...os.graph,
173
+ owner: result.ownerName ? { ...os.graph.owner, name: result.ownerName } : os.graph.owner,
174
+ entities: result.graphEntities
175
+ },
176
+ relationships: { version: 1, relationships: result.relationships },
177
+ personalKg: mergeById(os.personalKg, result.personalKgAdditions),
178
+ decisions: mergeById(os.decisions, result.decisionAdditions)
179
+ },
180
+ sidecarContent,
181
+ result: clone(run)
182
+ };
183
+ });
184
+ });
185
+ }
186
+ async firstValue(runId, input) {
187
+ assertSafePersistentValue(input);
188
+ return this.mutateLedger(async (ledger) => {
189
+ const run = requireRun(ledger, runId);
190
+ assertCanonicalReferences(run, await loadPersonalOs(this.dataDir));
191
+ if (input.action === 'record') {
192
+ if (!SHA256.test(input.answerHash)) {
193
+ throw new ConnectedOnboardingError('input_invalid', 'answerHash must be sha256:<hex>');
194
+ }
195
+ const usedIds = unique(boundedStrings(input.usedCanonicalIds, 'usedCanonicalIds', 200));
196
+ if (usedIds.length === 0)
197
+ throw new ConnectedOnboardingError('input_invalid', 'usedCanonicalIds must not be empty');
198
+ if (usedIds.some((id) => !run.promotedCanonicalIds.includes(id))) {
199
+ throw new ConnectedOnboardingError('unpromoted_reference', 'first value references an id not promoted by this run');
200
+ }
201
+ const nextReceipt = {
202
+ answerHash: input.answerHash,
203
+ usedCanonicalIds: usedIds,
204
+ missingContext: boundedStrings(input.missingContext ?? [], 'missingContext', 200),
205
+ recordedAt: this.timestamp()
206
+ };
207
+ if (run.firstValueReceipt) {
208
+ const sameReceipt = run.firstValueReceipt.answerHash === nextReceipt.answerHash
209
+ && stableJson(run.firstValueReceipt.usedCanonicalIds) === stableJson(nextReceipt.usedCanonicalIds)
210
+ && stableJson(run.firstValueReceipt.missingContext) === stableJson(nextReceipt.missingContext);
211
+ if (sameReceipt)
212
+ return clone(run);
213
+ throw new ConnectedOnboardingError('first_value_terminal', 'first value receipt is already recorded');
214
+ }
215
+ run.firstValueReceipt = nextReceipt;
216
+ run.state = advanceState(run.state, 'first_value_ready');
217
+ }
218
+ else {
219
+ if (!run.firstValueReceipt || !run.sourceReadyAt) {
220
+ throw new ConnectedOnboardingError('first_value_not_ready', 'first value must be recorded before review');
221
+ }
222
+ const missingContext = boundedStrings(input.missingContext ?? [], 'missingContext', 200);
223
+ if (run.firstValueReview) {
224
+ const sameReview = run.firstValueReview.verdict === input.verdict
225
+ && stableJson(run.firstValueReview.missingContext) === stableJson(missingContext);
226
+ if (sameReview)
227
+ return clone(run);
228
+ throw new ConnectedOnboardingError('first_value_terminal', 'first value review is already completed');
229
+ }
230
+ const reviewedAt = this.timestamp();
231
+ const elapsedSeconds = Math.max(0, Math.floor((Date.parse(reviewedAt) - Date.parse(run.sourceReadyAt)) / 1000));
232
+ run.firstValueReview = {
233
+ verdict: input.verdict,
234
+ missingContext,
235
+ reviewedAt,
236
+ elapsedSeconds,
237
+ withinTargetSeconds: elapsedSeconds <= 600
238
+ };
239
+ run.state = 'first_value_answer_reviewed';
240
+ }
241
+ run.updatedAt = this.timestamp();
242
+ return clone(run);
243
+ });
244
+ }
245
+ timestamp() {
246
+ return this.now().toISOString();
247
+ }
248
+ async readLedger() {
249
+ await initializePersonalOs(this.dataDir);
250
+ return withLedgerLock(this.dataDir, () => loadLedger(this.dataDir));
251
+ }
252
+ async mutateLedger(mutator) {
253
+ await initializePersonalOs(this.dataDir);
254
+ return withLedgerLock(this.dataDir, async () => {
255
+ const ledger = await loadLedger(this.dataDir);
256
+ const os = await loadPersonalOs(this.dataDir);
257
+ assertLedgerCanonicalReferences(ledger, os);
258
+ const result = await mutator(ledger);
259
+ await persistLedger(this.dataDir, ledger);
260
+ return result;
261
+ });
262
+ }
263
+ }
264
+ export function normalizeSourceInventory(raw) {
265
+ if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_ITEMS) {
266
+ throw new ConnectedOnboardingError('input_invalid', `sources must contain 1..${MAX_ITEMS} items`);
267
+ }
268
+ const seen = new Set();
269
+ return raw.map((source, index) => {
270
+ const id = requireString(source?.id, `sources.${index}.id`, 500);
271
+ if (seen.has(id))
272
+ throw new ConnectedOnboardingError('input_invalid', `duplicate source id: ${id}`);
273
+ seen.add(id);
274
+ if (!['mcp', 'drive', 'gmail', 'local_folder', 'single_document'].includes(source.mode)) {
275
+ throw new ConnectedOnboardingError('input_invalid', `unsupported source mode: ${source.mode}`);
276
+ }
277
+ if (!['ready', 'waiting_for_authorization', 'unavailable', 'error', 'unconfirmed'].includes(source.status)) {
278
+ throw new ConnectedOnboardingError('input_invalid', `unsupported source status: ${source.status}`);
279
+ }
280
+ return {
281
+ id,
282
+ mode: source.mode,
283
+ status: source.status,
284
+ evidencePointer: optionalString(source.evidencePointer, `sources.${index}.evidencePointer`, 1000),
285
+ permissionScope: unique(boundedStrings(source.permissionScope ?? [], `sources.${index}.permissionScope`, 500)),
286
+ detail: optionalString(source.detail, `sources.${index}.detail`, 500)
287
+ };
288
+ });
289
+ }
290
+ export function assertSafePersistentValue(value, trail = []) {
291
+ if (typeof value === 'string') {
292
+ let candidate = value.trim();
293
+ for (let pass = 0; pass < 8; pass += 1) {
294
+ if (SECRET_VALUE.test(candidate) || SECRET_ASSIGNMENT.test(candidate) || URI_USERINFO.test(candidate)) {
295
+ throw new ConnectedOnboardingError('secret_or_raw_content_rejected', `secret-like value rejected at ${trail.join('.') || 'input'}`);
296
+ }
297
+ const decoded = candidate.replaceAll('+', ' ').replace(/%([0-9a-f]{2})/gi, (_match, hex) => String.fromCharCode(Number.parseInt(hex, 16))).trim();
298
+ if (decoded === candidate)
299
+ return;
300
+ candidate = decoded;
301
+ }
302
+ throw new ConnectedOnboardingError('secret_or_raw_content_rejected', `over-encoded value rejected at ${trail.join('.') || 'input'}`);
303
+ }
304
+ if (Array.isArray(value)) {
305
+ if (value.length > MAX_ITEMS)
306
+ throw new ConnectedOnboardingError('input_invalid', `${trail.join('.')} exceeds ${MAX_ITEMS} items`);
307
+ value.forEach((item, index) => assertSafePersistentValue(item, [...trail, String(index)]));
308
+ return;
309
+ }
310
+ if (!value || typeof value !== 'object')
311
+ return;
312
+ for (const [key, child] of Object.entries(value)) {
313
+ if (isSecretKey(key) || RAW_CONTENT_KEY.test(key)) {
314
+ throw new ConnectedOnboardingError('secret_or_raw_content_rejected', `forbidden field rejected at ${[...trail, key].join('.')}`);
315
+ }
316
+ assertSafePersistentValue(child, [...trail, key]);
317
+ }
318
+ }
319
+ function isSecretKey(key) {
320
+ const normalized = key
321
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
322
+ .replace(/[^a-z0-9]+/gi, '_')
323
+ .toLowerCase();
324
+ return SECRET_KEY.test(normalized);
325
+ }
326
+ function normalizeReceipt(source, input, collectedAt) {
327
+ if (input.collectionStatus !== 'collected')
328
+ throw new ConnectedOnboardingError('source_unavailable', 'source was not collected');
329
+ const evidencePointer = requireString(input.evidencePointer, 'source.evidencePointer', 1000);
330
+ if (source.evidencePointer && source.evidencePointer !== evidencePointer) {
331
+ throw new ConnectedOnboardingError('source_receipt_conflict', 'receipt pointer differs from inventory pointer');
332
+ }
333
+ if (!SHA256.test(input.contentHash))
334
+ throw new ConnectedOnboardingError('input_invalid', 'contentHash must be sha256:<hex>');
335
+ const permissionSnapshot = validatePermissionSnapshot(input.permissionSnapshot, source.permissionScope);
336
+ return {
337
+ sourceId: source.id,
338
+ mode: source.mode,
339
+ evidencePointer,
340
+ contentHash: input.contentHash,
341
+ permissionSnapshot,
342
+ collectionStatus: 'collected',
343
+ collectedAt,
344
+ candidateIds: []
345
+ };
346
+ }
347
+ function validatePermissionSnapshot(value, allowedScope) {
348
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
349
+ throw new ConnectedOnboardingError('permission_snapshot_invalid', 'permissionSnapshot must be an object');
350
+ }
351
+ assertSafePersistentValue(value, ['permissionSnapshot']);
352
+ const forbidden = Object.keys(value).find((key) => !PERMISSION_KEYS.has(key));
353
+ if (forbidden)
354
+ throw new ConnectedOnboardingError('permission_snapshot_invalid', `permissionSnapshot field is not allowed: ${forbidden}`);
355
+ if (Buffer.byteLength(JSON.stringify(value), 'utf8') > MAX_PERMISSION_BYTES) {
356
+ throw new ConnectedOnboardingError('permission_snapshot_invalid', 'permissionSnapshot is too large');
357
+ }
358
+ for (const [key, child] of Object.entries(value)) {
359
+ const scalar = child === null || ['string', 'number', 'boolean'].includes(typeof child);
360
+ const array = Array.isArray(child) && child.length <= MAX_ITEMS && child.every((item) => typeof item === 'string' && item.length <= 500);
361
+ if (!scalar && !array)
362
+ throw new ConnectedOnboardingError('permission_snapshot_invalid', `permissionSnapshot.${key} must be scalar or bounded strings`);
363
+ }
364
+ const authorityKeys = ['grantId', 'accountId', 'folderId', 'projectCode', 'roleMin', 'scope', 'scopes', 'roles', 'clearance'];
365
+ for (const key of authorityKeys) {
366
+ const child = value[key];
367
+ if (child !== undefined && child !== null
368
+ && typeof child !== 'string'
369
+ && !(Array.isArray(child) && child.every((item) => typeof item === 'string'))) {
370
+ throw new ConnectedOnboardingError('permission_snapshot_invalid', `permissionSnapshot.${key} must be a string or bounded strings`);
371
+ }
372
+ }
373
+ const authorities = authorityKeys.flatMap((key) => {
374
+ const child = value[key];
375
+ return (Array.isArray(child) ? child : child === undefined || child === null ? [] : [child])
376
+ .filter((item) => typeof item === 'string')
377
+ .map((item) => ({ key, item }));
378
+ });
379
+ if (authorities.some(({ key, item }) => !allowedScope.includes(item) && !allowedScope.includes(`${key}:${item}`))) {
380
+ throw new ConnectedOnboardingError('permission_scope_denied', 'permissionSnapshot exceeds the selected source scope');
381
+ }
382
+ return clone(value);
383
+ }
384
+ function normalizeCandidates(runId, sourceId, raw) {
385
+ if (!Array.isArray(raw) || raw.length > MAX_ITEMS)
386
+ throw new ConnectedOnboardingError('input_invalid', `candidates must contain at most ${MAX_ITEMS} items`);
387
+ const candidates = raw.map((candidate, index) => {
388
+ const evidenceId = requireString(candidate.evidenceId, `candidates.${index}.evidenceId`, 1000);
389
+ if (!['observed', 'inferred'].includes(candidate.observationClass))
390
+ throw new ConnectedOnboardingError('input_invalid', 'unsupported observationClass');
391
+ if (!candidate.payload || typeof candidate.payload !== 'object' || Array.isArray(candidate.payload))
392
+ throw new ConnectedOnboardingError('input_invalid', 'candidate payload must be an object');
393
+ const ingestedPayloadHash = sha256(stableJson(candidate.payload));
394
+ const normalized = {
395
+ id: stableId('onb_cand', runId, sourceId, evidenceId, candidate.kind, ingestedPayloadHash),
396
+ ingestedPayloadHash,
397
+ sourceId,
398
+ kind: requireString(candidate.kind, `candidates.${index}.kind`, 100),
399
+ payload: clone(candidate.payload),
400
+ observationClass: candidate.observationClass,
401
+ evidenceId,
402
+ reviewStatus: 'pending',
403
+ promotedCanonicalIds: []
404
+ };
405
+ assertPromotable(normalized);
406
+ return normalized;
407
+ });
408
+ if (new Set(candidates.map((candidate) => candidate.id)).size !== candidates.length) {
409
+ throw new ConnectedOnboardingError('input_invalid', 'candidate evidenceId values must be unique within a source batch');
410
+ }
411
+ return candidates;
412
+ }
413
+ function prepareReview(run, actions) {
414
+ actions.forEach(validateReviewActionShape);
415
+ if (new Set(actions.map((action) => action.candidateId)).size !== actions.length) {
416
+ throw new ConnectedOnboardingError('input_invalid', 'each candidate may appear only once in a review batch');
417
+ }
418
+ const directCandidateIds = new Set(actions.map((action) => action.candidateId));
419
+ const mergeTargets = actions
420
+ .filter((action) => action.decision === 'merge')
421
+ .map((action) => action.mergeIntoCandidateId);
422
+ if (mergeTargets.some((targetId) => directCandidateIds.has(targetId))) {
423
+ throw new ConnectedOnboardingError('review_batch_conflict', 'a merge target cannot also have a direct action in the same review batch');
424
+ }
425
+ if (new Set(mergeTargets).size !== mergeTargets.length) {
426
+ throw new ConnectedOnboardingError('review_batch_conflict', 'a merge target may be used only once in a review batch');
427
+ }
428
+ const promotions = new Map();
429
+ const updates = [];
430
+ for (const action of actions) {
431
+ const candidate = run.candidates.find((item) => item.id === action.candidateId);
432
+ if (!candidate)
433
+ throw new ConnectedOnboardingError('candidate_not_found', `candidate does not belong to run: ${action.candidateId}`);
434
+ const reason = requireString(action.reason, 'review.reason', 500);
435
+ if (candidate.reviewStatus !== 'pending') {
436
+ if (isSameTerminalReview(candidate, action, reason))
437
+ continue;
438
+ throw new ConnectedOnboardingError('candidate_terminal', `candidate is already ${candidate.reviewStatus}`);
439
+ }
440
+ if (action.decision === 'approve') {
441
+ if (candidate.observationClass === 'inferred') {
442
+ throw new ConnectedOnboardingError('inferred_not_promotable', 'inferred candidates cannot be approved; review the source, then use decision "edit" with a human-confirmed payload, or use "reject"');
443
+ }
444
+ promotions.set(candidate.id, toApplyCandidate(candidate));
445
+ updates.push({ candidateId: candidate.id, patch: { reviewStatus: 'approved', reviewDecision: 'approve', reviewReason: reason } });
446
+ }
447
+ else if (action.decision === 'edit') {
448
+ if (!action.payload || typeof action.payload !== 'object' || Array.isArray(action.payload))
449
+ throw new ConnectedOnboardingError('input_invalid', 'edit requires payload');
450
+ assertSafePersistentValue(action.payload, ['review', 'payload']);
451
+ const edited = { ...candidate, payload: clone(action.payload), observationClass: 'observed' };
452
+ assertPromotable(edited);
453
+ promotions.set(candidate.id, toApplyCandidate(edited));
454
+ updates.push({ candidateId: candidate.id, patch: { payload: clone(action.payload), observationClass: 'observed', reviewStatus: 'approved', reviewDecision: 'edit', reviewReason: reason } });
455
+ }
456
+ else if (action.decision === 'reject') {
457
+ updates.push({ candidateId: candidate.id, patch: { reviewStatus: 'rejected', reviewDecision: 'reject', reviewReason: reason, promotedCanonicalIds: [] } });
458
+ }
459
+ else if (action.decision === 'merge') {
460
+ if (candidate.observationClass === 'inferred')
461
+ throw new ConnectedOnboardingError('inferred_not_promotable', 'inferred candidates cannot be merged');
462
+ const targetId = requireString(action.mergeIntoCandidateId, 'mergeIntoCandidateId', 200);
463
+ const target = run.candidates.find((item) => item.id === targetId);
464
+ if (!target || target.id === candidate.id)
465
+ throw new ConnectedOnboardingError('merge_target_invalid', 'merge target must be another candidate in this run');
466
+ if (target.observationClass === 'inferred')
467
+ throw new ConnectedOnboardingError('inferred_not_promotable', 'inferred merge target cannot be approved');
468
+ if (target.kind !== candidate.kind)
469
+ throw new ConnectedOnboardingError('merge_target_invalid', 'merge candidates must have the same kind');
470
+ if (target.reviewStatus !== 'pending')
471
+ throw new ConnectedOnboardingError('merge_target_invalid', 'merge target must still be pending');
472
+ const merged = { ...target, payload: { ...candidate.payload, ...target.payload } };
473
+ assertPromotable(merged);
474
+ promotions.set(target.id, toApplyCandidate(merged));
475
+ updates.push({ candidateId: candidate.id, patch: { reviewStatus: 'merged', reviewDecision: 'merge', reviewReason: reason, mergedIntoCandidateId: target.id, promotedCanonicalIds: [] } });
476
+ updates.push({ candidateId: target.id, patch: { payload: merged.payload, reviewStatus: 'approved', reviewDecision: 'merge', reviewReason: reason } });
477
+ }
478
+ else {
479
+ throw new ConnectedOnboardingError('input_invalid', `unsupported review decision: ${String(action.decision)}`);
480
+ }
481
+ }
482
+ return { promotions: [...promotions.values()], updates };
483
+ }
484
+ function validateReviewActionShape(action) {
485
+ if (!isRecord(action))
486
+ throw new ConnectedOnboardingError('input_invalid', 'review action must be an object');
487
+ requireString(action.candidateId, 'review.candidateId', 200);
488
+ requireString(action.reason, 'review.reason', 500);
489
+ if (!['approve', 'edit', 'reject', 'merge'].includes(action.decision)) {
490
+ throw new ConnectedOnboardingError('input_invalid', `unsupported review decision: ${String(action.decision)}`);
491
+ }
492
+ if (action.decision === 'edit') {
493
+ if (!hasOnlyKeys(action, ['candidateId', 'decision', 'reason', 'payload'])
494
+ || !action.payload || !isRecord(action.payload) || action.mergeIntoCandidateId !== undefined) {
495
+ throw new ConnectedOnboardingError('input_invalid', 'edit requires payload and forbids mergeIntoCandidateId');
496
+ }
497
+ return;
498
+ }
499
+ if (action.decision === 'merge') {
500
+ requireString(action.mergeIntoCandidateId, 'mergeIntoCandidateId', 200);
501
+ if (!hasOnlyKeys(action, ['candidateId', 'decision', 'reason', 'mergeIntoCandidateId']) || action.payload !== undefined) {
502
+ throw new ConnectedOnboardingError('input_invalid', 'merge forbids payload and extra fields');
503
+ }
504
+ return;
505
+ }
506
+ if (!hasOnlyKeys(action, ['candidateId', 'decision', 'reason']) || action.payload !== undefined || action.mergeIntoCandidateId !== undefined) {
507
+ throw new ConnectedOnboardingError('input_invalid', `${action.decision} forbids payload and mergeIntoCandidateId`);
508
+ }
509
+ }
510
+ function isSameTerminalReview(candidate, action, reason) {
511
+ if (candidate.reviewDecision !== action.decision || candidate.reviewReason !== reason)
512
+ return false;
513
+ if (action.decision === 'edit') {
514
+ return action.payload !== undefined && stableJson(candidate.payload) === stableJson(action.payload);
515
+ }
516
+ if (action.decision === 'merge')
517
+ return candidate.mergedIntoCandidateId === action.mergeIntoCandidateId;
518
+ return action.payload === undefined && action.mergeIntoCandidateId === undefined;
519
+ }
520
+ function assertPromotable(candidate) {
521
+ const result = planApply([toApplyCandidate(candidate)], { ids: new Set(), all: true }, {
522
+ graphEntities: [], relationships: [], personalKg: [], decisions: []
523
+ }, '2000-01-01T00:00:00.000Z');
524
+ if (result.skipped.length > 0 || result.applied.length !== 1) {
525
+ throw new ConnectedOnboardingError('candidate_not_promotable', result.skipped[0]?.reason ?? 'candidate is not promotable');
526
+ }
527
+ }
528
+ function toApplyCandidate(candidate) {
529
+ return { id: candidate.id, kind: candidate.kind, payload: candidate.payload };
530
+ }
531
+ function advanceState(current, target) {
532
+ return WORKFLOW_STATES.indexOf(current) >= WORKFLOW_STATES.indexOf(target) ? current : target;
533
+ }
534
+ function stableId(prefix, ...parts) {
535
+ const digest = createHash('sha256').update(parts.join('\0')).digest('hex');
536
+ return `${prefix}_${digest.slice(0, 32)}`;
537
+ }
538
+ function sha256(value) {
539
+ return `sha256:${createHash('sha256').update(value).digest('hex')}`;
540
+ }
541
+ function receiptIdentity(receipt) {
542
+ return stableJson({
543
+ sourceId: receipt.sourceId,
544
+ mode: receipt.mode,
545
+ evidencePointer: receipt.evidencePointer,
546
+ contentHash: receipt.contentHash,
547
+ permissionSnapshot: receipt.permissionSnapshot,
548
+ collectionStatus: receipt.collectionStatus
549
+ });
550
+ }
551
+ function candidateIdentity(candidate) {
552
+ return stableJson({
553
+ id: candidate.id,
554
+ ingestedPayloadHash: candidate.ingestedPayloadHash,
555
+ sourceId: candidate.sourceId,
556
+ kind: candidate.kind,
557
+ payload: candidate.payload,
558
+ observationClass: candidate.observationClass,
559
+ evidenceId: candidate.evidenceId
560
+ });
561
+ }
562
+ function stableJson(value) {
563
+ return JSON.stringify(canonicalize(value));
564
+ }
565
+ function canonicalize(value) {
566
+ if (Array.isArray(value))
567
+ return value.map(canonicalize);
568
+ if (value && typeof value === 'object') {
569
+ return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, child]) => [key, canonicalize(child)]));
570
+ }
571
+ return value;
572
+ }
573
+ function requireRun(ledger, runId) {
574
+ const id = requireString(runId, 'runId', 200);
575
+ const run = ledger.runs.find((item) => item.id === id);
576
+ if (!run)
577
+ throw new ConnectedOnboardingError('run_not_found', `onboarding run not found: ${id}`);
578
+ return run;
579
+ }
580
+ function requireString(value, field, max) {
581
+ if (typeof value !== 'string' || value.trim().length === 0 || value.length > max) {
582
+ throw new ConnectedOnboardingError('input_invalid', `${field} must be 1..${max} characters`);
583
+ }
584
+ return value.trim();
585
+ }
586
+ function optionalString(value, field, max) {
587
+ return value === undefined ? undefined : requireString(value, field, max);
588
+ }
589
+ function boundedStrings(value, field, maxLength) {
590
+ if (!Array.isArray(value) || value.length > MAX_ITEMS)
591
+ throw new ConnectedOnboardingError('input_invalid', `${field} must contain at most ${MAX_ITEMS} items`);
592
+ return value.map((item, index) => requireString(item, `${field}.${index}`, maxLength));
593
+ }
594
+ function unique(items) {
595
+ return [...new Set(items)];
596
+ }
597
+ function mergeById(current, additions) {
598
+ const byId = new Map(current.map((item) => [item.id, item]));
599
+ for (const item of additions)
600
+ byId.set(item.id, item);
601
+ return [...byId.values()];
602
+ }
603
+ function clone(value) {
604
+ return structuredClone(value);
605
+ }
606
+ function ledgerPath(dataDir) {
607
+ return join(dataDir, 'runs', 'connected-onboarding.json');
608
+ }
609
+ async function loadLedger(dataDir) {
610
+ try {
611
+ const parsed = JSON.parse(await readFile(ledgerPath(dataDir), 'utf8'));
612
+ if (parsed.schemaVersion !== LEDGER_SCHEMA_VERSION || !Array.isArray(parsed.runs)) {
613
+ throw new ConnectedOnboardingError('ledger_schema_unsupported', `unsupported connected onboarding ledger schema: ${String(parsed.schemaVersion ?? 'missing')}`);
614
+ }
615
+ assertSafePersistentValue(parsed);
616
+ validateLedgerRuns(parsed.runs);
617
+ return parsed;
618
+ }
619
+ catch (error) {
620
+ if (error.code === 'ENOENT')
621
+ return { schemaVersion: LEDGER_SCHEMA_VERSION, runs: [] };
622
+ throw error;
623
+ }
624
+ }
625
+ function validateLedgerRuns(runs) {
626
+ const runIds = new Set();
627
+ for (const run of runs) {
628
+ if (!isRecord(run)
629
+ || !hasOnlyKeys(run, ['id', 'valueTarget', 'path', 'state', 'startedAt', 'updatedAt', 'sourceReadyAt', 'sources', 'selectedSourceIds', 'receipts', 'candidates', 'promotedCanonicalIds', 'firstValueReceipt', 'firstValueReview'])
630
+ || !isBoundedString(run.id, 200) || runIds.has(run.id)
631
+ || !isBoundedString(run.valueTarget, 500)
632
+ || !['warm', 'fallback', 'blocked'].includes(String(run.path))
633
+ || !WORKFLOW_STATES.includes(run.state)
634
+ || !isIsoTimestamp(run.startedAt) || !isIsoTimestamp(run.updatedAt)
635
+ || (run.sourceReadyAt !== undefined && !isIsoTimestamp(run.sourceReadyAt))
636
+ || !Array.isArray(run.sources) || run.sources.length === 0 || run.sources.length > MAX_ITEMS
637
+ || !isBoundedStringArray(run.selectedSourceIds, 500)
638
+ || !Array.isArray(run.receipts) || run.receipts.length > MAX_ITEMS
639
+ || !Array.isArray(run.candidates) || run.candidates.length > MAX_ITEMS
640
+ || !isBoundedStringArray(run.promotedCanonicalIds, 200))
641
+ throwInvalidLedger();
642
+ runIds.add(run.id);
643
+ const sources = run.sources;
644
+ if (!sources.every(isValidLedgerSource))
645
+ throwInvalidLedger();
646
+ const sourceIds = new Set(sources.map((source) => source.id));
647
+ const sourceById = new Map(sources.map((source) => {
648
+ const item = source;
649
+ return [item.id, item];
650
+ }));
651
+ if (sourceIds.size !== sources.length || !run.selectedSourceIds.every((id) => sourceIds.has(id)))
652
+ throwInvalidLedger();
653
+ const candidates = run.candidates;
654
+ if (!candidates.every((candidate) => isValidLedgerCandidate(candidate, run.id, sourceIds)))
655
+ throwInvalidLedger();
656
+ const candidateIds = new Set(candidates.map((candidate) => candidate.id));
657
+ if (candidateIds.size !== candidates.length)
658
+ throwInvalidLedger();
659
+ if (candidates.some((candidate) => {
660
+ const targetId = candidate.mergedIntoCandidateId;
661
+ return typeof targetId === 'string' && !candidateIds.has(targetId);
662
+ }))
663
+ throwInvalidLedger();
664
+ const candidateById = new Map(candidates.map((candidate) => {
665
+ const item = candidate;
666
+ return [item.id, item];
667
+ }));
668
+ const mergeSourcesByTarget = new Map();
669
+ for (const candidate of candidates) {
670
+ const item = candidate;
671
+ if (item.reviewStatus !== 'merged')
672
+ continue;
673
+ const targetId = item.mergedIntoCandidateId;
674
+ const target = candidateById.get(targetId);
675
+ if (!target
676
+ || targetId === item.id
677
+ || item.observationClass !== 'observed'
678
+ || item.kind !== target.kind
679
+ || target.reviewStatus !== 'approved'
680
+ || target.reviewDecision !== 'merge'
681
+ || target.observationClass !== 'observed'
682
+ || target.mergedIntoCandidateId !== undefined
683
+ || target.promotedCanonicalIds.length === 0
684
+ || target.reviewReason !== item.reviewReason)
685
+ throwInvalidLedger();
686
+ mergeSourcesByTarget.set(targetId, [...(mergeSourcesByTarget.get(targetId) ?? []), item]);
687
+ }
688
+ if (candidates.some((candidate) => {
689
+ const item = candidate;
690
+ return item.reviewStatus === 'approved'
691
+ && item.reviewDecision === 'merge'
692
+ && (mergeSourcesByTarget.get(item.id)?.length ?? 0) !== 1;
693
+ }))
694
+ throwInvalidLedger();
695
+ const receipts = run.receipts;
696
+ if (!receipts.every((receipt) => isValidLedgerReceipt(receipt, sourceById, candidates)))
697
+ throwInvalidLedger();
698
+ if (new Set(receipts.map((receipt) => receipt.sourceId)).size !== receipts.length)
699
+ throwInvalidLedger();
700
+ const receiptBySourceId = new Map(receipts.map((receipt) => {
701
+ const item = receipt;
702
+ return [item.sourceId, item];
703
+ }));
704
+ if (candidates.some((candidate) => {
705
+ const item = candidate;
706
+ const receipt = receiptBySourceId.get(item.sourceId);
707
+ return !receipt || !receipt.candidateIds.includes(item.id);
708
+ }))
709
+ throwInvalidLedger();
710
+ const promoted = new Set(run.promotedCanonicalIds);
711
+ const candidatePromoted = new Set(candidates.flatMap((candidate) => candidate.promotedCanonicalIds));
712
+ if ([...promoted].some((id) => !candidatePromoted.has(id)) || [...candidatePromoted].some((id) => !promoted.has(id)))
713
+ throwInvalidLedger();
714
+ if (!isValidFirstValueReceipt(run.firstValueReceipt, promoted) || !isValidFirstValueReview(run.firstValueReview, run.firstValueReceipt !== undefined))
715
+ throwInvalidLedger();
716
+ }
717
+ }
718
+ function isValidLedgerSource(value) {
719
+ return isRecord(value)
720
+ && hasOnlyKeys(value, ['id', 'mode', 'status', 'evidencePointer', 'permissionScope', 'detail'])
721
+ && isBoundedString(value.id, 500)
722
+ && ['mcp', 'drive', 'gmail', 'local_folder', 'single_document'].includes(String(value.mode))
723
+ && ['ready', 'waiting_for_authorization', 'unavailable', 'error', 'unconfirmed'].includes(String(value.status))
724
+ && (value.evidencePointer === undefined || isBoundedString(value.evidencePointer, 1000))
725
+ && isBoundedStringArray(value.permissionScope, 500)
726
+ && (value.detail === undefined || isBoundedString(value.detail, 500));
727
+ }
728
+ function isValidLedgerReceipt(value, sourceById, candidates) {
729
+ if (!isRecord(value))
730
+ return false;
731
+ const source = typeof value.sourceId === 'string' ? sourceById.get(value.sourceId) : undefined;
732
+ if (!source || value.mode !== source.mode || !isRecord(value.permissionSnapshot))
733
+ return false;
734
+ try {
735
+ validatePermissionSnapshot(value.permissionSnapshot, source.permissionScope);
736
+ }
737
+ catch {
738
+ return false;
739
+ }
740
+ const candidatesById = new Map(candidates.map((candidate) => {
741
+ const item = candidate;
742
+ return [item.id, item];
743
+ }));
744
+ return hasOnlyKeys(value, ['sourceId', 'mode', 'evidencePointer', 'contentHash', 'permissionSnapshot', 'collectionStatus', 'collectedAt', 'candidateIds'])
745
+ && isBoundedString(value.sourceId, 500)
746
+ && ['mcp', 'drive', 'gmail', 'local_folder', 'single_document'].includes(String(value.mode))
747
+ && isBoundedString(value.evidencePointer, 1000)
748
+ && typeof value.contentHash === 'string' && SHA256.test(value.contentHash)
749
+ && value.collectionStatus === 'collected'
750
+ && isIsoTimestamp(value.collectedAt)
751
+ && isBoundedStringArray(value.candidateIds, 200)
752
+ && value.candidateIds.every((id) => candidatesById.get(id)?.sourceId === value.sourceId);
753
+ }
754
+ function isValidLedgerCandidate(value, runId, sourceIds) {
755
+ if (!isRecord(value)
756
+ || !hasOnlyKeys(value, ['id', 'ingestedPayloadHash', 'sourceId', 'kind', 'payload', 'observationClass', 'evidenceId', 'reviewStatus', 'reviewDecision', 'reviewReason', 'mergedIntoCandidateId', 'promotedCanonicalIds'])
757
+ || !isBoundedString(value.id, 200) || !isBoundedString(value.sourceId, 500) || !sourceIds.has(value.sourceId)
758
+ || typeof value.ingestedPayloadHash !== 'string' || !SHA256.test(value.ingestedPayloadHash)
759
+ || !isBoundedString(value.kind, 100) || !isRecord(value.payload)
760
+ || !['observed', 'inferred'].includes(String(value.observationClass))
761
+ || !isBoundedString(value.evidenceId, 1000)
762
+ || !['pending', 'approved', 'rejected', 'merged'].includes(String(value.reviewStatus))
763
+ || !isBoundedStringArray(value.promotedCanonicalIds, 200)
764
+ || value.id !== stableId('onb_cand', runId, value.sourceId, value.evidenceId, value.kind, value.ingestedPayloadHash))
765
+ return false;
766
+ const reviewedPayloadMayDiffer = value.reviewStatus === 'approved'
767
+ && (value.reviewDecision === 'edit' || value.reviewDecision === 'merge');
768
+ if (!reviewedPayloadMayDiffer && sha256(stableJson(value.payload)) !== value.ingestedPayloadHash)
769
+ return false;
770
+ if (value.reviewStatus === 'pending')
771
+ return value.reviewDecision === undefined
772
+ && value.reviewReason === undefined
773
+ && value.mergedIntoCandidateId === undefined
774
+ && value.promotedCanonicalIds.length === 0;
775
+ if (!['approve', 'edit', 'reject', 'merge'].includes(String(value.reviewDecision)) || !isBoundedString(value.reviewReason, 500))
776
+ return false;
777
+ if (value.reviewStatus === 'merged')
778
+ return value.reviewDecision === 'merge'
779
+ && isBoundedString(value.mergedIntoCandidateId, 200)
780
+ && value.promotedCanonicalIds.length === 0;
781
+ if (value.reviewStatus === 'rejected')
782
+ return value.reviewDecision === 'reject'
783
+ && value.mergedIntoCandidateId === undefined
784
+ && value.promotedCanonicalIds.length === 0;
785
+ if (value.observationClass !== 'observed'
786
+ || !['approve', 'edit', 'merge'].includes(String(value.reviewDecision))
787
+ || value.mergedIntoCandidateId !== undefined
788
+ || value.promotedCanonicalIds.length === 0)
789
+ return false;
790
+ return stableJson(value.promotedCanonicalIds) === stableJson(expectedCanonicalIds(value));
791
+ }
792
+ function expectedCanonicalIds(candidate) {
793
+ const result = planApply([toApplyCandidate(candidate)], { ids: new Set(), all: true }, {
794
+ graphEntities: [], relationships: [], personalKg: [], decisions: []
795
+ }, '2000-01-01T00:00:00.000Z');
796
+ return result.skipped.length === 0 && result.applied.length === 1 ? result.applied[0].canonicalIds : [];
797
+ }
798
+ function isValidFirstValueReceipt(value, promoted) {
799
+ if (value === undefined)
800
+ return true;
801
+ return isRecord(value)
802
+ && hasOnlyKeys(value, ['answerHash', 'usedCanonicalIds', 'missingContext', 'recordedAt'])
803
+ && typeof value.answerHash === 'string' && SHA256.test(value.answerHash)
804
+ && isBoundedStringArray(value.usedCanonicalIds, 200) && value.usedCanonicalIds.length > 0
805
+ && value.usedCanonicalIds.every((id) => promoted.has(id))
806
+ && isBoundedStringArray(value.missingContext, 200)
807
+ && isIsoTimestamp(value.recordedAt);
808
+ }
809
+ function isValidFirstValueReview(value, hasReceipt) {
810
+ if (value === undefined)
811
+ return true;
812
+ return hasReceipt && isRecord(value)
813
+ && hasOnlyKeys(value, ['verdict', 'missingContext', 'reviewedAt', 'elapsedSeconds', 'withinTargetSeconds'])
814
+ && ['useful', 'not_useful'].includes(String(value.verdict))
815
+ && isBoundedStringArray(value.missingContext, 200)
816
+ && isIsoTimestamp(value.reviewedAt)
817
+ && typeof value.elapsedSeconds === 'number' && Number.isFinite(value.elapsedSeconds) && value.elapsedSeconds >= 0
818
+ && typeof value.withinTargetSeconds === 'boolean';
819
+ }
820
+ function assertCanonicalReferences(run, os) {
821
+ const canonicalIds = new Set([
822
+ ...os.graph.entities.map((item) => item.id),
823
+ ...os.relationships.relationships.map((item) => item.id),
824
+ ...os.personalKg.map((item) => item.id),
825
+ ...os.decisions.map((item) => item.id)
826
+ ]);
827
+ if (run.promotedCanonicalIds.some((id) => !canonicalIds.has(id))) {
828
+ throwInvalidLedger();
829
+ }
830
+ }
831
+ function assertLedgerCanonicalReferences(ledger, os) {
832
+ for (const run of ledger.runs)
833
+ assertCanonicalReferences(run, os);
834
+ }
835
+ function hasOnlyKeys(value, allowed) {
836
+ const allowedSet = new Set(allowed);
837
+ return Object.keys(value).every((key) => allowedSet.has(key));
838
+ }
839
+ function isBoundedString(value, max) {
840
+ return typeof value === 'string' && value.length > 0 && value.length <= max;
841
+ }
842
+ function isBoundedStringArray(value, max) {
843
+ return Array.isArray(value) && value.length <= MAX_ITEMS && value.every((item) => isBoundedString(item, max));
844
+ }
845
+ function isIsoTimestamp(value) {
846
+ return typeof value === 'string' && Number.isFinite(Date.parse(value));
847
+ }
848
+ function throwInvalidLedger() {
849
+ throw new ConnectedOnboardingError('ledger_schema_unsupported', 'connected onboarding ledger contains invalid or incomplete data');
850
+ }
851
+ function isRecord(value) {
852
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
853
+ }
854
+ async function persistLedger(dataDir, ledger) {
855
+ const serialized = serializeLedger(ledger);
856
+ const runsDir = join(dataDir, 'runs');
857
+ await mkdir(runsDir, { recursive: true });
858
+ const target = ledgerPath(dataDir);
859
+ const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
860
+ try {
861
+ await writeFile(temporary, serialized, { mode: 0o600 });
862
+ await rename(temporary, target);
863
+ }
864
+ catch (error) {
865
+ await rm(temporary, { force: true }).catch(() => { });
866
+ throw error;
867
+ }
868
+ }
869
+ function serializeLedger(ledger) {
870
+ const serialized = `${JSON.stringify(ledger, null, 2)}\n`;
871
+ if (Buffer.byteLength(serialized, 'utf8') > MAX_LEDGER_BYTES)
872
+ throw new ConnectedOnboardingError('ledger_too_large', 'connected onboarding ledger exceeds 1 MiB');
873
+ return serialized;
874
+ }
875
+ async function withLedgerLock(dataDir, operation) {
876
+ const runsDir = join(dataDir, 'runs');
877
+ const lockDir = join(runsDir, '.connected-onboarding.lock');
878
+ await mkdir(runsDir, { recursive: true });
879
+ const owner = { pid: process.pid, hostname: hostname(), token: randomUUID() };
880
+ for (let attempt = 0;; attempt += 1) {
881
+ let created = false;
882
+ try {
883
+ await mkdir(lockDir);
884
+ created = true;
885
+ await writeFile(join(lockDir, 'owner.json'), JSON.stringify(owner), { mode: 0o600 });
886
+ break;
887
+ }
888
+ catch (error) {
889
+ if (created)
890
+ await rm(lockDir, { recursive: true, force: true }).catch(() => { });
891
+ if (error.code !== 'EEXIST')
892
+ throw error;
893
+ if (attempt >= 200)
894
+ throw new ConnectedOnboardingError('ledger_lock_timeout', `timed out waiting for connected onboarding ledger lock in ${dataDir}`);
895
+ await clearDeadSameHostLock(lockDir);
896
+ await new Promise((resolve) => setTimeout(resolve, 10));
897
+ }
898
+ }
899
+ try {
900
+ return await operation();
901
+ }
902
+ finally {
903
+ try {
904
+ const current = JSON.parse(await readFile(join(lockDir, 'owner.json'), 'utf8'));
905
+ if (current.token === owner.token)
906
+ await rm(lockDir, { recursive: true, force: true });
907
+ }
908
+ catch {
909
+ // A missing or foreign owner is not removed by this process.
910
+ }
911
+ }
912
+ }
913
+ async function clearDeadSameHostLock(lockDir) {
914
+ try {
915
+ const current = JSON.parse(await readFile(join(lockDir, 'owner.json'), 'utf8'));
916
+ if (current.hostname !== hostname() || typeof current.pid !== 'number' || isProcessAlive(current.pid))
917
+ return;
918
+ const staleDir = `${lockDir}.stale-${randomUUID()}`;
919
+ await rename(lockDir, staleDir);
920
+ await rm(staleDir, { recursive: true, force: true });
921
+ }
922
+ catch {
923
+ // Incomplete, live, or concurrently claimed locks are left for bounded retry.
924
+ }
925
+ }
926
+ function isProcessAlive(pid) {
927
+ try {
928
+ process.kill(pid, 0);
929
+ return true;
930
+ }
931
+ catch (error) {
932
+ return error.code === 'EPERM';
933
+ }
934
+ }