wowbagger 0.1.0-alpha.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 (46) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/LICENSE +201 -0
  3. package/README.md +464 -0
  4. package/adapters/claude-code/entrypoint.js +19 -0
  5. package/adapters/claude-code/wowbagger-adapter.json +25 -0
  6. package/adapters/codex/entrypoint.js +11 -0
  7. package/adapters/codex/wowbagger-adapter.json +25 -0
  8. package/adapters/opencode/entrypoint.js +11 -0
  9. package/adapters/opencode/wowbagger-adapter.json +25 -0
  10. package/bin/wowbagger.js +7 -0
  11. package/package.json +51 -0
  12. package/skills/wowbagger/SKILL.md +136 -0
  13. package/src/adapter/approval.js +135 -0
  14. package/src/adapter/bootstrap.js +43 -0
  15. package/src/adapter/context.js +34 -0
  16. package/src/adapter/core-probe.js +231 -0
  17. package/src/adapter/describe.js +383 -0
  18. package/src/adapter/entrypoint-main.js +335 -0
  19. package/src/adapter/entrypoint-path.js +103 -0
  20. package/src/adapter/handoff.js +124 -0
  21. package/src/adapter/instructions.js +106 -0
  22. package/src/adapter/invoke.js +294 -0
  23. package/src/adapter/limits.js +26 -0
  24. package/src/adapter/manifest.js +93 -0
  25. package/src/adapter/messages.js +15 -0
  26. package/src/adapter/paths.js +88 -0
  27. package/src/adapter/process-outcome.js +1116 -0
  28. package/src/adapter/schema-helpers.js +60 -0
  29. package/src/claim-capabilities.js +54 -0
  30. package/src/claim-coordinator.js +85 -0
  31. package/src/claim-journal.js +236 -0
  32. package/src/claim-operations.js +138 -0
  33. package/src/claim-publication.js +739 -0
  34. package/src/claim-request.js +140 -0
  35. package/src/claim-store.js +198 -0
  36. package/src/cli.js +1130 -0
  37. package/src/dependencies.js +3 -0
  38. package/src/git-reconciliation.js +62 -0
  39. package/src/ledger.js +296 -0
  40. package/src/mint.js +32 -0
  41. package/src/mutation.js +1979 -0
  42. package/src/namespace.js +35 -0
  43. package/src/ready.js +85 -0
  44. package/src/request.js +246 -0
  45. package/src/schema-migration.js +300 -0
  46. package/src/validate.js +1208 -0
@@ -0,0 +1,1979 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { withLegacyMutationFence } from './claim-coordinator.js';
4
+ import { link, lstat, mkdir, open, rename, unlink, writeFile } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { isDeepStrictEqual } from 'node:util';
7
+ import { isAlias, isMap, isScalar, isSeq, parseDocument, Scalar } from 'yaml';
8
+ import { isDependencySatisfied } from './dependencies.js';
9
+ import { loadLedger, parseLedgerItemSource } from './ledger.js';
10
+ import { JsonNumber, parseJsonRequest, pointer, sortIssues } from './request.js';
11
+ import { isCalendarDate, isRfc3339Utc, validateLedger } from './validate.js';
12
+
13
+ const REQUIRED_CORE_FIELDS = [
14
+ 'schema_version',
15
+ 'id',
16
+ 'title',
17
+ 'kind',
18
+ 'status',
19
+ 'created',
20
+ 'updated',
21
+ ];
22
+ const OPTIONAL_CORE_FIELDS = [
23
+ 'parent',
24
+ 'snoozed_until',
25
+ 'completed',
26
+ 'killed',
27
+ 'archived',
28
+ 'deferred',
29
+ ];
30
+ // Schema-1 fields a caller supplies and create must keep accepting; they join
31
+ // the core view but must stay out of CONTROLLED_ITEM_FIELDS.
32
+ const CONSUMER_CORE_FIELDS = [
33
+ 'number',
34
+ 'priority',
35
+ ];
36
+ const CONTROLLED_ITEM_FIELDS = new Set([
37
+ ...REQUIRED_CORE_FIELDS,
38
+ ...OPTIONAL_CORE_FIELDS,
39
+ 'provenance',
40
+ 'depends_on',
41
+ 'related',
42
+ 'decisions',
43
+ 'body',
44
+ ]);
45
+ // Everything the core view owns. Extension-node identity preserves only
46
+ // fields outside this set; core-owned values are compared through coreView.
47
+ const CORE_OWNED_FIELDS = new Set([
48
+ ...CONTROLLED_ITEM_FIELDS,
49
+ ...CONSUMER_CORE_FIELDS,
50
+ ]);
51
+ const ULID_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
52
+ const ULID_PATTERN = /^wb_([0-7][0-9A-HJKMNP-TV-Z]{25})$/;
53
+ const MAX_LOCK_CLOSURE_RETRIES = 3;
54
+
55
+ export async function inspectItem(ledgerDirectory, id) {
56
+ const ledger = await loadLedger(ledgerDirectory);
57
+ const validation = validateLedger(ledger);
58
+ if (!validation.valid) {
59
+ return { validation };
60
+ }
61
+
62
+ const item = ledger.items.find((candidate) => candidate.data.id === id);
63
+ if (!item) {
64
+ return { item: null };
65
+ }
66
+
67
+ return { item: inspectedItem(id, displayItemPath(item.path), item.bytes, item.data, item.body) };
68
+ }
69
+
70
+ export function revisionFor(bytes) {
71
+ return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
72
+ }
73
+
74
+ function inspectedPublishedItem(id, displayPath, bytes) {
75
+ const parsed = parseLedgerItemSource(bytes.toString('utf8'));
76
+ if (parsed.error) {
77
+ throw new Error('Published bytes could not be parsed.');
78
+ }
79
+ return inspectedItem(id, displayPath, bytes, parsed.data, parsed.body);
80
+ }
81
+
82
+ function inspectedItem(id, displayPath, bytes, data, body) {
83
+ return {
84
+ id,
85
+ path: displayPath,
86
+ revision: revisionFor(bytes),
87
+ source_encoding: 'base64',
88
+ source_media_type: 'text/markdown; charset=utf-8',
89
+ source_base64: bytes.toString('base64'),
90
+ core: coreView(data),
91
+ body,
92
+ };
93
+ }
94
+
95
+ export function validateCreateRequest(request, parseIssues = []) {
96
+ const issues = [...parseIssues];
97
+ if (issues.some((entry) => entry.code === 'invalid-json')) {
98
+ return sortIssues(issues);
99
+ }
100
+ if (!isMapping(request)) {
101
+ issues.push(issue('', 'invalid-type', 'Request input must be a JSON object.'));
102
+ return sortIssues(issues);
103
+ }
104
+
105
+ validateObjectMembers(request, [], ['id', 'item', 'body'], issues, 'Request member');
106
+ validateRequiredMember(request, [], 'id', issues);
107
+ validateRequiredMember(request, [], 'item', issues);
108
+ validateRequiredMember(request, [], 'body', issues);
109
+
110
+ if (hasOwn(request, 'id') && (typeof request.id !== 'string' || !ULID_PATTERN.test(request.id))) {
111
+ issues.push(issue('/id', 'invalid-value', 'Member id must be a canonical Wowbagger item ID.'));
112
+ }
113
+ if (hasOwn(request, 'body') && typeof request.body !== 'string') {
114
+ issues.push(issue('/body', 'invalid-type', 'Member body must be a string.'));
115
+ }
116
+
117
+ if (!isMapping(request.item)) {
118
+ if (hasOwn(request, 'item')) {
119
+ issues.push(issue('/item', 'invalid-type', 'Member item must be an object.'));
120
+ }
121
+ return sortIssues(issues);
122
+ }
123
+
124
+ const item = request.item;
125
+ for (const field of ['title', 'kind', 'provenance', 'depends_on']) {
126
+ validateRequiredMember(item, ['item'], field, issues);
127
+ }
128
+ const controlled = new Set([
129
+ 'schema_version', 'id', 'status', 'created', 'updated', 'completed',
130
+ 'killed', 'archived', 'deferred', 'decisions', 'body',
131
+ ]);
132
+ for (const field of Object.keys(item)) {
133
+ if (controlled.has(field)) {
134
+ const message = field === 'status'
135
+ ? 'Item member status is controlled by Wowbagger. Create assigns triage; a transition from triage to backlog accepts the item into ready.'
136
+ : `Item member ${field} is controlled by Wowbagger.`;
137
+ issues.push(issue(pointer(['item', field]), 'invalid-value', message));
138
+ }
139
+ }
140
+ if (hasOwn(item, 'title') && (typeof item.title !== 'string' || item.title.trim().length === 0)) {
141
+ issues.push(issue('/item/title', 'invalid-type', 'Item member title must be a non-empty string.'));
142
+ }
143
+ if (hasOwn(item, 'kind') && (item.kind !== 'task' && item.kind !== 'epic')) {
144
+ issues.push(issue('/item/kind', 'invalid-value', 'Item member kind must be task or epic.'));
145
+ }
146
+ if (hasOwn(item, 'priority') && !isPatchableInteger(item.priority, 0)) {
147
+ issues.push(issue('/item/priority', 'invalid-value', 'Item member priority must be a non-negative integer.'));
148
+ }
149
+ if (hasOwn(item, 'number') && !isPatchableInteger(item.number, 1)) {
150
+ issues.push(issue('/item/number', 'invalid-value', 'Item member number must be a positive integer.'));
151
+ }
152
+ if (hasOwn(item, 'depends_on') && !Array.isArray(item.depends_on)) {
153
+ issues.push(issue('/item/depends_on', 'invalid-type', 'Item member depends_on must be an array.'));
154
+ } else if (Array.isArray(item.depends_on)) {
155
+ validateRelationEntries(item.depends_on, 'depends_on', issues);
156
+ }
157
+ if (hasOwn(item, 'related') && !Array.isArray(item.related)) {
158
+ issues.push(issue('/item/related', 'invalid-type', 'Item member related must be an array.'));
159
+ } else if (Array.isArray(item.related)) {
160
+ validateRelationEntries(item.related, 'related', issues);
161
+ }
162
+ if (hasOwn(item, 'provenance') && !isMapping(item.provenance)) {
163
+ issues.push(issue('/item/provenance', 'invalid-type', 'Item member provenance must be an object.'));
164
+ } else if (isMapping(item.provenance)) {
165
+ for (const field of ['source', 'recorded_at']) {
166
+ if (!hasOwn(item.provenance, field)) {
167
+ issues.push(issue(`/item/provenance/${field}`, 'missing-member', `Provenance member ${field} is missing.`));
168
+ }
169
+ }
170
+ if (hasOwn(item.provenance, 'source')
171
+ && (typeof item.provenance.source !== 'string' || item.provenance.source.trim().length === 0)) {
172
+ issues.push(issue('/item/provenance/source', 'invalid-type', 'Provenance member source must be a non-empty string.'));
173
+ }
174
+ if (hasOwn(item.provenance, 'recorded_at') && !isRfc3339Utc(item.provenance.recorded_at)) {
175
+ issues.push(issue('/item/provenance/recorded_at', 'invalid-value', 'Provenance member recorded_at must be an RFC 3339 UTC instant.'));
176
+ }
177
+ }
178
+ if (hasOwn(item, 'parent') && (typeof item.parent !== 'string' || !ULID_PATTERN.test(item.parent))) {
179
+ issues.push(issue('/item/parent', 'invalid-value', 'Item member parent must be a canonical Wowbagger item ID.'));
180
+ }
181
+ if (hasOwn(item, 'snoozed_until') && !isCalendarDate(item.snoozed_until)) {
182
+ issues.push(issue('/item/snoozed_until', 'invalid-value', 'Item member snoozed_until must be an ISO calendar date.'));
183
+ }
184
+
185
+ return sortIssues(issues);
186
+ }
187
+
188
+ export async function createItem(ledgerDirectory, request, scenario) {
189
+ return withLegacyMutationFence(
190
+ ledgerDirectory,
191
+ request.id,
192
+ 'create-v1',
193
+ () => createItemUnfenced(ledgerDirectory, request, scenario),
194
+ );
195
+ }
196
+
197
+ async function createItemUnfenced(ledgerDirectory, request, scenario) {
198
+ const root = path.resolve(ledgerDirectory);
199
+ const id = request.id;
200
+
201
+ for (let attempt = 0; attempt < MAX_LOCK_CLOSURE_RETRIES; attempt += 1) {
202
+ const initial = await loadedValidLedger(root);
203
+ if (!initial.valid) {
204
+ return ledgerInvalid(initial.validation);
205
+ }
206
+ const nextIds = lockIdsForCreate(request, initial.ledger);
207
+ if (scenario === 'expand-lock-closure-through-bounded-retry-limit') {
208
+ return operationFailed(id, 'lock-closure', 'retry-limit-exhausted');
209
+ }
210
+ let locks;
211
+ try {
212
+ locks = await acquireLocks(root, nextIds, 'create', scenario);
213
+ } catch (error) {
214
+ if (error instanceof ResourceFailure) {
215
+ return operationFailed(id, error.operation, 'io-error', await artifactsForLocks(error.locks, root));
216
+ }
217
+ if (error instanceof LockHeldError) {
218
+ return lockHeld(id, error.file, await lockDetails(error.file));
219
+ }
220
+ return operationFailed(id, 'lock-closure', 'io-error');
221
+ }
222
+
223
+ let preserveLocks = false;
224
+ let temporaryPath = null;
225
+ const finishUncommitted = async (outcome, artifacts = []) => {
226
+ const finished = await finishUncommittedResources(id, outcome, locks, root, scenario, artifacts);
227
+ locks = finished.locks;
228
+ preserveLocks = finished.preserveLocks;
229
+ return finished.outcome;
230
+ };
231
+ const finishUnknown = async (outcome) => {
232
+ const finished = await finishUnknownResources(outcome, locks, root, scenario);
233
+ locks = finished.locks;
234
+ preserveLocks = finished.preserveLocks;
235
+ return finished.outcome;
236
+ };
237
+ const finishCommittedRecovery = async (bytes, artifacts = []) => {
238
+ const finished = await finishCommittedResources(id, bytes, locks, root, scenario, artifacts);
239
+ locks = finished.locks;
240
+ preserveLocks = finished.preserveLocks;
241
+ return finished.outcome;
242
+ };
243
+ try {
244
+ const current = await loadedValidLedger(root);
245
+ if (!current.valid) {
246
+ return await finishUncommitted(ledgerInvalid(current.validation));
247
+ }
248
+ const stableIds = lockIdsForCreate(request, current.ledger);
249
+ if (!sameIds(nextIds, stableIds)) {
250
+ const cleanupFailure = await finishUncommitted(null);
251
+ if (cleanupFailure) {
252
+ return cleanupFailure;
253
+ }
254
+ continue;
255
+ }
256
+
257
+ const existing = current.ledger.items.find((item) => item.data.id === id);
258
+ if (existing) {
259
+ return await finishUncommitted(mutationError('id-collision', 'The requested item ID already exists.', 'unchanged', 4, {
260
+ id,
261
+ path: displayItemPath(existing.path),
262
+ actual_revision: revisionFor(existing.bytes),
263
+ }));
264
+ }
265
+
266
+ const finalPath = path.join(root, `${id}.md`);
267
+ const occupant = await pathOccupant(finalPath, current.ledger);
268
+ if (occupant) {
269
+ const details = {
270
+ id,
271
+ path: `${id}.md`,
272
+ occupant_kind: occupant.kind,
273
+ };
274
+ if (occupant.id) {
275
+ details.occupying_id = occupant.id;
276
+ }
277
+ return await finishUncommitted(mutationError('path-collision', 'The default item path is occupied by a different item.', 'unchanged', 4, details));
278
+ }
279
+
280
+ const schemaVersion = current.ledger.items[0]?.data.schema_version ?? 1;
281
+ const bytes = createCandidateSource(request, schemaVersion);
282
+ const candidateValidation = validateSerializedCandidate(
283
+ current.ledger,
284
+ null,
285
+ bytes,
286
+ `${path.basename(root)}/${id}.md`,
287
+ finalPath,
288
+ );
289
+ if (!candidateValidation.valid) {
290
+ return await finishUncommitted(mutationError('candidate-invalid', 'The proposed item would make the ledger invalid.', 'unchanged', 2, {
291
+ id,
292
+ validation_errors: candidateValidation.errors,
293
+ }));
294
+ }
295
+
296
+ if (scenario === 'atomic-no-clobber-primitive-unavailable') {
297
+ return await finishUncommitted(mutationError('capability-unavailable', 'Atomic no-clobber publication is unavailable for this ledger.', 'unchanged', 5, {
298
+ capability: 'atomic-no-clobber-publication',
299
+ reason: 'filesystem-primitive-unavailable',
300
+ recovery_artifacts: [],
301
+ recovery_artifacts_truncated: false,
302
+ }));
303
+ }
304
+
305
+ temporaryPath = path.join(root, `.wowbagger-tmp-${id}-${randomSuffix()}`);
306
+ const temporaryFailure = await prepareTemporary(temporaryPath, bytes, scenario);
307
+ if (temporaryFailure) {
308
+ const artifacts = await cleanupTemporary(temporaryPath, root, scenario);
309
+ temporaryPath = artifacts.length > 0 ? temporaryPath : null;
310
+ return await finishUncommitted(operationFailed(id, temporaryFailure, 'io-error', artifacts), artifacts);
311
+ }
312
+
313
+ let publicationError = null;
314
+ try {
315
+ await link(temporaryPath, finalPath);
316
+ if (scenarioName(scenario) === 'create-link-applied-then-error') {
317
+ await writeFile(path.join(root, '.wowbagger-test-publication-fault'), 'link applied before error\n');
318
+ throw new Error('fixture link applied then error');
319
+ }
320
+ } catch (error) {
321
+ publicationError = error;
322
+ }
323
+ if (publicationError) {
324
+ const evidence = await observePublication(finalPath, bytes);
325
+ if (evidence.state !== 'expected') {
326
+ const artifacts = await cleanupTemporary(temporaryPath, root, scenario);
327
+ temporaryPath = artifacts.length > 0 ? temporaryPath : null;
328
+ if (evidence.state === 'absent') {
329
+ if (isUnavailableNoClobber(publicationError)) {
330
+ return await finishUncommitted(mutationError('capability-unavailable', 'Atomic no-clobber publication is unavailable for this ledger.', 'unchanged', 5, {
331
+ capability: 'atomic-no-clobber-publication',
332
+ reason: 'filesystem-primitive-unavailable',
333
+ recovery_artifacts: boundedArtifacts(artifacts).artifacts,
334
+ recovery_artifacts_truncated: boundedArtifacts(artifacts).truncated,
335
+ }), artifacts);
336
+ }
337
+ return await finishUncommitted(operationFailed(id, 'publish', 'io-error', artifacts), artifacts);
338
+ }
339
+ return await finishUnknown(unknownPublication('create', id, `${id}.md`, evidence.bytes, artifacts));
340
+ }
341
+ }
342
+
343
+ if (scenario === 'final-mismatch-and-temporary-unlink-fail') {
344
+ await unlink(finalPath);
345
+ await writeFile(finalPath, 'fixture different final bytes\n');
346
+ } else if (scenario === 'final-absence-and-temporary-unlink-fail') {
347
+ await unlink(finalPath);
348
+ }
349
+
350
+ const evidence = scenario === 'final-verification-read-fails-after-publication'
351
+ ? { state: 'unknown', bytes: null }
352
+ : await observePublication(finalPath, bytes);
353
+ if (evidence.state !== 'expected') {
354
+ const artifacts = await cleanupTemporary(temporaryPath, root, scenario);
355
+ temporaryPath = artifacts.length > 0 ? temporaryPath : null;
356
+ if (evidence.state === 'absent') {
357
+ return await finishUncommitted(operationFailed(id, 'verify-publication', 'verification-failed', artifacts), artifacts);
358
+ }
359
+ return await finishUnknown(unknownPublication('create', id, `${id}.md`, evidence.bytes, artifacts));
360
+ }
361
+
362
+ let directorySyncFailed = false;
363
+ try {
364
+ await syncDirectoryIfSupported(root);
365
+ } catch {
366
+ directorySyncFailed = true;
367
+ }
368
+
369
+ const temporaryArtifacts = await cleanupTemporary(temporaryPath, root, scenario);
370
+ temporaryPath = temporaryArtifacts.length > 0 ? temporaryPath : null;
371
+ if (directorySyncFailed || temporaryArtifacts.length > 0) {
372
+ return await finishCommittedRecovery(bytes, temporaryArtifacts);
373
+ }
374
+
375
+ if (scenario === 'final-bytes-verified-directory-sync-and-lock-cleanup-fail') {
376
+ const lock = locks.find((entry) => entry.id === id);
377
+ await writeFixtureRecoveryLock(lock.file, id);
378
+ return await finishCommittedRecovery(bytes);
379
+ }
380
+
381
+ const result = inspectedPublishedItem(id, `${id}.md`, evidence.bytes);
382
+ const failedLocks = await releaseLocks(locks, scenario);
383
+ locks = failedLocks;
384
+ if (failedLocks.length > 0) {
385
+ preserveLocks = true;
386
+ return postCommitRecovery(id, bytes, await artifactsForLocks(failedLocks, root));
387
+ }
388
+ await testCheckpoint(root, scenario, 'after-success-release');
389
+ return mutationSuccess(result);
390
+ } finally {
391
+ if (temporaryPath) {
392
+ await cleanupTemporary(temporaryPath, root, scenario);
393
+ }
394
+ if (!preserveLocks) {
395
+ await releaseLocks(locks, scenario);
396
+ }
397
+ }
398
+ }
399
+
400
+ return operationFailed(id, 'lock-closure', 'retry-limit-exhausted');
401
+ }
402
+
403
+ export function validateTransitionRequest(request, parseIssues = []) {
404
+ const issues = [...parseIssues];
405
+ if (issues.some((entry) => entry.code === 'invalid-json')) {
406
+ return sortIssues(issues);
407
+ }
408
+ if (!isMapping(request)) {
409
+ return [issue('', 'invalid-type', 'Request input must be a JSON object.')];
410
+ }
411
+ validateObjectMembers(request, [], ['id', 'expected_revision', 'to_status', 'date', 'decision'], issues, 'Request member');
412
+ for (const field of ['id', 'expected_revision', 'to_status', 'date']) {
413
+ validateRequiredMember(request, [], field, issues);
414
+ }
415
+ if (hasOwn(request, 'id') && (typeof request.id !== 'string' || !ULID_PATTERN.test(request.id))) {
416
+ issues.push(issue('/id', 'invalid-value', 'Member id must be a canonical Wowbagger item ID.'));
417
+ }
418
+ if (hasOwn(request, 'expected_revision') && (typeof request.expected_revision !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(request.expected_revision))) {
419
+ issues.push(issue('/expected_revision', 'invalid-value', 'Member expected_revision must be a lowercase SHA-256 revision token.'));
420
+ }
421
+ if (hasOwn(request, 'to_status') && typeof request.to_status !== 'string') {
422
+ issues.push(issue('/to_status', 'invalid-type', 'Member to_status must be a string.'));
423
+ }
424
+ if (hasOwn(request, 'date') && (typeof request.date !== 'string' || !isCalendarDate(request.date))) {
425
+ issues.push(issue('/date', 'invalid-value', 'Member date must be an ISO calendar date.'));
426
+ }
427
+ if (hasOwn(request, 'decision') && !isMapping(request.decision)) {
428
+ issues.push(issue('/decision', 'invalid-type', 'Member decision must be an object.'));
429
+ }
430
+ if (isMapping(request.decision)) {
431
+ validateObjectMembers(request.decision, ['decision'], ['summary', 'rationale'], issues, 'Decision member');
432
+ for (const field of ['summary', 'rationale']) {
433
+ if (!hasOwn(request.decision, field)) {
434
+ issues.push(issue(pointer(['decision', field]), 'missing-member', `Decision member ${field} is missing.`));
435
+ } else if (typeof request.decision[field] !== 'string' || request.decision[field].trim().length === 0) {
436
+ issues.push(issue(pointer(['decision', field]), 'invalid-type', `Decision member ${field} must be a non-empty string.`));
437
+ }
438
+ }
439
+ }
440
+ return sortIssues(issues);
441
+ }
442
+
443
+ export async function transitionItem(ledgerDirectory, request, scenario) {
444
+ return withLegacyMutationFence(ledgerDirectory, request.id, 'transition-v1', () => (
445
+ mutateExistingItem(ledgerDirectory, request, scenario, {
446
+ name: 'transition',
447
+ lockIds: lockIdsForTransition,
448
+ build: buildTransition,
449
+ })
450
+ ));
451
+ }
452
+
453
+ export async function publishClaimedCandidate(ledgerDirectory, request, scenario) {
454
+ return mutateExistingItem(ledgerDirectory, {
455
+ ...request,
456
+ id: request.item_id,
457
+ }, scenario, {
458
+ name: 'publish-claimed',
459
+ lockIds: (_target, ledger) => ledger.items
460
+ .map((item) => item.data.id)
461
+ .sort(compareText),
462
+ build: (_target, _ledger, publicationRequest) => {
463
+ const bytes = Buffer.from(publicationRequest.candidate_source_base64, 'base64');
464
+ const parsed = parseLedgerItemSource(bytes.toString('utf8'));
465
+ return { successor: parsed.data, bytes };
466
+ },
467
+ });
468
+ }
469
+
470
+ // Shared locked-mutation engine for operations that rewrite one existing
471
+ // item: lock closure, exact-byte revision compare-and-swap, candidate
472
+ // complete-ledger validation, and atomic same-path publication with the
473
+ // recovery protocol. `operation` supplies the name used in lock metadata and
474
+ // diagnostics, the lock-closure rule, and the request-specific build step.
475
+ async function mutateExistingItem(ledgerDirectory, request, scenario, operation) {
476
+ const root = path.resolve(ledgerDirectory);
477
+ const id = request.id;
478
+
479
+ for (let attempt = 0; attempt < MAX_LOCK_CLOSURE_RETRIES; attempt += 1) {
480
+ const initial = await loadedValidLedger(root);
481
+ if (!initial.valid) {
482
+ return ledgerInvalid(initial.validation);
483
+ }
484
+ const target = findItem(initial.ledger, id);
485
+ if (!target) {
486
+ return mutationError('item-not-found', 'The requested item was not found.', 'unchanged', 2, { id });
487
+ }
488
+ const nextIds = operation.lockIds(target, initial.ledger);
489
+ if (scenario === 'expand-lock-closure-through-bounded-retry-limit') {
490
+ return operationFailed(id, 'lock-closure', 'retry-limit-exhausted');
491
+ }
492
+ let locks;
493
+ try {
494
+ locks = await acquireLocks(root, nextIds, operation.name, scenario);
495
+ } catch (error) {
496
+ if (error instanceof ResourceFailure) {
497
+ return operationFailed(id, error.operation, 'io-error', await artifactsForLocks(error.locks, root));
498
+ }
499
+ if (error instanceof LockHeldError) {
500
+ return lockHeld(id, error.file, await lockDetails(error.file));
501
+ }
502
+ return operationFailed(id, 'lock-closure', 'io-error');
503
+ }
504
+
505
+ let preserveLocks = false;
506
+ let temporaryPath = null;
507
+ const finishUncommitted = async (outcome, artifacts = []) => {
508
+ const finished = await finishUncommittedResources(id, outcome, locks, root, scenario, artifacts);
509
+ locks = finished.locks;
510
+ preserveLocks = finished.preserveLocks;
511
+ return finished.outcome;
512
+ };
513
+ const finishUnknown = async (outcome) => {
514
+ const finished = await finishUnknownResources(outcome, locks, root, scenario);
515
+ locks = finished.locks;
516
+ preserveLocks = finished.preserveLocks;
517
+ return finished.outcome;
518
+ };
519
+ const finishCommittedRecovery = async (bytes, artifacts = []) => {
520
+ const finished = await finishCommittedResources(id, bytes, locks, root, scenario, artifacts);
521
+ locks = finished.locks;
522
+ preserveLocks = finished.preserveLocks;
523
+ return finished.outcome;
524
+ };
525
+ try {
526
+ const current = await loadedValidLedger(root);
527
+ if (!current.valid) {
528
+ return await finishUncommitted(ledgerInvalid(current.validation));
529
+ }
530
+ const lockedTarget = findItem(current.ledger, id);
531
+ if (!lockedTarget) {
532
+ return await finishUncommitted(mutationError('item-not-found', 'The requested item was not found.', 'unchanged', 2, { id }));
533
+ }
534
+ const stableIds = operation.lockIds(lockedTarget, current.ledger);
535
+ if (!sameIds(nextIds, stableIds)) {
536
+ const cleanupFailure = await finishUncommitted(null);
537
+ if (cleanupFailure) {
538
+ return cleanupFailure;
539
+ }
540
+ continue;
541
+ }
542
+
543
+ const actualRevision = revisionFor(lockedTarget.bytes);
544
+ if (actualRevision !== request.expected_revision) {
545
+ return await finishUncommitted(mutationError('revision-conflict', 'The item changed after it was inspected.', 'unchanged', 4, {
546
+ id,
547
+ expected_revision: request.expected_revision,
548
+ actual_revision: actualRevision,
549
+ }));
550
+ }
551
+
552
+ const built = operation.build(lockedTarget, current.ledger, request);
553
+ if (built.outcome) {
554
+ return await finishUncommitted(built.outcome);
555
+ }
556
+ const { successor, bytes } = built;
557
+ const candidateValidation = validateSerializedCandidate(
558
+ current.ledger,
559
+ id,
560
+ bytes,
561
+ lockedTarget.path,
562
+ lockedTarget.file,
563
+ successor,
564
+ lockedTarget.source,
565
+ );
566
+ if (!candidateValidation.valid) {
567
+ return await finishUncommitted(mutationError('candidate-invalid', 'The proposed item would make the ledger invalid.', 'unchanged', 2, {
568
+ id,
569
+ validation_errors: candidateValidation.errors,
570
+ }));
571
+ }
572
+
573
+ const targetDirectory = path.dirname(lockedTarget.file);
574
+ temporaryPath = path.join(targetDirectory, `.wowbagger-tmp-${id}-${randomSuffix()}`);
575
+ if (scenarioName(scenario) === 'transition-rename-applied-then-error') {
576
+ await writeFile(path.join(root, '.wowbagger-test-transition-paths.json'), JSON.stringify({
577
+ temporary_directory: relativeDirectory(root, path.dirname(temporaryPath)),
578
+ final_directory: relativeDirectory(root, targetDirectory),
579
+ synced_directory: null,
580
+ }));
581
+ }
582
+ const temporaryFailure = await prepareTemporary(temporaryPath, bytes, scenario);
583
+ if (temporaryFailure) {
584
+ const artifacts = await cleanupTemporary(temporaryPath, root, scenario);
585
+ temporaryPath = artifacts.length > 0 ? temporaryPath : null;
586
+ return await finishUncommitted(operationFailed(id, temporaryFailure, 'io-error', artifacts), artifacts);
587
+ }
588
+
589
+ let publicationError = null;
590
+ try {
591
+ await rename(temporaryPath, lockedTarget.file);
592
+ temporaryPath = null;
593
+ if (scenarioName(scenario) === 'transition-rename-applied-then-error') {
594
+ await writeFile(path.join(root, '.wowbagger-test-publication-fault'), 'rename applied before error\n');
595
+ throw new Error('fixture rename applied then error');
596
+ }
597
+ } catch (error) {
598
+ publicationError = error;
599
+ }
600
+ if (publicationError) {
601
+ const evidence = await observePublication(lockedTarget.file, bytes, lockedTarget.bytes);
602
+ if (evidence.state === 'expected') {
603
+ temporaryPath = null;
604
+ } else {
605
+ const artifacts = temporaryPath ? await cleanupTemporary(temporaryPath, root, scenario) : [];
606
+ temporaryPath = artifacts.length > 0 ? temporaryPath : null;
607
+ if (evidence.state === 'original') {
608
+ return await finishUncommitted(operationFailed(id, 'publish', 'verification-failed', artifacts), artifacts);
609
+ }
610
+ return await finishUnknown(unknownPublication(
611
+ operation.name,
612
+ id,
613
+ displayItemPath(lockedTarget.path),
614
+ evidence.bytes,
615
+ artifacts,
616
+ ));
617
+ }
618
+ }
619
+
620
+ let published;
621
+ try {
622
+ published = await readRegularFile(lockedTarget.file);
623
+ } catch {
624
+ return await finishUnknown(mutationError('write-outcome-unknown', `The ${operation.name} publication outcome could not be verified.`, 'unknown', 6, {
625
+ id,
626
+ recovery_artifacts: [{
627
+ path: displayItemPath(lockedTarget.path),
628
+ kind: 'final-item',
629
+ sha256: null,
630
+ size_bytes: null,
631
+ }],
632
+ recovery_artifacts_truncated: false,
633
+ }));
634
+ }
635
+ if (!published.equals(bytes)) {
636
+ return await finishUnknown(mutationError('write-outcome-unknown', `The ${operation.name} publication outcome could not be verified.`, 'unknown', 6, {
637
+ id,
638
+ recovery_artifacts: [{
639
+ path: displayItemPath(lockedTarget.path),
640
+ kind: 'final-item',
641
+ sha256: revisionFor(published),
642
+ size_bytes: published.length,
643
+ }],
644
+ recovery_artifacts_truncated: false,
645
+ }));
646
+ }
647
+ try {
648
+ await syncDirectoryIfSupported(targetDirectory);
649
+ if (scenarioName(scenario) === 'transition-rename-applied-then-error') {
650
+ await writeFile(path.join(root, '.wowbagger-test-transition-paths.json'), JSON.stringify({
651
+ temporary_directory: relativeDirectory(root, targetDirectory),
652
+ final_directory: relativeDirectory(root, targetDirectory),
653
+ synced_directory: relativeDirectory(root, targetDirectory),
654
+ }));
655
+ }
656
+ } catch {
657
+ return await finishCommittedRecovery(bytes);
658
+ }
659
+ const result = inspectedPublishedItem(id, displayItemPath(lockedTarget.path), published);
660
+ const failedLocks = await releaseLocks(locks, scenario);
661
+ locks = failedLocks;
662
+ if (failedLocks.length > 0) {
663
+ preserveLocks = true;
664
+ return postCommitRecovery(id, bytes, await artifactsForLocks(failedLocks, root));
665
+ }
666
+ await testCheckpoint(root, scenario, 'after-success-release');
667
+ return mutationSuccess(result);
668
+ } finally {
669
+ if (temporaryPath) {
670
+ await cleanupTemporary(temporaryPath, root, scenario);
671
+ }
672
+ if (!preserveLocks) {
673
+ await releaseLocks(locks, scenario);
674
+ }
675
+ }
676
+ }
677
+
678
+ return operationFailed(id, 'lock-closure', 'retry-limit-exhausted');
679
+ }
680
+
681
+ // The exact patchable field set (mutation contract section 8). Everything
682
+ // else stays a reviewable hand-edit or a transition concern.
683
+ const PATCHABLE_FIELDS = ['number', 'priority'];
684
+ // Where a newly added field lands in the frontmatter; both anchors are
685
+ // required members, so they always exist.
686
+ const PATCH_FIELD_ANCHORS = { number: 'id', priority: 'kind' };
687
+
688
+ export function validatePatchRequest(request, parseIssues = []) {
689
+ const issues = [...parseIssues];
690
+ if (issues.some((entry) => entry.code === 'invalid-json')) {
691
+ return sortIssues(issues);
692
+ }
693
+ if (!isMapping(request)) {
694
+ return [issue('', 'invalid-type', 'Request input must be a JSON object.')];
695
+ }
696
+ validateObjectMembers(request, [], ['id', 'expected_revision', 'date', 'set'], issues, 'Request member');
697
+ for (const field of ['id', 'expected_revision', 'date', 'set']) {
698
+ validateRequiredMember(request, [], field, issues);
699
+ }
700
+ if (hasOwn(request, 'id') && (typeof request.id !== 'string' || !ULID_PATTERN.test(request.id))) {
701
+ issues.push(issue('/id', 'invalid-value', 'Member id must be a canonical Wowbagger item ID.'));
702
+ }
703
+ if (hasOwn(request, 'expected_revision') && (typeof request.expected_revision !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(request.expected_revision))) {
704
+ issues.push(issue('/expected_revision', 'invalid-value', 'Member expected_revision must be a lowercase SHA-256 revision token.'));
705
+ }
706
+ if (hasOwn(request, 'date') && (typeof request.date !== 'string' || !isCalendarDate(request.date))) {
707
+ issues.push(issue('/date', 'invalid-value', 'Member date must be an ISO calendar date.'));
708
+ }
709
+ if (hasOwn(request, 'set') && !isMapping(request.set)) {
710
+ issues.push(issue('/set', 'invalid-type', 'Member set must be an object.'));
711
+ }
712
+ if (isMapping(request.set)) {
713
+ validateObjectMembers(request.set, ['set'], PATCHABLE_FIELDS, issues, 'Set member');
714
+ if (Object.keys(request.set).length === 0) {
715
+ issues.push(issue('/set', 'invalid-value', 'Member set must name at least one patchable field.'));
716
+ }
717
+ if (hasOwn(request.set, 'number') && request.set.number !== null && !isPatchableInteger(request.set.number, 1)) {
718
+ issues.push(issue('/set/number', 'invalid-value', 'Set member number must be a positive integer or null.'));
719
+ }
720
+ if (hasOwn(request.set, 'priority') && request.set.priority !== null && !isPatchableInteger(request.set.priority, 0)) {
721
+ issues.push(issue('/set/priority', 'invalid-value', 'Set member priority must be a non-negative integer or null.'));
722
+ }
723
+ }
724
+ return sortIssues(issues);
725
+ }
726
+
727
+ function isPatchableInteger(value, minimum) {
728
+ const unwrapped = value instanceof JsonNumber
729
+ ? (/^(0|[1-9][0-9]*)$/.test(value.source) ? Number(value.source) : NaN)
730
+ : value;
731
+ return typeof unwrapped === 'number' && Number.isSafeInteger(unwrapped) && unwrapped >= minimum;
732
+ }
733
+
734
+ export async function patchItem(ledgerDirectory, request, scenario) {
735
+ return withLegacyMutationFence(ledgerDirectory, request.id, 'transition-v1', () => (
736
+ mutateExistingItem(ledgerDirectory, request, scenario, {
737
+ name: 'patch',
738
+ lockIds: (target) => [target.data.id],
739
+ build: buildPatch,
740
+ })
741
+ ));
742
+ }
743
+
744
+ function buildPatch(lockedTarget, ledger, request) {
745
+ const issues = [];
746
+ if (request.date < lockedTarget.data.created) {
747
+ issues.push(transitionIssue('date-before-created', 'date', 'Patch date must not be earlier than the current created date.', []));
748
+ }
749
+ if (request.date < lockedTarget.data.updated) {
750
+ issues.push(transitionIssue('date-before-updated', 'date', 'Patch date must not be earlier than the current updated date.', []));
751
+ }
752
+ if (issues.length > 0) {
753
+ return { outcome: mutationError('patch-precondition-failed', 'The requested patch failed its preconditions.', 'unchanged', 2, {
754
+ id: lockedTarget.data.id,
755
+ issues: issues.sort(compareTransitionIssues),
756
+ }) };
757
+ }
758
+ const successor = patchData(lockedTarget.data, request);
759
+ const bytes = Buffer.from(serializePatch(lockedTarget.source, successor, request), 'utf8');
760
+ return { successor, bytes };
761
+ }
762
+
763
+ function patchData(data, request) {
764
+ const successor = {
765
+ ...data,
766
+ provenance: { ...data.provenance },
767
+ depends_on: [...(data.depends_on ?? [])],
768
+ related: [...(data.related ?? [])],
769
+ updated: request.date,
770
+ };
771
+ for (const [field, value] of Object.entries(request.set)) {
772
+ if (value === null) {
773
+ delete successor[field];
774
+ } else {
775
+ successor[field] = value instanceof JsonNumber ? Number(value.source) : value;
776
+ }
777
+ }
778
+ return successor;
779
+ }
780
+
781
+ function serializePatch(source, successor, request) {
782
+ return rewriteFrontmatter(source, (document) => {
783
+ setRootScalar(document, 'updated', successor.updated);
784
+ for (const field of Object.keys(request.set)) {
785
+ if (!Object.hasOwn(successor, field)) {
786
+ document.delete(field);
787
+ } else if (document.has(field)) {
788
+ setRootScalar(document, field, successor[field]);
789
+ } else {
790
+ insertRootAfter(document, PATCH_FIELD_ANCHORS[field], field, successor[field]);
791
+ }
792
+ }
793
+ });
794
+ }
795
+
796
+ function rewriteFrontmatter(source, edit) {
797
+ const bounds = frontmatterBounds(source);
798
+ const frontmatter = source.slice(bounds.start, bounds.end);
799
+ const document = parseDocument(frontmatter, {
800
+ intAsBigInt: true,
801
+ keepSourceTokens: true,
802
+ prettyErrors: false,
803
+ schema: 'core',
804
+ uniqueKeys: true,
805
+ });
806
+ if (document.errors.length > 0 || !isMap(document.contents)) {
807
+ throw new Error('Unable to mutate malformed frontmatter.');
808
+ }
809
+ edit(document);
810
+ let serialized = document.toString({ lineWidth: 0 });
811
+ if (bounds.newline === '\r\n') {
812
+ serialized = serialized.replaceAll('\n', '\r\n');
813
+ }
814
+ if (!frontmatter.endsWith(bounds.newline)) {
815
+ serialized = serialized.slice(0, -bounds.newline.length);
816
+ }
817
+ return `${source.slice(0, bounds.start)}${serialized}${source.slice(bounds.end)}`;
818
+ }
819
+
820
+ function buildTransition(lockedTarget, ledger, request) {
821
+ const id = lockedTarget.data.id;
822
+ const edge = transitionEdge(lockedTarget.data.kind, lockedTarget.data.status, request.to_status);
823
+ const issues = transitionPreconditions(lockedTarget, ledger, request, edge);
824
+ const blockers = transitionBlockers(lockedTarget, ledger, request.to_status);
825
+ if (blockers.length > 0) {
826
+ return { outcome: mutationError('atomic-scope-required', 'The requested transition requires multi-item atomicity.', 'unchanged', 5, {
827
+ id,
828
+ blockers,
829
+ precondition_issues: issues,
830
+ }) };
831
+ }
832
+ if (issues.length > 0) {
833
+ return { outcome: mutationError('transition-precondition-failed', 'The requested lifecycle transition failed its preconditions.', 'unchanged', 2, {
834
+ id,
835
+ issues,
836
+ }) };
837
+ }
838
+ if (edge.requiresDecision && !isMapping(request.decision)) {
839
+ return { outcome: invalidTransitionDecision() };
840
+ }
841
+ if (!edge.requiresDecision && hasOwn(request, 'decision')) {
842
+ return { outcome: invalidTransitionDecision() };
843
+ }
844
+
845
+ const successor = transitionData(lockedTarget.data, request, edge, ledger);
846
+ const bytes = Buffer.from(serializeTransition(lockedTarget.source, successor, edge), 'utf8');
847
+ return { successor, bytes };
848
+ }
849
+
850
+ function invalidTransitionDecision() {
851
+ return mutationError('invalid-request', 'The transition request is invalid.', 'unchanged', 2, {
852
+ issues: [issue('/decision', 'invalid-value', 'Decision evidence does not match the requested lifecycle edge.')],
853
+ });
854
+ }
855
+
856
+ function findItem(ledger, id) {
857
+ return ledger.items.find((item) => item.data.id === id);
858
+ }
859
+
860
+ function lockIdsForTransition(target, ledger) {
861
+ const ids = [target.data.id];
862
+ if (target.data.parent) {
863
+ ids.push(target.data.parent);
864
+ }
865
+ ids.push(...(target.data.depends_on ?? []));
866
+ for (const item of ledger.items) {
867
+ if ((item.data.depends_on ?? []).includes(target.data.id)) {
868
+ ids.push(item.data.id);
869
+ }
870
+ if (target.data.kind === 'epic' && item.data.parent === target.data.id) {
871
+ ids.push(item.data.id);
872
+ }
873
+ }
874
+ return [...new Set(ids)].sort(compareText);
875
+ }
876
+
877
+ function transitionEdge(kind, from, to) {
878
+ const action = {
879
+ 'task:triage:backlog': 'accept',
880
+ 'epic:triage:backlog': 'accept',
881
+ 'task:triage:killed': 'kill',
882
+ 'epic:triage:killed': 'kill',
883
+ 'task:backlog:deferred': 'defer',
884
+ 'epic:backlog:deferred': 'defer',
885
+ 'task:deferred:backlog': 'undefer',
886
+ 'epic:deferred:backlog': 'undefer',
887
+ 'task:backlog:archived': 'archive',
888
+ 'task:backlog:killed': 'kill',
889
+ 'task:in-progress:done': 'complete',
890
+ 'task:in-progress:killed': 'kill',
891
+ 'epic:backlog:done': 'complete',
892
+ 'epic:backlog:archived': 'archive',
893
+ 'epic:backlog:killed': 'kill',
894
+ 'task:archived:backlog': 'restore',
895
+ 'epic:archived:backlog': 'restore',
896
+ }[`${kind}:${from}:${to}`] ?? null;
897
+ const allowed = (from === 'triage' && (to === 'backlog' || to === 'killed'))
898
+ || (from === 'backlog' && (to === 'in-progress' || to === 'archived' || to === 'killed' || to === 'deferred'))
899
+ || (from === 'deferred' && to === 'backlog')
900
+ || (kind === 'task' && from === 'in-progress' && ['backlog', 'done', 'killed'].includes(to))
901
+ || (kind === 'epic' && from === 'backlog' && ['done', 'archived', 'killed'].includes(to))
902
+ || (from === 'archived' && to === 'backlog');
903
+ return { allowed, action, requiresDecision: action !== null };
904
+ }
905
+
906
+ function transitionPreconditions(target, ledger, request, edge) {
907
+ const issues = [];
908
+ if (request.date < target.data.created) {
909
+ issues.push(transitionIssue('date-before-created', 'date', 'Transition date must not be earlier than the current created date.', []));
910
+ }
911
+ if (request.date < target.data.updated) {
912
+ issues.push(transitionIssue('date-before-updated', 'date', 'Transition date must not be earlier than the current updated date.', []));
913
+ }
914
+ if (!edge.allowed) {
915
+ issues.push(transitionIssue('invalid-edge', 'to_status', 'The requested lifecycle edge is not allowed for this item.', []));
916
+ }
917
+ const dependencies = target.data.depends_on ?? [];
918
+ const liveDependencies = target.data.schema_version === 2
919
+ ? dependencies.filter((id) => !isDependencySatisfied(findItem(ledger, id)?.data.status))
920
+ : dependencies;
921
+ if (request.to_status === 'done' && liveDependencies.length > 0) {
922
+ const message = target.data.schema_version === 2
923
+ ? 'Completion requires every depends_on target to be done.'
924
+ : 'Completion requires an empty depends_on list.';
925
+ issues.push(transitionIssue('live-dependencies', 'depends_on', message, [...liveDependencies].sort(compareText)));
926
+ }
927
+ if (target.data.kind === 'epic' && request.to_status === 'done') {
928
+ const children = ledger.items.filter((item) => item.data.parent === target.data.id);
929
+ const nonterminal = children.filter((item) => !['done', 'killed'].includes(item.data.status))
930
+ .map((item) => item.data.id).sort(compareText);
931
+ if (nonterminal.length > 0) {
932
+ issues.push(transitionIssue('nonterminal-children', 'parent', 'Epic completion requires every direct child to be done or killed.', nonterminal));
933
+ }
934
+ }
935
+ return issues.sort(compareTransitionIssues);
936
+ }
937
+
938
+ function transitionBlockers(target, ledger, toStatus) {
939
+ const blockers = [];
940
+ const requiresDependentMutation = ['killed', 'archived'].includes(toStatus)
941
+ || (toStatus === 'done' && target.data.schema_version === 1);
942
+ if (requiresDependentMutation) {
943
+ for (const item of ledger.items) {
944
+ if (item.data.id === target.data.id || !(item.data.depends_on ?? []).includes(target.data.id)) {
945
+ continue;
946
+ }
947
+ blockers.push({
948
+ code: toStatus === 'done' ? 'dependent-cleanup' : 'dependent-disposition',
949
+ item_id: item.data.id,
950
+ field: 'depends_on',
951
+ });
952
+ }
953
+ }
954
+ if (target.data.kind === 'epic' && ['killed', 'archived'].includes(toStatus)) {
955
+ for (const item of ledger.items) {
956
+ if (item.data.parent === target.data.id && ['triage', 'backlog', 'in-progress'].includes(item.data.status)) {
957
+ blockers.push({ code: 'child-disposition', item_id: item.data.id, field: 'parent' });
958
+ }
959
+ }
960
+ }
961
+ return blockers.sort((left, right) => compareText(left.code, right.code)
962
+ || compareText(left.item_id, right.item_id)
963
+ || compareText(left.field, right.field));
964
+ }
965
+
966
+ function transitionIssue(code, field, message, relatedIds) {
967
+ return { code, field, message, related_ids: relatedIds };
968
+ }
969
+
970
+ function compareTransitionIssues(left, right) {
971
+ return compareText(left.code, right.code)
972
+ || compareText(left.field, right.field)
973
+ || compareText(left.related_ids.join('\u0000'), right.related_ids.join('\u0000'));
974
+ }
975
+
976
+ function transitionData(data, request, edge, ledger) {
977
+ const successor = {
978
+ ...data,
979
+ provenance: { ...data.provenance },
980
+ depends_on: [...(data.depends_on ?? [])],
981
+ related: [...(data.related ?? [])],
982
+ status: request.to_status,
983
+ updated: request.date,
984
+ };
985
+ delete successor.completed;
986
+ delete successor.killed;
987
+ delete successor.archived;
988
+ delete successor.deferred;
989
+ if (request.to_status === 'done') {
990
+ successor.completed = request.date;
991
+ } else if (request.to_status === 'killed') {
992
+ successor.killed = request.date;
993
+ } else if (request.to_status === 'archived') {
994
+ successor.archived = request.date;
995
+ } else if (request.to_status === 'deferred') {
996
+ successor.deferred = request.date;
997
+ }
998
+ if (edge.requiresDecision) {
999
+ const decision = {
1000
+ action: edge.action,
1001
+ date: request.date,
1002
+ summary: request.decision.summary,
1003
+ rationale: request.decision.rationale,
1004
+ };
1005
+ if (data.kind === 'epic' && request.to_status === 'done') {
1006
+ decision.rollup = ledger.items
1007
+ .filter((item) => item.data.parent === data.id)
1008
+ .map((item) => ({ id: item.data.id, status: item.data.status }))
1009
+ .sort((left, right) => compareText(left.id, right.id));
1010
+ }
1011
+ successor.decisions = [...(data.decisions ?? []), decision];
1012
+ }
1013
+ return successor;
1014
+ }
1015
+
1016
+ function serializeTransition(source, successor, edge) {
1017
+ return rewriteFrontmatter(source, (document) => {
1018
+ setRootScalar(document, 'status', successor.status);
1019
+ setRootScalar(document, 'updated', successor.updated);
1020
+ document.delete('completed');
1021
+ document.delete('killed');
1022
+ document.delete('archived');
1023
+ document.delete('deferred');
1024
+ const terminal = terminalField(successor.status);
1025
+ if (terminal) {
1026
+ insertRootAfter(document, 'updated', terminal, successor[terminal]);
1027
+ }
1028
+ if (edge.requiresDecision) {
1029
+ appendDecisionNode(document, successor.decisions.at(-1));
1030
+ }
1031
+ });
1032
+ }
1033
+
1034
+ function setRootScalar(document, key, value) {
1035
+ const node = document.get(key, true);
1036
+ if (isScalar(node)) {
1037
+ node.value = value;
1038
+ return;
1039
+ }
1040
+ document.set(key, value);
1041
+ }
1042
+
1043
+ function insertRootAfter(document, afterKey, key, value) {
1044
+ const existing = document.contents.items.findIndex((pair) => pair.key?.value === key);
1045
+ if (existing >= 0) {
1046
+ document.contents.items.splice(existing, 1);
1047
+ }
1048
+ const index = document.contents.items.findIndex((pair) => pair.key?.value === afterKey);
1049
+ document.contents.items.splice(index + 1, 0, document.createPair(key, value));
1050
+ }
1051
+
1052
+ function appendDecisionNode(document, decision) {
1053
+ const existing = document.get('decisions', true);
1054
+ let decisions = existing;
1055
+ if (isAlias(existing)) {
1056
+ const resolved = existing.resolve(document);
1057
+ if (isSeq(resolved)) {
1058
+ decisions = resolved.clone(document.schema);
1059
+ decisions.anchor = undefined;
1060
+ document.set('decisions', decisions);
1061
+ }
1062
+ }
1063
+ const node = document.createNode(decision);
1064
+ node.flow = false;
1065
+ node.get('summary', true).type = Scalar.QUOTE_DOUBLE;
1066
+ node.get('rationale', true).type = Scalar.QUOTE_DOUBLE;
1067
+ if (isSeq(decisions)) {
1068
+ decisions.add(node);
1069
+ return;
1070
+ }
1071
+ const sequence = document.createNode([]);
1072
+ sequence.flow = false;
1073
+ sequence.add(node);
1074
+ document.set('decisions', sequence);
1075
+ }
1076
+
1077
+ function frontmatterBounds(source) {
1078
+ const opening = nextLine(source, 0);
1079
+ if (opening.content !== '---' || opening.next === null) {
1080
+ throw new Error('missing frontmatter delimiter');
1081
+ }
1082
+ const start = opening.next;
1083
+ let cursor = start;
1084
+ while (cursor < source.length) {
1085
+ const line = nextLine(source, cursor);
1086
+ if (line.content === '---') {
1087
+ return { start, end: cursor, newline: opening.newline ?? line.newline ?? '\n' };
1088
+ }
1089
+ if (line.next === null) {
1090
+ break;
1091
+ }
1092
+ cursor = line.next;
1093
+ }
1094
+ throw new Error('missing frontmatter delimiter');
1095
+ }
1096
+
1097
+ function nextLine(source, start) {
1098
+ const lf = source.indexOf('\n', start);
1099
+ if (lf === -1) {
1100
+ return { content: source.slice(start), newline: null, next: null };
1101
+ }
1102
+ const carriageReturn = lf > start && source[lf - 1] === '\r';
1103
+ return {
1104
+ content: source.slice(start, carriageReturn ? lf - 1 : lf),
1105
+ newline: carriageReturn ? '\r\n' : '\n',
1106
+ next: lf + 1,
1107
+ };
1108
+ }
1109
+
1110
+ function terminalField(status) {
1111
+ return { done: 'completed', killed: 'killed', archived: 'archived', deferred: 'deferred' }[status] ?? null;
1112
+ }
1113
+
1114
+ async function loadedValidLedger(root) {
1115
+ const ledger = await loadLedger(root);
1116
+ const validation = validateLedger(ledger);
1117
+ return { ledger, validation, valid: validation.valid };
1118
+ }
1119
+
1120
+ function validateSerializedCandidate(ledger, replacementId, bytes, displayPath, file, expectedData, expectedSource) {
1121
+ const source = bytes.toString('utf8');
1122
+ const parsed = parseLedgerItemSource(source);
1123
+ const errors = parsed.error ? [{ path: displayPath, ...parsed.error }] : [];
1124
+ const coreMatches = parsed.error || !expectedData
1125
+ || isDeepStrictEqual(coreView(parsed.data), coreView(expectedData));
1126
+ const extensionsMatch = parsed.error || !expectedSource
1127
+ || isDeepStrictEqual(extensionNodeIdentity(source), extensionNodeIdentity(expectedSource));
1128
+ if (!parsed.error && (!coreMatches || !extensionsMatch)) {
1129
+ errors.push({
1130
+ path: displayPath,
1131
+ field: 'frontmatter',
1132
+ code: 'mutation-successor-mismatch',
1133
+ message: 'Serialized frontmatter does not exactly match the requested successor.',
1134
+ });
1135
+ }
1136
+ const candidate = parsed.error ? null : {
1137
+ path: displayPath,
1138
+ file,
1139
+ bytes,
1140
+ source,
1141
+ body: parsed.body,
1142
+ data: parsed.data,
1143
+ };
1144
+ const items = replacementId
1145
+ ? ledger.items.map((item) => item.data.id === replacementId ? candidate : item)
1146
+ : [...ledger.items, candidate];
1147
+ return validateLedger({ items: items.filter(Boolean), errors });
1148
+ }
1149
+
1150
+ function extensionNodeIdentity(source) {
1151
+ const bounds = frontmatterBounds(source);
1152
+ const document = parseDocument(source.slice(bounds.start, bounds.end), {
1153
+ keepSourceTokens: true,
1154
+ prettyErrors: false,
1155
+ schema: 'core',
1156
+ uniqueKeys: true,
1157
+ });
1158
+ if (document.errors.length > 0 || !isMap(document.contents)) {
1159
+ return null;
1160
+ }
1161
+
1162
+ const identity = [];
1163
+ for (const pair of document.contents.items) {
1164
+ const key = isScalar(pair.key) ? pair.key.value : undefined;
1165
+ if (!CORE_OWNED_FIELDS.has(key)) {
1166
+ identity.push(['item', sourceNodeIdentity(pair)]);
1167
+ continue;
1168
+ }
1169
+ if (key !== 'provenance' || !isMap(pair.value)) {
1170
+ continue;
1171
+ }
1172
+ for (const provenancePair of pair.value.items) {
1173
+ const provenanceKey = isScalar(provenancePair.key) ? provenancePair.key.value : undefined;
1174
+ if (provenanceKey !== 'source' && provenanceKey !== 'recorded_at') {
1175
+ identity.push(['provenance', sourceNodeIdentity(provenancePair)]);
1176
+ }
1177
+ }
1178
+ }
1179
+ return identity;
1180
+ }
1181
+
1182
+ function sourceNodeIdentity(node) {
1183
+ if (node && Object.hasOwn(node, 'key') && Object.hasOwn(node, 'value')) {
1184
+ return ['pair', sourceNodeIdentity(node.key), sourceNodeIdentity(node.value)];
1185
+ }
1186
+ const presentation = [node?.tag ?? null, node?.anchor ?? null, node?.commentBefore ?? null, node?.comment ?? null, node?.spaceBefore ?? false];
1187
+ if (isAlias(node)) {
1188
+ return ['alias', node.source, ...presentation];
1189
+ }
1190
+ if (isScalar(node)) {
1191
+ return ['scalar', node.source ?? String(node.value), node.type ?? null, ...presentation];
1192
+ }
1193
+ if (isMap(node) || isSeq(node)) {
1194
+ return [isMap(node) ? 'map' : 'sequence', node.flow ?? false, ...presentation, node.items.map(sourceNodeIdentity)];
1195
+ }
1196
+ return ['absent'];
1197
+ }
1198
+
1199
+ function lockIdsForCreate(request, ledger) {
1200
+ const existingIds = new Set(ledger.items.map((item) => item.data.id));
1201
+ const references = [request.item.parent, ...(request.item.depends_on ?? [])]
1202
+ .filter((id) => existingIds.has(id));
1203
+ return [request.id, ...references].sort(compareText);
1204
+ }
1205
+
1206
+ async function acquireLocks(root, ids, operation, scenario) {
1207
+ const lockDirectory = path.join(root, '.wowbagger-locks');
1208
+ await mkdir(lockDirectory, { recursive: true });
1209
+ const locks = [];
1210
+ try {
1211
+ for (const id of [...new Set(ids)].sort(compareText)) {
1212
+ const file = path.join(lockDirectory, `${id}.lock`);
1213
+ let handle;
1214
+ try {
1215
+ handle = await open(file, 'wx');
1216
+ } catch (error) {
1217
+ if (error?.code === 'EEXIST') {
1218
+ throw new LockHeldError(file);
1219
+ }
1220
+ throw error;
1221
+ }
1222
+ const lock = {
1223
+ id,
1224
+ file,
1225
+ source: Buffer.from(lockSource(id, operation), 'utf8'),
1226
+ device: null,
1227
+ inode: null,
1228
+ metadataComplete: false,
1229
+ released: false,
1230
+ };
1231
+ locks.push(lock);
1232
+ let failure = null;
1233
+ try {
1234
+ const stat = await handle.stat();
1235
+ lock.device = stat.dev;
1236
+ lock.inode = stat.ino;
1237
+ if (scenarioName(scenario) === 'lock-metadata-write-fails'
1238
+ || scenarioName(scenario) === 'lock-metadata-write-and-unlink-fail') {
1239
+ throw new Error('fixture lock metadata write failure');
1240
+ }
1241
+ await handle.writeFile(lock.source);
1242
+ if (scenarioName(scenario) === 'lock-metadata-sync-fails') {
1243
+ throw new Error('fixture lock metadata sync failure');
1244
+ }
1245
+ await handle.sync();
1246
+ lock.metadataComplete = true;
1247
+ } catch (error) {
1248
+ failure = error;
1249
+ }
1250
+ try {
1251
+ await handle.close();
1252
+ if (scenarioName(scenario) === 'lock-metadata-close-fails') {
1253
+ throw new Error('fixture lock metadata close failure');
1254
+ }
1255
+ } catch (error) {
1256
+ failure ??= error;
1257
+ }
1258
+ if (failure) {
1259
+ const failedLocks = await releaseLocks(locks, scenario);
1260
+ throw new ResourceFailure('lock-closure', failedLocks);
1261
+ }
1262
+ }
1263
+ await testCheckpoint(root, scenario, 'after-lock-acquired');
1264
+ return locks;
1265
+ } catch (error) {
1266
+ if (error instanceof ResourceFailure) {
1267
+ throw error;
1268
+ }
1269
+ const failedLocks = await releaseLocks(locks, scenario);
1270
+ if (failedLocks.length > 0) {
1271
+ throw new ResourceFailure('cleanup', failedLocks);
1272
+ }
1273
+ throw error;
1274
+ }
1275
+ }
1276
+
1277
+ async function releaseLocks(locks, scenario) {
1278
+ const failed = [];
1279
+ await Promise.all(locks.map(async (lock) => {
1280
+ if (lock.released) {
1281
+ return;
1282
+ }
1283
+ try {
1284
+ if (scenarioName(scenario) === 'lock-unlink-fails-after-publication'
1285
+ || scenarioName(scenario) === 'lock-metadata-write-and-unlink-fail') {
1286
+ failed.push(lock);
1287
+ return;
1288
+ }
1289
+ if (!await lockPathIsOwned(lock)) {
1290
+ failed.push(lock);
1291
+ return;
1292
+ }
1293
+ await unlink(lock.file);
1294
+ lock.released = true;
1295
+ } catch (error) {
1296
+ if (error?.code === 'ENOENT') {
1297
+ lock.released = true;
1298
+ return;
1299
+ }
1300
+ failed.push(lock);
1301
+ }
1302
+ }));
1303
+ return failed;
1304
+ }
1305
+
1306
+ async function finishUncommittedResources(id, outcome, locks, root, scenario, artifacts) {
1307
+ const failedLocks = await releaseLocks(locks, scenario);
1308
+ if (failedLocks.length === 0) {
1309
+ return { locks: [], preserveLocks: false, outcome };
1310
+ }
1311
+ return {
1312
+ locks: failedLocks,
1313
+ preserveLocks: true,
1314
+ outcome: operationFailed(id, 'cleanup', 'io-error', [
1315
+ ...artifacts,
1316
+ ...await artifactsForLocks(failedLocks, root),
1317
+ ]),
1318
+ };
1319
+ }
1320
+
1321
+ async function finishUnknownResources(outcome, locks, root, scenario) {
1322
+ const failedLocks = await releaseLocks(locks, scenario);
1323
+ if (failedLocks.length === 0) {
1324
+ return { locks: [], preserveLocks: false, outcome };
1325
+ }
1326
+ const lockArtifacts = await artifactsForLocks(failedLocks, root);
1327
+ const bounded = boundedArtifacts([...outcome.error.details.recovery_artifacts, ...lockArtifacts]);
1328
+ outcome.error.details.recovery_artifacts = bounded.artifacts;
1329
+ outcome.error.details.recovery_artifacts_truncated = bounded.truncated;
1330
+ return { locks: failedLocks, preserveLocks: true, outcome };
1331
+ }
1332
+
1333
+ async function finishCommittedResources(id, bytes, locks, root, scenario, artifacts) {
1334
+ const failedLocks = await releaseLocks(locks, scenario);
1335
+ return {
1336
+ locks: failedLocks,
1337
+ preserveLocks: failedLocks.length > 0,
1338
+ outcome: postCommitRecovery(id, bytes, [
1339
+ ...artifacts,
1340
+ ...await artifactsForLocks(failedLocks, root),
1341
+ ]),
1342
+ };
1343
+ }
1344
+
1345
+ async function lockPathIsOwned(lock) {
1346
+ const stat = await lstat(lock.file);
1347
+ if (!stat.isFile() || stat.isSymbolicLink()) {
1348
+ return false;
1349
+ }
1350
+ if (lock.device !== null && lock.inode !== null
1351
+ && (stat.dev !== lock.device || stat.ino !== lock.inode)) {
1352
+ return false;
1353
+ }
1354
+ if (lock.metadataComplete) {
1355
+ if (lock.source.length !== stat.size) {
1356
+ return false;
1357
+ }
1358
+ try {
1359
+ return (await readRegularFile(lock.file, lock.source.length + 1)).equals(lock.source);
1360
+ } catch {
1361
+ return false;
1362
+ }
1363
+ }
1364
+ return lock.device !== null && lock.inode !== null;
1365
+ }
1366
+
1367
+ class LockHeldError extends Error {
1368
+ constructor(file) {
1369
+ super('lock held');
1370
+ this.file = file;
1371
+ }
1372
+ }
1373
+
1374
+ class ResourceFailure extends Error {
1375
+ constructor(operation, locks) {
1376
+ super(operation);
1377
+ this.operation = operation;
1378
+ this.locks = locks;
1379
+ }
1380
+ }
1381
+
1382
+ function issue(pathValue, code, message) {
1383
+ return { path: pathValue, code, message };
1384
+ }
1385
+
1386
+ function validateRelationEntries(references, field, issues) {
1387
+ for (let index = 0; index < references.length; index += 1) {
1388
+ const reference = references[index];
1389
+ if (typeof reference !== 'string' || !ULID_PATTERN.test(reference)) {
1390
+ issues.push(issue(
1391
+ `/item/${field}/${index}`,
1392
+ 'invalid-value',
1393
+ `Item member ${field} entries must be canonical Wowbagger item IDs.`,
1394
+ ));
1395
+ }
1396
+ }
1397
+ }
1398
+
1399
+ function validateObjectMembers(value, location, allowed, issues, noun) {
1400
+ const allowedMembers = new Set(allowed);
1401
+ for (const member of Object.keys(value)) {
1402
+ if (!allowedMembers.has(member)) {
1403
+ issues.push(issue(pointer([...location, member]), 'unknown-member', `${noun} ${member} is not allowed.`));
1404
+ }
1405
+ }
1406
+ }
1407
+
1408
+ function validateRequiredMember(value, location, member, issues) {
1409
+ if (!hasOwn(value, member)) {
1410
+ issues.push(issue(pointer([...location, member]), 'missing-member', `Required member ${member} is missing.`));
1411
+ }
1412
+ }
1413
+
1414
+ function mutationSuccess(item) {
1415
+ return { ok: true, exit: 0, state: 'committed', item };
1416
+ }
1417
+
1418
+ function mutationError(code, message, state, exit, details) {
1419
+ return {
1420
+ ok: false,
1421
+ exit,
1422
+ state,
1423
+ error: { code, message, details },
1424
+ };
1425
+ }
1426
+
1427
+ function ledgerInvalid(validation) {
1428
+ return mutationError('ledger-invalid', 'The configured ledger is invalid.', 'unchanged', 3, {
1429
+ validation_errors: validation.errors,
1430
+ });
1431
+ }
1432
+
1433
+ function operationFailed(id, operation, reason, recoveryArtifacts = []) {
1434
+ const { artifacts, truncated } = boundedArtifacts(recoveryArtifacts);
1435
+ return mutationError('operation-failed', 'The mutation operation failed before a commit was established.', 'unchanged', 6, {
1436
+ id,
1437
+ operation,
1438
+ reason,
1439
+ recovery_artifacts: artifacts,
1440
+ recovery_artifacts_truncated: truncated,
1441
+ });
1442
+ }
1443
+
1444
+ function lockHeld(id, file, details) {
1445
+ return mutationError('lock-held', 'The item is locked by another cooperative Wowbagger writer.', 'unchanged', 4, {
1446
+ id,
1447
+ lock_path: `.wowbagger-locks/${path.basename(file)}`,
1448
+ owner: details.owner,
1449
+ owner_diagnostic: details.owner_diagnostic,
1450
+ });
1451
+ }
1452
+
1453
+ async function lockDetails(file) {
1454
+ let handle;
1455
+ try {
1456
+ const pathStat = await lstat(file);
1457
+ if (!pathStat.isFile() || pathStat.isSymbolicLink()) {
1458
+ return { owner: null, owner_diagnostic: 'invalid-shape' };
1459
+ }
1460
+ handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
1461
+ const fileStat = await handle.stat();
1462
+ if (!fileStat.isFile()) {
1463
+ return { owner: null, owner_diagnostic: 'invalid-shape' };
1464
+ }
1465
+ const bytes = Buffer.alloc(4097);
1466
+ const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
1467
+ if (bytesRead >= 4097) {
1468
+ return { owner: null, owner_diagnostic: 'too-large' };
1469
+ }
1470
+ const parsed = parseJsonRequest(bytes.subarray(0, bytesRead));
1471
+ if (parsed.issues.length > 0) {
1472
+ const code = parsed.issues[0].code;
1473
+ return {
1474
+ owner: null,
1475
+ owner_diagnostic: parsed.inputDiagnostic ?? (code === 'duplicate-key' ? 'duplicate-key' : 'invalid-json'),
1476
+ };
1477
+ }
1478
+ const owner = parsed.value;
1479
+ if (!validLockOwner(owner, file)) {
1480
+ return { owner: null, owner_diagnostic: 'invalid-shape' };
1481
+ }
1482
+ return {
1483
+ owner: {
1484
+ lock_version: 1,
1485
+ item_id: owner.item_id,
1486
+ operation: owner.operation,
1487
+ writer_id: owner.writer_id,
1488
+ started_at: owner.started_at,
1489
+ },
1490
+ owner_diagnostic: null,
1491
+ };
1492
+ } catch {
1493
+ return { owner: null, owner_diagnostic: 'invalid-json' };
1494
+ } finally {
1495
+ await handle?.close();
1496
+ }
1497
+ }
1498
+
1499
+ function validLockOwner(owner, file) {
1500
+ if (!isMapping(owner)) {
1501
+ return false;
1502
+ }
1503
+ const expectedId = path.basename(file, '.lock');
1504
+ const expected = new Set(['lock_version', 'item_id', 'operation', 'writer_id', 'started_at']);
1505
+ if (Object.keys(owner).length !== expected.size || Object.keys(owner).some((key) => !expected.has(key))) {
1506
+ return false;
1507
+ }
1508
+ return isJsonInteger(owner.lock_version, 1)
1509
+ && owner.item_id === expectedId
1510
+ && (owner.operation === 'create' || owner.operation === 'transition' || owner.operation === 'patch')
1511
+ && typeof owner.writer_id === 'string'
1512
+ && /^[\x21-\x7e]{1,128}$/.test(owner.writer_id)
1513
+ && isRfc3339Utc(owner.started_at);
1514
+ }
1515
+
1516
+ function lockSource(id, operation) {
1517
+ return `${JSON.stringify({
1518
+ lock_version: 1,
1519
+ item_id: id,
1520
+ operation,
1521
+ writer_id: randomBytes(18).toString('base64url'),
1522
+ started_at: new Date().toISOString(),
1523
+ })}\n`;
1524
+ }
1525
+
1526
+ async function writeFixtureRecoveryLock(file, id) {
1527
+ await writeFile(file, `{
1528
+ "lock_version": 1,
1529
+ "item_id": "${id}",
1530
+ "operation": "create",
1531
+ "writer_id": "fixture-create-writer",
1532
+ "started_at": "2030-01-10T12:34:56.789Z"
1533
+ }
1534
+ `);
1535
+ }
1536
+
1537
+ async function artifactFor(file, root, kind) {
1538
+ try {
1539
+ const bytes = await readRegularFile(file);
1540
+ return {
1541
+ path: path.relative(root, file).split(path.sep).join('/'),
1542
+ kind,
1543
+ sha256: revisionFor(bytes),
1544
+ size_bytes: bytes.length,
1545
+ };
1546
+ } catch {
1547
+ return {
1548
+ path: path.relative(root, file).split(path.sep).join('/'),
1549
+ kind,
1550
+ sha256: null,
1551
+ size_bytes: null,
1552
+ };
1553
+ }
1554
+ }
1555
+
1556
+ async function artifactsForLocks(locks, root) {
1557
+ const artifacts = await Promise.all(locks.map(({ file }) => artifactFor(file, root, 'lock-file')));
1558
+ return artifacts.sort((left, right) => compareText(left.path, right.path));
1559
+ }
1560
+
1561
+ function postCommitRecovery(id, bytes, recoveryArtifacts) {
1562
+ const bounded = boundedArtifacts(recoveryArtifacts);
1563
+ return mutationError('post-commit-recovery-required', 'The item was committed, but cleanup requires recovery.', 'committed', 6, {
1564
+ id,
1565
+ revision: revisionFor(bytes),
1566
+ recovery_artifacts: bounded.artifacts,
1567
+ recovery_artifacts_truncated: bounded.truncated,
1568
+ });
1569
+ }
1570
+
1571
+ function boundedArtifacts(recoveryArtifacts) {
1572
+ const unique = new Map();
1573
+ for (const artifact of recoveryArtifacts) {
1574
+ unique.set(`${artifact.path}\0${artifact.kind}`, artifact);
1575
+ }
1576
+ const all = [...unique.values()].sort((left, right) => compareText(left.path, right.path)
1577
+ || compareText(left.kind, right.kind));
1578
+ return { artifacts: all.slice(0, 16), truncated: all.length > 16 };
1579
+ }
1580
+
1581
+ async function syncDirectoryIfSupported(directory) {
1582
+ let handle;
1583
+ try {
1584
+ handle = await open(directory, constants.O_RDONLY | constants.O_NOFOLLOW);
1585
+ await handle.sync();
1586
+ } catch (error) {
1587
+ if (['EISDIR', 'EINVAL', 'ENOSYS', 'ENOTSUP', 'EOPNOTSUPP', 'EPERM'].includes(error?.code)) {
1588
+ return;
1589
+ }
1590
+ throw error;
1591
+ } finally {
1592
+ await handle?.close();
1593
+ }
1594
+ }
1595
+
1596
+ async function pathOccupant(finalPath, ledger) {
1597
+ let stat;
1598
+ try {
1599
+ stat = await lstat(finalPath);
1600
+ } catch (error) {
1601
+ if (error?.code === 'ENOENT') {
1602
+ return null;
1603
+ }
1604
+ throw error;
1605
+ }
1606
+ if (stat.isDirectory()) {
1607
+ return { kind: 'directory' };
1608
+ }
1609
+ const item = ledger.items.find((candidate) => candidate.file === finalPath);
1610
+ return { kind: 'item', id: item?.data.id };
1611
+ }
1612
+
1613
+ function isUnavailableNoClobber(error) {
1614
+ return ['EPERM', 'EOPNOTSUPP', 'ENOTSUP', 'EXDEV'].includes(error?.code);
1615
+ }
1616
+
1617
+ function sameIds(left, right) {
1618
+ return left.length === right.length && left.every((id, index) => id === right[index]);
1619
+ }
1620
+
1621
+ function displayItemPath(displayPath) {
1622
+ return displayPath.slice(displayPath.indexOf('/') + 1);
1623
+ }
1624
+
1625
+ function relativeDirectory(root, directory) {
1626
+ const relative = path.relative(root, directory).split(path.sep).join('/');
1627
+ return relative || '.';
1628
+ }
1629
+
1630
+ async function observePublication(file, expectedBytes, originalBytes = null) {
1631
+ try {
1632
+ const bytes = await readRegularFile(file);
1633
+ if (bytes.equals(expectedBytes)) {
1634
+ return { state: 'expected', bytes };
1635
+ }
1636
+ if (originalBytes && bytes.equals(originalBytes)) {
1637
+ return { state: 'original', bytes };
1638
+ }
1639
+ return { state: 'different', bytes };
1640
+ } catch (error) {
1641
+ return { state: error?.code === 'ENOENT' ? 'absent' : 'unknown', bytes: null };
1642
+ }
1643
+ }
1644
+
1645
+ function unknownPublication(command, id, displayPath, bytes, otherArtifacts = []) {
1646
+ const finalArtifact = {
1647
+ path: displayPath,
1648
+ kind: 'final-item',
1649
+ sha256: bytes ? revisionFor(bytes) : null,
1650
+ size_bytes: bytes?.length ?? null,
1651
+ };
1652
+ const bounded = boundedArtifacts([finalArtifact, ...otherArtifacts]);
1653
+ return mutationError(
1654
+ 'write-outcome-unknown',
1655
+ `The ${command} publication outcome could not be verified.`,
1656
+ 'unknown',
1657
+ 6,
1658
+ {
1659
+ id,
1660
+ recovery_artifacts: bounded.artifacts,
1661
+ recovery_artifacts_truncated: bounded.truncated,
1662
+ },
1663
+ );
1664
+ }
1665
+
1666
+ async function readRegularFile(file, maximumBytes = null) {
1667
+ const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
1668
+ try {
1669
+ const stat = await handle.stat();
1670
+ if (!stat.isFile()) {
1671
+ throw new Error('Published path is not a regular file.');
1672
+ }
1673
+ if (maximumBytes !== null && stat.size >= maximumBytes) {
1674
+ throw new Error('Published path exceeds its read bound.');
1675
+ }
1676
+ return await handle.readFile();
1677
+ } finally {
1678
+ await handle.close();
1679
+ }
1680
+ }
1681
+
1682
+ async function testCheckpoint(root, scenario, point) {
1683
+ const [name, suffix] = typeof scenario === 'string' ? scenario.split(':', 2) : [];
1684
+ if (!suffix) {
1685
+ return;
1686
+ }
1687
+ if (name === 'pause-after-success-release' && point === 'after-success-release') {
1688
+ await writeFile(path.join(root, `.wowbagger-test-${suffix}-released`), 'released\n');
1689
+ await waitForTestMarker(path.join(root, `.wowbagger-test-${suffix}-acquired`));
1690
+ }
1691
+ if (name === 'pause-after-lock-acquired' && point === 'after-lock-acquired') {
1692
+ await writeFile(path.join(root, `.wowbagger-test-${suffix}-acquired`), 'acquired\n');
1693
+ await waitForTestMarker(path.join(root, `.wowbagger-test-${suffix}-allow-successor`));
1694
+ }
1695
+ }
1696
+
1697
+ async function waitForTestMarker(file) {
1698
+ const deadline = Date.now() + 2_000;
1699
+ while (Date.now() < deadline) {
1700
+ try {
1701
+ await lstat(file);
1702
+ return;
1703
+ } catch (error) {
1704
+ if (error?.code !== 'ENOENT') {
1705
+ throw error;
1706
+ }
1707
+ }
1708
+ await new Promise((resolve) => setTimeout(resolve, 5));
1709
+ }
1710
+ throw new Error(`Timed out waiting for test marker ${path.basename(file)}.`);
1711
+ }
1712
+
1713
+ export function createCandidateSource(request, schemaVersion = 1) {
1714
+ return Buffer.from(serializeCreate(createData(request, dateFromId(request.id), schemaVersion), request.body), 'utf8');
1715
+ }
1716
+
1717
+ function createData(request, date, schemaVersion) {
1718
+ return {
1719
+ schema_version: schemaVersion,
1720
+ id: request.id,
1721
+ title: request.item.title,
1722
+ kind: request.item.kind,
1723
+ status: 'triage',
1724
+ created: date,
1725
+ updated: date,
1726
+ provenance: request.item.provenance,
1727
+ depends_on: request.item.depends_on,
1728
+ related: request.item.related ?? [],
1729
+ ...(hasOwn(request.item, 'parent') ? { parent: request.item.parent } : {}),
1730
+ ...(hasOwn(request.item, 'snoozed_until') ? { snoozed_until: request.item.snoozed_until } : {}),
1731
+ ...extensionMembers(request.item),
1732
+ };
1733
+ }
1734
+
1735
+ function serializeCreate(data, body) {
1736
+ const lines = [
1737
+ '---',
1738
+ `schema_version: ${data.schema_version}`,
1739
+ `id: ${data.id}`,
1740
+ ...(hasOwn(data, 'number') ? [`number: ${yamlScalar(data.number)}`] : []),
1741
+ `title: ${quote(data.title)}`,
1742
+ `kind: ${data.kind}`,
1743
+ ...(hasOwn(data, 'priority') ? [`priority: ${yamlScalar(data.priority)}`] : []),
1744
+ 'status: triage',
1745
+ `created: ${data.created}`,
1746
+ `updated: ${data.updated}`,
1747
+ 'provenance:',
1748
+ ` source: ${quote(data.provenance.source)}`,
1749
+ ` recorded_at: ${quote(data.provenance.recorded_at)}`,
1750
+ ];
1751
+
1752
+ for (const [key, value] of Object.entries(provenanceExtensions(data.provenance))) {
1753
+ lines.push(...yamlLines(key, value, 2));
1754
+ }
1755
+ lines.push(
1756
+ `depends_on: ${referenceList(data.depends_on)}`,
1757
+ `related: ${referenceList(data.related)}`,
1758
+ );
1759
+ if (hasOwn(data, 'parent')) {
1760
+ lines.push(`parent: ${data.parent}`);
1761
+ }
1762
+ if (hasOwn(data, 'snoozed_until')) {
1763
+ lines.push(`snoozed_until: ${data.snoozed_until}`);
1764
+ }
1765
+
1766
+ for (const [key, value] of Object.entries(extensionMembers(data))) {
1767
+ if (CONSUMER_CORE_FIELDS.includes(key)) {
1768
+ continue;
1769
+ }
1770
+ lines.push(...yamlLines(key, value, 0));
1771
+ }
1772
+
1773
+ lines.push('---');
1774
+ return `${lines.join('\n')}\n${body}`;
1775
+ }
1776
+
1777
+ function extensionMembers(item) {
1778
+ return Object.fromEntries(Object.entries(item).filter(([key]) => !CONTROLLED_ITEM_FIELDS.has(key)));
1779
+ }
1780
+
1781
+ function provenanceExtensions(provenance) {
1782
+ return Object.fromEntries(Object.entries(provenance)
1783
+ .filter(([key]) => key !== 'source' && key !== 'recorded_at'));
1784
+ }
1785
+
1786
+ function yamlLines(key, value, indentation) {
1787
+ const prefix = ' '.repeat(indentation);
1788
+ const renderedKey = yamlKey(key);
1789
+ if (Array.isArray(value)) {
1790
+ if (value.length === 0) {
1791
+ return [`${prefix}${renderedKey}: []`];
1792
+ }
1793
+ return [`${prefix}${renderedKey}:`, ...yamlSequenceLines(value, indentation + 2)];
1794
+ }
1795
+ if (isMapping(value)) {
1796
+ const entries = Object.entries(value);
1797
+ if (entries.length === 0) {
1798
+ return [`${prefix}${renderedKey}: {}`];
1799
+ }
1800
+ return [`${prefix}${renderedKey}:`, ...yamlMappingLines(entries, indentation + 2)];
1801
+ }
1802
+ return [`${prefix}${renderedKey}: ${yamlScalar(value)}`];
1803
+ }
1804
+
1805
+ function yamlMappingLines(entries, indentation) {
1806
+ return entries.flatMap(([key, value]) => yamlLines(key, value, indentation));
1807
+ }
1808
+
1809
+ function yamlSequenceLines(values, indentation) {
1810
+ const prefix = ' '.repeat(indentation);
1811
+ return values.flatMap((value) => {
1812
+ if (Array.isArray(value)) {
1813
+ if (value.length === 0) {
1814
+ return [`${prefix}- []`];
1815
+ }
1816
+ return [`${prefix}-`, ...yamlSequenceLines(value, indentation + 2)];
1817
+ }
1818
+ if (isMapping(value)) {
1819
+ const entries = Object.entries(value);
1820
+ if (entries.length === 0) {
1821
+ return [`${prefix}- {}`];
1822
+ }
1823
+ const [[firstKey, firstValue], ...remaining] = entries;
1824
+ const first = yamlLines(firstKey, firstValue, indentation + 2);
1825
+ first[0] = `${prefix}- ${first[0].slice(indentation + 2)}`;
1826
+ return [...first, ...yamlMappingLines(remaining, indentation + 2)];
1827
+ }
1828
+ return [`${prefix}- ${yamlScalar(value)}`];
1829
+ });
1830
+ }
1831
+
1832
+ function yamlKey(key) {
1833
+ return /^[A-Za-z_][A-Za-z0-9_-]*$/.test(key) ? key : quote(key);
1834
+ }
1835
+
1836
+ function yamlScalar(value) {
1837
+ if (typeof value === 'string') {
1838
+ return quote(value);
1839
+ }
1840
+ if (value === null) {
1841
+ return 'null';
1842
+ }
1843
+ if (value instanceof JsonNumber) {
1844
+ return value.source;
1845
+ }
1846
+ return String(value);
1847
+ }
1848
+
1849
+ function quote(value) {
1850
+ return JSON.stringify(value);
1851
+ }
1852
+
1853
+ function referenceList(references) {
1854
+ return references.length === 0 ? '[]' : `[${references.join(', ')}]`;
1855
+ }
1856
+
1857
+ function dateFromId(id) {
1858
+ const ulid = id.slice(3);
1859
+ let milliseconds = 0;
1860
+ for (const character of ulid.slice(0, 10)) {
1861
+ milliseconds = (milliseconds * 32) + ULID_ALPHABET.indexOf(character);
1862
+ }
1863
+ return new Date(milliseconds).toISOString().slice(0, 10);
1864
+ }
1865
+
1866
+ function randomSuffix() {
1867
+ return createHash('sha256').update(`${process.pid}:${Date.now()}:${Math.random()}`).digest('hex').slice(0, 24);
1868
+ }
1869
+
1870
+ async function prepareTemporary(file, bytes, scenario) {
1871
+ let handle;
1872
+ try {
1873
+ handle = await open(file, 'wx');
1874
+ } catch {
1875
+ return 'prepare-temporary';
1876
+ }
1877
+
1878
+ let failure = null;
1879
+ try {
1880
+ await handle.writeFile(bytes);
1881
+ } catch {
1882
+ failure = 'prepare-temporary';
1883
+ }
1884
+ if (!failure) {
1885
+ try {
1886
+ if (scenario === 'temporary-file-sync-fails') {
1887
+ throw new Error('fixture temporary sync failure');
1888
+ }
1889
+ await handle.sync();
1890
+ } catch {
1891
+ failure = 'sync-temporary';
1892
+ }
1893
+ }
1894
+ try {
1895
+ await handle.close();
1896
+ if (scenarioName(scenario) === 'temporary-close-fails'
1897
+ || scenarioName(scenario) === 'temporary-close-and-unlink-fail') {
1898
+ throw new Error('fixture temporary close failure');
1899
+ }
1900
+ } catch {
1901
+ failure ??= 'sync-temporary';
1902
+ }
1903
+ return failure;
1904
+ }
1905
+
1906
+ async function cleanupTemporary(file, root, scenario) {
1907
+ if (scenarioName(scenario) === 'temporary-close-and-unlink-fail'
1908
+ || scenarioName(scenario) === 'temporary-unlink-fails-after-publication'
1909
+ || scenario === 'final-mismatch-and-temporary-unlink-fail'
1910
+ || scenario === 'final-absence-and-temporary-unlink-fail') {
1911
+ return [await artifactFor(file, root, 'temporary-file')];
1912
+ }
1913
+ try {
1914
+ await unlink(file);
1915
+ return [];
1916
+ } catch (error) {
1917
+ if (error?.code === 'ENOENT') {
1918
+ return [];
1919
+ }
1920
+ return [await artifactFor(file, root, 'temporary-file')];
1921
+ }
1922
+ }
1923
+
1924
+ function scenarioName(scenario) {
1925
+ return typeof scenario === 'string' ? scenario.split(':', 1)[0] : '';
1926
+ }
1927
+
1928
+ function compareText(left, right) {
1929
+ return left < right ? -1 : left > right ? 1 : 0;
1930
+ }
1931
+
1932
+ function isMapping(value) {
1933
+ return value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof JsonNumber);
1934
+ }
1935
+
1936
+ function isJsonInteger(value, expected) {
1937
+ return value === expected || (value instanceof JsonNumber && value.source === String(expected));
1938
+ }
1939
+
1940
+ function hasOwn(value, key) {
1941
+ return Object.prototype.hasOwnProperty.call(value, key);
1942
+ }
1943
+
1944
+ function coreView(data) {
1945
+ const core = {};
1946
+ for (const field of REQUIRED_CORE_FIELDS) {
1947
+ core[field] = data[field];
1948
+ }
1949
+
1950
+ for (const field of [...OPTIONAL_CORE_FIELDS, ...CONSUMER_CORE_FIELDS]) {
1951
+ if (Object.hasOwn(data, field)) {
1952
+ core[field] = data[field];
1953
+ }
1954
+ }
1955
+
1956
+ core.provenance = {
1957
+ source: data.provenance.source,
1958
+ recorded_at: data.provenance.recorded_at,
1959
+ };
1960
+ core.depends_on = data.depends_on;
1961
+ core.related = data.related ?? [];
1962
+
1963
+ if (Object.hasOwn(data, 'decisions')) {
1964
+ core.decisions = data.decisions.map((decision) => {
1965
+ const normalized = {
1966
+ action: decision.action,
1967
+ date: decision.date,
1968
+ summary: decision.summary,
1969
+ rationale: decision.rationale,
1970
+ };
1971
+ if (Object.hasOwn(decision, 'rollup')) {
1972
+ normalized.rollup = decision.rollup.map(({ id, status }) => ({ id, status }));
1973
+ }
1974
+ return normalized;
1975
+ });
1976
+ }
1977
+
1978
+ return core;
1979
+ }