release-skill 0.1.4 → 0.1.6
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/CHANGELOG.md +104 -0
- package/INSTALL.md +81 -1
- package/INSTALL.zh-CN.md +69 -1
- package/README.md +233 -8
- package/README.zh-CN.md +188 -8
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +14164 -9912
- package/adapters/claude/bin/release-skill.mjs +24 -4
- package/adapters/claude/native/safe-write/binding.gyp +2 -1
- package/adapters/claude/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
- package/adapters/claude/native/safe-write/prebuilds.json +1 -1
- package/adapters/claude/schemas/.render-manifest.json +10 -10
- package/adapters/claude/schemas/release-project.schema.json +141 -0
- package/adapters/claude/skills/release-help/SKILL.md +21 -0
- package/adapters/claude/skills/release-prepare/SKILL.md +17 -6
- package/adapters/claude/skills/release-publish/SKILL.md +3 -1
- package/adapters/claude/skills/release-reconcile/SKILL.md +1 -1
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +14164 -9912
- package/adapters/codex/bin/release-skill.mjs +24 -4
- package/adapters/codex/native/safe-write/binding.gyp +2 -1
- package/adapters/codex/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
- package/adapters/codex/native/safe-write/prebuilds.json +1 -1
- package/adapters/codex/schemas/.render-manifest.json +10 -10
- package/adapters/codex/schemas/release-project.schema.json +141 -0
- package/adapters/codex/skills/release-help/SKILL.md +21 -0
- package/adapters/codex/skills/release-prepare/SKILL.md +17 -6
- package/adapters/codex/skills/release-publish/SKILL.md +3 -1
- package/adapters/codex/skills/release-reconcile/SKILL.md +1 -1
- package/bin/release-skill-cli.mjs +163 -4
- package/bin/release-skill.bundle.mjs +14164 -9912
- package/bin/release-skill.mjs +24 -4
- package/native/safe-write/binding.gyp +2 -1
- package/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
- package/native/safe-write/prebuilds.json +1 -1
- package/package.json +2 -2
- package/references/.render-manifest.json +4 -4
- package/references/02-project-config.md +24 -0
- package/references/05-evidence-and-errors.md +5 -0
- package/schemas/.render-manifest.json +10 -10
- package/schemas/release-project.schema.json +141 -0
- package/scripts/build-bundle.mjs +15 -2
- package/skills/release-help/SKILL.md +21 -0
- package/skills/release-prepare/SKILL.md +17 -6
- package/skills/release-publish/SKILL.md +3 -1
- package/skills/release-reconcile/SKILL.md +1 -1
- package/skills-src/release-help/SKILL.md +21 -0
- package/skills-src/release-prepare/SKILL.md +17 -6
- package/skills-src/release-publish/SKILL.md +3 -1
- package/skills-src/release-reconcile/SKILL.md +1 -1
- package/src/adapters/plugin-marketplace.mjs +70 -3
- package/src/artifacts/transaction-journal.mjs +1126 -105
- package/src/artifacts/transaction.mjs +313 -130
- package/src/commands/docs.mjs +332 -0
- package/src/commands/prepare.mjs +324 -17
- package/src/commands/reconcile.mjs +4 -1
- package/src/commands/verify.mjs +4 -1
- package/src/core/errors.mjs +64 -2
- package/src/core/plan.mjs +59 -1
- package/src/core/redact.mjs +206 -0
- package/src/docs/changelog-renderer.mjs +853 -0
- package/src/docs/config.mjs +337 -0
- package/src/docs/notes-loader.mjs +432 -0
- package/src/docs/notes.mjs +553 -0
- package/src/docs/readme-renderer.mjs +647 -0
- package/src/docs/refresh-planner.mjs +542 -0
- package/src/docs/refresh-service.mjs +675 -0
|
@@ -2,12 +2,22 @@
|
|
|
2
2
|
* Transaction journal for durable apply operations.
|
|
3
3
|
*
|
|
4
4
|
* Manages write-ahead logging, state transitions, and crash recovery
|
|
5
|
-
* for artifact plan applications. ALL filesystem writes go
|
|
6
|
-
* safe-fs backend DirectoryHandle — no Node path-based writes.
|
|
5
|
+
* for artifact plan applications. ALL transactional filesystem writes go
|
|
6
|
+
* through the safe-fs backend DirectoryHandle — no Node path-based writes.
|
|
7
|
+
*
|
|
8
|
+
* Documented exception — retention pruning: `pruneTerminalTransactionRecords`
|
|
9
|
+
* is a best-effort maintenance path that uses `node:fs` to enumerate and
|
|
10
|
+
* remove old TERMINAL records. The safe-fs handle exposes neither directory
|
|
11
|
+
* enumeration nor recursive removal, and a retention failure must never abort
|
|
12
|
+
* or roll back a transaction, so this single helper deliberately bypasses the
|
|
13
|
+
* handle. It is scoped strictly to the current process's transactions root and
|
|
14
|
+
* never prunes non-terminal (recovery-relevant) records. See that helper.
|
|
7
15
|
*
|
|
8
16
|
* @module artifacts/transaction-journal
|
|
9
17
|
*/
|
|
10
18
|
|
|
19
|
+
import { readdir, readFile, rm, stat } from 'node:fs/promises';
|
|
20
|
+
import { join } from 'node:path';
|
|
11
21
|
import { ReleaseError, INVALID_STATE_TRANSITION, TRANSACTION_INCOMPLETE, PATH_UNSAFE } from '../core/errors.mjs';
|
|
12
22
|
import { canonicalJson, sha256Hex } from '../core/digest.mjs';
|
|
13
23
|
import { canonicalArtifactPath } from './path-key.mjs';
|
|
@@ -40,6 +50,71 @@ export const VALID_TRANSITIONS = Object.freeze({
|
|
|
40
50
|
RECOVERY_CONFLICT: [],
|
|
41
51
|
});
|
|
42
52
|
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Retention policy constants
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Terminal transaction states that are safe to prune under the retention
|
|
59
|
+
* policy. These are the states with NO outgoing transitions in
|
|
60
|
+
* VALID_TRANSITIONS that the recovery protocol does NOT depend on.
|
|
61
|
+
*
|
|
62
|
+
* `RECOVERY_CONFLICT` also has no outgoing transitions but is deliberately
|
|
63
|
+
* EXCLUDED: it signals a rollback conflict that requires a human decision
|
|
64
|
+
* (see the state-machine discipline in AGENTS.md), so its record must be kept
|
|
65
|
+
* as evidence. Every other non-terminal state (PREPARED, APPLYING, APPLIED,
|
|
66
|
+
* VERIFYING, RECOVERY_REQUIRED, ROLLING_BACK) is recovery-relevant and is
|
|
67
|
+
* never pruned either.
|
|
68
|
+
*/
|
|
69
|
+
export const PRUNABLE_TERMINAL_STATES = Object.freeze(new Set(['COMMITTED', 'ROLLED_BACK']));
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Default retention cap: the maximum number of TERMINAL (COMMITTED /
|
|
73
|
+
* ROLLED_BACK) transaction records retained under `.release-skill/transactions/`.
|
|
74
|
+
*
|
|
75
|
+
* When the number of terminal records exceeds this cap, the oldest terminal
|
|
76
|
+
* records (by journal `createdAt`, falling back to directory mtime) are removed
|
|
77
|
+
* until exactly the cap remains. Non-terminal records are never counted against
|
|
78
|
+
* the cap and never pruned. Override per call via the `retentionMax` option on
|
|
79
|
+
* `createTransactionJournal` / `pruneTerminalTransactionRecords`.
|
|
80
|
+
*/
|
|
81
|
+
export const DEFAULT_TRANSACTION_RETENTION_MAX = 50;
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Terminal receipt convergence constants
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The explicitly versioned terminal-receipt schema version written by
|
|
89
|
+
* `convergeTerminalRecord`. Receipts carry `terminalReceiptVersion >= 1`;
|
|
90
|
+
* readers fail closed on unknown versions.
|
|
91
|
+
*/
|
|
92
|
+
export const TERMINAL_RECEIPT_VERSION = 1;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Fixed, payload-independent upper bound (bytes) for the serialized terminal
|
|
96
|
+
* receipt (`journal.json` of a converged COMMITTED / ROLLED_BACK record).
|
|
97
|
+
*
|
|
98
|
+
* Rationale: a receipt is a few KB of audit JSON even for a large write set
|
|
99
|
+
* (100 entries x ~300B of digests/metadata ~ 30KB); 256KB is ~8x headroom over
|
|
100
|
+
* that, >=3 orders of magnitude below the observed ~228MB defect records in
|
|
101
|
+
* the read-only `.release-skill/transactions/` evidence, and bounds the
|
|
102
|
+
* worst-case retention footprint to 50 x 256KB = 12.8MB (vs the ~11GB the
|
|
103
|
+
* count-only retention cap permits for payload-sized records). Receipts are
|
|
104
|
+
* never allowed to scale with payload bytes: the whole record is digests,
|
|
105
|
+
* counts, and small audit metadata only — never old/new file bodies,
|
|
106
|
+
* serialized Buffers, or canonicalPlan payload bytes.
|
|
107
|
+
*/
|
|
108
|
+
export const TERMINAL_RECEIPT_SIZE_CAP = 256 * 1024;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Maximum admissible growth of the whole terminal txn directory when the
|
|
112
|
+
* payload grows by several MB. Proves the record no longer scales with
|
|
113
|
+
* payload bytes while admitting per-entry audit growth. Pairs with
|
|
114
|
+
* TERMINAL_RECEIPT_SIZE_CAP (see that constant's rationale).
|
|
115
|
+
*/
|
|
116
|
+
export const TERMINAL_RECEIPT_DELTA_CAP = 64 * 1024;
|
|
117
|
+
|
|
43
118
|
const JOURNAL_SCHEMA_FIELDS = new Set([
|
|
44
119
|
'transactionId',
|
|
45
120
|
'planDigest',
|
|
@@ -53,6 +128,33 @@ const JOURNAL_SCHEMA_FIELDS = new Set([
|
|
|
53
128
|
'updatedAt',
|
|
54
129
|
]);
|
|
55
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Closed field set of the explicitly versioned terminal receipt (schema
|
|
133
|
+
* version 1). A receipt is what a terminal (COMMITTED / ROLLED_BACK) record's
|
|
134
|
+
* `journal.json` is atomically rewritten into by `convergeTerminalRecord`:
|
|
135
|
+
* small audit metadata only — never old/new file bodies, serialized Buffers,
|
|
136
|
+
* or canonicalPlan payload bytes. `oldManifest` survives as a digest-only
|
|
137
|
+
* summary (no `bytes`); `canonicalPlan` survives with artifact-plan v1
|
|
138
|
+
* `newEntry.bytes` stripped (docs-refresh v1 plans never carry bytes).
|
|
139
|
+
*/
|
|
140
|
+
const RECEIPT_SCHEMA_FIELDS = new Set([
|
|
141
|
+
'terminalReceiptVersion',
|
|
142
|
+
'transactionId',
|
|
143
|
+
'planDigest',
|
|
144
|
+
'canonicalPlan',
|
|
145
|
+
'planSummary',
|
|
146
|
+
'oldManifest',
|
|
147
|
+
'newManifest',
|
|
148
|
+
'state',
|
|
149
|
+
'transitions',
|
|
150
|
+
'entries',
|
|
151
|
+
'createdAt',
|
|
152
|
+
'updatedAt',
|
|
153
|
+
]);
|
|
154
|
+
const RECEIPT_ENTRY_FIELDS = new Set(['id', 'path', 'status', 'appliedAt', 'digest']);
|
|
155
|
+
const RECEIPT_DIGEST_FIELDS = new Set(['path', 'kind', 'sha256', 'size', 'mode']);
|
|
156
|
+
const RECEIPT_PLAN_SUMMARY_FIELDS = new Set(['apiVersion', 'operation']);
|
|
157
|
+
|
|
56
158
|
const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
|
|
57
159
|
const PLAN_SCHEMA_FIELDS = new Set([
|
|
58
160
|
'apiVersion', 'operation', 'bindings', 'safeToWrite', 'targetUnchanged',
|
|
@@ -62,6 +164,17 @@ const ARTIFACT_SCHEMA_FIELDS = new Set([
|
|
|
62
164
|
'id', 'path', 'oldEntry', 'newEntry', 'status', 'safeToWrite',
|
|
63
165
|
]);
|
|
64
166
|
|
|
167
|
+
// Closed canonical-plan schema for the docs-refresh v1 transaction authority
|
|
168
|
+
// (2026-07-21-release-docs-refresh-protocol §6). No bytes, no
|
|
169
|
+
// absolute paths: only canonical relative paths and digests.
|
|
170
|
+
const DOCS_REFRESH_PLAN_FIELDS = new Set([
|
|
171
|
+
'apiVersion', 'operation', 'unitId', 'version', 'refreshDigest', 'files',
|
|
172
|
+
]);
|
|
173
|
+
const DOCS_REFRESH_FILE_FIELDS = new Set([
|
|
174
|
+
'id', 'path', 'kind', 'locale', 'oldDigest', 'newDigest', 'change',
|
|
175
|
+
]);
|
|
176
|
+
const DOCS_REFRESH_UNIT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
177
|
+
|
|
65
178
|
function failSchema(message, details) {
|
|
66
179
|
throw new ReleaseError(TRANSACTION_INCOMPLETE, message, details);
|
|
67
180
|
}
|
|
@@ -143,7 +256,30 @@ function validateManifestItem(item, role, index) {
|
|
|
143
256
|
return Object.freeze({ path: canonical.path, kind: 'regular', digest, size: item.size, mode });
|
|
144
257
|
}
|
|
145
258
|
|
|
146
|
-
|
|
259
|
+
/**
|
|
260
|
+
* Select the per-entry authority items of a canonical plan by apiVersion:
|
|
261
|
+
* artifact-plan v1 plans carry `artifacts`, docs-refresh v1 plans carry
|
|
262
|
+
* `files`. Unknown apiVersions fail closed.
|
|
263
|
+
*
|
|
264
|
+
* @param {object} canonicalPlan — validated journal canonicalPlan.
|
|
265
|
+
* @returns {object[]} The per-entry authority items (id/path carriers).
|
|
266
|
+
*/
|
|
267
|
+
function canonicalPlanItems(canonicalPlan) {
|
|
268
|
+
if (canonicalPlan?.apiVersion === 'release-skill.dev/artifact-plan/v1') {
|
|
269
|
+
return canonicalPlan.artifacts;
|
|
270
|
+
}
|
|
271
|
+
if (canonicalPlan?.apiVersion === 'release-skill.dev/docs-refresh/v1') {
|
|
272
|
+
return canonicalPlan.files;
|
|
273
|
+
}
|
|
274
|
+
return failSchema('journal canonicalPlan apiVersion is unsupported');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Validate the closed artifact-plan v1 canonicalPlan authority against the
|
|
279
|
+
* journal and its manifests. Behaviour is byte-for-byte the pre-refactor
|
|
280
|
+
* validateCanonicalPlanBinding body.
|
|
281
|
+
*/
|
|
282
|
+
function validateArtifactPlanAuthority(journal, oldItems, newItems) {
|
|
147
283
|
const plan = journal.canonicalPlan;
|
|
148
284
|
assertClosedObject(plan, PLAN_SCHEMA_FIELDS, 'journal canonicalPlan');
|
|
149
285
|
for (const field of PLAN_SCHEMA_FIELDS) {
|
|
@@ -230,6 +366,95 @@ function validateCanonicalPlanBinding(journal, oldItems, newItems) {
|
|
|
230
366
|
}
|
|
231
367
|
}
|
|
232
368
|
|
|
369
|
+
/**
|
|
370
|
+
* Validate the closed docs-refresh v1 canonicalPlan authority against the
|
|
371
|
+
* journal and its manifests: closed field sets, refresh operation, safe
|
|
372
|
+
* unitId, non-empty version, refreshDigest bound to journal.planDigest, and
|
|
373
|
+
* per-file identity agreement with both manifests (canonical relative paths
|
|
374
|
+
* only; digests only; never bytes).
|
|
375
|
+
*/
|
|
376
|
+
function validateDocsRefreshAuthority(journal, oldItems, newItems) {
|
|
377
|
+
const plan = journal.canonicalPlan;
|
|
378
|
+
assertClosedObject(plan, DOCS_REFRESH_PLAN_FIELDS, 'journal canonicalPlan');
|
|
379
|
+
for (const field of DOCS_REFRESH_PLAN_FIELDS) {
|
|
380
|
+
if (!Object.hasOwn(plan, field)) failSchema(`journal canonicalPlan missing required field: ${field}`);
|
|
381
|
+
}
|
|
382
|
+
if (plan.operation !== 'refresh') {
|
|
383
|
+
failSchema('journal canonicalPlan operation must be refresh');
|
|
384
|
+
}
|
|
385
|
+
if (typeof plan.unitId !== 'string' || !DOCS_REFRESH_UNIT_ID_RE.test(plan.unitId)) {
|
|
386
|
+
failSchema('journal canonicalPlan unitId is invalid');
|
|
387
|
+
}
|
|
388
|
+
if (typeof plan.version !== 'string' || plan.version.length === 0) {
|
|
389
|
+
failSchema('journal canonicalPlan version is invalid');
|
|
390
|
+
}
|
|
391
|
+
if (typeof plan.refreshDigest !== 'string'
|
|
392
|
+
|| !DIGEST_RE.test(plan.refreshDigest)
|
|
393
|
+
|| plan.refreshDigest !== journal.planDigest) {
|
|
394
|
+
failSchema('journal canonicalPlan refreshDigest does not match journal planDigest');
|
|
395
|
+
}
|
|
396
|
+
if (!Array.isArray(plan.files)
|
|
397
|
+
|| plan.files.length !== oldItems.length
|
|
398
|
+
|| plan.files.length !== newItems.length) {
|
|
399
|
+
failSchema('journal canonicalPlan files do not match manifest length');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const seenIds = new Set();
|
|
403
|
+
for (let i = 0; i < plan.files.length; i++) {
|
|
404
|
+
const file = plan.files[i];
|
|
405
|
+
const label = `journal canonicalPlan.files[${i}]`;
|
|
406
|
+
assertClosedObject(file, DOCS_REFRESH_FILE_FIELDS, label);
|
|
407
|
+
for (const field of DOCS_REFRESH_FILE_FIELDS) {
|
|
408
|
+
if (!Object.hasOwn(file, field)) failSchema(`${label} missing required field: ${field}`);
|
|
409
|
+
}
|
|
410
|
+
if (typeof file.id !== 'string' || file.id.length === 0) failSchema(`${label} has invalid id`);
|
|
411
|
+
if (seenIds.has(file.id)) failSchema(`${label} has duplicate id`);
|
|
412
|
+
seenIds.add(file.id);
|
|
413
|
+
const canonical = canonicalArtifactPath(file.path);
|
|
414
|
+
if (canonical.path !== file.path
|
|
415
|
+
|| canonical.path !== oldItems[i].path
|
|
416
|
+
|| canonical.path !== newItems[i].path) {
|
|
417
|
+
failSchema(`${label} path does not match manifests`);
|
|
418
|
+
}
|
|
419
|
+
if (oldItems[i].kind !== 'regular') {
|
|
420
|
+
failSchema(`${label} old manifest entry is not regular`);
|
|
421
|
+
}
|
|
422
|
+
if (normaliseDigest(file.oldDigest, `${label}.oldDigest`) !== oldItems[i].digest) {
|
|
423
|
+
failSchema(`${label} oldDigest does not match manifest identity`);
|
|
424
|
+
}
|
|
425
|
+
if (normaliseDigest(file.newDigest, `${label}.newDigest`) !== newItems[i].digest) {
|
|
426
|
+
failSchema(`${label} newDigest does not match manifest identity`);
|
|
427
|
+
}
|
|
428
|
+
if (file.kind !== 'changelog' && file.kind !== 'readme') {
|
|
429
|
+
failSchema(`${label} has invalid kind`);
|
|
430
|
+
}
|
|
431
|
+
if (typeof file.locale !== 'string' || file.locale.length === 0) {
|
|
432
|
+
failSchema(`${label} has invalid locale`);
|
|
433
|
+
}
|
|
434
|
+
if (file.change !== 'insert' && file.change !== 'update') {
|
|
435
|
+
failSchema(`${label} has invalid change`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Dispatch canonicalPlan authority validation by apiVersion. The
|
|
442
|
+
* artifact-plan v1 branch preserves the pre-refactor behaviour exactly; the
|
|
443
|
+
* docs-refresh v1 branch validates the closed docs-refresh authority.
|
|
444
|
+
*/
|
|
445
|
+
function validateCanonicalPlanAuthority(journal, oldItems, newItems) {
|
|
446
|
+
const plan = journal.canonicalPlan;
|
|
447
|
+
if (plan.apiVersion === 'release-skill.dev/artifact-plan/v1') {
|
|
448
|
+
validateArtifactPlanAuthority(journal, oldItems, newItems);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (plan.apiVersion === 'release-skill.dev/docs-refresh/v1') {
|
|
452
|
+
validateDocsRefreshAuthority(journal, oldItems, newItems);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
failSchema('journal canonicalPlan apiVersion is unsupported');
|
|
456
|
+
}
|
|
457
|
+
|
|
233
458
|
// ---------------------------------------------------------------------------
|
|
234
459
|
// Handle helpers
|
|
235
460
|
// ---------------------------------------------------------------------------
|
|
@@ -361,81 +586,20 @@ async function abortTempAfterFailure(handle, token, primaryError) {
|
|
|
361
586
|
}
|
|
362
587
|
|
|
363
588
|
/**
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
* state transitions. Rejects from:null pseudo-transitions and metadata
|
|
369
|
-
* overwriting reserved fields.
|
|
589
|
+
* Replay the journal transition chain and validate its structure, legality,
|
|
590
|
+
* and continuity. Shared verbatim by the full-journal and terminal-receipt
|
|
591
|
+
* validation branches: both persist the complete transition chain, so both
|
|
592
|
+
* replay it the same way.
|
|
370
593
|
*
|
|
371
|
-
* @param {object} journal — journal object.
|
|
372
|
-
* @
|
|
373
|
-
*
|
|
594
|
+
* @param {object} journal — journal or receipt object.
|
|
595
|
+
* @param {number} manifestLength — newManifest length for entryIndex bounds.
|
|
596
|
+
* @throws {ReleaseError} TRANSACTION_INCOMPLETE / INVALID_STATE_TRANSITION.
|
|
374
597
|
*/
|
|
375
|
-
function
|
|
376
|
-
if (!journal || typeof journal !== 'object' || Array.isArray(journal)) {
|
|
377
|
-
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal is not an object');
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// P0-7: Validate closed schema
|
|
381
|
-
for (const key of Object.keys(journal)) {
|
|
382
|
-
if (!JOURNAL_SCHEMA_FIELDS.has(key)) {
|
|
383
|
-
throw new ReleaseError(
|
|
384
|
-
TRANSACTION_INCOMPLETE,
|
|
385
|
-
`journal has unknown field: ${key}`,
|
|
386
|
-
);
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
for (const field of JOURNAL_SCHEMA_FIELDS) {
|
|
390
|
-
if (!Object.hasOwn(journal, field)) {
|
|
391
|
-
throw new ReleaseError(TRANSACTION_INCOMPLETE, `journal missing required field: ${field}`);
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// P0-7: Validate required fields
|
|
396
|
-
if (typeof journal.transactionId !== 'string' || journal.transactionId.length === 0) {
|
|
397
|
-
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal missing transactionId');
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
// P0-7: Validate transactionId safety (no path separators, NUL, etc.)
|
|
401
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(journal.transactionId)) {
|
|
402
|
-
throw new ReleaseError(
|
|
403
|
-
TRANSACTION_INCOMPLETE,
|
|
404
|
-
'journal transactionId contains unsafe characters',
|
|
405
|
-
{ transactionId: journal.transactionId },
|
|
406
|
-
);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
if (typeof journal.planDigest !== 'string' || !DIGEST_RE.test(journal.planDigest)) {
|
|
410
|
-
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal missing planDigest');
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
if (!journal.canonicalPlan || typeof journal.canonicalPlan !== 'object'
|
|
414
|
-
|| Array.isArray(journal.canonicalPlan)) {
|
|
415
|
-
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal canonicalPlan must be an object');
|
|
416
|
-
}
|
|
417
|
-
if (!Array.isArray(journal.oldManifest) || !Array.isArray(journal.newManifest)) {
|
|
418
|
-
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal manifests must be arrays');
|
|
419
|
-
}
|
|
420
|
-
if (journal.oldManifest.length !== journal.newManifest.length) {
|
|
421
|
-
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal manifest lengths differ');
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
if (!VALID_STATES.has(journal.state)) {
|
|
425
|
-
throw new ReleaseError(
|
|
426
|
-
TRANSACTION_INCOMPLETE,
|
|
427
|
-
`journal has invalid state: ${journal.state}`,
|
|
428
|
-
);
|
|
429
|
-
}
|
|
430
|
-
|
|
598
|
+
function validateTransitionChain(journal, manifestLength) {
|
|
431
599
|
if (!Array.isArray(journal.transitions)) {
|
|
432
600
|
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal transitions is not an array');
|
|
433
601
|
}
|
|
434
602
|
|
|
435
|
-
if (!Array.isArray(journal.entries)) {
|
|
436
|
-
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal entries is not an array');
|
|
437
|
-
}
|
|
438
|
-
|
|
439
603
|
let replayState = 'PREPARED';
|
|
440
604
|
// P0-7: Validate transitions structure
|
|
441
605
|
for (let i = 0; i < journal.transitions.length; i++) {
|
|
@@ -481,7 +645,7 @@ function validateJournalSchema(journal) {
|
|
|
481
645
|
if (t.entryIndex !== undefined && t.entryIndex !== null) {
|
|
482
646
|
if (typeof t.entryIndex !== 'number' || t.entryIndex < 0
|
|
483
647
|
|| !Number.isInteger(t.entryIndex)
|
|
484
|
-
|| t.entryIndex >=
|
|
648
|
+
|| t.entryIndex >= manifestLength) {
|
|
485
649
|
throw new ReleaseError(
|
|
486
650
|
TRANSACTION_INCOMPLETE,
|
|
487
651
|
`journal transition[${i}] has invalid entryIndex: ${t.entryIndex}`,
|
|
@@ -509,6 +673,98 @@ function validateJournalSchema(journal) {
|
|
|
509
673
|
`journal state ${journal.state} does not match transition replay ${replayState}`,
|
|
510
674
|
);
|
|
511
675
|
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Validate a journal object against the closed schema.
|
|
680
|
+
*
|
|
681
|
+
* P0-7: Validates all required fields, transitions/entries structure,
|
|
682
|
+
* index ranges, path uniqueness, transactionId/path safety, and legal
|
|
683
|
+
* state transitions. Rejects from:null pseudo-transitions and metadata
|
|
684
|
+
* overwriting reserved fields.
|
|
685
|
+
*
|
|
686
|
+
* Two closed shapes are accepted:
|
|
687
|
+
* - the FULL journal (the recovery authority for non-terminal states, and
|
|
688
|
+
* the pre-convergence terminal record left behind by a crash/IO failure
|
|
689
|
+
* at 'before-terminal-receipt-write'); and
|
|
690
|
+
* - the explicitly versioned TERMINAL RECEIPT (carries
|
|
691
|
+
* `terminalReceiptVersion`), which is legal ONLY for
|
|
692
|
+
* PRUNABLE_TERMINAL_STATES — a receipt-shaped record in any non-terminal
|
|
693
|
+
* state is invalid, and a receipt never carries file bodies, serialized
|
|
694
|
+
* Buffers, or canonicalPlan payload bytes.
|
|
695
|
+
*
|
|
696
|
+
* @param {object} journal — journal object.
|
|
697
|
+
* @throws {ReleaseError} TRANSACTION_INCOMPLETE on unknown fields or
|
|
698
|
+
* invalid structure.
|
|
699
|
+
*/
|
|
700
|
+
function validateJournalSchema(journal) {
|
|
701
|
+
if (!journal || typeof journal !== 'object' || Array.isArray(journal)) {
|
|
702
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal is not an object');
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Terminal records converge to an explicitly versioned receipt. The
|
|
706
|
+
// presence of the version marker routes to the closed receipt schema.
|
|
707
|
+
if (Object.hasOwn(journal, 'terminalReceiptVersion')) {
|
|
708
|
+
validateTerminalReceiptSchema(journal);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// P0-7: Validate closed schema
|
|
713
|
+
for (const key of Object.keys(journal)) {
|
|
714
|
+
if (!JOURNAL_SCHEMA_FIELDS.has(key)) {
|
|
715
|
+
throw new ReleaseError(
|
|
716
|
+
TRANSACTION_INCOMPLETE,
|
|
717
|
+
`journal has unknown field: ${key}`,
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
for (const field of JOURNAL_SCHEMA_FIELDS) {
|
|
722
|
+
if (!Object.hasOwn(journal, field)) {
|
|
723
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, `journal missing required field: ${field}`);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// P0-7: Validate required fields
|
|
728
|
+
if (typeof journal.transactionId !== 'string' || journal.transactionId.length === 0) {
|
|
729
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal missing transactionId');
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// P0-7: Validate transactionId safety (no path separators, NUL, etc.)
|
|
733
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(journal.transactionId)) {
|
|
734
|
+
throw new ReleaseError(
|
|
735
|
+
TRANSACTION_INCOMPLETE,
|
|
736
|
+
'journal transactionId contains unsafe characters',
|
|
737
|
+
{ transactionId: journal.transactionId },
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (typeof journal.planDigest !== 'string' || !DIGEST_RE.test(journal.planDigest)) {
|
|
742
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal missing planDigest');
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (!journal.canonicalPlan || typeof journal.canonicalPlan !== 'object'
|
|
746
|
+
|| Array.isArray(journal.canonicalPlan)) {
|
|
747
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal canonicalPlan must be an object');
|
|
748
|
+
}
|
|
749
|
+
if (!Array.isArray(journal.oldManifest) || !Array.isArray(journal.newManifest)) {
|
|
750
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal manifests must be arrays');
|
|
751
|
+
}
|
|
752
|
+
if (journal.oldManifest.length !== journal.newManifest.length) {
|
|
753
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal manifest lengths differ');
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (!VALID_STATES.has(journal.state)) {
|
|
757
|
+
throw new ReleaseError(
|
|
758
|
+
TRANSACTION_INCOMPLETE,
|
|
759
|
+
`journal has invalid state: ${journal.state}`,
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
if (!Array.isArray(journal.entries)) {
|
|
764
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'journal entries is not an array');
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
validateTransitionChain(journal, journal.newManifest.length);
|
|
512
768
|
|
|
513
769
|
const manifestPaths = new Set();
|
|
514
770
|
const oldItems = [];
|
|
@@ -529,7 +785,8 @@ function validateJournalSchema(journal) {
|
|
|
529
785
|
}
|
|
530
786
|
manifestPaths.add(collisionKey);
|
|
531
787
|
}
|
|
532
|
-
|
|
788
|
+
validateCanonicalPlanAuthority(journal, oldItems, newItems);
|
|
789
|
+
const planItems = canonicalPlanItems(journal.canonicalPlan);
|
|
533
790
|
|
|
534
791
|
// P0-7: Validate entries structure
|
|
535
792
|
const seenIndices = new Set();
|
|
@@ -565,7 +822,7 @@ function validateJournalSchema(journal) {
|
|
|
565
822
|
throw new ReleaseError(TRANSACTION_INCOMPLETE, `journal entries[${i}] missing id/path`);
|
|
566
823
|
}
|
|
567
824
|
const entryPath = canonicalArtifactPath(e.path);
|
|
568
|
-
if (e.id !==
|
|
825
|
+
if (e.id !== planItems[i].id
|
|
569
826
|
|| entryPath.path !== newItems[i].path) {
|
|
570
827
|
throw new ReleaseError(
|
|
571
828
|
TRANSACTION_INCOMPLETE,
|
|
@@ -618,39 +875,766 @@ function validateJournalSchema(journal) {
|
|
|
618
875
|
}
|
|
619
876
|
|
|
620
877
|
// ---------------------------------------------------------------------------
|
|
621
|
-
//
|
|
878
|
+
// Terminal receipt (explicitly versioned convergence of terminal records)
|
|
622
879
|
// ---------------------------------------------------------------------------
|
|
623
880
|
|
|
624
881
|
/**
|
|
625
|
-
*
|
|
626
|
-
*
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
*
|
|
630
|
-
* @param {object} options
|
|
631
|
-
* @param {object} options.backend — safe-fs backend.
|
|
632
|
-
* @param {string} options.root — repository root.
|
|
633
|
-
* @param {string} options.transactionId — unique transaction ID.
|
|
634
|
-
* @param {string} options.planDigest — canonical plan digest.
|
|
635
|
-
* @param {object} options.canonicalPlan — decoded plan (with Buffer bytes).
|
|
636
|
-
* @param {object[]} options.oldManifest — snapshot of old entries with
|
|
637
|
-
* backup bytes (for absent entries, `absent: true`).
|
|
638
|
-
* @param {object[]} options.newManifest — snapshot of new entries.
|
|
639
|
-
* @returns {Promise<{ journal: object, txnHandle: object }>}
|
|
882
|
+
* Validate a manifest item of a terminal receipt: identical identity fields
|
|
883
|
+
* to the full journal, but NEVER bytes. The closed field set rejects any
|
|
884
|
+
* `bytes` key, which is the AC-1 payload-independence guarantee — a receipt
|
|
885
|
+
* keeps path/kind/sha256/size/mode digests only.
|
|
640
886
|
*/
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
canonicalPlan,
|
|
646
|
-
oldManifest,
|
|
647
|
-
newManifest,
|
|
648
|
-
} = {}) {
|
|
649
|
-
if (typeof transactionId !== 'string'
|
|
650
|
-
|| !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(transactionId)) {
|
|
651
|
-
throw new ReleaseError(PATH_UNSAFE, 'transactionId is not a safe path segment');
|
|
887
|
+
function validateReceiptManifestItem(item, role, index) {
|
|
888
|
+
const label = `receipt ${role}Manifest[${index}]`;
|
|
889
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
890
|
+
failSchema(`${label} must be an object`);
|
|
652
891
|
}
|
|
653
|
-
|
|
892
|
+
const canonical = canonicalArtifactPath(item.path);
|
|
893
|
+
if (canonical.path !== item.path) failSchema(`${label} path is not canonical`);
|
|
894
|
+
|
|
895
|
+
if (item.kind === 'absent') {
|
|
896
|
+
const allowed = role === 'old'
|
|
897
|
+
? new Set(['path', 'kind', 'absent'])
|
|
898
|
+
: new Set(['path', 'kind']);
|
|
899
|
+
assertClosedObject(item, allowed, label);
|
|
900
|
+
if (role === 'old' && item.absent !== true) {
|
|
901
|
+
failSchema(`${label} must carry an absence tombstone`);
|
|
902
|
+
}
|
|
903
|
+
return Object.freeze({ path: canonical.path, kind: 'absent' });
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
if (item.kind !== 'regular') failSchema(`${label} has invalid kind`);
|
|
907
|
+
const allowed = new Set(['path', 'kind', 'sha256', 'size', 'mode']);
|
|
908
|
+
assertClosedObject(item, allowed, label); // closed: `bytes` is rejected
|
|
909
|
+
for (const required of allowed) {
|
|
910
|
+
if (!Object.hasOwn(item, required)) failSchema(`${label} missing required field: ${required}`);
|
|
911
|
+
}
|
|
912
|
+
const digest = normaliseDigest(item.sha256, label);
|
|
913
|
+
if (!Number.isSafeInteger(item.size) || item.size < 0) failSchema(`${label} has invalid size`);
|
|
914
|
+
const mode = normaliseMode(item.mode, label);
|
|
915
|
+
return Object.freeze({ path: canonical.path, kind: 'regular', digest, size: item.size, mode });
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Validate the artifact-plan v1 canonicalPlan authority of a terminal
|
|
920
|
+
* receipt: the closed plan schema with `newEntry.bytes` stripped. Identity
|
|
921
|
+
* (digest/size/mode) must still agree with both manifests; the plan digest
|
|
922
|
+
* binding field must equal the journal planDigest. The digest is NOT
|
|
923
|
+
* recomputed: receipt canonicalPlans deliberately omit newEntry bytes, so the
|
|
924
|
+
* full-journal digest recomputation does not apply — planDigest remains the
|
|
925
|
+
* binding authority.
|
|
926
|
+
*/
|
|
927
|
+
function validateArtifactPlanReceiptAuthority(journal, oldItems, newItems) {
|
|
928
|
+
const plan = journal.canonicalPlan;
|
|
929
|
+
assertClosedObject(plan, PLAN_SCHEMA_FIELDS, 'receipt canonicalPlan');
|
|
930
|
+
for (const field of PLAN_SCHEMA_FIELDS) {
|
|
931
|
+
if (!Object.hasOwn(plan, field)) failSchema(`receipt canonicalPlan missing required field: ${field}`);
|
|
932
|
+
}
|
|
933
|
+
if (typeof plan.planDigest !== 'string' || plan.planDigest !== journal.planDigest) {
|
|
934
|
+
failSchema('receipt canonicalPlan planDigest does not bind the journal planDigest');
|
|
935
|
+
}
|
|
936
|
+
if (plan.apiVersion !== 'release-skill.dev/artifact-plan/v1'
|
|
937
|
+
|| !['inspect', 'status', 'apply'].includes(plan.operation)
|
|
938
|
+
|| plan.safeToWrite !== true || plan.targetUnchanged !== true) {
|
|
939
|
+
failSchema('receipt canonicalPlan is not an applyable v1 plan');
|
|
940
|
+
}
|
|
941
|
+
const bindingFields = new Set([
|
|
942
|
+
'repositoryIdentity', 'policyDigest', 'baseManifestDigest',
|
|
943
|
+
'currentManifestDigest', 'producerClosureDigest',
|
|
944
|
+
]);
|
|
945
|
+
assertClosedObject(plan.bindings, bindingFields, 'receipt canonicalPlan.bindings');
|
|
946
|
+
for (const field of bindingFields) {
|
|
947
|
+
if (!DIGEST_RE.test(plan.bindings[field])) {
|
|
948
|
+
failSchema(`receipt canonicalPlan binding ${field} is invalid`);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
assertClosedObject(plan.nextAction, new Set(['command']), 'receipt canonicalPlan.nextAction');
|
|
952
|
+
if (typeof plan.nextAction.command !== 'string'
|
|
953
|
+
|| !/\bartifacts apply\b/.test(plan.nextAction.command)) {
|
|
954
|
+
failSchema('receipt canonicalPlan nextAction is not apply');
|
|
955
|
+
}
|
|
956
|
+
if (!Array.isArray(plan.artifacts) || plan.artifacts.length !== newItems.length) {
|
|
957
|
+
failSchema('receipt canonicalPlan artifacts do not match manifest length');
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
for (let i = 0; i < plan.artifacts.length; i++) {
|
|
961
|
+
const artifact = plan.artifacts[i];
|
|
962
|
+
const label = `receipt canonicalPlan.artifacts[${i}]`;
|
|
963
|
+
assertClosedObject(artifact, ARTIFACT_SCHEMA_FIELDS, label);
|
|
964
|
+
for (const field of ARTIFACT_SCHEMA_FIELDS) {
|
|
965
|
+
if (!Object.hasOwn(artifact, field)) failSchema(`${label} missing required field: ${field}`);
|
|
966
|
+
}
|
|
967
|
+
if (typeof artifact.id !== 'string' || artifact.id.length === 0) failSchema(`${label} has invalid id`);
|
|
968
|
+
if (artifact.safeToWrite !== true
|
|
969
|
+
|| !new Set([
|
|
970
|
+
'READY', 'CLEAN', 'NEW', 'HUMAN_CHANGED',
|
|
971
|
+
'GENERATOR_CHANGED', 'MERGEABLE', 'RESOLVED',
|
|
972
|
+
]).has(artifact.status)) {
|
|
973
|
+
failSchema(`${label} is not safe to write`);
|
|
974
|
+
}
|
|
975
|
+
const canonical = canonicalArtifactPath(artifact.path);
|
|
976
|
+
if (canonical.path !== artifact.path || canonical.path !== oldItems[i].path) {
|
|
977
|
+
failSchema(`${label} path does not match manifests`);
|
|
978
|
+
}
|
|
979
|
+
for (const [role, entry, manifest] of [
|
|
980
|
+
['oldEntry', artifact.oldEntry, oldItems[i]],
|
|
981
|
+
['newEntry', artifact.newEntry, newItems[i]],
|
|
982
|
+
]) {
|
|
983
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry) || entry.kind !== manifest.kind) {
|
|
984
|
+
failSchema(`${label}.${role} does not match manifest kind`);
|
|
985
|
+
}
|
|
986
|
+
if (entry.kind === 'absent') {
|
|
987
|
+
if (Object.keys(entry).length !== 1) failSchema(`${label}.${role} absent entry is not closed`);
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
// Receipt form: digests only — NEVER bytes (closed field set).
|
|
991
|
+
const entryFields = new Set(['kind', 'sha256', 'size', 'mode']);
|
|
992
|
+
assertClosedObject(entry, entryFields, `${label}.${role}`);
|
|
993
|
+
for (const field of entryFields) {
|
|
994
|
+
if (!Object.hasOwn(entry, field)) failSchema(`${label}.${role} missing required field: ${field}`);
|
|
995
|
+
}
|
|
996
|
+
if (normaliseDigest(entry.sha256, `${label}.${role}`) !== manifest.digest
|
|
997
|
+
|| entry.size !== manifest.size
|
|
998
|
+
|| normaliseMode(entry.mode, `${label}.${role}`) !== manifest.mode) {
|
|
999
|
+
failSchema(`${label}.${role} does not match manifest identity`);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Dispatch receipt canonicalPlan authority validation by apiVersion. The
|
|
1007
|
+
* docs-refresh v1 plans never carry bytes, so the full authority validator
|
|
1008
|
+
* applies verbatim (refreshDigest must bind the journal planDigest); the
|
|
1009
|
+
* artifact-plan v1 branch validates the bytes-stripped receipt form.
|
|
1010
|
+
*/
|
|
1011
|
+
function validateReceiptCanonicalPlanAuthority(journal, oldItems, newItems) {
|
|
1012
|
+
const plan = journal.canonicalPlan;
|
|
1013
|
+
if (plan.apiVersion === 'release-skill.dev/artifact-plan/v1') {
|
|
1014
|
+
validateArtifactPlanReceiptAuthority(journal, oldItems, newItems);
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
if (plan.apiVersion === 'release-skill.dev/docs-refresh/v1') {
|
|
1018
|
+
validateDocsRefreshAuthority(journal, oldItems, newItems);
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
failSchema('receipt canonicalPlan apiVersion is unsupported');
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* Validate the closed terminal receipt schema (version 1).
|
|
1026
|
+
*
|
|
1027
|
+
* A receipt is legal ONLY for PRUNABLE_TERMINAL_STATES (COMMITTED /
|
|
1028
|
+
* ROLLED_BACK): a receipt-shaped record in any non-terminal state fails
|
|
1029
|
+
* closed, because non-terminal records must keep the full recovery authority
|
|
1030
|
+
* (oldManifest bytes + backups). The transition chain replays to `state`,
|
|
1031
|
+
* every field set is closed, manifests carry digests only (never bytes), and
|
|
1032
|
+
* per-entry audit metadata carries id/path/status/appliedAt plus a
|
|
1033
|
+
* path/kind/sha256/size/mode digest summary bound to the new manifest.
|
|
1034
|
+
*
|
|
1035
|
+
* @param {object} journal — receipt-shaped journal object.
|
|
1036
|
+
* @throws {ReleaseError} TRANSACTION_INCOMPLETE / INVALID_STATE_TRANSITION.
|
|
1037
|
+
*/
|
|
1038
|
+
function validateTerminalReceiptSchema(journal) {
|
|
1039
|
+
for (const key of Object.keys(journal)) {
|
|
1040
|
+
if (!RECEIPT_SCHEMA_FIELDS.has(key)) {
|
|
1041
|
+
throw new ReleaseError(
|
|
1042
|
+
TRANSACTION_INCOMPLETE,
|
|
1043
|
+
`terminal receipt has unknown field: ${key}`,
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
for (const field of RECEIPT_SCHEMA_FIELDS) {
|
|
1048
|
+
if (!Object.hasOwn(journal, field)) {
|
|
1049
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, `terminal receipt missing required field: ${field}`);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
if (!Number.isInteger(journal.terminalReceiptVersion) || journal.terminalReceiptVersion < 1) {
|
|
1054
|
+
throw new ReleaseError(
|
|
1055
|
+
TRANSACTION_INCOMPLETE,
|
|
1056
|
+
'terminal receipt must carry an explicit integer version >= 1',
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
if (journal.terminalReceiptVersion !== TERMINAL_RECEIPT_VERSION) {
|
|
1060
|
+
throw new ReleaseError(
|
|
1061
|
+
TRANSACTION_INCOMPLETE,
|
|
1062
|
+
`terminal receipt version ${journal.terminalReceiptVersion} is unsupported`,
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
if (typeof journal.transactionId !== 'string' || journal.transactionId.length === 0) {
|
|
1067
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'terminal receipt missing transactionId');
|
|
1068
|
+
}
|
|
1069
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(journal.transactionId)) {
|
|
1070
|
+
throw new ReleaseError(
|
|
1071
|
+
TRANSACTION_INCOMPLETE,
|
|
1072
|
+
'terminal receipt transactionId contains unsafe characters',
|
|
1073
|
+
{ transactionId: journal.transactionId },
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
if (typeof journal.planDigest !== 'string' || !DIGEST_RE.test(journal.planDigest)) {
|
|
1077
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'terminal receipt missing planDigest');
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// Receipts exist ONLY for terminal states; a receipt shape in any
|
|
1081
|
+
// non-terminal state is invalid (recovery authority must stay full).
|
|
1082
|
+
if (!PRUNABLE_TERMINAL_STATES.has(journal.state)) {
|
|
1083
|
+
throw new ReleaseError(
|
|
1084
|
+
TRANSACTION_INCOMPLETE,
|
|
1085
|
+
`terminal receipt is illegal for non-terminal state: ${journal.state}`,
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
validateTransitionChain(journal, journal.newManifest.length);
|
|
1090
|
+
|
|
1091
|
+
if (!journal.canonicalPlan || typeof journal.canonicalPlan !== 'object'
|
|
1092
|
+
|| Array.isArray(journal.canonicalPlan)) {
|
|
1093
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'terminal receipt canonicalPlan must be an object');
|
|
1094
|
+
}
|
|
1095
|
+
if (!Array.isArray(journal.oldManifest) || !Array.isArray(journal.newManifest)) {
|
|
1096
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'terminal receipt manifests must be arrays');
|
|
1097
|
+
}
|
|
1098
|
+
if (journal.oldManifest.length !== journal.newManifest.length) {
|
|
1099
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'terminal receipt manifest lengths differ');
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const manifestPaths = new Set();
|
|
1103
|
+
const oldItems = [];
|
|
1104
|
+
const newItems = [];
|
|
1105
|
+
for (let i = 0; i < journal.newManifest.length; i++) {
|
|
1106
|
+
const validatedOld = validateReceiptManifestItem(journal.oldManifest[i], 'old', i);
|
|
1107
|
+
const validatedNew = validateReceiptManifestItem(journal.newManifest[i], 'new', i);
|
|
1108
|
+
if (validatedOld.path !== validatedNew.path) {
|
|
1109
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, `terminal receipt manifest[${i}] paths differ`);
|
|
1110
|
+
}
|
|
1111
|
+
oldItems.push(validatedOld);
|
|
1112
|
+
newItems.push(validatedNew);
|
|
1113
|
+
const { collisionKey } = canonicalArtifactPath(validatedNew.path);
|
|
1114
|
+
if (manifestPaths.has(collisionKey)) {
|
|
1115
|
+
throw new ReleaseError(
|
|
1116
|
+
TRANSACTION_INCOMPLETE,
|
|
1117
|
+
`terminal receipt manifest path is duplicated: ${journal.newManifest[i].path}`,
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
1120
|
+
manifestPaths.add(collisionKey);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
validateReceiptCanonicalPlanAuthority(journal, oldItems, newItems);
|
|
1124
|
+
const planItems = canonicalPlanItems(journal.canonicalPlan);
|
|
1125
|
+
|
|
1126
|
+
assertClosedObject(journal.planSummary, RECEIPT_PLAN_SUMMARY_FIELDS, 'terminal receipt planSummary');
|
|
1127
|
+
if (typeof journal.planSummary.apiVersion !== 'string'
|
|
1128
|
+
|| typeof journal.planSummary.operation !== 'string') {
|
|
1129
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'terminal receipt planSummary fields must be strings');
|
|
1130
|
+
}
|
|
1131
|
+
if (journal.planSummary.apiVersion !== journal.canonicalPlan.apiVersion
|
|
1132
|
+
|| journal.planSummary.operation !== journal.canonicalPlan.operation) {
|
|
1133
|
+
throw new ReleaseError(
|
|
1134
|
+
TRANSACTION_INCOMPLETE,
|
|
1135
|
+
'terminal receipt planSummary does not match canonicalPlan authority',
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
if (!Array.isArray(journal.entries)) {
|
|
1140
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'terminal receipt entries is not an array');
|
|
1141
|
+
}
|
|
1142
|
+
if (journal.entries.length > journal.newManifest.length) {
|
|
1143
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'terminal receipt entries exceed manifest length');
|
|
1144
|
+
}
|
|
1145
|
+
const seenPaths = new Set();
|
|
1146
|
+
for (let i = 0; i < journal.entries.length; i++) {
|
|
1147
|
+
const e = journal.entries[i];
|
|
1148
|
+
if (e === null || e === undefined) {
|
|
1149
|
+
// Write-ahead null gaps survive convergence for partially-applied
|
|
1150
|
+
// rollback chains (ROLLED_BACK before every entry was applied).
|
|
1151
|
+
continue;
|
|
1152
|
+
}
|
|
1153
|
+
if (typeof e !== 'object' || Array.isArray(e)) {
|
|
1154
|
+
throw new ReleaseError(
|
|
1155
|
+
TRANSACTION_INCOMPLETE,
|
|
1156
|
+
`terminal receipt entries[${i}] is not an object or null`,
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
for (const key of Object.keys(e)) {
|
|
1160
|
+
if (!RECEIPT_ENTRY_FIELDS.has(key)) {
|
|
1161
|
+
throw new ReleaseError(
|
|
1162
|
+
TRANSACTION_INCOMPLETE,
|
|
1163
|
+
`terminal receipt entries[${i}] has unknown field: ${key}`,
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
if (typeof e.id !== 'string' || e.id.length === 0 || typeof e.path !== 'string') {
|
|
1168
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, `terminal receipt entries[${i}] missing id/path`);
|
|
1169
|
+
}
|
|
1170
|
+
const entryPath = canonicalArtifactPath(e.path);
|
|
1171
|
+
if (e.id !== planItems[i].id || entryPath.path !== newItems[i].path) {
|
|
1172
|
+
throw new ReleaseError(
|
|
1173
|
+
TRANSACTION_INCOMPLETE,
|
|
1174
|
+
`terminal receipt entries[${i}] does not match canonical plan authority`,
|
|
1175
|
+
);
|
|
1176
|
+
}
|
|
1177
|
+
if (seenPaths.has(entryPath.collisionKey)) {
|
|
1178
|
+
throw new ReleaseError(
|
|
1179
|
+
TRANSACTION_INCOMPLETE,
|
|
1180
|
+
`terminal receipt entries has duplicate path: ${e.path}`,
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1183
|
+
seenPaths.add(entryPath.collisionKey);
|
|
1184
|
+
if (!['pending', 'applied'].includes(e.status)) {
|
|
1185
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, `terminal receipt entries[${i}] has invalid status`);
|
|
1186
|
+
}
|
|
1187
|
+
if (typeof e.appliedAt !== 'string' || !Number.isFinite(Date.parse(e.appliedAt))) {
|
|
1188
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, `terminal receipt entries[${i}] has invalid appliedAt`);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// Per-entry digest summary bound to the new manifest — never bytes.
|
|
1192
|
+
const digest = e.digest;
|
|
1193
|
+
const digestLabel = `terminal receipt entries[${i}].digest`;
|
|
1194
|
+
assertClosedObject(digest, RECEIPT_DIGEST_FIELDS, digestLabel);
|
|
1195
|
+
const digestPath = canonicalArtifactPath(digest.path);
|
|
1196
|
+
if (digestPath.path !== newItems[i].path) {
|
|
1197
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, `${digestLabel} path does not match manifest`);
|
|
1198
|
+
}
|
|
1199
|
+
if (newItems[i].kind === 'absent') {
|
|
1200
|
+
if (digest.kind !== 'absent') {
|
|
1201
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, `${digestLabel} kind does not match manifest`);
|
|
1202
|
+
}
|
|
1203
|
+
} else {
|
|
1204
|
+
if (digest.kind !== 'regular') {
|
|
1205
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, `${digestLabel} kind does not match manifest`);
|
|
1206
|
+
}
|
|
1207
|
+
for (const required of ['sha256', 'size', 'mode']) {
|
|
1208
|
+
if (!Object.hasOwn(digest, required)) {
|
|
1209
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, `${digestLabel} missing required field: ${required}`);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
if (normaliseDigest(digest.sha256, digestLabel) !== newItems[i].digest
|
|
1213
|
+
|| digest.size !== newItems[i].size
|
|
1214
|
+
|| normaliseMode(digest.mode, digestLabel) !== newItems[i].mode) {
|
|
1215
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, `${digestLabel} does not match manifest identity`);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
if (typeof journal.createdAt !== 'string' || !Number.isFinite(Date.parse(journal.createdAt))) {
|
|
1221
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'terminal receipt createdAt must be a timestamp');
|
|
1222
|
+
}
|
|
1223
|
+
if (typeof journal.updatedAt !== 'string' || !Number.isFinite(Date.parse(journal.updatedAt))) {
|
|
1224
|
+
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'terminal receipt updatedAt must be a timestamp');
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
if (journal.state === 'COMMITTED') {
|
|
1228
|
+
if (journal.entries.length !== journal.newManifest.length
|
|
1229
|
+
|| journal.entries.some((entry) => !entry || entry.status !== 'applied')) {
|
|
1230
|
+
throw new ReleaseError(
|
|
1231
|
+
TRANSACTION_INCOMPLETE,
|
|
1232
|
+
'terminal receipt state COMMITTED requires every manifest entry to be applied',
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* Strip payload bytes from a validated canonicalPlan for receipt persistence.
|
|
1240
|
+
*
|
|
1241
|
+
* artifact-plan v1 plans embed `newEntry.bytes` (the full new file body) —
|
|
1242
|
+
* the receipt keeps the digest identity ({kind, sha256, size, mode}) and
|
|
1243
|
+
* drops the bytes. docs-refresh v1 plans never carry bytes and pass through
|
|
1244
|
+
* unchanged.
|
|
1245
|
+
*/
|
|
1246
|
+
function stripCanonicalPlanBytes(canonicalPlan) {
|
|
1247
|
+
if (canonicalPlan.apiVersion === 'release-skill.dev/artifact-plan/v1') {
|
|
1248
|
+
return {
|
|
1249
|
+
...canonicalPlan,
|
|
1250
|
+
artifacts: canonicalPlan.artifacts.map((artifact) => {
|
|
1251
|
+
const stripped = { ...artifact };
|
|
1252
|
+
if (stripped.newEntry && stripped.newEntry.kind === 'regular') {
|
|
1253
|
+
const { bytes: _dropped, ...digestIdentity } = stripped.newEntry;
|
|
1254
|
+
stripped.newEntry = digestIdentity;
|
|
1255
|
+
}
|
|
1256
|
+
return stripped;
|
|
1257
|
+
}),
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
return canonicalPlan;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* Build the explicitly versioned terminal receipt from a validated FULL
|
|
1265
|
+
* terminal journal. The receipt keeps: transactionId, planDigest, state, the
|
|
1266
|
+
* complete transition chain, createdAt/updatedAt, digest-only manifests, the
|
|
1267
|
+
* bytes-stripped canonicalPlan, a planSummary{apiVersion, operation}, and
|
|
1268
|
+
* per-entry audit metadata (id/path/status/appliedAt + a
|
|
1269
|
+
* path/kind/sha256/size/mode digest summary). It never keeps old/new file
|
|
1270
|
+
* bodies, serialized Buffers, or canonicalPlan payload bytes.
|
|
1271
|
+
*
|
|
1272
|
+
* @param {object} journal — validated full terminal journal.
|
|
1273
|
+
* @returns {object} The receipt (plain JSON-serialisable object).
|
|
1274
|
+
*/
|
|
1275
|
+
function buildTerminalReceipt(journal) {
|
|
1276
|
+
const oldManifest = journal.oldManifest.map((item) => {
|
|
1277
|
+
if (item.kind === 'absent') {
|
|
1278
|
+
return { path: item.path, kind: 'absent', absent: true };
|
|
1279
|
+
}
|
|
1280
|
+
return {
|
|
1281
|
+
path: item.path, kind: 'regular',
|
|
1282
|
+
sha256: item.sha256, size: item.size, mode: item.mode,
|
|
1283
|
+
};
|
|
1284
|
+
});
|
|
1285
|
+
const newManifest = journal.newManifest.map((item) => {
|
|
1286
|
+
if (item.kind === 'absent') return { path: item.path, kind: 'absent' };
|
|
1287
|
+
return {
|
|
1288
|
+
path: item.path, kind: 'regular',
|
|
1289
|
+
sha256: item.sha256, size: item.size, mode: item.mode,
|
|
1290
|
+
};
|
|
1291
|
+
});
|
|
1292
|
+
const entries = journal.entries.map((entry, index) => {
|
|
1293
|
+
if (entry === null || entry === undefined) return null;
|
|
1294
|
+
const manifestItem = journal.newManifest[index];
|
|
1295
|
+
const digest = manifestItem.kind === 'absent'
|
|
1296
|
+
? { path: manifestItem.path, kind: 'absent' }
|
|
1297
|
+
: {
|
|
1298
|
+
path: manifestItem.path, kind: 'regular',
|
|
1299
|
+
sha256: manifestItem.sha256, size: manifestItem.size, mode: manifestItem.mode,
|
|
1300
|
+
};
|
|
1301
|
+
return {
|
|
1302
|
+
id: entry.id, path: entry.path, status: entry.status,
|
|
1303
|
+
appliedAt: entry.appliedAt, digest,
|
|
1304
|
+
};
|
|
1305
|
+
});
|
|
1306
|
+
const canonicalPlan = stripCanonicalPlanBytes(journal.canonicalPlan);
|
|
1307
|
+
return {
|
|
1308
|
+
terminalReceiptVersion: TERMINAL_RECEIPT_VERSION,
|
|
1309
|
+
transactionId: journal.transactionId,
|
|
1310
|
+
planDigest: journal.planDigest,
|
|
1311
|
+
canonicalPlan,
|
|
1312
|
+
planSummary: {
|
|
1313
|
+
apiVersion: canonicalPlan.apiVersion,
|
|
1314
|
+
operation: canonicalPlan.operation,
|
|
1315
|
+
},
|
|
1316
|
+
oldManifest,
|
|
1317
|
+
newManifest,
|
|
1318
|
+
state: journal.state,
|
|
1319
|
+
transitions: journal.transitions.map((transition) => ({ ...transition })),
|
|
1320
|
+
entries,
|
|
1321
|
+
createdAt: journal.createdAt,
|
|
1322
|
+
// The receipt replaces the full journal atomically; updatedAt records
|
|
1323
|
+
// the convergence instant (the full chain's own timestamps are preserved
|
|
1324
|
+
// verbatim in `transitions`).
|
|
1325
|
+
updatedAt: new Date().toISOString(),
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* Remove recovery residue a terminal record no longer needs: the
|
|
1331
|
+
* `RECOVERY_REQUIRED` marker and the `backups/` directory (backups are named
|
|
1332
|
+
* `<entryIndex>.bak`). Called ONLY after the receipt has atomically and
|
|
1333
|
+
* durably replaced `journal.json` — the receipt is the authority first;
|
|
1334
|
+
* cleanup second.
|
|
1335
|
+
*/
|
|
1336
|
+
async function removeTerminalRecoveryResidue(txnHandle, journal) {
|
|
1337
|
+
const marker = await txnHandle.readEntry('RECOVERY_REQUIRED');
|
|
1338
|
+
if (!(marker === null || marker?.kind === 'absent')) {
|
|
1339
|
+
await txnHandle.unlink('RECOVERY_REQUIRED');
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
const backupsEntry = await txnHandle.readEntry('backups');
|
|
1343
|
+
if (backupsEntry === null || backupsEntry?.kind === 'absent') {
|
|
1344
|
+
await txnHandle.fsync();
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
const backupCount = Math.max(journal.newManifest.length, journal.entries.length);
|
|
1349
|
+
const backupsHandle = await txnHandle.openDir('backups');
|
|
1350
|
+
try {
|
|
1351
|
+
for (let i = 0; i < backupCount; i += 1) {
|
|
1352
|
+
const name = `${i}.bak`;
|
|
1353
|
+
const bak = await backupsHandle.readEntry(name);
|
|
1354
|
+
if (!(bak === null || bak?.kind === 'absent')) {
|
|
1355
|
+
await backupsHandle.unlink(name);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
} finally {
|
|
1359
|
+
await backupsHandle.close();
|
|
1360
|
+
}
|
|
1361
|
+
await txnHandle.rmdir('backups');
|
|
1362
|
+
await txnHandle.fsync();
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/**
|
|
1366
|
+
* Converge a terminal (COMMITTED / ROLLED_BACK) transaction record to the
|
|
1367
|
+
* explicitly versioned small receipt (AC-1 terminal bounding) — a
|
|
1368
|
+
* POST-TERMINAL phase that preserves the durable ordering:
|
|
1369
|
+
*
|
|
1370
|
+
* full terminal journal durable
|
|
1371
|
+
* -> 'before-terminal-receipt-write' (fault point)
|
|
1372
|
+
* -> receipt atomically replaces journal.json (writeJsonViaHandle:
|
|
1373
|
+
* createTemp + fsync + rename + fsync)
|
|
1374
|
+
* -> 'after-terminal-receipt-write' (fault point)
|
|
1375
|
+
* -> backups/*.bak and RECOVERY_REQUIRED marker removed.
|
|
1376
|
+
*
|
|
1377
|
+
* Crash consistency (AC-2): a hard crash (INJECTED_CRASH) at either fault
|
|
1378
|
+
* point propagates verbatim and leaves the latest durable state untouched —
|
|
1379
|
+
* either the complete verifiable full journal (pre-receipt) or the complete
|
|
1380
|
+
* verifiable small receipt (post-receipt); both re-read COMMITTED /
|
|
1381
|
+
* ROLLED_BACK, never RECOVERY_REQUIRED / ROLLING_BACK.
|
|
1382
|
+
*
|
|
1383
|
+
* Honesty (AC-2): an ordinary failure (e.g. EIO) is wrapped as
|
|
1384
|
+
* TRANSACTION_INCOMPLETE with `terminalReceiptPersisted`,
|
|
1385
|
+
* `targetApplied` (true for COMMITTED — the target WAS applied+verified and
|
|
1386
|
+
* the complete verifiable full journal is retained at its latest durable
|
|
1387
|
+
* state; convergence never rewrites COMMITTED back), `transactionId`, and
|
|
1388
|
+
* `recover` guidance. Re-running convergence completes the record.
|
|
1389
|
+
*
|
|
1390
|
+
* @param {object} options
|
|
1391
|
+
* @param {object} options.txnHandle — DirectoryHandle for the transaction dir.
|
|
1392
|
+
* @param {string} options.transactionId — transaction ID.
|
|
1393
|
+
* @param {Function} [options.faultInjector] — fault injection for testing.
|
|
1394
|
+
* @param {string} [options.recoverCommand] — caller-supplied recover command.
|
|
1395
|
+
* @returns {Promise<object>} The validated receipt (or the journal unchanged
|
|
1396
|
+
* if the record is not terminal).
|
|
1397
|
+
* @throws {ReleaseError} TRANSACTION_INCOMPLETE on convergence failure.
|
|
1398
|
+
*/
|
|
1399
|
+
export async function convergeTerminalRecord({
|
|
1400
|
+
txnHandle,
|
|
1401
|
+
transactionId,
|
|
1402
|
+
faultInjector,
|
|
1403
|
+
recoverCommand,
|
|
1404
|
+
} = {}) {
|
|
1405
|
+
const journal = await readJournal(txnHandle, transactionId);
|
|
1406
|
+
if (!PRUNABLE_TERMINAL_STATES.has(journal.state)) {
|
|
1407
|
+
// Non-terminal records keep the FULL recovery authority — never converge.
|
|
1408
|
+
return journal;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
const recover = typeof recoverCommand === 'string' && recoverCommand.length > 0
|
|
1412
|
+
? recoverCommand
|
|
1413
|
+
: `release-skill artifacts recover --transaction ${transactionId}`;
|
|
1414
|
+
let receiptPersisted = false;
|
|
1415
|
+
try {
|
|
1416
|
+
if (faultInjector) await faultInjector('before-terminal-receipt-write');
|
|
1417
|
+
|
|
1418
|
+
const receipt = buildTerminalReceipt(journal);
|
|
1419
|
+
// Fail closed on an invalid or oversized receipt BEFORE the atomic
|
|
1420
|
+
// replace: the full journal must remain untouched in that case.
|
|
1421
|
+
validateTerminalReceiptSchema(receipt);
|
|
1422
|
+
if (receipt.transactionId !== transactionId) {
|
|
1423
|
+
throw new ReleaseError(
|
|
1424
|
+
TRANSACTION_INCOMPLETE,
|
|
1425
|
+
'terminal receipt transactionId does not match its directory authority',
|
|
1426
|
+
);
|
|
1427
|
+
}
|
|
1428
|
+
const serializedLength = Buffer.byteLength(JSON.stringify(receipt, null, 2), 'utf8');
|
|
1429
|
+
if (serializedLength > TERMINAL_RECEIPT_SIZE_CAP) {
|
|
1430
|
+
throw new ReleaseError(
|
|
1431
|
+
TRANSACTION_INCOMPLETE,
|
|
1432
|
+
`terminal receipt exceeds the fixed size cap: ${serializedLength} > ${TERMINAL_RECEIPT_SIZE_CAP}`,
|
|
1433
|
+
{ serializedLength, cap: TERMINAL_RECEIPT_SIZE_CAP },
|
|
1434
|
+
);
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
await writeJsonViaHandle(txnHandle, 'journal.json', receipt);
|
|
1438
|
+
receiptPersisted = true;
|
|
1439
|
+
|
|
1440
|
+
if (faultInjector) await faultInjector('after-terminal-receipt-write');
|
|
1441
|
+
|
|
1442
|
+
await removeTerminalRecoveryResidue(txnHandle, journal);
|
|
1443
|
+
return receipt;
|
|
1444
|
+
} catch (error) {
|
|
1445
|
+
// Hard-crash semantics: the recovery protocol must not rewrite the last
|
|
1446
|
+
// durable state; propagate the injected crash verbatim.
|
|
1447
|
+
if (error?.code === 'INJECTED_CRASH' || error?.name === 'InjectedCrash') {
|
|
1448
|
+
throw error;
|
|
1449
|
+
}
|
|
1450
|
+
const wrapped = new ReleaseError(
|
|
1451
|
+
TRANSACTION_INCOMPLETE,
|
|
1452
|
+
`terminal receipt convergence failed after the transaction reached ${journal.state}: `
|
|
1453
|
+
+ (receiptPersisted
|
|
1454
|
+
? 'the small terminal receipt is durable but residue cleanup did not complete'
|
|
1455
|
+
: 'the complete verifiable journal is retained on disk at its latest durable state')
|
|
1456
|
+
+ `. ${recover}`,
|
|
1457
|
+
{
|
|
1458
|
+
transactionId,
|
|
1459
|
+
terminalReceiptPersisted: receiptPersisted,
|
|
1460
|
+
// COMMITTED means the target WAS applied and verified before
|
|
1461
|
+
// convergence; convergence failure must never lie about that.
|
|
1462
|
+
targetApplied: journal.state === 'COMMITTED',
|
|
1463
|
+
recover,
|
|
1464
|
+
phase: receiptPersisted ? 'terminal-residue-cleanup' : 'terminal-receipt-write',
|
|
1465
|
+
cause: error?.code || null,
|
|
1466
|
+
causeMessage: error?.message || null,
|
|
1467
|
+
},
|
|
1468
|
+
);
|
|
1469
|
+
// Marker for the transaction coordinator: this failure must bypass the
|
|
1470
|
+
// RECOVERY_REQUIRED protocol (the durable state is already terminal and
|
|
1471
|
+
// verifiable; re-running convergence completes the record).
|
|
1472
|
+
wrapped.terminalReceiptConvergenceFailed = true;
|
|
1473
|
+
wrapped.transactionId = transactionId;
|
|
1474
|
+
throw wrapped;
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// ---------------------------------------------------------------------------
|
|
1479
|
+
// Retention (terminal-record pruning)
|
|
1480
|
+
// ---------------------------------------------------------------------------
|
|
1481
|
+
|
|
1482
|
+
/**
|
|
1483
|
+
* Prune the oldest TERMINAL transaction records beyond a retention cap.
|
|
1484
|
+
*
|
|
1485
|
+
* Maintenance path — deliberately tolerant and best-effort. Transaction
|
|
1486
|
+
* journals are written exclusively through the safe-fs DirectoryHandle, but
|
|
1487
|
+
* that handle exposes neither directory enumeration nor recursive removal,
|
|
1488
|
+
* which retention needs. This helper therefore uses best-effort `node:fs`
|
|
1489
|
+
* operations scoped strictly to the current process's transactions root, and
|
|
1490
|
+
* it NEVER throws: a retention failure must not abort or roll back a
|
|
1491
|
+
* transaction (see the module docstring for the documented exception).
|
|
1492
|
+
*
|
|
1493
|
+
* Safety rules (all enforced below):
|
|
1494
|
+
* - Only directory entries named `txn-*` are ever considered.
|
|
1495
|
+
* - A record is prunable only if its `journal.json` parses AND its `state`
|
|
1496
|
+
* is a member of PRUNABLE_TERMINAL_STATES (COMMITTED / ROLLED_BACK).
|
|
1497
|
+
* Both durable terminal shapes parse here: the full journal and the
|
|
1498
|
+
* converged terminal receipt — both keep `state` and `createdAt`
|
|
1499
|
+
* (receipts additionally carry `terminalReceiptVersion`, which this
|
|
1500
|
+
* tolerant scan ignores).
|
|
1501
|
+
* - Unreadable, corrupt, or journal-less records are treated as
|
|
1502
|
+
* NON-terminal and always kept, so recovery evidence is never destroyed.
|
|
1503
|
+
* - Non-terminal states are never counted against the cap and never pruned.
|
|
1504
|
+
* - Removal is oldest-first by journal `createdAt` (directory mtime
|
|
1505
|
+
* fallback; unknown age sorts last so it is pruned last).
|
|
1506
|
+
* - Fast path: if the total `txn-*` record count is within the cap, the
|
|
1507
|
+
* helper returns after a single directory read without opening any
|
|
1508
|
+
* journal.json (which can be MB-sized), since nothing can need pruning.
|
|
1509
|
+
*
|
|
1510
|
+
* @param {string} transactionsRoot — absolute path to the transactions dir.
|
|
1511
|
+
* @param {object} [options]
|
|
1512
|
+
* @param {number} [options.retentionMax=DEFAULT_TRANSACTION_RETENTION_MAX]
|
|
1513
|
+
* Non-negative integer cap on retained terminal records.
|
|
1514
|
+
* @returns {Promise<{considered:number, terminal:number, pruned:string[], errors:string[]}>}
|
|
1515
|
+
* Best-effort summary; `errors` collects per-record failures (never thrown).
|
|
1516
|
+
*/
|
|
1517
|
+
export async function pruneTerminalTransactionRecords(transactionsRoot, {
|
|
1518
|
+
retentionMax = DEFAULT_TRANSACTION_RETENTION_MAX,
|
|
1519
|
+
} = {}) {
|
|
1520
|
+
const summary = { considered: 0, terminal: 0, pruned: [], errors: [] };
|
|
1521
|
+
try {
|
|
1522
|
+
if (typeof transactionsRoot !== 'string' || transactionsRoot.length === 0) return summary;
|
|
1523
|
+
if (!Number.isInteger(retentionMax) || retentionMax < 0) return summary;
|
|
1524
|
+
|
|
1525
|
+
let entries;
|
|
1526
|
+
try {
|
|
1527
|
+
entries = await readdir(transactionsRoot, { withFileTypes: true });
|
|
1528
|
+
} catch (scanErr) {
|
|
1529
|
+
// Missing or unreadable transactions root => nothing to prune.
|
|
1530
|
+
summary.errors.push(`scan: ${scanErr?.code || scanErr?.message || 'readdir-failed'}`);
|
|
1531
|
+
return summary;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// Consider only transaction-record directories.
|
|
1535
|
+
const txnDirs = entries.filter((entry) => entry.isDirectory() && entry.name.startsWith('txn-'));
|
|
1536
|
+
summary.considered = txnDirs.length;
|
|
1537
|
+
|
|
1538
|
+
// Fast path: terminal records are a subset of all records, so if the total
|
|
1539
|
+
// record count is already within the cap, nothing can need pruning. Skip
|
|
1540
|
+
// reading any journal.json (non-terminal recovery records can still be
|
|
1541
|
+
// MB-sized; converged terminal records are bounded receipts) in the common
|
|
1542
|
+
// below-cap case; only pay the per-record read cost once accumulation
|
|
1543
|
+
// exceeds the cap.
|
|
1544
|
+
if (txnDirs.length <= retentionMax) return summary;
|
|
1545
|
+
|
|
1546
|
+
const terminal = [];
|
|
1547
|
+
for (const entry of txnDirs) {
|
|
1548
|
+
const recordDir = join(transactionsRoot, entry.name);
|
|
1549
|
+
|
|
1550
|
+
let state = null;
|
|
1551
|
+
let createdAt = null;
|
|
1552
|
+
try {
|
|
1553
|
+
const raw = await readFile(join(recordDir, 'journal.json'), 'utf8');
|
|
1554
|
+
const journal = JSON.parse(raw);
|
|
1555
|
+
if (journal && typeof journal === 'object') {
|
|
1556
|
+
state = typeof journal.state === 'string' ? journal.state : null;
|
|
1557
|
+
createdAt = typeof journal.createdAt === 'string' ? journal.createdAt : null;
|
|
1558
|
+
}
|
|
1559
|
+
} catch {
|
|
1560
|
+
// Unreadable/corrupt journal => keep (never prune ambiguous records).
|
|
1561
|
+
state = null;
|
|
1562
|
+
}
|
|
1563
|
+
if (!PRUNABLE_TERMINAL_STATES.has(state)) continue;
|
|
1564
|
+
|
|
1565
|
+
let sortTime = Date.parse(createdAt);
|
|
1566
|
+
if (!Number.isFinite(sortTime)) {
|
|
1567
|
+
try {
|
|
1568
|
+
const dirStat = await stat(recordDir);
|
|
1569
|
+
sortTime = dirStat.mtimeMs;
|
|
1570
|
+
} catch {
|
|
1571
|
+
sortTime = Number.MAX_SAFE_INTEGER; // unknown age => prune last
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
summary.terminal += 1;
|
|
1575
|
+
terminal.push({ name: entry.name, dir: recordDir, sortTime });
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
if (terminal.length <= retentionMax) return summary;
|
|
1579
|
+
|
|
1580
|
+
terminal.sort((a, b) => (a.sortTime - b.sortTime) || (a.name < b.name ? -1 : 1));
|
|
1581
|
+
const excess = terminal.length - retentionMax;
|
|
1582
|
+
for (let i = 0; i < excess; i += 1) {
|
|
1583
|
+
const victim = terminal[i];
|
|
1584
|
+
try {
|
|
1585
|
+
await rm(victim.dir, { recursive: true, force: true, maxRetries: 0 });
|
|
1586
|
+
summary.pruned.push(victim.name);
|
|
1587
|
+
} catch (rmErr) {
|
|
1588
|
+
summary.errors.push(`${victim.name}: ${rmErr?.code || rmErr?.message || 'rm-failed'}`);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
} catch (err) {
|
|
1592
|
+
summary.errors.push(`retention: ${err?.code || err?.message || 'unexpected'}`);
|
|
1593
|
+
}
|
|
1594
|
+
return summary;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// ---------------------------------------------------------------------------
|
|
1598
|
+
// Journal creation
|
|
1599
|
+
// ---------------------------------------------------------------------------
|
|
1600
|
+
|
|
1601
|
+
/**
|
|
1602
|
+
* Create a new transaction journal through the safe-fs backend.
|
|
1603
|
+
*
|
|
1604
|
+
* Creates `.release-skill/transactions/<txnId>/journal.json` with initial
|
|
1605
|
+
* PREPARED state, canonical plan, old/new manifest.
|
|
1606
|
+
*
|
|
1607
|
+
* @param {object} options
|
|
1608
|
+
* @param {object} options.backend — safe-fs backend.
|
|
1609
|
+
* @param {object} options.rootHandle — open safe-fs handle for the repo root.
|
|
1610
|
+
* @param {string} [options.root] — repository root path. When provided, the
|
|
1611
|
+
* retention policy prunes the oldest terminal records beyond the cap before
|
|
1612
|
+
* the new record is created. Omit to skip retention (rootHandle-only path).
|
|
1613
|
+
* @param {string} options.transactionId — unique transaction ID.
|
|
1614
|
+
* @param {string} options.planDigest — canonical plan digest.
|
|
1615
|
+
* @param {object} options.canonicalPlan — decoded plan (with Buffer bytes).
|
|
1616
|
+
* @param {object[]} options.oldManifest — snapshot of old entries with
|
|
1617
|
+
* backup bytes (for absent entries, `absent: true`).
|
|
1618
|
+
* @param {object[]} options.newManifest — snapshot of new entries.
|
|
1619
|
+
* @param {number} [options.retentionMax] — override the retention cap
|
|
1620
|
+
* (defaults to DEFAULT_TRANSACTION_RETENTION_MAX).
|
|
1621
|
+
* @returns {Promise<{ journal: object, txnHandle: object }>}
|
|
1622
|
+
*/
|
|
1623
|
+
export async function createTransactionJournal({
|
|
1624
|
+
rootHandle,
|
|
1625
|
+
root,
|
|
1626
|
+
transactionId,
|
|
1627
|
+
planDigest,
|
|
1628
|
+
canonicalPlan,
|
|
1629
|
+
oldManifest,
|
|
1630
|
+
newManifest,
|
|
1631
|
+
retentionMax,
|
|
1632
|
+
} = {}) {
|
|
1633
|
+
if (typeof transactionId !== 'string'
|
|
1634
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(transactionId)) {
|
|
1635
|
+
throw new ReleaseError(PATH_UNSAFE, 'transactionId is not a safe path segment');
|
|
1636
|
+
}
|
|
1637
|
+
if (typeof planDigest !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(planDigest)) {
|
|
654
1638
|
throw new ReleaseError(TRANSACTION_INCOMPLETE, 'planDigest is invalid');
|
|
655
1639
|
}
|
|
656
1640
|
const openedHandles = [];
|
|
@@ -663,6 +1647,17 @@ export async function createTransactionJournal({
|
|
|
663
1647
|
openedHandles,
|
|
664
1648
|
);
|
|
665
1649
|
|
|
1650
|
+
// Retention: prune the oldest terminal records beyond the cap before creating
|
|
1651
|
+
// the new record. Best-effort — never blocks journal creation (see
|
|
1652
|
+
// pruneTerminalTransactionRecords). Operates only on this root's
|
|
1653
|
+
// `.release-skill/transactions`; skipped when `root` is not provided.
|
|
1654
|
+
if (typeof root === 'string' && root.length > 0) {
|
|
1655
|
+
await pruneTerminalTransactionRecords(
|
|
1656
|
+
join(root, '.release-skill', 'transactions'),
|
|
1657
|
+
{ retentionMax },
|
|
1658
|
+
);
|
|
1659
|
+
}
|
|
1660
|
+
|
|
666
1661
|
// Create the transaction directory
|
|
667
1662
|
try {
|
|
668
1663
|
await txnParent.mkdir(transactionId, 0o700);
|
|
@@ -725,9 +1720,15 @@ export async function createTransactionJournal({
|
|
|
725
1720
|
/**
|
|
726
1721
|
* Read and validate the journal from a transaction handle.
|
|
727
1722
|
*
|
|
1723
|
+
* Accepts both durable shapes: the full journal (recovery authority for
|
|
1724
|
+
* non-terminal states, and the pre-convergence record of a terminal state
|
|
1725
|
+
* left behind by a crash/IO failure at 'before-terminal-receipt-write') and
|
|
1726
|
+
* the explicitly versioned terminal receipt for converged COMMITTED /
|
|
1727
|
+
* ROLLED_BACK records. Terminal records are returned as validated receipts.
|
|
1728
|
+
*
|
|
728
1729
|
* @param {object} txnHandle — DirectoryHandle for the transaction directory.
|
|
729
1730
|
* @param {string} transactionId — for error context.
|
|
730
|
-
* @returns {Promise<object>} Validated journal data.
|
|
1731
|
+
* @returns {Promise<object>} Validated journal data (full journal or receipt).
|
|
731
1732
|
* @throws {ReleaseError} TRANSACTION_INCOMPLETE if missing or corrupt.
|
|
732
1733
|
*/
|
|
733
1734
|
export async function readJournal(txnHandle, transactionId) {
|
|
@@ -757,6 +1758,14 @@ export async function readJournal(txnHandle, transactionId) {
|
|
|
757
1758
|
/**
|
|
758
1759
|
* Write a journal state transition through the transaction handle.
|
|
759
1760
|
*
|
|
1761
|
+
* When the transition lands a PRUNABLE_TERMINAL state (COMMITTED /
|
|
1762
|
+
* ROLLED_BACK) and `convergeTerminal` is not false, the record is converged
|
|
1763
|
+
* to the explicitly versioned terminal receipt after the transition journal
|
|
1764
|
+
* is durable (see convergeTerminalRecord). Callers that run their own
|
|
1765
|
+
* convergence phase with their own fault-point ordering (the
|
|
1766
|
+
* applyWriteSetUnderLock 'after-committed' durable point) pass
|
|
1767
|
+
* `convergeTerminal: false`.
|
|
1768
|
+
*
|
|
760
1769
|
* @param {object} options
|
|
761
1770
|
* @param {object} options.txnHandle — DirectoryHandle.
|
|
762
1771
|
* @param {string} options.transactionId — for error context.
|
|
@@ -764,7 +1773,13 @@ export async function readJournal(txnHandle, transactionId) {
|
|
|
764
1773
|
* check for initial write).
|
|
765
1774
|
* @param {string} options.to — target state.
|
|
766
1775
|
* @param {number} [options.entryIndex] — entry index for write-ahead.
|
|
767
|
-
* @
|
|
1776
|
+
* @param {Function} [options.faultInjector] — fault injection forwarded to
|
|
1777
|
+
* terminal convergence ('before-terminal-receipt-write' /
|
|
1778
|
+
* 'after-terminal-receipt-write').
|
|
1779
|
+
* @param {boolean} [options.convergeTerminal=true] — set false to defer
|
|
1780
|
+
* terminal convergence to the caller.
|
|
1781
|
+
* @returns {Promise<object>} Updated journal — the validated terminal
|
|
1782
|
+
* receipt when the transition landed a terminal state and convergence ran.
|
|
768
1783
|
* @throws {ReleaseError} INVALID_STATE_TRANSITION on invalid transition.
|
|
769
1784
|
*/
|
|
770
1785
|
export async function writeJournalTransition({
|
|
@@ -773,6 +1788,8 @@ export async function writeJournalTransition({
|
|
|
773
1788
|
from,
|
|
774
1789
|
to,
|
|
775
1790
|
entryIndex,
|
|
1791
|
+
faultInjector,
|
|
1792
|
+
convergeTerminal = true,
|
|
776
1793
|
} = {}) {
|
|
777
1794
|
if (typeof from !== 'string' || !VALID_STATES.has(from)) {
|
|
778
1795
|
throw new ReleaseError(
|
|
@@ -814,6 +1831,10 @@ export async function writeJournalTransition({
|
|
|
814
1831
|
|
|
815
1832
|
await writeJsonViaHandle(txnHandle, 'journal.json', journal);
|
|
816
1833
|
|
|
1834
|
+
if (convergeTerminal && PRUNABLE_TERMINAL_STATES.has(journal.state)) {
|
|
1835
|
+
return await convergeTerminalRecord({ txnHandle, transactionId, faultInjector });
|
|
1836
|
+
}
|
|
1837
|
+
|
|
817
1838
|
return journal;
|
|
818
1839
|
}
|
|
819
1840
|
|