@remnic/core 9.3.572 → 9.3.574

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.
@@ -1,6 +1,7 @@
1
1
  import {
2
- promoteSemanticRuleFromMemory
3
- } from "./chunk-74VA26CT.js";
2
+ promoteSemanticRuleFromMemory,
3
+ setSemanticRulePromotionTestHooks
4
+ } from "./chunk-KDUFBSBF.js";
4
5
  import "./chunk-IRFF6LSF.js";
5
6
  import "./chunk-5UZXUTVO.js";
6
7
  import "./chunk-4H5ZJHEN.js";
@@ -22,6 +23,7 @@ import "./chunk-A6XUJE5D.js";
22
23
  import "./chunk-P7FMDTKL.js";
23
24
  import "./chunk-PZ5AY32C.js";
24
25
  export {
25
- promoteSemanticRuleFromMemory
26
+ promoteSemanticRuleFromMemory,
27
+ setSemanticRulePromotionTestHooks
26
28
  };
27
29
  //# sourceMappingURL=semantic-rule-promotion.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remnic/core",
3
- "version": "9.3.572",
3
+ "version": "9.3.574",
4
4
  "description": "Framework-agnostic Remnic memory engine — orchestrator, storage, extraction, search, trust zones",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,3 +1,7 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { link, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { setTimeout as sleep } from "node:timers/promises";
1
5
  import { StorageManager } from "./storage.js";
2
6
  import type { MemoryFile, MemoryLink } from "./types.js";
3
7
 
@@ -30,6 +34,465 @@ export interface SemanticRulePromotionReport {
30
34
  skipped: SemanticRulePromotionSkip[];
31
35
  }
32
36
 
37
+ const PROMOTION_LOCK_TIMEOUT_MS = 10 * 60 * 1000;
38
+ const PROMOTION_LOCK_RETRY_MS = 25;
39
+ const PROMOTION_LOCK_STALE_MS = 5 * 60 * 1000;
40
+ const PROMOTION_LOCK_OWNERLESS_GRACE_MS = 1000;
41
+ const PROMOTION_LOCK_HEARTBEAT_MS = 30 * 1000;
42
+
43
+ type SemanticRulePromotionTestHooks = {
44
+ beforeLockOwnerWrite?: (lockDir: string, ownerToken: string) => Promise<void> | void;
45
+ beforeLockRelease?: (lockDir: string, ownerToken: string) => Promise<void> | void;
46
+ beforePromotedRuleWrite?: (lockDir: string, ownerToken: string) => Promise<void> | void;
47
+ lockTimeoutMs?: number;
48
+ lockRetryMs?: number;
49
+ staleLockMs?: number;
50
+ ownerlessGraceMs?: number;
51
+ heartbeatMs?: number;
52
+ };
53
+
54
+ let testHooks: SemanticRulePromotionTestHooks | null = null;
55
+
56
+ export function setSemanticRulePromotionTestHooks(hooks: SemanticRulePromotionTestHooks | null): void {
57
+ testHooks = hooks;
58
+ }
59
+
60
+ function promotionLockTimeoutMs(): number {
61
+ return testHooks?.lockTimeoutMs ?? PROMOTION_LOCK_TIMEOUT_MS;
62
+ }
63
+
64
+ function promotionLockRetryMs(): number {
65
+ return testHooks?.lockRetryMs ?? PROMOTION_LOCK_RETRY_MS;
66
+ }
67
+
68
+ function promotionLockStaleMs(): number {
69
+ return testHooks?.staleLockMs ?? PROMOTION_LOCK_STALE_MS;
70
+ }
71
+
72
+ function promotionLockOwnerlessGraceMs(): number {
73
+ return testHooks?.ownerlessGraceMs ?? PROMOTION_LOCK_OWNERLESS_GRACE_MS;
74
+ }
75
+
76
+ function promotionLockHeartbeatMs(): number {
77
+ return testHooks?.heartbeatMs ?? PROMOTION_LOCK_HEARTBEAT_MS;
78
+ }
79
+
80
+ function isErrnoCode(err: unknown, code: string): boolean {
81
+ return typeof err === "object" && err !== null && "code" in err && (err as { code?: unknown }).code === code;
82
+ }
83
+
84
+ function promotionLockDir(memoryDir: string, ruleKey: string): string {
85
+ const digest = createHash("sha256").update(ruleKey).digest("hex");
86
+ return path.join(path.resolve(memoryDir), "state", "semantic-rule-promotion-locks", `${digest}.lock`);
87
+ }
88
+
89
+ function promotionHeartbeatPath(lockDir: string, token: string): string {
90
+ return path.join(lockDir, `heartbeat.${token}.json`);
91
+ }
92
+
93
+ function promotionReapDir(lockDir: string): string {
94
+ return `${lockDir}.reap`;
95
+ }
96
+
97
+ function promotionReleaseDir(lockDir: string): string {
98
+ return `${lockDir}.release`;
99
+ }
100
+
101
+ async function pathExists(targetPath: string): Promise<boolean> {
102
+ try {
103
+ await stat(targetPath);
104
+ return true;
105
+ } catch (err) {
106
+ if (isErrnoCode(err, "ENOENT")) return false;
107
+ throw err;
108
+ }
109
+ }
110
+
111
+ async function readPromotionLockOwner(lockDir: string): Promise<{
112
+ acquiredAtMs: number | null;
113
+ heartbeatAtMs: number | null;
114
+ token: string | null;
115
+ }> {
116
+ try {
117
+ const owner = JSON.parse(await readFile(path.join(lockDir, "owner.json"), "utf8")) as {
118
+ acquiredAt?: unknown;
119
+ heartbeatAt?: unknown;
120
+ token?: unknown;
121
+ };
122
+ const acquiredAtMs = typeof owner.acquiredAt === "string" ? Date.parse(owner.acquiredAt) : Number.NaN;
123
+ const heartbeatAtMs = typeof owner.heartbeatAt === "string" ? Date.parse(owner.heartbeatAt) : Number.NaN;
124
+ return {
125
+ acquiredAtMs: Number.isFinite(acquiredAtMs) ? acquiredAtMs : null,
126
+ heartbeatAtMs: Number.isFinite(heartbeatAtMs) ? heartbeatAtMs : null,
127
+ token: typeof owner.token === "string" ? owner.token : null,
128
+ };
129
+ } catch {
130
+ return {
131
+ acquiredAtMs: null,
132
+ heartbeatAtMs: null,
133
+ token: null,
134
+ };
135
+ }
136
+ }
137
+
138
+ async function tryWritePromotionLockOwner(
139
+ lockDir: string,
140
+ owner: { token: string; acquiredAt: string }
141
+ ): Promise<boolean> {
142
+ const ownerPath = path.join(lockDir, "owner.json");
143
+ const tempPath = path.join(lockDir, `owner.${process.pid}.${randomUUID()}.tmp`);
144
+ await testHooks?.beforeLockOwnerWrite?.(lockDir, owner.token);
145
+ try {
146
+ await writeFile(
147
+ tempPath,
148
+ `${JSON.stringify(
149
+ {
150
+ pid: process.pid,
151
+ token: owner.token,
152
+ acquiredAt: owner.acquiredAt,
153
+ },
154
+ null,
155
+ 2
156
+ )}\n`
157
+ );
158
+ await link(tempPath, ownerPath);
159
+ return true;
160
+ } catch (err) {
161
+ if (isErrnoCode(err, "EEXIST") || isErrnoCode(err, "ENOENT")) {
162
+ return false;
163
+ }
164
+ throw err;
165
+ } finally {
166
+ await rm(tempPath, { force: true });
167
+ }
168
+ }
169
+
170
+ async function writePromotionLockHeartbeat(lockDir: string, token: string): Promise<void> {
171
+ const heartbeatPath = promotionHeartbeatPath(lockDir, token);
172
+ const tempPath = path.join(lockDir, `heartbeat.${process.pid}.${randomUUID()}.tmp`);
173
+ try {
174
+ await writeFile(
175
+ tempPath,
176
+ `${JSON.stringify(
177
+ {
178
+ pid: process.pid,
179
+ token,
180
+ heartbeatAt: new Date().toISOString(),
181
+ },
182
+ null,
183
+ 2
184
+ )}\n`
185
+ );
186
+ await rename(tempPath, heartbeatPath);
187
+ } catch (err) {
188
+ await rm(tempPath, { force: true });
189
+ throw err;
190
+ }
191
+ }
192
+
193
+ async function refreshPromotionLockHeartbeat(lockDir: string, token: string): Promise<boolean> {
194
+ const owner = await readPromotionLockOwner(lockDir);
195
+ if (owner.token !== token) {
196
+ return false;
197
+ }
198
+ await writePromotionLockHeartbeat(lockDir, token);
199
+ return true;
200
+ }
201
+
202
+ async function readPromotionLockHeartbeatMs(lockDir: string, token: string | null): Promise<number | null> {
203
+ if (!token) return null;
204
+ try {
205
+ return (await stat(promotionHeartbeatPath(lockDir, token))).mtimeMs;
206
+ } catch (err) {
207
+ if (isErrnoCode(err, "ENOENT")) return null;
208
+ throw err;
209
+ }
210
+ }
211
+
212
+ async function reapAbandonedPromotionGuard(guardDir: string): Promise<boolean> {
213
+ let reapStat: { mtimeMs: number };
214
+ try {
215
+ reapStat = await stat(guardDir);
216
+ } catch (err) {
217
+ if (isErrnoCode(err, "ENOENT")) return false;
218
+ throw err;
219
+ }
220
+ if (Date.now() - reapStat.mtimeMs < promotionLockStaleMs()) {
221
+ return false;
222
+ }
223
+
224
+ const staleReapDir = `${guardDir}.stale-${process.pid}-${randomUUID()}`;
225
+ try {
226
+ await rename(guardDir, staleReapDir);
227
+ } catch (err) {
228
+ if (isErrnoCode(err, "ENOENT")) return false;
229
+ throw err;
230
+ }
231
+ await rm(staleReapDir, { recursive: true, force: true });
232
+ return true;
233
+ }
234
+
235
+ async function reapAbandonedPromotionGuards(lockDir: string): Promise<boolean> {
236
+ const reapedReapGuard = await reapAbandonedPromotionGuard(promotionReapDir(lockDir));
237
+ const reapedReleaseGuard = await reapAbandonedPromotionGuard(promotionReleaseDir(lockDir));
238
+ return reapedReapGuard || reapedReleaseGuard;
239
+ }
240
+
241
+ async function hasPromotionGuard(lockDir: string): Promise<boolean> {
242
+ return (await pathExists(promotionReapDir(lockDir))) || (await pathExists(promotionReleaseDir(lockDir)));
243
+ }
244
+
245
+ async function reapStalePromotionLock(lockDir: string): Promise<boolean> {
246
+ if (await hasPromotionGuard(lockDir)) {
247
+ await reapAbandonedPromotionGuards(lockDir);
248
+ return false;
249
+ }
250
+
251
+ const reapDir = promotionReapDir(lockDir);
252
+ try {
253
+ await mkdir(reapDir);
254
+ } catch (err) {
255
+ if (isErrnoCode(err, "EEXIST")) return false;
256
+ throw err;
257
+ }
258
+
259
+ let staleDir: string | null = null;
260
+ try {
261
+ return await reapStalePromotionLockWithGuard(lockDir, (value) => {
262
+ staleDir = value;
263
+ });
264
+ } finally {
265
+ if (staleDir) {
266
+ await rm(staleDir, { recursive: true, force: true });
267
+ }
268
+ await rm(reapDir, { recursive: true, force: true });
269
+ }
270
+ }
271
+
272
+ async function reapStalePromotionLockWithGuard(
273
+ lockDir: string,
274
+ setStaleDir: (staleDir: string) => void
275
+ ): Promise<boolean> {
276
+ let lockStat: { mtimeMs: number };
277
+ try {
278
+ lockStat = await stat(lockDir);
279
+ } catch (err) {
280
+ if (isErrnoCode(err, "ENOENT")) return false;
281
+ throw err;
282
+ }
283
+
284
+ const owner = await readPromotionLockOwner(lockDir);
285
+ const heartbeatMs = await readPromotionLockHeartbeatMs(lockDir, owner.token);
286
+ const lockedAtMs = heartbeatMs ?? owner.heartbeatAtMs ?? owner.acquiredAtMs ?? lockStat.mtimeMs;
287
+ const ownerlessLock = owner.token === null && owner.heartbeatAtMs === null && owner.acquiredAtMs === null;
288
+ const staleAfterMs = ownerlessLock ? promotionLockOwnerlessGraceMs() : promotionLockStaleMs();
289
+ if (Date.now() - lockedAtMs < staleAfterMs) {
290
+ return false;
291
+ }
292
+
293
+ const staleDir = `${lockDir}.stale-${process.pid}-${randomUUID()}`;
294
+ try {
295
+ await rename(lockDir, staleDir);
296
+ setStaleDir(staleDir);
297
+ } catch (err) {
298
+ if (isErrnoCode(err, "ENOENT")) return false;
299
+ throw err;
300
+ }
301
+
302
+ const staleOwner = await readPromotionLockOwner(staleDir);
303
+ const staleHeartbeatMs = await readPromotionLockHeartbeatMs(staleDir, staleOwner.token);
304
+ const staleLockedAtMs = staleHeartbeatMs ?? staleOwner.heartbeatAtMs ?? staleOwner.acquiredAtMs ?? lockStat.mtimeMs;
305
+ const staleOwnerlessLock =
306
+ staleOwner.token === null && staleOwner.heartbeatAtMs === null && staleOwner.acquiredAtMs === null;
307
+ const staleAfterRecheckMs = staleOwnerlessLock ? promotionLockOwnerlessGraceMs() : promotionLockStaleMs();
308
+ if (Date.now() - staleLockedAtMs < staleAfterRecheckMs) {
309
+ await rename(staleDir, lockDir);
310
+ setStaleDir("");
311
+ return false;
312
+ }
313
+
314
+ await rm(staleDir, { recursive: true, force: true });
315
+ setStaleDir("");
316
+ return true;
317
+ }
318
+
319
+ async function releasePromotionLock(lockDir: string, token: string): Promise<void> {
320
+ const releaseDir = promotionReleaseDir(lockDir);
321
+ const deadline = Date.now() + promotionLockTimeoutMs();
322
+ await testHooks?.beforeLockRelease?.(lockDir, token);
323
+ for (;;) {
324
+ try {
325
+ await mkdir(releaseDir);
326
+ break;
327
+ } catch (err) {
328
+ if (!isErrnoCode(err, "EEXIST")) {
329
+ throw err;
330
+ }
331
+ const owner = await readPromotionLockOwner(lockDir);
332
+ if (owner.token !== token) {
333
+ return;
334
+ }
335
+ await reapAbandonedPromotionGuard(releaseDir);
336
+ if (Date.now() >= deadline) {
337
+ throw new Error("Timed out releasing semantic rule promotion lock");
338
+ }
339
+ await sleep(promotionLockRetryMs());
340
+ }
341
+ }
342
+
343
+ try {
344
+ const owner = await readPromotionLockOwner(lockDir);
345
+ if (owner.token !== token) {
346
+ return;
347
+ }
348
+ await rm(lockDir, { recursive: true, force: true });
349
+ } finally {
350
+ await rm(releaseDir, { recursive: true, force: true });
351
+ }
352
+ }
353
+
354
+ type PromotionLockLease = {
355
+ assertHeld: () => Promise<void>;
356
+ beforePromotedRuleWrite: () => Promise<void>;
357
+ };
358
+
359
+ async function withPromotionLock<T>(
360
+ memoryDir: string,
361
+ ruleKey: string,
362
+ fn: (lease: PromotionLockLease) => Promise<T>
363
+ ): Promise<T> {
364
+ const lockDir = promotionLockDir(memoryDir, ruleKey);
365
+ const lockRoot = path.dirname(lockDir);
366
+ const deadline = Date.now() + promotionLockTimeoutMs();
367
+ let lockToken: string | null = null;
368
+ let heartbeat: NodeJS.Timeout | null = null;
369
+
370
+ await mkdir(lockRoot, { recursive: true });
371
+ for (;;) {
372
+ try {
373
+ if (await hasPromotionGuard(lockDir)) {
374
+ await reapAbandonedPromotionGuards(lockDir);
375
+ if (Date.now() >= deadline) {
376
+ throw new Error("Timed out acquiring semantic rule promotion lock");
377
+ }
378
+ await sleep(promotionLockRetryMs());
379
+ continue;
380
+ }
381
+ await mkdir(lockDir);
382
+ if (await hasPromotionGuard(lockDir)) {
383
+ await reapAbandonedPromotionGuards(lockDir);
384
+ if (Date.now() >= deadline) {
385
+ throw new Error("Timed out acquiring semantic rule promotion lock");
386
+ }
387
+ await sleep(promotionLockRetryMs());
388
+ continue;
389
+ }
390
+ try {
391
+ lockToken = randomUUID();
392
+ const lockOwner = {
393
+ token: lockToken,
394
+ acquiredAt: new Date().toISOString(),
395
+ };
396
+ const wroteOwner = await tryWritePromotionLockOwner(lockDir, lockOwner);
397
+ if (!wroteOwner) {
398
+ lockToken = null;
399
+ if (Date.now() >= deadline) {
400
+ throw new Error("Timed out acquiring semantic rule promotion lock");
401
+ }
402
+ await sleep(promotionLockRetryMs());
403
+ continue;
404
+ }
405
+ await writePromotionLockHeartbeat(lockDir, lockToken);
406
+ const heartbeatToken = lockToken;
407
+ const heartbeatMs = promotionLockHeartbeatMs();
408
+ if (heartbeatMs > 0) {
409
+ heartbeat = setInterval(() => {
410
+ void refreshPromotionLockHeartbeat(lockDir, heartbeatToken)
411
+ .then((ownsLock) => {
412
+ if (!ownsLock && heartbeat) {
413
+ clearInterval(heartbeat);
414
+ heartbeat = null;
415
+ }
416
+ })
417
+ .catch(() => undefined);
418
+ }, heartbeatMs);
419
+ }
420
+ } catch (err) {
421
+ lockToken = null;
422
+ if (heartbeat) {
423
+ clearInterval(heartbeat);
424
+ heartbeat = null;
425
+ }
426
+ await rm(lockDir, { recursive: true, force: true });
427
+ throw err;
428
+ }
429
+ break;
430
+ } catch (err) {
431
+ if (!isErrnoCode(err, "EEXIST")) {
432
+ throw err;
433
+ }
434
+ if (await reapStalePromotionLock(lockDir)) {
435
+ continue;
436
+ }
437
+ if (Date.now() >= deadline) {
438
+ throw new Error("Timed out acquiring semantic rule promotion lock");
439
+ }
440
+ await sleep(promotionLockRetryMs());
441
+ }
442
+ }
443
+
444
+ const assertHeld = async () => {
445
+ if (!lockToken) {
446
+ throw new Error("Semantic rule promotion lock is no longer held");
447
+ }
448
+ const ownsLock = await refreshPromotionLockHeartbeat(lockDir, lockToken);
449
+ if (!ownsLock) {
450
+ throw new Error("Semantic rule promotion lock is no longer held");
451
+ }
452
+ };
453
+
454
+ let hasResult = false;
455
+ let result: T | undefined;
456
+ let callbackError: unknown;
457
+ let releaseError: unknown;
458
+ try {
459
+ await assertHeld();
460
+ result = await fn({
461
+ assertHeld,
462
+ beforePromotedRuleWrite: async () => {
463
+ if (!lockToken) {
464
+ throw new Error("Semantic rule promotion lock is no longer held");
465
+ }
466
+ await testHooks?.beforePromotedRuleWrite?.(lockDir, lockToken);
467
+ await assertHeld();
468
+ },
469
+ });
470
+ hasResult = true;
471
+ } catch (err) {
472
+ callbackError = err;
473
+ } finally {
474
+ if (heartbeat) {
475
+ clearInterval(heartbeat);
476
+ }
477
+ if (lockToken) {
478
+ try {
479
+ await releasePromotionLock(lockDir, lockToken);
480
+ } catch (err) {
481
+ if (!hasResult && !callbackError) {
482
+ releaseError = err;
483
+ }
484
+ }
485
+ }
486
+ }
487
+ if (releaseError) {
488
+ throw releaseError;
489
+ }
490
+ if (callbackError) {
491
+ throw callbackError;
492
+ }
493
+ return result as T;
494
+ }
495
+
33
496
  function normalizeRuleWhitespace(value: string): string {
34
497
  return value.replace(/\s+/g, " ").trim();
35
498
  }
@@ -111,10 +574,7 @@ export async function promoteSemanticRuleFromMemory(options: {
111
574
  });
112
575
  return report;
113
576
  }
114
- if (
115
- sourceMemory.frontmatter.status === "archived" ||
116
- sourceMemory.frontmatter.memoryKind !== "episode"
117
- ) {
577
+ if (sourceMemory.frontmatter.status === "archived" || sourceMemory.frontmatter.memoryKind !== "episode") {
118
578
  report.skipped.push({
119
579
  sourceMemoryId: options.sourceMemoryId,
120
580
  reason: "source-memory-not-episode",
@@ -132,22 +592,6 @@ export async function promoteSemanticRuleFromMemory(options: {
132
592
  }
133
593
 
134
594
  const ruleKey = canonicalizeRuleKey(content);
135
- const existingRule = (await storage.readAllMemories()).find(
136
- (memory) =>
137
- memory.frontmatter.category === "rule" &&
138
- memory.frontmatter.status !== "archived" &&
139
- memory.frontmatter.status !== "forgotten" &&
140
- canonicalizeRuleKey(memory.content) === ruleKey,
141
- );
142
- if (existingRule) {
143
- report.skipped.push({
144
- sourceMemoryId: options.sourceMemoryId,
145
- reason: "duplicate-rule",
146
- existingRuleId: existingRule.frontmatter.id,
147
- });
148
- return report;
149
- }
150
-
151
595
  const confidence = promotionConfidence(sourceMemory);
152
596
  const candidateBase = {
153
597
  sourceMemoryId: options.sourceMemoryId,
@@ -158,26 +602,47 @@ export async function promoteSemanticRuleFromMemory(options: {
158
602
  lineage: [options.sourceMemoryId],
159
603
  };
160
604
 
161
- if (options.dryRun === true) {
605
+ return withPromotionLock(options.memoryDir, ruleKey, async (lock) => {
606
+ storage.invalidateAllMemoriesCacheForDir();
607
+ const existingRule = (await storage.readAllMemories()).find(
608
+ (memory) =>
609
+ memory.frontmatter.category === "rule" &&
610
+ memory.frontmatter.status !== "archived" &&
611
+ memory.frontmatter.status !== "forgotten" &&
612
+ canonicalizeRuleKey(memory.content) === ruleKey
613
+ );
614
+ if (existingRule) {
615
+ report.skipped.push({
616
+ sourceMemoryId: options.sourceMemoryId,
617
+ reason: "duplicate-rule",
618
+ existingRuleId: existingRule.frontmatter.id,
619
+ });
620
+ return report;
621
+ }
622
+
623
+ if (options.dryRun === true) {
624
+ await lock.assertHeld();
625
+ report.promoted.push({
626
+ id: `dry-run:${options.sourceMemoryId}`,
627
+ ...candidateBase,
628
+ });
629
+ return report;
630
+ }
631
+
632
+ await lock.beforePromotedRuleWrite();
633
+ const id = await storage.writeMemory("rule", content, {
634
+ confidence,
635
+ tags: candidateBase.tags,
636
+ source: "semantic-rule-promotion",
637
+ lineage: candidateBase.lineage,
638
+ sourceMemoryId: options.sourceMemoryId,
639
+ memoryKind: "note",
640
+ links: buildSupportLinks(options.sourceMemoryId, confidence),
641
+ });
162
642
  report.promoted.push({
163
- id: `dry-run:${options.sourceMemoryId}`,
643
+ id,
164
644
  ...candidateBase,
165
645
  });
166
646
  return report;
167
- }
168
-
169
- const id = await storage.writeMemory("rule", content, {
170
- confidence,
171
- tags: candidateBase.tags,
172
- source: "semantic-rule-promotion",
173
- lineage: candidateBase.lineage,
174
- sourceMemoryId: options.sourceMemoryId,
175
- memoryKind: "note",
176
- links: buildSupportLinks(options.sourceMemoryId, confidence),
177
- });
178
- report.promoted.push({
179
- id,
180
- ...candidateBase,
181
647
  });
182
- return report;
183
648
  }