release-skill 0.1.1

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 (125) hide show
  1. package/.agents/plugins/marketplace.json +23 -0
  2. package/.claude-plugin/marketplace.json +16 -0
  3. package/.claude-plugin/plugin.json +10 -0
  4. package/.codex-plugin/plugin.json +26 -0
  5. package/CHANGELOG.md +68 -0
  6. package/CODE_OF_CONDUCT.md +76 -0
  7. package/CONTRIBUTING.md +49 -0
  8. package/INSTALL.md +182 -0
  9. package/LICENSE +21 -0
  10. package/NOTICE +25 -0
  11. package/README.md +501 -0
  12. package/README.zh-CN.md +463 -0
  13. package/SECURITY.md +48 -0
  14. package/adapters/claude/.claude-plugin/marketplace.json +16 -0
  15. package/adapters/claude/.claude-plugin/plugin.json +10 -0
  16. package/adapters/claude/skills/release-assess/SKILL.md +52 -0
  17. package/adapters/claude/skills/release-help/SKILL.md +60 -0
  18. package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
  19. package/adapters/claude/skills/release-publish/SKILL.md +55 -0
  20. package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
  21. package/adapters/claude/skills/release-verify/SKILL.md +70 -0
  22. package/adapters/codex/.codex-plugin/plugin.json +26 -0
  23. package/adapters/codex/skills/release-assess/SKILL.md +52 -0
  24. package/adapters/codex/skills/release-help/SKILL.md +60 -0
  25. package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
  26. package/adapters/codex/skills/release-publish/SKILL.md +55 -0
  27. package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
  28. package/adapters/codex/skills/release-verify/SKILL.md +70 -0
  29. package/bin/release-skill.mjs +743 -0
  30. package/native/safe-write/binding.gyp +40 -0
  31. package/native/safe-write/prebuilds.json +4 -0
  32. package/native/safe-write/src/safe_write.cc +2023 -0
  33. package/package.json +75 -0
  34. package/references/.render-manifest.json +33 -0
  35. package/references/00-target-state.md +124 -0
  36. package/references/01-state-machine.md +155 -0
  37. package/references/02-project-config.md +217 -0
  38. package/references/03-readme-quality.md +136 -0
  39. package/references/04-supply-chain.md +147 -0
  40. package/references/05-evidence-and-errors.md +164 -0
  41. package/references/06-adapter-contract.md +178 -0
  42. package/schemas/.render-manifest.json +37 -0
  43. package/schemas/approval-record.schema.json +115 -0
  44. package/schemas/artifact-lock.schema.json +111 -0
  45. package/schemas/artifact-plan.schema.json +52 -0
  46. package/schemas/artifact-policy.schema.json +76 -0
  47. package/schemas/evidence-event.schema.json +89 -0
  48. package/schemas/release-plan.schema.json +369 -0
  49. package/schemas/release-project.schema.json +359 -0
  50. package/schemas/release-run.schema.json +195 -0
  51. package/skills/release-assess/SKILL.md +52 -0
  52. package/skills/release-help/SKILL.md +60 -0
  53. package/skills/release-prepare/SKILL.md +71 -0
  54. package/skills/release-publish/SKILL.md +55 -0
  55. package/skills/release-reconcile/SKILL.md +73 -0
  56. package/skills/release-verify/SKILL.md +70 -0
  57. package/skills-src/release-assess/SKILL.md +52 -0
  58. package/skills-src/release-help/SKILL.md +60 -0
  59. package/skills-src/release-prepare/SKILL.md +71 -0
  60. package/skills-src/release-publish/SKILL.md +55 -0
  61. package/skills-src/release-reconcile/SKILL.md +73 -0
  62. package/skills-src/release-verify/SKILL.md +70 -0
  63. package/src/adapters/contract.mjs +214 -0
  64. package/src/adapters/git-github.mjs +214 -0
  65. package/src/adapters/npm.mjs +947 -0
  66. package/src/adapters/plugin-marketplace.mjs +1365 -0
  67. package/src/adapters/push-snapshot.mjs +216 -0
  68. package/src/artifacts/adoption.mjs +743 -0
  69. package/src/artifacts/artifact-plan.mjs +162 -0
  70. package/src/artifacts/entry.mjs +240 -0
  71. package/src/artifacts/git-authority.mjs +637 -0
  72. package/src/artifacts/graph.mjs +189 -0
  73. package/src/artifacts/inspect.mjs +520 -0
  74. package/src/artifacts/inventory.mjs +192 -0
  75. package/src/artifacts/merge/binary.mjs +77 -0
  76. package/src/artifacts/merge/entry-merge.mjs +228 -0
  77. package/src/artifacts/merge/json.mjs +641 -0
  78. package/src/artifacts/merge/markdown.mjs +246 -0
  79. package/src/artifacts/merge/regions.mjs +156 -0
  80. package/src/artifacts/merge/text.mjs +432 -0
  81. package/src/artifacts/merge/tree.mjs +202 -0
  82. package/src/artifacts/merge/yaml.mjs +669 -0
  83. package/src/artifacts/path-key.mjs +94 -0
  84. package/src/artifacts/policy.mjs +319 -0
  85. package/src/artifacts/producer-registry.mjs +439 -0
  86. package/src/artifacts/project-lock.mjs +732 -0
  87. package/src/artifacts/resolution.mjs +658 -0
  88. package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
  89. package/src/artifacts/safe-fs.mjs +72 -0
  90. package/src/artifacts/state.mjs +495 -0
  91. package/src/artifacts/transaction-journal.mjs +983 -0
  92. package/src/artifacts/transaction.mjs +1361 -0
  93. package/src/commands/approve.mjs +280 -0
  94. package/src/commands/artifacts.mjs +627 -0
  95. package/src/commands/assess.mjs +838 -0
  96. package/src/commands/prepare.mjs +1377 -0
  97. package/src/commands/publish.mjs +883 -0
  98. package/src/commands/reconcile.mjs +1255 -0
  99. package/src/commands/verify.mjs +915 -0
  100. package/src/core/approval.mjs +332 -0
  101. package/src/core/baseline.mjs +272 -0
  102. package/src/core/blackbox-hard-gates.mjs +142 -0
  103. package/src/core/config.mjs +448 -0
  104. package/src/core/digest.mjs +90 -0
  105. package/src/core/errors.mjs +113 -0
  106. package/src/core/evidence.mjs +167 -0
  107. package/src/core/hooks.mjs +241 -0
  108. package/src/core/node-version.mjs +64 -0
  109. package/src/core/plan.mjs +735 -0
  110. package/src/core/previous-public-baseline.mjs +204 -0
  111. package/src/core/run.mjs +681 -0
  112. package/src/core/state-machine.mjs +76 -0
  113. package/src/core/version-consistency.mjs +111 -0
  114. package/src/producers/build-adapters.mjs +231 -0
  115. package/src/producers/render-public-assets.mjs +152 -0
  116. package/src/producers/sync-skills.mjs +96 -0
  117. package/src/readme/contract.mjs +297 -0
  118. package/src/readme/examples.mjs +288 -0
  119. package/src/readme/parity.mjs +122 -0
  120. package/src/snapshot/export.mjs +99 -0
  121. package/src/snapshot/frozen.mjs +401 -0
  122. package/src/snapshot/manifest.mjs +207 -0
  123. package/src/snapshot/public-map.mjs +1459 -0
  124. package/src/snapshot/public-path.mjs +110 -0
  125. package/src/snapshot/scan.mjs +419 -0
@@ -0,0 +1,983 @@
1
+ /**
2
+ * Transaction journal for durable apply operations.
3
+ *
4
+ * Manages write-ahead logging, state transitions, and crash recovery
5
+ * for artifact plan applications. ALL filesystem writes go through the
6
+ * safe-fs backend DirectoryHandle — no Node path-based writes.
7
+ *
8
+ * @module artifacts/transaction-journal
9
+ */
10
+
11
+ import { ReleaseError, INVALID_STATE_TRANSITION, TRANSACTION_INCOMPLETE, PATH_UNSAFE } from '../core/errors.mjs';
12
+ import { canonicalJson, sha256Hex } from '../core/digest.mjs';
13
+ import { canonicalArtifactPath } from './path-key.mjs';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Constants
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export const VALID_STATES = Object.freeze(new Set([
20
+ 'PREPARED',
21
+ 'APPLYING',
22
+ 'APPLIED',
23
+ 'VERIFYING',
24
+ 'COMMITTED',
25
+ 'RECOVERY_REQUIRED',
26
+ 'ROLLING_BACK',
27
+ 'ROLLED_BACK',
28
+ 'RECOVERY_CONFLICT',
29
+ ]));
30
+
31
+ export const VALID_TRANSITIONS = Object.freeze({
32
+ PREPARED: ['APPLYING', 'RECOVERY_REQUIRED'],
33
+ APPLYING: ['APPLIED', 'RECOVERY_REQUIRED'],
34
+ APPLIED: ['VERIFYING', 'RECOVERY_REQUIRED'],
35
+ VERIFYING: ['COMMITTED', 'RECOVERY_REQUIRED'],
36
+ COMMITTED: [],
37
+ RECOVERY_REQUIRED: ['ROLLING_BACK', 'APPLYING'],
38
+ ROLLING_BACK: ['ROLLED_BACK', 'RECOVERY_CONFLICT'],
39
+ ROLLED_BACK: [],
40
+ RECOVERY_CONFLICT: [],
41
+ });
42
+
43
+ const JOURNAL_SCHEMA_FIELDS = new Set([
44
+ 'transactionId',
45
+ 'planDigest',
46
+ 'canonicalPlan',
47
+ 'oldManifest',
48
+ 'newManifest',
49
+ 'state',
50
+ 'transitions',
51
+ 'entries',
52
+ 'createdAt',
53
+ 'updatedAt',
54
+ ]);
55
+
56
+ const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
57
+ const PLAN_SCHEMA_FIELDS = new Set([
58
+ 'apiVersion', 'operation', 'bindings', 'safeToWrite', 'targetUnchanged',
59
+ 'nextAction', 'artifacts', 'planDigest',
60
+ ]);
61
+ const ARTIFACT_SCHEMA_FIELDS = new Set([
62
+ 'id', 'path', 'oldEntry', 'newEntry', 'status', 'safeToWrite',
63
+ ]);
64
+
65
+ function failSchema(message, details) {
66
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, message, details);
67
+ }
68
+
69
+ function assertClosedObject(value, fields, label) {
70
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
71
+ failSchema(`${label} must be an object`);
72
+ }
73
+ for (const key of Object.keys(value)) {
74
+ if (!fields.has(key)) failSchema(`${label} has unknown field: ${key}`);
75
+ }
76
+ }
77
+
78
+ function normaliseMode(mode, label) {
79
+ if (Number.isSafeInteger(mode) && mode >= 0 && mode <= 0o777) return mode;
80
+ const text = String(mode);
81
+ const suffix = text.slice(-3);
82
+ if (!/^[0-7]{3}$/.test(suffix)) failSchema(`${label} has invalid mode`);
83
+ return Number.parseInt(suffix, 8);
84
+ }
85
+
86
+ function normaliseDigest(value, label) {
87
+ if (typeof value !== 'string' || !/^(?:sha256:)?[0-9a-f]{64}$/.test(value)) {
88
+ failSchema(`${label} has invalid sha256`);
89
+ }
90
+ return value.startsWith('sha256:') ? value : `sha256:${value}`;
91
+ }
92
+
93
+ function decodeJournalBytes(raw, label) {
94
+ if (Buffer.isBuffer(raw)) return Buffer.from(raw);
95
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)
96
+ || raw.type !== 'Buffer' || !Array.isArray(raw.data)
97
+ || Object.keys(raw).some((key) => !['type', 'data'].includes(key))) {
98
+ failSchema(`${label} has invalid bytes encoding`);
99
+ }
100
+ for (let i = 0; i < raw.data.length; i++) {
101
+ if (!Number.isInteger(raw.data[i]) || raw.data[i] < 0 || raw.data[i] > 255) {
102
+ failSchema(`${label}.bytes[${i}] must be an integer in range 0..255`);
103
+ }
104
+ }
105
+ return Buffer.from(raw.data);
106
+ }
107
+
108
+ function validateManifestItem(item, role, index) {
109
+ const label = `${role}Manifest[${index}]`;
110
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
111
+ failSchema(`${label} must be an object`);
112
+ }
113
+ const canonical = canonicalArtifactPath(item.path);
114
+ if (canonical.path !== item.path) failSchema(`${label} path is not canonical`);
115
+
116
+ if (item.kind === 'absent') {
117
+ const allowed = role === 'old'
118
+ ? new Set(['path', 'kind', 'absent'])
119
+ : new Set(['path', 'kind']);
120
+ assertClosedObject(item, allowed, label);
121
+ if (role === 'old' && item.absent !== true) {
122
+ failSchema(`${label} must carry an absence tombstone`);
123
+ }
124
+ return Object.freeze({ path: canonical.path, kind: 'absent' });
125
+ }
126
+
127
+ if (item.kind !== 'regular') failSchema(`${label} has invalid kind`);
128
+ const allowed = role === 'old'
129
+ ? new Set(['path', 'kind', 'sha256', 'size', 'mode', 'bytes'])
130
+ : new Set(['path', 'kind', 'sha256', 'size', 'mode']);
131
+ assertClosedObject(item, allowed, label);
132
+ for (const required of allowed) {
133
+ if (!Object.hasOwn(item, required)) failSchema(`${label} missing required field: ${required}`);
134
+ }
135
+ const digest = normaliseDigest(item.sha256, label);
136
+ if (!Number.isSafeInteger(item.size) || item.size < 0) failSchema(`${label} has invalid size`);
137
+ const mode = normaliseMode(item.mode, label);
138
+ if (role === 'old') {
139
+ const bytes = decodeJournalBytes(item.bytes, label);
140
+ if (bytes.length !== item.size) failSchema(`${label} bytes do not match size`);
141
+ if (`sha256:${sha256Hex(bytes)}` !== digest) failSchema(`${label} bytes do not match sha256`);
142
+ }
143
+ return Object.freeze({ path: canonical.path, kind: 'regular', digest, size: item.size, mode });
144
+ }
145
+
146
+ function validateCanonicalPlanBinding(journal, oldItems, newItems) {
147
+ const plan = journal.canonicalPlan;
148
+ assertClosedObject(plan, PLAN_SCHEMA_FIELDS, 'journal canonicalPlan');
149
+ for (const field of PLAN_SCHEMA_FIELDS) {
150
+ if (!Object.hasOwn(plan, field)) failSchema(`journal canonicalPlan missing required field: ${field}`);
151
+ }
152
+ const { planDigest: _ignored, ...content } = plan;
153
+ const actualDigest = `sha256:${sha256Hex(canonicalJson(content))}`;
154
+ if (plan.planDigest !== journal.planDigest || actualDigest !== journal.planDigest) {
155
+ failSchema('journal canonicalPlan digest does not match journal planDigest');
156
+ }
157
+ if (plan.apiVersion !== 'release-skill.dev/artifact-plan/v1'
158
+ || !['inspect', 'status', 'apply'].includes(plan.operation)
159
+ || plan.safeToWrite !== true || plan.targetUnchanged !== true) {
160
+ failSchema('journal canonicalPlan is not an applyable v1 plan');
161
+ }
162
+ const bindingFields = new Set([
163
+ 'repositoryIdentity', 'policyDigest', 'baseManifestDigest',
164
+ 'currentManifestDigest', 'producerClosureDigest',
165
+ ]);
166
+ assertClosedObject(plan.bindings, bindingFields, 'journal canonicalPlan.bindings');
167
+ for (const field of bindingFields) {
168
+ if (!DIGEST_RE.test(plan.bindings[field])) {
169
+ failSchema(`journal canonicalPlan binding ${field} is invalid`);
170
+ }
171
+ }
172
+ assertClosedObject(plan.nextAction, new Set(['command']), 'journal canonicalPlan.nextAction');
173
+ if (typeof plan.nextAction.command !== 'string'
174
+ || !/\bartifacts apply\b/.test(plan.nextAction.command)) {
175
+ failSchema('journal canonicalPlan nextAction is not apply');
176
+ }
177
+ if (!Array.isArray(plan.artifacts) || plan.artifacts.length !== newItems.length) {
178
+ failSchema('journal canonicalPlan artifacts do not match manifest length');
179
+ }
180
+
181
+ for (let i = 0; i < plan.artifacts.length; i++) {
182
+ const artifact = plan.artifacts[i];
183
+ const label = `journal canonicalPlan.artifacts[${i}]`;
184
+ assertClosedObject(artifact, ARTIFACT_SCHEMA_FIELDS, label);
185
+ for (const field of ARTIFACT_SCHEMA_FIELDS) {
186
+ if (!Object.hasOwn(artifact, field)) failSchema(`${label} missing required field: ${field}`);
187
+ }
188
+ if (typeof artifact.id !== 'string' || artifact.id.length === 0) failSchema(`${label} has invalid id`);
189
+ if (artifact.safeToWrite !== true
190
+ || !new Set([
191
+ 'READY', 'CLEAN', 'NEW', 'HUMAN_CHANGED',
192
+ 'GENERATOR_CHANGED', 'MERGEABLE', 'RESOLVED',
193
+ ]).has(artifact.status)) {
194
+ failSchema(`${label} is not safe to write`);
195
+ }
196
+ const canonical = canonicalArtifactPath(artifact.path);
197
+ if (canonical.path !== artifact.path || canonical.path !== oldItems[i].path) {
198
+ failSchema(`${label} path does not match manifests`);
199
+ }
200
+ for (const [role, entry, manifest] of [
201
+ ['oldEntry', artifact.oldEntry, oldItems[i]],
202
+ ['newEntry', artifact.newEntry, newItems[i]],
203
+ ]) {
204
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry) || entry.kind !== manifest.kind) {
205
+ failSchema(`${label}.${role} does not match manifest kind`);
206
+ }
207
+ if (entry.kind === 'absent') {
208
+ if (Object.keys(entry).length !== 1) failSchema(`${label}.${role} absent entry is not closed`);
209
+ continue;
210
+ }
211
+ const entryFields = role === 'oldEntry'
212
+ ? new Set(['kind', 'sha256', 'size', 'mode'])
213
+ : new Set(['kind', 'bytes', 'sha256', 'size', 'mode']);
214
+ assertClosedObject(entry, entryFields, `${label}.${role}`);
215
+ for (const field of entryFields) {
216
+ if (!Object.hasOwn(entry, field)) failSchema(`${label}.${role} missing required field: ${field}`);
217
+ }
218
+ if (normaliseDigest(entry.sha256, `${label}.${role}`) !== manifest.digest
219
+ || entry.size !== manifest.size
220
+ || normaliseMode(entry.mode, `${label}.${role}`) !== manifest.mode) {
221
+ failSchema(`${label}.${role} does not match manifest identity`);
222
+ }
223
+ if (role === 'newEntry') {
224
+ const bytes = decodeJournalBytes(entry.bytes, `${label}.${role}`);
225
+ if (bytes.length !== manifest.size || `sha256:${sha256Hex(bytes)}` !== manifest.digest) {
226
+ failSchema(`${label}.${role} bytes do not match manifest identity`);
227
+ }
228
+ }
229
+ }
230
+ }
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Handle helpers
235
+ // ---------------------------------------------------------------------------
236
+
237
+ /**
238
+ * Navigate a handle through a slash-separated path, creating each segment
239
+ * if it does not already exist.
240
+ *
241
+ * P0-5 fix: must NOT catch arbitrary openDir errors and mkdir.
242
+ * First check if segment exists via readEntry; only mkdir if truly absent.
243
+ * Symlinks, non-directories, permission/IO errors must fail closed.
244
+ *
245
+ * @param {object} handle — starting DirectoryHandle.
246
+ * @param {string[]} segments — path segments to navigate.
247
+ * @returns {Promise<object>} The leaf DirectoryHandle.
248
+ */
249
+ async function ensurePath(handle, segments, openedHandles = []) {
250
+ let current = handle;
251
+ for (const seg of segments) {
252
+ try {
253
+ current = await current.openDir(seg);
254
+ openedHandles.push(current);
255
+ } catch (openErr) {
256
+ // readEntry returns null (native) or {kind:'absent'} (recording) for
257
+ // ENOENT. Every other result/error must fail closed: never turn an
258
+ // EACCES, symlink, special file, or transient IO error into mkdir.
259
+ const entry = await current.readEntry(seg);
260
+ const absent = entry === null || entry?.kind === 'absent';
261
+ if (!absent) {
262
+ const isDirectory = entry?.type === 'directory' || entry?.kind === 'tree';
263
+ if (isDirectory) throw openErr;
264
+ throw new ReleaseError(
265
+ PATH_UNSAFE,
266
+ `ensurePath: path segment is not a directory: ${entry?.type || entry?.kind || 'unknown'}`,
267
+ );
268
+ }
269
+ await current.mkdir(seg, 0o700);
270
+ await current.fsync();
271
+ current = await current.openDir(seg);
272
+ openedHandles.push(current);
273
+ }
274
+ }
275
+ return current;
276
+ }
277
+
278
+ async function closeHandlesReverse(handles) {
279
+ const failures = [];
280
+ for (let i = handles.length - 1; i >= 0; i--) {
281
+ try {
282
+ await handles[i].close();
283
+ } catch (error) {
284
+ failures.push(error?.code || error?.message || 'close failed');
285
+ }
286
+ }
287
+ if (failures.length > 0) {
288
+ throw new ReleaseError(
289
+ TRANSACTION_INCOMPLETE,
290
+ 'transaction handle close failed',
291
+ { closeFailures: failures },
292
+ );
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Read and parse a JSON file through a handle.
298
+ *
299
+ * @param {object} handle — DirectoryHandle containing the file.
300
+ * @param {string} name — file name.
301
+ * @returns {Promise<object|null>} Parsed JSON or null if absent.
302
+ * @throws {ReleaseError} TRANSACTION_INCOMPLETE on corrupt JSON.
303
+ */
304
+ async function readJsonViaHandle(handle, name) {
305
+ const result = await handle.readFile(name);
306
+ if (result === null) return null;
307
+ const text = result.bytes.toString('utf8');
308
+ try {
309
+ return JSON.parse(text);
310
+ } catch {
311
+ throw new ReleaseError(
312
+ TRANSACTION_INCOMPLETE,
313
+ `journal file ${name} is corrupt (invalid JSON)`,
314
+ );
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Write a JSON object to a file atomically through a handle using
320
+ * createTemp + fsync + rename + parent fsync.
321
+ *
322
+ * @param {object} handle — DirectoryHandle for the target directory.
323
+ * @param {string} name — target file name.
324
+ * @param {object} data — JSON-serialisable data.
325
+ */
326
+ async function writeJsonViaHandle(handle, name, data) {
327
+ const bytes = Buffer.from(JSON.stringify(data, null, 2), 'utf8');
328
+ const expectedIdentity = await handle.readFile(name);
329
+ const token = await handle.createTemp(name, 0o600, bytes);
330
+ try {
331
+ await handle.rename(token, name, expectedIdentity);
332
+ } catch (error) {
333
+ try {
334
+ const aborted = await handle.abortTemp(token);
335
+ if (!aborted?.removed) {
336
+ error.details = { ...(error.details || {}), abortResult: aborted };
337
+ }
338
+ } catch (abortError) {
339
+ error.details = {
340
+ ...(error.details || {}),
341
+ abortError: abortError?.code || abortError?.message || 'abort failed',
342
+ };
343
+ }
344
+ throw error;
345
+ }
346
+ await handle.fsync();
347
+ }
348
+
349
+ async function abortTempAfterFailure(handle, token, primaryError) {
350
+ try {
351
+ const result = await handle.abortTemp(token);
352
+ if (!result?.removed) {
353
+ primaryError.details = { ...(primaryError.details || {}), abortResult: result };
354
+ }
355
+ } catch (abortError) {
356
+ primaryError.details = {
357
+ ...(primaryError.details || {}),
358
+ abortError: abortError?.code || abortError?.message || 'abort failed',
359
+ };
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Validate a journal object against the closed schema.
365
+ *
366
+ * P0-7: Validates all required fields, transitions/entries structure,
367
+ * index ranges, path uniqueness, transactionId/path safety, and legal
368
+ * state transitions. Rejects from:null pseudo-transitions and metadata
369
+ * overwriting reserved fields.
370
+ *
371
+ * @param {object} journal — journal object.
372
+ * @throws {ReleaseError} TRANSACTION_INCOMPLETE on unknown fields or
373
+ * invalid structure.
374
+ */
375
+ function validateJournalSchema(journal) {
376
+ if (!journal || typeof journal !== 'object' || Array.isArray(journal)) {
377
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal is not an object');
378
+ }
379
+
380
+ // P0-7: Validate closed schema
381
+ for (const key of Object.keys(journal)) {
382
+ if (!JOURNAL_SCHEMA_FIELDS.has(key)) {
383
+ throw new ReleaseError(
384
+ TRANSACTION_INCOMPLETE,
385
+ `journal has unknown field: ${key}`,
386
+ );
387
+ }
388
+ }
389
+ for (const field of JOURNAL_SCHEMA_FIELDS) {
390
+ if (!Object.hasOwn(journal, field)) {
391
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, `journal missing required field: ${field}`);
392
+ }
393
+ }
394
+
395
+ // P0-7: Validate required fields
396
+ if (typeof journal.transactionId !== 'string' || journal.transactionId.length === 0) {
397
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal missing transactionId');
398
+ }
399
+
400
+ // P0-7: Validate transactionId safety (no path separators, NUL, etc.)
401
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(journal.transactionId)) {
402
+ throw new ReleaseError(
403
+ TRANSACTION_INCOMPLETE,
404
+ 'journal transactionId contains unsafe characters',
405
+ { transactionId: journal.transactionId },
406
+ );
407
+ }
408
+
409
+ if (typeof journal.planDigest !== 'string' || !DIGEST_RE.test(journal.planDigest)) {
410
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal missing planDigest');
411
+ }
412
+
413
+ if (!journal.canonicalPlan || typeof journal.canonicalPlan !== 'object'
414
+ || Array.isArray(journal.canonicalPlan)) {
415
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal canonicalPlan must be an object');
416
+ }
417
+ if (!Array.isArray(journal.oldManifest) || !Array.isArray(journal.newManifest)) {
418
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal manifests must be arrays');
419
+ }
420
+ if (journal.oldManifest.length !== journal.newManifest.length) {
421
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal manifest lengths differ');
422
+ }
423
+
424
+ if (!VALID_STATES.has(journal.state)) {
425
+ throw new ReleaseError(
426
+ TRANSACTION_INCOMPLETE,
427
+ `journal has invalid state: ${journal.state}`,
428
+ );
429
+ }
430
+
431
+ if (!Array.isArray(journal.transitions)) {
432
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal transitions is not an array');
433
+ }
434
+
435
+ if (!Array.isArray(journal.entries)) {
436
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal entries is not an array');
437
+ }
438
+
439
+ let replayState = 'PREPARED';
440
+ // P0-7: Validate transitions structure
441
+ for (let i = 0; i < journal.transitions.length; i++) {
442
+ const t = journal.transitions[i];
443
+ if (!t || typeof t !== 'object') {
444
+ throw new ReleaseError(
445
+ TRANSACTION_INCOMPLETE,
446
+ `journal transition[${i}] is not an object`,
447
+ );
448
+ }
449
+
450
+ // P0-7: Reject from:null pseudo-transitions
451
+ if (t.from === null || t.from === undefined) {
452
+ throw new ReleaseError(
453
+ TRANSACTION_INCOMPLETE,
454
+ `journal transition[${i}] must not use a null from state`,
455
+ );
456
+ } else if (typeof t.from !== 'string' || !VALID_STATES.has(t.from)) {
457
+ throw new ReleaseError(
458
+ TRANSACTION_INCOMPLETE,
459
+ `journal transition[${i}] has invalid from state: ${t.from}`,
460
+ );
461
+ }
462
+
463
+ if (typeof t.to !== 'string' || !VALID_STATES.has(t.to)) {
464
+ throw new ReleaseError(
465
+ TRANSACTION_INCOMPLETE,
466
+ `journal transition[${i}] has invalid to state: ${t.to}`,
467
+ );
468
+ }
469
+
470
+ // P0-7: Validate transition legality
471
+ if (t.from !== replayState || !VALID_TRANSITIONS[t.from]?.includes(t.to)) {
472
+ throw new ReleaseError(
473
+ INVALID_STATE_TRANSITION,
474
+ `journal transition[${i}] illegal or discontinuous: ${t.from} -> ${t.to}`,
475
+ { from: t.from, to: t.to, expectedFrom: replayState },
476
+ );
477
+ }
478
+ replayState = t.to;
479
+
480
+ // Validate entryIndex if present
481
+ if (t.entryIndex !== undefined && t.entryIndex !== null) {
482
+ if (typeof t.entryIndex !== 'number' || t.entryIndex < 0
483
+ || !Number.isInteger(t.entryIndex)
484
+ || t.entryIndex >= journal.newManifest.length) {
485
+ throw new ReleaseError(
486
+ TRANSACTION_INCOMPLETE,
487
+ `journal transition[${i}] has invalid entryIndex: ${t.entryIndex}`,
488
+ );
489
+ }
490
+ }
491
+
492
+ // P0-7: Reject unknown transition fields
493
+ const TRANSITION_SCHEMA_FIELDS = new Set(['from', 'to', 'entryIndex', 'timestamp']);
494
+ for (const key of Object.keys(t)) {
495
+ if (!TRANSITION_SCHEMA_FIELDS.has(key)) {
496
+ throw new ReleaseError(
497
+ TRANSACTION_INCOMPLETE,
498
+ `journal transition[${i}] has unknown field: ${key}`,
499
+ );
500
+ }
501
+ }
502
+ if (typeof t.timestamp !== 'string' || !Number.isFinite(Date.parse(t.timestamp))) {
503
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, `journal transition[${i}] has invalid timestamp`);
504
+ }
505
+ }
506
+ if (replayState !== journal.state) {
507
+ throw new ReleaseError(
508
+ TRANSACTION_INCOMPLETE,
509
+ `journal state ${journal.state} does not match transition replay ${replayState}`,
510
+ );
511
+ }
512
+
513
+ const manifestPaths = new Set();
514
+ const oldItems = [];
515
+ const newItems = [];
516
+ for (let i = 0; i < journal.newManifest.length; i++) {
517
+ const oldItem = journal.oldManifest[i];
518
+ const newItem = journal.newManifest[i];
519
+ const validatedOld = validateManifestItem(oldItem, 'old', i);
520
+ const validatedNew = validateManifestItem(newItem, 'new', i);
521
+ if (validatedOld.path !== validatedNew.path) {
522
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, `journal manifest[${i}] paths differ`);
523
+ }
524
+ oldItems.push(validatedOld);
525
+ newItems.push(validatedNew);
526
+ const { collisionKey } = canonicalArtifactPath(validatedNew.path);
527
+ if (manifestPaths.has(collisionKey)) {
528
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, `journal manifest path is duplicated: ${newItem.path}`);
529
+ }
530
+ manifestPaths.add(collisionKey);
531
+ }
532
+ validateCanonicalPlanBinding(journal, oldItems, newItems);
533
+
534
+ // P0-7: Validate entries structure
535
+ const seenIndices = new Set();
536
+ const seenPaths = new Set();
537
+ if (journal.entries.length > journal.newManifest.length) {
538
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal entries exceed manifest length');
539
+ }
540
+ for (let i = 0; i < journal.entries.length; i++) {
541
+ const e = journal.entries[i];
542
+ if (e === null || e === undefined) {
543
+ // Entries may have null gaps (write-ahead placeholders)
544
+ continue;
545
+ }
546
+
547
+ if (typeof e !== 'object') {
548
+ throw new ReleaseError(
549
+ TRANSACTION_INCOMPLETE,
550
+ `journal entries[${i}] is not an object or null`,
551
+ );
552
+ }
553
+
554
+ // P0-7: Validate entry index uniqueness
555
+ if (seenIndices.has(i)) {
556
+ throw new ReleaseError(
557
+ TRANSACTION_INCOMPLETE,
558
+ `journal entries has duplicate index: ${i}`,
559
+ );
560
+ }
561
+ seenIndices.add(i);
562
+
563
+ // P0-7: Validate entry path uniqueness
564
+ if (typeof e.id !== 'string' || e.id.length === 0 || typeof e.path !== 'string') {
565
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, `journal entries[${i}] missing id/path`);
566
+ }
567
+ const entryPath = canonicalArtifactPath(e.path);
568
+ if (e.id !== journal.canonicalPlan.artifacts[i].id
569
+ || entryPath.path !== newItems[i].path) {
570
+ throw new ReleaseError(
571
+ TRANSACTION_INCOMPLETE,
572
+ `journal entries[${i}] does not match canonical plan authority`,
573
+ );
574
+ }
575
+ if (typeof e.path === 'string') {
576
+ if (seenPaths.has(entryPath.collisionKey)) {
577
+ throw new ReleaseError(
578
+ TRANSACTION_INCOMPLETE,
579
+ `journal entries has duplicate path: ${e.path}`,
580
+ );
581
+ }
582
+ seenPaths.add(entryPath.collisionKey);
583
+ }
584
+ if (!['pending', 'applied'].includes(e.status)) {
585
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, `journal entries[${i}] has invalid status`);
586
+ }
587
+ if (typeof e.appliedAt !== 'string' || !Number.isFinite(Date.parse(e.appliedAt))) {
588
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, `journal entries[${i}] has invalid appliedAt`);
589
+ }
590
+
591
+ // P0-7: Validate entry closed schema
592
+ const ENTRY_SCHEMA_FIELDS = new Set(['id', 'path', 'status', 'appliedAt']);
593
+ for (const key of Object.keys(e)) {
594
+ if (!ENTRY_SCHEMA_FIELDS.has(key)) {
595
+ throw new ReleaseError(
596
+ TRANSACTION_INCOMPLETE,
597
+ `journal entries[${i}] has unknown field: ${key}`,
598
+ );
599
+ }
600
+ }
601
+ }
602
+
603
+ if (typeof journal.createdAt !== 'string' || !Number.isFinite(Date.parse(journal.createdAt))) {
604
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal createdAt must be a timestamp');
605
+ }
606
+ if (typeof journal.updatedAt !== 'string' || !Number.isFinite(Date.parse(journal.updatedAt))) {
607
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal updatedAt must be a timestamp');
608
+ }
609
+ if (['APPLIED', 'VERIFYING', 'COMMITTED'].includes(journal.state)) {
610
+ if (journal.entries.length !== journal.newManifest.length
611
+ || journal.entries.some((entry) => !entry || entry.status !== 'applied')) {
612
+ throw new ReleaseError(
613
+ TRANSACTION_INCOMPLETE,
614
+ `journal state ${journal.state} requires every manifest entry to be applied`,
615
+ );
616
+ }
617
+ }
618
+ }
619
+
620
+ // ---------------------------------------------------------------------------
621
+ // Journal creation
622
+ // ---------------------------------------------------------------------------
623
+
624
+ /**
625
+ * Create a new transaction journal through the safe-fs backend.
626
+ *
627
+ * Creates `.release-skill/transactions/<txnId>/journal.json` with initial
628
+ * PREPARED state, canonical plan, old/new manifest.
629
+ *
630
+ * @param {object} options
631
+ * @param {object} options.backend — safe-fs backend.
632
+ * @param {string} options.root — repository root.
633
+ * @param {string} options.transactionId — unique transaction ID.
634
+ * @param {string} options.planDigest — canonical plan digest.
635
+ * @param {object} options.canonicalPlan — decoded plan (with Buffer bytes).
636
+ * @param {object[]} options.oldManifest — snapshot of old entries with
637
+ * backup bytes (for absent entries, `absent: true`).
638
+ * @param {object[]} options.newManifest — snapshot of new entries.
639
+ * @returns {Promise<{ journal: object, txnHandle: object }>}
640
+ */
641
+ export async function createTransactionJournal({
642
+ rootHandle,
643
+ transactionId,
644
+ planDigest,
645
+ canonicalPlan,
646
+ oldManifest,
647
+ newManifest,
648
+ } = {}) {
649
+ if (typeof transactionId !== 'string'
650
+ || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(transactionId)) {
651
+ throw new ReleaseError(PATH_UNSAFE, 'transactionId is not a safe path segment');
652
+ }
653
+ if (typeof planDigest !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(planDigest)) {
654
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'planDigest is invalid');
655
+ }
656
+ const openedHandles = [];
657
+ try {
658
+
659
+ // Ensure .release-skill/transactions/ exists
660
+ const txnParent = await ensurePath(
661
+ rootHandle,
662
+ ['.release-skill', 'transactions'],
663
+ openedHandles,
664
+ );
665
+
666
+ // Create the transaction directory
667
+ try {
668
+ await txnParent.mkdir(transactionId, 0o700);
669
+ } catch (err) {
670
+ if (err.code === 'EEXIST') {
671
+ throw new ReleaseError(
672
+ TRANSACTION_INCOMPLETE,
673
+ `transaction directory already exists: ${transactionId}`,
674
+ { transactionId },
675
+ );
676
+ }
677
+ throw err;
678
+ }
679
+ await txnParent.fsync();
680
+
681
+ const txnHandle = await txnParent.openDir(transactionId);
682
+ openedHandles.push(txnHandle);
683
+
684
+ const now = new Date().toISOString();
685
+ const journal = {
686
+ transactionId,
687
+ planDigest,
688
+ canonicalPlan,
689
+ oldManifest,
690
+ newManifest,
691
+ state: 'PREPARED',
692
+ transitions: [],
693
+ entries: [],
694
+ createdAt: now,
695
+ updatedAt: now,
696
+ };
697
+
698
+ validateJournalSchema(journal);
699
+ await writeJsonViaHandle(txnHandle, 'journal.json', journal);
700
+
701
+ return {
702
+ journal,
703
+ txnHandle,
704
+ async close() {
705
+ await closeHandlesReverse(openedHandles);
706
+ },
707
+ };
708
+ } catch (error) {
709
+ try {
710
+ await closeHandlesReverse(openedHandles);
711
+ } catch (closeError) {
712
+ error.details = {
713
+ ...(error.details || {}),
714
+ closeFailures: closeError.details?.closeFailures || [closeError.code || closeError.message],
715
+ };
716
+ }
717
+ throw error;
718
+ }
719
+ }
720
+
721
+ // ---------------------------------------------------------------------------
722
+ // Journal reading
723
+ // ---------------------------------------------------------------------------
724
+
725
+ /**
726
+ * Read and validate the journal from a transaction handle.
727
+ *
728
+ * @param {object} txnHandle — DirectoryHandle for the transaction directory.
729
+ * @param {string} transactionId — for error context.
730
+ * @returns {Promise<object>} Validated journal data.
731
+ * @throws {ReleaseError} TRANSACTION_INCOMPLETE if missing or corrupt.
732
+ */
733
+ export async function readJournal(txnHandle, transactionId) {
734
+ const journal = await readJsonViaHandle(txnHandle, 'journal.json');
735
+ if (journal === null) {
736
+ throw new ReleaseError(
737
+ TRANSACTION_INCOMPLETE,
738
+ 'transaction journal does not exist',
739
+ { transactionId },
740
+ );
741
+ }
742
+ validateJournalSchema(journal);
743
+ if (journal.transactionId !== transactionId) {
744
+ throw new ReleaseError(
745
+ TRANSACTION_INCOMPLETE,
746
+ 'journal transactionId does not match its directory authority',
747
+ { expected: transactionId, actual: journal.transactionId },
748
+ );
749
+ }
750
+ return journal;
751
+ }
752
+
753
+ // ---------------------------------------------------------------------------
754
+ // State transitions
755
+ // ---------------------------------------------------------------------------
756
+
757
+ /**
758
+ * Write a journal state transition through the transaction handle.
759
+ *
760
+ * @param {object} options
761
+ * @param {object} options.txnHandle — DirectoryHandle.
762
+ * @param {string} options.transactionId — for error context.
763
+ * @param {string|null} options.from — expected current state (null = skip
764
+ * check for initial write).
765
+ * @param {string} options.to — target state.
766
+ * @param {number} [options.entryIndex] — entry index for write-ahead.
767
+ * @returns {Promise<object>} Updated journal.
768
+ * @throws {ReleaseError} INVALID_STATE_TRANSITION on invalid transition.
769
+ */
770
+ export async function writeJournalTransition({
771
+ txnHandle,
772
+ transactionId,
773
+ from,
774
+ to,
775
+ entryIndex,
776
+ } = {}) {
777
+ if (typeof from !== 'string' || !VALID_STATES.has(from)) {
778
+ throw new ReleaseError(
779
+ INVALID_STATE_TRANSITION,
780
+ 'journal transitions require a concrete valid from state',
781
+ { from },
782
+ );
783
+ }
784
+ const journal = await readJournal(txnHandle, transactionId);
785
+
786
+ if (from !== null && journal.state !== from) {
787
+ throw new ReleaseError(
788
+ INVALID_STATE_TRANSITION,
789
+ `invalid state transition: expected ${from}, got ${journal.state}`,
790
+ { expected: from, actual: journal.state, transactionId },
791
+ );
792
+ }
793
+
794
+ if (from !== null && !VALID_TRANSITIONS[from]?.includes(to)) {
795
+ throw new ReleaseError(
796
+ INVALID_STATE_TRANSITION,
797
+ `invalid state transition: ${from} -> ${to}`,
798
+ { from, to, transactionId },
799
+ );
800
+ }
801
+
802
+ if (from !== null) {
803
+ journal.state = to;
804
+ }
805
+
806
+ journal.transitions.push({
807
+ from,
808
+ to,
809
+ entryIndex,
810
+ timestamp: new Date().toISOString(),
811
+ });
812
+
813
+ journal.updatedAt = new Date().toISOString();
814
+
815
+ await writeJsonViaHandle(txnHandle, 'journal.json', journal);
816
+
817
+ return journal;
818
+ }
819
+
820
+ // ---------------------------------------------------------------------------
821
+ // Entry recording
822
+ // ---------------------------------------------------------------------------
823
+
824
+ /**
825
+ * Record a write-ahead entry index (before mutation) or an applied entry
826
+ * (after mutation) in the journal.
827
+ *
828
+ * @param {object} options
829
+ * @param {object} options.txnHandle — DirectoryHandle.
830
+ * @param {string} options.transactionId — for error context.
831
+ * @param {number} options.entryIndex — entry index.
832
+ * @param {object} [options.entry] — entry data (omit for write-ahead).
833
+ * @returns {Promise<object>} Updated journal.
834
+ */
835
+ export async function recordAppliedEntry({
836
+ txnHandle,
837
+ transactionId,
838
+ entryIndex,
839
+ entry,
840
+ } = {}) {
841
+ if (!Number.isInteger(entryIndex) || entryIndex < 0) {
842
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'entryIndex must be a non-negative integer');
843
+ }
844
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)
845
+ || Object.keys(entry).some((key) => !['id', 'path', 'status'].includes(key))
846
+ || typeof entry.id !== 'string' || typeof entry.path !== 'string'
847
+ || !['pending', 'applied'].includes(entry.status)) {
848
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal entry metadata is invalid');
849
+ }
850
+ const journal = await readJournal(txnHandle, transactionId);
851
+ if (entryIndex >= journal.newManifest.length) {
852
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'entryIndex exceeds manifest length');
853
+ }
854
+
855
+ while (journal.entries.length <= entryIndex) {
856
+ journal.entries.push(null);
857
+ }
858
+
859
+ journal.entries[entryIndex] = {
860
+ ...entry,
861
+ appliedAt: new Date().toISOString(),
862
+ };
863
+
864
+ journal.updatedAt = new Date().toISOString();
865
+ await writeJsonViaHandle(txnHandle, 'journal.json', journal);
866
+
867
+ return journal;
868
+ }
869
+
870
+ // ---------------------------------------------------------------------------
871
+ // Backup operations (all through handle)
872
+ // ---------------------------------------------------------------------------
873
+
874
+ /**
875
+ * Create a backup of an old entry before applying changes.
876
+ *
877
+ * For regular files, backs up the full bytes. For absent entries,
878
+ * writes an absence tombstone.
879
+ *
880
+ * @param {object} options
881
+ * @param {object} options.txnHandle — DirectoryHandle for the transaction dir.
882
+ * @param {string} options.transactionId — for error context.
883
+ * @param {number} options.entryIndex — entry index.
884
+ * @param {object} options.oldEntry — old entry data (may have bytes or absent flag).
885
+ * @returns {Promise<void>}
886
+ */
887
+ export async function createBackup({
888
+ txnHandle,
889
+ transactionId,
890
+ entryIndex,
891
+ oldEntry,
892
+ } = {}) {
893
+ if (!Number.isInteger(entryIndex) || entryIndex < 0) {
894
+ throw new ReleaseError(TRANSACTION_INCOMPLETE, 'backup entryIndex is invalid');
895
+ }
896
+ // Ensure backups directory exists
897
+ let backupsHandle;
898
+ try {
899
+ backupsHandle = await txnHandle.openDir('backups');
900
+ } catch (openErr) {
901
+ const entry = await txnHandle.readEntry('backups');
902
+ const absent = entry === null || entry?.kind === 'absent';
903
+ if (!absent) {
904
+ const isDirectory = entry?.type === 'directory' || entry?.kind === 'tree';
905
+ if (isDirectory) throw openErr;
906
+ throw new ReleaseError(PATH_UNSAFE, 'backups path is not a directory');
907
+ }
908
+ await txnHandle.mkdir('backups', 0o700);
909
+ await txnHandle.fsync();
910
+ backupsHandle = await txnHandle.openDir('backups');
911
+ }
912
+
913
+ let primaryError;
914
+ try {
915
+ const backupData = oldEntry.kind === 'regular' && oldEntry.bytes
916
+ ? Buffer.from(oldEntry.bytes)
917
+ : Buffer.from(JSON.stringify({ absent: true, entryIndex, timestamp: new Date().toISOString() }), 'utf8');
918
+
919
+ const backupName = `${entryIndex}.bak`;
920
+ const token = await backupsHandle.createTemp(backupName, 0o600, backupData);
921
+ try {
922
+ await backupsHandle.rename(token, backupName);
923
+ } catch (error) {
924
+ await abortTempAfterFailure(backupsHandle, token, error);
925
+ throw error;
926
+ }
927
+ await backupsHandle.fsync();
928
+ } catch (error) {
929
+ primaryError = error;
930
+ } finally {
931
+ try {
932
+ await backupsHandle.close();
933
+ } catch (closeError) {
934
+ if (primaryError) {
935
+ primaryError.details = {
936
+ ...(primaryError.details || {}),
937
+ closeError: closeError?.code || closeError?.message || 'close failed',
938
+ };
939
+ } else {
940
+ throw closeError;
941
+ }
942
+ }
943
+ }
944
+ if (primaryError) throw primaryError;
945
+ }
946
+
947
+ // ---------------------------------------------------------------------------
948
+ // Recovery file
949
+ // ---------------------------------------------------------------------------
950
+
951
+ /**
952
+ * Write a RECOVERY_REQUIRED marker file through the transaction handle.
953
+ *
954
+ * @param {object} options
955
+ * @param {object} options.txnHandle — DirectoryHandle.
956
+ * @param {string} options.transactionId — transaction ID.
957
+ * @param {boolean} options.targetUnchanged — whether targets are unchanged.
958
+ * @param {string} options.recover — unique recover command.
959
+ * @returns {Promise<void>}
960
+ */
961
+ export async function writeRecoveryRequiredFile({
962
+ txnHandle,
963
+ transactionId,
964
+ targetUnchanged,
965
+ recover,
966
+ } = {}) {
967
+ const data = {
968
+ transactionId,
969
+ targetUnchanged,
970
+ recover,
971
+ failedAt: new Date().toISOString(),
972
+ };
973
+ const bytes = Buffer.from(JSON.stringify(data, null, 2), 'utf8');
974
+ const expectedIdentity = await txnHandle.readFile('RECOVERY_REQUIRED');
975
+ const token = await txnHandle.createTemp('RECOVERY_REQUIRED', 0o600, bytes);
976
+ try {
977
+ await txnHandle.rename(token, 'RECOVERY_REQUIRED', expectedIdentity);
978
+ } catch (error) {
979
+ await abortTempAfterFailure(txnHandle, token, error);
980
+ throw error;
981
+ }
982
+ await txnHandle.fsync();
983
+ }