@remnic/core 9.3.700 → 9.3.701

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.
Files changed (119) hide show
  1. package/dist/access-boundary.d.ts +3 -3
  2. package/dist/access-boundary.js +7 -7
  3. package/dist/access-cli.js +21 -21
  4. package/dist/access-http.d.ts +3 -3
  5. package/dist/access-http.js +10 -10
  6. package/dist/access-mcp.d.ts +3 -3
  7. package/dist/access-mcp.js +9 -9
  8. package/dist/access-operations.d.ts +3 -3
  9. package/dist/access-operations.js +8 -8
  10. package/dist/{access-service-Cte3ol0W.d.ts → access-service-CGVWK6lZ.d.ts} +1 -1
  11. package/dist/access-service.d.ts +3 -3
  12. package/dist/access-service.js +6 -6
  13. package/dist/access-surface-catalog.d.ts +3 -3
  14. package/dist/bootstrap.d.ts +2 -2
  15. package/dist/briefing.js +4 -4
  16. package/dist/{catalog-DN1PzThs.d.ts → catalog-DBIghceA.d.ts} +26 -52
  17. package/dist/causal-consolidation.js +5 -5
  18. package/dist/{chunk-GYVVQYA3.js → chunk-27LQPUMZ.js} +3 -3
  19. package/dist/{chunk-XJNBEDFE.js → chunk-3FAMU5TX.js} +31 -74
  20. package/dist/chunk-3FAMU5TX.js.map +1 -0
  21. package/dist/{chunk-PCZR32VL.js → chunk-3JJWNZTT.js} +2 -2
  22. package/dist/{chunk-GA5A6MJH.js → chunk-4HIAWLA2.js} +14 -14
  23. package/dist/{chunk-SEWF2O74.js → chunk-6W2D6FGG.js} +2 -2
  24. package/dist/{chunk-CHM274U6.js → chunk-7TAQEPLE.js} +2 -2
  25. package/dist/{chunk-G5PKTQ5J.js → chunk-DEDQXIDL.js} +2 -2
  26. package/dist/{chunk-3E5WRQNQ.js → chunk-FUCJAZ25.js} +4 -4
  27. package/dist/{chunk-6JDGADXK.js → chunk-HF4N43Q7.js} +2 -2
  28. package/dist/{chunk-NHBEO3F3.js → chunk-HXHKLVAS.js} +14 -14
  29. package/dist/{chunk-RC3CNIPK.js → chunk-IO5NQEGZ.js} +2 -2
  30. package/dist/{chunk-SMIVW7XC.js → chunk-ISLJ5WIM.js} +2 -2
  31. package/dist/{chunk-HDLC75NX.js → chunk-IYOPIG3E.js} +2 -2
  32. package/dist/{chunk-DR2JTSLZ.js → chunk-JKOKX3PS.js} +62 -163
  33. package/dist/chunk-JKOKX3PS.js.map +1 -0
  34. package/dist/{chunk-2NWHLAXX.js → chunk-JO3E5VGS.js} +2 -2
  35. package/dist/{chunk-CCOXIDRM.js → chunk-KF4TXW7Z.js} +4 -4
  36. package/dist/{chunk-JX3YZVII.js → chunk-KS7WQ4BZ.js} +3 -3
  37. package/dist/{chunk-YPR7DOPD.js → chunk-LTJAMRGI.js} +4 -4
  38. package/dist/{chunk-YPR7DOPD.js.map → chunk-LTJAMRGI.js.map} +1 -1
  39. package/dist/{chunk-ZDK2IW5F.js → chunk-MNU5G4TK.js} +2 -2
  40. package/dist/{chunk-PQG4T5V3.js → chunk-ODTWHSY2.js} +49 -45
  41. package/dist/chunk-ODTWHSY2.js.map +1 -0
  42. package/dist/{chunk-YMTGXDN6.js → chunk-OLOYQZFB.js} +4 -4
  43. package/dist/{chunk-JKW5XSWC.js → chunk-QP37KL5H.js} +2 -2
  44. package/dist/{chunk-X74FJSW7.js → chunk-SFMRLXIV.js} +2 -2
  45. package/dist/{chunk-RJ2THZ4H.js → chunk-TFVVONWD.js} +2 -2
  46. package/dist/{chunk-XY4WJTEX.js → chunk-WFEZUGU5.js} +2 -2
  47. package/dist/{chunk-O54DY26V.js → chunk-XTIRCSIH.js} +2 -2
  48. package/dist/{chunk-EOBJRBLC.js → chunk-YO4MBK3I.js} +2 -2
  49. package/dist/{chunk-YXIFA36P.js → chunk-ZT7B64BE.js} +2 -2
  50. package/dist/{chunk-33L6XHU2.js → chunk-ZYNMX6IU.js} +5 -5
  51. package/dist/{cli--yVN9yEV.d.ts → cli-D3XeenwN.d.ts} +2 -2
  52. package/dist/cli.d.ts +4 -4
  53. package/dist/cli.js +21 -21
  54. package/dist/compounding/engine.js +4 -4
  55. package/dist/connectors/codex-materialize-runner.js +4 -4
  56. package/dist/connectors/index.js +4 -4
  57. package/dist/entity-retrieval.js +4 -4
  58. package/dist/explicit-capture.d.ts +2 -2
  59. package/dist/{forget-BEXG5PQC.js → forget-6SOIPUMQ.js} +3 -3
  60. package/dist/index.d.ts +5 -5
  61. package/dist/index.js +29 -29
  62. package/dist/maintenance/memory-governance.js +4 -4
  63. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +4 -4
  64. package/dist/maintenance/rebuild-memory-projection.js +5 -5
  65. package/dist/mcp-memory-inspector-app.d.ts +3 -3
  66. package/dist/namespaces/migrate.d.ts +1 -1
  67. package/dist/namespaces/migrate.js +5 -5
  68. package/dist/namespaces/storage.d.ts +13 -2
  69. package/dist/namespaces/storage.js +4 -4
  70. package/dist/operator-toolkit.js +9 -9
  71. package/dist/orchestration/maintenance.d.ts +1 -1
  72. package/dist/orchestration/maintenance.js +6 -6
  73. package/dist/{orchestrator-CJI4xdqV.d.ts → orchestrator-BzMCZlKn.d.ts} +1 -1
  74. package/dist/orchestrator.d.ts +2 -2
  75. package/dist/orchestrator.js +17 -17
  76. package/dist/semantic-consolidation.js +5 -5
  77. package/dist/semantic-rule-promotion.js +4 -4
  78. package/dist/semantic-rule-verifier.js +4 -4
  79. package/dist/storage.js +3 -3
  80. package/dist/summarizer.js +3 -2
  81. package/dist/summary-snapshot.js +2 -1
  82. package/dist/utils/serialize-mutations.js +1 -1
  83. package/dist/verified-recall.js +4 -4
  84. package/package.json +2 -2
  85. package/src/namespaces/catalog.test.ts +222 -14
  86. package/src/namespaces/catalog.ts +54 -187
  87. package/src/namespaces/storage.ts +87 -80
  88. package/src/summary-snapshot.test.ts +63 -1
  89. package/src/summary-snapshot.ts +61 -80
  90. package/src/utils/serialize-mutations.ts +10 -6
  91. package/dist/chunk-DR2JTSLZ.js.map +0 -1
  92. package/dist/chunk-PQG4T5V3.js.map +0 -1
  93. package/dist/chunk-XJNBEDFE.js.map +0 -1
  94. /package/dist/{chunk-GYVVQYA3.js.map → chunk-27LQPUMZ.js.map} +0 -0
  95. /package/dist/{chunk-PCZR32VL.js.map → chunk-3JJWNZTT.js.map} +0 -0
  96. /package/dist/{chunk-GA5A6MJH.js.map → chunk-4HIAWLA2.js.map} +0 -0
  97. /package/dist/{chunk-SEWF2O74.js.map → chunk-6W2D6FGG.js.map} +0 -0
  98. /package/dist/{chunk-CHM274U6.js.map → chunk-7TAQEPLE.js.map} +0 -0
  99. /package/dist/{chunk-G5PKTQ5J.js.map → chunk-DEDQXIDL.js.map} +0 -0
  100. /package/dist/{chunk-3E5WRQNQ.js.map → chunk-FUCJAZ25.js.map} +0 -0
  101. /package/dist/{chunk-6JDGADXK.js.map → chunk-HF4N43Q7.js.map} +0 -0
  102. /package/dist/{chunk-NHBEO3F3.js.map → chunk-HXHKLVAS.js.map} +0 -0
  103. /package/dist/{chunk-RC3CNIPK.js.map → chunk-IO5NQEGZ.js.map} +0 -0
  104. /package/dist/{chunk-SMIVW7XC.js.map → chunk-ISLJ5WIM.js.map} +0 -0
  105. /package/dist/{chunk-HDLC75NX.js.map → chunk-IYOPIG3E.js.map} +0 -0
  106. /package/dist/{chunk-2NWHLAXX.js.map → chunk-JO3E5VGS.js.map} +0 -0
  107. /package/dist/{chunk-CCOXIDRM.js.map → chunk-KF4TXW7Z.js.map} +0 -0
  108. /package/dist/{chunk-JX3YZVII.js.map → chunk-KS7WQ4BZ.js.map} +0 -0
  109. /package/dist/{chunk-ZDK2IW5F.js.map → chunk-MNU5G4TK.js.map} +0 -0
  110. /package/dist/{chunk-YMTGXDN6.js.map → chunk-OLOYQZFB.js.map} +0 -0
  111. /package/dist/{chunk-JKW5XSWC.js.map → chunk-QP37KL5H.js.map} +0 -0
  112. /package/dist/{chunk-X74FJSW7.js.map → chunk-SFMRLXIV.js.map} +0 -0
  113. /package/dist/{chunk-RJ2THZ4H.js.map → chunk-TFVVONWD.js.map} +0 -0
  114. /package/dist/{chunk-XY4WJTEX.js.map → chunk-WFEZUGU5.js.map} +0 -0
  115. /package/dist/{chunk-O54DY26V.js.map → chunk-XTIRCSIH.js.map} +0 -0
  116. /package/dist/{chunk-EOBJRBLC.js.map → chunk-YO4MBK3I.js.map} +0 -0
  117. /package/dist/{chunk-YXIFA36P.js.map → chunk-ZT7B64BE.js.map} +0 -0
  118. /package/dist/{chunk-33L6XHU2.js.map → chunk-ZYNMX6IU.js.map} +0 -0
  119. /package/dist/{forget-BEXG5PQC.js.map → forget-6SOIPUMQ.js.map} +0 -0
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/utils/serialize-mutations.ts"],"sourcesContent":["// ---------------------------------------------------------------------------\n// Shared serialized-mutation utilities for TOCTOU hotspots (issue #1524).\n//\n// Two complementary primitives that the namespace catalog (`queueCritical` +\n// `withHeldCatalogLock`), the storage router's resolve-hook serialization, and\n// the summary-snapshot writer each re-implement today:\n//\n// 1. `serializeMutations(key, task)` — keyed IN-PROCESS async serialization\n// that recovers after a rejection (CLAUDE.md rule #40). One failed task\n// never poisons the tasks queued behind it; the failed task's error is\n// still surfaced to ITS caller.\n//\n// 2. `withHeldFileLock(lockPath, opts, task)` — a held CROSS-PROCESS file\n// lock with replacement-safe stale breaking (the NG7Bg invariant from\n// #1506 round 28) and ownership-checked release.\n//\n// This is the UTILITY module only. Per-issue PR split: one PR for the utility\n// + tests (this file), then one PR per adoption hotspot (catalog, router\n// provenance, summary snapshot). No adoptions live here.\n// ---------------------------------------------------------------------------\n\nimport { randomUUID } from \"node:crypto\";\nimport { link, mkdir, open, readFile, rename, stat, unlink, utimes } from \"node:fs/promises\";\nimport path from \"node:path\";\n\n// ─────────────────────────────────────────────────────────────────────────────\n// 1. serializeMutations — keyed async serialization with rejection recovery\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * One entry in the per-key serialization map. `tail` is the recovered promise\n * the next queued task chains off of; it never rejects (both settle handlers\n * swallow), so a prior task's failure can never break subsequent ones.\n */\ninterface MutationChainEntry {\n tail: Promise<void>;\n}\n\n/**\n * Instance-scoped keyed serializer. Holds the per-key chain map so that all\n * tasks queued under the same key on the SAME serializer run strictly in order.\n *\n * The map is instance-scoped (not module-level) so tests can construct a fresh\n * serializer per case and avoid cross-test contamination, and so adopters that\n * want isolation (e.g. one serializer per storage root) can have it. The free\n * {@link serializeMutations} export delegates to a single shared default\n * instance for callers that want process-wide serialization.\n */\nexport class MutationSerializer {\n private readonly chains = new Map<string, MutationChainEntry>();\n\n /**\n * Run `task` strictly after every other task already queued under `key` on\n * this serializer has settled.\n *\n * Rejection recovery (rule #40, mirroring the catalog's `queueCritical`):\n * if a prior task rejects, later tasks STILL RUN, while the rejecting task's\n * error is surfaced to ITS OWN caller. Concretely, the recovered tail is\n * `run.then(noop, noop)` — never a bare `.then(fn)`, which would let one\n * failure kill every queued task behind it.\n *\n * No unbounded growth: when a chain's last task settles and no newer task\n * chained onto it, its entry is deleted (the storage router's\n * `inFlightResolved` marker-then-clear discipline).\n */\n serialize<T>(key: string, task: () => Promise<T>): Promise<T> {\n if (typeof key !== \"string\" || key.length === 0) {\n throw new TypeError(\"MutationSerializer.serialize: key must be a non-empty string\");\n }\n if (typeof task !== \"function\") {\n throw new TypeError(\"MutationSerializer.serialize: task must be a function returning a promise\");\n }\n\n let entry = this.chains.get(key);\n if (!entry) {\n entry = { tail: Promise.resolve() };\n this.chains.set(key, entry);\n }\n\n // Chain this task off the prior tail. `tail.then(task)` runs task only once\n // the previous task has settled, preserving read-modify-write ordering.\n const run = entry.tail.then(task);\n\n // Recover the tail after a rejection so a failed task never poisons later\n // ones. Both handlers swallow; `run` still carries the original resolution\n // (or rejection) to THIS caller. This is the line a naive `.then(fn)`\n // implementation omits — see the \"naive poison chain\" prove-fail test.\n const recovered = run.then(settleNoop, settleNoop);\n entry.tail = recovered;\n\n // Self-cleaning: once our recovered tail settles, if no newer task chained\n // onto us the entry still points at `recovered` and is safe to delete. A\n // concurrent `serialize()` call enqueues synchronously and would have\n // replaced `entry.tail` BEFORE this microtask runs, so the identity check\n // is race-free (no newer task's entry can be wrongly removed).\n //\n // `recovered` cannot reject in correct operation (both handlers above\n // swallow) and the cleanup body cannot throw — but we attach a rejection\n // handler anyway so that IF the recovery invariant is ever broken, the\n // failure surfaces as a behavioral assertion (skipped tasks) rather than an\n // unhandled-rejection storm that masks which task failed. The handler is a\n // no-op: cleanup only runs on fulfillment.\n void recovered.then(\n () => {\n if (entry && entry.tail === recovered) {\n this.chains.delete(key);\n }\n },\n () => undefined,\n );\n\n return run;\n }\n\n /**\n * Test-only: the number of keys with a not-yet-cleaned chain. Used to assert\n * the no-unbounded-growth invariant. Not part of the public contract.\n */\n pendingKeysForTest(): number {\n return this.chains.size;\n }\n}\n\n/**\n * Recovery handler shared by both settle arms. Named (not inline\n * `() => undefined`) so the chain assignment stays self-documenting in stack\n * traces and the review-patterns poison-chain check can see the chain is\n * recovered, not bare `.then(fn)`.\n */\nfunction settleNoop(): void {\n /* swallow — the original resolution/rejection is carried by `run` */\n}\n\n/**\n * Process-wide default serializer backing the free {@link serializeMutations}\n * export. Lazy so it is only created when first used (tests that construct\n * their own `MutationSerializer` pay nothing).\n */\nlet defaultSerializer: MutationSerializer | undefined;\n\n/**\n * Free-function entry point (issue #1524 signature). Serializes `task` against\n * every other task queued under `key` across the whole process, via a shared\n * default {@link MutationSerializer}. For isolated/testable serialization,\n * construct a `MutationSerializer` directly.\n */\nexport function serializeMutations<T>(key: string, task: () => Promise<T>): Promise<T> {\n if (!defaultSerializer) defaultSerializer = new MutationSerializer();\n return defaultSerializer.serialize(key, task);\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// 2. withHeldFileLock — cross-process held file lock with stale breaking\n// ─────────────────────────────────────────────────────────────────────────────\n\n/** Options for {@link withHeldFileLock}. */\nexport interface HeldFileLockOptions {\n /**\n * A lock whose mtime is older than this (in ms) is treated as a crashed\n * holder and broken. Required — there is no safe default, since the right\n * value depends on how long the guarded critical section can legitimately\n * run.\n */\n readonly staleMs: number;\n /**\n * Bounded acquisition: give up trying to acquire a busy lock after this long\n * (ms) and invoke `task(false)` best-effort WITHOUT holding the lock, rather\n * than blocking forever or crashing the primary op. Default 5000ms (matches\n * the namespace catalog's `REBUILD_LOCK_MAX_WAIT_MS`).\n */\n readonly maxWaitMs?: number;\n /**\n * Poll interval (ms) while waiting for a busy lock to clear. Default 50ms.\n */\n readonly pollMs?: number;\n /**\n * While WE hold the lock, refresh its mtime on this cadence (ms) so a\n * legitimately long task is not mistaken for a crashed holder and broken out\n * from under. Default `floor(staleMs / 3)` (at least 100ms), mirroring the\n * catalog heartbeat ratio. Must be comfortably below `staleMs`.\n */\n readonly heartbeatMs?: number;\n /**\n * Test seam (NG7Bg, #1506 round 28): fires AFTER a lock is judged stale and\n * BEFORE the re-verify + unlink, simulating a replacement lock being created\n * in the race window. No-op in production.\n */\n readonly onBeforeBreakStaleUnlinkForTest?: () => Promise<void> | void;\n /**\n * Test seam (codex P2): fires AFTER the release rename moves the lock to a\n * trash path and BEFORE the ownership re-verify/restore — simulating a third\n * contender acquiring the (now-empty) lockPath in the race window. No-op in\n * production. Used to prove the pre-check prevents the rename entirely.\n */\n readonly onAfterReleaseRenameForTest?: () => Promise<void> | void;\n /**\n * Best-effort hook for non-fatal lock warnings (heartbeat refresh failure,\n * release-time ownership check failure). Never throws into the caller. If\n * omitted, warnings are swallowed (the lock is advisory; release/heartbeat\n * failures must never crash the guarded op).\n */\n readonly onLockWarning?: (message: string, err: unknown) => void;\n}\n\n/** Default bounded acquisition wait, mirroring the catalog. */\nconst DEFAULT_MAX_WAIT_MS = 5_000;\n/** Default busy-lock poll interval, mirroring the catalog. */\nconst DEFAULT_POLL_MS = 50;\n/** Floor for the derived heartbeat cadence. */\nconst MIN_HEARTBEAT_MS = 100;\n/** Node's setTimeout/setInterval 32-bit signed-int ceiling (2^31 − 1 ms ≈ 24.8\n * days). Delays above this are silently clamped to 1ms by the Node timer, so\n * timer-backed options (pollMs, heartbeatMs) must be rejected at this boundary\n * (chatgpt-codex-connector P2). */\nconst MAX_TIMER_DELAY_MS = 2_147_483_647;\n\n/** Internal handle for a lock we successfully acquired. */\ninterface HeldLock {\n readonly path: string;\n readonly ownerId: string;\n}\n\n/**\n * Run `task` under an exclusive on-disk lock at `lockPath`.\n *\n * Cross-process mutex via `open(lockPath, \"wx\")` (atomic exclusive create).\n * While held, a heartbeat timer refreshes the lock's mtime so a legitimately\n * long task is not mistaken for a crashed holder and broken out from under. A\n * lock older than `opts.staleMs` is treated as stale and broken — but\n * REPLACEMENT-SAFE (NG7Bg): we capture the stale lock's identity (full content\n * line: `<pid> <owner-uuid> <iso>`) when judging it stale, then RE-READ and\n * RE-STAT immediately before `unlink`, deleting only if byte-identical AND\n * still stale. A replacement lock created in the window has a different owner\n * id / timestamp, so its content differs and is left untouched.\n *\n * `task` receives `acquired: boolean` — `true` when we hold the lock, `false`\n * when acquisition timed out (best-effort). The signature takes\n * `(acquired) => Promise<T>` rather than the issue's sketched `() => Promise<T>`\n * so this can be the SINGLE lock home (issue: \"do NOT leave two lock\n * implementations; pick one home\"): the catalog's touch path needs to DROP on\n * timeout, which requires knowing whether the lock was acquired. A caller that\n * ignores the flag is still assignable (`() => Promise<T>` ⊆\n * `(acquired: boolean) => Promise<T>` in TypeScript).\n *\n * Release is ownership-checked: we only `unlink` a lock whose content still\n * identifies THIS acquirer (same owner id), so a replacement created after we\n * stopped heartbeating is never destroyed — mirroring the catalog's\n * `rebuildLockHeldBySelf`.\n *\n * ADOPTION NOTE: lock only the brief final read-merge-write window, never a\n * long scan — a scan-length lock makes concurrent writers time out and\n * silently drop work (catalog round 5, codex/cursor P2).\n */\nexport async function withHeldFileLock<T>(\n lockPath: string,\n opts: HeldFileLockOptions,\n task: (acquired: boolean) => Promise<T>,\n): Promise<T> {\n if (typeof lockPath !== \"string\" || lockPath.length === 0) {\n throw new TypeError(\"withHeldFileLock: lockPath must be a non-empty string\");\n }\n if (typeof opts?.staleMs !== \"number\" || !Number.isFinite(opts.staleMs) || opts.staleMs <= 0) {\n throw new TypeError(\n `withHeldFileLock: opts.staleMs must be a positive finite number ` +\n `(valid range: > 0 ms, finite; got ${formatInvalidNumber(opts?.staleMs)}).`,\n );\n }\n\n // Validate optional timings: a NaN/Infinity here is a real hazard (e.g.\n // `Date.now() + NaN` === NaN, so `Date.now() >= deadline` is always false and\n // the bounded acquire loop would wait forever instead of falling back to\n // best-effort). Reject invalid input rather than silently defaulting it\n // (codex P2 review). Omitting an option still picks its default.\n const maxWaitMs = optionalPositiveMs(opts.maxWaitMs, \"maxWaitMs\", DEFAULT_MAX_WAIT_MS, MAX_TIMER_DELAY_MS);\n const pollMs = optionalPositiveMs(opts.pollMs, \"pollMs\", DEFAULT_POLL_MS, MAX_TIMER_DELAY_MS);\n const heartbeatMs = optionalPositiveMs(\n opts.heartbeatMs,\n \"heartbeatMs\",\n Math.max(MIN_HEARTBEAT_MS, Math.floor(opts.staleMs / 3)),\n MAX_TIMER_DELAY_MS,\n );\n if (heartbeatMs >= opts.staleMs) {\n throw new TypeError(\n `withHeldFileLock: heartbeatMs (${heartbeatMs}) must be below staleMs (${opts.staleMs}) ` +\n `(valid range: > 0 and < staleMs ms) so at least one heartbeat lands per stale window.`,\n );\n }\n if (heartbeatMs > MAX_TIMER_DELAY_MS) {\n throw new TypeError(\n `withHeldFileLock: derived heartbeatMs (${heartbeatMs} = floor(staleMs/3)) exceeds ` +\n `Node's setTimeout ceiling (${MAX_TIMER_DELAY_MS} ms). Use an explicit opts.heartbeatMs ` +\n `at or below ${MAX_TIMER_DELAY_MS} ms.`,\n );\n }\n // Wrap the consumer's warning hook so a throwing callback never turns a\n // non-fatal advisory lock warning into an unhandled rejection (heartbeat\n // catch handler) or overrides the task's result (release path). The option\n // is documented as never throwing into the caller; enforce that here\n // (codex P2 review).\n const rawWarn = opts.onLockWarning;\n const warn = (message: string, err: unknown): void => {\n if (!rawWarn) return;\n try {\n rawWarn(message, err);\n } catch {\n /* swallow — a throwing advisory hook must not crash the guarded op */\n }\n };\n\n // Per-call owner identity. Two withHeldFileLock calls in the SAME process\n // get different ids, so neither mistakes the other's lock for its own\n // (stronger than the catalog's per-instance id, which is what we want for a\n // stateless utility).\n const ownerId = randomUUID();\n const lockDir = path.dirname(lockPath);\n\n const held = await acquireLock(lockPath, lockDir, ownerId, opts, maxWaitMs, pollMs);\n if (!held) {\n // Best-effort: run the task WITHOUT the lock. The caller decides what to\n // do (the catalog touch path will drop its append); we never crash the\n // primary op on contention.\n return task(false);\n }\n\n // Heartbeat: while WE hold the lock, refresh its mtime so age-based stale\n // detection sees an active holder and does not break us out from under\n // (catalog round 5). Failures are swallowed (advisory lock); the timer is\n // always cleared in the finally.\n //\n // OWNERSHIP CHECK (codex P2): if our event loop was paused long enough that\n // another process judged us stale, broke our lock, and created a replacement,\n // we must NOT refresh the replacement's mtime — that would keep a (possibly\n // crashed) replacement looking fresh. Verify lockHeldBySelf before each\n // utimes; if ownership is lost, stop heartbeating (our lock is gone).\n const heartbeat = setInterval(() => {\n lockHeldBySelf(held)\n .then((ours) => {\n if (!ours) return; // broken/replaced — stop refreshing\n return utimes(held.path, new Date(), new Date());\n })\n .catch((err: unknown) => {\n warn(\"withHeldFileLock heartbeat refresh failed\", err);\n });\n }, heartbeatMs);\n // Don't keep the event loop alive solely for the heartbeat.\n heartbeat.unref?.();\n try {\n return await task(true);\n } finally {\n clearInterval(heartbeat);\n await releaseLock(held, warn, opts.onAfterReleaseRenameForTest);\n }\n}\n\n/**\n * Resolve an optional millisecond timing option, REJECTING invalid values\n * (NaN, Infinity, non-positive, or above `maxMs`) rather than silently defaulting\n * them. A NaN or Infinity maxWaitMs would make the bounded acquire loop wait\n * forever (`Date.now() + NaN` is NaN); a non-positive poll/heartbeat makes no\n * sense. Timer-backed options (pollMs, heartbeatMs) are bounded to Node's\n * setTimeout ceiling (`MAX_TIMER_DELAY_MS`): a value above 2^31−1 is silently\n * clamped to 1ms by the timer, turning a typo into tight polling (codex P2).\n * Omitting the option (`undefined`) picks `fallback`. Non-number types are also\n * rejected (defensive against config/env coercion).\n */\nfunction optionalPositiveMs(\n value: number | undefined,\n name: \"maxWaitMs\" | \"pollMs\" | \"heartbeatMs\",\n fallback: number,\n maxMs: number,\n): number {\n if (value === undefined) return fallback;\n if (typeof value !== \"number\" || !Number.isFinite(value) || value <= 0) {\n throw new TypeError(\n `withHeldFileLock: opts.${name} must be a positive finite number ` +\n `(valid range: > 0 ms, finite; got ${formatInvalidNumber(value)}). ` +\n `Omit the option to use the default of ${fallback} ms.`,\n );\n }\n if (value > maxMs) {\n throw new TypeError(\n `withHeldFileLock: opts.${name} (${value} ms) exceeds the ${maxMs} ms ` +\n `ceiling (Node's setTimeout clamps larger delays to 1ms, turning a ` +\n `typo into tight polling). Omit the option to use the default of ${fallback} ms.`,\n );\n }\n return value;\n}\n\n/**\n * Human-readable label for a rejected numeric input. Makes the error message\n * immediately actionable for NaN/Infinity (which print as \"NaN\"/\"Infinity\" via\n * String() but are easier to triage with an explicit sign), and surfaces the\n * actual type for non-number values (defensive against config/env coercion).\n */\nfunction formatInvalidNumber(value: unknown): string {\n if (typeof value === \"number\") {\n if (Number.isNaN(value)) return \"NaN\";\n if (value === Infinity) return \"+Infinity\";\n if (value === -Infinity) return \"-Infinity\";\n return String(value);\n }\n return `${typeof value} ${JSON.stringify(value)}`;\n}\n\n/**\n * Atomically create the lock file, looping until acquired/stale-broken/timeout.\n * Returns the held-lock handle on success, or `undefined` on bounded-timeout.\n * Unexpected FS errors proceed best-effort (return undefined) rather than\n * crashing the guarded op, matching the catalog.\n */\nasync function acquireLock(\n lockPath: string,\n lockDir: string,\n ownerId: string,\n opts: HeldFileLockOptions,\n maxWaitMs: number,\n pollMs: number,\n): Promise<HeldLock | undefined> {\n try {\n await mkdir(lockDir, { recursive: true });\n } catch {\n // Lock-directory setup failure (e.g. an intermediate path is a file, or\n // permissions deny mkdir) must NOT crash the guarded op — the advisory\n // lock contract is best-effort. Return undefined so task(false) runs\n // instead of rejecting (codex P2 review).\n return undefined;\n }\n const deadline = Date.now() + maxWaitMs;\n for (;;) {\n try {\n const handle = await open(lockPath, \"wx\");\n let wroteMeta = true;\n try {\n await handle.writeFile(`${process.pid} ${ownerId} ${new Date().toISOString()}\\n`, \"utf8\");\n } catch {\n // The metadata write failed; the lock file may be empty or partial.\n // Our ownership check on release would NOT find this ownerId, leaving\n // a malformed lock that lingers until stale and blocks other callers\n // out of the mutex (codex P2). Undo our exclusive create and report\n // acquisition failure so the caller runs best-effort instead.\n wroteMeta = false;\n } finally {\n try {\n await handle.close();\n } catch {\n // close() can report a deferred I/O error (e.g. write that appeared\n // to succeed but failed on flush). The lock file may be malformed —\n // treat it as a metadata-write failure so the cleanup path unlinks\n // the orphaned lock (codex P2 review).\n wroteMeta = false;\n }\n }\n if (!wroteMeta) {\n await unlink(lockPath).catch(() => undefined);\n return undefined;\n }\n return { path: lockPath, ownerId };\n } catch (err) {\n if ((err as NodeJS.ErrnoException | undefined)?.code !== \"EEXIST\") {\n // Unexpected FS error — proceed best-effort without the lock.\n return undefined;\n }\n // Lock exists: break it if stale, then poll. breakStaleLock is\n // replacement-safe (NG7Bg) and never throws.\n await breakStaleLock(lockPath, opts.staleMs, opts.onBeforeBreakStaleUnlinkForTest);\n if (Date.now() >= deadline) return undefined;\n // Cap the sleep to the remaining budget so a large pollMs cannot block\n // acquisition far past maxWaitMs (e.g. maxWaitMs=1000, pollMs=60000\n // would otherwise block ~60s instead of 1s — codex P2).\n await sleep(Math.min(pollMs, deadline - Date.now()));\n }\n }\n}\n\n/**\n * Replacement-safe stale-lock breaking (NG7Bg, #1506 round 28). Capture the\n * lock's identity when judging it stale, then ATOMICALLY rename it to a unique\n * trash path and verify the moved content matches. A replacement lock created\n * in the race window is either left untouched (different identity at\n * lockPath, so the rename moves the stale lock — not the replacement) or\n * restored (if the rename accidentally moves a replacement, the verify\n * detects the mismatch and renames it back).\n *\n * ATOMICITY (codex P2): `rename` is atomic on POSIX — only ONE contender can\n * successfully rename a given file. This eliminates the TOCTOU between the\n * identity/stat checks and the deletion that a bare `unlink` leaves open:\n * without rename, contender A could verify identity X, pause, then unlink\n * contender B's freshly acquired replacement Y. With rename, A moves whatever\n * is at lockPath, then checks: if it is X, A broke the stale lock; if it is\n * not X (a replacement appeared between A's last check and the rename), A\n * restores it.\n */\nasync function breakStaleLock(\n lockPath: string,\n staleMs: number,\n onBeforeBreakStaleUnlinkForTest: (() => Promise<void> | void) | undefined,\n): Promise<void> {\n let staleIdentity: string;\n try {\n const info = await stat(lockPath);\n if (Date.now() - info.mtimeMs <= staleMs) {\n // Not stale (a live holder's heartbeat keeps it fresh) — leave it.\n return;\n }\n staleIdentity = await readFile(lockPath, \"utf8\");\n } catch {\n // Lock vanished (released by holder) or stat/read failed — nothing to do.\n return;\n }\n // Test seam: simulate a replacement lock being created in the race window\n // between the staleness judgment and the atomic break. No-op in production.\n if (onBeforeBreakStaleUnlinkForTest) {\n await onBeforeBreakStaleUnlinkForTest();\n }\n try {\n // Re-validate immediately before breaking: the lock must still carry the\n // SAME identity AND still be stale.\n const current = await readFile(lockPath, \"utf8\");\n if (current !== staleIdentity) return; // replaced — leave the fresh lock\n const recheck = await stat(lockPath);\n if (Date.now() - recheck.mtimeMs <= staleMs) return; // heartbeat refreshed it\n\n // ATOMIC BREAK: rename is atomic on POSIX. Only one contender succeeds;\n // others get ENOENT (the file is already gone). After the rename, verify\n // the moved content: if it matches staleIdentity we broke the right lock;\n // if it does not, a replacement appeared in the window and we restore it.\n const trashPath = `${lockPath}.breaking.${process.pid}.${Date.now()}`;\n await rename(lockPath, trashPath);\n try {\n const moved = await readFile(trashPath, \"utf8\");\n if (moved !== staleIdentity) {\n // We accidentally moved a replacement lock (created between our last\n // check and the rename). Restore it so the replacement holder's lock\n // survives. Use link (not rename) to AVOID overwriting a fresh lock\n // that a third contender may have acquired at lockPath while the file\n // was in trash: link fails with EEXIST if lockPath exists, leaving\n // the third contender's lock intact (codex P2 review).\n try {\n await link(trashPath, lockPath);\n // link succeeded — remove the redundant trash hard link. The lock\n // now lives only at lockPath.\n await unlink(trashPath).catch(() => undefined);\n } catch {\n // lockPath already exists (a third contender acquired it). Do NOT\n // unlink the moved file — it may be a LIVE lock whose holder is\n // still in its critical section. Destroying it would leave the\n // holder running with no visible lock, breaking mutual exclusion\n // (codex P2). Leave it in trash as a breadcrumb; it is not at\n // lockPath so it does not block other contenders.\n }\n } else {\n // Content matches — but verify the moved file is STILL stale. The\n // original holder may have resumed and heartbeated between our\n // pre-rename stat() and the rename, refreshing the mtime. If so, the\n // holder is live: restore the lock instead of deleting it (codex P2).\n const movedStat = await stat(trashPath);\n if (Date.now() - movedStat.mtimeMs <= staleMs) {\n // Mtime was refreshed — the holder resumed. Restore the lock.\n try {\n await link(trashPath, lockPath);\n await unlink(trashPath).catch(() => undefined);\n } catch {\n // lockPath already exists — another contender acquired it. Do NOT\n // unlink the moved file (it may be a live lock). Leave it in trash.\n }\n } else {\n // Still stale — we broke the right lock. Clean up the trash.\n await unlink(trashPath).catch(() => undefined);\n }\n }\n } catch {\n // Could not read the trash file — clean it up best-effort.\n await unlink(trashPath).catch(() => undefined);\n }\n } catch {\n // The lock changed/vanished between checks — another process handled it.\n }\n}\n\n/**\n * Release the lock ONLY if its content still identifies THIS acquirer (same\n * owner id). Two-stage ownership check:\n *\n * 1. PRE-CHECK (chatgpt-codex-connector P2): read lockPath BEFORE renaming.\n * If the lock is already a replacement (a contender broke our stale lock),\n * return WITHOUT renaming — renaming a replacement out of lockPath leaves\n * it empty, letting a third contender acquire while the replacement holder\n * is still active. The replacement is safe at lockPath; leave it alone.\n *\n * 2. ATOMIC CLAIM: if the pre-check saw our ownerId, rename lockPath→trash\n * (POSIX-atomic) and re-verify on the moved file. A replacement could\n * appear between the pre-check and the rename; if the moved file is no\n * longer ours, restore it via link (non-overwriting). This ties the\n * ownership check to the deletion so a bare readFile-then-unlink TOCTOU\n * cannot delete a fresh replacement (codex P2).\n */\nasync function releaseLock(\n held: HeldLock,\n warn: (message: string, err: unknown) => void,\n onAfterReleaseRenameForTest: (() => Promise<void> | void) | undefined,\n): Promise<void> {\n try {\n // PRE-CHECK (chatgpt-codex-connector P2): read lockPath before renaming. If\n // the lock is no longer ours, a contender broke our stale lock and created a\n // replacement. Return WITHOUT renaming — renaming the replacement out of\n // lockPath leaves it empty, so a third contender could acquire while the\n // replacement holder is still active. The replacement is safe at lockPath.\n let precheck: string;\n try {\n precheck = await readFile(held.path, \"utf8\");\n } catch {\n return; // lock vanished — nothing to release.\n }\n if (!precheck.includes(held.ownerId)) {\n return; // replacement lock — leave it untouched for its holder.\n }\n // It was ours when we read it. Atomically claim via rename, then re-verify\n // on the moved file: a replacement could appear between the pre-check read\n // above and this rename.\n const trashPath = `${held.path}.releasing.${process.pid}.${Date.now()}`;\n await rename(held.path, trashPath);\n // Test seam: simulate a third contender acquiring the now-empty lockPath\n // in the rename-to-restore window. No-op in production.\n if (onAfterReleaseRenameForTest) {\n await onAfterReleaseRenameForTest();\n }\n try {\n const moved = await readFile(trashPath, \"utf8\");\n if (moved.includes(held.ownerId)) {\n // Still our lock — safe to delete.\n await unlink(trashPath).catch(() => undefined);\n } else {\n // Not ours: a replacement appeared between the pre-check and the rename.\n // Restore it via link (non-overwriting — if lockPath already has a newer\n // lock, leave it).\n try {\n await link(trashPath, held.path);\n } catch {\n // lockPath already exists — a newer holder is active. Leave the\n // moved file in trash rather than destroying a live lock (codex P2).\n return;\n }\n await unlink(trashPath).catch(() => undefined);\n }\n } catch {\n // Could not read the moved file — clean it up best-effort.\n await unlink(trashPath).catch(() => undefined);\n }\n } catch (err) {\n // Best-effort release; a stale lock will be broken on the next acquire.\n warn(\"withHeldFileLock release failed\", err);\n }\n}\n\n/**\n * Whether the lock file at `held.path` was written by THIS acquirer (same owner\n * id). Reads the content and matches the `<pid> <owner-uuid>` prefix; the iso\n * timestamp varies so it is not part of the identity check.\n */\nasync function lockHeldBySelf(held: HeldLock): Promise<boolean> {\n try {\n const body = await readFile(held.path, \"utf8\");\n const parts = body.trim().split(/\\s+/);\n const fileOwner = parts[1];\n return typeof fileOwner === \"string\" && fileOwner === held.ownerId;\n } catch {\n return false;\n }\n}\n\nfunction sleep(ms: number): Promise<void> {\n const { promise, resolve } = Promise.withResolvers<void>();\n // NOT unref'd: this polls inside an awaited acquire loop, so the caller's\n // await chain keeps the loop alive; unref would let Node exit mid-poll when\n // nothing else is pending (the heartbeat interval IS unref'd separately).\n setTimeout(resolve, ms);\n return promise;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/utils/serialize-mutations.ts"],"sourcesContent":["// ---------------------------------------------------------------------------\n// Shared serialized-mutation utilities for TOCTOU hotspots (issue #1524).\n//\n// Two complementary primitives that the namespace catalog (`queueCritical` +\n// `withHeldCatalogLock`), the storage router's resolve-hook serialization, and\n// the summary-snapshot writer each re-implement today:\n//\n// 1. `serializeMutations(key, task)` — keyed IN-PROCESS async serialization\n// that recovers after a rejection (CLAUDE.md rule #40). One failed task\n// never poisons the tasks queued behind it; the failed task's error is\n// still surfaced to ITS caller.\n//\n// 2. `withHeldFileLock(lockPath, opts, task)` — a held CROSS-PROCESS file\n// lock with replacement-safe stale breaking (the NG7Bg invariant from\n// #1506 round 28) and ownership-checked release.\n//\n// This is the UTILITY module only. Per-issue PR split: one PR for the utility\n// + tests (this file), then one PR per adoption hotspot (catalog, router\n// provenance, summary snapshot). No adoptions live here.\n// ---------------------------------------------------------------------------\n\nimport { randomUUID } from \"node:crypto\";\nimport { link, mkdir, open, readFile, rename, stat, unlink, utimes } from \"node:fs/promises\";\nimport path from \"node:path\";\n\n// ─────────────────────────────────────────────────────────────────────────────\n// 1. serializeMutations — keyed async serialization with rejection recovery\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * One entry in the per-key serialization map. `tail` is the recovered promise\n * the next queued task chains off of; it never rejects (both settle handlers\n * swallow), so a prior task's failure can never break subsequent ones.\n */\ninterface MutationChainEntry {\n tail: Promise<void>;\n}\n\n/**\n * Instance-scoped keyed serializer. Holds the per-key chain map so that all\n * tasks queued under the same key on the SAME serializer run strictly in order.\n *\n * The map is instance-scoped (not module-level) so tests can construct a fresh\n * serializer per case and avoid cross-test contamination, and so adopters that\n * want isolation (e.g. one serializer per storage root) can have it. The free\n * {@link serializeMutations} export delegates to a single shared default\n * instance for callers that want process-wide serialization.\n */\nexport class MutationSerializer {\n private readonly chains = new Map<string, MutationChainEntry>();\n\n /**\n * Run `task` strictly after every other task already queued under `key` on\n * this serializer has settled.\n *\n * Rejection recovery (rule #40, mirroring the catalog's `queueCritical`):\n * if a prior task rejects, later tasks STILL RUN, while the rejecting task's\n * error is surfaced to ITS OWN caller. Concretely, the recovered tail is\n * `run.then(noop, noop)` — never a bare `.then(fn)`, which would let one\n * failure kill every queued task behind it.\n *\n * No unbounded growth: when a chain's last task settles and no newer task\n * chained onto it, its entry is deleted (the storage router's\n * `inFlightResolved` marker-then-clear discipline).\n */\n serialize<T>(key: string, task: () => Promise<T>): Promise<T> {\n if (typeof key !== \"string\" || key.length === 0) {\n throw new TypeError(\"MutationSerializer.serialize: key must be a non-empty string\");\n }\n if (typeof task !== \"function\") {\n throw new TypeError(\"MutationSerializer.serialize: task must be a function returning a promise\");\n }\n\n let entry = this.chains.get(key);\n if (!entry) {\n entry = { tail: Promise.resolve() };\n this.chains.set(key, entry);\n }\n\n // Chain this task off the prior tail. `tail.then(task)` runs task only once\n // the previous task has settled, preserving read-modify-write ordering.\n const run = entry.tail.then(task);\n\n // Recover the tail after a rejection so a failed task never poisons later\n // ones. Both handlers swallow; `run` still carries the original resolution\n // (or rejection) to THIS caller. This is the line a naive `.then(fn)`\n // implementation omits — see the \"naive poison chain\" prove-fail test.\n const recovered = run.then(settleNoop, settleNoop);\n entry.tail = recovered;\n\n // Self-cleaning: once our recovered tail settles, if no newer task chained\n // onto us the entry still points at `recovered` and is safe to delete. A\n // concurrent `serialize()` call enqueues synchronously and would have\n // replaced `entry.tail` BEFORE this microtask runs, so the identity check\n // is race-free (no newer task's entry can be wrongly removed).\n //\n // `recovered` cannot reject in correct operation (both handlers above\n // swallow) and the cleanup body cannot throw — but we attach a rejection\n // handler anyway so that IF the recovery invariant is ever broken, the\n // failure surfaces as a behavioral assertion (skipped tasks) rather than an\n // unhandled-rejection storm that masks which task failed. The handler is a\n // no-op: cleanup only runs on fulfillment.\n void recovered.then(\n () => {\n if (entry && entry.tail === recovered) {\n this.chains.delete(key);\n }\n },\n () => undefined,\n );\n\n return run;\n }\n\n /**\n * Test-only: the number of keys with a not-yet-cleaned chain. Used to assert\n * the no-unbounded-growth invariant. Not part of the public contract.\n */\n pendingKeysForTest(): number {\n return this.chains.size;\n }\n}\n\n/**\n * Recovery handler shared by both settle arms. Named (not inline\n * `() => undefined`) so the chain assignment stays self-documenting in stack\n * traces and the review-patterns poison-chain check can see the chain is\n * recovered, not bare `.then(fn)`.\n */\nfunction settleNoop(): void {\n /* swallow — the original resolution/rejection is carried by `run` */\n}\n\n/**\n * Process-wide default serializer backing the free {@link serializeMutations}\n * export. Lazy so it is only created when first used (tests that construct\n * their own `MutationSerializer` pay nothing).\n */\nlet defaultSerializer: MutationSerializer | undefined;\n\n/**\n * Free-function entry point (issue #1524 signature). Serializes `task` against\n * every other task queued under `key` across the whole process, via a shared\n * default {@link MutationSerializer}. For isolated/testable serialization,\n * construct a `MutationSerializer` directly.\n */\nexport function serializeMutations<T>(key: string, task: () => Promise<T>): Promise<T> {\n if (!defaultSerializer) defaultSerializer = new MutationSerializer();\n return defaultSerializer.serialize(key, task);\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// 2. withHeldFileLock — cross-process held file lock with stale breaking\n// ─────────────────────────────────────────────────────────────────────────────\n\n/** Options for {@link withHeldFileLock}. */\nexport interface HeldFileLockOptions {\n /**\n * A lock whose mtime is older than this (in ms) is treated as a crashed\n * holder and broken. Required — there is no safe default, since the right\n * value depends on how long the guarded critical section can legitimately\n * run.\n */\n readonly staleMs: number;\n /**\n * Bounded acquisition: give up trying to acquire a busy lock after this long\n * (ms) and invoke `task(false)` best-effort WITHOUT holding the lock, rather\n * than blocking forever or crashing the primary op. Default 5000ms (matches\n * the namespace catalog's `REBUILD_LOCK_MAX_WAIT_MS`).\n */\n readonly maxWaitMs?: number;\n /**\n * Poll interval (ms) while waiting for a busy lock to clear. Default 50ms.\n */\n readonly pollMs?: number;\n /**\n * While WE hold the lock, refresh its mtime on this cadence (ms) so a\n * legitimately long task is not mistaken for a crashed holder and broken out\n * from under. Default `floor(staleMs / 3)` (at least 100ms), mirroring the\n * catalog heartbeat ratio. Must be comfortably below `staleMs`.\n */\n readonly heartbeatMs?: number;\n /**\n * Test seam (NG7Bg, #1506 round 28): fires AFTER a lock is judged stale and\n * BEFORE the re-verify + unlink, simulating a replacement lock being created\n * in the race window. No-op in production.\n */\n readonly onBeforeBreakStaleUnlinkForTest?: () => Promise<void> | void;\n /**\n * Test seam (codex P2): fires AFTER the release rename moves the lock to a\n * trash path and BEFORE the ownership re-verify/restore — simulating a third\n * contender acquiring the (now-empty) lockPath in the race window. No-op in\n * production. Used to prove the pre-check prevents the rename entirely.\n */\n readonly onAfterReleaseRenameForTest?: () => Promise<void> | void;\n /**\n * Best-effort hook for non-fatal lock warnings (heartbeat refresh failure,\n * release-time ownership check failure). Never throws into the caller. If\n * omitted, warnings are swallowed (the lock is advisory; release/heartbeat\n * failures must never crash the guarded op).\n */\n readonly onLockWarning?: (message: string, err: unknown) => void;\n}\n\n/** Default bounded acquisition wait, mirroring the catalog. */\nconst DEFAULT_MAX_WAIT_MS = 5_000;\n/** Default busy-lock poll interval, mirroring the catalog. */\nconst DEFAULT_POLL_MS = 50;\n/** Floor for the derived heartbeat cadence. */\nconst MIN_HEARTBEAT_MS = 100;\n/** Node's setTimeout/setInterval 32-bit signed-int ceiling (2^31 − 1 ms ≈ 24.8\n * days). Delays above this are silently clamped to 1ms by the Node timer, so\n * timer-backed options (pollMs, heartbeatMs) must be rejected at this boundary\n * (chatgpt-codex-connector P2). */\nconst MAX_TIMER_DELAY_MS = 2_147_483_647;\n\n/** Internal handle for a lock we successfully acquired. */\ninterface HeldLock {\n readonly path: string;\n readonly ownerId: string;\n}\n\n/**\n * Run `task` under an exclusive on-disk lock at `lockPath`.\n *\n * Cross-process mutex via `open(lockPath, \"wx\")` (atomic exclusive create).\n * While held, a heartbeat timer refreshes the lock's mtime so a legitimately\n * long task is not mistaken for a crashed holder and broken out from under. A\n * lock older than `opts.staleMs` is treated as stale and broken — but\n * REPLACEMENT-SAFE (NG7Bg): we capture the stale lock's identity (full content\n * line: `<pid> <owner-uuid> <iso>`) when judging it stale, then RE-READ and\n * RE-STAT immediately before `unlink`, deleting only if byte-identical AND\n * still stale. A replacement lock created in the window has a different owner\n * id / timestamp, so its content differs and is left untouched.\n *\n * `task` receives `acquired: boolean` — `true` when we hold the lock, `false`\n * when acquisition timed out (best-effort). The signature takes\n * `(acquired) => Promise<T>` rather than the issue's sketched `() => Promise<T>`\n * so this can be the SINGLE lock home (issue: \"do NOT leave two lock\n * implementations; pick one home\"): the catalog's touch path needs to DROP on\n * timeout, which requires knowing whether the lock was acquired. A caller that\n * ignores the flag is still assignable (`() => Promise<T>` ⊆\n * `(acquired: boolean) => Promise<T>` in TypeScript).\n *\n * Release is ownership-checked: we only `unlink` a lock whose content still\n * identifies THIS acquirer (same owner id), so a replacement created after we\n * stopped heartbeating is never destroyed — mirroring the catalog's\n * `rebuildLockHeldBySelf`.\n *\n * ADOPTION NOTE: lock only the brief final read-merge-write window, never a\n * long scan — a scan-length lock makes concurrent writers time out and\n * silently drop work (catalog round 5, codex/cursor P2).\n */\nexport async function withHeldFileLock<T>(\n lockPath: string,\n opts: HeldFileLockOptions,\n task: (acquired: boolean) => Promise<T>,\n): Promise<T> {\n if (typeof lockPath !== \"string\" || lockPath.length === 0) {\n throw new TypeError(\"withHeldFileLock: lockPath must be a non-empty string\");\n }\n if (typeof opts?.staleMs !== \"number\" || !Number.isFinite(opts.staleMs) || opts.staleMs <= 0) {\n throw new TypeError(\n `withHeldFileLock: opts.staleMs must be a positive finite number ` +\n `(valid range: > 0 ms, finite; got ${formatInvalidNumber(opts?.staleMs)}).`,\n );\n }\n\n // Validate optional timings: a NaN/Infinity here is a real hazard (e.g.\n // `Date.now() + NaN` === NaN, so `Date.now() >= deadline` is always false and\n // the bounded acquire loop would wait forever instead of falling back to\n // best-effort). Reject invalid input rather than silently defaulting it\n // (codex P2 review). Omitting an option still picks its default.\n const maxWaitMs = optionalPositiveMs(opts.maxWaitMs, \"maxWaitMs\", DEFAULT_MAX_WAIT_MS, MAX_TIMER_DELAY_MS);\n const pollMs = optionalPositiveMs(opts.pollMs, \"pollMs\", DEFAULT_POLL_MS, MAX_TIMER_DELAY_MS);\n const heartbeatMs = optionalPositiveMs(\n opts.heartbeatMs,\n \"heartbeatMs\",\n Math.max(MIN_HEARTBEAT_MS, Math.floor(opts.staleMs / 3)),\n MAX_TIMER_DELAY_MS,\n );\n if (heartbeatMs >= opts.staleMs) {\n throw new TypeError(\n `withHeldFileLock: heartbeatMs (${heartbeatMs}) must be below staleMs (${opts.staleMs}) ` +\n `(valid range: > 0 and < staleMs ms) so at least one heartbeat lands per stale window.`,\n );\n }\n if (heartbeatMs > MAX_TIMER_DELAY_MS) {\n throw new TypeError(\n `withHeldFileLock: derived heartbeatMs (${heartbeatMs} = floor(staleMs/3)) exceeds ` +\n `Node's setTimeout ceiling (${MAX_TIMER_DELAY_MS} ms). Use an explicit opts.heartbeatMs ` +\n `at or below ${MAX_TIMER_DELAY_MS} ms.`,\n );\n }\n // Wrap the consumer's warning hook so a throwing callback never turns a\n // non-fatal advisory lock warning into an unhandled rejection (heartbeat\n // catch handler) or overrides the task's result (release path). The option\n // is documented as never throwing into the caller; enforce that here\n // (codex P2 review).\n const rawWarn = opts.onLockWarning;\n const warn = (message: string, err: unknown): void => {\n if (!rawWarn) return;\n try {\n rawWarn(message, err);\n } catch {\n /* swallow — a throwing advisory hook must not crash the guarded op */\n }\n };\n\n // Per-call owner identity. Two withHeldFileLock calls in the SAME process\n // get different ids, so neither mistakes the other's lock for its own\n // (stronger than the catalog's per-instance id, which is what we want for a\n // stateless utility).\n const ownerId = randomUUID();\n const lockDir = path.dirname(lockPath);\n\n const held = await acquireLock(lockPath, lockDir, ownerId, opts, maxWaitMs, pollMs);\n if (!held) {\n // Best-effort: run the task WITHOUT the lock. The caller decides what to\n // do (the catalog touch path will drop its append); we never crash the\n // primary op on contention.\n return task(false);\n }\n\n // Heartbeat: while WE hold the lock, refresh its mtime so age-based stale\n // detection sees an active holder and does not break us out from under\n // (catalog round 5). Failures are swallowed (advisory lock); the timer is\n // always cleared in the finally.\n //\n // OWNERSHIP CHECK (codex P2): if our event loop was paused long enough that\n // another process judged us stale, broke our lock, and created a replacement,\n // we must NOT refresh the replacement's mtime — that would keep a (possibly\n // crashed) replacement looking fresh. Verify lockHeldBySelf before each\n // utimes; if ownership is lost, stop heartbeating (our lock is gone).\n const heartbeat = setInterval(() => {\n lockHeldBySelf(held)\n .then((ours) => {\n if (!ours) return; // broken/replaced — stop refreshing\n return utimes(held.path, new Date(), new Date());\n })\n .catch((err: unknown) => {\n warn(\"withHeldFileLock heartbeat refresh failed\", err);\n });\n }, heartbeatMs);\n // Don't keep the event loop alive solely for the heartbeat.\n heartbeat.unref?.();\n try {\n return await task(true);\n } finally {\n clearInterval(heartbeat);\n await releaseLock(held, warn, opts.onAfterReleaseRenameForTest);\n }\n}\n\n/**\n * Resolve an optional millisecond timing option, REJECTING invalid values\n * (NaN, Infinity, non-positive, or above `maxMs`) rather than silently defaulting\n * them. A NaN or Infinity maxWaitMs would make the bounded acquire loop wait\n * forever (`Date.now() + NaN` is NaN); a non-positive poll/heartbeat makes no\n * sense. Timer-backed options (pollMs, heartbeatMs) are bounded to Node's\n * setTimeout ceiling (`MAX_TIMER_DELAY_MS`): a value above 2^31−1 is silently\n * clamped to 1ms by the timer, turning a typo into tight polling (codex P2).\n * Omitting the option (`undefined`) picks `fallback`. Non-number types are also\n * rejected (defensive against config/env coercion).\n */\nfunction optionalPositiveMs(\n value: number | undefined,\n name: \"maxWaitMs\" | \"pollMs\" | \"heartbeatMs\",\n fallback: number,\n maxMs: number,\n): number {\n if (value === undefined) return fallback;\n if (typeof value !== \"number\" || !Number.isFinite(value) || value <= 0) {\n throw new TypeError(\n `withHeldFileLock: opts.${name} must be a positive finite number ` +\n `(valid range: > 0 ms, finite; got ${formatInvalidNumber(value)}). ` +\n `Omit the option to use the default of ${fallback} ms.`,\n );\n }\n if (value > maxMs) {\n throw new TypeError(\n `withHeldFileLock: opts.${name} (${value} ms) exceeds the ${maxMs} ms ` +\n `ceiling (Node's setTimeout clamps larger delays to 1ms, turning a ` +\n `typo into tight polling). Omit the option to use the default of ${fallback} ms.`,\n );\n }\n return value;\n}\n\n/**\n * Human-readable label for a rejected numeric input. Makes the error message\n * immediately actionable for NaN/Infinity (which print as \"NaN\"/\"Infinity\" via\n * String() but are easier to triage with an explicit sign), and surfaces the\n * actual type for non-number values (defensive against config/env coercion).\n */\nfunction formatInvalidNumber(value: unknown): string {\n if (typeof value === \"number\") {\n if (Number.isNaN(value)) return \"NaN\";\n if (value === Infinity) return \"+Infinity\";\n if (value === -Infinity) return \"-Infinity\";\n return String(value);\n }\n return `${typeof value} ${JSON.stringify(value)}`;\n}\n\n/**\n * Atomically create the lock file, looping until acquired/stale-broken/timeout.\n * Returns the held-lock handle on success, or `undefined` on bounded-timeout.\n * Unexpected FS errors proceed best-effort (return undefined) rather than\n * crashing the guarded op, matching the catalog.\n */\nasync function acquireLock(\n lockPath: string,\n lockDir: string,\n ownerId: string,\n opts: HeldFileLockOptions,\n maxWaitMs: number,\n pollMs: number,\n): Promise<HeldLock | undefined> {\n try {\n await mkdir(lockDir, { recursive: true });\n } catch {\n // Lock-directory setup failure (e.g. an intermediate path is a file, or\n // permissions deny mkdir) must NOT crash the guarded op — the advisory\n // lock contract is best-effort. Return undefined so task(false) runs\n // instead of rejecting (codex P2 review).\n return undefined;\n }\n const deadline = Date.now() + maxWaitMs;\n for (;;) {\n try {\n const handle = await open(lockPath, \"wx\");\n let wroteMeta = true;\n try {\n await handle.writeFile(`${process.pid} ${ownerId} ${new Date().toISOString()}\\n`, \"utf8\");\n } catch {\n // The metadata write failed; the lock file may be empty or partial.\n // Our ownership check on release would NOT find this ownerId, leaving\n // a malformed lock that lingers until stale and blocks other callers\n // out of the mutex (codex P2). Undo our exclusive create and report\n // acquisition failure so the caller runs best-effort instead.\n wroteMeta = false;\n } finally {\n try {\n await handle.close();\n } catch {\n // close() can report a deferred I/O error (e.g. write that appeared\n // to succeed but failed on flush). The lock file may be malformed —\n // treat it as a metadata-write failure so the cleanup path unlinks\n // the orphaned lock (codex P2 review).\n wroteMeta = false;\n }\n }\n if (!wroteMeta) {\n await unlink(lockPath).catch(() => undefined);\n return undefined;\n }\n return { path: lockPath, ownerId };\n } catch (err) {\n if ((err as NodeJS.ErrnoException | undefined)?.code !== \"EEXIST\") {\n // Unexpected FS error — proceed best-effort without the lock.\n return undefined;\n }\n // Lock exists: break it if stale, then poll. breakStaleLock is\n // replacement-safe (NG7Bg) and never throws.\n await breakStaleLock(lockPath, opts.staleMs, opts.onBeforeBreakStaleUnlinkForTest);\n if (Date.now() >= deadline) return undefined;\n // Cap the sleep to the remaining budget so a large pollMs cannot block\n // acquisition far past maxWaitMs (e.g. maxWaitMs=1000, pollMs=60000\n // would otherwise block ~60s instead of 1s — codex P2).\n await sleep(Math.min(pollMs, deadline - Date.now()));\n }\n }\n}\n\n/**\n * Replacement-safe stale-lock breaking (NG7Bg, #1506 round 28). Capture the\n * lock's identity when judging it stale, then ATOMICALLY rename it to a unique\n * trash path and verify the moved content matches. A replacement lock created\n * in the race window is either left untouched (different identity at\n * lockPath, so the rename moves the stale lock — not the replacement) or\n * restored (if the rename accidentally moves a replacement, the verify\n * detects the mismatch and renames it back).\n *\n * ATOMICITY (codex P2): `rename` is atomic on POSIX — only ONE contender can\n * successfully rename a given file. This eliminates the TOCTOU between the\n * identity/stat checks and the deletion that a bare `unlink` leaves open:\n * without rename, contender A could verify identity X, pause, then unlink\n * contender B's freshly acquired replacement Y. With rename, A moves whatever\n * is at lockPath, then checks: if it is X, A broke the stale lock; if it is\n * not X (a replacement appeared between A's last check and the rename), A\n * restores it.\n */\nasync function breakStaleLock(\n lockPath: string,\n staleMs: number,\n onBeforeBreakStaleUnlinkForTest: (() => Promise<void> | void) | undefined,\n): Promise<void> {\n let staleIdentity: string;\n try {\n const info = await stat(lockPath);\n if (Date.now() - info.mtimeMs <= staleMs) {\n // Not stale (a live holder's heartbeat keeps it fresh) — leave it.\n return;\n }\n staleIdentity = await readFile(lockPath, \"utf8\");\n } catch {\n // Lock vanished (released by holder) or stat/read failed — nothing to do.\n return;\n }\n // Test seam: simulate a replacement lock being created in the race window\n // between the staleness judgment and the atomic break. No-op in production.\n if (onBeforeBreakStaleUnlinkForTest) {\n await onBeforeBreakStaleUnlinkForTest();\n }\n try {\n // Re-validate immediately before breaking: the lock must still carry the\n // SAME identity AND still be stale.\n const current = await readFile(lockPath, \"utf8\");\n if (current !== staleIdentity) return; // replaced — leave the fresh lock\n const recheck = await stat(lockPath);\n if (Date.now() - recheck.mtimeMs <= staleMs) return; // heartbeat refreshed it\n\n // ATOMIC BREAK: rename is atomic on POSIX. Only one contender succeeds;\n // others get ENOENT (the file is already gone). After the rename, verify\n // the moved content: if it matches staleIdentity we broke the right lock;\n // if it does not, a replacement appeared in the window and we restore it.\n const trashPath = `${lockPath}.breaking.${process.pid}.${Date.now()}`;\n await rename(lockPath, trashPath);\n try {\n const moved = await readFile(trashPath, \"utf8\");\n if (moved !== staleIdentity) {\n // We accidentally moved a replacement lock (created between our last\n // check and the rename). Restore it so the replacement holder's lock\n // survives. Use link (not rename) to AVOID overwriting a fresh lock\n // that a third contender may have acquired at lockPath while the file\n // was in trash: link fails with EEXIST if lockPath exists, leaving\n // the third contender's lock intact (codex P2 review).\n try {\n await link(trashPath, lockPath);\n // link succeeded — remove the redundant trash hard link. The lock\n // now lives only at lockPath.\n await unlink(trashPath).catch(() => undefined);\n } catch {\n // lockPath already exists (a third contender acquired it). Do NOT\n // unlink the moved file — it may be a LIVE lock whose holder is\n // still in its critical section. Destroying it would leave the\n // holder running with no visible lock, breaking mutual exclusion\n // (codex P2). Leave it in trash as a breadcrumb; it is not at\n // lockPath so it does not block other contenders.\n }\n } else {\n // Content matches — but verify the moved file is STILL stale. The\n // original holder may have resumed and heartbeated between our\n // pre-rename stat() and the rename, refreshing the mtime. If so, the\n // holder is live: restore the lock instead of deleting it (codex P2).\n const movedStat = await stat(trashPath);\n if (Date.now() - movedStat.mtimeMs <= staleMs) {\n // Mtime was refreshed — the holder resumed. Restore the lock.\n try {\n await link(trashPath, lockPath);\n await unlink(trashPath).catch(() => undefined);\n } catch {\n // lockPath already exists — another contender acquired it. Do NOT\n // unlink the moved file (it may be a live lock). Leave it in trash.\n }\n } else {\n // Still stale — we broke the right lock. Clean up the trash.\n await unlink(trashPath).catch(() => undefined);\n }\n }\n } catch {\n // Could not read the trash file — clean it up best-effort.\n await unlink(trashPath).catch(() => undefined);\n }\n } catch {\n // The lock changed/vanished between checks — another process handled it.\n }\n}\n\n/**\n * Release the lock ONLY if its content still identifies THIS acquirer (same\n * owner id). Two-stage ownership check:\n *\n * 1. PRE-CHECK (chatgpt-codex-connector P2): read lockPath BEFORE renaming.\n * If the lock is already a replacement (a contender broke our stale lock),\n * return WITHOUT renaming — renaming a replacement out of lockPath leaves\n * it empty, letting a third contender acquire while the replacement holder\n * is still active. The replacement is safe at lockPath; leave it alone.\n *\n * 2. ATOMIC CLAIM: if the pre-check saw our ownerId, rename lockPath→trash\n * (POSIX-atomic) and re-verify on the moved file. A replacement could\n * appear between the pre-check and the rename; if the moved file is no\n * longer ours, restore it via link (non-overwriting). This ties the\n * ownership check to the deletion so a bare readFile-then-unlink TOCTOU\n * cannot delete a fresh replacement (codex P2).\n */\nasync function releaseLock(\n held: HeldLock,\n warn: (message: string, err: unknown) => void,\n onAfterReleaseRenameForTest: (() => Promise<void> | void) | undefined,\n): Promise<void> {\n try {\n // PRE-CHECK (chatgpt-codex-connector P2): read lockPath before renaming. If\n // the lock is no longer ours, a contender broke our stale lock and created a\n // replacement. Return WITHOUT renaming — renaming the replacement out of\n // lockPath leaves it empty, so a third contender could acquire while the\n // replacement holder is still active. The replacement is safe at lockPath.\n let precheck: string;\n try {\n precheck = await readFile(held.path, \"utf8\");\n } catch {\n return; // lock vanished — nothing to release.\n }\n if (!precheck.includes(held.ownerId)) {\n return; // replacement lock — leave it untouched for its holder.\n }\n // It was ours when we read it. Atomically claim via rename, then re-verify\n // on the moved file: a replacement could appear between the pre-check read\n // above and this rename.\n const trashPath = `${held.path}.releasing.${process.pid}.${Date.now()}`;\n await rename(held.path, trashPath);\n // Test seam: simulate a third contender acquiring the now-empty lockPath\n // in the rename-to-restore window. No-op in production.\n if (onAfterReleaseRenameForTest) {\n await onAfterReleaseRenameForTest();\n }\n try {\n const moved = await readFile(trashPath, \"utf8\");\n if (moved.includes(held.ownerId)) {\n // Still our lock — safe to delete.\n await unlink(trashPath).catch(() => undefined);\n } else {\n // Not ours: a replacement appeared between the pre-check and the rename.\n // Restore it via link (non-overwriting — if lockPath already has a newer\n // lock, leave it).\n try {\n await link(trashPath, held.path);\n } catch {\n // lockPath already exists — a newer holder is active. Leave the\n // moved file in trash rather than destroying a live lock (codex P2).\n return;\n }\n await unlink(trashPath).catch(() => undefined);\n }\n } catch {\n // Could not read the moved file — clean it up best-effort.\n await unlink(trashPath).catch(() => undefined);\n }\n } catch (err) {\n // Best-effort release; a stale lock will be broken on the next acquire.\n warn(\"withHeldFileLock release failed\", err);\n }\n}\n\n/**\n * Whether the lock file at `held.path` was written by THIS acquirer (same owner\n * id). Reads the content and matches the `<pid> <owner-uuid>` prefix; the iso\n * timestamp varies so it is not part of the identity check.\n */\nasync function lockHeldBySelf(held: HeldLock): Promise<boolean> {\n try {\n const body = await readFile(held.path, \"utf8\");\n const parts = body.trim().split(/\\s+/);\n const fileOwner = parts[1];\n return typeof fileOwner === \"string\" && fileOwner === held.ownerId;\n } catch {\n return false;\n }\n}\n\nfunction sleep(ms: number): Promise<void> {\n // Manual deferred instead of Promise.withResolvers (ES2024) — plugin-openclaw's\n // standalone tsconfig targets ES2022 lib and this module is reachable from its\n // type graph, so withResolvers would TS2550 there (same fix as\n // extraction-faithfulness.ts:467).\n return new Promise<void>((resolve) => {\n // NOT unref'd: this polls inside an awaited acquire loop, so the caller's\n // await chain keeps the loop alive; unref would let Node exit mid-poll when\n // nothing else is pending (the heartbeat interval IS unref'd separately).\n setTimeout(resolve, ms);\n });\n}\n"],"mappings":"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","names":[]}
@@ -4,7 +4,7 @@ import {
4
4
  } from "./chunk-UFS7OXGL.js";
5
5
  import {
6
6
  StorageManager
7
- } from "./chunk-X74FJSW7.js";
7
+ } from "./chunk-SFMRLXIV.js";
8
8
  import {
9
9
  parseContinuityImprovementLoops
10
10
  } from "./chunk-LDXUBPMO.js";
@@ -1261,4 +1261,4 @@ export {
1261
1261
  defaultTierMigrationCycleBudget,
1262
1262
  CompoundingEngine
1263
1263
  };
1264
- //# sourceMappingURL=chunk-ZDK2IW5F.js.map
1264
+ //# sourceMappingURL=chunk-MNU5G4TK.js.map
@@ -4,7 +4,10 @@ import {
4
4
  } from "./chunk-ZFXCQPNO.js";
5
5
  import {
6
6
  StorageManager
7
- } from "./chunk-X74FJSW7.js";
7
+ } from "./chunk-SFMRLXIV.js";
8
+ import {
9
+ MutationSerializer
10
+ } from "./chunk-LTJAMRGI.js";
8
11
  import {
9
12
  ALL_CATEGORY_DIRS
10
13
  } from "./chunk-VS2IYZRU.js";
@@ -113,21 +116,22 @@ var NamespaceStorageRouter = class {
113
116
  // rebuilds. We fire the hook only when the (namespace, storageDir) pair is new
114
117
  // or its dir changed, so a steady-state cache hit is a no-op for the catalog.
115
118
  notifiedResolved = /* @__PURE__ */ new Map();
116
- // In-flight resolve-hook dedup (NFJV-, codex P2). The catalog's `onResolve`
117
- // hook is ASYNC (it returns `registerResolved(...)`), so `notifiedResolved` is
118
- // only set after the hook's promise SETTLES. Without tracking the in-flight
119
- // window, a burst of `storageFor()` cache hits for the SAME namespace before
120
- // the first registration finishes would each pass the `notifiedResolved` guard
121
- // and fire their OWN `onResolve` queueing N duplicate catalog touches + lock
122
- // acquisitions despite the once-per-namespace intent. We therefore record the
123
- // (namespace storageDir) being registered BEFORE awaiting the hook so a
124
- // concurrent call for the same pair skips firing. On SUCCESS the pair is
125
- // promoted to `notifiedResolved` (future calls skip permanently); on `false`
126
- // (dropped touch e.g. rebuild-lock timeout) OR rejection the in-flight marker
127
- // is CLEARED so a later `storageFor()` can RETRY the dropped registration. The
128
- // entry is always removed when the promise settles, so the map cannot grow
129
- // unbounded (one transient entry per concurrently-resolving namespace).
130
- inFlightResolved = /* @__PURE__ */ new Map();
119
+ // Instance-scoped serializer for the resolve hook (issue #1524 adoption).
120
+ // Replaces the bespoke `inFlightResolved` marker-then-clear pattern: the
121
+ // serializer's per-key chain strictly orders concurrent notifications for the
122
+ // SAME namespace, and the task re-checks `notifiedResolved` once it runs, so a
123
+ // burst of cache hits before the first hook settles collapses to a single
124
+ // hook invocation (the once-per-namespace intent). Recovery is preserved
125
+ // one rejected hook never poisons subsequent notifications for that key.
126
+ resolveSerializer = new MutationSerializer();
127
+ // (namespace, storageDir) pairs whose resolve hook is currently pending. Set SYNCHRONOUSLY in
128
+ // notifyResolved (before queueing) so a burst of concurrent storageFor()
129
+ // cache hits collapses onto the one in-flight hook instead of each enqueuing
130
+ // its own task. Cleared when the queued hook settles. Without this, a dropped
131
+ // hook (returns false on a rebuild-lock timeout) leaves notifiedResolved
132
+ // unset, so every queued sibling task would re-run the hook — N serial lock
133
+ // waits (cursor Medium 06f58a7c, codex P2).
134
+ inFlightResolveHooks = /* @__PURE__ */ new Set();
131
135
  // Tracks every in-flight resolve-hook promise so callers can deterministically
132
136
  // await the fire-and-forget registrations that `storageFor()` kicks off (see
133
137
  // `whenResolveHooksSettled`). Entries are removed as each hook settles, so the
@@ -184,41 +188,41 @@ var NamespaceStorageRouter = class {
184
188
  /**
185
189
  * Fire the resolve hook defensively. A hook failure (e.g. a catalog write
186
190
  * error) MUST NOT crash storage resolution — see CLAUDE.md gotcha #13.
191
+ *
192
+ * Issue #1524 adoption: hook invocations for the SAME namespace are now
193
+ * strictly ordered through the shared `MutationSerializer` rather than the
194
+ * bespoke `inFlightResolved` marker-then-clear pattern. The serializer
195
+ * guarantees one in-flight hook per namespace; a concurrent burst of
196
+ * `storageFor()` cache hits collapses to a single hook invocation because
197
+ * each queued task re-checks `notifiedResolved` before invoking the hook. A
198
+ * dropped (`false`) or rejected hook leaves `notifiedResolved` unset so the
199
+ * next `storageFor()` retries — the serializer recovers from the rejection,
200
+ * so a failed hook never poisons later notifications.
187
201
  */
188
202
  notifyResolved(namespace, storageDir) {
189
203
  const hook = this.hooks.onResolve;
190
204
  if (!hook) return;
191
205
  if (this.notifiedResolved.get(namespace) === storageDir) return;
192
- if (this.inFlightResolved.get(namespace) === storageDir) return;
193
- try {
194
- this.inFlightResolved.set(namespace, storageDir);
195
- const hookResult = Promise.resolve(hook(namespace, storageDir));
196
- this.pendingResolveHooks.add(hookResult);
197
- hookResult.then(
198
- (persisted) => {
199
- if (this.inFlightResolved.get(namespace) === storageDir) {
200
- this.inFlightResolved.delete(namespace);
201
- }
202
- if (persisted !== false) {
203
- this.notifiedResolved.set(namespace, storageDir);
204
- }
205
- this.pendingResolveHooks.delete(hookResult);
206
- },
207
- () => {
208
- if (this.inFlightResolved.get(namespace) === storageDir) {
209
- this.inFlightResolved.delete(namespace);
210
- }
211
- if (this.notifiedResolved.get(namespace) === storageDir) {
212
- this.notifiedResolved.delete(namespace);
213
- }
214
- this.pendingResolveHooks.delete(hookResult);
206
+ const inFlightKey = namespace + "\0" + storageDir;
207
+ if (this.inFlightResolveHooks.has(inFlightKey)) return;
208
+ this.inFlightResolveHooks.add(inFlightKey);
209
+ const task = async () => {
210
+ if (this.notifiedResolved.get(namespace) === storageDir) return;
211
+ try {
212
+ const persisted = await Promise.resolve(hook(namespace, storageDir));
213
+ if (persisted !== false) {
214
+ this.notifiedResolved.set(namespace, storageDir);
215
215
  }
216
- );
217
- } catch {
218
- if (this.inFlightResolved.get(namespace) === storageDir) {
219
- this.inFlightResolved.delete(namespace);
216
+ } catch {
220
217
  }
221
- }
218
+ };
219
+ const queued = this.resolveSerializer.serialize(namespace, task);
220
+ this.pendingResolveHooks.add(queued);
221
+ const cleanup = () => {
222
+ this.inFlightResolveHooks.delete(inFlightKey);
223
+ this.pendingResolveHooks.delete(queued);
224
+ };
225
+ void queued.then(cleanup, cleanup);
222
226
  }
223
227
  /**
224
228
  * Install the post-write catalog touch hook on an externally-constructed
@@ -332,4 +336,4 @@ export {
332
336
  resolveNamespaceStorageRoot,
333
337
  NamespaceStorageRouter
334
338
  };
335
- //# sourceMappingURL=chunk-PQG4T5V3.js.map
339
+ //# sourceMappingURL=chunk-ODTWHSY2.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/namespaces/storage.ts"],"sourcesContent":["import path from \"node:path\";\nimport { access, lstat, readdir } from \"node:fs/promises\";\nimport { isSafeRouteNamespace } from \"../routing/engine.js\";\nimport { StorageManager } from \"../storage.js\";\nimport type { PluginConfig } from \"../types.js\";\nimport { ALL_CATEGORY_DIRS } from \"../utils/category-dir.js\";\nimport { namespaceIdentityToken, normalizeNamespaceIdentity } from \"./identity.js\";\nimport type { NamespaceCatalog } from \"./catalog.js\";\nimport { MutationSerializer } from \"../utils/serialize-mutations.js\";\n\nasync function exists(p: string): Promise<boolean> {\n try {\n await access(p);\n return true;\n } catch {\n return false;\n }\n}\n\nasync function hasStoredEntries(p: string): Promise<boolean> {\n try {\n const entry = await lstat(p);\n if (entry.isSymbolicLink()) return true;\n if (!entry.isDirectory()) return true;\n const children = await readdir(p, { withFileTypes: true });\n for (const child of children) {\n const childPath = path.join(p, child.name);\n if (child.isSymbolicLink() || child.isFile()) return true;\n if (child.isDirectory() && (await hasStoredEntries(childPath))) return true;\n }\n return false;\n } catch {\n return false;\n }\n}\n\n// Build a per-namespace directory under `<memoryDir>/namespaces` and assert the\n// resolved path stays inside that base. Namespace identifiers can originate from\n// operator config (config.defaultNamespace) and request-derived routing, so this\n// containment check prevents directory traversal (CodeQL js/path-injection).\n// For safe segments this returns exactly `path.join(base, segment)`, so there is\n// no behavioral change for valid namespaces.\nfunction resolveNamespaceDir(memoryDir: string, segment: string): string {\n // Mirror isSafeRouteNamespace's separator/parent-ref rejection (without its\n // 64-char cap, so identity tokens still pass). Rejecting separators and \"..\"\n // up front keeps the value a single contained child of <memoryDir>/namespaces.\n if (\n segment.length === 0 ||\n segment.includes(\"/\") ||\n segment.includes(\"\\\\\") ||\n segment.includes(\"..\") ||\n path.isAbsolute(segment)\n ) {\n throw new Error(`unsafe namespace path segment: ${segment}`);\n }\n return path.join(memoryDir, \"namespaces\", segment);\n}\n\nconst LEGACY_NAMESPACE_CONTENT_CHILDREN = [\n ...ALL_CATEGORY_DIRS,\n \"entities\",\n \"artifacts\",\n \"identity\",\n \"config\",\n \"summaries\",\n \"profile.md\",\n] as const;\n\nconst LEGACY_NAMESPACE_RUNTIME_CHILDREN = [\"state\"] as const;\n\nasync function hasAnyLegacyData(\n rootDir: string,\n options: { includeRuntimeState?: boolean } = {},\n): Promise<boolean> {\n const children = options.includeRuntimeState === true\n ? [...LEGACY_NAMESPACE_CONTENT_CHILDREN, ...LEGACY_NAMESPACE_RUNTIME_CHILDREN]\n : LEGACY_NAMESPACE_CONTENT_CHILDREN;\n for (const child of children) {\n if (await hasStoredEntries(path.join(rootDir, child))) return true;\n }\n return false;\n}\n\nasync function hasAnyNamespaceStorageMarker(\n rootDir: string,\n options: { includeRuntimeState?: boolean } = {},\n): Promise<boolean> {\n const children = options.includeRuntimeState === true\n ? [...LEGACY_NAMESPACE_CONTENT_CHILDREN, ...LEGACY_NAMESPACE_RUNTIME_CHILDREN]\n : LEGACY_NAMESPACE_CONTENT_CHILDREN;\n for (const child of children) {\n if (await exists(path.join(rootDir, child))) return true;\n }\n return false;\n}\n\n/**\n * Storage routing for namespaces.\n *\n * Compatibility note:\n * - When namespaces are enabled, existing raw namespace roots are preserved.\n * New namespace roots use tokenized names under `memoryDir/namespaces/<token>`.\n * - The default namespace continues to use the legacy `memoryDir` root unless the caller\n * has created `memoryDir/namespaces/<defaultNamespace>` (in which case we use that).\n *\n * This avoids surprising \"lost memories\" when an install flips namespaces on without\n * migrating existing data.\n */\n/**\n * Optional hooks for the storage router. `onResolve` fires whenever a namespace's\n * storage is resolved/created, so a downstream consumer (e.g. the namespace\n * catalog, issue #1499) can register the namespace. The hook MUST NOT throw into\n * the router; the router invokes it defensively and a hook failure never affects\n * storage resolution.\n *\n * The hook MAY return (or resolve to) a boolean indicating whether the\n * registration actually PERSISTED (round 6, codex P2 — NEFoX). When it resolves\n * to `false` (a dropped/no-op registration), the router does NOT mark the\n * (namespace, storageDir) pair as notified, so the next resolve RETRIES it\n * instead of suppressing it forever. A `void`/`undefined` result is treated as\n * success (legacy hooks).\n */\nexport interface NamespaceStorageRouterHooks {\n onResolve?: (\n namespace: string,\n storageDir: string,\n ) => void | boolean | Promise<void | boolean>;\n}\n\n/**\n * Resolve the runtime storage root for the configured DEFAULT namespace.\n *\n * Shared between the live router (`NamespaceStorageRouter.defaultNamespaceRoot`)\n * and the rebuildable catalog (`NamespaceCatalog.rebuildFromDisk`) so the two\n * can never diverge (CLAUDE.md rule #22/#42 — read & write paths resolve through\n * the same logic). The contract is: while legacy memory data still lives\n * directly under `memoryDir`, the default root stays `memoryDir`; only once the\n * legacy root is empty and a `namespaces/<default|token>` dir holds data does\n * the default migrate into that tokenized/legacy-named dir.\n */\nexport async function resolveDefaultNamespaceRoot(config: PluginConfig): Promise<string> {\n if (!config.namespacesEnabled) {\n return config.memoryDir;\n }\n\n // Build the legacy default root from the NORMALIZED (trimmed) name so a\n // whitespace-padded `defaultNamespace` still finds the live `namespaces/default`\n // root (NIabe). `storageFor()` classifies the trimmed value as the default, and\n // the on-disk legacy dir is created under the trimmed name; using the raw spaced\n // name here would look for `namespaces/<spaced>` and miss the real root, falling\n // back to memoryDir/tokenized. `namespaceIdentityToken` already normalizes\n // internally, so the tokenized path is unaffected.\n const defaultIdentity = normalizeNamespaceIdentity(config.defaultNamespace);\n const legacyNsDir = resolveNamespaceDir(config.memoryDir, defaultIdentity);\n const tokenizedNsDir = resolveNamespaceDir(\n config.memoryDir,\n namespaceIdentityToken(config.defaultNamespace),\n );\n const tokenizedHasData =\n (await exists(tokenizedNsDir)) &&\n (await hasAnyNamespaceStorageMarker(tokenizedNsDir, { includeRuntimeState: true }));\n const nsDir = tokenizedHasData\n ? tokenizedNsDir\n : (await exists(legacyNsDir))\n ? legacyNsDir\n : tokenizedNsDir;\n return (await exists(nsDir)) && !(await hasAnyLegacyData(config.memoryDir))\n ? nsDir\n : config.memoryDir;\n}\n\n/**\n * Resolve the runtime storage root for ANY namespace exactly as the live router\n * would (`NamespaceStorageRouter.namespaceRoot`). Shared so the rebuildable\n * catalog records the SAME on-disk root the router routes to — a recall/read\n * touch must not guess `namespaces/<token>` when the router actually serves a\n * legacy raw-name dir or a migrated default root (CLAUDE.md rule #22/#42; round\n * 4, cursor Medium). The default namespace delegates to `resolveDefaultNamespaceRoot`;\n * every other namespace prefers the tokenized root when it has a storage marker,\n * else a legacy raw-name dir when present, else the tokenized root.\n */\nexport async function resolveNamespaceStorageRoot(\n config: PluginConfig,\n namespace: string,\n): Promise<string> {\n if (!config.namespacesEnabled) return config.memoryDir;\n // Compare on NORMALIZED identity so a whitespace-padded configured default name\n // still routes to the default root rather than a tokenized non-default dir\n // (NH-FH). The catalog keys records by the same normalized identity.\n if (normalizeNamespaceIdentity(namespace) === normalizeNamespaceIdentity(config.defaultNamespace)) {\n return resolveDefaultNamespaceRoot(config);\n }\n const legacyRoot = resolveNamespaceDir(config.memoryDir, namespace);\n const tokenizedRoot = resolveNamespaceDir(config.memoryDir, namespaceIdentityToken(namespace));\n if (\n (await exists(tokenizedRoot)) &&\n (await hasAnyNamespaceStorageMarker(tokenizedRoot, { includeRuntimeState: true }))\n ) {\n return tokenizedRoot;\n }\n return (await exists(legacyRoot)) ? legacyRoot : tokenizedRoot;\n}\n\nexport class NamespaceStorageRouter {\n private readonly cache = new Map<string, StorageManager>();\n private defaultNsRootResolved: string | null = null;\n // Dedup the resolve hook (round 6, cursor Medium — NCNL2). Recall/extraction\n // call `storageFor` repeatedly; firing `onResolve` (→ catalog loadCompacted +\n // append) on every cache hit grows `namespaces.jsonl` without bound between\n // rebuilds. We fire the hook only when the (namespace, storageDir) pair is new\n // or its dir changed, so a steady-state cache hit is a no-op for the catalog.\n private readonly notifiedResolved = new Map<string, string>();\n // Instance-scoped serializer for the resolve hook (issue #1524 adoption).\n // Replaces the bespoke `inFlightResolved` marker-then-clear pattern: the\n // serializer's per-key chain strictly orders concurrent notifications for the\n // SAME namespace, and the task re-checks `notifiedResolved` once it runs, so a\n // burst of cache hits before the first hook settles collapses to a single\n // hook invocation (the once-per-namespace intent). Recovery is preserved —\n // one rejected hook never poisons subsequent notifications for that key.\n private readonly resolveSerializer = new MutationSerializer();\n // (namespace, storageDir) pairs whose resolve hook is currently pending. Set SYNCHRONOUSLY in\n // notifyResolved (before queueing) so a burst of concurrent storageFor()\n // cache hits collapses onto the one in-flight hook instead of each enqueuing\n // its own task. Cleared when the queued hook settles. Without this, a dropped\n // hook (returns false on a rebuild-lock timeout) leaves notifiedResolved\n // unset, so every queued sibling task would re-run the hook — N serial lock\n // waits (cursor Medium 06f58a7c, codex P2).\n private readonly inFlightResolveHooks = new Set<string>();\n // Tracks every in-flight resolve-hook promise so callers can deterministically\n // await the fire-and-forget registrations that `storageFor()` kicks off (see\n // `whenResolveHooksSettled`). Entries are removed as each hook settles, so the\n // set holds at most one promise per concurrently-resolving namespace.\n private readonly pendingResolveHooks = new Set<Promise<unknown>>();\n // Pending post-write catalog touch promises (#1522). Like pendingResolveHooks,\n // lets tests await fire-and-forget write touches deterministically.\n private readonly pendingWriteTouches = new Set<Promise<unknown>>();\n\n // Normalized (trimmed) default namespace identity (NH-FH). `storageFor`\n // normalizes its input, so default-namespace branches must compare against the\n // normalized config default too — otherwise a whitespace-padded configured\n // default name routes the default namespace to a tokenized non-default root.\n private readonly defaultNamespaceIdentity: string;\n\n constructor(\n private readonly config: PluginConfig,\n private readonly hooks: NamespaceStorageRouterHooks = {},\n /** Catalog reference for post-write/read touches (issue #1522 chokepoint). */\n private readonly catalog?: NamespaceCatalog,\n ) {\n this.defaultNamespaceIdentity = normalizeNamespaceIdentity(config.defaultNamespace);\n }\n\n private async defaultNamespaceRoot(): Promise<string> {\n this.defaultNsRootResolved = await resolveDefaultNamespaceRoot(this.config);\n return this.defaultNsRootResolved;\n }\n\n private async namespaceRoot(namespace: string): Promise<string> {\n // NOTE: only used after defaultNamespaceRoot() resolution.\n if (!this.config.namespacesEnabled) return this.config.memoryDir;\n if (normalizeNamespaceIdentity(namespace) === this.defaultNamespaceIdentity) {\n return this.defaultNsRootResolved ?? this.config.memoryDir;\n }\n return resolveNamespaceStorageRoot(this.config, namespace);\n }\n\n async storageFor(namespace: string): Promise<StorageManager> {\n const ns = normalizeNamespaceIdentity(namespace || this.config.defaultNamespace);\n if (ns !== this.defaultNamespaceIdentity && !isSafeRouteNamespace(ns)) {\n throw new Error(`unsafe namespace: ${ns}`);\n }\n // Even when the default namespace is exempt from the check above, every\n // on-disk path is built through resolveNamespaceDir(), which rejects\n // traversal segments — so an unsafe configured default still cannot escape\n // <memoryDir>/namespaces (CodeQL js/path-injection).\n\n let root: string;\n if (ns === this.defaultNamespaceIdentity) {\n root = await this.defaultNamespaceRoot();\n const cached = this.cache.get(ns);\n if (cached && cached.dir === root) {\n this.notifyResolved(ns, root);\n return cached;\n }\n } else {\n const cached = this.cache.get(ns);\n root = await this.namespaceRoot(ns);\n if (cached && cached.dir === root) {\n this.notifyResolved(ns, root);\n return cached;\n }\n }\n\n const sm = new StorageManager(root, this.config.entitySchemas);\n // Propagate the inline-attribution template so that router-created storages\n // (used by extraction and shared-promotion paths) strip citations consistently,\n // matching the behaviour of the primary this.storage instance in the orchestrator.\n sm.citationTemplate = this.config.inlineSourceAttributionFormat;\n // #1522: install the post-write catalog touch at the chokepoint — every\n // successful write on this StorageManager records the namespace touch.\n this.bindCatalogWriteHook(sm, ns);\n // #1579: apply the tombstone non-resurrection config so every namespace\n // storage enforces the invariant at its own writeMemory chokepoint.\n this.applyTombstonesConfig(sm, ns);\n this.cache.set(ns, sm);\n this.notifyResolved(ns, root);\n return sm;\n }\n\n /**\n * Fire the resolve hook defensively. A hook failure (e.g. a catalog write\n * error) MUST NOT crash storage resolution — see CLAUDE.md gotcha #13.\n *\n * Issue #1524 adoption: hook invocations for the SAME namespace are now\n * strictly ordered through the shared `MutationSerializer` rather than the\n * bespoke `inFlightResolved` marker-then-clear pattern. The serializer\n * guarantees one in-flight hook per namespace; a concurrent burst of\n * `storageFor()` cache hits collapses to a single hook invocation because\n * each queued task re-checks `notifiedResolved` before invoking the hook. A\n * dropped (`false`) or rejected hook leaves `notifiedResolved` unset so the\n * next `storageFor()` retries — the serializer recovers from the rejection,\n * so a failed hook never poisons later notifications.\n */\n private notifyResolved(namespace: string, storageDir: string): void {\n const hook = this.hooks.onResolve;\n if (!hook) return;\n // Permanent dedup: skip once we've SUCCESSFULLY notified this exact\n // (namespace, storageDir). A changed dir (rare: migration/realignment)\n // still re-fires once. The mark is set ONLY AFTER the hook succeeds, so a\n // dropped registration (e.g. rebuild-lock timeout) is RETRIED on the next\n // cache hit instead of being suppressed forever (round 6, cursor Medium —\n // ND3EJ).\n if (this.notifiedResolved.get(namespace) === storageDir) return;\n // In-flight dedup (cursor Medium 06f58a7c, codex P2): if a hook for THIS\n // namespace is already pending, collapse this call onto it instead of\n // enqueueing another task. The serializer strictly orders queued tasks, but\n // ordering alone is not enough — when the first hook returns `false`\n // (dropped touch, e.g. rebuild-lock timeout), `notifiedResolved` stays\n // unset, so each queued sibling task would pass its re-check and re-run the\n // hook. With the real catalog hook each retry can spend the full lock wait,\n // so N cache hits during a rebuild leave N serial background lock attempts.\n // Collapsing here means at most ONE hook runs per in-flight registration;\n // its result (set `notifiedResolved` on success, leave unset on drop)\n // decides whether a LATER `storageFor()` retries — collapsing loses\n // nothing because the drop is already retried on the next cache hit.\n // Keyed by the composite (namespace, storageDir) so a CHANGED dir\n // (migration/realignment) for the same namespace still gets its own hook —\n // it is NOT collapsed onto the old dir's pending registration (cursor\n // Medium, codex P2).\n const inFlightKey = namespace + \"\\u0000\" + storageDir;\n if (this.inFlightResolveHooks.has(inFlightKey)) return;\n this.inFlightResolveHooks.add(inFlightKey);\n // Queue through the serializer. Concurrent calls for the same namespace\n // strictly order here; the 2nd call's task runs only after the 1st's hook\n // settles, by which point `notifiedResolved` is either set (no-op) or still\n // unset (retry). The returned promise is tracked for\n // `whenResolveHooksSettled()`. Rejections from the task body are caught so\n // they never reach the caller (best-effort hook contract); the serializer's\n // recovered tail still lets subsequent notifications run.\n const task = async (): Promise<void> => {\n // Re-check after queueing: a prior task in this same chain may have just\n // marked the pair as notified.\n if (this.notifiedResolved.get(namespace) === storageDir) return;\n try {\n // Hook may be sync or async; Promise.resolve normalizes both. A result\n // of `false` means the registration was dropped/no-op (e.g. rebuild-lock\n // timeout) — leave notifiedResolved UNSET so the next storageFor retries\n // (round 6, codex P2 — NEFoX). `void`/`undefined` is success for legacy\n // hooks. A rejection leaves notifiedResolved unset for the same reason.\n const persisted = await Promise.resolve(hook(namespace, storageDir));\n if (persisted !== false) {\n this.notifiedResolved.set(namespace, storageDir);\n }\n } catch {\n // Best-effort: a hook failure MUST NOT crash storage resolution. Leave\n // notifiedResolved unset so a later storageFor retries the registration.\n }\n };\n const queued = this.resolveSerializer.serialize(namespace, task);\n this.pendingResolveHooks.add(queued);\n // Clear the in-flight marker when the hook settles so a subsequent\n // storageFor() can retry after a drop, or short-circuit via notifiedResolved\n // after a success.\n const cleanup = (): void => {\n this.inFlightResolveHooks.delete(inFlightKey);\n this.pendingResolveHooks.delete(queued);\n };\n void queued.then(cleanup, cleanup);\n }\n\n /**\n * Install the post-write catalog touch hook on an externally-constructed\n * StorageManager (issue #1522). Used by the orchestrator for the legacy\n * default-namespace storage (`this.storage`) that bypasses the router.\n */\n bindCatalogWriteHook(sm: StorageManager, namespace: string): void {\n sm.onCatalogWrite = () => this.touchCatalogWrite(namespace, sm.dir);\n }\n\n /**\n * Install the tombstone config on an externally-constructed StorageManager\n * (issue #1579). Mirrors `bindCatalogWriteHook` — used for the legacy\n * default-namespace storage that bypasses the router. Router-created\n * storages are wired inline in `storageFor()` via `applyTombstonesConfig`.\n */\n bindTombstonesConfig(\n sm: StorageManager,\n namespace: string,\n config: { enabled: boolean; semanticMatch: boolean; semanticThreshold: number },\n ): void {\n this.tombstonesGlobalConfig = { ...config };\n sm.setTombstonesConfig({ ...config, namespace });\n }\n\n private tombstonesGlobalConfig: {\n enabled: boolean;\n semanticMatch: boolean;\n semanticThreshold: number;\n } = { enabled: false, semanticMatch: false, semanticThreshold: 0.9 };\n\n /**\n * Apply the tombstone config to a router-created StorageManager. Called\n * inline in `storageFor()` so every namespace storage enforces the\n * non-resurrection invariant, namespace-scoped (rule 42).\n */\n private applyTombstonesConfig(sm: StorageManager, namespace: string): void {\n sm.setTombstonesConfig({ ...this.tombstonesGlobalConfig, namespace });\n }\n\n /**\n * Post-write catalog touch (issue #1522 chokepoint). Called by every\n * StorageManager's post-write hook AFTER a successful write. Best-effort\n * and failure-tolerant — a catalog error MUST NOT affect the primary write\n * (gotcha #13, rule #40). Fire-and-forget by design.\n */\n private touchCatalogWrite(namespace: string, storageDir: string): void {\n if (!this.catalog) return;\n const touch = this.catalog\n .markWrite(namespace, { discoveredBy: \"write\", storageDir })\n .catch(() => undefined);\n this.pendingWriteTouches.add(touch);\n void touch.then(\n () => { this.pendingWriteTouches.delete(touch); },\n () => { this.pendingWriteTouches.delete(touch); },\n );\n }\n\n /**\n * Record a namespace read in the catalog (issue #1522 chokepoint move).\n * Best-effort and failure-tolerant. Used by recall paths so the read touch\n * lives in the storage layer, not at the caller.\n */\n recordRead(namespace: string, storageDir?: string): void {\n if (!this.catalog) return;\n const ns = normalizeNamespaceIdentity(namespace || this.config.defaultNamespace);\n void this.catalog\n .markRead(ns, { discoveredBy: \"read\", storageDir })\n .catch(() => undefined);\n }\n /**\n * Record a namespace write touch in the catalog (issue #1522). Best-effort\n * and failure-tolerant. Used by consolidation/cleanup passes that may mutate\n * the store via delete-only paths (e.g. entity-file merges, TTL cleanup) so\n * the namespace's lastWriteAt stays fresh even when no explicit write went\n * through the storage chokepoint. This is NOT a substitute for the chokepoint\n * — it's a belt-and-suspenders for paths the chokepoint doesn't cover.\n */\n recordWrite(namespace: string, storageDir?: string): void {\n if (!this.catalog) return;\n const ns = normalizeNamespaceIdentity(namespace || this.config.defaultNamespace);\n const touch = this.catalog\n .markWrite(ns, { discoveredBy: \"write\", storageDir })\n .catch(() => undefined);\n this.pendingWriteTouches.add(touch);\n void touch.then(\n () => { this.pendingWriteTouches.delete(touch); },\n () => { this.pendingWriteTouches.delete(touch); },\n );\n }\n\n /**\n * Resolve once every in-flight post-write catalog touch has settled (#1522).\n * Mirrors `whenResolveHooksSettled()`: the StorageManager's post-write hook\n * fires the catalog touch fire-and-forget, so tests asserting lastWriteAt\n * moved should await this instead of racing a timer.\n */\n async whenWriteTouchesSettled(): Promise<void> {\n while (this.pendingWriteTouches.size > 0) {\n await Promise.allSettled([...this.pendingWriteTouches]);\n }\n }\n\n /**\n * Resolve once every in-flight `onResolve` registration has settled.\n *\n * `storageFor()` fires the resolve hook fire-and-forget, so its catalog side\n * effect (e.g. `registerResolved(...)`) is not observable the moment\n * `storageFor()` returns. Callers that must act on that side effect — notably\n * tests asserting the catalog was updated — should await this instead of\n * racing a timer. Resolves immediately when no hook is registered or nothing\n * is in flight. The loop re-checks because a settling hook could, in\n * principle, trigger a follow-on resolution.\n */\n async whenResolveHooksSettled(): Promise<void> {\n while (this.pendingResolveHooks.size > 0) {\n await Promise.allSettled([...this.pendingResolveHooks]);\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAAA,OAAO,UAAU;AACjB,SAAS,QAAQ,OAAO,eAAe;AASvC,eAAe,OAAO,GAA6B;AACjD,MAAI;AACF,UAAM,OAAO,CAAC;AACd,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,eAAe,iBAAiB,GAA6B;AAC3D,MAAI;AACF,UAAM,QAAQ,MAAM,MAAM,CAAC;AAC3B,QAAI,MAAM,eAAe,EAAG,QAAO;AACnC,QAAI,CAAC,MAAM,YAAY,EAAG,QAAO;AACjC,UAAM,WAAW,MAAM,QAAQ,GAAG,EAAE,eAAe,KAAK,CAAC;AACzD,eAAW,SAAS,UAAU;AAC5B,YAAM,YAAY,KAAK,KAAK,GAAG,MAAM,IAAI;AACzC,UAAI,MAAM,eAAe,KAAK,MAAM,OAAO,EAAG,QAAO;AACrD,UAAI,MAAM,YAAY,KAAM,MAAM,iBAAiB,SAAS,EAAI,QAAO;AAAA,IACzE;AACA,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAQA,SAAS,oBAAoB,WAAmB,SAAyB;AAIvE,MACE,QAAQ,WAAW,KACnB,QAAQ,SAAS,GAAG,KACpB,QAAQ,SAAS,IAAI,KACrB,QAAQ,SAAS,IAAI,KACrB,KAAK,WAAW,OAAO,GACvB;AACA,UAAM,IAAI,MAAM,kCAAkC,OAAO,EAAE;AAAA,EAC7D;AACA,SAAO,KAAK,KAAK,WAAW,cAAc,OAAO;AACnD;AAEA,IAAM,oCAAoC;AAAA,EACxC,GAAG;AAAA,EACH;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAEA,IAAM,oCAAoC,CAAC,OAAO;AAElD,eAAe,iBACb,SACA,UAA6C,CAAC,GAC5B;AAClB,QAAM,WAAW,QAAQ,wBAAwB,OAC7C,CAAC,GAAG,mCAAmC,GAAG,iCAAiC,IAC3E;AACJ,aAAW,SAAS,UAAU;AAC5B,QAAI,MAAM,iBAAiB,KAAK,KAAK,SAAS,KAAK,CAAC,EAAG,QAAO;AAAA,EAChE;AACA,SAAO;AACT;AAEA,eAAe,6BACb,SACA,UAA6C,CAAC,GAC5B;AAClB,QAAM,WAAW,QAAQ,wBAAwB,OAC7C,CAAC,GAAG,mCAAmC,GAAG,iCAAiC,IAC3E;AACJ,aAAW,SAAS,UAAU;AAC5B,QAAI,MAAM,OAAO,KAAK,KAAK,SAAS,KAAK,CAAC,EAAG,QAAO;AAAA,EACtD;AACA,SAAO;AACT;AA8CA,eAAsB,4BAA4B,QAAuC;AACvF,MAAI,CAAC,OAAO,mBAAmB;AAC7B,WAAO,OAAO;AAAA,EAChB;AASA,QAAM,kBAAkB,2BAA2B,OAAO,gBAAgB;AAC1E,QAAM,cAAc,oBAAoB,OAAO,WAAW,eAAe;AACzE,QAAM,iBAAiB;AAAA,IACrB,OAAO;AAAA,IACP,uBAAuB,OAAO,gBAAgB;AAAA,EAChD;AACA,QAAM,mBACH,MAAM,OAAO,cAAc,KAC3B,MAAM,6BAA6B,gBAAgB,EAAE,qBAAqB,KAAK,CAAC;AACnF,QAAM,QAAQ,mBACV,iBACC,MAAM,OAAO,WAAW,IACvB,cACA;AACN,SAAQ,MAAM,OAAO,KAAK,KAAM,CAAE,MAAM,iBAAiB,OAAO,SAAS,IACrE,QACA,OAAO;AACb;AAYA,eAAsB,4BACpB,QACA,WACiB;AACjB,MAAI,CAAC,OAAO,kBAAmB,QAAO,OAAO;AAI7C,MAAI,2BAA2B,SAAS,MAAM,2BAA2B,OAAO,gBAAgB,GAAG;AACjG,WAAO,4BAA4B,MAAM;AAAA,EAC3C;AACA,QAAM,aAAa,oBAAoB,OAAO,WAAW,SAAS;AAClE,QAAM,gBAAgB,oBAAoB,OAAO,WAAW,uBAAuB,SAAS,CAAC;AAC7F,MACG,MAAM,OAAO,aAAa,KAC1B,MAAM,6BAA6B,eAAe,EAAE,qBAAqB,KAAK,CAAC,GAChF;AACA,WAAO;AAAA,EACT;AACA,SAAQ,MAAM,OAAO,UAAU,IAAK,aAAa;AACnD;AAEO,IAAM,yBAAN,MAA6B;AAAA,EAwClC,YACmB,QACA,QAAqC,CAAC,GAEtC,SACjB;AAJiB;AACA;AAEA;AAEjB,SAAK,2BAA2B,2BAA2B,OAAO,gBAAgB;AAAA,EACpF;AAAA,EANmB;AAAA,EACA;AAAA,EAEA;AAAA,EA3CF,QAAQ,oBAAI,IAA4B;AAAA,EACjD,wBAAuC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAM9B,mBAAmB,oBAAI,IAAoB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQ3C,oBAAoB,IAAI,mBAAmB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQ3C,uBAAuB,oBAAI,IAAY;AAAA;AAAA;AAAA;AAAA;AAAA,EAKvC,sBAAsB,oBAAI,IAAsB;AAAA;AAAA;AAAA,EAGhD,sBAAsB,oBAAI,IAAsB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMhD;AAAA,EAWjB,MAAc,uBAAwC;AACpD,SAAK,wBAAwB,MAAM,4BAA4B,KAAK,MAAM;AAC1E,WAAO,KAAK;AAAA,EACd;AAAA,EAEA,MAAc,cAAc,WAAoC;AAE9D,QAAI,CAAC,KAAK,OAAO,kBAAmB,QAAO,KAAK,OAAO;AACvD,QAAI,2BAA2B,SAAS,MAAM,KAAK,0BAA0B;AAC3E,aAAO,KAAK,yBAAyB,KAAK,OAAO;AAAA,IACnD;AACA,WAAO,4BAA4B,KAAK,QAAQ,SAAS;AAAA,EAC3D;AAAA,EAEA,MAAM,WAAW,WAA4C;AAC3D,UAAM,KAAK,2BAA2B,aAAa,KAAK,OAAO,gBAAgB;AAC/E,QAAI,OAAO,KAAK,4BAA4B,CAAC,qBAAqB,EAAE,GAAG;AACrE,YAAM,IAAI,MAAM,qBAAqB,EAAE,EAAE;AAAA,IAC3C;AAMA,QAAI;AACJ,QAAI,OAAO,KAAK,0BAA0B;AACxC,aAAO,MAAM,KAAK,qBAAqB;AACvC,YAAM,SAAS,KAAK,MAAM,IAAI,EAAE;AAChC,UAAI,UAAU,OAAO,QAAQ,MAAM;AACjC,aAAK,eAAe,IAAI,IAAI;AAC5B,eAAO;AAAA,MACT;AAAA,IACF,OAAO;AACL,YAAM,SAAS,KAAK,MAAM,IAAI,EAAE;AAChC,aAAO,MAAM,KAAK,cAAc,EAAE;AAClC,UAAI,UAAU,OAAO,QAAQ,MAAM;AACjC,aAAK,eAAe,IAAI,IAAI;AAC5B,eAAO;AAAA,MACT;AAAA,IACF;AAEA,UAAM,KAAK,IAAI,eAAe,MAAM,KAAK,OAAO,aAAa;AAI7D,OAAG,mBAAmB,KAAK,OAAO;AAGlC,SAAK,qBAAqB,IAAI,EAAE;AAGhC,SAAK,sBAAsB,IAAI,EAAE;AACjC,SAAK,MAAM,IAAI,IAAI,EAAE;AACrB,SAAK,eAAe,IAAI,IAAI;AAC5B,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAgBQ,eAAe,WAAmB,YAA0B;AAClE,UAAM,OAAO,KAAK,MAAM;AACxB,QAAI,CAAC,KAAM;AAOX,QAAI,KAAK,iBAAiB,IAAI,SAAS,MAAM,WAAY;AAiBzD,UAAM,cAAc,YAAY,OAAW;AAC3C,QAAI,KAAK,qBAAqB,IAAI,WAAW,EAAG;AAChD,SAAK,qBAAqB,IAAI,WAAW;AAQzC,UAAM,OAAO,YAA2B;AAGtC,UAAI,KAAK,iBAAiB,IAAI,SAAS,MAAM,WAAY;AACzD,UAAI;AAMF,cAAM,YAAY,MAAM,QAAQ,QAAQ,KAAK,WAAW,UAAU,CAAC;AACnE,YAAI,cAAc,OAAO;AACvB,eAAK,iBAAiB,IAAI,WAAW,UAAU;AAAA,QACjD;AAAA,MACF,QAAQ;AAAA,MAGR;AAAA,IACF;AACA,UAAM,SAAS,KAAK,kBAAkB,UAAU,WAAW,IAAI;AAC/D,SAAK,oBAAoB,IAAI,MAAM;AAInC,UAAM,UAAU,MAAY;AAC1B,WAAK,qBAAqB,OAAO,WAAW;AAC5C,WAAK,oBAAoB,OAAO,MAAM;AAAA,IACxC;AACA,SAAK,OAAO,KAAK,SAAS,OAAO;AAAA,EACnC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,qBAAqB,IAAoB,WAAyB;AAChE,OAAG,iBAAiB,MAAM,KAAK,kBAAkB,WAAW,GAAG,GAAG;AAAA,EACpE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,qBACE,IACA,WACA,QACM;AACN,SAAK,yBAAyB,EAAE,GAAG,OAAO;AAC1C,OAAG,oBAAoB,EAAE,GAAG,QAAQ,UAAU,CAAC;AAAA,EACjD;AAAA,EAEQ,yBAIJ,EAAE,SAAS,OAAO,eAAe,OAAO,mBAAmB,IAAI;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAO3D,sBAAsB,IAAoB,WAAyB;AACzE,OAAG,oBAAoB,EAAE,GAAG,KAAK,wBAAwB,UAAU,CAAC;AAAA,EACtE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQQ,kBAAkB,WAAmB,YAA0B;AACrE,QAAI,CAAC,KAAK,QAAS;AACnB,UAAM,QAAQ,KAAK,QAChB,UAAU,WAAW,EAAE,cAAc,SAAS,WAAW,CAAC,EAC1D,MAAM,MAAM,MAAS;AACxB,SAAK,oBAAoB,IAAI,KAAK;AAClC,SAAK,MAAM;AAAA,MACT,MAAM;AAAE,aAAK,oBAAoB,OAAO,KAAK;AAAA,MAAG;AAAA,MAChD,MAAM;AAAE,aAAK,oBAAoB,OAAO,KAAK;AAAA,MAAG;AAAA,IAClD;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,WAAW,WAAmB,YAA2B;AACvD,QAAI,CAAC,KAAK,QAAS;AACnB,UAAM,KAAK,2BAA2B,aAAa,KAAK,OAAO,gBAAgB;AAC/E,SAAK,KAAK,QACP,SAAS,IAAI,EAAE,cAAc,QAAQ,WAAW,CAAC,EACjD,MAAM,MAAM,MAAS;AAAA,EAC1B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,YAAY,WAAmB,YAA2B;AACxD,QAAI,CAAC,KAAK,QAAS;AACnB,UAAM,KAAK,2BAA2B,aAAa,KAAK,OAAO,gBAAgB;AAC/E,UAAM,QAAQ,KAAK,QAChB,UAAU,IAAI,EAAE,cAAc,SAAS,WAAW,CAAC,EACnD,MAAM,MAAM,MAAS;AACxB,SAAK,oBAAoB,IAAI,KAAK;AAClC,SAAK,MAAM;AAAA,MACT,MAAM;AAAE,aAAK,oBAAoB,OAAO,KAAK;AAAA,MAAG;AAAA,MAChD,MAAM;AAAE,aAAK,oBAAoB,OAAO,KAAK;AAAA,MAAG;AAAA,IAClD;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,MAAM,0BAAyC;AAC7C,WAAO,KAAK,oBAAoB,OAAO,GAAG;AACxC,YAAM,QAAQ,WAAW,CAAC,GAAG,KAAK,mBAAmB,CAAC;AAAA,IACxD;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAaA,MAAM,0BAAyC;AAC7C,WAAO,KAAK,oBAAoB,OAAO,GAAG;AACxC,YAAM,QAAQ,WAAW,CAAC,GAAG,KAAK,mBAAmB,CAAC;AAAA,IACxD;AAAA,EACF;AACF;","names":[]}
@@ -14,16 +14,16 @@ import {
14
14
  } from "./chunk-Q4O3ET6F.js";
15
15
  import {
16
16
  getOperation
17
- } from "./chunk-EOBJRBLC.js";
17
+ } from "./chunk-YO4MBK3I.js";
18
18
  import {
19
19
  EngramAccessInputError
20
- } from "./chunk-3E5WRQNQ.js";
20
+ } from "./chunk-FUCJAZ25.js";
21
21
  import {
22
22
  projectTagProjectId
23
23
  } from "./chunk-HRUULBBV.js";
24
24
  import {
25
25
  validateBriefingFormat
26
- } from "./chunk-6JDGADXK.js";
26
+ } from "./chunk-HF4N43Q7.js";
27
27
  import {
28
28
  resolvePrincipal
29
29
  } from "./chunk-UZYLX7M6.js";
@@ -3302,4 +3302,4 @@ ${body}`;
3302
3302
  export {
3303
3303
  EngramMcpServer
3304
3304
  };
3305
- //# sourceMappingURL=chunk-YMTGXDN6.js.map
3305
+ //# sourceMappingURL=chunk-OLOYQZFB.js.map
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  StorageManager
3
- } from "./chunk-X74FJSW7.js";
3
+ } from "./chunk-SFMRLXIV.js";
4
4
  import {
5
5
  getCachedRuleMemories,
6
6
  setCachedRuleMemories
@@ -117,4 +117,4 @@ export {
117
117
  compareVerifiedSemanticRuleResults,
118
118
  searchVerifiedSemanticRules
119
119
  };
120
- //# sourceMappingURL=chunk-JKW5XSWC.js.map
120
+ //# sourceMappingURL=chunk-QP37KL5H.js.map
@@ -2,7 +2,7 @@ import {
2
2
  TombstoneStore,
3
3
  buildRetiredFactTombstoneInputs,
4
4
  collectRetiredMemoriesForRebuild
5
- } from "./chunk-PCZR32VL.js";
5
+ } from "./chunk-3JJWNZTT.js";
6
6
  import {
7
7
  WEARABLES_DIR_NAME,
8
8
  isValidTranscriptDate
@@ -5840,4 +5840,4 @@ export {
5840
5840
  serializeEntityFile,
5841
5841
  StorageManager
5842
5842
  };
5843
- //# sourceMappingURL=chunk-X74FJSW7.js.map
5843
+ //# sourceMappingURL=chunk-SFMRLXIV.js.map
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  StorageManager
3
- } from "./chunk-X74FJSW7.js";
3
+ } from "./chunk-SFMRLXIV.js";
4
4
  import {
5
5
  assertPathInsideRoot
6
6
  } from "./chunk-5GPPACXK.js";
@@ -751,4 +751,4 @@ export {
751
751
  listMemoryGovernanceRuns,
752
752
  readMemoryGovernanceRunArtifact
753
753
  };
754
- //# sourceMappingURL=chunk-RJ2THZ4H.js.map
754
+ //# sourceMappingURL=chunk-TFVVONWD.js.map
@@ -3,7 +3,7 @@ import {
3
3
  readNamespaceMaintenanceLastRanStatuses,
4
4
  readNamespaceMaintenanceStatuses,
5
5
  runNamespaceMaintenancePlan
6
- } from "./chunk-DR2JTSLZ.js";
6
+ } from "./chunk-JKOKX3PS.js";
7
7
 
8
8
  // src/maintenance/namespace-maintenance-fanout.ts
9
9
  var NAMESPACE_MAINTENANCE_JOBS = [
@@ -146,4 +146,4 @@ export {
146
146
  summarizeNamespaceMaintenanceHealth,
147
147
  formatNamespaceMaintenanceHealthText
148
148
  };
149
- //# sourceMappingURL=chunk-XY4WJTEX.js.map
149
+ //# sourceMappingURL=chunk-WFEZUGU5.js.map
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-7FL4CNPV.js";
4
4
  import {
5
5
  StorageManager
6
- } from "./chunk-X74FJSW7.js";
6
+ } from "./chunk-SFMRLXIV.js";
7
7
  import {
8
8
  getCachedEpisodeMap,
9
9
  setCachedEpisodeMap
@@ -107,4 +107,4 @@ export {
107
107
  compareVerifiedEpisodeResults,
108
108
  searchVerifiedEpisodes
109
109
  };
110
- //# sourceMappingURL=chunk-O54DY26V.js.map
110
+ //# sourceMappingURL=chunk-XTIRCSIH.js.map
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  EngramAccessInputError
3
- } from "./chunk-3E5WRQNQ.js";
3
+ } from "./chunk-FUCJAZ25.js";
4
4
  import {
5
5
  expandTildePath
6
6
  } from "./chunk-EYIEWJNI.js";
@@ -104,4 +104,4 @@ export {
104
104
  listRegisteredOperations,
105
105
  __resetRegistryForTest
106
106
  };
107
- //# sourceMappingURL=chunk-EOBJRBLC.js.map
107
+ //# sourceMappingURL=chunk-YO4MBK3I.js.map
@@ -10,7 +10,7 @@ import {
10
10
  import {
11
11
  planNamespaceMaintenance,
12
12
  runNamespaceMaintenanceBatchPlan
13
- } from "./chunk-DR2JTSLZ.js";
13
+ } from "./chunk-JKOKX3PS.js";
14
14
  import {
15
15
  resolveHomeDir
16
16
  } from "./chunk-JUC24CTX.js";
@@ -441,4 +441,4 @@ var MaintenanceScheduler = class {
441
441
  export {
442
442
  MaintenanceScheduler
443
443
  };
444
- //# sourceMappingURL=chunk-YXIFA36P.js.map
444
+ //# sourceMappingURL=chunk-ZT7B64BE.js.map
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  listNamespaces
3
- } from "./chunk-G5PKTQ5J.js";
3
+ } from "./chunk-DEDQXIDL.js";
4
4
  import {
5
5
  runConsolidationProvenanceCheck
6
6
  } from "./chunk-7XH7VJN4.js";
@@ -9,7 +9,7 @@ import {
9
9
  } from "./chunk-YBPYIAA5.js";
10
10
  import {
11
11
  summarizeNamespaceMaintenanceHealth
12
- } from "./chunk-XY4WJTEX.js";
12
+ } from "./chunk-WFEZUGU5.js";
13
13
  import {
14
14
  analyzeSessionIntegrity,
15
15
  applySessionRepair,
@@ -39,13 +39,13 @@ import {
39
39
  import {
40
40
  listMemoryGovernanceRuns,
41
41
  readMemoryGovernanceRunArtifact
42
- } from "./chunk-RJ2THZ4H.js";
42
+ } from "./chunk-TFVVONWD.js";
43
43
  import {
44
44
  analyzeGraphHealth
45
45
  } from "./chunk-KF74X62T.js";
46
46
  import {
47
47
  StorageManager
48
- } from "./chunk-X74FJSW7.js";
48
+ } from "./chunk-SFMRLXIV.js";
49
49
  import {
50
50
  lintWorkspaceFiles
51
51
  } from "./chunk-DM2T26WE.js";
@@ -1395,4 +1395,4 @@ export {
1395
1395
  runBenchmarkRecall,
1396
1396
  runOperatorRepair
1397
1397
  };
1398
- //# sourceMappingURL=chunk-33L6XHU2.js.map
1398
+ //# sourceMappingURL=chunk-ZYNMX6IU.js.map
@@ -7,7 +7,7 @@ import { RebuildMemoryLifecycleLedgerResult } from './maintenance/rebuild-memory
7
7
  import { RebuildObservationsResult } from './maintenance/rebuild-observations.js';
8
8
  import { ArchiveObservationsResult } from './maintenance/archive-observations.js';
9
9
  import { Writable, Readable } from 'node:stream';
10
- import { O as Orchestrator } from './orchestrator-CJI4xdqV.js';
10
+ import { O as Orchestrator } from './orchestrator-BzMCZlKn.js';
11
11
  import { ReplayRunSummary } from './replay/runner.js';
12
12
  import { ReplaySource, ReplayTurn } from './replay/types.js';
13
13
  import { b as BulkImportResult } from './types-ByK7T3L6.js';
@@ -16,7 +16,7 @@ import { WorkProjectStatus, WorkTaskStatus, WorkTaskPriority } from './work/type
16
16
  import { RoutePatternType } from './routing/engine.js';
17
17
  import { TailscaleSyncOptions } from './network/tailscale.js';
18
18
  import { DashboardStatus } from './dashboard-runtime.js';
19
- import { g as EngramAccessService } from './access-service-Cte3ol0W.js';
19
+ import { g as EngramAccessService } from './access-service-CGVWK6lZ.js';
20
20
  import { ResolveSecretRefFn } from './resolve-auth-token.js';
21
21
  import { CompatRunner, CompatReport } from './compat/types.js';
22
22
  import { EvalBaselineDeltaReport, EvalBaselineSnapshot, EvalCiGateReport, EvalBenchmarkPackSummary, EvalHarnessStatus, EvalStoredBaselineCiGateReport } from './evals.js';