taskflow-mcp-core 0.3.0-beta.1.2 → 1.0.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 (185) hide show
  1. package/CONTROL_GUIDE.md +113 -0
  2. package/README.md +34 -25
  3. package/dist/control/admission-service.d.ts +15 -0
  4. package/dist/control/admission-service.d.ts.map +1 -0
  5. package/dist/control/admission-service.js +64 -0
  6. package/dist/control/admission-service.js.map +1 -0
  7. package/dist/control/approval-edits.d.ts +12 -0
  8. package/dist/control/approval-edits.d.ts.map +1 -0
  9. package/dist/control/approval-edits.js +66 -0
  10. package/dist/control/approval-edits.js.map +1 -0
  11. package/dist/control/approval-service.d.ts +173 -0
  12. package/dist/control/approval-service.d.ts.map +1 -0
  13. package/dist/control/approval-service.js +342 -0
  14. package/dist/control/approval-service.js.map +1 -0
  15. package/dist/control/authorization.d.ts +94 -0
  16. package/dist/control/authorization.d.ts.map +1 -0
  17. package/dist/control/authorization.js +302 -0
  18. package/dist/control/authorization.js.map +1 -0
  19. package/dist/control/control-cli.d.ts +3 -0
  20. package/dist/control/control-cli.d.ts.map +1 -0
  21. package/dist/control/control-cli.js +279 -0
  22. package/dist/control/control-cli.js.map +1 -0
  23. package/dist/control/control-console.d.ts +17 -0
  24. package/dist/control/control-console.d.ts.map +1 -0
  25. package/dist/control/control-console.js +67 -0
  26. package/dist/control/control-console.js.map +1 -0
  27. package/dist/control/control-host.d.ts +98 -0
  28. package/dist/control/control-host.d.ts.map +1 -0
  29. package/dist/control/control-host.js +459 -0
  30. package/dist/control/control-host.js.map +1 -0
  31. package/dist/control/control-mcp.d.ts +11 -0
  32. package/dist/control/control-mcp.d.ts.map +1 -0
  33. package/dist/control/control-mcp.js +78 -0
  34. package/dist/control/control-mcp.js.map +1 -0
  35. package/dist/control/errors.d.ts +44 -0
  36. package/dist/control/errors.d.ts.map +1 -0
  37. package/dist/control/errors.js +93 -0
  38. package/dist/control/errors.js.map +1 -0
  39. package/dist/control/hello.d.ts +39 -0
  40. package/dist/control/hello.d.ts.map +1 -0
  41. package/dist/control/hello.js +80 -0
  42. package/dist/control/hello.js.map +1 -0
  43. package/dist/control/host-runtime.d.ts +32 -0
  44. package/dist/control/host-runtime.d.ts.map +1 -0
  45. package/dist/control/host-runtime.js +542 -0
  46. package/dist/control/host-runtime.js.map +1 -0
  47. package/dist/control/index.d.ts +32 -0
  48. package/dist/control/index.d.ts.map +1 -0
  49. package/dist/control/index.js +32 -0
  50. package/dist/control/index.js.map +1 -0
  51. package/dist/control/legacy-conflict.d.ts +19 -0
  52. package/dist/control/legacy-conflict.d.ts.map +1 -0
  53. package/dist/control/legacy-conflict.js +176 -0
  54. package/dist/control/legacy-conflict.js.map +1 -0
  55. package/dist/control/local-bootstrap.d.ts +21 -0
  56. package/dist/control/local-bootstrap.d.ts.map +1 -0
  57. package/dist/control/local-bootstrap.js +224 -0
  58. package/dist/control/local-bootstrap.js.map +1 -0
  59. package/dist/control/modes.d.ts +60 -0
  60. package/dist/control/modes.d.ts.map +1 -0
  61. package/dist/control/modes.js +86 -0
  62. package/dist/control/modes.js.map +1 -0
  63. package/dist/control/project-admin-cli.d.ts +3 -0
  64. package/dist/control/project-admin-cli.d.ts.map +1 -0
  65. package/dist/control/project-admin-cli.js +155 -0
  66. package/dist/control/project-admin-cli.js.map +1 -0
  67. package/dist/control/project-registry.d.ts +20 -0
  68. package/dist/control/project-registry.d.ts.map +1 -0
  69. package/dist/control/project-registry.js +181 -0
  70. package/dist/control/project-registry.js.map +1 -0
  71. package/dist/control/runtime-checkpoint.d.ts +11 -0
  72. package/dist/control/runtime-checkpoint.d.ts.map +1 -0
  73. package/dist/control/runtime-checkpoint.js +39 -0
  74. package/dist/control/runtime-checkpoint.js.map +1 -0
  75. package/dist/control/runtime-provider.d.ts +100 -0
  76. package/dist/control/runtime-provider.d.ts.map +1 -0
  77. package/dist/control/runtime-provider.js +258 -0
  78. package/dist/control/runtime-provider.js.map +1 -0
  79. package/dist/control/schema/approval.d.ts +108 -0
  80. package/dist/control/schema/approval.d.ts.map +1 -0
  81. package/dist/control/schema/approval.js +65 -0
  82. package/dist/control/schema/approval.js.map +1 -0
  83. package/dist/control/schema/commands.d.ts +663 -0
  84. package/dist/control/schema/commands.d.ts.map +1 -0
  85. package/dist/control/schema/commands.js +147 -0
  86. package/dist/control/schema/commands.js.map +1 -0
  87. package/dist/control/schema/common.d.ts +24 -0
  88. package/dist/control/schema/common.d.ts.map +1 -0
  89. package/dist/control/schema/common.js +38 -0
  90. package/dist/control/schema/common.js.map +1 -0
  91. package/dist/control/schema/coordinator.d.ts +98 -0
  92. package/dist/control/schema/coordinator.d.ts.map +1 -0
  93. package/dist/control/schema/coordinator.js +87 -0
  94. package/dist/control/schema/coordinator.js.map +1 -0
  95. package/dist/control/schema/evidence.d.ts +140 -0
  96. package/dist/control/schema/evidence.d.ts.map +1 -0
  97. package/dist/control/schema/evidence.js +64 -0
  98. package/dist/control/schema/evidence.js.map +1 -0
  99. package/dist/control/schema/header.d.ts +79 -0
  100. package/dist/control/schema/header.d.ts.map +1 -0
  101. package/dist/control/schema/header.js +66 -0
  102. package/dist/control/schema/header.js.map +1 -0
  103. package/dist/control/schema/index.d.ts +31 -0
  104. package/dist/control/schema/index.d.ts.map +1 -0
  105. package/dist/control/schema/index.js +31 -0
  106. package/dist/control/schema/index.js.map +1 -0
  107. package/dist/control/schema/plan.d.ts +291 -0
  108. package/dist/control/schema/plan.d.ts.map +1 -0
  109. package/dist/control/schema/plan.js +80 -0
  110. package/dist/control/schema/plan.js.map +1 -0
  111. package/dist/control/schema/policy.d.ts +96 -0
  112. package/dist/control/schema/policy.d.ts.map +1 -0
  113. package/dist/control/schema/policy.js +73 -0
  114. package/dist/control/schema/policy.js.map +1 -0
  115. package/dist/control/schema/run.d.ts +61 -0
  116. package/dist/control/schema/run.d.ts.map +1 -0
  117. package/dist/control/schema/run.js +56 -0
  118. package/dist/control/schema/run.js.map +1 -0
  119. package/dist/control/schema/te-mirrors.d.ts +145 -0
  120. package/dist/control/schema/te-mirrors.d.ts.map +1 -0
  121. package/dist/control/schema/te-mirrors.js +145 -0
  122. package/dist/control/schema/te-mirrors.js.map +1 -0
  123. package/dist/control/schema/transport.d.ts +479 -0
  124. package/dist/control/schema/transport.d.ts.map +1 -0
  125. package/dist/control/schema/transport.js +181 -0
  126. package/dist/control/schema/transport.js.map +1 -0
  127. package/dist/control/singleton.d.ts +135 -0
  128. package/dist/control/singleton.d.ts.map +1 -0
  129. package/dist/control/singleton.js +607 -0
  130. package/dist/control/singleton.js.map +1 -0
  131. package/dist/control/store/coordinator-store.d.ts +143 -0
  132. package/dist/control/store/coordinator-store.d.ts.map +1 -0
  133. package/dist/control/store/coordinator-store.js +620 -0
  134. package/dist/control/store/coordinator-store.js.map +1 -0
  135. package/dist/control/store/evidence-adapter.d.ts +10 -0
  136. package/dist/control/store/evidence-adapter.d.ts.map +1 -0
  137. package/dist/control/store/evidence-adapter.js +114 -0
  138. package/dist/control/store/evidence-adapter.js.map +1 -0
  139. package/dist/control/store/evidence-explanation.d.ts +55 -0
  140. package/dist/control/store/evidence-explanation.d.ts.map +1 -0
  141. package/dist/control/store/evidence-explanation.js +50 -0
  142. package/dist/control/store/evidence-explanation.js.map +1 -0
  143. package/dist/control/store/evidence-store.d.ts +143 -0
  144. package/dist/control/store/evidence-store.d.ts.map +1 -0
  145. package/dist/control/store/evidence-store.js +437 -0
  146. package/dist/control/store/evidence-store.js.map +1 -0
  147. package/dist/control/store/index.d.ts +3 -0
  148. package/dist/control/store/index.d.ts.map +1 -0
  149. package/dist/control/store/index.js +2 -0
  150. package/dist/control/store/index.js.map +1 -0
  151. package/dist/control/store/journal.d.ts +270 -0
  152. package/dist/control/store/journal.d.ts.map +1 -0
  153. package/dist/control/store/journal.js +50 -0
  154. package/dist/control/store/journal.js.map +1 -0
  155. package/dist/control/store/project-lifecycle.d.ts +49 -0
  156. package/dist/control/store/project-lifecycle.d.ts.map +1 -0
  157. package/dist/control/store/project-lifecycle.js +288 -0
  158. package/dist/control/store/project-lifecycle.js.map +1 -0
  159. package/dist/control/store/store.d.ts +93 -0
  160. package/dist/control/store/store.d.ts.map +1 -0
  161. package/dist/control/store/store.js +873 -0
  162. package/dist/control/store/store.js.map +1 -0
  163. package/dist/control/te-provider.d.ts +86 -0
  164. package/dist/control/te-provider.d.ts.map +1 -0
  165. package/dist/control/te-provider.js +131 -0
  166. package/dist/control/te-provider.js.map +1 -0
  167. package/dist/control/uds.d.ts +114 -0
  168. package/dist/control/uds.d.ts.map +1 -0
  169. package/dist/control/uds.js +456 -0
  170. package/dist/control/uds.js.map +1 -0
  171. package/dist/control/web-console/view.d.ts +5 -0
  172. package/dist/control/web-console/view.d.ts.map +1 -0
  173. package/dist/control/web-console/view.js +122 -0
  174. package/dist/control/web-console/view.js.map +1 -0
  175. package/dist/control/web-console.d.ts +50 -0
  176. package/dist/control/web-console.d.ts.map +1 -0
  177. package/dist/control/web-console.js +317 -0
  178. package/dist/control/web-console.js.map +1 -0
  179. package/dist/mcp/jsonrpc.d.ts.map +1 -1
  180. package/dist/mcp/jsonrpc.js +12 -3
  181. package/dist/mcp/jsonrpc.js.map +1 -1
  182. package/dist/mcp/server.d.ts.map +1 -1
  183. package/dist/mcp/server.js +26 -17
  184. package/dist/mcp/server.js.map +1 -1
  185. package/package.json +13 -5
@@ -0,0 +1,873 @@
1
+ /**
2
+ * Authoritative per-project append journal. A lifetime OS writer lock excludes
3
+ * competing hosts; an async fence serializes evidence work with lifecycle CAS.
4
+ * V1 submit records stay readable. Chained V2 batches atomically bind run state,
5
+ * approvals/outboxes, commands and immutable responses, admission decisions,
6
+ * receipts and evidence metadata. Corrupt or incomplete bytes are preserved.
7
+ * Storage ports are trusted in-process APIs: live authorization belongs to the
8
+ * service/host before lookup and again before result disclosure.
9
+ */
10
+ import { AsyncLocalStorage } from "node:async_hooks";
11
+ import * as crypto from "node:crypto";
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import { Value } from "typebox/value";
15
+ import { ControlError } from "../errors.js";
16
+ import { CONTROL_WIRE_SCHEMA_VERSION, CommandRecordSchema, ControlEventSchema, ControlStoreHeaderSchema, } from "../schema/index.js";
17
+ import { JOURNAL_GENESIS, journalHash, validJournalBatch, ProjectStateSchema } from "./journal.js";
18
+ export const CONTROL_CRASH_ENV = "TASKFLOW_CONTROL_CRASH_AT";
19
+ const LOCK_NAME = "writer.lock";
20
+ const HEADER_NAME = "header";
21
+ const COMMIT_SEQ_NAME = "commit-seq.json";
22
+ const JOURNAL_DIR = "journal";
23
+ const JOURNAL_SEGMENT = "000001.jsonl";
24
+ const PROJECTIONS_DIR = "projections";
25
+ const COMMAND_INDEX = "commands.json";
26
+ const COMMANDS_DIR = "commands";
27
+ const RECEIPTS_DIR = "receipts";
28
+ export class ControlStore {
29
+ storePath;
30
+ #header;
31
+ #commitSeq;
32
+ #status = "healthy";
33
+ #closed = false;
34
+ #releaseWriter;
35
+ #commandIndex = new Map();
36
+ #states = new Map();
37
+ #responses = new Map();
38
+ #admissions = new Map();
39
+ #tip = JOURNAL_GENESIS;
40
+ #writerProof;
41
+ #headerProof;
42
+ #writing = false;
43
+ #fence = new AsyncLocalStorage();
44
+ #fenceOwner;
45
+ #fenceQueue = Promise.resolve();
46
+ constructor(storePath, header, commitSeq, commands, releaseWriter, batches = []) {
47
+ this.#releaseWriter = releaseWriter;
48
+ this.storePath = storePath;
49
+ this.#writerProof = fs.readFileSync(path.join(storePath, LOCK_NAME), "utf8");
50
+ this.#headerProof = fs.readFileSync(path.join(storePath, HEADER_NAME), "utf8");
51
+ this.#header = structuredClone(header);
52
+ this.#commitSeq = commitSeq;
53
+ for (const command of commands)
54
+ this.#commandIndex.set(command.commandId, structuredClone(command));
55
+ for (const batch of batches)
56
+ this.#apply(batch);
57
+ }
58
+ get header() {
59
+ return structuredClone(this.#header);
60
+ }
61
+ get commitSeq() {
62
+ return this.#commitSeq;
63
+ }
64
+ get status() {
65
+ return this.#status;
66
+ }
67
+ readCommand(commandId) {
68
+ this.#assertOpen();
69
+ const command = this.#commandIndex.get(commandId);
70
+ return command === undefined ? undefined : structuredClone(command);
71
+ }
72
+ snapshot() {
73
+ this.#assertOpen();
74
+ return { header: this.header, commitSeq: this.#commitSeq, status: this.#status };
75
+ }
76
+ appendBatch(input) {
77
+ this.#assertOpen();
78
+ this.#assertFence();
79
+ if (this.#status === "fail-closed") {
80
+ throw durabilityFailed("control store is fail-closed; refusing mutation");
81
+ }
82
+ try {
83
+ this.readJournal();
84
+ }
85
+ catch (error) {
86
+ this.#status = "fail-closed";
87
+ throw durabilityFailed(String(error));
88
+ }
89
+ if (input.command.kind !== "run.submit") {
90
+ throw durabilityFailed(`beta.2 store only accepts run.submit; got ${input.command.kind}`);
91
+ }
92
+ if (input.events.length === 0) {
93
+ throw durabilityFailed("atomic batch requires at least one ControlEvent");
94
+ }
95
+ for (const event of input.events) {
96
+ if (!Value.Check(ControlEventSchema, event)) {
97
+ throw durabilityFailed("ControlEvent failed schema check");
98
+ }
99
+ }
100
+ if (!Value.Check(CommandRecordSchema, {
101
+ ...input.command,
102
+ projectId: this.#header.projectId,
103
+ controlDomainId: this.#header.controlDomainId,
104
+ })) {
105
+ // Validate after identity rewrite below; pre-check kind/required fields first.
106
+ }
107
+ const nextSeq = this.#commitSeq + 1;
108
+ let command = {
109
+ ...input.command,
110
+ projectId: this.#header.projectId,
111
+ controlDomainId: this.#header.controlDomainId,
112
+ firstCommitSeq: nextSeq,
113
+ lastCommitSeq: nextSeq,
114
+ };
115
+ if (!Value.Check(CommandRecordSchema, command)) {
116
+ throw durabilityFailed("CommandRecord failed schema check");
117
+ }
118
+ // Keep authoritative records independent of both caller-owned nested
119
+ // input and the copies returned by append/read/snapshot boundaries.
120
+ command = structuredClone(command);
121
+ if (this.#commandIndex.has(command.commandId)) {
122
+ // Matching audit fields cannot authorize disclosure (RFC §9.4).
123
+ // This S3 path only appends a journal batch; it never submits TE work.
124
+ throw new ControlError("TF_IDEMPOTENCY_CONFLICT", `command ${command.commandId} is already committed; authorized command-result replay is not implemented without a live authorization boundary`, { recoveryAction: "none", sideEffects: "none" });
125
+ }
126
+ const events = input.events.map((event, index) => ({
127
+ ...structuredClone(event),
128
+ projectId: this.#header.projectId,
129
+ controlDomainId: this.#header.controlDomainId,
130
+ commitSeq: nextSeq,
131
+ commandId: command.commandId,
132
+ commandEventIndex: index,
133
+ }));
134
+ const record = {
135
+ recordKind: "commit-batch",
136
+ commitSeq: nextSeq,
137
+ command,
138
+ events,
139
+ };
140
+ // Once persistence begins, a failure can leave a complete or torn journal
141
+ // record behind. Only reopening/recovery can establish its outcome; never
142
+ // reuse this instance's old sequence number after an ambiguous write.
143
+ try {
144
+ crashDuringJournalAppend(journalPath(this.storePath));
145
+ appendJsonLineDurable(journalPath(this.storePath), record);
146
+ writeJsonAtomicHardened(commitSeqPath(this.storePath), { commitSeq: nextSeq });
147
+ const nextIndex = new Map(this.#commandIndex);
148
+ nextIndex.set(command.commandId, command);
149
+ maybeCrash("projection-rebuild");
150
+ writeJsonAtomicHardened(commandIndexPath(this.storePath), Object.fromEntries(nextIndex));
151
+ this.#commandIndex = nextIndex;
152
+ this.#commitSeq = nextSeq;
153
+ this.#apply(record);
154
+ }
155
+ catch (error) {
156
+ this.#status = "fail-closed";
157
+ throw durabilityFailed(`control store persistence failed; close and reopen to recover: ${error instanceof Error ? error.message : String(error)}`);
158
+ }
159
+ return { commitSeq: nextSeq, command: structuredClone(command) };
160
+ }
161
+ /** Trusted in-process storage ports; callers must authorize disclosure. */
162
+ readRun(runId) { this.readJournal(); return structuredClone(this.#states.get(runId)?.run); }
163
+ listRuns() { this.readJournal(); return [...this.#states.values()].map(s => structuredClone(s.run)); }
164
+ readRunState(runId) { this.readJournal(); return structuredClone(this.#states.get(runId)); }
165
+ readApproval(id) { this.readJournal(); for (const s of this.#states.values()) {
166
+ const request = s.approvals.find(a => a.approvalRequestId === id);
167
+ if (request)
168
+ return structuredClone(request);
169
+ } }
170
+ listApprovals() { this.readJournal(); return [...this.#states.values()].flatMap(s => structuredClone(s.approvals)); }
171
+ readOutbox(runId) { this.readJournal(); return structuredClone(this.#states.get(runId)?.outbox ?? []); }
172
+ readCommittedCommand(binding) {
173
+ this.readJournal();
174
+ this.#assertIdentity(binding.projectId, binding.controlDomainId);
175
+ const record = this.#commandIndex.get(binding.commandId);
176
+ if (!record)
177
+ return;
178
+ if (record.callerPrincipal !== binding.callerPrincipal)
179
+ throw new ControlError("TF_CROSS_PRINCIPAL_COMMAND", "command belongs to another principal");
180
+ if (record.kind !== binding.kind || record.requestHash !== binding.requestHash)
181
+ throw new ControlError("TF_IDEMPOTENCY_CONFLICT", "command identity has different request content");
182
+ const saved = this.#responses.get(binding.commandId);
183
+ if (!saved)
184
+ throw durabilityFailed("committed command has no immutable response");
185
+ return structuredClone({ record: saved.record, responseJson: saved.responseJson });
186
+ }
187
+ createRun(run, events = [], result) {
188
+ this.#assertOpen();
189
+ if (this.#states.has(run.runId))
190
+ throw new ControlError("TF_IDEMPOTENCY_CONFLICT", "run already exists");
191
+ const state = { run: structuredClone(run), approvals: [], outbox: [] };
192
+ this.#commitLifecycle(state, events, result?.command, result?.responseJson);
193
+ return structuredClone(run);
194
+ }
195
+ mutateRun(runId, expectedRunVersion, build, binding) {
196
+ this.#assertOpen();
197
+ if (binding) {
198
+ const previous = this.readCommittedCommand(binding);
199
+ if (previous) {
200
+ const saved = this.#responses.get(binding.commandId);
201
+ return { ...structuredClone(saved.state), commitSeq: saved.record.lastCommitSeq, responseJson: saved.responseJson };
202
+ }
203
+ }
204
+ if (this.#writing)
205
+ throw durabilityFailed("nested project mutations are forbidden");
206
+ const state = this.#states.get(runId);
207
+ if (!state || state.run.runVersion !== expectedRunVersion)
208
+ throw new ControlError("TF_STALE_VERSION", "run version changed");
209
+ this.#writing = true;
210
+ try {
211
+ const next = build(structuredClone(state), { commitSeq: this.#commitSeq + 1 });
212
+ if (next.run.runId !== runId || next.run.runVersion !== expectedRunVersion + 1)
213
+ throw durabilityFailed("mutation must advance the same run exactly once");
214
+ if (binding && next.command && (next.command.commandId !== binding.commandId || next.command.requestHash !== binding.requestHash || next.command.callerPrincipal !== binding.callerPrincipal || next.command.kind !== binding.kind))
215
+ throw durabilityFailed("mutation command differs from checked identity");
216
+ this.#commitLifecycle({ run: next.run, approvals: next.approvals, outbox: next.outbox }, next.events, next.command, next.responseJson);
217
+ return { ...structuredClone({ run: next.run, approvals: next.approvals, outbox: next.outbox }), commitSeq: this.#commitSeq, ...(next.responseJson !== undefined ? { responseJson: next.responseJson } : {}) };
218
+ }
219
+ finally {
220
+ this.#writing = false;
221
+ }
222
+ }
223
+ readAdmission(binding) {
224
+ this.readJournal();
225
+ this.#assertIdentity(binding.projectId, binding.projectControlDomainId);
226
+ const prior = this.#admissions.get(binding.reservationId);
227
+ if (prior && prior.runId !== binding.runId)
228
+ throw durabilityFailed("reservation is bound to another run");
229
+ if (prior)
230
+ return structuredClone(prior);
231
+ const run = this.#states.get(binding.runId)?.run;
232
+ if (run?.reservationId === binding.reservationId && run.projectAdmitCommitSeq)
233
+ return { ...binding, status: "admitted", decisionCommitSeq: run.projectAdmitCommitSeq, runVersion: run.runVersion };
234
+ return undefined;
235
+ }
236
+ admitRun(binding, expectedRunVersion) { return this.decideAdmission(binding, "admitted", expectedRunVersion); }
237
+ abandonAdmissionIfAbsent(binding) { return this.decideAdmission(binding, "abandoned"); }
238
+ decideAdmission(binding, status, expectedRunVersion) {
239
+ const prior = this.readAdmission(binding);
240
+ if (prior)
241
+ return prior;
242
+ const old = this.#states.get(binding.runId);
243
+ // Upgrade pre-existing admission facts before allowing an abandonment decision.
244
+ if (old?.run.reservationId === binding.reservationId && old.run.projectAdmitCommitSeq)
245
+ return { ...binding, status: "admitted", decisionCommitSeq: old.run.projectAdmitCommitSeq, runVersion: old.run.runVersion };
246
+ if (status === "admitted" && (!old || old.run.status !== "running" || !["linked", "queued"].includes(old.run.stage) || old.run.requiresReadmission || old.run.runVersion !== expectedRunVersion))
247
+ throw new ControlError("TF_STALE_VERSION", "admission run version changed");
248
+ const decision = { ...binding, status, decisionCommitSeq: this.#commitSeq + 1, runVersion: status === "admitted" ? old.run.runVersion + 1 : old?.run.runVersion ?? 0 };
249
+ const projection = status === "admitted" ? { ...structuredClone(old), run: { ...old.run, status: "running", stage: "admitted", slot: "reserved", reservationId: binding.reservationId, projectAdmitCommitSeq: decision.decisionCommitSeq, runVersion: decision.runVersion } } : undefined;
250
+ this.#commitLifecycle(projection, [], undefined, undefined, decision);
251
+ return structuredClone(decision);
252
+ }
253
+ get journalTip() { this.#assertOpen(); return { commitSeq: this.#commitSeq, hash: this.#tip }; }
254
+ readJournal() {
255
+ this.#assertOpen();
256
+ const batches = readJournalBatches(journalPath(this.storePath));
257
+ let tip = JOURNAL_GENESIS, seq = 0;
258
+ for (const b of batches) {
259
+ if (b.commitSeq !== ++seq || b.events.some(e => e.projectId !== this.#header.projectId || e.controlDomainId !== this.#header.controlDomainId) || b.recordKind === "lifecycle-batch" && b.previousHash !== tip)
260
+ throw durabilityFailed("journal changed or lost identity");
261
+ tip = b.recordKind === "lifecycle-batch" ? b.contentHash : journalHash(b);
262
+ }
263
+ if (seq !== this.#commitSeq || tip !== this.#tip)
264
+ throw durabilityFailed("journal differs from held writer state");
265
+ const file = journalPath(this.storePath);
266
+ if (fs.existsSync(file)) {
267
+ const bytes = fs.readFileSync(file);
268
+ if (bytes.length && bytes.at(-1) !== 10)
269
+ throw durabilityFailed("journal has torn trailing evidence");
270
+ }
271
+ return structuredClone(batches);
272
+ }
273
+ withWriter(operation) {
274
+ if (this.#fenceOwner && this.#fence.getStore() === this.#fenceOwner)
275
+ return operation();
276
+ const task = this.#fenceQueue.then(async () => {
277
+ this.#assertOpen();
278
+ const owner = Symbol("project-writer");
279
+ this.#fenceOwner = owner;
280
+ try {
281
+ return await this.#fence.run(owner, operation);
282
+ }
283
+ finally {
284
+ this.#fenceOwner = undefined;
285
+ }
286
+ });
287
+ this.#fenceQueue = task.catch(() => { });
288
+ return task;
289
+ }
290
+ recordRunEvidence(runId, metadata) {
291
+ const records = this.readJournal();
292
+ const prior = records.find(b => b.recordKind === "lifecycle-batch" && b.evidenceMetadata?.runId === runId);
293
+ if (prior) {
294
+ if (JSON.stringify(prior.evidenceMetadata) !== JSON.stringify({ runId, ...metadata }))
295
+ throw durabilityFailed("run evidence metadata is immutable");
296
+ return;
297
+ }
298
+ const old = this.#states.get(runId);
299
+ if (!old)
300
+ throw durabilityFailed("run does not exist");
301
+ const next = { ...structuredClone(old), run: { ...old.run, runVersion: old.run.runVersion + 1 } };
302
+ this.#commitLifecycle(next, [], undefined, undefined, undefined, { evidenceMetadata: { runId, ...metadata } });
303
+ }
304
+ appendReceipt(runId, evidence, expectedBatchTip) {
305
+ this.#checkTip(expectedBatchTip);
306
+ if (this.readJournal().some(b => b.recordKind === "lifecycle-batch" && b.receipt?.runId === runId))
307
+ throw new ControlError("TF_IDEMPOTENCY_CONFLICT", "receipt already issued");
308
+ const old = this.#states.get(runId);
309
+ if (!old || evidence.receipt.runId !== runId || !["completed", "failed", "blocked", "cancelled"].includes(old.run.status))
310
+ throw durabilityFailed("receipt requires same terminal run");
311
+ const next = { ...structuredClone(old), run: { ...old.run, runVersion: old.run.runVersion + 1 } };
312
+ this.#commitLifecycle(next, [{ eventId: crypto.randomUUID(), recordedAt: Date.now(), payload: { kind: "receipt.issued", receipt: evidence.receipt } }], undefined, undefined, undefined, evidence);
313
+ }
314
+ appendCheckpoint(throughCommitSeq, expectedBatchTip) {
315
+ this.#checkTip(expectedBatchTip);
316
+ const run = [...this.#states.values()][0];
317
+ if (!run || throughCommitSeq < 1 || throughCommitSeq > this.#commitSeq)
318
+ throw durabilityFailed("invalid compaction checkpoint");
319
+ const next = { ...structuredClone(run), run: { ...run.run, runVersion: run.run.runVersion + 1 } };
320
+ this.#commitLifecycle(next, [{ eventId: crypto.randomUUID(), recordedAt: Date.now(), payload: { kind: "compaction.checkpoint", throughCommitSeq } }]);
321
+ }
322
+ #checkTip(tip) { this.readJournal(); if (tip.commitSeq !== this.#commitSeq || tip.hash !== this.#tip)
323
+ throw new ControlError("TF_STALE_VERSION", "journal tip changed"); }
324
+ #assertIdentity(projectId, controlDomainId) {
325
+ if (projectId !== this.#header.projectId || controlDomainId !== this.#header.controlDomainId)
326
+ throw durabilityFailed("project/domain differs from authoritative store header");
327
+ }
328
+ #apply(batch) {
329
+ this.#tip = batch.recordKind === "lifecycle-batch" ? batch.contentHash : journalHash(batch);
330
+ if (batch.command)
331
+ this.#commandIndex.set(batch.command.commandId, structuredClone(batch.command));
332
+ if (batch.recordKind !== "lifecycle-batch")
333
+ return;
334
+ if (batch.projection)
335
+ this.#states.set(batch.projection.run.runId, structuredClone(batch.projection));
336
+ if (batch.admission)
337
+ this.#admissions.set(batch.admission.reservationId, structuredClone(batch.admission));
338
+ if (batch.command && batch.responseJson !== undefined && batch.projection)
339
+ this.#responses.set(batch.command.commandId, { record: structuredClone(batch.command), responseJson: batch.responseJson, state: structuredClone(batch.projection) });
340
+ }
341
+ #commitLifecycle(projection, facts, command, responseJson, admission, evidence) {
342
+ this.#assertOpen();
343
+ this.#assertFence();
344
+ this.readJournal();
345
+ if (this.#status === "fail-closed")
346
+ throw durabilityFailed("store is fail-closed");
347
+ if (projection) {
348
+ this.#assertIdentity(projection.run.projectId, projection.run.controlDomainId);
349
+ if (!Value.Check(ProjectStateSchema, projection) || projection.approvals.some(a => a.runId !== projection.run.runId) || projection.outbox.some(o => o.runId !== projection.run.runId))
350
+ throw durabilityFailed("invalid project projection");
351
+ }
352
+ const seq = this.#commitSeq + 1;
353
+ if (projection) {
354
+ const previous = this.#states.get(projection.run.runId);
355
+ for (const intent of projection.outbox) {
356
+ if (intent.kind !== "approval.readmission" || previous?.outbox.some(old => old.intentId === intent.intentId))
357
+ continue;
358
+ const r = intent.reservation, run = projection.run, winner = this.#admissions.get(r.reservationId);
359
+ if (winner || admission || r.projectId !== run.projectId || r.projectControlDomainId !== run.controlDomainId || r.runId !== run.runId || run.reservationId !== r.reservationId || intent.projectAdmitCommitSeq !== seq || run.projectAdmitCommitSeq !== seq || run.stage !== "admitted")
360
+ throw new ControlError("TF_STALE_VERSION", "readmission lost admission arbitration or binding changed");
361
+ admission = { reservationId: r.reservationId, projectId: r.projectId, projectControlDomainId: r.projectControlDomainId, runId: r.runId, status: "admitted", decisionCommitSeq: seq, runVersion: run.runVersion };
362
+ }
363
+ }
364
+ if (command) {
365
+ this.#assertIdentity(command.projectId, command.controlDomainId);
366
+ if (this.#commandIndex.has(command.commandId))
367
+ throw new ControlError("TF_IDEMPOTENCY_CONFLICT", "command already exists");
368
+ command = { ...structuredClone(command), firstCommitSeq: seq, lastCommitSeq: seq };
369
+ if (responseJson === undefined || !projection)
370
+ throw durabilityFailed("lifecycle command requires immutable response and projection");
371
+ try {
372
+ JSON.parse(responseJson);
373
+ }
374
+ catch {
375
+ throw durabilityFailed("invalid command response JSON");
376
+ }
377
+ }
378
+ else if (responseJson !== undefined)
379
+ throw durabilityFailed("response requires a command");
380
+ const id = crypto.randomUUID(), runId = projection?.run.runId ?? admission.runId;
381
+ const entries = facts.length ? structuredClone([...facts]) : [{ eventId: id, recordedAt: Date.now(), payload: projection ? { kind: "run.snapshot", run: projection.run } : { kind: "reconcile.started", attempt: 1 } }];
382
+ const events = entries.map((fact, index) => ({ ...fact, schemaVersion: 2, projectId: this.#header.projectId, controlDomainId: this.#header.controlDomainId, streamId: runId, streamSeq: seq, commitSeq: seq, causationId: command?.commandId ?? id, correlationId: runId, ...(command ? { commandId: command.commandId, commandEventIndex: index } : {}) }));
383
+ const body = { recordKind: "lifecycle-batch", schemaVersion: 2, commitSeq: seq, previousHash: this.#tip, events, ...(projection ? { projection: structuredClone(projection) } : {}), ...(command ? { command } : {}), ...(responseJson !== undefined ? { responseJson } : {}), ...(admission ? { admission } : {}), ...evidence };
384
+ const batch = { ...body, contentHash: journalHash(body) };
385
+ if (!validJournalBatch(batch))
386
+ throw durabilityFailed("lifecycle batch schema invalid");
387
+ try {
388
+ crashDuringJournalAppend(journalPath(this.storePath));
389
+ appendJsonLineDurable(journalPath(this.storePath), batch);
390
+ maybeCrash("lifecycle-committed");
391
+ writeJsonAtomicHardened(commitSeqPath(this.storePath), { commitSeq: seq });
392
+ this.#apply(batch);
393
+ this.#commitSeq = seq;
394
+ }
395
+ catch (e) {
396
+ this.#status = "fail-closed";
397
+ throw durabilityFailed(`lifecycle persistence failed: ${String(e)}`);
398
+ }
399
+ }
400
+ close() {
401
+ if (this.#closed)
402
+ return;
403
+ this.#closed = true;
404
+ this.#releaseWriter();
405
+ }
406
+ #assertFence() { if (this.#fenceOwner && this.#fence.getStore() !== this.#fenceOwner)
407
+ throw new ControlError("TF_STALE_VERSION", "project writer is held by another operation"); }
408
+ #assertOpen() {
409
+ if (this.#closed)
410
+ throw durabilityFailed("control store is closed");
411
+ const st = fs.lstatSync(this.storePath), binding = this.#header.directoryBinding;
412
+ for (const child of [LOCK_NAME, HEADER_NAME, JOURNAL_DIR, PROJECTIONS_DIR, COMMANDS_DIR, RECEIPTS_DIR])
413
+ assertNotSymlink(path.join(this.storePath, child));
414
+ if (fs.readFileSync(path.join(this.storePath, HEADER_NAME), "utf8") !== this.#headerProof)
415
+ throw durabilityFailed("durable project header changed");
416
+ if (fs.readFileSync(path.join(this.storePath, LOCK_NAME), "utf8") !== this.#writerProof)
417
+ throw durabilityFailed("project writer ownership changed");
418
+ if (st.isSymbolicLink() || String(st.dev) !== binding.device || String(st.ino) !== binding.inode || fs.realpathSync(this.storePath) !== binding.canonicalPath)
419
+ throw durabilityFailed("store directory identity changed");
420
+ }
421
+ }
422
+ export function openControlStore(storePath, options = {}) {
423
+ const resolved = path.resolve(storePath);
424
+ ensureDirectory(resolved);
425
+ const releaseWriter = acquireWriterLock(resolved);
426
+ try {
427
+ const existingHeader = readHeader(resolved);
428
+ if (existingHeader === undefined) {
429
+ // A missing identity is only an uninitialized store when nothing else
430
+ // exists. Never assign a new project ID or reset an existing ledger.
431
+ // Other contenders can be preparing their unpublished lock files.
432
+ const candidateName = /^\.writer\.lock-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
433
+ if (fs.readdirSync(resolved, { withFileTypes: true }).some((entry) => entry.name !== LOCK_NAME && !(entry.isFile() && candidateName.test(entry.name)))) {
434
+ throw durabilityFailed(`missing control store header in nonempty store at ${resolved}`);
435
+ }
436
+ const header = createHeader(resolved, options);
437
+ writeJsonAtomicHardened(headerPath(resolved), header);
438
+ maybeCrash("header-fsynced");
439
+ writeJsonAtomicHardened(commitSeqPath(resolved), { commitSeq: 0 });
440
+ ensureDirectory(path.join(resolved, JOURNAL_DIR));
441
+ ensureDirectory(path.join(resolved, PROJECTIONS_DIR));
442
+ ensureDirectory(path.join(resolved, COMMANDS_DIR));
443
+ ensureDirectory(path.join(resolved, RECEIPTS_DIR));
444
+ fsyncDirectory(resolved);
445
+ return new ControlStore(resolved, header, 0, [], releaseWriter);
446
+ }
447
+ // Recovery may rewrite projections, so verify the authoritative binding
448
+ // first. A copied/moved/replaced store is not proof of the same project.
449
+ // Never reset UUIDs or rewrite historical evidence to make it attach.
450
+ if (options.projectId && options.projectId !== existingHeader.projectId || options.controlDomainId && options.controlDomainId !== existingHeader.controlDomainId)
451
+ throw durabilityFailed("requested identity differs from existing store");
452
+ const binding = existingHeader.directoryBinding;
453
+ const stat = fs.statSync(resolved);
454
+ if (binding.canonicalPath !== fs.realpathSync(resolved)
455
+ || binding.device !== String(stat.dev) || binding.inode !== String(stat.ino)) {
456
+ throw durabilityFailed("control store directory identity changed; explicit verified rebind or a new project store is required");
457
+ }
458
+ for (const child of [JOURNAL_DIR, PROJECTIONS_DIR, COMMANDS_DIR, RECEIPTS_DIR])
459
+ assertNotSymlink(path.join(resolved, child));
460
+ const recovered = recoverFromJournal(resolved, existingHeader);
461
+ return new ControlStore(resolved, existingHeader, recovered.commitSeq, recovered.commands, releaseWriter, recovered.batches);
462
+ }
463
+ catch (error) {
464
+ releaseWriter();
465
+ throw error;
466
+ }
467
+ }
468
+ function createHeader(storePath, options) {
469
+ const st = fs.statSync(storePath);
470
+ return {
471
+ projectId: options.projectId ?? crypto.randomUUID(),
472
+ controlDomainId: options.controlDomainId ?? crypto.randomUUID(),
473
+ schemaVersion: CONTROL_WIRE_SCHEMA_VERSION,
474
+ directoryBinding: {
475
+ canonicalPath: fs.realpathSync(storePath),
476
+ device: String(st.dev),
477
+ inode: String(st.ino),
478
+ },
479
+ };
480
+ }
481
+ function readHeader(storePath) {
482
+ const file = headerPath(storePath);
483
+ assertNotSymlink(file);
484
+ if (!fs.existsSync(file))
485
+ return undefined;
486
+ let raw;
487
+ try {
488
+ raw = JSON.parse(fs.readFileSync(file, "utf8"));
489
+ }
490
+ catch {
491
+ throw durabilityFailed(`unreadable control store header at ${file}`);
492
+ }
493
+ if (!Value.Check(ControlStoreHeaderSchema, raw)) {
494
+ throw durabilityFailed(`malformed control store header at ${file}`);
495
+ }
496
+ return raw;
497
+ }
498
+ function recoverFromJournal(storePath, header) {
499
+ const batches = readJournalBatches(journalPath(storePath));
500
+ const commands = [], commandIds = new Set();
501
+ const runs = new Map(), admissions = new Set();
502
+ let commitSeq = 0, tip = JOURNAL_GENESIS;
503
+ for (const batch of batches) {
504
+ if (batch.commitSeq !== commitSeq + 1)
505
+ throw durabilityFailed("journal sequence gap");
506
+ if (batch.command) {
507
+ const c = batch.command;
508
+ if (c.projectId !== header.projectId || c.controlDomainId !== header.controlDomainId || commandIds.has(c.commandId) || c.firstCommitSeq !== batch.commitSeq || c.lastCommitSeq !== batch.commitSeq)
509
+ throw durabilityFailed("journal command binding/sequence invalid");
510
+ commandIds.add(c.commandId);
511
+ commands.push(c);
512
+ }
513
+ for (const [index, event] of batch.events.entries()) {
514
+ if (event.projectId !== header.projectId || event.controlDomainId !== header.controlDomainId || event.commitSeq !== batch.commitSeq || (batch.command && (event.commandId !== batch.command.commandId || event.commandEventIndex !== index)) || (!batch.command && event.commandId !== undefined))
515
+ throw durabilityFailed("journal event binding/sequence invalid");
516
+ }
517
+ if (batch.recordKind === "lifecycle-batch") {
518
+ if (batch.previousHash !== tip)
519
+ throw durabilityFailed("journal hash chain mismatch");
520
+ if (batch.command && (batch.responseJson === undefined || !batch.projection))
521
+ throw durabilityFailed("missing immutable command response");
522
+ if (batch.responseJson !== undefined) {
523
+ if (!batch.command)
524
+ throw durabilityFailed("orphan command response");
525
+ try {
526
+ JSON.parse(batch.responseJson);
527
+ }
528
+ catch {
529
+ throw durabilityFailed("invalid immutable response");
530
+ }
531
+ }
532
+ if (batch.projection) {
533
+ const state = batch.projection, run = state.run, old = runs.get(run.runId);
534
+ if (run.projectId !== header.projectId || run.controlDomainId !== header.controlDomainId || state.approvals.some(a => a.runId !== run.runId) || state.outbox.some(o => o.runId !== run.runId) || (old && run.runVersion !== old.run.runVersion + 1))
535
+ throw durabilityFailed("journal projection binding/version invalid");
536
+ runs.set(run.runId, state);
537
+ }
538
+ if (batch.admission) {
539
+ const d = batch.admission;
540
+ if (d.projectId !== header.projectId || d.projectControlDomainId !== header.controlDomainId || d.decisionCommitSeq !== batch.commitSeq || admissions.has(d.reservationId))
541
+ throw durabilityFailed("admission binding invalid");
542
+ admissions.add(d.reservationId);
543
+ }
544
+ tip = batch.contentHash;
545
+ }
546
+ else
547
+ tip = journalHash(batch);
548
+ commitSeq = batch.commitSeq;
549
+ }
550
+ const onDiskSeq = readCommitSeqFile(storePath);
551
+ if (onDiskSeq !== undefined && onDiskSeq > commitSeq) {
552
+ throw durabilityFailed(`mixed state: commit-seq.json (${onDiskSeq}) is ahead of journal (${commitSeq})`);
553
+ }
554
+ // Validate the complete authoritative state before any recovery write. A
555
+ // torn tail is recoverable only when the preceding ledger is consistent.
556
+ // Damaged/torn evidence is preserved; recovery never rewrites journal bytes.
557
+ ensureDirectory(path.join(storePath, JOURNAL_DIR));
558
+ ensureDirectory(path.join(storePath, PROJECTIONS_DIR));
559
+ ensureDirectory(path.join(storePath, COMMANDS_DIR));
560
+ ensureDirectory(path.join(storePath, RECEIPTS_DIR));
561
+ writeJsonAtomicHardened(commitSeqPath(storePath), { commitSeq });
562
+ writeJsonAtomicHardened(commandIndexPath(storePath), Object.fromEntries(commands.map((command) => [command.commandId, command])));
563
+ return { commitSeq, commands, batches };
564
+ }
565
+ function readCommitSeqFile(storePath) {
566
+ const file = commitSeqPath(storePath);
567
+ assertNotSymlink(file);
568
+ if (!fs.existsSync(file))
569
+ return undefined;
570
+ let raw;
571
+ try {
572
+ raw = JSON.parse(fs.readFileSync(file, "utf8"));
573
+ }
574
+ catch {
575
+ throw durabilityFailed(`unreadable commit-seq.json at ${file}`);
576
+ }
577
+ if (!raw || typeof raw !== "object" || !("commitSeq" in raw)
578
+ || typeof raw.commitSeq !== "number" || !Number.isSafeInteger(raw.commitSeq) || raw.commitSeq < 0) {
579
+ throw durabilityFailed(`malformed commit-seq.json at ${file}`);
580
+ }
581
+ return raw.commitSeq;
582
+ }
583
+ function readJournalBatches(filePath) {
584
+ assertNotSymlink(filePath);
585
+ if (!fs.existsSync(filePath))
586
+ return [];
587
+ const raw = fs.readFileSync(filePath);
588
+ const text = raw.toString("utf8");
589
+ if (raw.length && raw[raw.length - 1] !== 10)
590
+ throw durabilityFailed("incomplete journal tail; preserve evidence for explicit recovery");
591
+ const lines = text.split("\n");
592
+ const complete = text.endsWith("\n") ? lines.slice(0, -1) : lines.slice(0, -1);
593
+ // Every authoritative line must be complete; damaged bytes remain untouched.
594
+ const batches = [];
595
+ for (const line of complete) {
596
+ if (!line)
597
+ continue;
598
+ let parsed;
599
+ try {
600
+ parsed = JSON.parse(line);
601
+ }
602
+ catch {
603
+ throw durabilityFailed("journal contains a complete but unreadable line");
604
+ }
605
+ if (!validJournalBatch(parsed))
606
+ throw durabilityFailed("journal contains a malformed commit batch");
607
+ batches.push(parsed);
608
+ }
609
+ return batches;
610
+ }
611
+ /** Publish a fully written owner atomically; a visible lock is never half-written. */
612
+ export function acquireWriterLock(storePath) {
613
+ const lockPath = path.join(storePath, LOCK_NAME);
614
+ const owner = { ownerId: crypto.randomUUID(), pid: process.pid, acquiredAt: Date.now() };
615
+ const contents = JSON.stringify(owner);
616
+ const candidatePath = path.join(storePath, `.${LOCK_NAME}-${owner.ownerId}`);
617
+ const fd = fs.openSync(candidatePath, "wx", 0o600);
618
+ try {
619
+ try {
620
+ fs.writeFileSync(fd, contents);
621
+ fs.fsyncSync(fd);
622
+ }
623
+ finally {
624
+ fs.closeSync(fd);
625
+ }
626
+ for (let attempt = 0; attempt < 3; attempt++) {
627
+ try {
628
+ // Hard-link creation is exclusive, unlike rename (which overwrites).
629
+ fs.linkSync(candidatePath, lockPath);
630
+ fsyncDirectory(storePath);
631
+ return () => releaseWriterLock(lockPath, contents);
632
+ }
633
+ catch (error) {
634
+ if (error.code !== "EEXIST")
635
+ throw error;
636
+ if (reclaimDeadWriter(lockPath))
637
+ continue;
638
+ throw durabilityFailed(`control store already has a live or unverified writer at ${storePath}`);
639
+ }
640
+ }
641
+ throw durabilityFailed(`could not acquire control store writer lock at ${storePath}`);
642
+ }
643
+ finally {
644
+ try {
645
+ fs.unlinkSync(candidatePath);
646
+ }
647
+ catch { /* best effort */ }
648
+ }
649
+ }
650
+ function releaseWriterLock(lockPath, contents) {
651
+ try {
652
+ assertNotSymlink(lockPath);
653
+ // The exact acquisition owns the lock, not every store in this process.
654
+ if (fs.readFileSync(lockPath, "utf8") !== contents)
655
+ return;
656
+ fs.unlinkSync(lockPath);
657
+ }
658
+ catch {
659
+ /* best effort */
660
+ }
661
+ }
662
+ function reclaimDeadWriter(lockPath) {
663
+ let contents;
664
+ try {
665
+ assertNotSymlink(lockPath);
666
+ contents = fs.readFileSync(lockPath, "utf8");
667
+ }
668
+ catch (error) {
669
+ if (error.code === "ENOENT")
670
+ return true;
671
+ throw error;
672
+ }
673
+ if (!isVerifiedDeadWriter(contents))
674
+ return false;
675
+ // Serialize reclamation of this exact generation. Without this claim, two
676
+ // reclaimers can both observe a dead PID and one can unlink the other's
677
+ // newly acquired lock. A crashed reclaimer leaves an unverified claim:
678
+ // fail closed for operator recovery rather than risk a second writer.
679
+ const generation = crypto.createHash("sha256").update(contents).digest("hex");
680
+ const claimPath = `${lockPath}.reclaim-${generation}`;
681
+ try {
682
+ fs.mkdirSync(claimPath, { mode: 0o700 });
683
+ }
684
+ catch (error) {
685
+ if (error.code === "EEXIST")
686
+ return false;
687
+ throw error;
688
+ }
689
+ try {
690
+ assertNotSymlink(lockPath);
691
+ if (fs.readFileSync(lockPath, "utf8") !== contents)
692
+ return true;
693
+ if (!isVerifiedDeadWriter(contents))
694
+ return false;
695
+ fs.unlinkSync(lockPath);
696
+ return true;
697
+ }
698
+ catch (error) {
699
+ if (error.code === "ENOENT")
700
+ return true;
701
+ throw error;
702
+ }
703
+ finally {
704
+ fs.rmdirSync(claimPath);
705
+ }
706
+ }
707
+ function isVerifiedDeadWriter(contents) {
708
+ try {
709
+ const raw = JSON.parse(contents);
710
+ if (!raw || typeof raw !== "object" || !("pid" in raw)
711
+ || typeof raw.pid !== "number" || !Number.isSafeInteger(raw.pid) || raw.pid <= 0
712
+ || !("acquiredAt" in raw) || typeof raw.acquiredAt !== "number"
713
+ || !Number.isSafeInteger(raw.acquiredAt) || raw.acquiredAt < 0
714
+ || ("ownerId" in raw && (typeof raw.ownerId !== "string"
715
+ || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw.ownerId))))
716
+ return false;
717
+ process.kill(raw.pid, 0);
718
+ return false;
719
+ }
720
+ catch (error) {
721
+ // Empty, malformed, inaccessible, and EPERM locks are unverified/live.
722
+ // Only the OS confirming that the recorded process is gone allows steal.
723
+ return error.code === "ESRCH";
724
+ }
725
+ }
726
+ export function maybeCrash(point) {
727
+ if (process.env[CONTROL_CRASH_ENV] === point) {
728
+ process.kill(process.pid, "SIGKILL");
729
+ }
730
+ }
731
+ /** SIGKILL after a torn (unterminated) journal write — process-level A4. */
732
+ function crashDuringJournalAppend(filePath) {
733
+ if (process.env[CONTROL_CRASH_ENV] !== "journal-append")
734
+ return;
735
+ ensureDirectory(path.dirname(filePath));
736
+ const fd = fs.openSync(filePath, "a", 0o600);
737
+ try {
738
+ fs.writeSync(fd, Buffer.from("{\"recordKind\":\"commit-batch\""));
739
+ fs.fsyncSync(fd);
740
+ }
741
+ finally {
742
+ fs.closeSync(fd);
743
+ }
744
+ process.kill(process.pid, "SIGKILL");
745
+ }
746
+ export function writeJsonAtomicHardened(filePath, value) {
747
+ assertNotSymlink(filePath);
748
+ const directory = path.dirname(filePath);
749
+ ensureDirectory(directory);
750
+ let temp = "";
751
+ let fd = -1;
752
+ for (let attempt = 0; attempt < 8; attempt++) {
753
+ temp = path.join(directory, `.${crypto.randomUUID()}`);
754
+ try {
755
+ fd = fs.openSync(temp, "wx", 0o600);
756
+ break;
757
+ }
758
+ catch (error) {
759
+ if (error.code === "EEXIST")
760
+ continue;
761
+ throw error;
762
+ }
763
+ }
764
+ if (fd < 0)
765
+ throw durabilityFailed(`could not create exclusive temp file for ${filePath}`);
766
+ try {
767
+ fs.writeFileSync(fd, `${JSON.stringify(value)}\n`);
768
+ fs.fsyncSync(fd);
769
+ }
770
+ finally {
771
+ fs.closeSync(fd);
772
+ }
773
+ try {
774
+ assertNotSymlink(filePath);
775
+ fs.renameSync(temp, filePath);
776
+ fsyncDirectory(directory);
777
+ }
778
+ catch (error) {
779
+ try {
780
+ fs.unlinkSync(temp);
781
+ }
782
+ catch { /* best effort */ }
783
+ throw error;
784
+ }
785
+ }
786
+ function appendJsonLineDurable(filePath, record) {
787
+ assertNotSymlink(filePath);
788
+ ensureDirectory(path.dirname(filePath));
789
+ const existed = fs.existsSync(filePath);
790
+ if (existed) {
791
+ const raw = fs.readFileSync(filePath);
792
+ if (raw.length && raw.at(-1) !== 10)
793
+ throw durabilityFailed("refusing append after incomplete journal tail");
794
+ }
795
+ const fd = fs.openSync(filePath, "a", 0o600);
796
+ try {
797
+ const data = Buffer.from(`${JSON.stringify(record)}\n`);
798
+ let offset = 0;
799
+ while (offset < data.length)
800
+ offset += fs.writeSync(fd, data, offset, data.length - offset);
801
+ fs.fsyncSync(fd);
802
+ }
803
+ finally {
804
+ fs.closeSync(fd);
805
+ }
806
+ if (!existed)
807
+ fsyncDirectory(path.dirname(filePath));
808
+ }
809
+ function assertNotSymlink(filePath) {
810
+ try {
811
+ const st = fs.lstatSync(filePath);
812
+ if (st.isSymbolicLink()) {
813
+ throw durabilityFailed(`refusing to touch symlink at ${filePath}`);
814
+ }
815
+ }
816
+ catch (error) {
817
+ if (error.code === "ENOENT")
818
+ return;
819
+ throw error;
820
+ }
821
+ }
822
+ function ensureDirectory(directory) {
823
+ assertNotSymlink(directory);
824
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
825
+ assertNotSymlink(directory);
826
+ }
827
+ function fsyncDirectory(directory) {
828
+ try {
829
+ const fd = fs.openSync(directory, "r");
830
+ try {
831
+ fs.fsyncSync(fd);
832
+ }
833
+ finally {
834
+ fs.closeSync(fd);
835
+ }
836
+ }
837
+ catch (error) {
838
+ const code = error.code;
839
+ // Only explicit platform lack of directory-fsync support is tolerated.
840
+ // I/O, capacity and permission failures must never become a successful commit.
841
+ if (code === "EINVAL" || code === "ENOTSUP" || process.platform === "win32" && (code === "EISDIR" || code === "EPERM"))
842
+ return;
843
+ throw error;
844
+ }
845
+ }
846
+ function durabilityFailed(message) {
847
+ return new ControlError("TF_DURABILITY_FAILED", message, {
848
+ recoveryAction: "none",
849
+ sideEffects: "unknown",
850
+ });
851
+ }
852
+ function headerPath(storePath) {
853
+ return path.join(storePath, HEADER_NAME);
854
+ }
855
+ function commitSeqPath(storePath) {
856
+ return path.join(storePath, COMMIT_SEQ_NAME);
857
+ }
858
+ function journalPath(storePath) {
859
+ return path.join(storePath, JOURNAL_DIR, JOURNAL_SEGMENT);
860
+ }
861
+ function commandIndexPath(storePath) {
862
+ return path.join(storePath, PROJECTIONS_DIR, COMMAND_INDEX);
863
+ }
864
+ export function controlStorePaths(storePath) {
865
+ return {
866
+ header: headerPath(storePath),
867
+ commitSeq: commitSeqPath(storePath),
868
+ journal: journalPath(storePath),
869
+ commandIndex: commandIndexPath(storePath),
870
+ lock: path.join(storePath, LOCK_NAME),
871
+ };
872
+ }
873
+ //# sourceMappingURL=store.js.map