@latysh-development/monolith 0.0.0-stage → 0.1.0-alpha.0

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 (291) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +132 -2
  3. package/dist/brief/cli.d.ts +30 -0
  4. package/dist/brief/cli.d.ts.map +1 -0
  5. package/dist/brief/cli.js +85 -0
  6. package/dist/brief/collect.d.ts +17 -0
  7. package/dist/brief/collect.d.ts.map +1 -0
  8. package/dist/brief/collect.js +302 -0
  9. package/dist/brief/config.d.ts +40 -0
  10. package/dist/brief/config.d.ts.map +1 -0
  11. package/dist/brief/config.js +85 -0
  12. package/dist/brief/constants.d.ts +10 -0
  13. package/dist/brief/constants.d.ts.map +1 -0
  14. package/dist/brief/constants.js +9 -0
  15. package/dist/brief/docs.d.ts +53 -0
  16. package/dist/brief/docs.d.ts.map +1 -0
  17. package/dist/brief/docs.js +229 -0
  18. package/dist/brief/freshness.d.ts +104 -0
  19. package/dist/brief/freshness.d.ts.map +1 -0
  20. package/dist/brief/freshness.js +263 -0
  21. package/dist/brief/git-signals.d.ts +17 -0
  22. package/dist/brief/git-signals.d.ts.map +1 -0
  23. package/dist/brief/git-signals.js +101 -0
  24. package/dist/brief/index.d.ts +11 -0
  25. package/dist/brief/index.d.ts.map +1 -0
  26. package/dist/brief/index.js +5 -0
  27. package/dist/brief/reconcile.d.ts +75 -0
  28. package/dist/brief/reconcile.d.ts.map +1 -0
  29. package/dist/brief/reconcile.js +259 -0
  30. package/dist/brief/render.d.ts +60 -0
  31. package/dist/brief/render.d.ts.map +1 -0
  32. package/dist/brief/render.js +407 -0
  33. package/dist/brief/sanitize.d.ts +38 -0
  34. package/dist/brief/sanitize.d.ts.map +1 -0
  35. package/dist/brief/sanitize.js +48 -0
  36. package/dist/brief/selectors.d.ts +36 -0
  37. package/dist/brief/selectors.d.ts.map +1 -0
  38. package/dist/brief/selectors.js +75 -0
  39. package/dist/brief/story.d.ts +18 -0
  40. package/dist/brief/story.d.ts.map +1 -0
  41. package/dist/brief/story.js +59 -0
  42. package/dist/brief/types.d.ts +110 -0
  43. package/dist/brief/types.d.ts.map +1 -0
  44. package/dist/brief/types.js +11 -0
  45. package/dist/cli/index.d.ts +3 -0
  46. package/dist/cli/index.d.ts.map +1 -0
  47. package/dist/cli/index.js +166 -0
  48. package/dist/cli/init-args.d.ts +16 -0
  49. package/dist/cli/init-args.d.ts.map +1 -0
  50. package/dist/cli/init-args.js +24 -0
  51. package/dist/cli/ui-args.d.ts +23 -0
  52. package/dist/cli/ui-args.d.ts.map +1 -0
  53. package/dist/cli/ui-args.js +66 -0
  54. package/dist/cli/ui.d.ts +40 -0
  55. package/dist/cli/ui.d.ts.map +1 -0
  56. package/dist/cli/ui.js +180 -0
  57. package/dist/coordinatorium/checkpoint.d.ts +44 -0
  58. package/dist/coordinatorium/checkpoint.d.ts.map +1 -0
  59. package/dist/coordinatorium/checkpoint.js +124 -0
  60. package/dist/coordinatorium/cli.d.ts +39 -0
  61. package/dist/coordinatorium/cli.d.ts.map +1 -0
  62. package/dist/coordinatorium/cli.js +203 -0
  63. package/dist/coordinatorium/errors.d.ts +112 -0
  64. package/dist/coordinatorium/errors.d.ts.map +1 -0
  65. package/dist/coordinatorium/errors.js +145 -0
  66. package/dist/coordinatorium/index.d.ts +11 -0
  67. package/dist/coordinatorium/index.d.ts.map +1 -0
  68. package/dist/coordinatorium/index.js +5 -0
  69. package/dist/coordinatorium/lock.d.ts +187 -0
  70. package/dist/coordinatorium/lock.d.ts.map +1 -0
  71. package/dist/coordinatorium/lock.js +659 -0
  72. package/dist/coordinatorium/mission.d.ts +32 -0
  73. package/dist/coordinatorium/mission.d.ts.map +1 -0
  74. package/dist/coordinatorium/mission.js +87 -0
  75. package/dist/coordinatorium/state-keeper.d.ts +115 -0
  76. package/dist/coordinatorium/state-keeper.d.ts.map +1 -0
  77. package/dist/coordinatorium/state-keeper.js +396 -0
  78. package/dist/coordinatorium/types.d.ts +69 -0
  79. package/dist/coordinatorium/types.d.ts.map +1 -0
  80. package/dist/coordinatorium/types.js +27 -0
  81. package/dist/durable/assess.d.ts +105 -0
  82. package/dist/durable/assess.d.ts.map +1 -0
  83. package/dist/durable/assess.js +239 -0
  84. package/dist/durable/canonical.d.ts +25 -0
  85. package/dist/durable/canonical.d.ts.map +1 -0
  86. package/dist/durable/canonical.js +78 -0
  87. package/dist/durable/cli.d.ts +37 -0
  88. package/dist/durable/cli.d.ts.map +1 -0
  89. package/dist/durable/cli.js +141 -0
  90. package/dist/durable/errors.d.ts +188 -0
  91. package/dist/durable/errors.d.ts.map +1 -0
  92. package/dist/durable/errors.js +317 -0
  93. package/dist/durable/fs-store.d.ts +233 -0
  94. package/dist/durable/fs-store.d.ts.map +1 -0
  95. package/dist/durable/fs-store.js +1393 -0
  96. package/dist/durable/index.d.ts +20 -0
  97. package/dist/durable/index.d.ts.map +1 -0
  98. package/dist/durable/index.js +13 -0
  99. package/dist/durable/log.d.ts +33 -0
  100. package/dist/durable/log.d.ts.map +1 -0
  101. package/dist/durable/log.js +157 -0
  102. package/dist/durable/memory-store.d.ts +51 -0
  103. package/dist/durable/memory-store.d.ts.map +1 -0
  104. package/dist/durable/memory-store.js +302 -0
  105. package/dist/durable/projection.d.ts +35 -0
  106. package/dist/durable/projection.d.ts.map +1 -0
  107. package/dist/durable/projection.js +328 -0
  108. package/dist/durable/prune-check.d.ts +17 -0
  109. package/dist/durable/prune-check.d.ts.map +1 -0
  110. package/dist/durable/prune-check.js +45 -0
  111. package/dist/durable/register.d.ts +100 -0
  112. package/dist/durable/register.d.ts.map +1 -0
  113. package/dist/durable/register.js +532 -0
  114. package/dist/durable/retention.d.ts +49 -0
  115. package/dist/durable/retention.d.ts.map +1 -0
  116. package/dist/durable/retention.js +59 -0
  117. package/dist/durable/runtime.d.ts +497 -0
  118. package/dist/durable/runtime.d.ts.map +1 -0
  119. package/dist/durable/runtime.js +1271 -0
  120. package/dist/durable/seams.d.ts +40 -0
  121. package/dist/durable/seams.d.ts.map +1 -0
  122. package/dist/durable/seams.js +21 -0
  123. package/dist/durable/testing.d.ts +11 -0
  124. package/dist/durable/testing.d.ts.map +1 -0
  125. package/dist/durable/testing.js +25 -0
  126. package/dist/durable/types.d.ts +442 -0
  127. package/dist/durable/types.d.ts.map +1 -0
  128. package/dist/durable/types.js +75 -0
  129. package/dist/graph/builder.d.ts +11 -0
  130. package/dist/graph/builder.d.ts.map +1 -0
  131. package/dist/graph/builder.js +153 -0
  132. package/dist/graph/identity.d.ts +37 -0
  133. package/dist/graph/identity.d.ts.map +1 -0
  134. package/dist/graph/identity.js +46 -0
  135. package/dist/graph/index.d.ts +7 -0
  136. package/dist/graph/index.d.ts.map +1 -0
  137. package/dist/graph/index.js +3 -0
  138. package/dist/graph/resolver.d.ts +41 -0
  139. package/dist/graph/resolver.d.ts.map +1 -0
  140. package/dist/graph/resolver.js +36 -0
  141. package/dist/graph/types.d.ts +42 -0
  142. package/dist/graph/types.d.ts.map +1 -0
  143. package/dist/graph/types.js +1 -0
  144. package/dist/http/index.d.ts +5 -0
  145. package/dist/http/index.d.ts.map +1 -0
  146. package/dist/http/index.js +2 -0
  147. package/dist/http/server.d.ts +76 -0
  148. package/dist/http/server.d.ts.map +1 -0
  149. package/dist/http/server.js +195 -0
  150. package/dist/http/ui-assets.d.ts +22 -0
  151. package/dist/http/ui-assets.d.ts.map +1 -0
  152. package/dist/http/ui-assets.js +100 -0
  153. package/dist/index.d.ts +31 -0
  154. package/dist/index.d.ts.map +1 -0
  155. package/dist/index.js +26 -0
  156. package/dist/init/index.d.ts +12 -0
  157. package/dist/init/index.d.ts.map +1 -0
  158. package/dist/init/index.js +90 -0
  159. package/dist/init/prompts.d.ts +3 -0
  160. package/dist/init/prompts.d.ts.map +1 -0
  161. package/dist/init/prompts.js +47 -0
  162. package/dist/init/scaffold.d.ts +25 -0
  163. package/dist/init/scaffold.d.ts.map +1 -0
  164. package/dist/init/scaffold.js +204 -0
  165. package/dist/init/types.d.ts +14 -0
  166. package/dist/init/types.d.ts.map +1 -0
  167. package/dist/init/types.js +1 -0
  168. package/dist/readmodel/dashboard.d.ts +40 -0
  169. package/dist/readmodel/dashboard.d.ts.map +1 -0
  170. package/dist/readmodel/dashboard.js +236 -0
  171. package/dist/readmodel/index.d.ts +3 -0
  172. package/dist/readmodel/index.d.ts.map +1 -0
  173. package/dist/readmodel/index.js +1 -0
  174. package/dist/readmodel/types.d.ts +104 -0
  175. package/dist/readmodel/types.d.ts.map +1 -0
  176. package/dist/readmodel/types.js +15 -0
  177. package/dist/root/index.d.ts +179 -0
  178. package/dist/root/index.d.ts.map +1 -0
  179. package/dist/root/index.js +418 -0
  180. package/dist/root/safe-write.d.ts +268 -0
  181. package/dist/root/safe-write.d.ts.map +1 -0
  182. package/dist/root/safe-write.js +966 -0
  183. package/dist/root/write-artifacts.d.ts +20 -0
  184. package/dist/root/write-artifacts.d.ts.map +1 -0
  185. package/dist/root/write-artifacts.js +42 -0
  186. package/dist/scriptorium/compile.d.ts +4 -0
  187. package/dist/scriptorium/compile.d.ts.map +1 -0
  188. package/dist/scriptorium/compile.js +185 -0
  189. package/dist/scriptorium/frontmatter.d.ts +13 -0
  190. package/dist/scriptorium/frontmatter.d.ts.map +1 -0
  191. package/dist/scriptorium/frontmatter.js +90 -0
  192. package/dist/scriptorium/index.d.ts +3 -0
  193. package/dist/scriptorium/index.d.ts.map +1 -0
  194. package/dist/scriptorium/index.js +1 -0
  195. package/dist/scriptorium/markdown.d.ts +31 -0
  196. package/dist/scriptorium/markdown.d.ts.map +1 -0
  197. package/dist/scriptorium/markdown.js +88 -0
  198. package/dist/scriptorium/types.d.ts +78 -0
  199. package/dist/scriptorium/types.d.ts.map +1 -0
  200. package/dist/scriptorium/types.js +1 -0
  201. package/dist/types/enforcement.d.ts +11 -0
  202. package/dist/types/enforcement.d.ts.map +1 -0
  203. package/dist/types/enforcement.js +1 -0
  204. package/dist/types/integrity.d.ts +33 -0
  205. package/dist/types/integrity.d.ts.map +1 -0
  206. package/dist/types/integrity.js +1 -0
  207. package/dist/types/project-definition.d.ts +30 -0
  208. package/dist/types/project-definition.d.ts.map +1 -0
  209. package/dist/types/project-definition.js +1 -0
  210. package/dist/ui/assets/cosmic-glass-bg-Dwx47zPu.webp +0 -0
  211. package/dist/ui/assets/index-C4NhMaq7.js +28 -0
  212. package/dist/ui/assets/index-DMZ7ZvZY.css +1 -0
  213. package/dist/ui/index.html +13 -0
  214. package/dist/validate/index.d.ts +6 -0
  215. package/dist/validate/index.d.ts.map +1 -0
  216. package/dist/validate/index.js +214 -0
  217. package/dist/version.d.ts +2 -0
  218. package/dist/version.d.ts.map +1 -0
  219. package/dist/version.js +1 -0
  220. package/package.json +67 -4
  221. package/templates/.gitkeep +0 -0
  222. package/templates/default-instance/instruction/.cursorrules +53 -0
  223. package/templates/default-instance/instruction/.windsurfrules +53 -0
  224. package/templates/default-instance/instruction/CLAUDE.md +53 -0
  225. package/templates/default-instance/monolith/context/boot/HANDOFF.md +49 -0
  226. package/templates/default-instance/monolith/context/boot/WORK_SESSION.md +30 -0
  227. package/templates/default-instance/monolith/context/execution/current-state.md +30 -0
  228. package/templates/default-instance/monolith/context/maps/dependency-map.md +38 -0
  229. package/templates/default-instance/monolith/context/maps/project-map.md +85 -0
  230. package/templates/default-instance/monolith/context/maps/roadmap-index.md +41 -0
  231. package/templates/default-instance/monolith/context/maps/routing-map.md +147 -0
  232. package/templates/default-instance/monolith/context/maps/semantic-map.md +73 -0
  233. package/templates/default-instance/monolith/context/models/model-registry.md +37 -0
  234. package/templates/default-instance/monolith/context/models/privacy-classification.md +35 -0
  235. package/templates/default-instance/monolith/context/templates/plan-template.md +56 -0
  236. package/templates/default-instance/monolith/governance/README.md +29 -0
  237. package/templates/default-instance/monolith/governance/RULE_SCHEMA.md +58 -0
  238. package/templates/default-instance/monolith/learning/INDEX.md +48 -0
  239. package/templates/default-instance/monolith/learning/injection-map.md +49 -0
  240. package/templates/default-instance/monolith/learning/taxonomy.md +30 -0
  241. package/templates/default-instance/monolith/memory/errors/README.md +26 -0
  242. package/templates/default-instance/monolith/memory/errors/SCHEMA.md +47 -0
  243. package/templates/default-instance/monolith/memory/errors/TAXONOMY.md +27 -0
  244. package/templates/default-instance/monolith/memory/long-term/architecture-principles.md +31 -0
  245. package/templates/default-instance/monolith/memory/long-term/decisions.md +36 -0
  246. package/templates/default-instance/monolith/memory/long-term/execution-history.md +20 -0
  247. package/templates/default-instance/monolith/memory/long-term/tech-debt.md +25 -0
  248. package/templates/default-instance/monolith/memory/working/active.md +41 -0
  249. package/templates/default-instance/monolith/memory/working/governance-result.md +0 -0
  250. package/templates/default-instance/monolith/memory/working/integrity-result.md +0 -0
  251. package/templates/default-instance/monolith/memory/working/story-index.md +28 -0
  252. package/templates/default-instance/monolith/protocols/boot/boot-sequence.md +59 -0
  253. package/templates/default-instance/monolith/protocols/cognition/cognitive-modes.md +90 -0
  254. package/templates/default-instance/monolith/protocols/cognition/dry-run.md +56 -0
  255. package/templates/default-instance/monolith/protocols/cognition/execution-trace-format.md +52 -0
  256. package/templates/default-instance/monolith/protocols/cognition/semantic-planning.md +51 -0
  257. package/templates/default-instance/monolith/protocols/execution/hybrid-execution.md +37 -0
  258. package/templates/default-instance/monolith/protocols/execution/local-llm-governance.md +42 -0
  259. package/templates/default-instance/monolith/protocols/governance/command-policy.md +51 -0
  260. package/templates/default-instance/monolith/protocols/governance/core-invariants.md +40 -0
  261. package/templates/default-instance/monolith/protocols/governance/memory-governance.md +54 -0
  262. package/templates/default-instance/monolith/protocols/governance/metadata-schema.md +62 -0
  263. package/templates/default-instance/monolith/protocols/governance/skills.md +53 -0
  264. package/templates/default-instance/monolith/protocols/governance/work-session.md +46 -0
  265. package/templates/default-instance/monolith/skills/INDEX.md +28 -0
  266. package/templates/default-instance/monolith/skills/current-status/SKILL.md +47 -0
  267. package/templates/default-instance/monolith/skills/finalize-story/SKILL.md +86 -0
  268. package/templates/default-instance/monolith/skills/global-status/SKILL.md +56 -0
  269. package/templates/default-instance/monolith/skills/self-improvement/SKILL.md +77 -0
  270. package/templates/default-instance/monolith/state.json +11 -0
  271. package/templates/default-instance/monolith/tests/INDEX.md +57 -0
  272. package/templates/default-instance/monolith/tests/scenarios/architecture-review.md +32 -0
  273. package/templates/default-instance/monolith/tests/scenarios/debug-failure.md +32 -0
  274. package/templates/default-instance/monolith/tests/scenarios/dry-run-activation.md +33 -0
  275. package/templates/default-instance/monolith/tests/scenarios/execution-trace-verification.md +31 -0
  276. package/templates/default-instance/monolith/tests/scenarios/finalize-story.md +35 -0
  277. package/templates/default-instance/monolith/tests/scenarios/implement-feature.md +36 -0
  278. package/templates/default-instance/monolith/tests/scenarios/local-llm-delegation.md +31 -0
  279. package/templates/default-instance/monolith/tests/scenarios/planning-request.md +32 -0
  280. package/templates/default-instance/monolith/tests/scenarios/sensitive-context-task.md +30 -0
  281. package/templates/default-instance/monolith/tests/scenarios/validator-verification.md +33 -0
  282. package/templates/default-instance/monolith/tests/validation/expected-behavior.md +70 -0
  283. package/templates/default-instance/monolith/tests/validators/INDEX.md +17 -0
  284. package/templates/default-instance/monolith/tests/validators/delegation-semantics.md +22 -0
  285. package/templates/default-instance/monolith/tests/validators/mode-consistency.md +25 -0
  286. package/templates/default-instance/monolith/tests/validators/privacy-routing.md +23 -0
  287. package/templates/default-instance/monolith/tests/validators/protocol-references.md +27 -0
  288. package/templates/default-instance/monolith/tests/validators/routing-integrity.md +30 -0
  289. package/templates/default-instance/projectroot/AGENTS.md +107 -0
  290. package/templates/default-instance/projectroot/monolith.config.json +32 -0
  291. package/templates/default-instance/projectroot/project.definition.mjs.template +41 -0
@@ -0,0 +1,1393 @@
1
+ /**
2
+ * Filesystem `EventStore`.
3
+ *
4
+ * Layout under the store directory:
5
+ *
6
+ * tasks/<taskId>/events/<seq,9 digits>.json one canonical event, immutable
7
+ * tasks/<taskId>/segments/S<from>-<to>.jsonl packed, verified events (compaction)
8
+ * tasks/<taskId>/prune-<n>.prepare.json prune decision register (ADR-019): one attempt per file pair,
9
+ * tasks/<taskId>/prune-<n>.decision.json create-only, never deleted, never rewritten
10
+ * tasks/<taskId>/tombstone.json published from the `commit` record of the register
11
+ * durable-protocol.json version marker; created only by `initializeProtocol()`
12
+ *
13
+ * Concurrency needs no lock file. The one linearizable primitive is "create
14
+ * `<seq>.json` if it does not exist" — `createFile` of the safe-write
15
+ * primitive (temp file, fsync, `link()`, ADR-012). Of N writers racing for the
16
+ * same slot exactly one wins; the others see `conflict`, re-read and retry.
17
+ * There is no owner to declare dead, so none of the stale-lock failure modes
18
+ * (PID reuse across namespaces, wedged lock after a crash; ADR-015) exist.
19
+ *
20
+ * Crash behaviour:
21
+ * - before `link()` : nothing is visible; the temp file is a stranger.
22
+ * - between `link()`/`unlink` : the event is complete and visible, but the file
23
+ * has two names (`nlink == 2`). The strict reader of ADR-014 refuses such a
24
+ * file, which would wedge the task for good. Here that state is *provable
25
+ * residue* — the second name is `<file>.tmp.<pid>.<rand>` with the same
26
+ * dev/ino — and the reader removes exactly that name and re-reads. Any
27
+ * other second link stays a hard `StoreSecurityError`.
28
+ * - compaction / prune : ordered so that every prefix of the steps is a
29
+ * consistent state (segment before unlink, tombstone before removal).
30
+ *
31
+ * Out of scope (same as the rest of MONOLITH, ADR-013): a local adversary that
32
+ * swaps directories concurrently (TM-C), NFS/SMB, Windows.
33
+ */
34
+ import * as fs from 'fs';
35
+ import { dirname, join } from 'path';
36
+ import { sha256Hex } from './canonical.js';
37
+ import { LogCorruptError, PruneAttemptLimitError, PruneDecisionCorruptError, RetentionRefusedError, StoreIoError, StoreProtocolError, StoreSecurityError, TaskPrunedError, TombstoneCommittedError, } from './errors.js';
38
+ import { MAX_REGISTER_BYTES, MAX_WRITE_RESOLVE, PROTOCOL_MARKER_FILE, STAGING_RE, TOMBSTONE_FILE, decisionFile, derivePruneState, encodeDecision, encodePrepare, encodeTombstone, parseDecision, parsePrepare, parseTombstoneFile, prepareFile, sameTombstone, } from './register.js';
39
+ import { tombstoneProblem } from './prune-check.js';
40
+ import { takeFsSeams } from './seams.js';
41
+ import { MAX_EVENT_BYTES, ID_PATTERN, parseEvent, serializeEvent } from './log.js';
42
+ import { MAX_PRUNE_ATTEMPTS } from './types.js';
43
+ import { SafeWriteError, createFile, openParent, projectBoundary, readBoundedFile, trustedDirectoryBoundary, } from '../root/index.js';
44
+ /** Packed segments stay well below this; it is the reader's ceiling. */
45
+ export const MAX_SEGMENT_BYTES = 16 * 1024 * 1024;
46
+ const EVENT_NAME = /^(\d{9})\.json$/;
47
+ const SEGMENT_NAME = /^S(\d{9})-(\d{9})\.jsonl$/;
48
+ const REF_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}\.json$/;
49
+ const TMP_NAME = STAGING_RE;
50
+ const TOMBSTONE_NAME = TOMBSTONE_FILE;
51
+ const MAX_TOMBSTONE_BYTES = 4096;
52
+ const READ_ATTEMPTS = 8;
53
+ export class AppendOutcomeUnknownError extends StoreIoError {
54
+ taskId;
55
+ seq;
56
+ constructor(taskId, seq, cause) {
57
+ super(`publication of event ${seq} of task "${taskId}" has an unknown outcome; re-read the log to find out.`, cause);
58
+ this.taskId = taskId;
59
+ this.seq = seq;
60
+ this.name = 'AppendOutcomeUnknownError';
61
+ }
62
+ }
63
+ const SECURITY_WRITE_CODES = new Set([
64
+ 'WRITE_BOUNDARY_INVALID',
65
+ 'WRITE_OUTSIDE_BOUNDARY',
66
+ 'WRITE_PARENT_SYMLINK',
67
+ 'WRITE_PARENT_NOT_DIRECTORY',
68
+ 'WRITE_PARENT_CHANGED',
69
+ 'WRITE_TARGET_SYMLINK',
70
+ 'WRITE_TARGET_NOT_REGULAR',
71
+ 'WRITE_TARGET_HARDLINKED',
72
+ ]);
73
+ const SECURITY_REASONS = new Set(['FILE_SYMLINK', 'FILE_OUTSIDE_PROJECT', 'FILE_NOT_REGULAR', 'READ_BOUNDARY_INVALID', 'FILE_HARDLINKED']);
74
+ const SECURITY_READ_CODES = new Set(['FILE_OUTSIDE_PROJECT', 'FILE_SYMLINK', 'FILE_NOT_REGULAR', 'READ_BOUNDARY_INVALID', 'READ_UNSUPPORTED_PLATFORM']);
75
+ function errno(err) {
76
+ return err?.code;
77
+ }
78
+ function sleepMs(ms) {
79
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
80
+ }
81
+ const pad = (n) => String(n).padStart(9, '0');
82
+ export class FsEventStore {
83
+ root;
84
+ boundaryDir;
85
+ boundaryCache = null;
86
+ opts;
87
+ /** True once a valid marker was seen. Only then may a read repair anything (it is a mutation). Never reset: the marker is never removed. */
88
+ ready = false;
89
+ maxAttempts;
90
+ seams;
91
+ constructor(opts) {
92
+ // Only the public fields are taken from `opts`; seams arrive through `createFsStoreWithSeams` alone.
93
+ this.opts = { target: opts.target, ...(opts.onWarning ? { onWarning: opts.onWarning } : {}), ...(opts.durability ? { durability: opts.durability } : {}) };
94
+ this.seams = takeFsSeams();
95
+ this.root = opts.target.dir;
96
+ this.boundaryDir = opts.target.scope === 'project' ? opts.target.projectRoot : opts.target.dir;
97
+ this.maxAttempts = this.seams.maxPruneAttempts ?? MAX_PRUNE_ATTEMPTS;
98
+ }
99
+ /**
100
+ * The trust boundary. In `trusted-directory` scope the directory is the boundary and is created on the
101
+ * first write if missing (same contract as the state keeper); a read of a store that does not exist yet
102
+ * simply finds nothing. In `project` scope the project root must already exist.
103
+ */
104
+ boundaryFor(forWrite) {
105
+ if (this.boundaryCache)
106
+ return this.boundaryCache;
107
+ const t = this.opts.target;
108
+ if (t.scope === 'trusted-directory' && !fs.existsSync(t.dir)) {
109
+ if (!forWrite)
110
+ return null;
111
+ fs.mkdirSync(t.dir, { recursive: true, mode: 0o700 });
112
+ }
113
+ this.boundaryCache = this.wrapSafe(() => (t.scope === 'project' ? projectBoundary(t.projectRoot) : trustedDirectoryBoundary(t.dir)));
114
+ return this.boundaryCache;
115
+ }
116
+ // ----- paths --------------------------------------------------------------
117
+ taskDir(taskId) {
118
+ if (!ID_PATTERN.test(taskId))
119
+ throw new StoreSecurityError(`invalid task id "${taskId}".`, 'BAD_ID');
120
+ return join(this.root, 'tasks', taskId);
121
+ }
122
+ eventsDir(taskId) {
123
+ return join(this.taskDir(taskId), 'events');
124
+ }
125
+ segmentsDir(taskId) {
126
+ return join(this.taskDir(taskId), 'segments');
127
+ }
128
+ refsDir(taskId) {
129
+ return join(this.taskDir(taskId), 'refs');
130
+ }
131
+ // ----- error mapping ------------------------------------------------------
132
+ wrapSafe(fn) {
133
+ try {
134
+ return fn();
135
+ }
136
+ catch (err) {
137
+ throw this.mapWriteError(err);
138
+ }
139
+ }
140
+ mapWriteError(err) {
141
+ if (err instanceof SafeWriteError) {
142
+ if (SECURITY_WRITE_CODES.has(err.code))
143
+ return new StoreSecurityError(err.message, err.code);
144
+ return new StoreIoError(err.message, err);
145
+ }
146
+ return err instanceof Error ? err : new StoreIoError(String(err));
147
+ }
148
+ // ----- version marker, bootstrap and diagnostic mode (Design A sections 13, 46.5) -----------------------------
149
+ markerRead() {
150
+ // The marker's own staging twin (a crash between link() and unlink(tmp) of its publication) may be repaired by whoever
151
+ // reads it: the marker is there, so the store is initialized, and removing a provable twin changes no state.
152
+ const r = this.readFileObs(join(this.root, PROTOCOL_MARKER_FILE), PROTOCOL_MARKER_FILE, 1024, true);
153
+ if (r.k === 'bad' && SECURITY_REASONS.has(r.reason)) {
154
+ throw new StoreSecurityError(`${PROTOCOL_MARKER_FILE} or a directory above it is not what a managed file must be (${r.reason}); refusing to use the store.`, r.reason);
155
+ }
156
+ if (r.k !== 'ok')
157
+ return r;
158
+ try {
159
+ const o = JSON.parse(r.v);
160
+ if (typeof o === 'object' && o !== null && !Array.isArray(o) && Object.keys(o).join() === 'v') {
161
+ const v = o.v;
162
+ if (typeof v === 'number' && Number.isInteger(v) && v >= 0)
163
+ return { k: 'ok', v };
164
+ }
165
+ }
166
+ catch {
167
+ /* falls through */
168
+ }
169
+ return { k: 'bad', reason: 'schema' };
170
+ }
171
+ hasTasks() {
172
+ return (this.listDir(join(this.root, 'tasks')) ?? []).some((e) => e.isDirectory() || e.isSymbolicLink());
173
+ }
174
+ /**
175
+ * What this store may be used for. Pure observation: it creates nothing and repairs nothing. A store with no marker is
176
+ * `diagnostic` (empty or not): reads work, every mutation is refused until the operator calls `initializeProtocol()`.
177
+ */
178
+ async protocolStatus() {
179
+ const m = this.markerRead();
180
+ if (m.k === 'ioerr')
181
+ throw new StoreIoError(`cannot read ${PROTOCOL_MARKER_FILE}: ${m.reason}`);
182
+ if (m.k === 'bad')
183
+ return { mode: 'incompatible', reason: 'invalid-marker' };
184
+ if (m.k === 'ok') {
185
+ if (m.v !== 2)
186
+ return { mode: 'incompatible', reason: 'unsupported-version' };
187
+ this.ready = true;
188
+ return { mode: 'ready', version: 2 };
189
+ }
190
+ return { mode: 'diagnostic', reason: 'uninitialized', tasksPresent: this.hasTasks() };
191
+ }
192
+ /**
193
+ * Explicit operator action (Design A section 46.5, ADR-018 D3): publish `durable-protocol.json` (`{"v":2}`, create-only)
194
+ * in an EMPTY store, with every runtime of an older version stopped. It refuses a store that holds tasks (that needs the
195
+ * migration decision of the operator, not a bootstrap), refuses a marker of another version, and is a no-op when a valid
196
+ * marker is already there. Opening a store never does this.
197
+ */
198
+ async initializeProtocol() {
199
+ return this.initializeProtocolSync();
200
+ }
201
+ /** The same operator action as `initializeProtocol`, for scripts and tests that are not async. */
202
+ initializeProtocolSync() {
203
+ const m = this.markerRead();
204
+ if (m.k === 'ioerr')
205
+ throw new StoreIoError(`cannot read ${PROTOCOL_MARKER_FILE}: ${m.reason}`);
206
+ if (m.k === 'bad' || (m.k === 'ok' && m.v !== 2)) {
207
+ throw new StoreProtocolError(`the store carries a ${PROTOCOL_MARKER_FILE} this version does not understand; it will not be touched.`, 'PROTOCOL_UNSUPPORTED');
208
+ }
209
+ if (m.k === 'ok') {
210
+ this.ready = true;
211
+ return 'already-initialized';
212
+ }
213
+ if (this.hasTasks()) {
214
+ throw new StoreProtocolError('the store already holds tasks; a store with tasks and no marker is only for diagnostics until the operator migrates it.', 'PROTOCOL_STORE_NOT_EMPTY');
215
+ }
216
+ const parent = this.wrapSafe(() => openParent(this.boundaryFor(true), this.root, { create: true, ops: this.seams.ops }));
217
+ try {
218
+ const out = createFile(parent, PROTOCOL_MARKER_FILE, '{"v":2}\n', { mode: 0o600, durability: this.opts.durability ?? 'full', maxBytes: 1024, ops: this.seams.ops });
219
+ for (const w of out.warnings)
220
+ this.opts.onWarning?.(w);
221
+ }
222
+ catch (err) {
223
+ if (err instanceof SafeWriteError && err.code === 'WRITE_TARGET_EXISTS') {
224
+ const again = this.markerRead();
225
+ if (again.k === 'ok' && again.v === 2) {
226
+ this.ready = true;
227
+ return 'already-initialized';
228
+ }
229
+ throw new StoreProtocolError(`another ${PROTOCOL_MARKER_FILE} appeared that this version does not understand; it will not be touched.`, 'PROTOCOL_UNSUPPORTED');
230
+ }
231
+ throw this.mapWriteError(err);
232
+ }
233
+ this.fsyncChain(this.root);
234
+ this.ready = true;
235
+ return 'initialized';
236
+ }
237
+ /** Called first by every public method. A write needs a valid marker; a read needs the marker to be absent or valid. */
238
+ gate(kind) {
239
+ if (this.ready)
240
+ return;
241
+ const m = this.markerRead();
242
+ if (m.k === 'ok' && m.v === 2) {
243
+ this.ready = true;
244
+ return;
245
+ }
246
+ if (m.k === 'ioerr')
247
+ throw new StoreIoError(`cannot read ${PROTOCOL_MARKER_FILE}: ${m.reason}`);
248
+ if (m.k === 'bad' || m.k === 'ok') {
249
+ throw new StoreProtocolError(`the store carries a ${PROTOCOL_MARKER_FILE} this version does not understand; it is not opened as compatible.`, 'PROTOCOL_UNSUPPORTED');
250
+ }
251
+ if (kind === 'write') {
252
+ throw new StoreProtocolError(`the durable store has no ${PROTOCOL_MARKER_FILE}: it is open for diagnostics only. An operator must initialize it explicitly (initializeProtocol) on an empty store; a store with tasks needs the migration procedure.`, 'PROTOCOL_NOT_INITIALIZED');
253
+ }
254
+ }
255
+ // ----- directory helpers --------------------------------------------------
256
+ /** Verified directory (no symlink below the boundary) or `null` if it does not exist. */
257
+ existingDir(dir) {
258
+ const boundary = this.boundaryFor(false);
259
+ if (!boundary)
260
+ return false;
261
+ try {
262
+ openParent(boundary, dir, { ops: this.seams.ops });
263
+ return true;
264
+ }
265
+ catch (err) {
266
+ if (err instanceof SafeWriteError && err.code === 'WRITE_PARENT_MISSING')
267
+ return false;
268
+ throw this.mapWriteError(err);
269
+ }
270
+ }
271
+ listDir(dir) {
272
+ if (!this.existingDir(dir))
273
+ return null;
274
+ try {
275
+ return fs.readdirSync(dir, { withFileTypes: true });
276
+ }
277
+ catch (err) {
278
+ if (errno(err) === 'ENOENT')
279
+ return null;
280
+ throw new StoreIoError(`cannot list "${dir}": ${err.message}`, err);
281
+ }
282
+ }
283
+ // ----- strict read with residue repair ------------------------------------
284
+ /**
285
+ * The second name of a freshly published file, if (and only if) it is
286
+ * provably this file's staging name: `<name>.tmp.<pid>.<rand>` in the same
287
+ * directory with the same dev/ino. Returns the removed name or `null`.
288
+ * Removing it is a mutation: a store without a valid marker (diagnostics only) never does it.
289
+ */
290
+ repairResidue(abs, force = false) {
291
+ if (!this.ready && !force)
292
+ return null;
293
+ const dir = dirname(abs);
294
+ const name = abs.slice(dir.length + 1);
295
+ let final;
296
+ try {
297
+ final = fs.lstatSync(abs, { bigint: true });
298
+ }
299
+ catch {
300
+ return null;
301
+ }
302
+ if (!final.isFile() || final.nlink !== 2n)
303
+ return null;
304
+ let entries;
305
+ try {
306
+ entries = fs.readdirSync(dir);
307
+ }
308
+ catch {
309
+ return null;
310
+ }
311
+ const twins = [];
312
+ for (const e of entries) {
313
+ const m = TMP_NAME.exec(e);
314
+ if (!m || m[1] !== name)
315
+ continue;
316
+ try {
317
+ const st = fs.lstatSync(join(dir, e), { bigint: true });
318
+ if (st.isFile() && st.dev === final.dev && st.ino === final.ino)
319
+ twins.push(e);
320
+ }
321
+ catch {
322
+ /* vanished: the writer finished itself */
323
+ }
324
+ }
325
+ if (twins.length !== 1)
326
+ return null;
327
+ try {
328
+ fs.unlinkSync(join(dir, twins[0]));
329
+ }
330
+ catch (err) {
331
+ if (errno(err) !== 'ENOENT')
332
+ return null;
333
+ }
334
+ return twins[0];
335
+ }
336
+ /** `null` when the file is missing. Throws on anything unsafe or unreadable. */
337
+ readManaged(abs, label, maxBytes, taskId, diag) {
338
+ let hardlinkTries = 0;
339
+ for (let attempt = 0; attempt < 8; attempt++) {
340
+ const r = readBoundedFile(abs, label, { boundary: this.boundaryDir, maxBytes }, true);
341
+ if (r.ok)
342
+ return r.content;
343
+ if (r.code === 'FILE_MISSING')
344
+ return null;
345
+ if (r.code === 'FILE_CHANGED')
346
+ continue;
347
+ if (r.code === 'FILE_HARDLINKED') {
348
+ const repaired = this.repairResidue(abs);
349
+ if (repaired) {
350
+ diag?.push({ code: 'STAGING_RESIDUE_REPAIRED', taskId, name: repaired });
351
+ continue;
352
+ }
353
+ // A live writer may be between link() and unlink(): give it a moment before judging.
354
+ if (hardlinkTries++ < 3) {
355
+ sleepMs(2);
356
+ continue;
357
+ }
358
+ throw new StoreSecurityError(`${label} has a second hard link that is not its own staging name; refusing to read it.`, r.code);
359
+ }
360
+ if (SECURITY_READ_CODES.has(r.code))
361
+ throw new StoreSecurityError(r.detail, r.code);
362
+ if (r.code === 'FILE_EMPTY')
363
+ throw new LogCorruptError(taskId, null, `${label} is empty`);
364
+ if (r.code === 'FILE_TOO_LARGE')
365
+ throw new LogCorruptError(taskId, null, `${label} exceeds ${maxBytes} bytes`);
366
+ throw new StoreIoError(r.detail);
367
+ }
368
+ throw new StoreIoError(`${label} kept changing while being read.`);
369
+ }
370
+ /**
371
+ * The same safe read, reported as an observation instead of an exception, for the places that must tell
372
+ * "not there" (`absent`) from "there and wrong" (`bad`: evidence) from "could not find out" (`ioerr`: never evidence).
373
+ */
374
+ readFileObs(abs, label, maxBytes, repair = false) {
375
+ const fault = this.seams.readFault?.(abs);
376
+ if (fault)
377
+ return { k: 'ioerr', reason: `${fault}:${label}` };
378
+ if (!fs.existsSync(this.boundaryDir))
379
+ return { k: 'absent' };
380
+ let hardlinkTries = 0;
381
+ for (let attempt = 0; attempt < 8; attempt++) {
382
+ const r = readBoundedFile(abs, label, { boundary: this.boundaryDir, maxBytes }, true);
383
+ if (r.ok)
384
+ return { k: 'ok', v: r.content };
385
+ switch (r.code) {
386
+ case 'FILE_MISSING':
387
+ return { k: 'absent' };
388
+ case 'FILE_CHANGED':
389
+ continue;
390
+ case 'FILE_HARDLINKED': {
391
+ if (this.repairResidue(abs, repair))
392
+ continue;
393
+ if (hardlinkTries++ < 3) {
394
+ sleepMs(2);
395
+ continue;
396
+ }
397
+ // Without a marker nothing may be repaired: the crash residue of a publisher is not evidence of damage.
398
+ return this.ready || repair ? { k: 'bad', reason: r.code } : { k: 'ioerr', reason: `${r.code}:${label}` };
399
+ }
400
+ case 'FILE_SYMLINK':
401
+ case 'FILE_OUTSIDE_PROJECT':
402
+ case 'FILE_NOT_REGULAR':
403
+ case 'READ_BOUNDARY_INVALID':
404
+ case 'FILE_EMPTY':
405
+ case 'FILE_TOO_LARGE':
406
+ return { k: 'bad', reason: r.code };
407
+ default:
408
+ return { k: 'ioerr', reason: `${r.code}:${label}` };
409
+ }
410
+ }
411
+ return { k: 'ioerr', reason: `FILE_CHANGED:${label}` };
412
+ }
413
+ // ----- the prune decision register ----------------------------------------
414
+ registerIO(taskId) {
415
+ const dir = this.taskDir(taskId);
416
+ const read = (name, max, parse) => {
417
+ const r = this.readFileObs(join(dir, name), name, max);
418
+ return r.k === 'ok' ? parse(r.v) : r;
419
+ };
420
+ return {
421
+ prepare: (n) => read(prepareFile(n), MAX_REGISTER_BYTES, (t) => parsePrepare(t, n, taskId)),
422
+ decision: (n) => read(decisionFile(n), MAX_REGISTER_BYTES, (t) => parseDecision(t, n, taskId)),
423
+ tombstone: () => read(TOMBSTONE_NAME, MAX_TOMBSTONE_BYTES, (t) => parseTombstoneFile(t, taskId)),
424
+ };
425
+ }
426
+ /** Files of the task still on disk (events, segments, references, staging). `> 0` after a commit means "deleting". */
427
+ residueCount(taskId) {
428
+ let n = 0;
429
+ try {
430
+ for (const dir of [this.eventsDir(taskId), this.segmentsDir(taskId), this.refsDir(taskId)]) {
431
+ for (const e of this.listDir(dir) ?? []) {
432
+ if (e.isFile() && (EVENT_NAME.test(e.name) || SEGMENT_NAME.test(e.name) || REF_NAME.test(e.name) || TMP_NAME.test(e.name)))
433
+ n++;
434
+ }
435
+ }
436
+ }
437
+ catch {
438
+ return 1; // unknown: assume there is something left; no decision depends on "gone"
439
+ }
440
+ return n;
441
+ }
442
+ /** `derivePruneState` over the real directory. Reads only. */
443
+ derive(taskId) {
444
+ const dir = this.taskDir(taskId);
445
+ if (!this.existingDir(dir))
446
+ return { kind: 'missing' };
447
+ let names;
448
+ try {
449
+ names = fs.readdirSync(dir);
450
+ if (this.seams.registerListing)
451
+ names = this.seams.registerListing(taskId, names);
452
+ }
453
+ catch (err) {
454
+ if (errno(err) === 'ENOENT')
455
+ return { kind: 'missing' };
456
+ return { kind: 'unreadable', reason: `io:list:${errno(err) ?? 'error'}` };
457
+ }
458
+ const st = derivePruneState(names, this.registerIO(taskId), this.maxAttempts);
459
+ if (st.kind === 'committed')
460
+ st.residue = this.residueCount(taskId);
461
+ return st;
462
+ }
463
+ /** The error a state that grants nothing turns into. */
464
+ refusal(taskId, st) {
465
+ switch (st.kind) {
466
+ case 'corrupt':
467
+ return new PruneDecisionCorruptError(taskId, (st.reason ?? 'register'), st.file ?? null, st.attempt ?? null);
468
+ case 'legacy':
469
+ return new PruneDecisionCorruptError(taskId, 'legacy-tombstone-unmigrated');
470
+ case 'missing':
471
+ return new PruneDecisionCorruptError(taskId, 'task-dir-missing');
472
+ case 'unreadable':
473
+ return new StoreIoError(`the prune state of task "${taskId}" cannot be read now (${st.reason ?? 'unknown'}); nothing was changed. Try again.`);
474
+ case 'committed':
475
+ return new TaskPrunedError(taskId);
476
+ default:
477
+ return new StoreIoError(`unexpected prune state "${st.kind}" for task "${taskId}".`);
478
+ }
479
+ }
480
+ async pruneState(taskId) {
481
+ this.gate('read');
482
+ return this.derive(taskId);
483
+ }
484
+ publishRecord(taskId, name, data) {
485
+ const parent = this.wrapSafe(() => openParent(this.boundaryFor(true), this.taskDir(taskId), { ops: this.seams.ops }));
486
+ try {
487
+ const out = createFile(parent, name, data, { mode: 0o600, durability: this.opts.durability ?? 'full', maxBytes: MAX_REGISTER_BYTES, ops: this.seams.ops });
488
+ for (const w of out.warnings)
489
+ this.opts.onWarning?.(w);
490
+ return 'created';
491
+ }
492
+ catch (err) {
493
+ if (err instanceof SafeWriteError && err.code === 'WRITE_TARGET_EXISTS')
494
+ return 'exists';
495
+ throw this.mapWriteError(err);
496
+ }
497
+ }
498
+ /**
499
+ * The precondition of an irreversible prune (F-1): the log, read now, is that of a finished task whose head is the one
500
+ * the tombstone names. Meaningful only for an attempt that is already visible in the register (or, as a refusal that
501
+ * leaves no residue, just before one is published): from the moment the prepare is visible, an append that lands after
502
+ * this read settles through G2, finds the open attempt and closes it with `abort`, so a commit can never follow it.
503
+ */
504
+ assertPrunable(tombstone) {
505
+ if (this.seams.skipPrunePrecondition)
506
+ return;
507
+ const taskId = tombstone.taskId;
508
+ const log = this.readLogImpl(taskId, { rawEvents: true });
509
+ if (!log.exists)
510
+ throw new LogCorruptError(taskId, null, 'cannot prune an unknown task');
511
+ const problem = tombstoneProblem(taskId, log.events, tombstone);
512
+ if (problem !== null)
513
+ throw new RetentionRefusedError(taskId, problem);
514
+ }
515
+ async preparePrune(tombstone) {
516
+ this.gate('write');
517
+ const taskId = tombstone.taskId;
518
+ for (let i = 0; i < 8; i++) {
519
+ const st = this.derive(taskId);
520
+ switch (st.kind) {
521
+ case 'missing':
522
+ throw new LogCorruptError(taskId, null, 'cannot prune an unknown task');
523
+ case 'preparing':
524
+ return { attempt: st.attempt, created: false };
525
+ case 'live': {
526
+ const n = (st.attempt ?? 0) + 1;
527
+ if (n > this.maxAttempts)
528
+ throw new PruneAttemptLimitError(taskId, this.maxAttempts);
529
+ this.assertPrunable(tombstone); // early refusal: nothing is written
530
+ if (this.publishRecord(taskId, prepareFile(n), encodePrepare(n, tombstone)) === 'created') {
531
+ try {
532
+ this.assertPrunable(tombstone); // authoritative: the prepare is visible now
533
+ }
534
+ catch (err) {
535
+ // Whatever the reason (finished-ness, head, a log that does not verify, a read that failed), an attempt that
536
+ // cannot be justified is closed: abort is always safe, and it is the pruner's own attempt.
537
+ await this.decidePrune(taskId, n, 'abort', undefined, 'pruner').catch(() => undefined);
538
+ throw err;
539
+ }
540
+ return { attempt: n, created: true };
541
+ }
542
+ continue; // somebody opened attempt n first: look again and accept theirs
543
+ }
544
+ default:
545
+ throw this.refusal(taskId, st);
546
+ }
547
+ }
548
+ throw new StoreIoError(`prune attempts of task "${taskId}" kept changing; try again.`);
549
+ }
550
+ async decidePrune(taskId, attempt, outcome, tombstone, by) {
551
+ this.gate('write');
552
+ if (!Number.isInteger(attempt) || attempt < 1)
553
+ throw new LogCorruptError(taskId, null, `invalid prune attempt ${attempt}`);
554
+ if (outcome !== 'commit' && outcome !== 'abort')
555
+ throw new LogCorruptError(taskId, null, 'a prune decision is either "commit" or "abort"');
556
+ const io = this.registerIO(taskId);
557
+ // Only for an attempt whose valid prepare is visible to the caller.
558
+ const p = io.prepare(attempt);
559
+ if (p.k === 'absent')
560
+ throw new LogCorruptError(taskId, null, `there is no prepare for prune attempt ${attempt}; a decision needs one`);
561
+ if (p.k === 'bad')
562
+ throw new PruneDecisionCorruptError(taskId, 'register', prepareFile(attempt), attempt);
563
+ if (p.k === 'ioerr')
564
+ throw new StoreIoError(`cannot read ${prepareFile(attempt)} of task "${taskId}": ${p.reason}`);
565
+ if (outcome === 'commit' && (!tombstone || !sameTombstone(tombstone, p.v.tombstone))) {
566
+ throw new LogCorruptError(taskId, null, 'a commit must carry the tombstone of its attempt');
567
+ }
568
+ const existing = () => {
569
+ const d = io.decision(attempt);
570
+ if (d.k === 'ok')
571
+ return d.v.outcome;
572
+ if (d.k === 'bad')
573
+ throw new PruneDecisionCorruptError(taskId, 'register', decisionFile(attempt), attempt);
574
+ throw new StoreIoError(`cannot read ${decisionFile(attempt)} of task "${taskId}": ${d.k === 'ioerr' ? d.reason : 'vanished'}`);
575
+ };
576
+ // A later attempt can only exist if this one was decided; if its decision is missing the register has a hole,
577
+ // and a hole is never filled in (it is what a deleted record looks like).
578
+ if (io.prepare(attempt + 1).k !== 'absent') {
579
+ const d = io.decision(attempt);
580
+ if (d.k === 'absent')
581
+ throw new PruneDecisionCorruptError(taskId, 'gap', decisionFile(attempt), attempt);
582
+ return existing();
583
+ }
584
+ // A commit is irreversible: the head it names must be the head of the log, checked while the prepare is visible.
585
+ // A decision that already exists is the value of the register and is returned as it is, not re-judged.
586
+ if (outcome === 'commit' && io.decision(attempt).k === 'absent')
587
+ this.assertPrunable(p.v.tombstone);
588
+ const data = encodeDecision(attempt, outcome, p.v.tombstone, by);
589
+ return this.publishRecord(taskId, decisionFile(attempt), data) === 'created' ? outcome : existing();
590
+ }
591
+ // ----- adapters of the pre-register operations ----------------------------------------------------------------
592
+ async markPruned(tombstone) {
593
+ this.gate('write');
594
+ const taskId = tombstone.taskId;
595
+ const st = this.derive(taskId);
596
+ if (st.kind === 'preparing' || st.kind === 'committed') {
597
+ if (st.tombstone.headHash !== tombstone.headHash)
598
+ throw new LogCorruptError(taskId, null, 'a different tombstone already exists');
599
+ return;
600
+ }
601
+ await this.preparePrune(tombstone);
602
+ }
603
+ async withdrawTombstone(tombstone) {
604
+ this.gate('write');
605
+ const taskId = tombstone.taskId;
606
+ const st = this.derive(taskId);
607
+ if (st.kind === 'live' || st.kind === 'missing')
608
+ return; // already withdrawn
609
+ if (st.kind === 'committed')
610
+ throw new TombstoneCommittedError(taskId, 'the prune is committed; the tombstone cannot be withdrawn');
611
+ if (st.kind !== 'preparing')
612
+ throw this.refusal(taskId, st);
613
+ if (st.tombstone.headHash !== tombstone.headHash)
614
+ throw new LogCorruptError(taskId, null, 'a different tombstone exists; refusing to withdraw it');
615
+ const w = await this.decidePrune(taskId, st.attempt, 'abort', undefined, 'creator');
616
+ if (w === 'commit')
617
+ throw new TombstoneCommittedError(taskId, 'the prune is committed; the tombstone cannot be withdrawn');
618
+ }
619
+ async prune(tombstone, opts = {}) {
620
+ this.gate('write');
621
+ const taskId = tombstone.taskId;
622
+ let committed = null;
623
+ for (let i = 0; i < 8 && committed === null; i++) {
624
+ const st = this.derive(taskId);
625
+ switch (st.kind) {
626
+ case 'missing':
627
+ throw new LogCorruptError(taskId, null, 'cannot prune an unknown task');
628
+ case 'committed':
629
+ if (st.tombstone.headHash !== tombstone.headHash)
630
+ throw new LogCorruptError(taskId, null, 'a different tombstone already exists');
631
+ committed = st.tombstone;
632
+ break;
633
+ case 'live':
634
+ case 'preparing': {
635
+ if (opts.requireCommit)
636
+ throw new RetentionRefusedError(taskId, 'no prune decision has been committed for it');
637
+ // The low-level form starts a prune by itself only when none was ever attempted. Once an attempt exists it can
638
+ // only be finished: an attempt that was ABORTED (by a task that needs the parent) is not silently replaced by a
639
+ // new one that commits without looking for referrers. A pruner that wants another try calls `markPruned` again
640
+ // and does its own check.
641
+ if (st.kind === 'live' && (st.attempt ?? 0) > 0)
642
+ throw new RetentionRefusedError(taskId, 'its last prune attempt was aborted by a task that needs it');
643
+ if (st.kind === 'preparing' && st.tombstone.headHash !== tombstone.headHash) {
644
+ throw new LogCorruptError(taskId, null, 'a different tombstone already exists');
645
+ }
646
+ // The low-level form: publish the attempt and its commit without looking for referrers. The register
647
+ // decides, so a concurrent abort wins over this call or this call wins over it, never both.
648
+ // `st` was read before `preparePrune` re-derives: an abort that lands in between would make it open a NEW attempt
649
+ // here. Only the attempt this call saw (or the first one) may be committed; anything else is a replaced attempt.
650
+ const expected = st.kind === 'preparing' ? st.attempt : 1;
651
+ const opened = await this.preparePrune(tombstone);
652
+ const attempt = opened.attempt;
653
+ if (attempt !== expected) {
654
+ if (opened.created)
655
+ await this.decidePrune(taskId, attempt, 'abort', undefined, 'pruner');
656
+ throw new RetentionRefusedError(taskId, 'its prune attempt was aborted by a task that needs it');
657
+ }
658
+ const prepared = this.registerIO(taskId).prepare(attempt);
659
+ const tomb = prepared.k === 'ok' ? prepared.v.tombstone : tombstone;
660
+ if (tomb.headHash !== tombstone.headHash)
661
+ throw new LogCorruptError(taskId, null, 'a different tombstone already exists');
662
+ const w = await this.decidePrune(taskId, attempt, 'commit', tomb, 'pruner');
663
+ if (w === 'abort')
664
+ throw new RetentionRefusedError(taskId, 'the prune attempt was aborted by a task that needs it');
665
+ break;
666
+ }
667
+ default:
668
+ throw this.refusal(taskId, st);
669
+ }
670
+ }
671
+ if (committed === null)
672
+ throw new StoreIoError(`the prune state of task "${taskId}" kept changing; try again.`);
673
+ return this.rollForward(taskId, committed);
674
+ }
675
+ /**
676
+ * Prune cleanup (class C, section 30.3): only for a verified, consistent `COMMITTED`. Publishes `tombstone.json` from the
677
+ * commit, then removes the data of the task. It never touches the register, `tombstone.json` or the task directory,
678
+ * decides nothing, and finishing a half-done run is the same call again.
679
+ */
680
+ rollForward(taskId, tombstone) {
681
+ const parent = this.wrapSafe(() => openParent(this.boundaryFor(true), this.taskDir(taskId), { ops: this.seams.ops }));
682
+ try {
683
+ const out = createFile(parent, TOMBSTONE_NAME, encodeTombstone(tombstone), { mode: 0o600, durability: this.opts.durability ?? 'full', maxBytes: MAX_TOMBSTONE_BYTES, ops: this.seams.ops });
684
+ for (const w of out.warnings)
685
+ this.opts.onWarning?.(w);
686
+ }
687
+ catch (err) {
688
+ if (!(err instanceof SafeWriteError && err.code === 'WRITE_TARGET_EXISTS'))
689
+ throw this.mapWriteError(err);
690
+ const t = parseTombstoneFile(this.readManaged(join(this.taskDir(taskId), TOMBSTONE_NAME), 'tombstone', MAX_TOMBSTONE_BYTES, taskId) ?? '', taskId);
691
+ if (t.k !== 'ok' || !sameTombstone(t.v, tombstone))
692
+ throw new PruneDecisionCorruptError(taskId, 'commit-audit', TOMBSTONE_NAME);
693
+ }
694
+ let removed = 0;
695
+ for (const dir of [this.eventsDir(taskId), this.segmentsDir(taskId), this.refsDir(taskId)]) {
696
+ const entries = this.listDir(dir);
697
+ if (!entries)
698
+ continue;
699
+ let leftovers = 0;
700
+ for (const e of entries) {
701
+ if (!(e.isFile() && (EVENT_NAME.test(e.name) || SEGMENT_NAME.test(e.name) || REF_NAME.test(e.name) || TMP_NAME.test(e.name)))) {
702
+ leftovers++;
703
+ continue;
704
+ }
705
+ try {
706
+ fs.unlinkSync(join(dir, e.name));
707
+ removed++;
708
+ }
709
+ catch (err) {
710
+ if (errno(err) !== 'ENOENT')
711
+ throw new StoreIoError(`cannot remove ${e.name}: ${err.message}`, err);
712
+ }
713
+ }
714
+ if (leftovers === 0) {
715
+ try {
716
+ fs.rmdirSync(dir);
717
+ }
718
+ catch (err) {
719
+ if (errno(err) !== 'ENOENT' && errno(err) !== 'ENOTEMPTY')
720
+ throw new StoreIoError(`cannot remove ${dir}: ${err.message}`, err);
721
+ }
722
+ }
723
+ }
724
+ return { removedFiles: removed };
725
+ }
726
+ // ----- G1 / G2 ---------------------------------------------------------------------------------------------------
727
+ /**
728
+ * G1 (section 24.6): an optional early refusal, so that a write to a pruned or damaged task does not leave a file
729
+ * behind. Safety never depends on it (G2 decides); turning it off changes only how much is left over.
730
+ */
731
+ earlyCheck(taskId) {
732
+ const st = this.derive(taskId);
733
+ if (this.seams.earlyPruneCheck === false)
734
+ return st;
735
+ if (st.kind === 'committed' || st.kind === 'corrupt' || st.kind === 'legacy' || st.kind === 'unreadable')
736
+ throw this.refusal(taskId, st);
737
+ return st;
738
+ }
739
+ /**
740
+ * G2 (section 24.2): after a file was published, ask the register. It never unlinks anything: a published file is
741
+ * shared state (F-1, class A is forbidden). `PREPARING` is closed with an `abort` (always safe), at most
742
+ * `MAX_WRITE_RESOLVE` times; a pruner that keeps opening attempts leaves the outcome UNKNOWN, never a deletion.
743
+ */
744
+ async settle(taskId, by) {
745
+ let last = 'attempts kept being opened';
746
+ for (let i = 0; i < MAX_WRITE_RESOLVE; i++) {
747
+ const st = this.derive(taskId);
748
+ switch (st.kind) {
749
+ case 'live':
750
+ return { t: 'CONFIRMED' };
751
+ case 'committed':
752
+ return { t: 'PRUNED' };
753
+ case 'corrupt':
754
+ case 'legacy':
755
+ return { t: 'CORRUPT', state: st };
756
+ case 'preparing':
757
+ try {
758
+ await this.decidePrune(taskId, st.attempt, 'abort', undefined, by);
759
+ }
760
+ catch (err) {
761
+ if (err instanceof PruneDecisionCorruptError)
762
+ return { t: 'CORRUPT', state: { kind: 'corrupt', reason: err.reason, ...(err.file ? { file: err.file } : {}) } };
763
+ if (err instanceof StoreIoError)
764
+ return { t: 'UNKNOWN', why: err.message };
765
+ throw err;
766
+ }
767
+ last = 'a new prune attempt was opened while the previous one was being closed';
768
+ continue;
769
+ default:
770
+ return { t: 'UNKNOWN', why: st.kind === 'missing' ? 'the task directory disappeared' : `unreadable: ${st.reason ?? ''}` };
771
+ }
772
+ }
773
+ return { t: 'UNKNOWN', why: last };
774
+ }
775
+ // ----- EventStore ---------------------------------------------------------
776
+ async listTaskIds() {
777
+ this.gate('read');
778
+ const entries = this.listDir(join(this.root, 'tasks'));
779
+ if (!entries)
780
+ return [];
781
+ return entries
782
+ .filter((e) => e.isDirectory() && ID_PATTERN.test(e.name))
783
+ .map((e) => e.name)
784
+ .sort();
785
+ }
786
+ async readLog(taskId, opts = {}) {
787
+ this.gate('read');
788
+ return this.readLogImpl(taskId, opts);
789
+ }
790
+ readLogImpl(taskId, opts) {
791
+ const after = opts.afterSeq ?? 0;
792
+ const taskDir = this.taskDir(taskId);
793
+ // Segments are immutable: once parsed in this call they are not parsed again on a retry.
794
+ const parsed = new Map();
795
+ for (let attempt = 0; attempt < READ_ATTEMPTS; attempt++) {
796
+ const diagnostics = [];
797
+ if (!this.existingDir(taskDir))
798
+ return { exists: false, tombstone: null, events: [], compactedThrough: 0, diagnostics };
799
+ const st = this.derive(taskId);
800
+ if (st.kind === 'unreadable')
801
+ throw this.refusal(taskId, st);
802
+ const hidden = st.kind === 'committed' || st.kind === 'legacy' || (st.kind === 'preparing' && !opts.rawEvents);
803
+ if (hidden) {
804
+ // Pruned (or a prune attempt is open). An open attempt is shown the way the pre-register protocol showed a published
805
+ // tombstone — "tombstone visible, data intact" — because the consumers of this store (claim, createTask) rely on
806
+ // that to refuse work against a task that may be about to disappear. `prune.kind` is the authority.
807
+ for (const dir of [this.eventsDir(taskId), this.segmentsDir(taskId)]) {
808
+ for (const e of this.listDir(dir) ?? []) {
809
+ if (e.isFile() && (EVENT_NAME.test(e.name) || SEGMENT_NAME.test(e.name) || TMP_NAME.test(e.name))) {
810
+ diagnostics.push({ code: 'PRUNE_INCOMPLETE', taskId, name: e.name });
811
+ }
812
+ }
813
+ }
814
+ for (const a of st.anomalies ?? [])
815
+ diagnostics.push({ code: 'UNKNOWN_PRUNE_ARTIFACT', taskId, name: a.replace(/^UNKNOWN_PRUNE:/, '') });
816
+ return { exists: true, tombstone: st.tombstone, prune: st, events: [], compactedThrough: 0, diagnostics };
817
+ }
818
+ for (const a of st.anomalies ?? [])
819
+ diagnostics.push({ code: 'UNKNOWN_PRUNE_ARTIFACT', taskId, name: a.replace(/^UNKNOWN_PRUNE:/, '') });
820
+ // ORDER MATTERS. Compaction publishes a segment BEFORE it removes the loose files it covers. So if the
821
+ // loose files are listed first and the segments second, an event missing from the first listing was
822
+ // removed before it, hence its segment was already published and appears in the second. Listing in the
823
+ // other order lets a reader that is slow between the two listings see a hole on every attempt.
824
+ const eventEntries = this.listDir(this.eventsDir(taskId)) ?? [];
825
+ const segEntries = this.listDir(this.segmentsDir(taskId)) ?? [];
826
+ this.seams.afterListing?.(attempt);
827
+ const segments = [];
828
+ for (const e of segEntries) {
829
+ const m = SEGMENT_NAME.exec(e.name);
830
+ if (m && e.isFile())
831
+ segments.push({ name: e.name, from: Number(m[1]), to: Number(m[2]) });
832
+ else if (TMP_NAME.test(e.name))
833
+ diagnostics.push({ code: 'STAGING_FILE_PRESENT', taskId, name: e.name });
834
+ else
835
+ diagnostics.push({ code: 'UNKNOWN_ENTRY', taskId, name: e.name });
836
+ }
837
+ segments.sort((a, b) => a.from - b.from || a.to - b.to);
838
+ const segEnd = segments.reduce((m, s) => Math.max(m, s.to), 0);
839
+ // Read the loose files first, right after listing them: they are small and they are what a
840
+ // concurrent compaction removes. The (slow) segment parsing comes after, when a vanished file
841
+ // is already known to be covered.
842
+ const bySeq = new Map();
843
+ let vanished = false;
844
+ for (const e of eventEntries) {
845
+ const m = EVENT_NAME.exec(e.name);
846
+ if (!m) {
847
+ diagnostics.push({ code: TMP_NAME.test(e.name) ? 'STAGING_FILE_PRESENT' : 'UNKNOWN_ENTRY', taskId, name: e.name });
848
+ continue;
849
+ }
850
+ if (!e.isFile())
851
+ throw new StoreSecurityError(`event entry ${e.name} of task "${taskId}" is not a regular file.`, 'FILE_NOT_REGULAR');
852
+ const seq = Number(m[1]);
853
+ if (seq <= after)
854
+ continue;
855
+ // Covered by a segment of THIS listing (Cov(s) != empty, section 35.1), not "below the highest segment": a slot
856
+ // outside every segment is canonical even when a later segment exists (F-G-1).
857
+ if (segments.some((g) => g.from <= seq && seq <= g.to)) {
858
+ diagnostics.push({ code: 'LOOSE_COPY_OF_COMPACTED_EVENT', taskId, name: e.name });
859
+ continue;
860
+ }
861
+ const content = this.readManaged(join(this.eventsDir(taskId), e.name), `event ${e.name}`, MAX_EVENT_BYTES + 4096, taskId, diagnostics);
862
+ if (content === null) {
863
+ vanished = true;
864
+ continue;
865
+ }
866
+ const ev = parseEvent(content.trim(), taskId, seq);
867
+ if (ev.seq !== seq)
868
+ throw new LogCorruptError(taskId, seq, `file ${e.name} holds event ${ev.seq}`);
869
+ bySeq.set(seq, ev);
870
+ }
871
+ for (const s of segments) {
872
+ if (s.to <= after)
873
+ continue;
874
+ let evs = parsed.get(s.name);
875
+ if (!evs) {
876
+ const content = this.readManaged(join(this.segmentsDir(taskId), s.name), `segment ${s.name}`, MAX_SEGMENT_BYTES, taskId);
877
+ if (content === null)
878
+ continue;
879
+ const lines = content.split('\n').filter((l) => l.length > 0);
880
+ if (lines.length !== s.to - s.from + 1)
881
+ throw new LogCorruptError(taskId, s.from, `segment ${s.name} holds ${lines.length} events, its name says ${s.to - s.from + 1}`);
882
+ evs = lines.map((line, i) => {
883
+ const ev = parseEvent(line, taskId, s.from + i);
884
+ if (ev.seq !== s.from + i)
885
+ throw new LogCorruptError(taskId, ev.seq, `segment ${s.name} is not contiguous`);
886
+ return ev;
887
+ });
888
+ parsed.set(s.name, evs);
889
+ }
890
+ for (const ev of evs) {
891
+ const dup = bySeq.get(ev.seq);
892
+ if (dup && dup.hash !== ev.hash)
893
+ throw new LogCorruptError(taskId, ev.seq, 'overlapping segments disagree');
894
+ if (ev.seq > after)
895
+ bySeq.set(ev.seq, ev); // a segment may straddle `after`; hand back only the suffix asked for
896
+ }
897
+ }
898
+ const events = [...bySeq.values()].sort((a, b) => a.seq - b.seq);
899
+ let contiguous = events.length === 0 || events[0].seq === after + 1;
900
+ for (let i = 1; contiguous && i < events.length; i++)
901
+ contiguous = events[i].seq === events[i - 1].seq + 1;
902
+ if (!contiguous || vanished) {
903
+ // Either a writer is mid-way (a later file listed before an earlier one became visible) or a
904
+ // compaction moved files into a segment after we listed. A fresh pass resolves both.
905
+ if (attempt < READ_ATTEMPTS - 1) {
906
+ sleepMs(2 * (attempt + 1));
907
+ continue;
908
+ }
909
+ const firstBad = events.find((ev, i) => (i === 0 ? ev.seq !== after + 1 : ev.seq !== events[i - 1].seq + 1));
910
+ throw new LogCorruptError(taskId, firstBad?.seq ?? null, vanished ? 'event files kept vanishing while being read' : 'events are missing from the log');
911
+ }
912
+ return { exists: true, tombstone: null, ...(st.kind === 'live' ? {} : { prune: st }), events, compactedThrough: segEnd, diagnostics };
913
+ }
914
+ throw new StoreIoError(`could not obtain a stable view of task "${taskId}".`);
915
+ }
916
+ fileExists(abs) {
917
+ try {
918
+ fs.lstatSync(abs);
919
+ return true;
920
+ }
921
+ catch (err) {
922
+ if (errno(err) === 'ENOENT')
923
+ return false;
924
+ throw new StoreIoError(`cannot inspect "${abs}": ${err.message}`, err);
925
+ }
926
+ }
927
+ // ----- slots, segments, canonical content (Design A sections 35 and 40) ---------------------------------------
928
+ slotName(seq) {
929
+ return `${pad(seq)}.json`;
930
+ }
931
+ /** The loose file of a slot as an observation (trimmed line), never an exception. */
932
+ readSlot(taskId, seq) {
933
+ const r = this.readFileObs(join(this.eventsDir(taskId), this.slotName(seq)), `event ${this.slotName(seq)}`, MAX_EVENT_BYTES + 4096);
934
+ return r.k === 'ok' ? { k: 'ok', v: r.v.trim() } : r;
935
+ }
936
+ listSegments(taskId) {
937
+ const out = [];
938
+ for (const e of this.listDir(this.segmentsDir(taskId)) ?? []) {
939
+ const m = SEGMENT_NAME.exec(e.name);
940
+ if (m && e.isFile())
941
+ out.push({ name: e.name, from: Number(m[1]), to: Number(m[2]) });
942
+ }
943
+ return out.sort((a, b) => a.from - b.from || a.to - b.to);
944
+ }
945
+ /** A segment as its lines, validated the way `readLog` validates it. */
946
+ readSegmentLines(taskId, seg) {
947
+ const r = this.readFileObs(join(this.segmentsDir(taskId), seg.name), `segment ${seg.name}`, MAX_SEGMENT_BYTES);
948
+ if (r.k !== 'ok')
949
+ return r;
950
+ const lines = r.v.split('\n').filter((l) => l.length > 0);
951
+ if (lines.length !== seg.to - seg.from + 1)
952
+ return { k: 'bad', reason: 'line-count' };
953
+ try {
954
+ lines.forEach((line, i) => {
955
+ if (parseEvent(line, taskId, seg.from + i).seq !== seg.from + i)
956
+ throw new Error('not contiguous');
957
+ });
958
+ }
959
+ catch {
960
+ return { k: 'bad', reason: 'content' };
961
+ }
962
+ return { k: 'ok', v: lines };
963
+ }
964
+ /**
965
+ * `CanonRead` for the slots `from..to` (section 40.2). Every loose file is read BEFORE the single listing of
966
+ * `segments/` (the order is what makes a slot that was compacted in between visible, section 35.3), and a slot
967
+ * covered by a segment takes the segment's row: a loose file next to it is a copy or an orphan and is not consulted
968
+ * (CEI-2), so an I/O error on it does not matter. Writes nothing.
969
+ */
970
+ canonRead(taskId, from, to) {
971
+ const loose = new Map();
972
+ for (let s = from; s <= to; s++)
973
+ loose.set(s, this.readSlot(taskId, s));
974
+ const segs = this.listSegments(taskId);
975
+ const cache = new Map();
976
+ const out = new Map();
977
+ for (let s = from; s <= to; s++) {
978
+ const cov = segs.filter((g) => g.from <= s && s <= g.to);
979
+ if (cov.length > 0) {
980
+ let row = null;
981
+ let verdict = null;
982
+ for (const g of cov) {
983
+ let lines = cache.get(g.name);
984
+ if (!lines) {
985
+ lines = this.readSegmentLines(taskId, g);
986
+ cache.set(g.name, lines);
987
+ }
988
+ if (lines.k === 'ioerr')
989
+ verdict = { t: 'IOERR' };
990
+ else if (lines.k === 'bad')
991
+ verdict = { t: 'BAD' };
992
+ else if (lines.k === 'absent')
993
+ verdict = { t: 'GONE' };
994
+ else {
995
+ const mine = lines.v[s - g.from];
996
+ if (row !== null && row !== mine)
997
+ verdict = { t: 'BAD' }; // overlapping segments disagree
998
+ row = mine;
999
+ }
1000
+ if (verdict)
1001
+ break;
1002
+ }
1003
+ out.set(s, verdict ?? { t: 'SEGMENT', line: row });
1004
+ continue;
1005
+ }
1006
+ const c = loose.get(s);
1007
+ out.set(s, c.k === 'ok' ? { t: 'LOOSE', line: c.v } : c.k === 'absent' ? { t: 'EMPTY' } : c.k === 'ioerr' ? { t: 'IOERR' } : { t: 'BAD' });
1008
+ }
1009
+ return out;
1010
+ }
1011
+ /**
1012
+ * Canonical Event Identity (section 35.2), run right after the `link` of a loose file (or its `EEXIST`): the slot was
1013
+ * read first (`slot`), the listing of `segments/` comes strictly after. Confirmation needs the slot to equal the event
1014
+ * of the caller, canonically: the row of a covering segment, or — if nothing covers it — the loose file.
1015
+ */
1016
+ cei(taskId, seq, line, mode, slot) {
1017
+ let cov;
1018
+ try {
1019
+ cov = this.listSegments(taskId).filter((g) => g.from <= seq && seq <= g.to);
1020
+ }
1021
+ catch {
1022
+ return { t: 'UNKNOWN' };
1023
+ }
1024
+ if (cov.length === 0)
1025
+ return slot === line ? { t: 'EVIDENCE', via: 'loose' } : { t: 'CONFLICT' };
1026
+ let row = null;
1027
+ for (const g of cov) {
1028
+ const lines = this.readSegmentLines(taskId, g);
1029
+ if (lines.k === 'ioerr')
1030
+ return { t: 'UNKNOWN' };
1031
+ if (lines.k === 'bad')
1032
+ throw new LogCorruptError(taskId, seq, `segment ${g.name} is not readable as a log segment; refusing to judge event ${seq}.`);
1033
+ if (lines.k === 'absent')
1034
+ return { t: 'LOST' };
1035
+ const mine = lines.v[seq - g.from];
1036
+ if (row !== null && row !== mine)
1037
+ throw new LogCorruptError(taskId, seq, 'overlapping segments disagree');
1038
+ row = mine;
1039
+ }
1040
+ // The caller's own copy in a covered slot is a redundant file (class B): readers ignore it. Only the publisher of a
1041
+ // file may remove it, and only if it is byte-identical to what the publisher wrote.
1042
+ if (mode === 'own')
1043
+ this.removeOwnLooseFile(taskId, this.slotName(seq), line);
1044
+ return row === line ? { t: 'EVIDENCE', via: 'segment' } : { t: 'CONFLICT' };
1045
+ }
1046
+ /** AV-4: the error for a write that is not valid, chosen from the register only AFTER the refusal (section 31.1). */
1047
+ refuseWrite(taskId, otherwise) {
1048
+ const st = this.derive(taskId);
1049
+ if (st.kind === 'committed' || st.kind === 'corrupt' || st.kind === 'legacy')
1050
+ return this.refusal(taskId, st);
1051
+ return otherwise;
1052
+ }
1053
+ async append(event) {
1054
+ this.gate('write');
1055
+ const taskId = event.taskId;
1056
+ const seq = event.seq;
1057
+ const taskDir = this.taskDir(taskId);
1058
+ const line = serializeEvent(event);
1059
+ const name = this.slotName(seq);
1060
+ // AV (section 31.1): validity of the append. Mandatory, independent of G1, reads only the log.
1061
+ if (seq > 1) {
1062
+ if (!this.existingDir(taskDir))
1063
+ throw new PruneDecisionCorruptError(taskId, 'task-dir-missing');
1064
+ let hasPredecessor = this.fileExists(join(this.eventsDir(taskId), this.slotName(seq - 1)));
1065
+ if (!hasPredecessor)
1066
+ hasPredecessor = this.listSegments(taskId).some((g) => g.from <= seq - 1 && seq - 1 <= g.to);
1067
+ if (!hasPredecessor)
1068
+ throw this.refuseWrite(taskId, new LogCorruptError(taskId, seq, 'predecessor event is missing; refusing to publish past a hole'));
1069
+ }
1070
+ this.earlyCheck(taskId);
1071
+ const parent = this.wrapSafe(() => openParent(this.boundaryFor(true), this.eventsDir(taskId), { create: true, ops: this.seams.ops }));
1072
+ await this.seams.beforePublish?.(event);
1073
+ let mode = 'own';
1074
+ let slot = line;
1075
+ for (let attempt = 0;; attempt++) {
1076
+ try {
1077
+ const out = createFile(parent, name, `${line}\n`, {
1078
+ mode: 0o600,
1079
+ durability: this.opts.durability ?? 'full',
1080
+ maxBytes: MAX_EVENT_BYTES + 4096,
1081
+ ops: this.seams.ops,
1082
+ });
1083
+ for (const w of out.warnings)
1084
+ this.opts.onWarning?.(w);
1085
+ mode = 'own';
1086
+ slot = line;
1087
+ break;
1088
+ }
1089
+ catch (err) {
1090
+ if (err instanceof SafeWriteError && err.code === 'WRITE_TARGET_EXISTS') {
1091
+ // Not a verdict: the slot is occupied, by whom and with what is decided by CEI, never by "the bytes are equal".
1092
+ const r = this.readSlot(taskId, seq);
1093
+ if (r.k === 'ok') {
1094
+ mode = 'adopt';
1095
+ slot = r.v;
1096
+ break;
1097
+ }
1098
+ if (r.k === 'ioerr')
1099
+ throw new AppendOutcomeUnknownError(taskId, seq, new Error(r.reason));
1100
+ if (r.k === 'bad')
1101
+ throw new LogCorruptError(taskId, seq, `the file in slot ${seq} cannot be judged (${r.reason}); refusing to confirm anything.`);
1102
+ // The file was taken away between the EEXIST and the read (compaction cleanup): publish again.
1103
+ if (attempt + 1 >= MAX_WRITE_RESOLVE)
1104
+ throw new StoreIoError(`slot ${seq} of task "${taskId}" kept changing; nothing was published or confirmed.`);
1105
+ continue;
1106
+ }
1107
+ if (err instanceof SafeWriteError && err.publication === 'UNKNOWN')
1108
+ throw new AppendOutcomeUnknownError(taskId, seq, err);
1109
+ // The directory we were about to write into is gone or replaced: if the task was pruned meanwhile, say so.
1110
+ if (err instanceof SafeWriteError && (err.code === 'WRITE_PARENT_CHANGED' || err.code === 'WRITE_PARENT_MISSING')) {
1111
+ if (this.derive(taskId).kind === 'committed')
1112
+ throw new TaskPrunedError(taskId);
1113
+ }
1114
+ throw this.mapWriteError(err);
1115
+ }
1116
+ }
1117
+ if (seq === 1 && mode === 'own' && (this.opts.durability ?? 'full') === 'full')
1118
+ this.fsyncChain(join(this.eventsDir(taskId)));
1119
+ const ev = this.cei(taskId, seq, line, mode, slot);
1120
+ if (ev.t === 'CONFLICT')
1121
+ return 'conflict';
1122
+ if (ev.t === 'UNKNOWN')
1123
+ throw new AppendOutcomeUnknownError(taskId, seq, new Error('the slot could not be examined'));
1124
+ const g2 = await this.settle(taskId, 'append');
1125
+ if (g2.t === 'PRUNED')
1126
+ throw new TaskPrunedError(taskId);
1127
+ if (g2.t === 'CORRUPT')
1128
+ throw this.published(this.refusal(taskId, g2.state));
1129
+ if (ev.t === 'LOST' || g2.t === 'UNKNOWN')
1130
+ throw new AppendOutcomeUnknownError(taskId, seq, new Error(g2.t === 'UNKNOWN' ? g2.why : 'the covering segment is gone'));
1131
+ // CEI-6: 'appended' only for own (this call's link created the slot) AND loose evidence AND G2 LIVE; every other
1132
+ // canonical match is 'adopted' and gives no right derived from winning the slot.
1133
+ return mode === 'own' && ev.via === 'loose' ? 'appended' : 'adopted';
1134
+ }
1135
+ /** The file is already visible: say so on the corruption error. */
1136
+ published(err) {
1137
+ if (err instanceof PruneDecisionCorruptError)
1138
+ return new PruneDecisionCorruptError(err.taskId, err.reason, err.file, err.attempt, true);
1139
+ return err;
1140
+ }
1141
+ /** Removes `name` only if its bytes are exactly `line` (a copy the caller published itself, class B). */
1142
+ removeOwnLooseFile(taskId, name, line) {
1143
+ const abs = join(this.eventsDir(taskId), name);
1144
+ try {
1145
+ const content = this.readManaged(abs, `event ${name}`, MAX_EVENT_BYTES + 4096, taskId);
1146
+ if (content !== null && content.trim() === line)
1147
+ fs.unlinkSync(abs);
1148
+ }
1149
+ catch {
1150
+ /* a leftover loose copy of a compacted event is ignored by readers (diagnostic) */
1151
+ }
1152
+ }
1153
+ async compact(taskId, throughSeq, events) {
1154
+ this.gate('write');
1155
+ const taskDir = this.taskDir(taskId);
1156
+ // K0 (section 40.3)
1157
+ if (this.fileExists(join(taskDir, TOMBSTONE_NAME)))
1158
+ throw new TaskPrunedError(taskId);
1159
+ const early = this.earlyCheck(taskId);
1160
+ if (events.length === 0)
1161
+ return { segment: null, packedEvents: 0, removedFiles: 0 };
1162
+ const first = events[0];
1163
+ const last = events[events.length - 1];
1164
+ // Position i must hold slot first.seq + i: matching ends and length do not exclude a duplicate or a swapped pair, and
1165
+ // the segment written from such an input would not be contiguous (AC-CC-1: each line is the Canon of ITS slot).
1166
+ if (last.seq !== throughSeq || last.seq - first.seq + 1 !== events.length || events.some((e, i) => e.seq !== first.seq + i)) {
1167
+ throw new LogCorruptError(taskId, first.seq, 'compaction input is not a contiguous range ending at throughSeq');
1168
+ }
1169
+ const lines = events.map((e) => serializeEvent(e));
1170
+ const data = `${lines.join('\n')}\n`;
1171
+ if (Buffer.byteLength(data, 'utf8') > MAX_SEGMENT_BYTES)
1172
+ throw new StoreIoError('segment would exceed the size ceiling; pack fewer events.');
1173
+ // K1: the task directory must be there; compaction never creates a task.
1174
+ if (early.kind === 'missing' || !this.existingDir(taskDir))
1175
+ throw new PruneDecisionCorruptError(taskId, 'task-dir-missing');
1176
+ // K2 (CC-1, CC-2, CC-4): before anything is created or published, every slot of the input must equal the canonical
1177
+ // content of that slot. The store enforces it; the caller is not trusted. A refusal leaves no trace.
1178
+ const canon = this.canonRead(taskId, first.seq, last.seq);
1179
+ for (let i = 0; i < events.length; i++) {
1180
+ const seq = events[i].seq;
1181
+ const c = canon.get(seq);
1182
+ if (c.t === 'LOOSE' || c.t === 'SEGMENT') {
1183
+ if (c.line !== lines[i])
1184
+ throw this.refuseWrite(taskId, new LogCorruptError(taskId, seq, 'compaction input is not the canonical log'));
1185
+ }
1186
+ else if (c.t === 'EMPTY') {
1187
+ throw this.refuseWrite(taskId, new LogCorruptError(taskId, seq, 'compaction input slot has no canonical event'));
1188
+ }
1189
+ else if (c.t === 'IOERR') {
1190
+ throw new StoreIoError(`slot ${seq} of task "${taskId}" cannot be read now; nothing was published. Try again.`);
1191
+ }
1192
+ else if (c.t === 'GONE') {
1193
+ throw new TaskPrunedError(taskId);
1194
+ }
1195
+ else {
1196
+ throw this.refuseWrite(taskId, new LogCorruptError(taskId, seq, 'the canonical content of the slot cannot be judged'));
1197
+ }
1198
+ }
1199
+ // K3: publish
1200
+ const name = `S${pad(first.seq)}-${pad(last.seq)}.jsonl`;
1201
+ const parent = this.wrapSafe(() => openParent(this.boundaryFor(true), this.segmentsDir(taskId), { create: true, ops: this.seams.ops }));
1202
+ try {
1203
+ const out = createFile(parent, name, data, { mode: 0o600, durability: this.opts.durability ?? 'full', maxBytes: MAX_SEGMENT_BYTES, ops: this.seams.ops });
1204
+ for (const w of out.warnings)
1205
+ this.opts.onWarning?.(w);
1206
+ }
1207
+ catch (err) {
1208
+ if (!(err instanceof SafeWriteError && err.code === 'WRITE_TARGET_EXISTS'))
1209
+ throw this.mapWriteError(err);
1210
+ // A concurrent/interrupted compaction made the same segment: fine iff it is byte-identical (checked below).
1211
+ }
1212
+ this.fsyncChain(this.segmentsDir(taskId));
1213
+ // K4: read the segment back; this proves the bytes, not the canonical identity (that was K2).
1214
+ const back = this.readManaged(join(this.segmentsDir(taskId), name), `segment ${name}`, MAX_SEGMENT_BYTES, taskId);
1215
+ if (back === null || sha256Hex(back) !== sha256Hex(data)) {
1216
+ throw new LogCorruptError(taskId, first.seq, `segment ${name} does not read back as written; loose events are kept`);
1217
+ }
1218
+ // K5: the segment is published and verified: only now are the loose copies of THESE events redundant (class B,
1219
+ // CL-1..CL-4). A loose file is removed only if it is byte-identical to the event just packed.
1220
+ const lineBySeq = new Map(events.map((e, i) => [e.seq, lines[i]]));
1221
+ let removed = 0;
1222
+ for (const e of this.listDir(this.eventsDir(taskId)) ?? []) {
1223
+ const m = EVENT_NAME.exec(e.name);
1224
+ if (!m || !e.isFile())
1225
+ continue;
1226
+ const want = lineBySeq.get(Number(m[1]));
1227
+ if (want === undefined)
1228
+ continue;
1229
+ if (this.removeLooseIfIdentical(taskId, e.name, want))
1230
+ removed++;
1231
+ }
1232
+ this.fsyncDir(this.eventsDir(taskId));
1233
+ // K6: G2. It never takes the segment back.
1234
+ const g2 = await this.settle(taskId, 'compact');
1235
+ if (g2.t === 'PRUNED')
1236
+ throw new TaskPrunedError(taskId);
1237
+ if (g2.t === 'CORRUPT')
1238
+ throw this.published(this.refusal(taskId, g2.state));
1239
+ if (g2.t === 'UNKNOWN')
1240
+ throw new StoreIoError(`segment ${name} of task "${taskId}" was published, but the prune state could not be settled (${g2.why}); the segment stays. Try again.`);
1241
+ return { segment: name, packedEvents: events.length, removedFiles: removed };
1242
+ }
1243
+ /** Removes the loose event file `name` iff its content is exactly `line`. `true` if it was removed. */
1244
+ removeLooseIfIdentical(taskId, name, line) {
1245
+ const abs = join(this.eventsDir(taskId), name);
1246
+ const content = this.readManaged(abs, `event ${name}`, MAX_EVENT_BYTES + 4096, taskId);
1247
+ if (content === null || content.trim() !== line)
1248
+ return false;
1249
+ try {
1250
+ fs.unlinkSync(abs);
1251
+ return true;
1252
+ }
1253
+ catch (err) {
1254
+ if (errno(err) === 'ENOENT')
1255
+ return false;
1256
+ throw new StoreIoError(`cannot remove ${name}: ${err.message}`, err);
1257
+ }
1258
+ }
1259
+ async sweepCompacted(taskId) {
1260
+ this.gate('write');
1261
+ const st = this.derive(taskId);
1262
+ if (st.kind === 'committed')
1263
+ throw new TaskPrunedError(taskId);
1264
+ if (st.kind === 'corrupt' || st.kind === 'legacy' || st.kind === 'unreadable')
1265
+ throw this.refusal(taskId, st);
1266
+ const log = this.readLogImpl(taskId, { rawEvents: true }); // verified view; events up to `compactedThrough` come from the segments
1267
+ if (log.compactedThrough === 0)
1268
+ return { removedFiles: 0, keptFiles: 0 };
1269
+ // Only a loose file the reader itself found covered by a segment is redundant (F-G-1: a slot outside every segment is
1270
+ // canonical, and for it `log.events` holds the loose file, which would be compared with itself).
1271
+ const covered = new Set(log.diagnostics.filter((d) => d.code === 'LOOSE_COPY_OF_COMPACTED_EVENT').map((d) => d.name));
1272
+ const lineBySeq = new Map(log.events.filter((e) => e.seq <= log.compactedThrough).map((e) => [e.seq, serializeEvent(e)]));
1273
+ let removedFiles = 0;
1274
+ let keptFiles = 0;
1275
+ for (const e of this.listDir(this.eventsDir(taskId)) ?? []) {
1276
+ const m = EVENT_NAME.exec(e.name);
1277
+ if (!m || !e.isFile())
1278
+ continue;
1279
+ const want = lineBySeq.get(Number(m[1]));
1280
+ if (want === undefined || !covered.has(e.name))
1281
+ continue; // not covered by a segment: a live event, not ours to touch
1282
+ if (this.removeLooseIfIdentical(taskId, e.name, want))
1283
+ removedFiles++;
1284
+ else if (this.fileExists(join(this.eventsDir(taskId), e.name)))
1285
+ keptFiles++; // differs: possibly a paused writer's file or foreign data
1286
+ }
1287
+ this.fsyncDir(this.eventsDir(taskId));
1288
+ return { removedFiles, keptFiles };
1289
+ }
1290
+ async pinReference(parentId, childId, at) {
1291
+ this.gate('write');
1292
+ if (!ID_PATTERN.test(childId))
1293
+ throw new StoreSecurityError(`invalid task id "${childId}".`, 'BAD_ID');
1294
+ const taskDir = this.taskDir(parentId);
1295
+ if (!this.existingDir(taskDir))
1296
+ return 'parent-missing';
1297
+ const before = this.derive(parentId);
1298
+ if (before.kind === 'committed')
1299
+ return 'parent-pruned';
1300
+ if (before.kind === 'corrupt' || before.kind === 'legacy' || before.kind === 'unreadable')
1301
+ throw this.refusal(parentId, before);
1302
+ if (before.kind === 'missing')
1303
+ return 'parent-missing';
1304
+ const name = `${childId}.json`;
1305
+ try {
1306
+ const parent = this.wrapSafe(() => openParent(this.boundaryFor(true), this.refsDir(parentId), { create: true, ops: this.seams.ops }));
1307
+ const out = createFile(parent, name, `${JSON.stringify({ v: 1, childId, at })}\n`, {
1308
+ mode: 0o600, durability: this.opts.durability ?? 'full', maxBytes: 1024, ops: this.seams.ops,
1309
+ });
1310
+ for (const w of out.warnings)
1311
+ this.opts.onWarning?.(w);
1312
+ }
1313
+ catch (err) {
1314
+ if (err instanceof SafeWriteError && err.code === 'WRITE_TARGET_EXISTS') {
1315
+ /* pinned before (a retry of the same creation): the pin is shared, not owned */
1316
+ }
1317
+ else if (err instanceof SafeWriteError && (err.code === 'WRITE_PARENT_CHANGED' || err.code === 'WRITE_PARENT_MISSING') && this.derive(parentId).kind === 'committed') {
1318
+ return 'parent-pruned';
1319
+ }
1320
+ else {
1321
+ throw this.mapWriteError(err);
1322
+ }
1323
+ }
1324
+ this.fsyncChain(this.refsDir(parentId));
1325
+ // The pin is visible. G2 asks the register only AFTER publishing: a prune that opened its attempt before this point is
1326
+ // closed here with an `abort` (or has committed, and the pin is left for the roll-forward); one that opens it later
1327
+ // will find the pin. The pin is never taken back (section 24.1).
1328
+ const g2 = await this.settle(parentId, 'pin');
1329
+ if (g2.t === 'PRUNED')
1330
+ return 'parent-pruned';
1331
+ if (g2.t === 'CORRUPT')
1332
+ throw this.refusal(parentId, g2.state);
1333
+ if (g2.t === 'UNKNOWN')
1334
+ throw new StoreIoError(`the reference to task "${parentId}" was recorded, but its prune state could not be settled (${g2.why}); the pin stays. Try again.`);
1335
+ return 'pinned';
1336
+ }
1337
+ async listReferences(parentId) {
1338
+ this.gate('read');
1339
+ const out = [];
1340
+ for (const e of this.listDir(this.refsDir(parentId)) ?? []) {
1341
+ const m = /^([A-Za-z0-9][A-Za-z0-9_-]{0,63})\.json$/.exec(e.name);
1342
+ if (!m || !e.isFile())
1343
+ continue;
1344
+ const content = this.readManaged(join(this.refsDir(parentId), e.name), `reference ${e.name}`, 1024 + 4096, parentId);
1345
+ if (content === null)
1346
+ continue;
1347
+ let at = new Date(0).toISOString(); // unreadable: counts as old, so only an existing active child still blocks
1348
+ try {
1349
+ const o = JSON.parse(content);
1350
+ if (typeof o.at === 'string' && Number.isFinite(Date.parse(o.at)))
1351
+ at = o.at;
1352
+ }
1353
+ catch {
1354
+ /* keep the default */
1355
+ }
1356
+ out.push({ childId: m[1], at });
1357
+ }
1358
+ return out.sort((a, b) => (a.childId < b.childId ? -1 : 1));
1359
+ }
1360
+ /** fsync `dir` and every ancestor down to the store root: directory entries created by mkdir are not durable until their parent is synced. */
1361
+ fsyncChain(dir) {
1362
+ let cur = dir;
1363
+ for (let i = 0; i < 6; i++) {
1364
+ this.fsyncDir(cur);
1365
+ if (cur === this.root)
1366
+ return;
1367
+ const up = dirname(cur);
1368
+ if (up === cur)
1369
+ return;
1370
+ cur = up;
1371
+ }
1372
+ }
1373
+ /**
1374
+ * Mode A (the only mode claimed): a failed directory fsync is a warning of the write primitive, never a reason to refuse or
1375
+ * to continue differently — durability verification (`DV`) is Mode B and is not called here (Design A section 18.2).
1376
+ */
1377
+ fsyncDir(dir) {
1378
+ if ((this.opts.durability ?? 'full') !== 'full')
1379
+ return;
1380
+ let fd = null;
1381
+ try {
1382
+ fd = fs.openSync(dir, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY);
1383
+ fs.fsyncSync(fd);
1384
+ }
1385
+ catch {
1386
+ /* best effort: the segment itself was fsynced by the write primitive */
1387
+ }
1388
+ finally {
1389
+ if (fd !== null)
1390
+ fs.closeSync(fd);
1391
+ }
1392
+ }
1393
+ }