wowbagger 0.1.0-alpha.2 → 0.1.0-alpha.4
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.
- package/CHANGELOG.md +29 -0
- package/README.md +74 -35
- package/adapters/claude-code/wowbagger-adapter.json +1 -1
- package/docs/adapter-contract.md +1280 -0
- package/docs/work-claim-contract.md +21 -4
- package/package.json +3 -1
- package/skills/wowbagger/SKILL.md +37 -5
- package/src/adapter/entrypoint-main.js +19 -5
- package/src/claim-coordinator.js +113 -7
- package/src/claim-journal.js +45 -2
- package/src/claim-publication.js +121 -59
- package/src/claim-store.js +20 -8
- package/src/cli.js +118 -11
- package/src/mutation.js +151 -32
- package/src/ready.js +64 -38
- package/src/report-html.js +198 -0
- package/src/report-markdown.js +171 -0
- package/src/report.js +396 -0
package/src/claim-store.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
|
-
import { link, mkdir, open, readFile, realpath, rename, rm, stat } from 'node:fs/promises';
|
|
3
|
+
import { link, lstat, mkdir, open, readFile, realpath, rename, rm, stat } from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
|
|
@@ -9,13 +9,13 @@ const GIT_ENVIRONMENT = Object.fromEntries(
|
|
|
9
9
|
Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_')),
|
|
10
10
|
);
|
|
11
11
|
|
|
12
|
-
export async function resolveGitCommonDir(startDir) {
|
|
12
|
+
export async function resolveGitCommonDir(startDir, options = {}) {
|
|
13
13
|
let current = path.resolve(startDir);
|
|
14
14
|
for (;;) {
|
|
15
15
|
const candidate = path.join(current, '.git');
|
|
16
16
|
let info = null;
|
|
17
17
|
try {
|
|
18
|
-
info = await
|
|
18
|
+
info = await lstat(candidate);
|
|
19
19
|
} catch (error) {
|
|
20
20
|
if (error?.code !== 'ENOENT') throw error;
|
|
21
21
|
}
|
|
@@ -23,7 +23,10 @@ export async function resolveGitCommonDir(startDir) {
|
|
|
23
23
|
if (info?.isFile()) {
|
|
24
24
|
const text = await readFile(candidate, 'utf8');
|
|
25
25
|
const match = /^gitdir:\s*(.+)\s*$/m.exec(text);
|
|
26
|
-
if (!match)
|
|
26
|
+
if (!match) {
|
|
27
|
+
if (options.failClosed) throw gitVerificationFailed();
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
27
30
|
const gitDir = path.resolve(current, match[1].trim());
|
|
28
31
|
try {
|
|
29
32
|
const commonText = await readFile(path.join(gitDir, 'commondir'), 'utf8');
|
|
@@ -33,14 +36,15 @@ export async function resolveGitCommonDir(startDir) {
|
|
|
33
36
|
return gitDir;
|
|
34
37
|
}
|
|
35
38
|
}
|
|
39
|
+
if (info && options.failClosed) throw gitVerificationFailed();
|
|
36
40
|
const parent = path.dirname(current);
|
|
37
41
|
if (parent === current) return null;
|
|
38
42
|
current = parent;
|
|
39
43
|
}
|
|
40
44
|
}
|
|
41
45
|
|
|
42
|
-
export async function resolveVerifiedGitCommonDir(startDir) {
|
|
43
|
-
const discovered = await resolveGitCommonDir(startDir);
|
|
46
|
+
export async function resolveVerifiedGitCommonDir(startDir, options = {}) {
|
|
47
|
+
const discovered = await resolveGitCommonDir(startDir, options);
|
|
44
48
|
if (!discovered) return null;
|
|
45
49
|
try {
|
|
46
50
|
const { stdout } = await execFileAsync(
|
|
@@ -57,10 +61,18 @@ export async function resolveVerifiedGitCommonDir(startDir) {
|
|
|
57
61
|
realpath(stdout.trim()),
|
|
58
62
|
realpath(discovered),
|
|
59
63
|
]);
|
|
60
|
-
|
|
61
|
-
} catch {
|
|
64
|
+
if (reported === expected) return discovered;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (options.failClosed) throw error;
|
|
62
67
|
return null;
|
|
63
68
|
}
|
|
69
|
+
if (options.failClosed) throw gitVerificationFailed();
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
function gitVerificationFailed() {
|
|
73
|
+
const error = new Error('Git common directory verification failed.');
|
|
74
|
+
error.code = 'GIT_VERIFICATION_FAILED';
|
|
75
|
+
return error;
|
|
64
76
|
}
|
|
65
77
|
|
|
66
78
|
export function claimStorePath(commonDir, namespace) {
|
package/src/cli.js
CHANGED
|
@@ -29,6 +29,14 @@ import {
|
|
|
29
29
|
writeClaimState,
|
|
30
30
|
} from './claim-store.js';
|
|
31
31
|
import { loadLedger } from './ledger.js';
|
|
32
|
+
import {
|
|
33
|
+
assertReportOutputOutsideLedger,
|
|
34
|
+
buildReportModel,
|
|
35
|
+
loadReportConfig,
|
|
36
|
+
readLogoDataUrl,
|
|
37
|
+
writeReportFile,
|
|
38
|
+
} from './report.js';
|
|
39
|
+
import { renderReportHtml } from './report-html.js';
|
|
32
40
|
import {
|
|
33
41
|
createItem,
|
|
34
42
|
inspectItem,
|
|
@@ -55,6 +63,7 @@ const DISTRIBUTION_VERSION = JSON.parse(
|
|
|
55
63
|
const COMMAND_SUMMARIES = {
|
|
56
64
|
validate: 'Validate a ledger and print the single JSON validation result.',
|
|
57
65
|
ready: 'Validate a ledger and print the readiness queue for a date.',
|
|
66
|
+
report: 'Render one validated ledger as a self-contained HTML report.',
|
|
58
67
|
capabilities: 'Describe the core contract and unbound default claim profile.',
|
|
59
68
|
inspect: 'Inspect one ledger item as a lossless raw-byte snapshot.',
|
|
60
69
|
create: 'Create one ledger item through atomic, no-clobber publication.',
|
|
@@ -70,6 +79,7 @@ const COMMAND_SUMMARIES = {
|
|
|
70
79
|
const KNOWN_COMMANDS = new Set([
|
|
71
80
|
'validate',
|
|
72
81
|
'ready',
|
|
82
|
+
'report',
|
|
73
83
|
'capabilities',
|
|
74
84
|
'inspect',
|
|
75
85
|
'create',
|
|
@@ -124,6 +134,21 @@ export async function runCli(argumentsList, { scenario } = {}) {
|
|
|
124
134
|
return;
|
|
125
135
|
}
|
|
126
136
|
|
|
137
|
+
if (command === 'report') {
|
|
138
|
+
const parsedOptions = parseContractOptions(command, argumentsList.slice(1));
|
|
139
|
+
if (parsedOptions.options.asOf !== undefined
|
|
140
|
+
&& !isCalendarDate(parsedOptions.options.asOf)) {
|
|
141
|
+
parsedOptions.issues.push(argumentIssue(-1, 'invalid-value', 'Argument --as-of must be an ISO calendar date.'));
|
|
142
|
+
}
|
|
143
|
+
parsedOptions.issues = sortIssues(parsedOptions.issues);
|
|
144
|
+
if (parsedOptions.issues.length > 0) {
|
|
145
|
+
writeInvalidRequest(command, parsedOptions.issues);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
await runReportCommand(parsedOptions.options);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
127
152
|
if (command === 'inspect') {
|
|
128
153
|
const parsedOptions = parseContractOptions(command, argumentsList.slice(1));
|
|
129
154
|
if (parsedOptions.issues.length > 0) {
|
|
@@ -444,6 +469,69 @@ export async function runCli(argumentsList, { scenario } = {}) {
|
|
|
444
469
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
445
470
|
}
|
|
446
471
|
|
|
472
|
+
async function runReportCommand(options) {
|
|
473
|
+
const ledger = await loadLedger(options.ledger);
|
|
474
|
+
const validation = validateLedger(ledger);
|
|
475
|
+
if (!validation.valid) {
|
|
476
|
+
writeReportFailure('ledger-invalid', 'The configured ledger is invalid.', {
|
|
477
|
+
errors: validation.errors,
|
|
478
|
+
}, 1);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
try {
|
|
483
|
+
const config = await loadReportConfig(options.ledger, options.out);
|
|
484
|
+
const model = buildReportModel(ledger.items, config, options.asOf);
|
|
485
|
+
const logoDataUrl = await readLogoDataUrl(config.repository.logo);
|
|
486
|
+
const html = renderReportHtml(model, { logoDataUrl });
|
|
487
|
+
await assertReportOutputOutsideLedger(options.ledger, config.outputPath);
|
|
488
|
+
await writeReportFile(config.outputPath, html);
|
|
489
|
+
process.stdout.write(`${JSON.stringify({
|
|
490
|
+
ok: true,
|
|
491
|
+
command: 'report',
|
|
492
|
+
contract_version: MUTATION_CONTRACT_VERSION,
|
|
493
|
+
result: {
|
|
494
|
+
report_version: config.reportVersion,
|
|
495
|
+
as_of: options.asOf,
|
|
496
|
+
output: config.outputPath,
|
|
497
|
+
item_count: ledger.items.length,
|
|
498
|
+
ready_count: selectReady(ledger.items, options.asOf).length,
|
|
499
|
+
},
|
|
500
|
+
})}\n`);
|
|
501
|
+
} catch (error) {
|
|
502
|
+
const code = error?.code === 'report-config-invalid'
|
|
503
|
+
|| error?.code === 'report-read-failed'
|
|
504
|
+
|| error?.code === 'report-write-failed'
|
|
505
|
+
? error.code
|
|
506
|
+
: 'report-write-failed';
|
|
507
|
+
const exit = code === 'report-config-invalid' ? 2 : 1;
|
|
508
|
+
const details = code === 'report-config-invalid'
|
|
509
|
+
? { issues: [issue('/configuration', code, error?.message ?? 'Report configuration is invalid.')] }
|
|
510
|
+
: error?.details ?? {};
|
|
511
|
+
writeReportFailure(code, reportFailureMessage(code), details, exit);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function reportFailureMessage(code) {
|
|
516
|
+
if (code === 'report-config-invalid') {
|
|
517
|
+
return 'The report configuration is invalid.';
|
|
518
|
+
}
|
|
519
|
+
if (code === 'report-read-failed') {
|
|
520
|
+
return 'A report input could not be read.';
|
|
521
|
+
}
|
|
522
|
+
return 'The report could not be published.';
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function writeReportFailure(code, message, details, exit) {
|
|
526
|
+
process.stdout.write(`${JSON.stringify({
|
|
527
|
+
ok: false,
|
|
528
|
+
command: 'report',
|
|
529
|
+
contract_version: MUTATION_CONTRACT_VERSION,
|
|
530
|
+
error: { code, message, details },
|
|
531
|
+
})}\n`);
|
|
532
|
+
process.exitCode = exit;
|
|
533
|
+
}
|
|
534
|
+
|
|
447
535
|
async function capabilities(ledger) {
|
|
448
536
|
const gitCommonDir = await resolveGitCommonDir(ledger ?? process.cwd());
|
|
449
537
|
return {
|
|
@@ -557,17 +645,23 @@ function parseContractOptions(command, argumentsList) {
|
|
|
557
645
|
const seen = new Set();
|
|
558
646
|
const valueFlags = command === 'inspect'
|
|
559
647
|
? new Map([['--ledger', 'ledger'], ['--id', 'id']])
|
|
560
|
-
: command === '
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
? new Map([['--ledger', 'ledger']])
|
|
567
|
-
: command === '
|
|
568
|
-
? new Map([['--
|
|
569
|
-
:
|
|
570
|
-
|
|
648
|
+
: command === 'report'
|
|
649
|
+
? new Map([['--ledger', 'ledger'], ['--as-of', 'asOf'], ['--out', 'out']])
|
|
650
|
+
: command === 'create' || command === 'transition' || command === 'patch'
|
|
651
|
+
|| command === 'publish-claimed' || command === 'publication-read'
|
|
652
|
+
|| command === 'claim-read' || command === 'claim-acquire' || command === 'claim-renew'
|
|
653
|
+
|| command === 'claim-release'
|
|
654
|
+
? new Map([['--ledger', 'ledger'], ['--input', 'input']])
|
|
655
|
+
: command === 'provision' || command === 'claim-capabilities' || command === 'claim-verify'
|
|
656
|
+
? new Map([['--ledger', 'ledger']])
|
|
657
|
+
: command === 'mint-id'
|
|
658
|
+
? new Map([['--date', 'date']])
|
|
659
|
+
: new Map();
|
|
660
|
+
const optionalFlags = command === 'mint-id'
|
|
661
|
+
? new Set(['--date'])
|
|
662
|
+
: command === 'report'
|
|
663
|
+
? new Set(['--out'])
|
|
664
|
+
: new Set();
|
|
571
665
|
for (let index = 0; index < argumentsList.length; index += 1) {
|
|
572
666
|
const argument = argumentsList[index];
|
|
573
667
|
if (argument === '--json') {
|
|
@@ -800,6 +894,15 @@ async function runClaimCommand(claimCommand, argumentsList) {
|
|
|
800
894
|
const storePath = claimStorePath(gitCommonDir, namespace);
|
|
801
895
|
const journalPath = claimJournalPath(gitCommonDir, namespace);
|
|
802
896
|
const operation = CLAIM_OPERATIONS[claimCommand];
|
|
897
|
+
if (claimCommand === 'read') {
|
|
898
|
+
try {
|
|
899
|
+
const replayed = await replayClaimJournal(journalPath, namespace);
|
|
900
|
+
writeClaimEnvelope(operation(replayed.state, request, new Date().toISOString()).envelope);
|
|
901
|
+
} catch {
|
|
902
|
+
writeClaimEnvelope(claimStoreUnavailable(claimCommand, 'claim-store-unreadable'));
|
|
903
|
+
}
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
803
906
|
try {
|
|
804
907
|
const envelope = await withClaimLock(storePath, async () => {
|
|
805
908
|
let replayed;
|
|
@@ -976,6 +1079,10 @@ function usage(command) {
|
|
|
976
1079
|
return 'Usage: wowbagger ready --ledger <dir> --as-of YYYY-MM-DD [--json]';
|
|
977
1080
|
}
|
|
978
1081
|
|
|
1082
|
+
if (command === 'report') {
|
|
1083
|
+
return 'Usage: wowbagger report --ledger <dir> --as-of YYYY-MM-DD [--out <file>] --json';
|
|
1084
|
+
}
|
|
1085
|
+
|
|
979
1086
|
if (command === 'capabilities') {
|
|
980
1087
|
return 'Usage: wowbagger capabilities --json';
|
|
981
1088
|
}
|
package/src/mutation.js
CHANGED
|
@@ -4,7 +4,7 @@ import { withLegacyMutationFence } from './claim-coordinator.js';
|
|
|
4
4
|
import { link, lstat, mkdir, open, rename, unlink, writeFile } from 'node:fs/promises';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { isDeepStrictEqual } from 'node:util';
|
|
7
|
-
import { isAlias, isMap, isScalar, isSeq, parseDocument, Scalar } from 'yaml';
|
|
7
|
+
import { isAlias, isMap, isScalar, isSeq, parseDocument, Scalar, visit } from 'yaml';
|
|
8
8
|
import { isDependencySatisfied } from './dependencies.js';
|
|
9
9
|
import { loadLedger, parseLedgerItemSource } from './ledger.js';
|
|
10
10
|
import { JsonNumber, parseJsonRequest, pointer, sortIssues } from './request.js';
|
|
@@ -51,6 +51,8 @@ const CORE_OWNED_FIELDS = new Set([
|
|
|
51
51
|
const ULID_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
52
52
|
const ULID_PATTERN = /^wb_([0-7][0-9A-HJKMNP-TV-Z]{25})$/;
|
|
53
53
|
const MAX_LOCK_CLOSURE_RETRIES = 3;
|
|
54
|
+
const NUMBER_INDEX_LOCK_ID = '__number-index__';
|
|
55
|
+
const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]);
|
|
54
56
|
|
|
55
57
|
export async function inspectItem(ledgerDirectory, id) {
|
|
56
58
|
const ledger = await loadLedger(ledgerDirectory);
|
|
@@ -72,12 +74,18 @@ export function revisionFor(bytes) {
|
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
function inspectedPublishedItem(id, displayPath, bytes) {
|
|
75
|
-
const parsed = parseLedgerItemSource(bytes
|
|
77
|
+
const parsed = parseLedgerItemSource(decodedItemSource(bytes));
|
|
76
78
|
if (parsed.error) {
|
|
77
79
|
throw new Error('Published bytes could not be parsed.');
|
|
78
80
|
}
|
|
79
81
|
return inspectedItem(id, displayPath, bytes, parsed.data, parsed.body);
|
|
80
82
|
}
|
|
83
|
+
function decodedItemSource(bytes) {
|
|
84
|
+
return bytes.subarray(0, UTF8_BOM.length).equals(UTF8_BOM)
|
|
85
|
+
? bytes.subarray(UTF8_BOM.length).toString('utf8')
|
|
86
|
+
: bytes.toString('utf8');
|
|
87
|
+
}
|
|
88
|
+
|
|
81
89
|
|
|
82
90
|
function inspectedItem(id, displayPath, bytes, data, body) {
|
|
83
91
|
return {
|
|
@@ -278,7 +286,13 @@ async function createItemUnfenced(ledgerDirectory, request, scenario) {
|
|
|
278
286
|
}
|
|
279
287
|
|
|
280
288
|
const schemaVersion = current.ledger.items[0]?.data.schema_version ?? 2;
|
|
281
|
-
|
|
289
|
+
let bytes;
|
|
290
|
+
try {
|
|
291
|
+
failCandidateSerializationForTest(scenario);
|
|
292
|
+
bytes = createCandidateSource(request, schemaVersion);
|
|
293
|
+
} catch {
|
|
294
|
+
return await finishUncommitted(operationFailed(id, 'serialize-candidate', 'serialization-failed'));
|
|
295
|
+
}
|
|
282
296
|
const candidateValidation = validateSerializedCandidate(
|
|
283
297
|
current.ledger,
|
|
284
298
|
null,
|
|
@@ -303,7 +317,7 @@ async function createItemUnfenced(ledgerDirectory, request, scenario) {
|
|
|
303
317
|
}
|
|
304
318
|
|
|
305
319
|
temporaryPath = path.join(root, `.wowbagger-tmp-${id}-${randomSuffix()}`);
|
|
306
|
-
const temporaryFailure = await prepareTemporary(temporaryPath, bytes, scenario);
|
|
320
|
+
const temporaryFailure = await prepareTemporary(temporaryPath, bytes, null, scenario);
|
|
307
321
|
if (temporaryFailure) {
|
|
308
322
|
const artifacts = await cleanupTemporary(temporaryPath, root, scenario);
|
|
309
323
|
temporaryPath = artifacts.length > 0 ? temporaryPath : null;
|
|
@@ -441,11 +455,12 @@ export function validateTransitionRequest(request, parseIssues = []) {
|
|
|
441
455
|
}
|
|
442
456
|
|
|
443
457
|
export async function transitionItem(ledgerDirectory, request, scenario) {
|
|
444
|
-
return withLegacyMutationFence(ledgerDirectory, request.id, 'transition-v1', () => (
|
|
458
|
+
return withLegacyMutationFence(ledgerDirectory, request.id, 'transition-v1', (authorize) => (
|
|
445
459
|
mutateExistingItem(ledgerDirectory, request, scenario, {
|
|
446
460
|
name: 'transition',
|
|
447
461
|
lockIds: lockIdsForTransition,
|
|
448
462
|
build: buildTransition,
|
|
463
|
+
authorize,
|
|
449
464
|
})
|
|
450
465
|
));
|
|
451
466
|
}
|
|
@@ -485,7 +500,7 @@ async function mutateExistingItem(ledgerDirectory, request, scenario, operation)
|
|
|
485
500
|
if (!target) {
|
|
486
501
|
return mutationError('item-not-found', 'The requested item was not found.', 'unchanged', 2, { id });
|
|
487
502
|
}
|
|
488
|
-
const nextIds = operation.lockIds(target, initial.ledger);
|
|
503
|
+
const nextIds = operation.lockIds(target, initial.ledger, request);
|
|
489
504
|
if (scenario === 'expand-lock-closure-through-bounded-retry-limit') {
|
|
490
505
|
return operationFailed(id, 'lock-closure', 'retry-limit-exhausted');
|
|
491
506
|
}
|
|
@@ -531,7 +546,7 @@ async function mutateExistingItem(ledgerDirectory, request, scenario, operation)
|
|
|
531
546
|
if (!lockedTarget) {
|
|
532
547
|
return await finishUncommitted(mutationError('item-not-found', 'The requested item was not found.', 'unchanged', 2, { id }));
|
|
533
548
|
}
|
|
534
|
-
const stableIds = operation.lockIds(lockedTarget, current.ledger);
|
|
549
|
+
const stableIds = operation.lockIds(lockedTarget, current.ledger, request);
|
|
535
550
|
if (!sameIds(nextIds, stableIds)) {
|
|
536
551
|
const cleanupFailure = await finishUncommitted(null);
|
|
537
552
|
if (cleanupFailure) {
|
|
@@ -549,7 +564,7 @@ async function mutateExistingItem(ledgerDirectory, request, scenario, operation)
|
|
|
549
564
|
}));
|
|
550
565
|
}
|
|
551
566
|
|
|
552
|
-
const built = operation.build(lockedTarget, current.ledger, request);
|
|
567
|
+
const built = operation.build(lockedTarget, current.ledger, request, scenario);
|
|
553
568
|
if (built.outcome) {
|
|
554
569
|
return await finishUncommitted(built.outcome);
|
|
555
570
|
}
|
|
@@ -569,8 +584,21 @@ async function mutateExistingItem(ledgerDirectory, request, scenario, operation)
|
|
|
569
584
|
validation_errors: candidateValidation.errors,
|
|
570
585
|
}));
|
|
571
586
|
}
|
|
587
|
+
if (operation.authorize) {
|
|
588
|
+
await operation.authorize(actualRevision, revisionFor(bytes));
|
|
589
|
+
}
|
|
572
590
|
|
|
573
591
|
const targetDirectory = path.dirname(lockedTarget.file);
|
|
592
|
+
let targetMode;
|
|
593
|
+
try {
|
|
594
|
+
const targetStat = await lstat(lockedTarget.file);
|
|
595
|
+
if (!targetStat.isFile() || targetStat.isSymbolicLink()) {
|
|
596
|
+
throw new Error('The target is not a regular file.');
|
|
597
|
+
}
|
|
598
|
+
targetMode = targetStat.mode & 0o7777;
|
|
599
|
+
} catch {
|
|
600
|
+
return await finishUncommitted(operationFailed(id, 'prepare-temporary', 'io-error'));
|
|
601
|
+
}
|
|
574
602
|
temporaryPath = path.join(targetDirectory, `.wowbagger-tmp-${id}-${randomSuffix()}`);
|
|
575
603
|
if (scenarioName(scenario) === 'transition-rename-applied-then-error') {
|
|
576
604
|
await writeFile(path.join(root, '.wowbagger-test-transition-paths.json'), JSON.stringify({
|
|
@@ -579,7 +607,7 @@ async function mutateExistingItem(ledgerDirectory, request, scenario, operation)
|
|
|
579
607
|
synced_directory: null,
|
|
580
608
|
}));
|
|
581
609
|
}
|
|
582
|
-
const temporaryFailure = await prepareTemporary(temporaryPath, bytes, scenario);
|
|
610
|
+
const temporaryFailure = await prepareTemporary(temporaryPath, bytes, targetMode, scenario);
|
|
583
611
|
if (temporaryFailure) {
|
|
584
612
|
const artifacts = await cleanupTemporary(temporaryPath, root, scenario);
|
|
585
613
|
temporaryPath = artifacts.length > 0 ? temporaryPath : null;
|
|
@@ -732,16 +760,20 @@ function isPatchableInteger(value, minimum) {
|
|
|
732
760
|
}
|
|
733
761
|
|
|
734
762
|
export async function patchItem(ledgerDirectory, request, scenario) {
|
|
735
|
-
return withLegacyMutationFence(ledgerDirectory, request.id, '
|
|
763
|
+
return withLegacyMutationFence(ledgerDirectory, request.id, 'patch-v1', (authorize) => (
|
|
736
764
|
mutateExistingItem(ledgerDirectory, request, scenario, {
|
|
737
765
|
name: 'patch',
|
|
738
|
-
lockIds: (target) => [
|
|
766
|
+
lockIds: (target, _ledger, patchRequest) => [
|
|
767
|
+
target.data.id,
|
|
768
|
+
...(hasOwn(patchRequest.set, 'number') ? [NUMBER_INDEX_LOCK_ID] : []),
|
|
769
|
+
],
|
|
739
770
|
build: buildPatch,
|
|
771
|
+
authorize,
|
|
740
772
|
})
|
|
741
773
|
));
|
|
742
774
|
}
|
|
743
775
|
|
|
744
|
-
function buildPatch(lockedTarget, ledger, request) {
|
|
776
|
+
function buildPatch(lockedTarget, ledger, request, scenario) {
|
|
745
777
|
const issues = [];
|
|
746
778
|
if (request.date < lockedTarget.data.created) {
|
|
747
779
|
issues.push(transitionIssue('date-before-created', 'date', 'Patch date must not be earlier than the current created date.', []));
|
|
@@ -756,8 +788,13 @@ function buildPatch(lockedTarget, ledger, request) {
|
|
|
756
788
|
}) };
|
|
757
789
|
}
|
|
758
790
|
const successor = patchData(lockedTarget.data, request);
|
|
759
|
-
|
|
760
|
-
|
|
791
|
+
try {
|
|
792
|
+
failCandidateSerializationForTest(scenario);
|
|
793
|
+
const bytes = serializedMutationBytes(lockedTarget, serializePatch(lockedTarget.source, successor, request));
|
|
794
|
+
return { successor, bytes };
|
|
795
|
+
} catch {
|
|
796
|
+
return { outcome: operationFailed(request.id, 'serialize-candidate', 'serialization-failed') };
|
|
797
|
+
}
|
|
761
798
|
}
|
|
762
799
|
|
|
763
800
|
function patchData(data, request) {
|
|
@@ -778,12 +815,27 @@ function patchData(data, request) {
|
|
|
778
815
|
return successor;
|
|
779
816
|
}
|
|
780
817
|
|
|
818
|
+
function failCandidateSerializationForTest(scenario) {
|
|
819
|
+
if (scenarioName(scenario) === 'candidate-serialization-fails') {
|
|
820
|
+
throw new Error('Fixture candidate serialization failure.');
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
function serializedMutationBytes(lockedTarget, source) {
|
|
824
|
+
const serialized = Buffer.from(source, 'utf8');
|
|
825
|
+
if (lockedTarget.bytes.length < UTF8_BOM.length
|
|
826
|
+
|| !lockedTarget.bytes.subarray(0, UTF8_BOM.length).equals(UTF8_BOM)) {
|
|
827
|
+
return serialized;
|
|
828
|
+
}
|
|
829
|
+
return Buffer.concat([UTF8_BOM, serialized]);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
|
|
781
833
|
function serializePatch(source, successor, request) {
|
|
782
834
|
return rewriteFrontmatter(source, (document) => {
|
|
783
835
|
setRootScalar(document, 'updated', successor.updated);
|
|
784
836
|
for (const field of Object.keys(request.set)) {
|
|
785
837
|
if (!Object.hasOwn(successor, field)) {
|
|
786
|
-
document
|
|
838
|
+
deleteRootFieldPreservingAliases(document, field);
|
|
787
839
|
} else if (document.has(field)) {
|
|
788
840
|
setRootScalar(document, field, successor[field]);
|
|
789
841
|
} else {
|
|
@@ -817,7 +869,7 @@ function rewriteFrontmatter(source, edit) {
|
|
|
817
869
|
return `${source.slice(0, bounds.start)}${serialized}${source.slice(bounds.end)}`;
|
|
818
870
|
}
|
|
819
871
|
|
|
820
|
-
function buildTransition(lockedTarget, ledger, request) {
|
|
872
|
+
function buildTransition(lockedTarget, ledger, request, scenario) {
|
|
821
873
|
const id = lockedTarget.data.id;
|
|
822
874
|
const edge = transitionEdge(lockedTarget.data.kind, lockedTarget.data.status, request.to_status);
|
|
823
875
|
const issues = transitionPreconditions(lockedTarget, ledger, request, edge);
|
|
@@ -843,8 +895,13 @@ function buildTransition(lockedTarget, ledger, request) {
|
|
|
843
895
|
}
|
|
844
896
|
|
|
845
897
|
const successor = transitionData(lockedTarget.data, request, edge, ledger);
|
|
846
|
-
|
|
847
|
-
|
|
898
|
+
try {
|
|
899
|
+
failCandidateSerializationForTest(scenario);
|
|
900
|
+
const bytes = serializedMutationBytes(lockedTarget, serializeTransition(lockedTarget.source, successor, edge));
|
|
901
|
+
return { successor, bytes };
|
|
902
|
+
} catch {
|
|
903
|
+
return { outcome: operationFailed(request.id, 'serialize-candidate', 'serialization-failed') };
|
|
904
|
+
}
|
|
848
905
|
}
|
|
849
906
|
|
|
850
907
|
function invalidTransitionDecision() {
|
|
@@ -895,7 +952,10 @@ function transitionEdge(kind, from, to) {
|
|
|
895
952
|
'epic:archived:backlog': 'restore',
|
|
896
953
|
}[`${kind}:${from}:${to}`] ?? null;
|
|
897
954
|
const allowed = (from === 'triage' && (to === 'backlog' || to === 'killed'))
|
|
898
|
-
|| (from === 'backlog' && (
|
|
955
|
+
|| (from === 'backlog' && (
|
|
956
|
+
(kind === 'task' && to === 'in-progress')
|
|
957
|
+
|| ['archived', 'killed', 'deferred'].includes(to)
|
|
958
|
+
))
|
|
899
959
|
|| (from === 'deferred' && to === 'backlog')
|
|
900
960
|
|| (kind === 'task' && from === 'in-progress' && ['backlog', 'done', 'killed'].includes(to))
|
|
901
961
|
|| (kind === 'epic' && from === 'backlog' && ['done', 'archived', 'killed'].includes(to))
|
|
@@ -1017,10 +1077,10 @@ function serializeTransition(source, successor, edge) {
|
|
|
1017
1077
|
return rewriteFrontmatter(source, (document) => {
|
|
1018
1078
|
setRootScalar(document, 'status', successor.status);
|
|
1019
1079
|
setRootScalar(document, 'updated', successor.updated);
|
|
1020
|
-
document
|
|
1021
|
-
document
|
|
1022
|
-
document
|
|
1023
|
-
document
|
|
1080
|
+
deleteRootFieldPreservingAliases(document, 'completed');
|
|
1081
|
+
deleteRootFieldPreservingAliases(document, 'killed');
|
|
1082
|
+
deleteRootFieldPreservingAliases(document, 'archived');
|
|
1083
|
+
deleteRootFieldPreservingAliases(document, 'deferred');
|
|
1024
1084
|
const terminal = terminalField(successor.status);
|
|
1025
1085
|
if (terminal) {
|
|
1026
1086
|
insertRootAfter(document, 'updated', terminal, successor[terminal]);
|
|
@@ -1040,6 +1100,21 @@ function setRootScalar(document, key, value) {
|
|
|
1040
1100
|
document.set(key, value);
|
|
1041
1101
|
}
|
|
1042
1102
|
|
|
1103
|
+
function deleteRootFieldPreservingAliases(document, key) {
|
|
1104
|
+
const node = document.get(key, true);
|
|
1105
|
+
if (node?.anchor) {
|
|
1106
|
+
visit(document, {
|
|
1107
|
+
Alias(_key, alias) {
|
|
1108
|
+
if (alias.resolve(document) !== node) return undefined;
|
|
1109
|
+
const replacement = node.clone(document.schema);
|
|
1110
|
+
replacement.anchor = undefined;
|
|
1111
|
+
return replacement;
|
|
1112
|
+
},
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
document.delete(key);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1043
1118
|
function insertRootAfter(document, afterKey, key, value) {
|
|
1044
1119
|
const existing = document.contents.items.findIndex((pair) => pair.key?.value === key);
|
|
1045
1120
|
if (existing >= 0) {
|
|
@@ -1118,13 +1193,16 @@ async function loadedValidLedger(root) {
|
|
|
1118
1193
|
}
|
|
1119
1194
|
|
|
1120
1195
|
function validateSerializedCandidate(ledger, replacementId, bytes, displayPath, file, expectedData, expectedSource) {
|
|
1121
|
-
const source = bytes
|
|
1196
|
+
const source = decodedItemSource(bytes);
|
|
1122
1197
|
const parsed = parseLedgerItemSource(source);
|
|
1123
1198
|
const errors = parsed.error ? [{ path: displayPath, ...parsed.error }] : [];
|
|
1124
1199
|
const coreMatches = parsed.error || !expectedData
|
|
1125
1200
|
|| isDeepStrictEqual(coreView(parsed.data), coreView(expectedData));
|
|
1126
1201
|
const extensionsMatch = parsed.error || !expectedSource
|
|
1127
|
-
|| isDeepStrictEqual(
|
|
1202
|
+
|| isDeepStrictEqual(
|
|
1203
|
+
extensionNodeIdentity(source),
|
|
1204
|
+
expectedExtensionNodeIdentity(expectedSource, expectedData),
|
|
1205
|
+
);
|
|
1128
1206
|
if (!parsed.error && (!coreMatches || !extensionsMatch)) {
|
|
1129
1207
|
errors.push({
|
|
1130
1208
|
path: displayPath,
|
|
@@ -1147,6 +1225,25 @@ function validateSerializedCandidate(ledger, replacementId, bytes, displayPath,
|
|
|
1147
1225
|
return validateLedger({ items: items.filter(Boolean), errors });
|
|
1148
1226
|
}
|
|
1149
1227
|
|
|
1228
|
+
function expectedExtensionNodeIdentity(source, expectedData) {
|
|
1229
|
+
const bounds = frontmatterBounds(source);
|
|
1230
|
+
const document = parseDocument(source.slice(bounds.start, bounds.end), {
|
|
1231
|
+
schema: 'core',
|
|
1232
|
+
uniqueKeys: true,
|
|
1233
|
+
});
|
|
1234
|
+
const removedAnchors = [...CORE_OWNED_FIELDS].filter((field) => (
|
|
1235
|
+
!Object.hasOwn(expectedData, field)
|
|
1236
|
+
&& document.get(field, true)?.anchor
|
|
1237
|
+
));
|
|
1238
|
+
if (removedAnchors.length === 0) return extensionNodeIdentity(source);
|
|
1239
|
+
const normalized = rewriteFrontmatter(source, (normalizedDocument) => {
|
|
1240
|
+
for (const field of removedAnchors) {
|
|
1241
|
+
deleteRootFieldPreservingAliases(normalizedDocument, field);
|
|
1242
|
+
}
|
|
1243
|
+
});
|
|
1244
|
+
return extensionNodeIdentity(normalized);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1150
1247
|
function extensionNodeIdentity(source) {
|
|
1151
1248
|
const bounds = frontmatterBounds(source);
|
|
1152
1249
|
const document = parseDocument(source.slice(bounds.start, bounds.end), {
|
|
@@ -1200,7 +1297,8 @@ function lockIdsForCreate(request, ledger) {
|
|
|
1200
1297
|
const existingIds = new Set(ledger.items.map((item) => item.data.id));
|
|
1201
1298
|
const references = [request.item.parent, ...(request.item.depends_on ?? [])]
|
|
1202
1299
|
.filter((id) => existingIds.has(id));
|
|
1203
|
-
|
|
1300
|
+
const numberLock = hasOwn(request.item, 'number') ? [NUMBER_INDEX_LOCK_ID] : [];
|
|
1301
|
+
return [request.id, ...references, ...numberLock].sort(compareText);
|
|
1204
1302
|
}
|
|
1205
1303
|
|
|
1206
1304
|
async function acquireLocks(root, ids, operation, scenario) {
|
|
@@ -1692,6 +1790,10 @@ async function testCheckpoint(root, scenario, point) {
|
|
|
1692
1790
|
await writeFile(path.join(root, `.wowbagger-test-${suffix}-acquired`), 'acquired\n');
|
|
1693
1791
|
await waitForTestMarker(path.join(root, `.wowbagger-test-${suffix}-allow-successor`));
|
|
1694
1792
|
}
|
|
1793
|
+
if (name === 'pause-after-temporary-open' && point === 'after-temporary-open') {
|
|
1794
|
+
await writeFile(path.join(root, `.wowbagger-test-${suffix}-temporary-open`), 'open\n');
|
|
1795
|
+
await waitForTestMarker(path.join(root, `.wowbagger-test-${suffix}-continue`));
|
|
1796
|
+
}
|
|
1695
1797
|
}
|
|
1696
1798
|
|
|
1697
1799
|
async function waitForTestMarker(file) {
|
|
@@ -1867,19 +1969,36 @@ function randomSuffix() {
|
|
|
1867
1969
|
return createHash('sha256').update(`${process.pid}:${Date.now()}:${Math.random()}`).digest('hex').slice(0, 24);
|
|
1868
1970
|
}
|
|
1869
1971
|
|
|
1870
|
-
async function prepareTemporary(file, bytes, scenario) {
|
|
1972
|
+
async function prepareTemporary(file, bytes, mode, scenario) {
|
|
1871
1973
|
let handle;
|
|
1872
1974
|
try {
|
|
1873
|
-
handle = await open(file, 'wx');
|
|
1975
|
+
handle = await open(file, 'wx', (mode ?? 0o666) & 0o777);
|
|
1874
1976
|
} catch {
|
|
1875
1977
|
return 'prepare-temporary';
|
|
1876
1978
|
}
|
|
1979
|
+
await testCheckpoint(path.dirname(file), scenario, 'after-temporary-open');
|
|
1877
1980
|
|
|
1878
1981
|
let failure = null;
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1982
|
+
if (mode !== null) {
|
|
1983
|
+
try {
|
|
1984
|
+
await handle.chmod(mode);
|
|
1985
|
+
} catch {
|
|
1986
|
+
failure = 'prepare-temporary';
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
if (!failure) {
|
|
1990
|
+
try {
|
|
1991
|
+
await handle.writeFile(bytes);
|
|
1992
|
+
} catch {
|
|
1993
|
+
failure = 'prepare-temporary';
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
if (!failure && mode !== null) {
|
|
1997
|
+
try {
|
|
1998
|
+
await handle.chmod(mode);
|
|
1999
|
+
} catch {
|
|
2000
|
+
failure = 'prepare-temporary';
|
|
2001
|
+
}
|
|
1883
2002
|
}
|
|
1884
2003
|
if (!failure) {
|
|
1885
2004
|
try {
|