@astrosheep/keiyaku 2.9.11 → 2.9.13

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 (218) hide show
  1. package/README.md +79 -85
  2. package/build/.tsbuildinfo +1 -1
  3. package/build/agents/call-terms.js +1 -0
  4. package/build/agents/providers/claude-agent-sdk/adapter.js +17 -4
  5. package/build/cli/commands/akuma/akuma/handler.js +2 -1
  6. package/build/cli/commands/akuma/akuma/meta.js +4 -3
  7. package/build/cli/commands/akuma/catalog.js +2 -0
  8. package/build/cli/commands/akuma/list/handler.js +1 -1
  9. package/build/cli/commands/akuma/list/meta-list.js +12 -0
  10. package/build/cli/commands/akuma/list/meta-ls.js +2 -2
  11. package/build/cli/commands/akuma.js +5 -0
  12. package/build/cli/commands/contract/amend/meta.js +4 -3
  13. package/build/cli/commands/contract/arc/meta.js +2 -0
  14. package/build/cli/commands/contract/audit/handler.js +28 -2
  15. package/build/cli/commands/contract/audit/meta.js +4 -3
  16. package/build/cli/commands/contract/bind/meta.js +4 -4
  17. package/build/cli/commands/contract/petition/handler.js +2 -1
  18. package/build/cli/commands/contract/petition/meta.js +2 -2
  19. package/build/cli/commands/contract/renew/handler.js +44 -3
  20. package/build/cli/commands/contract/renew/meta.js +5 -4
  21. package/build/cli/commands/metadata.js +14 -10
  22. package/build/cli/commands/projection/call/handler.js +1 -1
  23. package/build/cli/commands/projection/call/meta.js +1 -1
  24. package/build/cli/commands/projection/catalog.js +2 -2
  25. package/build/cli/commands/projection/{revive → fork}/handler.js +10 -5
  26. package/build/cli/commands/projection/fork/meta.js +12 -0
  27. package/build/cli/commands/projection/history/handler.js +46 -2
  28. package/build/cli/commands/projection/history/meta.js +3 -3
  29. package/build/cli/commands/projection/kill/handler.js +4 -5
  30. package/build/cli/commands/projection/kill/meta.js +2 -2
  31. package/build/cli/commands/projection/status/handler.js +29 -13
  32. package/build/cli/commands/projection/status/meta.js +14 -3
  33. package/build/cli/commands/projection/tell/handler.js +8 -4
  34. package/build/cli/commands/projection/tell/meta.js +1 -1
  35. package/build/cli/commands/projection/wait/handler.js +45 -20
  36. package/build/cli/commands/projection/wait/meta.js +1 -1
  37. package/build/cli/commands/shared.js +2 -2
  38. package/build/cli/commands/system/catalog.js +2 -0
  39. package/build/cli/commands/task/add/handler.js +28 -12
  40. package/build/cli/commands/task/add/meta.js +18 -6
  41. package/build/cli/commands/task/catalog.js +4 -0
  42. package/build/cli/commands/task/compose/handler.js +18 -0
  43. package/build/cli/commands/task/compose/meta.js +10 -0
  44. package/build/cli/commands/task/doctor/handler.js +2 -3
  45. package/build/cli/commands/task/doctor/meta.js +2 -2
  46. package/build/cli/commands/task/done/meta.js +2 -2
  47. package/build/cli/commands/task/drop/handler.js +7 -2
  48. package/build/cli/commands/task/drop/meta.js +6 -3
  49. package/build/cli/commands/task/hold/meta.js +2 -2
  50. package/build/cli/commands/task/log/meta.js +2 -2
  51. package/build/cli/commands/task/ls/meta.js +2 -2
  52. package/build/cli/commands/task/note/handler.js +6 -0
  53. package/build/cli/commands/task/note/meta.js +11 -0
  54. package/build/cli/commands/task/resume/meta.js +2 -2
  55. package/build/cli/commands/task/shared.js +29 -15
  56. package/build/cli/commands/task/show/meta.js +2 -2
  57. package/build/cli/commands/task/start/meta.js +2 -2
  58. package/build/cli/commands/task/stop/meta.js +2 -2
  59. package/build/cli/commands/task/update/meta.js +3 -2
  60. package/build/cli/commands/verification/handler.js +43 -0
  61. package/build/cli/commands/verification/meta.js +13 -0
  62. package/build/cli/completion.js +16 -13
  63. package/build/cli/flags.js +48 -12
  64. package/build/cli/help.js +29 -30
  65. package/build/cli/index.js +14 -8
  66. package/build/cli/parse-flags.js +60 -21
  67. package/build/cli/parse-metadata.js +10 -4
  68. package/build/cli/parse-selectors.js +22 -4
  69. package/build/cli/parse.js +43 -30
  70. package/build/cli/projection-address.js +7 -7
  71. package/build/cli/render/arc.js +12 -3
  72. package/build/cli/render/audit.js +2 -2
  73. package/build/cli/render/call.js +4 -4
  74. package/build/cli/render/kanshi.js +122 -85
  75. package/build/cli/render/misc.js +2 -2
  76. package/build/cli/render/petition.js +16 -1
  77. package/build/cli/render/projection-history.js +17 -4
  78. package/build/cli/render/shared.js +5 -5
  79. package/build/cli/render/status-indicator.js +47 -0
  80. package/build/cli/render/status.js +106 -76
  81. package/build/cli/render/success-response.js +20 -5
  82. package/build/cli/render/task.js +33 -5
  83. package/build/cli/render/tell.js +10 -4
  84. package/build/cli/render/tool-command-normalization.js +137 -0
  85. package/build/cli/render/tool-presentation.js +2 -1
  86. package/build/cli/render/verification.js +23 -0
  87. package/build/cli/render/wait.js +101 -33
  88. package/build/cli/types.js +2 -1
  89. package/build/config/akuma-loader.js +21 -1
  90. package/build/config/settings/knobs.js +7 -1
  91. package/build/config/settings/schema.js +10 -5
  92. package/build/core/addressing.js +3 -3
  93. package/build/core/amend.js +102 -17
  94. package/build/core/arc.js +4 -4
  95. package/build/core/audit/candidate.js +10 -18
  96. package/build/core/audit/coordinates.js +12 -17
  97. package/build/core/audit/evidence.js +6 -4
  98. package/build/core/audit/facade.js +3 -3
  99. package/build/core/audit/report.js +1 -1
  100. package/build/core/bind-reconciliation.js +42 -23
  101. package/build/core/bind.js +182 -81
  102. package/build/core/call/call.js +6 -7
  103. package/build/core/call/context.js +46 -33
  104. package/build/core/call/execution.js +21 -6
  105. package/build/core/call/prompt.js +11 -42
  106. package/build/core/contract-carrier-runtime.js +209 -105
  107. package/build/core/contract-carrier.js +156 -73
  108. package/build/core/contract-view.js +43 -6
  109. package/build/core/contract.js +2 -1
  110. package/build/core/derived-replay.js +168 -35
  111. package/build/core/draft.js +60 -27
  112. package/build/core/forfeit.js +1 -1
  113. package/build/core/lifecycle-history.js +13 -0
  114. package/build/core/lifecycle-recovery.js +32 -27
  115. package/build/core/lifecycle-runner.js +14 -14
  116. package/build/core/log.js +1 -1
  117. package/build/core/outcome-base.js +2 -2
  118. package/build/core/projection/akuma-name.js +43 -0
  119. package/build/core/projection/generation/database.js +141 -79
  120. package/build/core/projection/generation/ledger.js +91 -0
  121. package/build/core/projection/generation/model.js +19 -14
  122. package/build/core/projection/generation/projection-generation-continuation.js +1 -1
  123. package/build/core/projection/generation/projection-generation-doorbell.js +88 -0
  124. package/build/core/projection/generation/projection-generation-execution.js +24 -0
  125. package/build/core/projection/generation/projection-generation-identity.js +4 -0
  126. package/build/core/projection/generation/projection-generation-launcher.js +72 -138
  127. package/build/core/projection/generation/projection-generation-process.js +16 -15
  128. package/build/core/projection/generation/projection-generation-runner.js +132 -76
  129. package/build/core/projection/generation/projection-generation-runtime.js +10 -61
  130. package/build/core/projection/generation/protocol.js +68 -0
  131. package/build/core/projection/generation/store.js +17 -7
  132. package/build/core/projection/generation/transitions.js +24 -14
  133. package/build/core/projection/index.js +5 -5
  134. package/build/core/projection/projection-core.js +6 -2
  135. package/build/core/projection/projection-execution-observer.js +1 -1
  136. package/build/core/projection/projection-history.js +8 -8
  137. package/build/core/projection/projection-kill.js +20 -10
  138. package/build/core/projection/projection-life-observer.js +8 -8
  139. package/build/core/projection/projection-mint.js +6 -8
  140. package/build/core/projection/projection-status-observation.js +77 -8
  141. package/build/core/projection/projection-status.js +151 -4
  142. package/build/core/projection/projection-wait.js +48 -29
  143. package/build/core/projection/projection-wake.js +28 -88
  144. package/build/core/projection/tell/launch-store.js +1 -1
  145. package/build/core/projection/tell/store.js +1 -1
  146. package/build/core/renew-build.js +204 -74
  147. package/build/core/renew-plan.js +34 -9
  148. package/build/core/renew-rewrite.js +56 -15
  149. package/build/core/renew.js +39 -35
  150. package/build/core/repository-ledger/accepted-fold-read.js +6 -3
  151. package/build/core/repository-ledger/claim-evidence.js +1 -1
  152. package/build/core/repository-ledger/codec.js +11 -5
  153. package/build/core/repository-ledger/current-state-store.js +464 -349
  154. package/build/core/repository-ledger/fold-repository.js +5 -6
  155. package/build/core/repository-ledger/identity.js +60 -0
  156. package/build/core/repository-ledger/inventory.js +3 -3
  157. package/build/core/repository-ledger/read-model.js +57 -9
  158. package/build/core/repository-ledger/write-transaction.js +24 -17
  159. package/build/core/run-control/connection-bound-close.js +41 -0
  160. package/build/core/run-control/detached-lease-bootstrap.js +208 -0
  161. package/build/core/run-control/detached-process.js +14 -0
  162. package/build/core/run-control/index.js +6 -0
  163. package/build/core/{process-group.js → run-control/process-tree.js} +1 -1
  164. package/build/core/run-control/runner-lease.js +172 -0
  165. package/build/core/run-control/sqlite-process-lifetime-lock.js +174 -0
  166. package/build/core/seal.js +44 -32
  167. package/build/core/settlement/claim-delivery.js +22 -9
  168. package/build/core/settlement/claim.js +8 -6
  169. package/build/core/settlement/petition-claim-gates.js +9 -8
  170. package/build/core/settlement/petition-head-guard.js +5 -6
  171. package/build/core/settlement/petition-preview.js +21 -5
  172. package/build/core/settlement/petition.js +51 -17
  173. package/build/core/settlement/settlement.js +20 -15
  174. package/build/core/settlement/verdict.js +2 -2
  175. package/build/core/settlement/verification.js +492 -364
  176. package/build/core/status/board.js +184 -75
  177. package/build/core/status/drift.js +8 -10
  178. package/build/core/task/board.js +1 -0
  179. package/build/core/task/commands.js +25 -0
  180. package/build/core/task/compose.js +328 -0
  181. package/build/core/task/document.js +102 -39
  182. package/build/core/task/index.js +4 -3
  183. package/build/core/task/settlement-git.js +70 -30
  184. package/build/core/task/settlement-policy.js +27 -14
  185. package/build/core/task/source-board.js +1 -0
  186. package/build/core/task/task-bind-preparation.js +59 -9
  187. package/build/core/task/task-contract.js +2 -2
  188. package/build/core/task/task-git-store.js +1 -0
  189. package/build/core/task/task-store-repository.js +2 -0
  190. package/build/core/task/task.js +2 -2
  191. package/build/core/transcripts.js +4 -4
  192. package/build/core/verification-declaration.js +95 -0
  193. package/build/core/worktree-bootstrap.js +1 -1
  194. package/build/core/worktree-path.js +7 -11
  195. package/build/flow-error.js +15 -2
  196. package/build/generated/version.js +2 -2
  197. package/build/git/branches.js +41 -70
  198. package/build/git/commits.js +92 -21
  199. package/build/git/core.js +9 -161
  200. package/build/git/diff/preview.js +4 -4
  201. package/build/git/diff/read.js +4 -4
  202. package/build/git/diff/structured.js +5 -5
  203. package/build/git/process.js +229 -0
  204. package/build/git/refs.js +30 -5
  205. package/build/git/staging.js +39 -115
  206. package/build/git/streaming-batch.js +9 -16
  207. package/build/git/worktree.js +104 -91
  208. package/build/index.js +7 -0
  209. package/package.json +2 -2
  210. package/skills/keiyaku-akuma/SKILL.md +27 -21
  211. package/skills/keiyaku-task/SKILL.md +61 -5
  212. package/skills/keiyaku-workflow/SKILL.md +81 -26
  213. package/build/cli/commands/projection/revive/meta.js +0 -12
  214. package/build/core/projection/projection-life-protocol.js +0 -115
  215. package/build/core/projection/projection-runner-lock.js +0 -382
  216. package/build/core/projection/tell/database.js +0 -127
  217. package/build/core/settlement/verification-coordination.js +0 -305
  218. package/build/core/settlement/verification-supervisor.js +0 -275
@@ -1,13 +1,17 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import { createHash } from "node:crypto";
3
+ import { createHash, randomUUID } from "node:crypto";
4
4
  import { DatabaseSync } from "node:sqlite";
5
5
  import { runGitProcess } from "../../git/core.js";
6
+ import { acquireSqliteProcessLifetimeLock } from "../run-control/sqlite-process-lifetime-lock.js";
6
7
  import { applyFoldEntry } from "./fold.js";
7
8
  import { createLifecycleEntrySchema, evidenceObjectId, repositoryDeliveryRef } from "./codec.js";
8
- export const REPOSITORY_CURRENT_STATE_VERSION = 3;
9
- export const REPOSITORY_CURRENT_STATE_FILE = "keiyaku-current-state.sqlite";
10
- const SNAPSHOT_WRITE_BUSY_MS = 5_000;
9
+ import { repositoryLedgerHead } from "./identity.js";
10
+ export const REPOSITORY_CURRENT_STATE_VERSION = 4;
11
+ export const REPOSITORY_CURRENT_STATE_PREFIX = "keiyaku-current-state-";
12
+ export const REPOSITORY_CURRENT_STATE_SUFFIX = ".sqlite";
13
+ const SNAPSHOT_READER_BUSY_MS = 5_000;
14
+ const SNAPSHOT_WRITER_LOCK_FILE = "keiyaku-current-state-writer-coordination.sqlite";
11
15
  const SCHEMA = `
12
16
  CREATE TABLE snapshot_schema (
13
17
  singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
@@ -64,7 +68,7 @@ function exactRows(database, sql, ...params) {
64
68
  return database.prepare(sql).all(...params);
65
69
  }
66
70
  function pinDigest(pin) {
67
- return digest(["keiyaku-current-state-v3", REPOSITORY_CURRENT_STATE_VERSION, pin.head, pin.objectFormat, pin.emptyTree]);
71
+ return digest(["keiyaku-current-state-v4", REPOSITORY_CURRENT_STATE_VERSION, pin.head, pin.objectFormat, pin.emptyTree]);
68
72
  }
69
73
  function repositoryDigest(database) {
70
74
  return digest(["repository", exactRows(database, "SELECT singleton, diagnostics_json FROM snapshot_repository ORDER BY singleton")]);
@@ -126,13 +130,16 @@ function validateRepositoryReceipts(database, contracts, summaryOnly, includeTop
126
130
  if (includeTopology)
127
131
  assertReceipt(database, "topology", topologyDigest(database));
128
132
  }
129
- async function snapshotPath(cwd, signal) {
133
+ function snapshotFileName(head) {
134
+ return `${REPOSITORY_CURRENT_STATE_PREFIX}${head ?? "empty"}${REPOSITORY_CURRENT_STATE_SUFFIX}`;
135
+ }
136
+ export async function repositoryCurrentStateSnapshotPath(cwd, head, signal) {
130
137
  // Addressing callers already pass the resolved primary worktree root. Reuse
131
138
  // that physical coordinate within this request instead of probing Git again.
132
139
  const localGitDirectory = path.join(cwd, ".git");
133
140
  try {
134
141
  if ((await fs.stat(localGitDirectory)).isDirectory()) {
135
- return path.join(await fs.realpath(localGitDirectory), REPOSITORY_CURRENT_STATE_FILE);
142
+ return path.join(await fs.realpath(localGitDirectory), snapshotFileName(head));
136
143
  }
137
144
  }
138
145
  catch {
@@ -145,38 +152,163 @@ async function snapshotPath(cwd, signal) {
145
152
  if (result.status !== 0 || result.error || !path.isAbsolute(commonDir)) {
146
153
  throw new Error(`cannot resolve repository common directory: ${result.stderr.trim() || result.error?.message || `exit ${String(result.status)}`}`);
147
154
  }
148
- return path.join(commonDir, REPOSITORY_CURRENT_STATE_FILE);
155
+ return path.join(commonDir, snapshotFileName(head));
156
+ }
157
+ function classifySnapshotFailure(error) {
158
+ const code = error.code;
159
+ const message = error instanceof Error ? error.message.toLowerCase() : "";
160
+ if (code === "ENOENT" || message.includes("no such file or directory"))
161
+ return "missing";
162
+ if (code === "ERR_SQLITE_BUSY" || code === "ERR_SQLITE_LOCKED" || message.includes("database is locked") || message.includes("database is busy"))
163
+ return "transient";
164
+ if (code === "ERR_SQLITE_CORRUPT" || code === "ERR_SQLITE_NOTADB" || message.includes("database disk image is malformed") || message.includes("file is not a database"))
165
+ return "corrupt";
166
+ return "validation-failure";
167
+ }
168
+ function isCantOpen(error) {
169
+ const code = error.code;
170
+ const message = error instanceof Error ? error.message.toLowerCase() : "";
171
+ return code === "ERR_SQLITE_CANTOPEN" || message.includes("unable to open database file");
172
+ }
173
+ async function snapshotExists(databasePath) {
174
+ try {
175
+ await fs.lstat(databasePath);
176
+ return true;
177
+ }
178
+ catch (error) {
179
+ if (error.code === "ENOENT")
180
+ return false;
181
+ return undefined;
182
+ }
149
183
  }
150
- function openValidated(databasePath) {
184
+ async function openValidated(databasePath) {
185
+ const existedBeforeOpen = await snapshotExists(databasePath);
151
186
  let database;
152
187
  try {
153
- database = new DatabaseSync(databasePath);
154
- database.exec("PRAGMA query_only = ON");
188
+ database = new DatabaseSync(databasePath, { readOnly: true });
189
+ database.exec(`PRAGMA busy_timeout = ${SNAPSHOT_READER_BUSY_MS}; PRAGMA query_only = ON`);
155
190
  const rows = database.prepare("SELECT version FROM snapshot_schema WHERE singleton = 1").all();
156
191
  if (rows.length !== 1 || rows[0]?.version !== REPOSITORY_CURRENT_STATE_VERSION) {
157
192
  database.close();
158
- return null;
193
+ return { kind: "validation-failure" };
159
194
  }
160
- return database;
195
+ return { kind: "opened", database };
161
196
  }
162
- catch {
197
+ catch (error) {
163
198
  try {
164
199
  database.close();
165
200
  }
166
- catch { /* discarded below */ }
167
- return null;
201
+ catch { /* open did not yield a usable connection */ }
202
+ if (isCantOpen(error)) {
203
+ const existsAfterFailure = await snapshotExists(databasePath);
204
+ if (existedBeforeOpen === false || existsAfterFailure === false)
205
+ return { kind: "missing", error };
206
+ }
207
+ return { kind: classifySnapshotFailure(error), error };
168
208
  }
169
209
  }
170
- async function discard(databasePath) {
171
- await Promise.all(["", "-wal", "-shm"].map(async (suffix) => {
210
+ async function readSnapshot(databasePath, read) {
211
+ const opened = await openValidated(databasePath);
212
+ if (opened.kind !== "opened")
213
+ return opened;
214
+ try {
215
+ return { kind: "read", value: read(opened.database) };
216
+ }
217
+ catch (error) {
218
+ return { kind: classifySnapshotFailure(error), error };
219
+ }
220
+ finally {
172
221
  try {
173
- await fs.unlink(`${databasePath}${suffix}`);
222
+ opened.database.close();
174
223
  }
175
- catch (error) {
176
- if (error.code !== "ENOENT")
177
- throw error;
224
+ catch { /* validation failure already has its outcome */ }
225
+ }
226
+ }
227
+ export function currentStateWriterCoordination(databasePath) {
228
+ const lockPath = path.join(path.dirname(databasePath), SNAPSHOT_WRITER_LOCK_FILE);
229
+ return {
230
+ path: lockPath,
231
+ async acquire(signal) {
232
+ const lease = await acquireSqliteProcessLifetimeLock({ path: lockPath, signal });
233
+ return { release: () => lease.close() };
234
+ },
235
+ };
236
+ }
237
+ async function withSnapshotWriter(databasePath, signal, operation) {
238
+ const lock = await currentStateWriterCoordination(databasePath).acquire(signal);
239
+ try {
240
+ return await operation();
241
+ }
242
+ finally {
243
+ lock.release();
244
+ }
245
+ }
246
+ function temporarySnapshotPath(databasePath) {
247
+ return `${databasePath}.${process.pid}.${randomUUID()}.tmp`;
248
+ }
249
+ async function discardTemporarySnapshot(databasePath) {
250
+ await fs.unlink(databasePath).catch((error) => {
251
+ if (error.code !== "ENOENT")
252
+ throw error;
253
+ });
254
+ }
255
+ /** Writers alone publish or retire cache bytes; readers have no filesystem mutation path. */
256
+ async function publishImmutableSnapshot(buildPath, databasePath) {
257
+ if (await snapshotExists(databasePath))
258
+ return false;
259
+ await fs.rename(buildPath, databasePath);
260
+ return true;
261
+ }
262
+ /**
263
+ * A filename becomes published only after its complete signed contents validate.
264
+ * This runs under writer coordination; readers merely report an unusable cache.
265
+ */
266
+ async function preparePublicationTarget(databasePath, expectedHead) {
267
+ const snapshot = await readSnapshot(databasePath, (database) => {
268
+ const pin = metadata(database);
269
+ if (!pin || pin.head !== expectedHead)
270
+ throw new Error("snapshot pin does not match its immutable filename");
271
+ validatePinReceipt(database, pin);
272
+ const rows = database.prepare(`
273
+ SELECT contract, status, current_json AS currentJson, fold_json AS foldJson, diagnostics_json AS diagnosticsJson
274
+ FROM snapshot_contracts ORDER BY contract
275
+ `).all();
276
+ const repository = database.prepare("SELECT diagnostics_json AS diagnosticsJson FROM snapshot_repository WHERE singleton = 1").get();
277
+ if (!repository)
278
+ throw new Error("snapshot repository row is missing");
279
+ validateRepositoryReceipts(database, rows.map((row) => row.contract), false, true);
280
+ parseDiagnostics(repository.diagnosticsJson);
281
+ for (const row of rows) {
282
+ parseDiagnostics(row.diagnosticsJson, row.contract);
283
+ validateContractConsistency(database, row, pin);
178
284
  }
179
- }));
285
+ });
286
+ if (snapshot.kind === "read")
287
+ return "published";
288
+ if (snapshot.kind === "missing")
289
+ return "missing";
290
+ if (snapshot.kind === "transient")
291
+ return "transient";
292
+ await fs.unlink(databasePath).catch((error) => {
293
+ if (error.code !== "ENOENT")
294
+ throw error;
295
+ });
296
+ return "missing";
297
+ }
298
+ async function garbageCollectSnapshots(databasePath) {
299
+ const directory = path.dirname(databasePath);
300
+ const retained = path.basename(databasePath);
301
+ let entries;
302
+ try {
303
+ entries = await fs.readdir(directory);
304
+ }
305
+ catch {
306
+ return;
307
+ }
308
+ const cacheFile = /^keiyaku-current-state-(?:[0-9a-f]{40}|[0-9a-f]{64}|empty)\.sqlite(?:\..+\.tmp)?$/;
309
+ await Promise.all(entries
310
+ .filter((entry) => entry !== retained && cacheFile.test(entry))
311
+ .map((entry) => fs.unlink(path.join(directory, entry)).catch(() => undefined)));
180
312
  }
181
313
  function parseJson(text) {
182
314
  return JSON.parse(text);
@@ -204,8 +336,8 @@ function parseCurrent(text, contract) {
204
336
  const value = JSON.parse(text);
205
337
  if (!isRecord(value))
206
338
  throw new Error("snapshot current facts are not an object");
207
- assertClosedKeys(value, ["observedLedgerHead", "contract", "phase", "intent", "receiptSegments", "seal", "bootstrapReplay", "petition", "verdict", "pins", "effectiveScope", "causal", "resources"], "snapshot current facts");
208
- if (value.contract !== contract || typeof value.observedLedgerHead !== "string" || !isRecord(value.resources)) {
339
+ assertClosedKeys(value, ["contractHead", "contract", "phase", "intent", "receiptSegments", "seal", "bootstrapReplay", "petition", "verdict", "pins", "effectiveScope", "causal", "resources"], "snapshot current facts");
340
+ if (value.contract !== contract || typeof value.contractHead !== "string" || !isRecord(value.resources)) {
209
341
  throw new Error("snapshot current facts disagree with their row key");
210
342
  }
211
343
  return value;
@@ -256,11 +388,8 @@ function validateContractConsistency(database, input, pin, summaryOnly = false)
256
388
  const diagnostics = parseDiagnostics(diagnosticRow.diagnosticsJson, input.contract);
257
389
  if ((input.status === "healthy") !== (diagnostics.length === 0))
258
390
  throw new Error("snapshot status disagrees with diagnostics");
259
- if (summaryOnly) {
260
- if (model.observedLedgerHead !== pin.head)
261
- throw new Error("snapshot current summary disagrees with pin");
391
+ if (summaryOnly)
262
392
  return model;
263
- }
264
393
  const fold = JSON.parse(input.foldJson);
265
394
  if (!isRecord(fold) || fold.contract !== input.contract || !Array.isArray(fold.entryObjectIds))
266
395
  throw new Error("snapshot fold is invalid");
@@ -268,7 +397,7 @@ function validateContractConsistency(database, input, pin, summaryOnly = false)
268
397
  if (fold.entryObjectIds.length !== objectIds.length || fold.entryObjectIds.some((oid, index) => oid !== objectIds[index])) {
269
398
  throw new Error("snapshot fold and lifecycle history disagree");
270
399
  }
271
- if (model.observedLedgerHead !== pin.head || model.phase !== fold.phase || model.pins.base !== fold.bind?.data?.base) {
400
+ if (model.contractHead !== objectIds.at(-1) || model.phase !== fold.phase || model.pins.base !== fold.bind?.data?.base) {
272
401
  throw new Error("snapshot current facts disagree with fold or pin");
273
402
  }
274
403
  const typedFold = fold;
@@ -341,17 +470,12 @@ function storedContract(database, contract) {
341
470
  diagnostics: parseDiagnostics(row.diagnosticsJson, contract),
342
471
  };
343
472
  }
344
- function atPin(model, head) {
345
- return model.observedLedgerHead === head ? model : { ...model, observedLedgerHead: head };
473
+ function atPin(model, _head) {
474
+ return model;
346
475
  }
347
476
  export async function readPinnedCurrentContract(cwd, observedHead, contract, signal) {
348
- const databasePath = await snapshotPath(cwd, signal);
349
- const database = openValidated(databasePath);
350
- if (!database) {
351
- await discard(databasePath);
352
- return null;
353
- }
354
- try {
477
+ const databasePath = await repositoryCurrentStateSnapshotPath(cwd, observedHead, signal);
478
+ const snapshot = await readSnapshot(databasePath, (database) => {
355
479
  const pin = metadata(database);
356
480
  if (!pin || pin.head !== observedHead)
357
481
  return null;
@@ -371,53 +495,36 @@ export async function readPinnedCurrentContract(cwd, observedHead, contract, sig
371
495
  return unattributed.length > 0
372
496
  ? { status: "indeterminate", contract, diagnostics: unattributed }
373
497
  : { status: "absent", contract, diagnostics: [] };
374
- }
375
- catch {
376
- database.close();
377
- await discard(databasePath);
378
- return null;
379
- }
380
- finally {
381
- try {
382
- database.close();
383
- }
384
- catch { /* corruption path already discarded */ }
385
- }
498
+ });
499
+ return snapshot.kind === "read" ? snapshot.value : null;
386
500
  }
387
- export async function readRepositoryCurrentStatePin(cwd, signal) {
388
- const databasePath = await snapshotPath(cwd, signal);
389
- const database = openValidated(databasePath);
390
- if (!database) {
391
- await discard(databasePath);
392
- return undefined;
393
- }
394
- try {
501
+ export async function readRepositoryCurrentStatePin(cwd, observedHead, signal) {
502
+ const databasePath = await repositoryCurrentStateSnapshotPath(cwd, observedHead, signal);
503
+ const snapshot = await readSnapshot(databasePath, (database) => {
395
504
  const pin = metadata(database);
396
505
  if (!pin)
397
506
  return undefined;
398
507
  validatePinReceipt(database, pin);
399
508
  return pin.head;
400
- }
401
- catch {
402
- database.close();
403
- await discard(databasePath);
404
- return undefined;
405
- }
406
- finally {
407
- try {
408
- database.close();
409
- }
410
- catch { /* corruption path already discarded */ }
411
- }
509
+ });
510
+ return snapshot.kind === "read" ? snapshot.value : undefined;
511
+ }
512
+ /**
513
+ * The first commit parent is the only inexpensive candidate for a linear
514
+ * incremental predecessor. It is not a current pointer: the caller still
515
+ * validates the cache addressed by that exact OID before using it.
516
+ */
517
+ export async function readRepositoryCurrentStatePredecessor(cwd, observedHead, signal) {
518
+ const result = await runGitProcess(cwd, ["show", "-s", "--format=%P", observedHead], { signal });
519
+ if (result.error?.name === "AbortError")
520
+ throw result.error;
521
+ if (result.status !== 0 || result.error)
522
+ return null;
523
+ return result.stdout.trim().split(/\s+/)[0] || null;
412
524
  }
413
525
  export async function readRepositoryCurrentStateNodeIds(cwd, observedHead, signal) {
414
- const databasePath = await snapshotPath(cwd, signal);
415
- const database = openValidated(databasePath);
416
- if (!database) {
417
- await discard(databasePath);
418
- return null;
419
- }
420
- try {
526
+ const databasePath = await repositoryCurrentStateSnapshotPath(cwd, observedHead, signal);
527
+ const snapshot = await readSnapshot(databasePath, (database) => {
421
528
  const pin = metadata(database);
422
529
  if (!pin || pin.head !== observedHead)
423
530
  return null;
@@ -442,28 +549,13 @@ export async function readRepositoryCurrentStateNodeIds(cwd, observedHead, signa
442
549
  }
443
550
  }
444
551
  return { ledgerParents, entryIntroductions };
445
- }
446
- catch {
447
- database.close();
448
- await discard(databasePath);
449
- return null;
450
- }
451
- finally {
452
- try {
453
- database.close();
454
- }
455
- catch { /* corruption path already discarded */ }
456
- }
552
+ });
553
+ return snapshot.kind === "read" ? snapshot.value : null;
457
554
  }
458
555
  /** Rehydrate the accepted typed topology for publication admission without Git history traversal. */
459
556
  export async function readPinnedAcceptedTraversal(cwd, observedHead, signal) {
460
- const databasePath = await snapshotPath(cwd, signal);
461
- const database = openValidated(databasePath);
462
- if (!database) {
463
- await discard(databasePath);
464
- return null;
465
- }
466
- try {
557
+ const databasePath = await repositoryCurrentStateSnapshotPath(cwd, observedHead, signal);
558
+ const snapshot = await readSnapshot(databasePath, (database) => {
467
559
  const pin = metadata(database);
468
560
  if (!pin || pin.head !== observedHead || !pin.objectFormat || !pin.emptyTree)
469
561
  return null;
@@ -520,27 +612,12 @@ export async function readPinnedAcceptedTraversal(cwd, observedHead, signal) {
520
612
  };
521
613
  visit(observedHead);
522
614
  return { head: observedHead, coordinates: { objectFormat: pin.objectFormat, objectIdLength: pin.objectFormat === "sha1" ? 40 : 64, emptyTree: pin.emptyTree }, nodes, order, diseases: [] };
523
- }
524
- catch {
525
- database.close();
526
- await discard(databasePath);
527
- return null;
528
- }
529
- finally {
530
- try {
531
- database.close();
532
- }
533
- catch { /* corruption path already discarded */ }
534
- }
615
+ });
616
+ return snapshot.kind === "read" ? snapshot.value : null;
535
617
  }
536
618
  export async function readPinnedRepositoryLifecycle(cwd, observedHead, signal, options = {}) {
537
- const databasePath = await snapshotPath(cwd, signal);
538
- const database = openValidated(databasePath);
539
- if (!database) {
540
- await discard(databasePath);
541
- return null;
542
- }
543
- try {
619
+ const databasePath = await repositoryCurrentStateSnapshotPath(cwd, observedHead, signal);
620
+ const snapshot = await readSnapshot(databasePath, (database) => {
544
621
  const pin = metadata(database);
545
622
  if (!pin || pin.head !== observedHead)
546
623
  return null;
@@ -554,7 +631,7 @@ export async function readPinnedRepositoryLifecycle(cwd, observedHead, signal, o
554
631
  validatePinReceipt(database, pin);
555
632
  validateRepositoryReceipts(database, rows.map((row) => row.contract), options.summaryOnly === true, options.includeTopology !== false);
556
633
  return {
557
- observedLedgerHead: pin.head,
634
+ observedLedgerHead: pin.head === null ? null : repositoryLedgerHead(pin.head),
558
635
  objectFormat: pin.objectFormat,
559
636
  emptyTree: pin.emptyTree,
560
637
  contracts: rows.flatMap((row) => {
@@ -575,279 +652,317 @@ export async function readPinnedRepositoryLifecycle(cwd, observedHead, signal, o
575
652
  : database.prepare("SELECT node_json AS nodeJson FROM snapshot_topology ORDER BY rowid").all().map((row) => parseJson(row.nodeJson)),
576
653
  },
577
654
  };
578
- }
579
- catch {
580
- database.close();
581
- await discard(databasePath);
582
- return null;
583
- }
584
- finally {
585
- try {
586
- database.close();
587
- }
588
- catch { /* corruption path already discarded */ }
589
- }
655
+ });
656
+ return snapshot.kind === "read" ? snapshot.value : null;
590
657
  }
591
658
  export async function publishRepositoryCurrentState(cwd, read, options) {
592
- const databasePath = await snapshotPath(cwd, options.signal);
659
+ const databasePath = await repositoryCurrentStateSnapshotPath(cwd, read.observedLedgerHead, options.signal);
593
660
  await fs.mkdir(path.dirname(databasePath), { recursive: true });
594
661
  if (await options.confirmHead() !== read.observedLedgerHead)
595
662
  return false;
596
- let database;
597
- try {
598
- database = new DatabaseSync(databasePath);
599
- database.exec(`PRAGMA busy_timeout = ${SNAPSHOT_WRITE_BUSY_MS}; PRAGMA synchronous = FULL; PRAGMA journal_mode = DELETE; BEGIN IMMEDIATE`);
600
- database.exec("DROP TABLE IF EXISTS snapshot_integrity; DROP TABLE IF EXISTS snapshot_resources; DROP TABLE IF EXISTS snapshot_topology; DROP TABLE IF EXISTS snapshot_summaries; DROP TABLE IF EXISTS snapshot_history; DROP TABLE IF EXISTS snapshot_contracts; DROP TABLE IF EXISTS snapshot_repository; DROP TABLE IF EXISTS snapshot_pin; DROP TABLE IF EXISTS snapshot_schema");
601
- database.exec(SCHEMA);
602
- database.prepare("INSERT INTO snapshot_schema(singleton, version) VALUES (1, ?)").run(REPOSITORY_CURRENT_STATE_VERSION);
603
- database.prepare("INSERT INTO snapshot_pin(singleton, ledger_head, object_format, empty_tree) VALUES (1, ?, ?, ?)").run(read.observedLedgerHead, read.objectFormat, read.emptyTree);
604
- const insert = database.prepare("INSERT INTO snapshot_contracts(contract, status, current_json, fold_json, diagnostics_json) VALUES (?, ?, ?, ?, ?)");
605
- const insertHistory = database.prepare("INSERT INTO snapshot_history(contract, position, object_id, entry_json) VALUES (?, ?, ?, ?)");
606
- const insertSummary = database.prepare("INSERT INTO snapshot_summaries(contract, history_json) VALUES (?, ?)");
607
- const isolated = new Set(read.isolatedContracts);
608
- const models = new Map(read.contracts.map((model) => [model.contract, model]));
609
- for (const contract of new Set([...models.keys(), ...isolated])) {
610
- const model = models.get(contract) ?? null;
611
- const diagnostics = read.diagnostics.filter((disease) => disease.contract === contract);
612
- const status = isolated.has(contract) ? "isolated" : diagnostics.length > 0 ? "diseased" : "healthy";
613
- const folded = options.fold.contracts.get(contract) ?? null;
614
- const current = model === null ? null : Object.fromEntries(Object.entries(model).filter(([key]) => key !== "history"));
615
- insert.run(contract, status, current === null ? null : JSON.stringify(current), folded === null ? null : JSON.stringify(folded), JSON.stringify(diagnostics));
616
- model?.history.forEach((row, position) => insertHistory.run(contract, position, row.objectId, JSON.stringify(row.entry)));
617
- if (model)
618
- insertSummary.run(contract, JSON.stringify(summaryHistory(model.history)));
619
- }
620
- database.prepare("INSERT INTO snapshot_repository(singleton, diagnostics_json) VALUES (1, ?)").run(JSON.stringify(read.diagnostics.filter((disease) => disease.contract === undefined)));
621
- const insertTopology = database.prepare("INSERT INTO snapshot_topology(object_id, node_json) VALUES (?, ?)");
622
- for (const node of read.topology.nodes)
623
- insertTopology.run(node.objectId, JSON.stringify(node));
624
- const insertResource = database.prepare("INSERT INTO snapshot_resources(kind, resource_key, contract) VALUES (?, ?, ?)");
625
- for (const model of read.contracts) {
626
- if (model.resources.worktree?.state === "held")
627
- insertResource.run("worktree", model.resources.worktree.place, model.contract);
628
- for (const task of model.resources.tasks)
629
- if (task.state === "held")
630
- insertResource.run("task", task.taskId, model.contract);
631
- }
632
- writeIntegrityReceipts(database);
633
- options.beforeCommit?.();
634
- database.exec("COMMIT");
635
- database.close();
636
- return true;
637
- }
638
- catch (error) {
639
- try {
640
- database.exec("ROLLBACK");
641
- }
642
- catch { /* database may not have opened */ }
663
+ return withSnapshotWriter(databasePath, options.signal, async () => {
664
+ if (await options.confirmHead() !== read.observedLedgerHead)
665
+ return false;
666
+ const existing = await preparePublicationTarget(databasePath, read.observedLedgerHead);
667
+ if (existing === "published")
668
+ return true;
669
+ if (existing === "transient")
670
+ return false;
671
+ const buildPath = temporarySnapshotPath(databasePath);
672
+ let database;
643
673
  try {
674
+ await fs.unlink(buildPath).catch((error) => {
675
+ if (error.code !== "ENOENT")
676
+ throw error;
677
+ });
678
+ database = new DatabaseSync(buildPath);
679
+ database.exec("PRAGMA synchronous = FULL; PRAGMA journal_mode = DELETE; BEGIN IMMEDIATE");
680
+ database.exec("DROP TABLE IF EXISTS snapshot_integrity; DROP TABLE IF EXISTS snapshot_resources; DROP TABLE IF EXISTS snapshot_topology; DROP TABLE IF EXISTS snapshot_summaries; DROP TABLE IF EXISTS snapshot_history; DROP TABLE IF EXISTS snapshot_contracts; DROP TABLE IF EXISTS snapshot_repository; DROP TABLE IF EXISTS snapshot_pin; DROP TABLE IF EXISTS snapshot_schema");
681
+ database.exec(SCHEMA);
682
+ database.prepare("INSERT INTO snapshot_schema(singleton, version) VALUES (1, ?)").run(REPOSITORY_CURRENT_STATE_VERSION);
683
+ database.prepare("INSERT INTO snapshot_pin(singleton, ledger_head, object_format, empty_tree) VALUES (1, ?, ?, ?)").run(read.observedLedgerHead, read.objectFormat, read.emptyTree);
684
+ const insert = database.prepare("INSERT INTO snapshot_contracts(contract, status, current_json, fold_json, diagnostics_json) VALUES (?, ?, ?, ?, ?)");
685
+ const insertHistory = database.prepare("INSERT INTO snapshot_history(contract, position, object_id, entry_json) VALUES (?, ?, ?, ?)");
686
+ const insertSummary = database.prepare("INSERT INTO snapshot_summaries(contract, history_json) VALUES (?, ?)");
687
+ const isolated = new Set(read.isolatedContracts);
688
+ const models = new Map(read.contracts.map((model) => [model.contract, model]));
689
+ for (const contract of new Set([...models.keys(), ...isolated])) {
690
+ const model = models.get(contract) ?? null;
691
+ const diagnostics = read.diagnostics.filter((disease) => disease.contract === contract);
692
+ const status = isolated.has(contract) ? "isolated" : diagnostics.length > 0 ? "diseased" : "healthy";
693
+ const folded = options.fold.contracts.get(contract) ?? null;
694
+ const current = model === null ? null : Object.fromEntries(Object.entries(model).filter(([key]) => key !== "history"));
695
+ insert.run(contract, status, current === null ? null : JSON.stringify(current), folded === null ? null : JSON.stringify(folded), JSON.stringify(diagnostics));
696
+ model?.history.forEach((row, position) => insertHistory.run(contract, position, row.objectId, JSON.stringify(row.entry)));
697
+ if (model)
698
+ insertSummary.run(contract, JSON.stringify(summaryHistory(model.history)));
699
+ }
700
+ database.prepare("INSERT INTO snapshot_repository(singleton, diagnostics_json) VALUES (1, ?)").run(JSON.stringify(read.diagnostics.filter((disease) => disease.contract === undefined)));
701
+ const insertTopology = database.prepare("INSERT INTO snapshot_topology(object_id, node_json) VALUES (?, ?)");
702
+ for (const node of read.topology.nodes)
703
+ insertTopology.run(node.objectId, JSON.stringify(node));
704
+ const insertResource = database.prepare("INSERT INTO snapshot_resources(kind, resource_key, contract) VALUES (?, ?, ?)");
705
+ for (const model of read.contracts) {
706
+ if (model.resources.worktree?.state === "held")
707
+ insertResource.run("worktree", model.resources.worktree.place, model.contract);
708
+ for (const task of model.resources.tasks)
709
+ if (task.state === "held")
710
+ insertResource.run("task", task.taskId, model.contract);
711
+ }
712
+ writeIntegrityReceipts(database);
713
+ await options.beforeCommit?.();
714
+ database.exec("COMMIT");
644
715
  database.close();
716
+ // No reader can be holding this new name. An existing same-head file is
717
+ // convergence, never a target for replacement.
718
+ await publishImmutableSnapshot(buildPath, databasePath);
719
+ await garbageCollectSnapshots(databasePath);
720
+ return true;
645
721
  }
646
- catch { /* database may not have opened */ }
647
- throw error;
648
- }
722
+ catch (error) {
723
+ try {
724
+ database.exec("ROLLBACK");
725
+ }
726
+ catch { /* database may not have opened */ }
727
+ try {
728
+ database.close();
729
+ }
730
+ catch { /* database may not have opened */ }
731
+ await discardTemporarySnapshot(buildPath);
732
+ throw error;
733
+ }
734
+ });
649
735
  }
650
736
  export async function advanceRepositoryCurrentState(cwd, pinnedHead, observedHead, segment, options) {
651
- const databasePath = await snapshotPath(cwd, options.signal);
737
+ const pinnedDatabasePath = await repositoryCurrentStateSnapshotPath(cwd, pinnedHead, options.signal);
738
+ const databasePath = await repositoryCurrentStateSnapshotPath(cwd, observedHead, options.signal);
652
739
  if (await options.confirmHead() !== observedHead)
653
740
  return false;
654
- const database = openValidated(databasePath);
655
- if (!database)
656
- return false;
657
- try {
658
- database.exec(`PRAGMA query_only = OFF; PRAGMA busy_timeout = ${SNAPSHOT_WRITE_BUSY_MS}; PRAGMA synchronous = FULL; BEGIN IMMEDIATE`);
659
- const pin = metadata(database);
660
- if (!pin || pin.head !== pinnedHead) {
661
- database.exec("ROLLBACK");
741
+ return withSnapshotWriter(databasePath, options.signal, async () => {
742
+ if (await options.confirmHead() !== observedHead)
662
743
  return false;
663
- }
664
- validatePinReceipt(database, pin);
665
- assertReceipt(database, "topology", topologyDigest(database));
666
- assertReceipt(database, "resources", resourcesDigest(database));
667
- for (const contract of new Set(segment.transactions.map((transaction) => transaction.entries[0]?.entry.contract).filter((contract) => contract !== undefined))) {
668
- if (database.prepare("SELECT 1 FROM snapshot_contracts WHERE contract = ?").get(contract))
669
- validateContractReceipt(database, contract, false);
670
- }
671
- const acceptedTopology = new Set(database.prepare("SELECT object_id AS objectId FROM snapshot_topology").all().map((row) => row.objectId));
672
- const pendingJoins = new Map(segment.joins.map((join) => [join.objectId, join]));
673
- const promoteReadyJoins = () => {
674
- let promoted;
675
- do {
676
- promoted = false;
677
- for (const join of pendingJoins.values()) {
678
- if (!join.parents.every((parent) => acceptedTopology.has(parent)))
679
- continue;
680
- database.prepare("INSERT INTO snapshot_topology(object_id, node_json) VALUES (?, ?)").run(join.objectId, JSON.stringify({
681
- objectId: join.objectId,
682
- type: "join",
683
- ledgerParents: join.parents,
684
- }));
685
- acceptedTopology.add(join.objectId);
686
- pendingJoins.delete(join.objectId);
687
- promoted = true;
688
- }
689
- } while (promoted);
690
- };
691
- for (const transaction of segment.transactions) {
692
- promoteReadyJoins();
693
- if ((transaction.predecessor !== null && !acceptedTopology.has(transaction.predecessor)) || transaction.entries.length === 0) {
744
+ const existing = await preparePublicationTarget(databasePath, observedHead);
745
+ if (existing === "published")
746
+ return true;
747
+ if (existing === "transient")
748
+ return false;
749
+ const buildPath = temporarySnapshotPath(databasePath);
750
+ let database;
751
+ try {
752
+ await fs.copyFile(pinnedDatabasePath, buildPath);
753
+ database = new DatabaseSync(buildPath);
754
+ database.exec("PRAGMA synchronous = FULL; BEGIN IMMEDIATE");
755
+ const versions = database.prepare("SELECT version FROM snapshot_schema WHERE singleton = 1").all();
756
+ if (versions.length !== 1 || versions[0]?.version !== REPOSITORY_CURRENT_STATE_VERSION) {
694
757
  database.exec("ROLLBACK");
758
+ database.close();
695
759
  return false;
696
760
  }
697
- if (transaction.entries.some((entry) => database.prepare("SELECT 1 FROM snapshot_history WHERE object_id = ?").get(entry.objectId) !== undefined)) {
761
+ }
762
+ catch {
763
+ try {
764
+ database.close();
765
+ }
766
+ catch { /* replacement recovery owns unusable bytes */ }
767
+ return false;
768
+ }
769
+ try {
770
+ const pin = metadata(database);
771
+ if (!pin || pin.head !== pinnedHead) {
698
772
  database.exec("ROLLBACK");
699
773
  return false;
700
774
  }
701
- const contract = transaction.entries[0].entry.contract;
702
- const row = database.prepare("SELECT status, current_json AS currentJson, fold_json AS foldJson, diagnostics_json AS diagnosticsJson FROM snapshot_contracts WHERE contract = ?").get(contract);
703
- let state = row?.foldJson ? parseJson(row.foldJson) : undefined;
704
- const priorModel = row?.currentJson ? currentModel(database, contract, row.currentJson) : null;
705
- const first = transaction.entries[0];
706
- if (first.entry.kind === "bind" && (state || row)) {
707
- const diagnostics = [...(row ? parseJson(row.diagnosticsJson) : []), {
708
- objectId: first.objectId,
709
- code: "same-contract-divergence",
710
- contract,
711
- detail: "incremental bind diverges from an already admitted contract",
712
- }];
713
- database.prepare("UPDATE snapshot_contracts SET status = 'isolated', current_json = NULL, fold_json = NULL, diagnostics_json = ? WHERE contract = ?").run(JSON.stringify(diagnostics), contract);
775
+ validatePinReceipt(database, pin);
776
+ assertReceipt(database, "topology", topologyDigest(database));
777
+ assertReceipt(database, "resources", resourcesDigest(database));
778
+ for (const contract of new Set(segment.transactions.map((transaction) => transaction.entries[0]?.entry.contract).filter((contract) => contract !== undefined))) {
779
+ if (database.prepare("SELECT 1 FROM snapshot_contracts WHERE contract = ?").get(contract))
780
+ validateContractReceipt(database, contract, false);
714
781
  }
715
- else {
716
- if (first.entry.kind !== "bind" && (!state || first.causalParent !== state.entryObjectIds.at(-1))) {
782
+ const acceptedTopology = new Set(database.prepare("SELECT object_id AS objectId FROM snapshot_topology").all().map((row) => row.objectId));
783
+ const pendingJoins = new Map(segment.joins.map((join) => [join.objectId, join]));
784
+ const promoteReadyJoins = () => {
785
+ let promoted;
786
+ do {
787
+ promoted = false;
788
+ for (const join of pendingJoins.values()) {
789
+ if (!join.parents.every((parent) => acceptedTopology.has(parent)))
790
+ continue;
791
+ database.prepare("INSERT INTO snapshot_topology(object_id, node_json) VALUES (?, ?)").run(join.objectId, JSON.stringify({
792
+ objectId: join.objectId,
793
+ type: "join",
794
+ ledgerParents: join.parents,
795
+ }));
796
+ acceptedTopology.add(join.objectId);
797
+ pendingJoins.delete(join.objectId);
798
+ promoted = true;
799
+ }
800
+ } while (promoted);
801
+ };
802
+ for (const transaction of segment.transactions) {
803
+ promoteReadyJoins();
804
+ if ((transaction.predecessor !== null && !acceptedTopology.has(transaction.predecessor)) || transaction.entries.length === 0) {
717
805
  database.exec("ROLLBACK");
718
806
  return false;
719
807
  }
720
- let staged = state === undefined ? undefined : structuredClone(state);
721
- let disease;
722
- const basePosition = staged?.entryObjectIds.length ?? 0;
723
- for (let index = 0; index < transaction.entries.length; index += 1) {
724
- const entry = transaction.entries[index];
725
- const applied = applyFoldEntry(staged, { objectId: entry.objectId, entry: entry.entry, position: basePosition + index });
726
- staged = applied.state;
727
- if (applied.disease) {
728
- disease = { ...applied.disease, transactionObjectId: transaction.objectId };
729
- break;
730
- }
808
+ if (transaction.entries.some((entry) => database.prepare("SELECT 1 FROM snapshot_history WHERE object_id = ?").get(entry.objectId) !== undefined)) {
809
+ database.exec("ROLLBACK");
810
+ return false;
731
811
  }
732
- if (disease || !staged) {
733
- const diagnostics = [...(row ? parseJson(row.diagnosticsJson) : []), ...(disease ? [disease] : [])];
734
- if (row)
735
- database.prepare("UPDATE snapshot_contracts SET status = 'diseased', diagnostics_json = ? WHERE contract = ?").run(JSON.stringify(diagnostics), contract);
736
- else
737
- database.prepare("INSERT INTO snapshot_contracts(contract, status, current_json, fold_json, diagnostics_json) VALUES (?, 'isolated', NULL, NULL, ?)").run(contract, JSON.stringify(diagnostics));
812
+ const contract = transaction.entries[0].entry.contract;
813
+ const row = database.prepare("SELECT status, current_json AS currentJson, fold_json AS foldJson, diagnostics_json AS diagnosticsJson FROM snapshot_contracts WHERE contract = ?").get(contract);
814
+ let state = row?.foldJson ? parseJson(row.foldJson) : undefined;
815
+ const priorModel = row?.currentJson ? currentModel(database, contract, row.currentJson) : null;
816
+ const first = transaction.entries[0];
817
+ if (first.entry.kind === "bind" && (state || row)) {
818
+ const diagnostics = [...(row ? parseJson(row.diagnosticsJson) : []), {
819
+ objectId: first.objectId,
820
+ code: "same-contract-divergence",
821
+ contract,
822
+ detail: "incremental bind diverges from an already admitted contract",
823
+ }];
824
+ database.prepare("UPDATE snapshot_contracts SET status = 'isolated', current_json = NULL, fold_json = NULL, diagnostics_json = ? WHERE contract = ?").run(JSON.stringify(diagnostics), contract);
738
825
  }
739
826
  else {
740
- if (first.entry.kind === "bind" && (first.entry.data.after?.length ?? 0) > 0) {
741
- const unavailable = first.entry.data.after.filter((dependency) => {
742
- const prerequisite = database.prepare("SELECT status, fold_json AS foldJson FROM snapshot_contracts WHERE contract = ?").get(dependency);
743
- if (!prerequisite || prerequisite.status !== "healthy" || prerequisite.foldJson === null)
744
- return true;
745
- return parseJson(prerequisite.foldJson).terminalObjectId === null;
746
- });
747
- if (unavailable.length > 0) {
748
- const diagnostic = {
749
- objectId: first.objectId,
750
- code: "causal-prerequisite",
751
- contract,
752
- detail: `after prerequisites are not admitted terminal contracts: ${unavailable.join(", ")}`,
753
- };
754
- database.prepare(`
827
+ if (first.entry.kind !== "bind" && (!state || first.causalParent !== state.entryObjectIds.at(-1))) {
828
+ database.exec("ROLLBACK");
829
+ return false;
830
+ }
831
+ let staged = state === undefined ? undefined : structuredClone(state);
832
+ let disease;
833
+ const basePosition = staged?.entryObjectIds.length ?? 0;
834
+ for (let index = 0; index < transaction.entries.length; index += 1) {
835
+ const entry = transaction.entries[index];
836
+ const applied = applyFoldEntry(staged, { objectId: entry.objectId, entry: entry.entry, position: basePosition + index });
837
+ staged = applied.state;
838
+ if (applied.disease) {
839
+ disease = { ...applied.disease, transactionObjectId: transaction.objectId };
840
+ break;
841
+ }
842
+ }
843
+ if (disease || !staged) {
844
+ const diagnostics = [...(row ? parseJson(row.diagnosticsJson) : []), ...(disease ? [disease] : [])];
845
+ if (row)
846
+ database.prepare("UPDATE snapshot_contracts SET status = 'diseased', diagnostics_json = ? WHERE contract = ?").run(JSON.stringify(diagnostics), contract);
847
+ else
848
+ database.prepare("INSERT INTO snapshot_contracts(contract, status, current_json, fold_json, diagnostics_json) VALUES (?, 'isolated', NULL, NULL, ?)").run(contract, JSON.stringify(diagnostics));
849
+ }
850
+ else {
851
+ if (first.entry.kind === "bind" && (first.entry.data.after?.length ?? 0) > 0) {
852
+ const unavailable = first.entry.data.after.filter((dependency) => {
853
+ const prerequisite = database.prepare("SELECT status, fold_json AS foldJson FROM snapshot_contracts WHERE contract = ?").get(dependency);
854
+ if (!prerequisite || prerequisite.status !== "healthy" || prerequisite.foldJson === null)
855
+ return true;
856
+ return false;
857
+ });
858
+ if (unavailable.length > 0) {
859
+ const diagnostic = {
860
+ objectId: first.objectId,
861
+ code: "causal-prerequisite",
862
+ contract,
863
+ detail: `after prerequisites are not admitted healthy contracts: ${unavailable.join(", ")}`,
864
+ };
865
+ database.prepare(`
755
866
  INSERT INTO snapshot_contracts(contract, status, current_json, fold_json, diagnostics_json)
756
867
  VALUES (?, 'isolated', NULL, NULL, ?)
757
868
  ON CONFLICT(contract) DO UPDATE SET status = 'isolated', current_json = NULL, fold_json = NULL, diagnostics_json = excluded.diagnostics_json
758
869
  `).run(contract, JSON.stringify([diagnostic]));
759
- acceptedTopology.add(transaction.objectId);
760
- database.prepare("INSERT INTO snapshot_topology(object_id, node_json) VALUES (?, ?)").run(transaction.objectId, JSON.stringify({
761
- objectId: transaction.objectId,
762
- type: "transaction",
763
- ledgerParents: transaction.predecessor === null ? [] : [transaction.predecessor],
764
- verb: transaction.transaction.verb,
765
- contract,
766
- entryObjectIds: transaction.entries.map((entry) => entry.objectId),
767
- finalEntryObjectId: transaction.entries.at(-1).objectId,
768
- }));
769
- continue;
870
+ acceptedTopology.add(transaction.objectId);
871
+ database.prepare("INSERT INTO snapshot_topology(object_id, node_json) VALUES (?, ?)").run(transaction.objectId, JSON.stringify({
872
+ objectId: transaction.objectId,
873
+ type: "transaction",
874
+ ledgerParents: transaction.predecessor === null ? [] : [transaction.predecessor],
875
+ verb: transaction.transaction.verb,
876
+ contract,
877
+ entryObjectIds: transaction.entries.map((entry) => entry.objectId),
878
+ finalEntryObjectId: transaction.entries.at(-1).objectId,
879
+ }));
880
+ continue;
881
+ }
770
882
  }
771
- }
772
- const history = [...(priorModel?.history ?? []), ...transaction.entries.map((entry) => ({ objectId: entry.objectId, entry: entry.entry }))];
773
- const model = await options.materialize(staged, history);
774
- database.prepare(`
883
+ const history = [...(priorModel?.history ?? []), ...transaction.entries.map((entry) => ({ objectId: entry.objectId, entry: entry.entry }))];
884
+ const model = await options.materialize(staged, history);
885
+ database.prepare(`
775
886
  INSERT INTO snapshot_contracts(contract, status, current_json, fold_json, diagnostics_json)
776
887
  VALUES (?, 'healthy', ?, ?, '[]')
777
888
  ON CONFLICT(contract) DO UPDATE SET status = 'healthy', current_json = excluded.current_json, fold_json = excluded.fold_json, diagnostics_json = '[]'
778
889
  `).run(contract, JSON.stringify(Object.fromEntries(Object.entries(model).filter(([key]) => key !== "history"))), JSON.stringify(staged));
779
- database.prepare("DELETE FROM snapshot_history WHERE contract = ?").run(contract);
780
- const insertHistory = database.prepare("INSERT INTO snapshot_history(contract, position, object_id, entry_json) VALUES (?, ?, ?, ?)");
781
- model.history.forEach((historyRow, position) => insertHistory.run(contract, position, historyRow.objectId, JSON.stringify(historyRow.entry)));
782
- database.prepare("INSERT INTO snapshot_summaries(contract, history_json) VALUES (?, ?) ON CONFLICT(contract) DO UPDATE SET history_json = excluded.history_json").run(contract, JSON.stringify(summaryHistory(model.history)));
783
- database.prepare("DELETE FROM snapshot_resources WHERE contract = ?").run(contract);
784
- const insertResource = database.prepare("INSERT INTO snapshot_resources(kind, resource_key, contract) VALUES (?, ?, ?)");
785
- if (model.resources.worktree?.state === "held")
786
- insertResource.run("worktree", model.resources.worktree.place, contract);
787
- for (const task of model.resources.tasks)
788
- if (task.state === "held")
789
- insertResource.run("task", task.taskId, contract);
790
- const collisions = [
791
- ...(model.resources.worktree?.state === "held" ? [{ kind: "worktree", key: model.resources.worktree.place }] : []),
792
- ...model.resources.tasks.filter((task) => task.state === "held").map((task) => ({ kind: "task", key: task.taskId })),
793
- ];
794
- for (const resource of collisions) {
795
- const owners = database.prepare("SELECT contract FROM snapshot_resources WHERE kind = ? AND resource_key = ?").all(resource.kind, resource.key);
796
- if (owners.length < 2)
797
- continue;
798
- for (const owner of owners) {
799
- const ownerRow = database.prepare("SELECT diagnostics_json AS diagnosticsJson FROM snapshot_contracts WHERE contract = ?").get(owner.contract);
800
- const diagnostic = {
801
- objectId: first.objectId,
802
- code: resource.kind === "worktree" ? "worktree-place-collision" : "task-ownership-collision",
803
- contract: owner.contract,
804
- detail: `${resource.kind} ${resource.key} is held by ${owners.map((candidate) => candidate.contract).sort().join(", ")}`,
805
- };
806
- database.prepare("UPDATE snapshot_contracts SET status = 'isolated', current_json = NULL, fold_json = NULL, diagnostics_json = ? WHERE contract = ?").run(JSON.stringify([...parseJson(ownerRow.diagnosticsJson), diagnostic]), owner.contract);
890
+ database.prepare("DELETE FROM snapshot_history WHERE contract = ?").run(contract);
891
+ const insertHistory = database.prepare("INSERT INTO snapshot_history(contract, position, object_id, entry_json) VALUES (?, ?, ?, ?)");
892
+ model.history.forEach((historyRow, position) => insertHistory.run(contract, position, historyRow.objectId, JSON.stringify(historyRow.entry)));
893
+ database.prepare("INSERT INTO snapshot_summaries(contract, history_json) VALUES (?, ?) ON CONFLICT(contract) DO UPDATE SET history_json = excluded.history_json").run(contract, JSON.stringify(summaryHistory(model.history)));
894
+ database.prepare("DELETE FROM snapshot_resources WHERE contract = ?").run(contract);
895
+ const insertResource = database.prepare("INSERT INTO snapshot_resources(kind, resource_key, contract) VALUES (?, ?, ?)");
896
+ if (model.resources.worktree?.state === "held")
897
+ insertResource.run("worktree", model.resources.worktree.place, contract);
898
+ for (const task of model.resources.tasks)
899
+ if (task.state === "held")
900
+ insertResource.run("task", task.taskId, contract);
901
+ const collisions = [
902
+ ...(model.resources.worktree?.state === "held" ? [{ kind: "worktree", key: model.resources.worktree.place }] : []),
903
+ ...model.resources.tasks.filter((task) => task.state === "held").map((task) => ({ kind: "task", key: task.taskId })),
904
+ ];
905
+ for (const resource of collisions) {
906
+ const owners = database.prepare("SELECT contract FROM snapshot_resources WHERE kind = ? AND resource_key = ?").all(resource.kind, resource.key);
907
+ if (owners.length < 2)
908
+ continue;
909
+ for (const owner of owners) {
910
+ const ownerRow = database.prepare("SELECT diagnostics_json AS diagnosticsJson FROM snapshot_contracts WHERE contract = ?").get(owner.contract);
911
+ const diagnostic = {
912
+ objectId: first.objectId,
913
+ code: resource.kind === "worktree" ? "worktree-place-collision" : "task-ownership-collision",
914
+ contract: owner.contract,
915
+ detail: `${resource.kind} ${resource.key} is held by ${owners.map((candidate) => candidate.contract).sort().join(", ")}`,
916
+ };
917
+ database.prepare("UPDATE snapshot_contracts SET status = 'isolated', current_json = NULL, fold_json = NULL, diagnostics_json = ? WHERE contract = ?").run(JSON.stringify([...parseJson(ownerRow.diagnosticsJson), diagnostic]), owner.contract);
918
+ }
807
919
  }
808
920
  }
809
921
  }
810
- }
811
- const insertLifecycleTopology = database.prepare("INSERT OR IGNORE INTO snapshot_topology(object_id, node_json) VALUES (?, ?)");
812
- for (const entry of transaction.entries)
813
- insertLifecycleTopology.run(entry.objectId, JSON.stringify({
814
- objectId: entry.objectId,
815
- type: "lifecycle",
816
- ledgerParents: entry.causalParent === null ? [] : [entry.causalParent],
922
+ const insertLifecycleTopology = database.prepare("INSERT OR IGNORE INTO snapshot_topology(object_id, node_json) VALUES (?, ?)");
923
+ for (const entry of transaction.entries)
924
+ insertLifecycleTopology.run(entry.objectId, JSON.stringify({
925
+ objectId: entry.objectId,
926
+ type: "lifecycle",
927
+ ledgerParents: entry.causalParent === null ? [] : [entry.causalParent],
928
+ }));
929
+ database.prepare("INSERT INTO snapshot_topology(object_id, node_json) VALUES (?, ?)").run(transaction.objectId, JSON.stringify({
930
+ objectId: transaction.objectId,
931
+ type: "transaction",
932
+ ledgerParents: transaction.predecessor === null ? [] : [transaction.predecessor],
933
+ verb: transaction.transaction.verb,
934
+ contract,
935
+ entryObjectIds: transaction.entries.map((entry) => entry.objectId),
936
+ finalEntryObjectId: transaction.entries.at(-1).objectId,
817
937
  }));
818
- database.prepare("INSERT INTO snapshot_topology(object_id, node_json) VALUES (?, ?)").run(transaction.objectId, JSON.stringify({
819
- objectId: transaction.objectId,
820
- type: "transaction",
821
- ledgerParents: transaction.predecessor === null ? [] : [transaction.predecessor],
822
- verb: transaction.transaction.verb,
823
- contract,
824
- entryObjectIds: transaction.entries.map((entry) => entry.objectId),
825
- finalEntryObjectId: transaction.entries.at(-1).objectId,
826
- }));
827
- acceptedTopology.add(transaction.objectId);
938
+ acceptedTopology.add(transaction.objectId);
939
+ }
940
+ promoteReadyJoins();
941
+ if (pendingJoins.size > 0 || !acceptedTopology.has(observedHead)) {
942
+ database.exec("ROLLBACK");
943
+ return false;
944
+ }
945
+ database.prepare("UPDATE snapshot_pin SET ledger_head = ? WHERE singleton = 1").run(observedHead);
946
+ writeIntegrityReceipts(database);
947
+ database.exec("COMMIT");
948
+ database.close();
949
+ await publishImmutableSnapshot(buildPath, databasePath);
950
+ await garbageCollectSnapshots(databasePath);
951
+ return true;
828
952
  }
829
- promoteReadyJoins();
830
- if (pendingJoins.size > 0 || !acceptedTopology.has(observedHead)) {
831
- database.exec("ROLLBACK");
953
+ catch {
954
+ try {
955
+ database.exec("ROLLBACK");
956
+ }
957
+ catch { /* handled by recovery */ }
832
958
  return false;
833
959
  }
834
- database.prepare("UPDATE snapshot_contracts SET current_json = json_set(current_json, '$.observedLedgerHead', ?) WHERE current_json IS NOT NULL").run(observedHead);
835
- database.prepare("UPDATE snapshot_pin SET ledger_head = ? WHERE singleton = 1").run(observedHead);
836
- writeIntegrityReceipts(database);
837
- database.exec("COMMIT");
838
- return true;
839
- }
840
- catch {
841
- try {
842
- database.exec("ROLLBACK");
960
+ finally {
961
+ try {
962
+ database.close();
963
+ }
964
+ catch { /* database may not have opened */ }
965
+ await discardTemporarySnapshot(buildPath);
843
966
  }
844
- catch { /* handled by recovery */ }
845
- return false;
846
- }
847
- finally {
848
- database.close();
849
- }
850
- }
851
- export async function discardRepositoryCurrentState(cwd, signal) {
852
- await discard(await snapshotPath(cwd, signal));
967
+ });
853
968
  }